mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-08 16:02:20 +02:00
feat(provider): support multiple ChatGPT subscriptions with usage
This commit is contained in:
+1
-1
@@ -37,7 +37,7 @@ import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js?v=2
|
||||
import tasksModule from './js/tasks.js?v=20260914taskmodel1';
|
||||
import calendarModule from './js/calendar.js?v=20260914emailsource11';
|
||||
import notesModule from './js/notes.js?v=20260911notesselectioncancel1';
|
||||
import adminModule from './js/admin.js?v=20260914toolschemaprofiles1';
|
||||
import adminModule from './js/admin.js?v=20260921chatgptusage1';
|
||||
import settingsModule from './js/settings.js?v=20260912writingstyle3';
|
||||
// Eagerly bind unified minimize/restore behavior across all tool modals.
|
||||
import './js/modalManager.js';
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@
|
||||
real request is discarded and the font fetched a second time. -->
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260914taskbutton1">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260921chatgptusage1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260916autoscroll1">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
|
||||
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
|
||||
|
||||
+179
-47
@@ -6,6 +6,12 @@ import settingsModule from './settings.js?v=20260912writingstyle3';
|
||||
import { providerLogo, providerLogoFromUrl } from './providers.js';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
|
||||
import {
|
||||
accountTitle as chatgptAccountTitle,
|
||||
buildUsageViewModel as buildChatgptUsageViewModel,
|
||||
isChatgptSubscriptionEndpoint,
|
||||
renderUsageCardHtml as renderChatgptUsageCardHtml,
|
||||
} from './chatgptSubscriptionUsage.js';
|
||||
import { getSettings, getTools, invalidateSettings, invalidateTools } from './appConfig.js';
|
||||
|
||||
let initialized = false;
|
||||
@@ -652,6 +658,7 @@ async function loadEndpoints() {
|
||||
const keyLabel = ep.has_key
|
||||
? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)')
|
||||
: '';
|
||||
const isChatgptAccount = isChatgptSubscriptionEndpoint(ep);
|
||||
return `
|
||||
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
|
||||
@@ -661,7 +668,7 @@ async function loadEndpoints() {
|
||||
<span class="adm-check-dot adm-endpoint-select-dot" aria-hidden="true"></span>
|
||||
</label>` : ''}
|
||||
<span class="adm-ep-row-logo" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;opacity:0.9;">${providerLogoFromUrl(ep.base_url) || ''}</span>
|
||||
<span class="admin-user-name">${esc(ep.name)}</span>
|
||||
<span class="admin-user-name">${esc(isChatgptAccount ? chatgptAccountTitle(ep) : ep.name)}</span>
|
||||
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
|
||||
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
|
||||
${statusBadge}
|
||||
@@ -675,6 +682,7 @@ async function loadEndpoints() {
|
||||
${hasModels ? '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
${isChatgptAccount ? `<div class="adm-chatgpt-usage-host" data-adm-chatgpt-usage-host="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}"><div class="adm-chatgpt-usage adm-chatgpt-usage-loading"><div class="adm-chatgpt-usage-status">Loading usage...</div></div></div>` : ''}
|
||||
<div class="admin-ep-detail">${esc(ep.base_url)}${category === 'local' ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>` : ''}${keyLabel}</div>
|
||||
${hasModels ? `<div class="mcp-tools-panel hidden" data-adm-ep-models-panel="${ep.id}"></div>` : ''}
|
||||
</div>`;
|
||||
@@ -715,6 +723,12 @@ async function loadEndpoints() {
|
||||
});
|
||||
return out;
|
||||
};
|
||||
// One usage card per ChatGPT account: each fetch targets that card's own
|
||||
// auth id, so account A's refresh can never repaint account B.
|
||||
queryAll('[data-adm-chatgpt-usage-host]').forEach(host => {
|
||||
host.addEventListener('click', (e) => e.stopPropagation());
|
||||
_loadChatgptUsage(host, host.dataset.admChatgptUsageHost, host.dataset.chatgptEndpointId);
|
||||
});
|
||||
queryAll('[data-adm-toggle-ep]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -983,6 +997,133 @@ async function _saveEpModelState(epId, panel) {
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
// Render the shared "waiting for authorization" panel (code + manual link).
|
||||
// Built for both the Add Models flow and per-account Reconnect. Never opens a
|
||||
// tab automatically; the user clicks the Authorize link.
|
||||
function _renderDeviceAuthWaitPanel(status, providerKey, start, authUrl) {
|
||||
if (!status) return;
|
||||
status.className = '';
|
||||
const authLabel = providerKey === 'copilot' ? 'Authorize on GitHub' : 'Authorize with OpenAI';
|
||||
const waitLabel = providerKey === 'copilot' ? 'Waiting for GitHub authorization...' : 'Waiting for ChatGPT authorization...';
|
||||
status.innerHTML =
|
||||
'<div class="adm-copilot-panel">' +
|
||||
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
|
||||
'<span>' + esc(waitLabel) + '</span></div>' +
|
||||
'<div class="adm-copilot-coderow">' +
|
||||
'<span class="adm-copilot-code-label">Code</span>' +
|
||||
'<code class="adm-copilot-code">' + esc(start.user_code) + '</code>' +
|
||||
'<button type="button" class="admin-btn-sm adm-device-auth-copy">Copy</button>' +
|
||||
'</div>' +
|
||||
'<a class="admin-btn-add adm-copilot-auth" href="' + esc(authUrl || '') + '" target="_blank" rel="noopener">' + esc(authLabel) + ' ↗</a>' +
|
||||
'</div>';
|
||||
const copyBtn = status.querySelector('.adm-device-auth-copy');
|
||||
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||
const code = start.user_code || '';
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
ok = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
if (!ok) {
|
||||
// navigator.clipboard is unavailable in non-secure contexts (HTTP
|
||||
// self-host over a LAN IP), so fall back to execCommand('copy').
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = code;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try { ta.setSelectionRange(0, code.length); } catch (e) {}
|
||||
try { ok = document.execCommand('copy'); } catch (e) {}
|
||||
ta.remove();
|
||||
}
|
||||
copyBtn.textContent = ok ? 'Copied' : 'Failed';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// ── ChatGPT Subscription per-account usage + reconnect ─────────────────────
|
||||
// Each ChatGPT account (ProviderAuthSession) is addressed by its stable
|
||||
// auth id; the endpoint id pins which row the action came from. Usage is
|
||||
// read-only telemetry: a failed read never disables the endpoint.
|
||||
const _chatgptReconnectInflight = new Set();
|
||||
|
||||
async function _loadChatgptUsage(container, authId, epId, { refresh = false } = {}) {
|
||||
if (!container || !authId) return;
|
||||
const url = '/api/chatgpt-subscription/accounts/' + encodeURIComponent(authId) + '/usage' + (refresh ? '?refresh=1' : '');
|
||||
let payload = null;
|
||||
try {
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
if (res.ok) {
|
||||
try { payload = await res.json(); } catch (_) { payload = null; }
|
||||
} else {
|
||||
let reason = 'upstream';
|
||||
if (res.status === 404) reason = 'upstream';
|
||||
payload = { available: false, reason, account: { auth_id: authId } };
|
||||
}
|
||||
} catch (_) {
|
||||
payload = { available: false, reason: 'network', account: { auth_id: authId } };
|
||||
}
|
||||
if (!payload || typeof payload !== 'object') payload = { available: false, reason: 'malformed', account: { auth_id: authId } };
|
||||
const vm = buildChatgptUsageViewModel(payload);
|
||||
vm.authId = authId;
|
||||
container.innerHTML = renderChatgptUsageCardHtml(vm, { esc, endpointId: epId });
|
||||
const refreshBtn = container.querySelector('[data-adm-chatgpt-usage-refresh]');
|
||||
if (refreshBtn) refreshBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
refreshBtn.disabled = true;
|
||||
refreshBtn.textContent = 'Refreshing...';
|
||||
await _loadChatgptUsage(container, refreshBtn.dataset.admChatgptUsageRefresh, refreshBtn.dataset.chatgptEndpointId, { refresh: true });
|
||||
});
|
||||
const reconnectBtn = container.querySelector('[data-adm-chatgpt-reconnect]');
|
||||
if (reconnectBtn) reconnectBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await _reconnectChatgptAccount(container, reconnectBtn.dataset.admChatgptReconnect, reconnectBtn.dataset.chatgptEndpointId);
|
||||
});
|
||||
}
|
||||
|
||||
async function _reconnectChatgptAccount(container, authId, epId) {
|
||||
if (!container || !authId) return;
|
||||
if (_chatgptReconnectInflight.has(authId)) return;
|
||||
_chatgptReconnectInflight.add(authId);
|
||||
const status = document.createElement('div');
|
||||
status.className = 'adm-chatgpt-reconnect-status';
|
||||
const actions = container.querySelector('.adm-chatgpt-usage-actions');
|
||||
if (actions) actions.replaceWith(status); else container.appendChild(status);
|
||||
status.textContent = 'Starting ChatGPT sign-in...';
|
||||
try {
|
||||
// Reconnect targets exactly this auth session + endpoint. The backend
|
||||
// re-checks ownership on start and on every poll.
|
||||
const formData = new FormData();
|
||||
formData.append('reconnect_auth_id', authId);
|
||||
if (epId) formData.append('reconnect_endpoint_id', epId);
|
||||
const result = await runProviderDeviceFlow('chatgpt-subscription', {
|
||||
openWindow: () => {},
|
||||
formData,
|
||||
onStart: ({ start, authUrl }) => _renderDeviceAuthWaitPanel(status, 'chatgpt-subscription', start, authUrl),
|
||||
});
|
||||
if (result.status === 'authorized') {
|
||||
status.className = 'admin-success';
|
||||
status.textContent = 'Reconnected.';
|
||||
await loadEndpoints();
|
||||
return;
|
||||
}
|
||||
status.className = 'admin-error';
|
||||
status.textContent = result.status === 'expired'
|
||||
? 'Authorization expired.'
|
||||
: 'Authorization failed (' + (result.error || 'denied') + ').';
|
||||
} catch (e) {
|
||||
status.className = 'admin-error';
|
||||
status.textContent = formatDeviceFlowError(e);
|
||||
} finally {
|
||||
_chatgptReconnectInflight.delete(authId);
|
||||
// Re-render the card so Refresh/Reconnect buttons come back.
|
||||
setTimeout(() => { _loadChatgptUsage(container, authId, epId); }, 1200);
|
||||
}
|
||||
}
|
||||
|
||||
function initEndpointForm() {
|
||||
const provider = el('adm-epProvider');
|
||||
const urlInput = el('adm-epUrl');
|
||||
@@ -997,6 +1138,8 @@ function initEndpointForm() {
|
||||
const pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null;
|
||||
const DEVICE_AUTH_PROVIDER_VALUES = new Set(Object.keys(PROVIDER_DEVICE_FLOWS));
|
||||
let deviceAuthPolling = false;
|
||||
// True while the URL box is repurposed as the ChatGPT account-label input.
|
||||
let _chatgptLabelMode = false;
|
||||
function _selectedProviderOption() {
|
||||
return provider && provider.selectedOptions ? provider.selectedOptions[0] : null;
|
||||
}
|
||||
@@ -1018,11 +1161,22 @@ function initEndpointForm() {
|
||||
const status = el('adm-deviceAuthStatus');
|
||||
const msg = _endpointMsg('api');
|
||||
if (deviceAuthConfig) {
|
||||
urlInput.value = '';
|
||||
urlInput.placeholder = deviceAuthProvider === 'copilot'
|
||||
? 'GitHub Copilot uses GitHub account sign-in'
|
||||
: 'ChatGPT Subscription uses OpenAI account sign-in';
|
||||
urlInput.readOnly = true;
|
||||
if (deviceAuthProvider === 'chatgpt-subscription') {
|
||||
// The URL box doubles as the optional account label so several
|
||||
// ChatGPT subscriptions stay distinguishable ("ChatGPT · codex00").
|
||||
if (!_chatgptLabelMode) urlInput.value = '';
|
||||
urlInput.placeholder = 'Account label, e.g. codex00 (optional)';
|
||||
urlInput.readOnly = false;
|
||||
urlInput.maxLength = 40;
|
||||
urlInput.setAttribute('aria-label', 'ChatGPT account label (optional)');
|
||||
_chatgptLabelMode = true;
|
||||
} else {
|
||||
_chatgptLabelMode = false;
|
||||
urlInput.value = '';
|
||||
urlInput.placeholder = 'GitHub Copilot uses GitHub account sign-in';
|
||||
urlInput.readOnly = true;
|
||||
urlInput.removeAttribute('maxlength');
|
||||
}
|
||||
if (apiKey) {
|
||||
apiKey.value = '';
|
||||
apiKey.placeholder = 'No API key needed';
|
||||
@@ -1045,8 +1199,12 @@ function initEndpointForm() {
|
||||
msg.className = '';
|
||||
}
|
||||
} else {
|
||||
if (_chatgptLabelMode) urlInput.value = '';
|
||||
_chatgptLabelMode = false;
|
||||
urlInput.placeholder = 'Base URL or pick provider';
|
||||
urlInput.readOnly = false;
|
||||
urlInput.removeAttribute('maxlength');
|
||||
urlInput.setAttribute('aria-label', 'Model endpoint URL');
|
||||
if (apiKey) {
|
||||
apiKey.placeholder = 'API key';
|
||||
apiKey.disabled = false;
|
||||
@@ -1130,6 +1288,8 @@ function initEndpointForm() {
|
||||
_setApiFormForProvider();
|
||||
});
|
||||
urlInput.addEventListener('input', () => {
|
||||
// Typing an account label must not flip the picker back to "Custom URL".
|
||||
if (_isDeviceAuthSelected()) return;
|
||||
if (provider.value && urlInput.value.trim() !== provider.value) {
|
||||
provider.value = '';
|
||||
if (kindSel) kindSel.value = 'api';
|
||||
@@ -1372,58 +1532,30 @@ function initEndpointForm() {
|
||||
status.textContent = `Starting ${config.label} sign-in...`;
|
||||
|
||||
try {
|
||||
// New ChatGPT connections carry the optional account label. Only the
|
||||
// label travels: the backend creates fresh auth/endpoint rows and never
|
||||
// reuses another account's credentials.
|
||||
const formData = new FormData();
|
||||
if (providerKey === 'chatgpt-subscription' && _chatgptLabelMode) {
|
||||
const label = (urlInput.value || '').trim();
|
||||
if (label) formData.append('label', label);
|
||||
}
|
||||
const result = await runProviderDeviceFlow(providerKey, {
|
||||
openWindow: () => {},
|
||||
formData,
|
||||
onStart: ({ start, authUrl }) => {
|
||||
if (triggerEl) triggerEl.textContent = 'Waiting...';
|
||||
status.className = '';
|
||||
const authLabel = providerKey === 'copilot' ? 'Authorize on GitHub' : 'Authorize with OpenAI';
|
||||
const waitLabel = providerKey === 'copilot' ? 'Waiting for GitHub authorization...' : 'Waiting for ChatGPT authorization...';
|
||||
status.innerHTML =
|
||||
'<div class="adm-copilot-panel">' +
|
||||
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
|
||||
'<span>' + esc(waitLabel) + '</span></div>' +
|
||||
'<div class="adm-copilot-coderow">' +
|
||||
'<span class="adm-copilot-code-label">Code</span>' +
|
||||
'<code class="adm-copilot-code">' + esc(start.user_code) + '</code>' +
|
||||
'<button type="button" class="admin-btn-sm adm-device-auth-copy">Copy</button>' +
|
||||
'</div>' +
|
||||
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl || '') + '" target="_blank" rel="noopener">' + esc(authLabel) + ' ↗</a>' +
|
||||
'</div>';
|
||||
const copyBtn = status.querySelector('.adm-device-auth-copy');
|
||||
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||
const code = start.user_code || '';
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
ok = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
if (!ok) {
|
||||
// navigator.clipboard is unavailable in non-secure contexts (HTTP
|
||||
// self-host over a LAN IP), so fall back to execCommand('copy').
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = code;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try { ta.setSelectionRange(0, code.length); } catch (e) {}
|
||||
try { ok = document.execCommand('copy'); } catch (e) {}
|
||||
ta.remove();
|
||||
}
|
||||
copyBtn.textContent = ok ? 'Copied' : 'Failed';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
_renderDeviceAuthWaitPanel(status, providerKey, start, authUrl);
|
||||
},
|
||||
});
|
||||
if (result.status === 'authorized') {
|
||||
const endpoint = result.endpoint || {};
|
||||
const n = ((endpoint && endpoint.models) || []).length;
|
||||
status.className = 'admin-success';
|
||||
status.textContent = 'Connected - ' + n + ' ' + config.label + ' model' + (n !== 1 ? 's' : '') + ' available.';
|
||||
const connectedName = endpoint && endpoint.name ? endpoint.name : config.label;
|
||||
status.textContent = 'Connected ' + connectedName + ' - ' + n + ' model' + (n !== 1 ? 's' : '') + ' available.';
|
||||
if (endpoint && endpoint.id) _recentlyAddedEpId = String(endpoint.id);
|
||||
if (_chatgptLabelMode) urlInput.value = '';
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(endpoint || {});
|
||||
reset();
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ import searchModule from './search.js';
|
||||
import documentModule from './document.js?v=20260916docctx2';
|
||||
import * as emailInbox from './emailInbox.js?v=20260914aireply4';
|
||||
import codeRunnerModule from './codeRunner.js?v=20260831richtexttools91';
|
||||
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260902tuiharness1';
|
||||
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260921chatgptusage1';
|
||||
import createResearchSynapse from './researchSynapse.js?v=20260910roundlabels2';
|
||||
import { createStreamRenderer } from './streamingRenderer.js';
|
||||
import { createTurnRendering, startsContinuationRound } from './turnRendering.js?v=20260910round1stable1';
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// DOM-free view-model + renderer for per-account ChatGPT Subscription usage.
|
||||
//
|
||||
// The backend (/api/chatgpt-subscription/accounts/{auth_id}/usage) returns a
|
||||
// normalized, credential-free payload. This module turns it into something the
|
||||
// Settings "Added Models" card can render, defensively: unknown buckets are
|
||||
// kept, missing windows are tolerated and nothing (reset times, percentages)
|
||||
// is ever invented.
|
||||
|
||||
function _num(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _clampPercent(value) {
|
||||
const n = _num(value);
|
||||
if (n === null) return null;
|
||||
return Math.max(0, Math.min(100, n));
|
||||
}
|
||||
|
||||
function _defaultEsc(text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function formatDuration(totalSeconds) {
|
||||
const secs = _num(totalSeconds);
|
||||
if (secs === null) return '';
|
||||
if (secs <= 0) return 'now';
|
||||
const days = Math.floor(secs / 86400);
|
||||
const hours = Math.floor((secs % 86400) / 3600);
|
||||
const minutes = Math.floor((secs % 3600) / 60);
|
||||
if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
||||
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
export function formatResetIn(resetsAt, nowSeconds) {
|
||||
const at = _num(resetsAt);
|
||||
if (at === null || at <= 0) return '';
|
||||
const now = _num(nowSeconds);
|
||||
if (now === null) return '';
|
||||
const delta = at - now;
|
||||
if (delta <= 0) return 'resets now';
|
||||
return `resets in ${formatDuration(delta)}`;
|
||||
}
|
||||
|
||||
export function formatPercent(value) {
|
||||
const n = _clampPercent(value);
|
||||
if (n === null) return '';
|
||||
const rounded = Math.round(n);
|
||||
return `${rounded}%`;
|
||||
}
|
||||
|
||||
export function planDisplayName(planType) {
|
||||
if (typeof planType !== 'string' || !planType.trim()) return '';
|
||||
const key = planType.trim().toLowerCase();
|
||||
const known = {
|
||||
free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro', prolite: 'Pro Lite', team: 'Team',
|
||||
business: 'Business', enterprise: 'Enterprise', edu: 'Edu', education: 'Education',
|
||||
guest: 'Guest',
|
||||
};
|
||||
if (known[key]) return known[key];
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function _windowViewModel(win, nowSeconds) {
|
||||
if (!win || typeof win !== 'object') return null;
|
||||
const used = _clampPercent(win.used_percent);
|
||||
const remaining = used === null ? _clampPercent(win.remaining_percent) : Math.round((100 - used) * 100) / 100;
|
||||
const windowMinutes = _num(win.window_minutes);
|
||||
const resetsAt = _num(win.resets_at);
|
||||
return {
|
||||
kind: typeof win.kind === 'string' ? win.kind : '',
|
||||
name: typeof win.name === 'string' && win.name ? win.name : 'LIMIT',
|
||||
usedPercent: used,
|
||||
remainingPercent: remaining,
|
||||
usedLabel: used === null ? 'usage unknown' : `${formatPercent(used)} used`,
|
||||
remainingLabel: remaining === null ? '' : `${formatPercent(remaining)} remaining`,
|
||||
windowMinutes,
|
||||
resetsAt,
|
||||
resetLabel: formatResetIn(resetsAt, nowSeconds),
|
||||
exhausted: used !== null && used >= 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a render-ready view model from the backend usage payload.
|
||||
*
|
||||
* `payload` is the JSON body of the usage route. `nowSeconds` is injected so
|
||||
* reset countdowns are deterministic in tests.
|
||||
*/
|
||||
export function buildUsageViewModel(payload, nowSeconds = Math.floor(Date.now() / 1000)) {
|
||||
const account = (payload && typeof payload.account === 'object' && payload.account) || {};
|
||||
const base = {
|
||||
authId: typeof account.auth_id === 'string' ? account.auth_id : '',
|
||||
label: typeof account.label === 'string' ? account.label : '',
|
||||
name: typeof account.name === 'string' ? account.name : '',
|
||||
};
|
||||
if (!payload || typeof payload !== 'object' || payload.available !== true || !payload.usage || typeof payload.usage !== 'object') {
|
||||
const reason = payload && typeof payload.reason === 'string' ? payload.reason : 'unavailable';
|
||||
const messages = {
|
||||
reauth: 'Usage unavailable — account may need reconnecting',
|
||||
rate_limited: 'Usage temporarily unavailable (rate limited)',
|
||||
timeout: 'Usage unavailable (timed out)',
|
||||
network: 'Usage unavailable (network)',
|
||||
malformed: 'Usage unavailable (unexpected response)',
|
||||
upstream: 'Usage unavailable',
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
available: false,
|
||||
reason,
|
||||
reconnectSuggested: !!(payload && payload.reconnect_suggested),
|
||||
message: messages[reason] || 'Usage unavailable',
|
||||
plan: '',
|
||||
limits: [],
|
||||
};
|
||||
}
|
||||
const usage = payload.usage;
|
||||
const limits = [];
|
||||
const rawLimits = Array.isArray(usage.limits) ? usage.limits : [];
|
||||
rawLimits.forEach((limit) => {
|
||||
if (!limit || typeof limit !== 'object') return;
|
||||
const windows = (Array.isArray(limit.windows) ? limit.windows : [])
|
||||
.map((w) => _windowViewModel(w, nowSeconds))
|
||||
.filter(Boolean);
|
||||
const limitId = typeof limit.limit_id === 'string' ? limit.limit_id : '';
|
||||
const limitName = typeof limit.limit_name === 'string' ? limit.limit_name : '';
|
||||
limits.push({
|
||||
limitId,
|
||||
title: limitId === 'codex' ? '' : (limitName || limitId || 'Additional limit'),
|
||||
modelSlug: typeof limit.normal_model_slug === 'string' ? limit.normal_model_slug : '',
|
||||
limitReached: limit.limit_reached === true,
|
||||
windows,
|
||||
});
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
available: true,
|
||||
reason: '',
|
||||
reconnectSuggested: false,
|
||||
message: '',
|
||||
plan: planDisplayName(usage.plan_type),
|
||||
ordinaryUsageAllowed: typeof usage.ordinary_usage_allowed === 'boolean' ? usage.ordinary_usage_allowed : null,
|
||||
rateLimitReachedType: typeof usage.rate_limit_reached_type === 'string' ? usage.rate_limit_reached_type : '',
|
||||
cached: payload.usage.cached === true,
|
||||
fetchedAt: _num(usage.fetched_at),
|
||||
limits,
|
||||
hasWindows: limits.some((l) => l.windows.length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
function _barHtml(win, esc) {
|
||||
const used = win.usedPercent === null ? 0 : win.usedPercent;
|
||||
const tone = win.usedPercent === null ? 'unknown' : (used >= 90 ? 'critical' : (used >= 70 ? 'warn' : 'ok'));
|
||||
return (
|
||||
`<div class="adm-chatgpt-usage-row" data-usage-window="${esc(win.kind || win.name)}">` +
|
||||
`<div class="adm-chatgpt-usage-head">` +
|
||||
`<span class="adm-chatgpt-usage-name">${esc(win.name)}</span>` +
|
||||
`<span class="adm-chatgpt-usage-remaining">${esc(win.remainingLabel || win.usedLabel)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="adm-chatgpt-usage-bar adm-chatgpt-usage-${tone}" role="progressbar" aria-valuemin="0" aria-valuemax="100"` +
|
||||
(win.usedPercent === null ? '' : ` aria-valuenow="${Math.round(used)}"`) +
|
||||
` aria-label="${esc(win.name)} ${esc(win.usedLabel)}">` +
|
||||
`<span class="adm-chatgpt-usage-fill" style="width:${Math.max(0, Math.min(100, used)).toFixed(0)}%"></span>` +
|
||||
`</div>` +
|
||||
`<div class="adm-chatgpt-usage-meta">` +
|
||||
`<span>${esc(win.usedLabel)}</span>` +
|
||||
(win.resetLabel ? `<span>${esc(win.resetLabel)}</span>` : '') +
|
||||
`</div>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the compact usage card body for one account. `esc` is the host
|
||||
* page's HTML escaper (defaults to a local one). Buttons carry data
|
||||
* attributes with the exact auth/endpoint ids so handlers target only this
|
||||
* account.
|
||||
*/
|
||||
export function renderUsageCardHtml(viewModel, options = {}) {
|
||||
const esc = typeof options.esc === 'function' ? options.esc : _defaultEsc;
|
||||
const vm = viewModel || {};
|
||||
const authId = esc(vm.authId || '');
|
||||
const endpointId = esc(options.endpointId || '');
|
||||
const buttons =
|
||||
`<div class="adm-chatgpt-usage-actions">` +
|
||||
`<button type="button" class="admin-btn-sm" data-adm-chatgpt-usage-refresh="${authId}" data-chatgpt-endpoint-id="${endpointId}">Refresh usage</button>` +
|
||||
`<button type="button" class="admin-btn-sm" data-adm-chatgpt-reconnect="${authId}" data-chatgpt-endpoint-id="${endpointId}">Reconnect</button>` +
|
||||
`</div>`;
|
||||
if (!vm.available) {
|
||||
return (
|
||||
`<div class="adm-chatgpt-usage adm-chatgpt-usage-unavailable" data-adm-chatgpt-usage="${authId}">` +
|
||||
`<div class="adm-chatgpt-usage-status">${esc(vm.message || 'Usage unavailable')}</div>` +
|
||||
buttons +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
const plan = vm.plan ? `<span class="admin-badge adm-chatgpt-plan">${esc(vm.plan)}</span>` : '';
|
||||
const limitsHtml = (vm.limits || []).map((limit) => {
|
||||
const title = limit.title
|
||||
? `<div class="adm-chatgpt-usage-limit-title">${esc(limit.title)}${limit.modelSlug ? ` <span class="adm-chatgpt-usage-model">${esc(limit.modelSlug)}</span>` : ''}</div>`
|
||||
: '';
|
||||
return `<div class="adm-chatgpt-usage-limit" data-usage-limit="${esc(limit.limitId)}">${title}${limit.windows.length ? limit.windows.map((w) => _barHtml(w, esc)).join('') : '<div class="adm-chatgpt-usage-status">No rate-limit windows reported</div>'}</div>`;
|
||||
}).join('');
|
||||
const blocked = vm.ordinaryUsageAllowed === false
|
||||
? `<div class="adm-chatgpt-usage-status adm-chatgpt-usage-blocked">Usage currently blocked${vm.rateLimitReachedType ? ` (${esc(vm.rateLimitReachedType.replace(/_/g, ' '))})` : ''}</div>`
|
||||
: '';
|
||||
return (
|
||||
`<div class="adm-chatgpt-usage" data-adm-chatgpt-usage="${authId}">` +
|
||||
(plan ? `<div class="adm-chatgpt-usage-plan">${plan}</div>` : '') +
|
||||
blocked +
|
||||
limitsHtml +
|
||||
buttons +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
/** True when an endpoint row from /api/model-endpoints is a ChatGPT account. */
|
||||
export function isChatgptSubscriptionEndpoint(ep) {
|
||||
return !!(ep && ep.provider === 'chatgpt-subscription' && ep.provider_auth_id);
|
||||
}
|
||||
|
||||
/** Owner-visible account title, e.g. "ChatGPT · codex00". */
|
||||
export function accountTitle(ep) {
|
||||
if (!ep) return '';
|
||||
if (ep.account_label) return `ChatGPT · ${ep.account_label}`;
|
||||
return ep.name || 'ChatGPT Subscription';
|
||||
}
|
||||
|
||||
export default {
|
||||
buildUsageViewModel,
|
||||
renderUsageCardHtml,
|
||||
formatResetIn,
|
||||
formatDuration,
|
||||
formatPercent,
|
||||
planDisplayName,
|
||||
isChatgptSubscriptionEndpoint,
|
||||
accountTitle,
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
// Lightweight popup that surfaces the existing /command registry as users
|
||||
// type. Reads COMMANDS from slashCommands.js — no command logic lives here.
|
||||
|
||||
import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js?v=20260902tuiharness1';
|
||||
import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js?v=20260921chatgptusage1';
|
||||
|
||||
const POPUP_ID = 'slash-autocomplete';
|
||||
const MAX_VISIBLE = 14;
|
||||
|
||||
@@ -5035,16 +5035,22 @@ function _clearSetupCommandInput() {
|
||||
}
|
||||
}
|
||||
|
||||
async function _setupProviderDeviceFlow(providerKey) {
|
||||
async function _setupProviderDeviceFlow(providerKey, options = {}) {
|
||||
_clearSetupGuideMessages();
|
||||
const config = PROVIDER_DEVICE_FLOWS[providerKey];
|
||||
if (!config) {
|
||||
await _setupReply('Provider not recognised.');
|
||||
return;
|
||||
}
|
||||
await _setupReply(`Starting ${config.label} sign-in...`);
|
||||
// `/setup chatgpt-subscription codex00` labels the new account so several
|
||||
// ChatGPT subscriptions stay distinguishable. The label is cosmetic only.
|
||||
const accountLabel = providerKey === 'chatgpt-subscription' ? String(options.label || '').trim().slice(0, 40) : '';
|
||||
const formData = new FormData();
|
||||
if (accountLabel) formData.append('label', accountLabel);
|
||||
await _setupReply(`Starting ${config.label} sign-in${accountLabel ? ` for "${accountLabel}"` : ''}...`);
|
||||
try {
|
||||
const result = await runProviderDeviceFlow(providerKey, {
|
||||
formData,
|
||||
onStart: async ({ start, authUrl }) => {
|
||||
const place = providerKey === 'copilot' ? 'GitHub' : 'OpenAI';
|
||||
const action = providerKey === 'copilot' ? 'approve the request' : 'enter the code';
|
||||
@@ -5067,7 +5073,8 @@ async function _setupProviderDeviceFlow(providerKey) {
|
||||
});
|
||||
if (result.status === 'authorized') {
|
||||
const n = ((result.endpoint && result.endpoint.models) || []).length;
|
||||
await _setupReply(`Connected - ${n} ${config.label} model${n !== 1 ? 's' : ''} available.`);
|
||||
const connectedName = (result.endpoint && result.endpoint.name) || config.label;
|
||||
await _setupReply(`Connected ${connectedName} - ${n} model${n !== 1 ? 's' : ''} available.`);
|
||||
if (modelsModule) modelsModule.refreshModels(true);
|
||||
return;
|
||||
}
|
||||
@@ -5091,7 +5098,7 @@ async function _cmdSetup(args, ctx) {
|
||||
const topicArgs = args.slice(1);
|
||||
const deviceAuthProvider = _setupDeviceAuthProviderFromInput(topic);
|
||||
if (deviceAuthProvider) {
|
||||
await _setupProviderDeviceFlow(deviceAuthProvider);
|
||||
await _setupProviderDeviceFlow(deviceAuthProvider, { label: topicArgs.join(' ') });
|
||||
return true;
|
||||
}
|
||||
const provider = _setupProviderFromInput(topic);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// Mobile is excluded — tours position halos by rect math that doesn't fit
|
||||
// the bottom-sheet layout cleanly.
|
||||
|
||||
import { handleSlashCommand } from './slashCommands.js?v=20260902tuiharness1';
|
||||
import { handleSlashCommand } from './slashCommands.js?v=20260921chatgptusage1';
|
||||
|
||||
// Modal id → slash command to fire (without the leading "/"). Add to this
|
||||
// map when a new feature picks up a `tour-*` command.
|
||||
|
||||
@@ -49548,6 +49548,79 @@ body.theme-frosted .modal {
|
||||
line-height: 1.4;
|
||||
color: color-mix(in srgb, var(--fg) 45%, transparent);
|
||||
}
|
||||
/* ChatGPT Subscription per-account usage card (Settings → Added Models) */
|
||||
.adm-chatgpt-usage-host {
|
||||
margin: 4px 0 6px 22px;
|
||||
}
|
||||
.adm-chatgpt-usage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.adm-chatgpt-usage-plan { display: flex; gap: 6px; align-items: center; }
|
||||
.adm-chatgpt-usage-limit { display: flex; flex-direction: column; gap: 6px; }
|
||||
.adm-chatgpt-usage-limit-title {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: color-mix(in srgb, var(--fg) 55%, transparent);
|
||||
}
|
||||
.adm-chatgpt-usage-model {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.adm-chatgpt-usage-row { display: flex; flex-direction: column; gap: 3px; }
|
||||
.adm-chatgpt-usage-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.adm-chatgpt-usage-name {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: color-mix(in srgb, var(--fg) 70%, transparent);
|
||||
}
|
||||
.adm-chatgpt-usage-remaining { font-weight: 600; }
|
||||
.adm-chatgpt-usage-bar {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--fg) 12%, transparent);
|
||||
}
|
||||
.adm-chatgpt-usage-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
.adm-chatgpt-usage-warn .adm-chatgpt-usage-fill { background: #d99a2b; }
|
||||
.adm-chatgpt-usage-critical .adm-chatgpt-usage-fill { background: #d9534f; }
|
||||
.adm-chatgpt-usage-unknown .adm-chatgpt-usage-fill { background: color-mix(in srgb, var(--fg) 25%, transparent); }
|
||||
.adm-chatgpt-usage-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
color: color-mix(in srgb, var(--fg) 50%, transparent);
|
||||
}
|
||||
.adm-chatgpt-usage-status {
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--fg) 60%, transparent);
|
||||
}
|
||||
.adm-chatgpt-usage-blocked { color: #d9534f; }
|
||||
.adm-chatgpt-usage-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; }
|
||||
.adm-chatgpt-reconnect-status { font-size: 11px; }
|
||||
/* Cookbook serve panel: Launch + ^ split button pair */
|
||||
.hwfit-serve-launch-group {
|
||||
display: inline-flex;
|
||||
|
||||
+5
-4
@@ -7,7 +7,7 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||
const CACHE_NAME = 'odysseus-v649-large-tool-synthesis-scroll';
|
||||
const CACHE_NAME = 'odysseus-v650-chatgpt-multi-account-usage';
|
||||
|
||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||
// gives offline math fallback glyphs instead of proper typesetting.
|
||||
@@ -39,7 +39,7 @@ const KATEX_FONTS = [
|
||||
// exact URL the browser requests, query string included.
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/static/style.css?v=20260914pdfstrip1',
|
||||
'/static/style.css?v=20260921chatgptusage1',
|
||||
'/static/app.js?v=20260916autoscroll1',
|
||||
'/static/js/storage.js',
|
||||
'/static/js/appConfig.js',
|
||||
@@ -75,9 +75,10 @@ const PRECACHE = [
|
||||
'/static/js/theme.js?v=20260911organsrain1',
|
||||
'/static/js/censor.js',
|
||||
'/static/js/settings.js?v=20260912writingstyle3',
|
||||
'/static/js/admin.js?v=20260914toolschemaprofiles1',
|
||||
'/static/js/admin.js?v=20260921chatgptusage1',
|
||||
'/static/js/chatgptSubscriptionUsage.js',
|
||||
'/static/js/init.js?v=20260829chatstyle12',
|
||||
'/static/js/slashCommands.js?v=20260902tuiharness1',
|
||||
'/static/js/slashCommands.js?v=20260921chatgptusage1',
|
||||
'/static/js/research/jobs.js?v=20260910researcherrorpersist1',
|
||||
'/static/js/emailInbox.js?v=20260914aireply4',
|
||||
'/static/js/emailLibrary/utils.js',
|
||||
|
||||
Reference in New Issue
Block a user