// Model Picker — chatbox model selector dropdown // Extracted from sessions.js import { providerLogo } from './providers.js'; import uiModule from './ui.js'; import settingsModule from './settings.js'; import { sortModelObjects } from './modelSort.js'; const API_BASE = window.location.origin; // ── Shared keyboard nav for model pickers ── function _handlePickerKeydown(e, listEl, itemSelector, closeFn) { if (e.key === 'Escape') { closeFn(); return; } if (e.key === 'Enter') { e.preventDefault(); const active = listEl.querySelector(itemSelector + '.kb-active') || listEl.querySelector(itemSelector); if (active) active.click(); return; } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const items = [...listEl.querySelectorAll(itemSelector)].filter(el => el.style.display !== 'none'); if (!items.length) return; const cur = items.findIndex(el => el.classList.contains('kb-active')); items.forEach(el => el.classList.remove('kb-active')); let next; if (e.key === 'ArrowDown') next = cur < items.length - 1 ? cur + 1 : 0; else next = cur > 0 ? cur - 1 : items.length - 1; items[next].classList.add('kb-active'); items[next].scrollIntoView({ block: 'nearest' }); } } // Dependencies injected via initModelPicker() let _deps = null; let _autoSelectingDefault = false; function _modelExists(modelId, url) { if (!modelId || !window.modelsModule || !window.modelsModule.getCachedItems) return false; const items = window.modelsModule.getCachedItems() || []; if (!items.length) return true; const targetUrl = (url || '').replace(/\/+$/, ''); return items.some(item => { if (item.offline) return false; const itemUrl = (item.url || '').replace(/\/+$/, ''); const models = (item.models || []).concat(item.models_extra || []); return models.includes(modelId) && (!targetUrl || itemUrl === targetUrl); }); } /** * Initialize the model picker dropdown. * @param {Object} deps * @param {function} deps.getCurrentSessionId - returns current session ID * @param {function} deps.getSessions - returns sessions array * @param {function} deps.getPendingChat - returns _pendingChat object * @param {function} deps.setPendingChat - sets _pendingChat object * @param {function} deps.createDirectChat - creates a new direct chat session */ export function initModelPicker(deps) { _deps = deps; _initModelPickerDropdown(); } function _initModelPickerDropdown() { const wrap = document.getElementById('model-picker-wrap'); const btn = document.getElementById('model-picker-btn'); const menu = document.getElementById('model-picker-menu'); const search = document.getElementById('model-picker-search'); const listEl = document.getElementById('model-picker-list'); const searchRow = menu ? menu.querySelector('.model-picker-search-row') : null; if (!wrap || !btn || !menu || !search || !listEl) return; function _close() { if (menu.classList.contains('hidden')) return; // Restore scroll button const _scrollBtn = document.getElementById('scroll-bottom-btn'); if (_scrollBtn) _scrollBtn.style.display = ''; menu.classList.add('closing'); menu.addEventListener('animationend', function _onDone() { menu.removeEventListener('animationend', _onDone); menu.classList.remove('closing'); menu.classList.add('hidden'); search.value = ''; }, { once: true }); // Fallback if animationend doesn't fire setTimeout(() => { if (!menu.classList.contains('hidden')) { menu.classList.remove('closing'); menu.classList.add('hidden'); search.value = ''; } }, 200); } function _openPickerShortcut(kind) { _close(); try { if (kind === 'cookbook') { if (window.cookbookModule && typeof window.cookbookModule.open === 'function') { window.cookbookModule.open(); } else { const btn = document.getElementById('tool-cookbook-btn') || document.getElementById('rail-cookbook'); if (btn) btn.click(); else location.hash = '#cookbook'; } } else if (kind === 'settings') { if (settingsModule && typeof settingsModule.open === 'function') settingsModule.open(); } else if (window.adminModule && typeof window.adminModule.open === 'function') { window.adminModule.open('services'); } else if (settingsModule && typeof settingsModule.open === 'function') { settingsModule.open('services'); } } catch (_) {} } // 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. let _localProbe = {}; // {endpoint_id: {alive, latency_ms, error}} let _localProbeFetchedAt = 0; const _LOCAL_PROBE_TTL_MS = 5000; async function _refreshLocalProbe() { const now = Date.now(); if (now - _localProbeFetchedAt < _LOCAL_PROBE_TTL_MS) return; _localProbeFetchedAt = now; try { const r = await fetch('/api/model-endpoints/probe-local', { credentials: 'same-origin' }); if (r.ok) _localProbe = (await r.json()) || {}; } catch (_) { /* leave stale data; picker still works */ } } function _getAllModels() { const items = (window.modelsModule && window.modelsModule.getCachedItems) ? window.modelsModule.getCachedItems() : []; const result = []; const seen = new Set(); items.forEach(item => { if (item.offline) return; const allModels = (item.models || []).concat(item.models_extra || []); const allDisplay = (item.models_display || []).concat(item.models_extra_display || []); // Mark local endpoints whose live probe failed. const probeResult = item.endpoint_id ? _localProbe[item.endpoint_id] : null; const isLocalDead = !!(probeResult && probeResult.alive === false); allModels.forEach((mid, i) => { // Deduplicate by model ID — prefer DB endpoints over env-discovered if (seen.has(mid)) return; seen.add(mid); result.push({ mid, display: (allDisplay[i] || mid).split('/').pop(), url: item.url, endpointId: item.endpoint_id, epName: item.endpoint_name || '', stale: isLocalDead, staleReason: isLocalDead ? (probeResult.error || 'not responding') : '', }); }); }); return sortModelObjects(result); } function _populate(filter) { listEl.innerHTML = ''; const all = _getAllModels(); const q = (filter || '').toLowerCase(); const hasAnyModel = all.length > 0; listEl.classList.toggle('is-empty', !hasAnyModel); menu.classList.toggle('no-models', !hasAnyModel); if (search) { search.placeholder = hasAnyModel ? 'Search models…' : 'No models connected'; } if (searchRow) { searchRow.classList.toggle('searching', !!filter); } // Load favorites const favs = (function() { try { return JSON.parse(localStorage.getItem('odysseus-model-favorites') || '[]'); } catch { return []; } })(); // Partition: favorites first, then rest const favModels = []; const restModels = []; all.forEach(m => { if (q && !m.mid.toLowerCase().includes(q) && !m.display.toLowerCase().includes(q)) return; if (favs.includes(m.mid)) favModels.push(m); else restModels.push(m); }); sortModelObjects(favModels).forEach(function(m, i) { favModels[i] = m; }); sortModelObjects(restModels).forEach(function(m, i) { restModels[i] = m; }); function _addSection(label) { const el = document.createElement('div'); el.className = 'mp-section-label'; el.textContent = label; listEl.appendChild(el); } function _addRow(m) { const row = document.createElement('div'); row.className = 'model-switch-item'; if (m.stale) { row.classList.add('model-switch-stale'); row.style.opacity = '0.45'; row.title = `Local server appears offline: ${m.staleReason}. Click to try anyway, or relaunch in Cookbook.`; } const _mlogo = providerLogo(m.mid); if (_mlogo) { const logoSpan = document.createElement('span'); logoSpan.className = 'provider-logo'; logoSpan.style.opacity = '0.6'; logoSpan.innerHTML = _mlogo; row.appendChild(logoSpan); } const nameSpan = document.createElement('span'); nameSpan.textContent = m.display; row.appendChild(nameSpan); if (m.stale) { const badge = document.createElement('span'); badge.className = 'model-switch-stale-badge'; badge.textContent = 'offline'; badge.style.cssText = 'font-size:10px;opacity:0.7;padding:1px 6px;border:1px solid var(--border);border-radius:8px;margin-left:6px;'; row.appendChild(badge); } 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 : ''; epSpan.textContent = _epDisplay; row.appendChild(epSpan); row.addEventListener('click', () => _pick(m)); listEl.appendChild(row); } if (favModels.length > 0) { _addSection('Favorites'); favModels.forEach(_addRow); } if (restModels.length > 0) { if (favModels.length > 0) _addSection('All models'); restModels.forEach(_addRow); } if (listEl.children.length === 0) { const empty = document.createElement('div'); empty.className = 'model-switch-empty'; if (hasAnyModel) { empty.textContent = 'No matching models'; } else { return; } listEl.appendChild(empty); } } async function _pick(m) { const currentSessionId = _deps.getCurrentSessionId(); const _pendingChat = _deps.getPendingChat(); // Broadcast immediately so listeners (e.g. the tour) can advance without // waiting for the async session-create/PATCH that follows. try { document.dispatchEvent(new CustomEvent('odysseus:model-picked', { detail: m })); } catch {} // Blur search input before closing to dismiss keyboard on mobile if (document.activeElement) document.activeElement.blur(); _close(); // Refocus main textarea — skip on mobile to avoid keyboard bounce if (window.innerWidth >= 768) { const _ta = document.getElementById('message'); if (_ta) setTimeout(() => _ta.focus(), 50); } if (!currentSessionId && _pendingChat) { // Already have a deferred session — just update the model _deps.setPendingChat({ url: m.url, modelId: m.mid, endpointId: m.endpointId }); // Header stays as session name — model switch only updates picker updateModelPicker(); uiModule.showToast(`Using ${m.display}`); return; } else if (!currentSessionId) { // No session yet — create one with this model await _deps.createDirectChat(m.url, m.mid, m.endpointId); } else { // Existing session with no model — PATCH it const fd = new FormData(); fd.append('model', m.mid); fd.append('endpoint_url', m.url); if (m.endpointId) fd.append('endpoint_id', m.endpointId); try { const res = await fetch(`${API_BASE}/api/session/${currentSessionId}`, { method: 'PATCH', body: fd }); if (!res.ok) { uiModule.showError('Failed to set model'); 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); return; } } // Update picker visibility — model is now set updateModelPicker(); uiModule.showToast(`Using ${m.display}`); } document.addEventListener('odysseus:auto-select-model', async (e) => { const detail = (e && e.detail) || {}; const currentSessionId = _deps.getCurrentSessionId(); const sessions = _deps.getSessions(); const current = sessions.find(x => x.id === currentSessionId); const pending = _deps.getPendingChat(); if ((current && current.model) || (pending && pending.modelId)) return; if (window.modelsModule && window.modelsModule.refreshModels) { try { await window.modelsModule.refreshModels(true); } catch (_) {} } const items = window.modelsModule && window.modelsModule.getCachedItems ? window.modelsModule.getCachedItems() : []; const targetEndpointId = detail.endpointId ? String(detail.endpointId) : ''; const targetModel = detail.modelId || ''; let match = null; for (const item of items) { if (item.offline) continue; if (targetEndpointId && String(item.endpoint_id || '') !== targetEndpointId) continue; const models = (item.models || []).concat(item.models_extra || []); const displays = (item.models_display || []).concat(item.models_extra_display || []); const idx = targetModel ? models.indexOf(targetModel) : (models.length ? 0 : -1); if (idx >= 0) { match = { mid: models[idx], display: (displays[idx] || models[idx]).split('/').pop(), url: item.url || detail.url || '', endpointId: item.endpoint_id || detail.endpointId || '', epName: item.endpoint_name || detail.endpointName || '', }; break; } } if (!match && detail.modelId && detail.url) { match = { mid: detail.modelId, display: String(detail.modelId).split('/').pop(), url: detail.url, endpointId: detail.endpointId || '', epName: detail.endpointName || '', }; } if (match) await _pick(match); }); 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(''); if (window.modelsModule && window.modelsModule.refreshModels) { window.modelsModule.refreshModels(true).then(() => { if (!menu.classList.contains('hidden')) _populate(search.value || ''); updateModelPicker(); }).catch(() => {}); } // Kick off a local-endpoint probe — when it returns, re-render // the list so stale local servers get dimmed. Cloud entries // aren't probed; they stay visible. _refreshLocalProbe().then(() => { if (!menu.classList.contains('hidden')) _populate(search.value || ''); }); if (window.innerWidth >= 768) search.focus(); // Hide scroll button so it doesn't overlap const _scrollBtn = document.getElementById('scroll-bottom-btn'); if (_scrollBtn) _scrollBtn.style.display = 'none'; } else { _close(); } }); search.addEventListener('input', () => _populate(search.value)); search.addEventListener('click', (e) => e.stopPropagation()); search.addEventListener('keydown', (e) => { _handlePickerKeydown(e, listEl, '.model-switch-item', _close); }); const addModelsBtn = document.getElementById('model-picker-add-models-btn'); if (addModelsBtn) { addModelsBtn.addEventListener('click', (e) => { e.stopPropagation(); _openPickerShortcut('models'); }); } document.addEventListener('click', (e) => { if (!menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) { _close(); } }); } /** * Update the model picker label to show the current model. * Always visible — shows current model name or "Select model" if none. * Called after selectSession, createDirectChat, and model switch. */ export function updateModelPicker() { if (!_deps) return; const label = document.getElementById('model-picker-label'); if (!label) return; // Hide model picker when group chat is active const wrap = document.getElementById('model-picker-wrap'); if (window.groupModule && window.groupModule.isActive()) { if (wrap) { wrap.style.display = 'none'; } return; } // Reset inline visibility (may have been hidden by typing in previous session) if (wrap) { wrap.style.display = ''; wrap.style.opacity = ''; wrap.style.pointerEvents = ''; } const currentSessionId = _deps.getCurrentSessionId(); const sessions = _deps.getSessions(); const _pendingChat = _deps.getPendingChat(); const s = sessions.find(x => x.id === currentSessionId); 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 || '')) { _deps.setPendingChat(null); modelId = null; } } // 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 // silently pre-populate the chatbox of the next user that signed in. If // we have no session model and no pending-chat pick, fall through to // the "Select model" placeholder below. // 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) { const items = window.modelsModule.getCachedItems(); const allAvailable = []; items.forEach(item => { if (item.offline) return; (item.models || []).concat(item.models_extra || []).forEach(m => allAvailable.push(m)); }); if (allAvailable.length > 0 && !allAvailable.includes(modelId)) { // Model no longer available — switch to first available const fallback = items.find(item => !item.offline && (item.models || []).length > 0); if (fallback) { modelId = fallback.models[0]; _deps.setPendingChat({ url: fallback.url, modelId, endpointId: fallback.endpoint_id }); } } } if (!modelId && !_autoSelectingDefault && window.modelsModule && window.modelsModule.getCachedItems) { const items = window.modelsModule.getCachedItems(); const first = items.find(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length)); if (first) { const models = (first.models || []).concat(first.models_extra || []); modelId = models[0]; if (!currentSessionId) { _deps.setPendingChat({ url: first.url, modelId, endpointId: first.endpoint_id }); } else { if (s) { s.model = modelId; s.endpoint_url = first.url; } _autoSelectingDefault = true; const fd = new FormData(); fd.append('model', modelId); fd.append('endpoint_url', first.url || ''); if (first.endpoint_id) fd.append('endpoint_id', first.endpoint_id); fetch(`${API_BASE}/api/session/${currentSessionId}`, { method: 'PATCH', body: fd }) .catch(() => {}) .finally(() => { _autoSelectingDefault = false; }); } } } const displayName = modelId ? modelId.split('/').pop() : 'Select model'; const logo = modelId ? providerLogo(modelId) : null; if (logo) { label.innerHTML = ' ' + displayName; } else { label.textContent = displayName; } }