Merge verified Odysseus fixes

This commit is contained in:
pewdiepie-archdaemon
2026-07-23 14:49:08 +00:00
parent 93107c5415
commit cf4e240ad1
246 changed files with 28636 additions and 5493 deletions
+205 -44
View File
@@ -5,6 +5,7 @@ import { providerLogo } from './providers.js';
import uiModule from './ui.js';
import settingsModule from './settings.js';
import { sortModelObjects } from './modelSort.js';
import spinnerModule from './spinner.js';
const API_BASE = window.location.origin;
@@ -51,6 +52,11 @@ function _toggleFavorite(mid) {
return i < 0; // true when now favorited
}
function _pickerModelKey(m) {
if (!m) return '';
return `${m.endpointId || m.url || m.epName || 'model'}::${m.mid || ''}`;
}
// ── Shared keyboard nav for model pickers ──
function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
if (e.key === 'Escape') { closeFn(); return; }
@@ -78,6 +84,7 @@ function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
let _deps = null;
let _autoSelectingDefault = false;
let _defaultChatPickInFlight = false;
let _defaultPendingSeq = 0;
function _modelExists(modelId, url) {
if (!modelId || !window.modelsModule || !window.modelsModule.getCachedItems) return false;
@@ -121,17 +128,29 @@ async function _ensureDefaultPendingChat() {
if (!_deps || _defaultChatPickInFlight) return;
if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return;
const pending = _deps.getPendingChat && _deps.getPendingChat();
if (pending && pending.modelId && pending.source === 'manual') return;
if (pending && pending.modelId) return;
_defaultChatPickInFlight = true;
const seq = ++_defaultPendingSeq;
try {
await _ensureModelCacheForFallback();
let dc = null;
try {
const res = await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' });
if (res.ok) dc = await res.json();
dc = window.__odysseusDefaultChat || null;
} catch (_) {}
if (dc && dc.endpoint_url && dc.model && _modelExists(dc.model, dc.endpoint_url)) {
const pendingUrl = String((pending && pending.url) || '').replace(/\/+$/, '');
if (!dc || !dc.endpoint_url || !dc.model) {
try {
const res = await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' });
if (res.ok) dc = await res.json();
} catch (_) {}
}
if (dc && dc.endpoint_url && dc.model) {
if (seq !== _defaultPendingSeq) return;
const latest = _deps.getPendingChat && _deps.getPendingChat();
if (latest && latest.modelId && latest.source !== 'default' && latest.source !== 'fallback') return;
try {
window.__odysseusDefaultChat = dc;
localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc));
} catch (_) {}
const pendingUrl = String((latest && latest.url) || '').replace(/\/+$/, '');
const defaultUrl = String(dc.endpoint_url || '').replace(/\/+$/, '');
_deps.setPendingChat({
url: dc.endpoint_url,
@@ -139,17 +158,20 @@ async function _ensureDefaultPendingChat() {
endpointId: dc.endpoint_id || '',
source: 'default',
});
try { window.__odysseusDefaultChat = dc; } catch (_) {}
if (!pending || pending.modelId !== dc.model || pendingUrl !== defaultUrl || pending.source !== 'default') {
if (!latest || latest.modelId !== dc.model || pendingUrl !== defaultUrl || latest.source !== 'default') {
updateModelPicker();
}
return;
}
if (pending && pending.modelId) return;
await _ensureModelCacheForFallback();
// No configured default, or the configured default is gone/offline:
// preserve the convenience fallback and keep the picker usable.
const fallback = _firstAvailableModel();
if (fallback) {
if (seq !== _defaultPendingSeq) return;
const latest = _deps.getPendingChat && _deps.getPendingChat();
if (latest && latest.modelId && latest.source !== 'default' && latest.source !== 'fallback') return;
_deps.setPendingChat({ ...fallback, source: 'fallback' });
updateModelPicker();
}
@@ -181,6 +203,8 @@ function _initModelPickerDropdown() {
const searchRow = menu ? menu.querySelector('.model-picker-search-row') : null;
const refreshBtn = document.getElementById('model-picker-refresh-btn');
if (!wrap || !btn || !menu || !search || !listEl) return;
if (wrap.dataset.modelPickerBound === '1') return;
wrap.dataset.modelPickerBound = '1';
function _close() {
if (menu.classList.contains('hidden')) return;
@@ -227,10 +251,13 @@ function _initModelPickerDropdown() {
// Local endpoint health — only probed for LOCAL endpoints, since
// cloud APIs are essentially always up. Cached briefly on the
// server side too (8s TTL). Picker opens trigger a refresh.
// server side too (8s TTL). Picker opens do not probe; the refresh button
// is the explicit network/probe action.
let _localProbe = {}; // {endpoint_id: {alive, latency_ms, error}}
let _localProbeFetchedAt = 0;
const _LOCAL_PROBE_TTL_MS = 5000;
let _pickerLoading = false;
let _pickerLoadSeq = 0;
async function _refreshLocalProbe() {
try {
@@ -263,18 +290,26 @@ function _initModelPickerDropdown() {
// Mark local endpoints whose live probe failed.
const probeResult = item.endpoint_id ? _localProbe[item.endpoint_id] : null;
const isLocalDead = !!(probeResult && probeResult.alive === false);
const isApiEndpoint = item.category && item.category !== 'local';
allModels.forEach((mid, i) => {
// Deduplicate by model ID — prefer ONLINE endpoint entries over
// offline duplicates so the user gets a working endpoint first
// when the same model is exposed by both.
if (seen.has(mid)) return;
seen.add(mid);
// Local/self-hosted servers often expose the same model through several
// stale endpoints, so keep deduping those by model id. Cloud/API
// endpoints are user-selected provider routes; the same model id can be
// intentionally enabled on OpenRouter and OpenAI, so key those by
// endpoint too or the chat picker silently drops one.
const seenKey = isApiEndpoint
? `${item.endpoint_id || item.url || item.endpoint_name || 'api'}::${mid}`
: mid;
if (seen.has(seenKey)) return;
seen.add(seenKey);
result.push({
key: seenKey,
mid,
display: (allDisplay[i] || mid).split('/').pop(),
url: item.url,
endpointId: item.endpoint_id,
epName: item.endpoint_name || '',
category: item.category || '',
providerText: [
item.endpoint_name || '',
item.category || '',
@@ -292,6 +327,48 @@ function _initModelPickerDropdown() {
return sortModelObjects(result);
}
function _hasModelCache() {
try {
return !!(window.modelsModule && window.modelsModule.getCachedItems && (window.modelsModule.getCachedItems() || []).length);
} catch (_) {
return false;
}
}
function _renderLoading(text = 'Loading models…') {
listEl.innerHTML = '';
listEl.classList.remove('is-empty');
listEl.classList.add('is-loading');
menu.classList.remove('no-models');
if (search) search.placeholder = text;
let row = null;
try {
row = spinnerModule.createLoadingRow(text, 15);
} catch (_) {
row = document.createElement('div');
row.className = 'model-switch-empty';
row.textContent = text;
}
row.classList.add('model-picker-loading-row');
listEl.appendChild(row);
}
async function _refreshPickerModels({ force = false, showLoading = false } = {}) {
if (!window.modelsModule || typeof window.modelsModule.refreshModels !== 'function') return;
const seq = ++_pickerLoadSeq;
_pickerLoading = true;
if (showLoading) _renderLoading(force ? 'Refreshing models…' : 'Loading models…');
try {
await window.modelsModule.refreshModels(force);
await _refreshLocalProbe();
} finally {
if (seq === _pickerLoadSeq) {
_pickerLoading = false;
listEl.classList.remove('is-loading');
}
}
}
// ── Provider display names and grouping ──
const _PROVIDER_NAMES = {
'01-ai': 'Yi', 'abacusai': 'Abacus AI', 'adept': 'Adept',
@@ -332,6 +409,16 @@ function _initModelPickerDropdown() {
function _providerDisplayName(slug) {
return _PROVIDER_NAMES[slug] || slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, ' ');
}
function _providerGroupKey(m) {
if (m && m.category && m.category !== 'local' && 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 _providerSlug(mid) {
const slash = mid.indexOf('/');
let slug = slash > 0 ? mid.substring(0, slash) : 'other';
@@ -342,6 +429,7 @@ function _initModelPickerDropdown() {
function _populate(filter) {
listEl.innerHTML = '';
listEl.classList.remove('is-loading');
const all = _getAllModels();
const q = (filter || '').trim().toLowerCase();
const hasAnyModel = all.length > 0;
@@ -359,7 +447,12 @@ function _initModelPickerDropdown() {
// Unique lookup so Recent/Favorites (stored as bare model IDs) can be
// resolved back to full model objects; drops anything no longer offered.
const byId = new Map();
all.forEach(m => { if (!byId.has(m.mid)) byId.set(m.mid, m); });
const byKey = new Map();
all.forEach(m => {
const key = _pickerModelKey(m);
if (key && !byKey.has(key)) byKey.set(key, m);
if (!byId.has(m.mid)) byId.set(m.mid, m);
});
const favs = _loadFavorites();
@@ -470,44 +563,44 @@ function _initModelPickerDropdown() {
// list fits below as "All models" and a separate Recent
// section just duplicates rows.
const shown = new Set();
const favModels = favs.map(id => byId.get(id)).filter(Boolean);
const favModels = favs.map(id => byKey.get(id) || byId.get(id)).filter(Boolean);
if (favModels.length) {
_addSection('Favorites');
favModels.forEach(m => { shown.add(m.mid); _addRow(m); });
favModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); });
}
// Recent: only render when the catalog is big enough that surfacing
// a recency shortlist is actually useful, AND only models that
// aren't already in Favorites (dedupe).
if (all.length > BROWSE_ALL_LIMIT) {
const recentModels = _loadRecent()
.map(id => byId.get(id))
.map(id => byKey.get(id) || byId.get(id))
.filter(Boolean)
.filter(m => !shown.has(m.mid))
.filter(m => !shown.has(_pickerModelKey(m)))
.slice(0, RECENT_MAX);
if (recentModels.length) {
_addSection('Recent');
recentModels.forEach(m => { shown.add(m.mid); _addRow(m); });
recentModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); });
}
}
// 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(m.mid));
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(m.mid));
const rest = all.filter(m => !shown.has(_pickerModelKey(m)));
const groups = new Map();
rest.forEach(m => {
const slug = _providerSlug(m.mid);
const slug = _providerGroupKey(m);
if (!groups.has(slug)) groups.set(slug, []);
groups.get(slug).push(m);
});
const sorted = [...groups.keys()].sort((a, b) =>
_providerDisplayName(a).localeCompare(_providerDisplayName(b)));
_providerGroupName(a).localeCompare(_providerGroupName(b)));
sorted.forEach(provider => {
const models = groups.get(provider);
@@ -516,7 +609,7 @@ function _initModelPickerDropdown() {
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">${_providerDisplayName(provider)}</span>`
+ `<span class="mp-provider-name">${_providerGroupName(provider)}</span>`
+ `<span class="mp-provider-count">${models.length}</span>`;
header.addEventListener('click', (e) => {
e.stopPropagation();
@@ -548,13 +641,32 @@ function _initModelPickerDropdown() {
}
}
async function _pick(m) {
async function _pick(m) {
_defaultPendingSeq++;
try {
window.__odysseusLastPickedRoute = {
model: m.mid || '',
endpoint_url: m.url || '',
endpoint_id: m.endpointId || '',
display: m.display || m.mid || '',
picked_at: Date.now(),
};
} catch (_) {}
let switchDone = null;
const switchPromise = new Promise(resolve => { switchDone = resolve; });
try { window.__odysseusModelSwitchPromise = switchPromise; } catch (_) {}
const finishSwitch = () => {
try {
if (switchDone) switchDone();
if (window.__odysseusModelSwitchPromise === switchPromise) delete window.__odysseusModelSwitchPromise;
} catch (_) {}
};
const currentSessionId = _deps.getCurrentSessionId();
const _pendingChat = _deps.getPendingChat();
// Remember this pick so it surfaces under "Recent" next time the picker
// opens — the whole point of quick-switch.
if (m && m.mid) _pushRecent(m.mid);
if (m && m.mid) _pushRecent(_pickerModelKey(m) || m.mid);
// Broadcast immediately so listeners (e.g. the tour) can advance without
// waiting for the async session-create/PATCH that follows.
@@ -574,12 +686,23 @@ function _initModelPickerDropdown() {
// Header stays as session name — model switch only updates picker
updateModelPicker();
uiModule.showToast(`Using ${m.display}`);
finishSwitch();
return;
} else if (!currentSessionId) {
// No session yet — create one with this model
await _deps.createDirectChat(m.url, m.mid, m.endpointId);
try {
await _deps.createDirectChat(m.url, m.mid, m.endpointId);
} catch (e) {
uiModule.showError('Failed to start chat: ' + e);
finishSwitch();
return;
}
} else {
// Existing session with no model — PATCH it
const sessions = _deps.getSessions();
const s = sessions.find(x => x.id === currentSessionId);
if (s) { s.model = m.mid; s.endpoint_url = m.url; s.endpoint_id = m.endpointId || s.endpoint_id || ''; }
updateModelPicker();
const fd = new FormData();
fd.append('model', m.mid);
fd.append('endpoint_url', m.url);
@@ -588,20 +711,21 @@ function _initModelPickerDropdown() {
const res = await fetch(`${API_BASE}/api/session/${currentSessionId}`, { method: 'PATCH', body: fd });
if (!res.ok) {
uiModule.showError('Failed to set model');
finishSwitch();
return;
}
const sessions = _deps.getSessions();
const s = sessions.find(x => x.id === currentSessionId);
if (s) { s.model = m.mid; s.endpoint_url = m.url; }
// Header stays as session name — model info shown in picker only
} catch (e) {
uiModule.showError('Failed to set model: ' + e);
finishSwitch();
return;
}
}
// Update picker visibility — model is now set
updateModelPicker();
if (window.refreshChatContextHeader) window.refreshChatContextHeader('model-pick');
uiModule.showToast(`Using ${m.display}`);
finishSwitch();
}
document.addEventListener('odysseus:auto-select-model', async (e) => {
@@ -650,14 +774,27 @@ function _initModelPickerDropdown() {
if (match) await _pick(match);
});
btn.addEventListener('pointerdown', (e) => {
e.stopPropagation();
});
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.classList.contains('hidden') || menu.classList.contains('closing')) {
// Force-clear any in-progress close animation
menu.classList.remove('closing', 'hidden');
_populate('');
const hasCache = _hasModelCache();
if (hasCache) {
_populate('');
} else {
_renderLoading('Loading models…');
}
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels().then(() => {
// Force the cheap /api/models cache refresh when the picker opens.
// This does not wait on provider probes; the backend returns cached
// inventory and starts refresh work separately. Without this, models
// enabled in Added Models can be absent from the chatbox picker until
// the tab's frontend cache ages out.
_refreshPickerModels({ force: hasCache, showLoading: !hasCache }).then(() => {
if (!menu.classList.contains('hidden')) _populate(search.value || '');
updateModelPicker();
}).catch(() => {});
@@ -671,7 +808,10 @@ function _initModelPickerDropdown() {
}
});
search.addEventListener('input', () => _populate(search.value));
search.addEventListener('input', () => {
if (_pickerLoading) return;
_populate(search.value);
});
search.addEventListener('click', (e) => e.stopPropagation());
if (refreshBtn) {
refreshBtn.addEventListener('click', async (e) => {
@@ -679,10 +819,7 @@ function _initModelPickerDropdown() {
refreshBtn.disabled = true;
refreshBtn.classList.add('spinning');
try {
if (window.modelsModule && window.modelsModule.refreshModels) {
await window.modelsModule.refreshModels(true);
}
await _refreshLocalProbe();
await _refreshPickerModels({ force: true, showLoading: true });
if (!menu.classList.contains('hidden')) _populate(search.value || '');
updateModelPicker();
} catch (_) {
@@ -704,7 +841,7 @@ function _initModelPickerDropdown() {
});
}
document.addEventListener('click', (e) => {
if (!menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) {
if (!menu.classList.contains('hidden') && !wrap.contains(e.target)) {
_close();
}
});
@@ -738,16 +875,33 @@ export function updateModelPicker() {
let modelId = null;
if (s && s.model) {
modelId = s.model;
if (!_modelExists(modelId, s.endpoint_url || '')) {
modelId = null;
}
} else if (_pendingChat && _pendingChat.modelId) {
modelId = _pendingChat.modelId;
if (!_modelExists(modelId, _pendingChat.url || '')) {
if (_pendingChat.source === 'fallback' && !_modelExists(modelId, _pendingChat.url || '')) {
_deps.setPendingChat(null);
modelId = null;
}
}
if (!modelId && !currentSessionId && !_pendingChat && _deps.setPendingChat) {
let cachedDefault = null;
try {
cachedDefault = window.__odysseusDefaultChat || null;
} catch (_) {}
if (!cachedDefault || !cachedDefault.endpoint_url || !cachedDefault.model) {
try {
cachedDefault = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null');
} catch (_) {}
}
if (cachedDefault && cachedDefault.endpoint_url && cachedDefault.model) {
modelId = cachedDefault.model;
_deps.setPendingChat({
url: cachedDefault.endpoint_url,
modelId,
endpointId: cachedDefault.endpoint_id || '',
source: 'default',
});
}
}
// SECURITY: deliberately NOT auto-injecting `odysseus-model-favorites[0]`
// here. localStorage favorites are per-browser, not per-user, so on a
// shared browser the previous account's first favorited model would
@@ -757,7 +911,14 @@ export function updateModelPicker() {
//
// Check if selected model is still available — fall back ONLY for pending chats with no user selection
// Never override an existing session's model — the user explicitly chose it
if (modelId && !currentSessionId && _pendingChat && window.modelsModule && window.modelsModule.getCachedItems) {
if (
modelId &&
!currentSessionId &&
_pendingChat &&
_pendingChat.source !== 'manual' &&
window.modelsModule &&
window.modelsModule.getCachedItems
) {
const items = window.modelsModule.getCachedItems();
const allAvailable = [];
items.forEach(item => {