mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-21 22:32:20 +02:00
feat(ui): refine subscription model and usage controls
This commit is contained in:
@@ -1195,6 +1195,14 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-input-right">
|
||||
<!-- Reasoning effort dropdown for models supporting reasoning levels -->
|
||||
<div class="reasoning-effort-wrap" id="reasoning-effort-wrap" style="display:none;">
|
||||
<button type="button" class="reasoning-effort-btn" id="reasoning-effort-btn" title="Reasoning effort" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="reasoning-effort-prefix">Effort: </span><span class="reasoning-effort-current" id="reasoning-effort-current">Default</span>
|
||||
<svg class="reasoning-effort-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="reasoning-effort-menu hidden" id="reasoning-effort-menu" role="listbox"></div>
|
||||
</div>
|
||||
<!-- Agent / Chat mode toggle -->
|
||||
<div class="mode-toggle">
|
||||
<button type="button" class="mode-toggle-btn active" id="mode-agent-btn" aria-pressed="true">Agent</button>
|
||||
|
||||
+111
-3
@@ -599,6 +599,50 @@ function _ensureEndpointBulkControls() {
|
||||
_updateEndpointBulkControls();
|
||||
}
|
||||
|
||||
// ChatGPT per-endpoint usage panel expanded state persistence.
|
||||
// Preserves only endpoint/auth identifiers, never tokens, secrets, or labels.
|
||||
const CHATGPT_USAGE_EXPANDED_KEY = 'odysseus-chatgpt-usage-expanded';
|
||||
|
||||
function _loadExpandedUsageEndpoints() {
|
||||
try {
|
||||
const raw = localStorage.getItem(CHATGPT_USAGE_EXPANDED_KEY);
|
||||
if (!raw) return new Set();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return new Set(parsed.filter(x => typeof x === 'string' && x.length > 0));
|
||||
}
|
||||
} catch (_) {}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function _saveExpandedUsageEndpoints(set) {
|
||||
try {
|
||||
const arr = Array.from(set).filter(x => typeof x === 'string' && x.length > 0);
|
||||
localStorage.setItem(CHATGPT_USAGE_EXPANDED_KEY, JSON.stringify(arr));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function _isChatgptUsageExpanded(endpointId, authId) {
|
||||
const set = _loadExpandedUsageEndpoints();
|
||||
if (endpointId != null && set.has(String(endpointId))) return true;
|
||||
if (authId != null && set.has(String(authId))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function _setChatgptUsageExpanded(endpointId, authId, expanded) {
|
||||
const set = _loadExpandedUsageEndpoints();
|
||||
const epKey = endpointId != null ? String(endpointId) : null;
|
||||
const authKey = authId != null ? String(authId) : null;
|
||||
if (expanded) {
|
||||
if (epKey) set.add(epKey);
|
||||
if (authKey) set.add(authKey);
|
||||
} else {
|
||||
if (epKey) set.delete(epKey);
|
||||
if (authKey) set.delete(authKey);
|
||||
}
|
||||
_saveExpandedUsageEndpoints(set);
|
||||
}
|
||||
|
||||
async function loadEndpoints() {
|
||||
const listLocal = el('adm-epList-local');
|
||||
const listApi = el('adm-epList-api');
|
||||
@@ -659,6 +703,7 @@ async function loadEndpoints() {
|
||||
? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)')
|
||||
: '';
|
||||
const isChatgptAccount = isChatgptSubscriptionEndpoint(ep);
|
||||
const isUsageExpanded = isChatgptAccount && _isChatgptUsageExpanded(ep.id, ep.provider_auth_id);
|
||||
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}">
|
||||
@@ -682,7 +727,14 @@ 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>` : ''}
|
||||
${isChatgptAccount ? `
|
||||
<div class="adm-chatgpt-controls">
|
||||
<button type="button" class="admin-btn-sm adm-chatgpt-usage-toggle" data-adm-chatgpt-usage-toggle="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}" aria-expanded="${isUsageExpanded ? 'true' : 'false'}" aria-controls="adm-chatgpt-usage-${esc(ep.id)}">
|
||||
Usage <span class="adm-chatgpt-usage-chevron" aria-hidden="true">${isUsageExpanded ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
<button type="button" class="admin-btn-sm" data-adm-chatgpt-reconnect="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}">Reconnect</button>
|
||||
</div>
|
||||
<div id="adm-chatgpt-usage-${esc(ep.id)}" class="adm-chatgpt-usage-host${isUsageExpanded ? '' : ' hidden'}" data-adm-chatgpt-usage-host="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}"${isUsageExpanded ? '' : ' style="display:none;"'}><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>`;
|
||||
@@ -727,7 +779,61 @@ async function loadEndpoints() {
|
||||
// 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);
|
||||
const epId = host.dataset.chatgptEndpointId;
|
||||
const authId = host.dataset.admChatgptUsageHost;
|
||||
if (_isChatgptUsageExpanded(epId, authId)) {
|
||||
_loadChatgptUsage(host, host.dataset.admChatgptUsageHost, host.dataset.chatgptEndpointId);
|
||||
}
|
||||
});
|
||||
queryAll('[data-adm-chatgpt-usage-toggle]').forEach(toggleBtn => {
|
||||
toggleBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const epId = toggleBtn.dataset.chatgptEndpointId;
|
||||
const authId = toggleBtn.dataset.admChatgptUsageToggle;
|
||||
const row = toggleBtn.closest('.admin-user-row');
|
||||
const host = row ? row.querySelector('[data-adm-chatgpt-usage-host]') : null;
|
||||
if (!host) return;
|
||||
const isHidden = host.classList.contains('hidden') || host.style.display === 'none';
|
||||
if (isHidden) {
|
||||
host.classList.remove('hidden');
|
||||
host.style.display = '';
|
||||
toggleBtn.setAttribute('aria-expanded', 'true');
|
||||
const chevron = toggleBtn.querySelector('.adm-chatgpt-usage-chevron');
|
||||
if (chevron) chevron.textContent = '▴';
|
||||
_setChatgptUsageExpanded(epId, authId, true);
|
||||
if (!host.dataset.usageLoaded) {
|
||||
_loadChatgptUsage(host, authId, epId);
|
||||
}
|
||||
} else {
|
||||
host.classList.add('hidden');
|
||||
host.style.display = 'none';
|
||||
toggleBtn.setAttribute('aria-expanded', 'false');
|
||||
const chevron = toggleBtn.querySelector('.adm-chatgpt-usage-chevron');
|
||||
if (chevron) chevron.textContent = '▾';
|
||||
_setChatgptUsageExpanded(epId, authId, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
queryAll('.adm-chatgpt-controls [data-adm-chatgpt-reconnect]').forEach(reconnectBtn => {
|
||||
reconnectBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const epId = reconnectBtn.dataset.chatgptEndpointId;
|
||||
const authId = reconnectBtn.dataset.admChatgptReconnect;
|
||||
const row = reconnectBtn.closest('.admin-user-row');
|
||||
const host = row ? row.querySelector('[data-adm-chatgpt-usage-host]') : null;
|
||||
if (host) {
|
||||
host.classList.remove('hidden');
|
||||
host.style.display = '';
|
||||
const toggleBtn = row ? row.querySelector('[data-adm-chatgpt-usage-toggle]') : null;
|
||||
if (toggleBtn) {
|
||||
toggleBtn.setAttribute('aria-expanded', 'true');
|
||||
const chevron = toggleBtn.querySelector('.adm-chatgpt-usage-chevron');
|
||||
if (chevron) chevron.textContent = '▴';
|
||||
}
|
||||
_setChatgptUsageExpanded(epId, authId, true);
|
||||
await _reconnectChatgptAccount(host, authId, epId);
|
||||
}
|
||||
});
|
||||
});
|
||||
queryAll('[data-adm-toggle-ep]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
@@ -1069,7 +1175,9 @@ async function _loadChatgptUsage(container, authId, epId, { refresh = false } =
|
||||
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 });
|
||||
if (!container.dataset) container.dataset = {};
|
||||
container.dataset.usageLoaded = '1';
|
||||
container.innerHTML = renderChatgptUsageCardHtml(vm, { esc, endpointId: epId, includeReconnect: false });
|
||||
const refreshBtn = container.querySelector('[data-adm-chatgpt-usage-refresh]');
|
||||
if (refreshBtn) refreshBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -2575,6 +2575,12 @@ import { invalidateSettings } from './appConfig.js';
|
||||
if (presetsModule.getSelectedPreset()) {
|
||||
fd.append('preset_id', presetsModule.getSelectedPreset());
|
||||
}
|
||||
try {
|
||||
const effort = window.__odysseusGetReasoningEffort ? window.__odysseusGetReasoningEffort() : null;
|
||||
if (effort) {
|
||||
fd.append('reasoning_effort', effort);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
|
||||
// Superseded during preflight (uploads, document saves): a newer send
|
||||
|
||||
@@ -192,10 +192,11 @@ export function renderUsageCardHtml(viewModel, options = {}) {
|
||||
const vm = viewModel || {};
|
||||
const authId = esc(vm.authId || '');
|
||||
const endpointId = esc(options.endpointId || '');
|
||||
const includeReconnect = options.includeReconnect !== false;
|
||||
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>` +
|
||||
(includeReconnect ? `<button type="button" class="admin-btn-sm" data-adm-chatgpt-reconnect="${authId}" data-chatgpt-endpoint-id="${endpointId}">Reconnect</button>` : '') +
|
||||
`</div>`;
|
||||
if (!vm.available) {
|
||||
return (
|
||||
|
||||
+276
-81
@@ -202,6 +202,7 @@ function _initModelPickerDropdown() {
|
||||
const listEl = document.getElementById('model-picker-list');
|
||||
const searchRow = menu ? menu.querySelector('.model-picker-search-row') : null;
|
||||
const refreshBtn = document.getElementById('model-picker-refresh-btn');
|
||||
_initReasoningEffort();
|
||||
if (!wrap || !btn || !menu || !search || !listEl) return;
|
||||
if (wrap.dataset.modelPickerBound === '1') return;
|
||||
wrap.dataset.modelPickerBound = '1';
|
||||
@@ -294,8 +295,10 @@ function _initModelPickerDropdown() {
|
||||
// A registered route is a user choice, including local routes using
|
||||
// identical weights with different harness profiles. Never collapse
|
||||
// distinct endpoints just because their model IDs match.
|
||||
const seenKey = _pickerModelKey({ endpointId: item.endpoint_id,
|
||||
url: item.url, epName: item.endpoint_name, mid });
|
||||
const isApiEndpoint = item.category && item.category !== 'local';
|
||||
const seenKey = isApiEndpoint
|
||||
? `${item.endpoint_id || item.url || item.endpoint_name || 'api'}::${mid}`
|
||||
: _pickerModelKey({ endpointId: item.endpoint_id, url: item.url, epName: item.endpoint_name, mid }); // const seenKey = _pickerModelKey(
|
||||
if (seen.has(seenKey)) return;
|
||||
seen.add(seenKey);
|
||||
result.push({
|
||||
@@ -306,6 +309,7 @@ function _initModelPickerDropdown() {
|
||||
endpointId: item.endpoint_id,
|
||||
epName: item.endpoint_name || '',
|
||||
category: item.category || '',
|
||||
modelsMetadata: item.models_metadata || {},
|
||||
providerText: [
|
||||
item.endpoint_name || '',
|
||||
item.category || '',
|
||||
@@ -402,18 +406,28 @@ function _initModelPickerDropdown() {
|
||||
'bytedance-seed': 'bytedance', '~anthropic': 'anthropic',
|
||||
'~google': 'google', '~moonshotai': 'moonshotai', '~openai': 'openai',
|
||||
};
|
||||
function _providerDisplayName(slug) {
|
||||
return _PROVIDER_NAMES[slug] || slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, ' ');
|
||||
}
|
||||
const _endpointGroupNames = new Map();
|
||||
function _providerGroupKey(m) {
|
||||
if (m && m.category && m.category !== 'local' && m.epName) {
|
||||
if (!m) return 'other';
|
||||
// Grouping must be keyed on endpoint_id, falling back to url or epName
|
||||
const gid = m.endpointId || m.url || (m.category && m.category !== 'local' && m.epName ? m.epName : '');
|
||||
if (gid) {
|
||||
const gname = m.epName || (m.endpointId ? m.endpointId : (m.url || 'Other Models'));
|
||||
_endpointGroupNames.set(gid, gname);
|
||||
return `~endpoint:${gid}`;
|
||||
}
|
||||
if (m.category && m.category !== 'local' && m.epName) {
|
||||
_endpointGroupNames.set(m.epName, m.epName);
|
||||
return `~endpoint:${m.epName}`;
|
||||
}
|
||||
return _providerSlug((m && m.mid) || '');
|
||||
}
|
||||
function _providerGroupName(key) {
|
||||
if (String(key || '').startsWith('~endpoint:')) return String(key).slice('~endpoint:'.length);
|
||||
return _providerDisplayName(key);
|
||||
function _providerGroupName(provider) {
|
||||
if (String(provider || '').startsWith('~endpoint:')) {
|
||||
const raw = String(provider).slice('~endpoint:'.length);
|
||||
return _endpointGroupNames.get(raw) || raw;
|
||||
}
|
||||
return _providerDisplayName(provider);
|
||||
}
|
||||
function _providerSlug(mid) {
|
||||
const slash = mid.indexOf('/');
|
||||
@@ -464,7 +478,7 @@ function _initModelPickerDropdown() {
|
||||
empty.textContent = text;
|
||||
listEl.appendChild(empty);
|
||||
}
|
||||
function _addRow(m) {
|
||||
function _addRow(m, { inGroup = false } = {}) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'model-switch-item';
|
||||
if (m.stale) {
|
||||
@@ -494,7 +508,8 @@ function _initModelPickerDropdown() {
|
||||
const epSpan = document.createElement('span');
|
||||
epSpan.className = 'model-switch-ep';
|
||||
// Don't show endpoint name if it matches the model name (local self-hosted)
|
||||
const _epDisplay = m.epName && !m.display.toLowerCase().includes(m.epName.toLowerCase().split('/').pop()) ? m.epName : '';
|
||||
// or if it's already rendered under its endpoint group header
|
||||
const _epDisplay = (!inGroup && m.epName && !m.display.toLowerCase().includes(m.epName.toLowerCase().split('/').pop())) ? m.epName : '';
|
||||
epSpan.textContent = _epDisplay;
|
||||
row.appendChild(epSpan);
|
||||
|
||||
@@ -537,32 +552,83 @@ function _initModelPickerDropdown() {
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
|
||||
// ── Search mode: flat, filtered results across the whole catalog ──
|
||||
function _renderGroup(provider, models, { isSearch = false } = {}) {
|
||||
if (!models || !models.length) return;
|
||||
const isCollapsed = !isSearch && _collapsedProviders.has(provider);
|
||||
const header = document.createElement('div');
|
||||
header.className = 'mp-provider-header';
|
||||
header.innerHTML =
|
||||
`<svg class="mp-provider-chevron${isCollapsed ? ' collapsed' : ''}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`;
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'mp-provider-name';
|
||||
nameSpan.textContent = _providerGroupName(provider);
|
||||
header.appendChild(nameSpan);
|
||||
const countSpan = document.createElement('span');
|
||||
countSpan.className = 'mp-provider-count';
|
||||
countSpan.textContent = `${models.length} model${models.length === 1 ? '' : 's'}`;
|
||||
header.appendChild(countSpan);
|
||||
|
||||
header.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (_collapsedProviders.has(provider)) {
|
||||
_collapsedProviders.delete(provider);
|
||||
_justExpandedProvider = provider;
|
||||
} else {
|
||||
_collapsedProviders.add(provider);
|
||||
_justExpandedProvider = null;
|
||||
}
|
||||
_saveList('odysseus-model-collapsed', [..._collapsedProviders]);
|
||||
const st = listEl.scrollTop;
|
||||
_populate(search ? search.value : '');
|
||||
listEl.scrollTop = st;
|
||||
});
|
||||
listEl.appendChild(header);
|
||||
|
||||
if (!isCollapsed) {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'mp-provider-group' + (_justExpandedProvider === provider ? ' mp-just-expanded' : '');
|
||||
models.forEach(m => {
|
||||
_addRow(m, { inGroup: true });
|
||||
// Move the just-appended row into the group container
|
||||
group.appendChild(listEl.lastElementChild);
|
||||
});
|
||||
listEl.appendChild(group);
|
||||
if (_justExpandedProvider === provider) _justExpandedProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search mode: grouped, filtered results across the whole catalog ──
|
||||
if (q) {
|
||||
const matches = all.filter(m => {
|
||||
const provName = _providerDisplayName(_providerSlug(m.mid)).toLowerCase();
|
||||
return [m.mid, m.display, m.epName, m.providerText, provName]
|
||||
const groupKey = _providerGroupKey(m);
|
||||
const groupName = _providerGroupName(groupKey).toLowerCase();
|
||||
return [m.mid, m.display, m.epName, m.providerText, groupName]
|
||||
.filter(Boolean).join(' ').toLowerCase().includes(q);
|
||||
});
|
||||
if (matches.length === 0) _addEmpty('No matching models');
|
||||
else matches.forEach(_addRow);
|
||||
if (matches.length === 0) {
|
||||
_addEmpty('No matching models');
|
||||
} else {
|
||||
const groups = new Map();
|
||||
matches.forEach(m => {
|
||||
const key = _providerGroupKey(m);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(m);
|
||||
});
|
||||
const sorted = [...groups.keys()].sort((a, b) =>
|
||||
_providerGroupName(a).localeCompare(_providerGroupName(b)));
|
||||
sorted.forEach(provider => {
|
||||
_renderGroup(provider, groups.get(provider), { isSearch: true });
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Browse mode: Favorites (manual) + Recent (auto), with dedupe. ──
|
||||
// Rules:
|
||||
// 1. Never list the same model twice in the dropdown. Favorites
|
||||
// win over Recent (if you favorited it, that's where it
|
||||
// belongs — Recent shouldn't show it again as duplicate).
|
||||
// 2. Small catalogs (≤ BROWSE_ALL_LIMIT total) skip the Recent
|
||||
// section entirely — when there's only ~10 models, the whole
|
||||
// list fits below as "All models" and a separate Recent
|
||||
// section just duplicates rows.
|
||||
const shown = new Set();
|
||||
const favModels = favs.map(id => byKey.get(id) || byId.get(id)).filter(Boolean);
|
||||
if (favModels.length) {
|
||||
_addSection('Favorites');
|
||||
favModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); });
|
||||
favModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m, { inGroup: false }); });
|
||||
}
|
||||
// Recent: only render when the catalog is big enough that surfacing
|
||||
// a recency shortlist is actually useful, AND only models that
|
||||
@@ -575,66 +641,24 @@ function _initModelPickerDropdown() {
|
||||
.slice(0, RECENT_MAX);
|
||||
if (recentModels.length) {
|
||||
_addSection('Recent');
|
||||
recentModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); });
|
||||
recentModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m, { inGroup: false }); });
|
||||
}
|
||||
}
|
||||
|
||||
// Small catalogs: still list everything so users aren't forced to search.
|
||||
if (all.length <= BROWSE_ALL_LIMIT) {
|
||||
const rest = all.filter(m => !shown.has(_pickerModelKey(m)));
|
||||
if (rest.length) {
|
||||
if (shown.size) _addSection('All models');
|
||||
rest.forEach(_addRow);
|
||||
}
|
||||
} else {
|
||||
// Large catalog: show provider groups with collapsible sections.
|
||||
const rest = all.filter(m => !shown.has(_pickerModelKey(m)));
|
||||
const groups = new Map();
|
||||
rest.forEach(m => {
|
||||
const slug = _providerGroupKey(m);
|
||||
if (!groups.has(slug)) groups.set(slug, []);
|
||||
groups.get(slug).push(m);
|
||||
});
|
||||
const sorted = [...groups.keys()].sort((a, b) =>
|
||||
_providerGroupName(a).localeCompare(_providerGroupName(b)));
|
||||
// Provider / endpoint groups with collapsible sections.
|
||||
const rest = all.filter(m => !shown.has(_pickerModelKey(m)));
|
||||
const groups = new Map();
|
||||
rest.forEach(m => {
|
||||
const key = _providerGroupKey(m);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(m);
|
||||
});
|
||||
const sorted = [...groups.keys()].sort((a, b) =>
|
||||
_providerGroupName(a).localeCompare(_providerGroupName(b)));
|
||||
|
||||
sorted.forEach(provider => {
|
||||
const models = groups.get(provider);
|
||||
const isCollapsed = _collapsedProviders.has(provider);
|
||||
const header = document.createElement('div');
|
||||
header.className = 'mp-provider-header';
|
||||
header.innerHTML =
|
||||
`<svg class="mp-provider-chevron${isCollapsed ? ' collapsed' : ''}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`
|
||||
+ `<span class="mp-provider-name">${_providerGroupName(provider)}</span>`
|
||||
+ `<span class="mp-provider-count">${models.length}</span>`;
|
||||
header.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (_collapsedProviders.has(provider)) {
|
||||
_collapsedProviders.delete(provider);
|
||||
_justExpandedProvider = provider;
|
||||
} else {
|
||||
_collapsedProviders.add(provider);
|
||||
_justExpandedProvider = null;
|
||||
}
|
||||
_saveList('odysseus-model-collapsed', [..._collapsedProviders]);
|
||||
const st = listEl.scrollTop;
|
||||
_populate('');
|
||||
listEl.scrollTop = st;
|
||||
});
|
||||
listEl.appendChild(header);
|
||||
if (!isCollapsed) {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'mp-provider-group' + (_justExpandedProvider === provider ? ' mp-just-expanded' : '');
|
||||
models.forEach(m => {
|
||||
_addRow(m);
|
||||
// Move the just-appended row into the group container
|
||||
group.appendChild(listEl.lastElementChild);
|
||||
});
|
||||
listEl.appendChild(group);
|
||||
if (_justExpandedProvider === provider) _justExpandedProvider = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
sorted.forEach(provider => {
|
||||
_renderGroup(provider, groups.get(provider), { isSearch: false });
|
||||
});
|
||||
}
|
||||
|
||||
async function _pick(m) {
|
||||
@@ -964,4 +988,175 @@ export function updateModelPicker() {
|
||||
} else {
|
||||
label.textContent = displayName;
|
||||
}
|
||||
_updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint);
|
||||
}
|
||||
|
||||
// ── Reasoning effort control for models supporting reasoning levels ──
|
||||
let _reasoningEffortBound = false;
|
||||
let _pendingReasoningEffort = null;
|
||||
|
||||
export function getSelectedReasoningEffort() {
|
||||
if (!_deps) return _pendingReasoningEffort;
|
||||
const currentSessionId = _deps.getCurrentSessionId ? _deps.getCurrentSessionId() : null;
|
||||
if (!currentSessionId) return _pendingReasoningEffort;
|
||||
const sessions = _deps.getSessions ? _deps.getSessions() : [];
|
||||
const s = sessions.find(x => x.id === currentSessionId);
|
||||
const mode = s?.thinking_mode || '';
|
||||
if (mode.startsWith('effort:')) {
|
||||
return mode.slice('effort:'.length).trim().toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try { window.__odysseusGetReasoningEffort = getSelectedReasoningEffort; } catch (_) {}
|
||||
|
||||
function _initReasoningEffort() {
|
||||
if (_reasoningEffortBound) return;
|
||||
const wrap = document.getElementById('reasoning-effort-wrap');
|
||||
const btn = document.getElementById('reasoning-effort-btn');
|
||||
const menu = document.getElementById('reasoning-effort-menu');
|
||||
if (!wrap || !btn || !menu) return;
|
||||
_reasoningEffortBound = true;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isHidden = menu.classList.contains('hidden');
|
||||
if (isHidden) {
|
||||
menu.classList.remove('hidden');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
} else {
|
||||
menu.classList.add('hidden');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!wrap.contains(e.target) && !menu.classList.contains('hidden')) {
|
||||
menu.classList.add('hidden');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _findModelMetadata(modelId, selectedEndpoint) {
|
||||
if (selectedEndpoint?.models_metadata?.[modelId]) {
|
||||
return selectedEndpoint.models_metadata[modelId];
|
||||
}
|
||||
const routeItems = window.modelsModule?.getCachedItems?.() || [];
|
||||
for (const ep of routeItems) {
|
||||
if (ep.models_metadata && ep.models_metadata[modelId]) {
|
||||
return ep.models_metadata[modelId];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint) {
|
||||
_initReasoningEffort();
|
||||
const wrap = document.getElementById('reasoning-effort-wrap');
|
||||
const btn = document.getElementById('reasoning-effort-btn');
|
||||
const currentSpan = document.getElementById('reasoning-effort-current');
|
||||
const menu = document.getElementById('reasoning-effort-menu');
|
||||
if (!wrap || !btn || !currentSpan || !menu) return;
|
||||
|
||||
if (!modelId) {
|
||||
wrap.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = _findModelMetadata(modelId, selectedEndpoint);
|
||||
const levels = metadata?.supported_reasoning_levels;
|
||||
if (!Array.isArray(levels) || levels.length === 0) {
|
||||
wrap.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.style.display = 'inline-flex';
|
||||
|
||||
const supportedEffortNames = levels.map(l => (typeof l === 'string' ? l : l.effort).toLowerCase());
|
||||
let activeLevel = 'default';
|
||||
const sessionMode = s?.thinking_mode || '';
|
||||
|
||||
if (s && s.id) {
|
||||
if (sessionMode.startsWith('effort:')) {
|
||||
const parsed = sessionMode.slice('effort:'.length).trim().toLowerCase();
|
||||
if (supportedEffortNames.includes(parsed)) {
|
||||
activeLevel = parsed;
|
||||
} else {
|
||||
activeLevel = 'default';
|
||||
s.thinking_mode = 'off';
|
||||
try {
|
||||
fetch(`${API_BASE}/api/session/${encodeURIComponent(s.id)}/generation-settings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ thinking_mode: 'off', reasoning_effort: null }),
|
||||
}).catch(() => {});
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} else if (_pendingReasoningEffort) {
|
||||
if (supportedEffortNames.includes(_pendingReasoningEffort)) {
|
||||
activeLevel = _pendingReasoningEffort;
|
||||
} else {
|
||||
activeLevel = 'default';
|
||||
_pendingReasoningEffort = null;
|
||||
}
|
||||
}
|
||||
|
||||
currentSpan.textContent = activeLevel === 'default' ? 'Default' : (activeLevel.charAt(0).toUpperCase() + activeLevel.slice(1));
|
||||
btn.title = 'Reasoning effort';
|
||||
|
||||
menu.innerHTML = '';
|
||||
const options = [{ effort: 'default', label: 'Default', desc: `Model default (${metadata.default_reasoning_level || 'standard'})` }];
|
||||
for (const l of levels) {
|
||||
const eff = (typeof l === 'string' ? l : l.effort).toLowerCase();
|
||||
const desc = (typeof l === 'object' && l.description) ? l.description : '';
|
||||
options.push({ effort: eff, label: eff.charAt(0).toUpperCase() + eff.slice(1), desc });
|
||||
}
|
||||
|
||||
for (const opt of options) {
|
||||
const optBtn = document.createElement('button');
|
||||
optBtn.type = 'button';
|
||||
optBtn.className = 'reasoning-effort-option' + (opt.effort === activeLevel ? ' active' : '');
|
||||
optBtn.setAttribute('role', 'option');
|
||||
optBtn.setAttribute('aria-selected', opt.effort === activeLevel ? 'true' : 'false');
|
||||
if (opt.desc) optBtn.title = opt.desc;
|
||||
|
||||
const lbl = document.createElement('span');
|
||||
lbl.textContent = opt.label;
|
||||
optBtn.appendChild(lbl);
|
||||
|
||||
if (opt.effort === activeLevel) {
|
||||
const check = document.createElement('span');
|
||||
check.textContent = '✓';
|
||||
check.style.fontSize = '11px';
|
||||
optBtn.appendChild(check);
|
||||
}
|
||||
|
||||
optBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
menu.classList.add('hidden');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
const newEffort = opt.effort;
|
||||
const thinkingModeVal = newEffort === 'default' ? 'off' : `effort:${newEffort}`;
|
||||
const effortVal = newEffort === 'default' ? null : newEffort;
|
||||
|
||||
if (s && s.id) {
|
||||
s.thinking_mode = thinkingModeVal;
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/session/${encodeURIComponent(s.id)}/generation-settings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ thinking_mode: thinkingModeVal, reasoning_effort: effortVal }),
|
||||
});
|
||||
} catch (_) {}
|
||||
} else {
|
||||
_pendingReasoningEffort = effortVal;
|
||||
}
|
||||
_updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint);
|
||||
});
|
||||
|
||||
menu.appendChild(optBtn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2788,6 +2788,86 @@ body.bg-pattern-ascii-fireflies {
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Reasoning effort pill / dropdown in composer */
|
||||
.reasoning-effort-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.reasoning-effort-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--fg) 4%, transparent);
|
||||
color: var(--fg);
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.reasoning-effort-btn:hover {
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--fg) 20%, transparent);
|
||||
}
|
||||
.reasoning-effort-chevron {
|
||||
opacity: 0.5;
|
||||
transition: transform 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.reasoning-effort-btn[aria-expanded="true"] .reasoning-effort-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.reasoning-effort-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 140px;
|
||||
background: var(--bg-surface, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
padding: 4px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.reasoning-effort-menu.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.reasoning-effort-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.reasoning-effort-option:hover {
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
}
|
||||
.reasoning-effort-option.active {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
@container chatbar (max-width: 480px) {
|
||||
.reasoning-effort-prefix { display: none; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.reasoning-effort-prefix { display: none; }
|
||||
}
|
||||
.input-icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -49549,6 +49629,18 @@ body.theme-frosted .modal {
|
||||
color: color-mix(in srgb, var(--fg) 45%, transparent);
|
||||
}
|
||||
/* ChatGPT Subscription per-account usage card (Settings → Added Models) */
|
||||
.adm-chatgpt-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
.adm-chatgpt-usage-chevron {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.adm-chatgpt-usage-host {
|
||||
margin: 4px 0 6px 22px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user