// compare/selector.js — model selection modal import state from './state.js'; import Storage from '../storage.js'; import { fetchModels, _persistSelections, getExcludedModels } from './models.js'; import { showScoreboard } from './scoreboard.js'; import { EYE_OPEN, EYE_CLOSED, ICON_DICE, ICON_PARALLEL, ICON_SEQUENTIAL, SAVE_ICON, WAVE_FRAMES, CHAT_ICON } from './icons.js'; import { _clearProbeWaves } from './probe.js'; import uiModule from '../ui.js'; import spinnerModule from '../spinner.js'; import themeModule from '../theme.js'; const escapeHtml = uiModule.esc; // Match the Deep Research "Start" button (play icon + "Start", styled by // .research-start-btn) so the two primary actions look identical. const _CMP_PLAY_ICON = ''; const _CMP_START_LABEL = _CMP_PLAY_ICON + ' Start'; /** Slot label: letters (A, B) in parallel, numbers (1, 2) in sequential */ function _slotChar(i) { return state._parallel ? String.fromCharCode(65 + i) : String(i + 1); } /** Sync the Compare toolbar indicator button state. */ function _syncToolbarIndicator(active) { // The old red-accent "Compare active — click to deactivate" chip is no longer // shown — Compare is exited from its own header bar, so the input-bar tool // indicator was redundant. Keep it hidden regardless of state. const indicator = document.getElementById('compare-indicator-btn'); if (indicator) { indicator.style.display = 'none'; indicator.classList.remove('active'); } // Notify app.js to update the plus-dot indicator document.dispatchEvent(new CustomEvent('overflow-state-change')); } /** Disable tool toggles (web, bash, RAG, research) for clean comparison. */ function disableToolToggles() { const ids = ['web-toggle', 'bash-toggle', 'rag-toggle', 'research-toggle']; state._savedToggles = {}; ids.forEach(id => { const chk = document.getElementById(id); if (chk) { state._savedToggles[id] = chk.checked; if (chk.checked) { chk.checked = false; chk.dispatchEvent(new Event('change')); } } }); } /** Restore tool toggles to pre-compare state. */ function restoreToolToggles() { if (!state._savedToggles) return; Object.entries(state._savedToggles).forEach(([id, wasChecked]) => { const chk = document.getElementById(id); if (chk && wasChecked && !chk.checked) { chk.checked = true; chk.dispatchEvent(new Event('change')); } }); state._savedToggles = null; } /** Show model selection modal with dynamic model list + toggles. */ async function showModelSelector() { return new Promise((resolve) => { let models = []; let _modelsLoaded = false; const overlay = document.createElement('div'); overlay.id = 'compare-model-overlay'; overlay.className = 'modal'; const content = document.createElement('div'); content.className = 'modal-content'; content.style.width = 'min(520px, 92vw)'; // ── Header (draggable) ── const header = document.createElement('div'); header.className = 'modal-header'; const title = document.createElement('h4'); title.innerHTML = 'Model Comparison'; // Absorb the free space so the injected minimize (_) and close (✕) cluster // together on the right instead of being spread apart by space-between. title.style.marginRight = 'auto'; header.appendChild(title); // Minimize (_) + Close (✕) grouped in one wrapper so they're always // adjacent on the right (the auto-injected minimize otherwise drifted // away from the close). The minimize carries .minimize-btn so the modal // manager wires it instead of injecting a second one. const headerCtrls = document.createElement('div'); headerCtrls.style.cssText = 'display:flex;align-items:center;gap:6px;flex-shrink:0;'; const headerMinBtn = document.createElement('button'); headerMinBtn.type = 'button'; headerMinBtn.className = 'modal-minimize-btn minimize-btn'; headerMinBtn.title = 'Minimize'; headerMinBtn.innerHTML = ''; headerMinBtn.style.margin = '0'; const headerCloseBtn = document.createElement('button'); headerCloseBtn.className = 'close-btn'; headerCloseBtn.innerHTML = '✖'; headerCloseBtn.style.cssText = 'flex-shrink:0;margin:0;'; headerCloseBtn.addEventListener('click', () => cleanup(false)); headerCtrls.appendChild(headerMinBtn); headerCtrls.appendChild(headerCloseBtn); // Toggle icons container const toggleRow = document.createElement('div'); toggleRow.style.cssText = 'display:flex;gap:4px;align-items:flex-start;margin-left:auto;margin-right:8px;'; function _toggleLabel(text) { return '' + text + ''; } state._blindMode = true; const blindBtn = document.createElement('button'); blindBtn.type = 'button'; blindBtn.className = 'compare-blind-toggle active'; blindBtn.title = 'Blind Mode — hide model names until you vote'; blindBtn.innerHTML = EYE_CLOSED + _toggleLabel('Blind'); blindBtn.addEventListener('click', () => { state._blindMode = !state._blindMode; blindBtn.classList.toggle('active', state._blindMode); blindBtn.innerHTML = (state._blindMode ? EYE_CLOSED : EYE_OPEN) + _toggleLabel('Blind'); // Turning off blind mode reveals shuffled models if (!state._blindMode && _shuffled) { _shuffled = false; diceBtn.classList.remove('active'); } renderModelRows(); // Mobile hides the button labels — surface the new state as a toast. uiModule.showToast('Mode: Blind ' + (state._blindMode ? 'on' : 'off')); _updateModeLabel(); _setModeHint(state._blindMode ? 'Blind mode: model names stay hidden until you vote.' : 'Blind mode off: model names are shown.'); }); toggleRow.appendChild(blindBtn); // Parallel / Sequential toggle — right after blind state._parallel = true; const parallelBtn = document.createElement('button'); parallelBtn.type = 'button'; parallelBtn.className = 'compare-parallel-toggle active'; parallelBtn.title = 'Parallel — run all models at once vs one at a time'; parallelBtn.innerHTML = ICON_PARALLEL + _toggleLabel('Parallel'); parallelBtn.addEventListener('click', () => { state._parallel = !state._parallel; parallelBtn.classList.toggle('active', state._parallel); parallelBtn.innerHTML = (state._parallel ? ICON_PARALLEL : ICON_SEQUENTIAL) + _toggleLabel(state._parallel ? 'Parallel' : 'Sequential'); parallelBtn.title = state._parallel ? 'Switch to one at a time' : 'Run side by side'; renderModelRows(); uiModule.showToast('Mode: ' + (state._parallel ? 'Parallel' : 'Sequential')); _updateModeLabel(); _setModeHint(state._parallel ? 'Parallel: all models answer at once, side by side.' : 'Sequential: models answer one at a time.'); }); toggleRow.appendChild(parallelBtn); // Dice / shuffle button — next to blind toggle const diceBtn = document.createElement('button'); diceBtn.type = 'button'; diceBtn.className = 'compare-dice-toggle'; diceBtn.title = 'Shuffle — randomly pick models for each slot'; diceBtn.innerHTML = ICON_DICE + _toggleLabel('Shuffle'); diceBtn.addEventListener('click', () => { if (!_modelsLoaded) return; // Toggle off if already shuffled if (_shuffled) { _shuffled = false; diceBtn.classList.remove('active'); renderModelRows(); uiModule.showToast('Mode: Shuffle off'); _updateModeLabel(); _setModeHint('Shuffle off: choose the models yourself.'); return; } // Randomly pick models from filtered list for each slot const excluded = getExcludedModels(); const pool = filteredModels().filter(m => !excluded.includes(m.id)).slice(); if (pool.length === 0) return; for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; } for (let i = 0; i < selections.length; i++) { const m = pool[i % pool.length]; selections[i] = { model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name, endpointName: m.endpointName || '' }; } _shuffled = true; // Auto-enable blind mode so picks stay hidden if (!state._blindMode) { state._blindMode = true; blindBtn.classList.add('active'); blindBtn.innerHTML = EYE_CLOSED + _toggleLabel('Blind'); } renderModelRows(); uiModule.showToast(state._blindMode ? 'Mode: Shuffle on · Blind on' : 'Mode: Shuffle on'); _updateModeLabel(); _setModeHint('Shuffle: random models picked for each slot (auto-hidden).'); // Show active state + spin only the dice icon diceBtn.classList.add('active'); const diceSvg = diceBtn.querySelector('svg'); if (diceSvg) { diceSvg.style.transition = 'transform 0.3s ease'; diceSvg.style.transform = 'rotate(360deg)'; setTimeout(() => { diceSvg.style.transition = ''; diceSvg.style.transform = ''; }, 300); } }); toggleRow.appendChild(diceBtn); // (Pre-round "Shuffle models?" reminder removed at the user's request — the // running-state panes still show their own shuffle nudge.) function _remindShuffle() { /* no-op in the selector */ } state._continueChat = false; state._saveOnClose = false; const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'compare-save-toggle'; saveBtn.title = 'Save — keep sessions after closing compare'; saveBtn.innerHTML = SAVE_ICON + _toggleLabel('Save'); saveBtn.addEventListener('click', () => { state._saveOnClose = !state._saveOnClose; saveBtn.classList.toggle('active', state._saveOnClose); uiModule.showToast('Mode: Save ' + (state._saveOnClose ? 'on' : 'off')); _updateModeLabel(); _setModeHint(state._saveOnClose ? 'Save: keep these sessions after you close Compare.' : 'Save off: sessions are discarded when you close Compare.'); }); toggleRow.appendChild(saveBtn); // Reset button const resetBtn = document.createElement('button'); resetBtn.type = 'button'; resetBtn.className = 'compare-reset-toggle'; resetBtn.title = 'Reset — restore all defaults'; resetBtn.innerHTML = '' + _toggleLabel('Reset'); resetBtn.addEventListener('click', () => { state._blindMode = true; blindBtn.classList.add('active'); blindBtn.innerHTML = EYE_CLOSED + _toggleLabel('Blind'); _shuffled = false; diceBtn.classList.remove('active'); state._continueChat = false; state._saveOnClose = false; saveBtn.classList.remove('active'); state._parallel = true; parallelBtn.classList.add('active'); parallelBtn.innerHTML = ICON_PARALLEL + _toggleLabel('Parallel'); selections = [null, null]; renderModelRows(); }); toggleRow.appendChild(resetBtn); header.appendChild(headerCtrls); content.appendChild(header); // ── Body ── const body = document.createElement('div'); body.className = 'modal-body'; body.style.padding = '12px 16px'; const desc = document.createElement('p'); desc.style.cssText = 'color:color-mix(in srgb, var(--fg) 55%, transparent);font-size:0.85em;margin:0 0 12px;'; desc.textContent = 'Select models to compare side-by-side. Send the same prompt to all.'; body.appendChild(desc); // Options row toggleRow.style.cssText = 'display:flex;gap:4px;align-items:flex-start;flex-wrap:wrap;'; const modeWrap = document.createElement('div'); modeWrap.className = 'compare-section'; const modeLabel = document.createElement('div'); modeLabel.className = 'compare-section-label'; // The active modes (+colors) are appended in a span shown only on mobile, // where the toggle text labels are hidden so the icons alone are ambiguous. modeLabel.innerHTML = 'Mode: '; modeWrap.appendChild(modeLabel); modeWrap.appendChild(toggleRow); // Contextual one-liner describing the mode you just toggled. const modeHint = document.createElement('div'); modeHint.className = 'compare-mode-hint'; modeWrap.appendChild(modeHint); function _setModeHint(html) { modeHint.innerHTML = html || ''; } body.appendChild(modeWrap); // Reflect the active modes in the "Mode:" label, each in its icon's color. function _updateModeLabel() { const cur = modeLabel.querySelector('.compare-mode-current'); if (!cur) return; const parts = []; if (state._blindMode) parts.push('Blind'); parts.push(state._parallel ? 'Parallel' : 'Sequential'); if (_shuffled) parts.push('Shuffle'); if (state._saveOnClose) parts.push('Save'); cur.innerHTML = parts.join(', '); } // ── Type tabs (Chat / Agent / Search / Research) ── state._compareMode = 'chat'; const typeWrap = document.createElement('div'); typeWrap.className = 'compare-section'; const typeLabel = document.createElement('div'); typeLabel.className = 'compare-section-label'; // The active type name (+icon) is appended in a span shown only on mobile, // where the tab text labels are hidden so the icons alone are ambiguous. typeLabel.innerHTML = 'Type: '; typeWrap.appendChild(typeLabel); const tabBar = document.createElement('div'); tabBar.className = 'compare-mode-tabs compare-type-tabs'; // Agent — shell prompt `>_` (matches the bash-toggle-btn icon in the composer) const _ICON_AGENT = ''; const _ICON_SEARCH = ''; // Research — magnifying glass with `+` (matches the sidebar Deep Research icon) const _ICON_RESEARCH = ''; const _modes = [ { id: 'chat', label: 'Chat', icon: CHAT_ICON }, { id: 'agent', label: 'Agent', icon: _ICON_AGENT }, { id: 'search', label: 'Search', icon: _ICON_SEARCH }, { id: 'research', label: 'Research', icon: _ICON_RESEARCH }, ]; _modes.forEach(m => { const tab = document.createElement('button'); tab.type = 'button'; tab.className = 'compare-mode-tab' + (m.id === 'chat' ? ' active' : ''); tab.innerHTML = m.icon + '' + m.label + ''; tab.dataset.mode = m.id; tab.addEventListener('click', () => setModeTab(m.id)); tabBar.appendChild(tab); }); // Reflect the active type in the "Type:" label (icon + name) for mobile. function _updateTypeLabel(mode) { const cur = typeLabel.querySelector('.compare-type-current'); const m = _modes.find(x => x.id === mode); if (cur && m) cur.innerHTML = m.icon + '' + m.label + ''; } _updateTypeLabel('chat'); typeWrap.appendChild(tabBar); body.appendChild(typeWrap); // Per-tab selection memory const _tabSelections = { chat: null, agent: null, search: null, research: null }; function setModeTab(mode) { if (!_modelsLoaded) return; // Save current tab's selections before switching _tabSelections[state._compareMode] = selections.map(s => s ? { ...s } : null); state._compareMode = mode; tabBar.querySelectorAll('.compare-mode-tab').forEach(t => t.classList.remove('active')); const activeTab = tabBar.querySelector(`[data-mode="${mode}"]`); if (activeTab) activeTab.classList.add('active'); _updateTypeLabel(mode); _shuffled = false; diceBtn.classList.remove('active'); // Search and Research default to sequential; others default to parallel if (mode === 'search' || mode === 'research') { state._parallel = false; parallelBtn.classList.remove('active'); parallelBtn.innerHTML = ICON_SEQUENTIAL + _toggleLabel('Sequential'); } else { state._parallel = true; parallelBtn.classList.add('active'); parallelBtn.innerHTML = ICON_PARALLEL + _toggleLabel('Parallel'); } // Restore saved selections for this tab, or default selections = _tabSelections[mode] ? _tabSelections[mode].slice() : [null, null]; _updateModeLabel(); _setModeHint(''); renderModelRows(); } // Tab click listeners are set in the loop above // ── Model list ── const listContainer = document.createElement('div'); body.appendChild(listContainer); // Show loading state immediately with spinner const _loadingDiv = document.createElement('div'); _loadingDiv.style.cssText = 'color:color-mix(in srgb, var(--fg) 40%, transparent);font-size:0.85em;padding:12px 0;text-align:left;'; if (spinnerModule) { const _loadSpinner = spinnerModule.create('Loading models', 'right'); _loadingDiv.appendChild(_loadSpinner.createElement()); _loadSpinner.start(); } else { _loadingDiv.textContent = 'Loading models\u2026'; } listContainer.appendChild(_loadingDiv); // Restore last used selections from storage (per-mode) const _selKey = 'odysseus-compare-selections-' + (state._compareMode || 'chat'); let selections = Storage.getJSON(_selKey) || Storage.getJSON('odysseus-compare-selections') || []; // Restore synthesis models for search/research if (state._compareMode === 'search' || state._compareMode === 'research') { const savedSynth = Storage.getJSON('odysseus-compare-synth-' + state._compareMode); if (savedSynth) state._searchSynthModels = savedSynth; } // Validate saved selections against available models (done after models load) let _needsValidation = selections.length > 0; let addBtn = null; let _shuffled = false; _updateModeLabel(); // initial readout (Blind + Parallel on by default) function filteredModels() { // Agent and Research modes use chat models const effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode; return models.filter(m => m.type === effectiveType); } function buildOption(m) { return { val: JSON.stringify({ model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name, endpointName: m.endpointName || '' }), label: m.endpointName ? `${m.name} (${m.endpointName})` : m.name, }; } /** Build a searchable model picker (used when >5 models) */ function _buildSearchablePicker(modelList, currentSel, slotIdx, onSelect) { const wrap = document.createElement('div'); wrap.style.cssText = 'flex:1;position:relative;'; const input = document.createElement('input'); input.type = 'text'; input.placeholder = 'Search models\u2026'; input.className = 'cmp-form-control'; input.style.cssText = 'width:100%;box-sizing:border-box;'; // Mobile: suppress the on-screen keyboard so tapping the picker // opens the dropdown but doesn't shove a keyboard up over the list. // (Matches the +Model dropdown's mobile behavior.) if (window.innerWidth <= 768) { input.setAttribute('inputmode', 'none'); input.setAttribute('readonly', 'readonly'); } if (currentSel) { const m = modelList.find(m => m.id === currentSel.model && m.url === currentSel.endpoint) || modelList.find(m => m.id === currentSel.model); if (m) input.value = buildOption(m).label; } else { const fallback = modelList[Math.min(slotIdx, modelList.length - 1)]; if (fallback) input.value = buildOption(fallback).label; } wrap.appendChild(input); const dropdown = document.createElement('div'); dropdown.className = 'cmp-picker-dropdown'; // Appended to document.body (NOT wrap) and position:fixed so it escapes // both the modal's overflow clipping AND any transform on the modal-content // (a transformed ancestor makes position:fixed clip to it — which was why // the dropdown kept cropping under the next row). Coords set in _placeDropdown. dropdown.style.cssText = 'display:none;position:fixed;max-height:200px;overflow-y:auto;background:var(--panel);border:1px solid var(--border);border-radius:6px;z-index:100000;box-shadow:0 4px 12px rgba(0,0,0,0.2);'; document.body.appendChild(dropdown); function renderItems(query) { dropdown.innerHTML = ''; const q = (query || '').toLowerCase(); const matches = modelList.filter(m => { const label = buildOption(m).label.toLowerCase(); return !q || label.includes(q); }); if (matches.length === 0) { const empty = document.createElement('div'); empty.style.cssText = 'padding:8px 12px;color:color-mix(in srgb, var(--fg) 40%, transparent);font-size:0.82em;font-style:italic;'; empty.textContent = 'No matches'; dropdown.appendChild(empty); return; } matches.forEach(m => { const opt = buildOption(m); const item = document.createElement('div'); item.style.cssText = 'padding:6px 12px;cursor:pointer;font-size:0.85em;transition:background 0.08s;'; item.textContent = opt.label; const isSelected = currentSel && currentSel.model === m.id && (currentSel.endpoint === m.url || !modelList.some(o => o.id === m.id && o !== m)); if (isSelected) item.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; item.addEventListener('mouseenter', () => { item.style.background = 'color-mix(in srgb, var(--fg) 10%, transparent)'; }); item.addEventListener('mouseleave', () => { item.style.background = isSelected ? 'color-mix(in srgb, var(--fg) 8%, transparent)' : ''; }); item.addEventListener('click', () => { const chosen = { model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name, endpointName: m.endpointName || '' }; input.value = opt.label; currentSel = chosen; onSelect(chosen); dropdown.style.display = 'none'; input.blur(); }); dropdown.appendChild(item); }); } // Position the dropdown either below or above the input depending // on which side has more room — otherwise on a mobile bottom-sheet // a picker near the bottom of the screen would open downward and // either clip past the modal or extend off the viewport. const _placeDropdown = () => { const inRect = input.getBoundingClientRect(); const vh = window.innerHeight; const vw = window.innerWidth; const below = vh - inRect.bottom; const above = inRect.top; const flipUp = below < 220 && above > below; // Horizontal: align to the input but clamp inside the viewport so it // never runs off the screen edge on mobile. const width = Math.min(inRect.width, vw - 16); let left = inRect.left; if (left + width > vw - 8) left = vw - 8 - width; if (left < 8) left = 8; dropdown.style.left = left + 'px'; dropdown.style.width = width + 'px'; // Vertical: flip above/below based on available room (fixed coords). if (flipUp) { dropdown.style.top = 'auto'; dropdown.style.bottom = (vh - inRect.top + 2) + 'px'; dropdown.style.maxHeight = Math.max(120, Math.min(280, above - 16)) + 'px'; } else { dropdown.style.bottom = 'auto'; dropdown.style.top = (inRect.bottom + 2) + 'px'; dropdown.style.maxHeight = Math.max(120, Math.min(280, below - 16)) + 'px'; } }; input.addEventListener('focus', () => { input.value = ''; renderItems(''); dropdown.style.display = ''; _placeDropdown(); }); input.addEventListener('input', () => { renderItems(input.value); dropdown.style.display = ''; _placeDropdown(); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const first = dropdown.querySelector('div[style*="cursor:pointer"]'); if (first) first.click(); } }); // Close on outside click. The dropdown lives in document.body, so check // both wrap and dropdown; and tear the dropdown down when the picker row // is removed from the DOM (rebuild) so it doesn't orphan in the body. function _closeHandler(e) { if (!wrap.contains(e.target) && !dropdown.contains(e.target)) { dropdown.style.display = 'none'; if (currentSel) { const m = modelList.find(m => m.id === currentSel.model && m.url === currentSel.endpoint); if (m) input.value = buildOption(m).label; } if (!wrap.isConnected) { dropdown.remove(); document.removeEventListener('click', _closeHandler, true); } } } setTimeout(() => document.addEventListener('click', _closeHandler, true), 0); return wrap; } function renderModelRows() { if (!_modelsLoaded) return; // The picker dropdowns live in document.body (to escape modal clipping); // clear any leftovers before rebuilding the rows so they don't orphan. document.querySelectorAll('.cmp-picker-dropdown').forEach(d => d.remove()); // ── Search mode: show provider dropdowns ── if (state._compareMode === 'search') { listContainer.innerHTML = ''; if (!state._cachedProviders) { listContainer.innerHTML = '
Loading search providers\u2026
'; fetch(`${state.API_BASE}/api/search/providers`).then(r => r.json()).then(providers => { state._cachedProviders = providers; renderModelRows(); }).catch(() => { listContainer.innerHTML = '
Failed to load search providers
'; }); return; } const available = state._cachedProviders.filter(p => p.available); if (available.length === 0) { listContainer.innerHTML = '
No search providers configured
'; if (addBtn) addBtn.style.display = 'none'; return; } // Ensure per-pane synth model array matches selections length if (!state._searchSynthModels) state._searchSynthModels = []; while (state._searchSynthModels.length < selections.length) state._searchSynthModels.push(null); const chatModels = state._cachedModels.filter(m => m.type === 'chat'); const _seqStepS = !state._parallel ? Math.min(20, Math.floor(80 / Math.max(selections.length, 1))) : 0; selections.forEach((sel, idx) => { const row = document.createElement('div'); row.className = 'cmp-model-row'; if (_seqStepS) row.style.marginLeft = (idx * _seqStepS) + 'px'; // Left label: number/letter or blind eye icon const lbl = document.createElement('span'); lbl.className = 'cmp-row-label'; if (state._blindMode) { lbl.innerHTML = ''; } else { lbl.textContent = _slotChar(idx); } row.appendChild(lbl); // Model picker (synthesis LLM) — searchable for large lists if (!state._searchSynthModels[idx] && chatModels.length > 0) { const fb = chatModels[Math.min(idx, chatModels.length - 1)]; state._searchSynthModels[idx] = { model: fb.id, endpoint: fb.url, endpointId: fb.endpointId, name: fb.name }; } if (chatModels.length >= 5) { const picker = _buildSearchablePicker(chatModels, state._searchSynthModels[idx], idx, (chosen) => { state._searchSynthModels[idx] = chosen; }); row.appendChild(picker); } else { const modelSelect = document.createElement('select'); modelSelect.className = 'cmp-form-control'; modelSelect.style.flex = '1'; chatModels.forEach(m => { const opt = document.createElement('option'); opt.value = JSON.stringify({ model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name, endpointName: m.endpointName || '' }); opt.textContent = m.endpointName ? `${m.name} (${m.endpointName})` : m.name; if (state._searchSynthModels[idx] && state._searchSynthModels[idx].model === m.id) opt.selected = true; modelSelect.appendChild(opt); }); modelSelect.addEventListener('change', () => { try { state._searchSynthModels[idx] = JSON.parse(modelSelect.value); } catch (e) {} }); try { if (!state._searchSynthModels[idx]) state._searchSynthModels[idx] = JSON.parse(modelSelect.value); } catch (e) {} row.appendChild(modelSelect); } // Search provider picker (smaller) const provSelect = document.createElement('select'); provSelect.className = 'cmp-form-control cmp-prov-select'; available.forEach((p, pi) => { const optEl = document.createElement('option'); optEl.value = JSON.stringify({ model: p.id, endpoint: '', endpointId: null, name: p.label, searchProvider: p.id }); optEl.textContent = p.label; if (sel && sel.model === p.id) optEl.selected = true; else if (!sel && pi === Math.min(idx, available.length - 1)) optEl.selected = true; provSelect.appendChild(optEl); }); provSelect.addEventListener('change', () => { try { selections[idx] = JSON.parse(provSelect.value); } catch (e) {} }); try { if (!selections[idx]) selections[idx] = JSON.parse(provSelect.value); } catch (e) {} row.appendChild(provSelect); // X remove button when >2 slots if (selections.length > 2) { const rmBtn = document.createElement('button'); rmBtn.type = 'button'; rmBtn.textContent = '\u00d7'; rmBtn.className = 'cmp-rm-btn'; rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--color-error)'; }); rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--fg)'; }); rmBtn.addEventListener('click', () => { selections.splice(idx, 1); state._searchSynthModels.splice(idx, 1); renderModelRows(); }); row.appendChild(rmBtn); } listContainer.appendChild(row); }); if (addBtn) addBtn.style.display = selections.length >= 8 ? 'none' : ''; return; } // ── Chat / Image / Agent / Research mode: show model dropdowns ── const filtered = filteredModels(); listContainer.innerHTML = ''; // Research mode needs search providers too — fetch if not cached const needsProviders = state._compareMode === 'research'; if (needsProviders && !state._cachedProviders) { listContainer.innerHTML = '
Loading search providers\u2026
'; fetch(`${state.API_BASE}/api/search/providers`).then(r => r.json()).then(providers => { state._cachedProviders = providers; renderModelRows(); }).catch(() => { state._cachedProviders = []; renderModelRows(); }); return; } if (filtered.length === 0) { const empty = document.createElement('div'); empty.style.cssText = 'color:color-mix(in srgb, var(--fg) 40%, transparent);font-size:0.85em;padding:12px 0;text-align:center;font-style:italic;'; empty.textContent = 'No ' + state._compareMode + ' models available'; listContainer.appendChild(empty); if (addBtn) addBtn.style.display = 'none'; return; } // Research: ensure per-pane provider array const researchProviders = needsProviders && state._cachedProviders ? state._cachedProviders.filter(p => p.available) : []; if (!state._searchSynthModels) state._searchSynthModels = []; while (state._searchSynthModels.length < selections.length) state._searchSynthModels.push(null); const _seqStep = !state._parallel ? Math.min(20, Math.floor(80 / Math.max(selections.length, 1))) : 0; selections.forEach((sel, idx) => { const row = document.createElement('div'); row.className = 'cmp-model-row'; if (_seqStep) row.style.marginLeft = (idx * _seqStep) + 'px'; // Left label: number/letter or blind eye icon const lbl = document.createElement('span'); lbl.className = 'cmp-row-label'; if (state._blindMode) { lbl.innerHTML = ''; } else { lbl.textContent = _slotChar(idx); } row.appendChild(lbl); if (_shuffled) { const mask = document.createElement('div'); mask.className = 'cmp-form-control'; mask.style.cssText = 'flex:1;opacity:0.4;font-style:italic;'; mask.textContent = 'Hidden'; row.appendChild(mask); } else if (filtered.length >= 5) { const picker = _buildSearchablePicker(filtered, sel, idx, (chosen) => { selections[idx] = chosen; _remindShuffle(); }); if (!selections[idx]) { const fallback = filtered[Math.min(idx, filtered.length - 1)]; selections[idx] = { model: fallback.id, endpoint: fallback.url, endpointId: fallback.endpointId, name: fallback.name }; } row.appendChild(picker); } else { const select = document.createElement('select'); select.className = 'cmp-form-control'; select.style.flex = '1'; filtered.forEach((m, mi) => { const opt = buildOption(m); const optEl = document.createElement('option'); optEl.value = opt.val; optEl.textContent = opt.label; if (sel && sel.model === m.id && (sel.endpoint === m.url || !filtered.some(o => o.id === m.id && o !== m))) optEl.selected = true; else if (!sel && mi === Math.min(idx, filtered.length - 1)) optEl.selected = true; select.appendChild(optEl); }); select.addEventListener('change', () => { try { selections[idx] = JSON.parse(select.value); } catch (e) { console.warn('Compare model select parse failed:', e); } _remindShuffle(); }); try { if (!selections[idx]) selections[idx] = JSON.parse(select.value); } catch (e) { console.warn('Compare model init parse failed:', e); } row.appendChild(select); } // Research mode: search provider picker next to model if (needsProviders && researchProviders.length > 0 && !_shuffled) { const provSelect = document.createElement('select'); provSelect.className = 'cmp-form-control cmp-prov-select'; provSelect.title = 'Search provider'; researchProviders.forEach((p, pi) => { const optEl = document.createElement('option'); optEl.value = p.id; optEl.textContent = p.label; if (state._searchSynthModels[idx] && state._searchSynthModels[idx] === p.id) optEl.selected = true; else if (!state._searchSynthModels[idx] && pi === 0) optEl.selected = true; provSelect.appendChild(optEl); }); provSelect.addEventListener('change', () => { state._searchSynthModels[idx] = provSelect.value; }); if (!state._searchSynthModels[idx]) state._searchSynthModels[idx] = provSelect.value; row.appendChild(provSelect); } // X remove button when >2 slots if (selections.length > 2) { const rmBtn = document.createElement('button'); rmBtn.type = 'button'; rmBtn.textContent = '\u00d7'; rmBtn.className = 'cmp-rm-btn'; rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--color-error)'; }); rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--fg)'; }); rmBtn.addEventListener('click', () => { selections.splice(idx, 1); if (state._searchSynthModels.length > idx) state._searchSynthModels.splice(idx, 1); renderModelRows(); }); row.appendChild(rmBtn); } listContainer.appendChild(row); }); if (addBtn) addBtn.style.display = (selections.length >= 8) ? 'none' : ''; } // Default to 2 empty slots if no saved selections if (!selections.length || !selections.some(s => s !== null)) selections = [null, null]; addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.style.cssText = 'display:none;align-items:center;gap:6px;background:none;border:1px dashed var(--border);color:var(--fg);border-radius:6px;cursor:pointer;padding:6px 12px;font-size:0.82em;opacity:0.6;transition:all 0.15s;margin-bottom:16px;width:100%;justify-content:center;'; addBtn.textContent = '+ Add Model'; addBtn.addEventListener('mouseenter', () => { addBtn.style.opacity = '1'; }); addBtn.addEventListener('mouseleave', () => { addBtn.style.opacity = '0.6'; }); addBtn.addEventListener('click', () => { if (selections.length >= 8) return; if (_shuffled) { // In shuffle mode every slot is a hidden, randomly-picked model — so a // new slot must get a random pool model too, not an empty picker. const excluded = getExcludedModels(); const used = new Set(selections.filter(Boolean).map(s => s.model + '|' + s.endpoint)); const pool = filteredModels().filter(m => !excluded.includes(m.id)); const fresh = pool.filter(m => !used.has(m.id + '|' + m.url)); const src = fresh.length ? fresh : pool; const pick = src.length ? src[Math.floor(Math.random() * src.length)] : null; selections.push(pick ? { model: pick.id, endpoint: pick.url, endpointId: pick.endpointId, name: pick.name, endpointName: pick.endpointName || '' } : null); } else { selections.push(null); } renderModelRows(); _remindShuffle(); }); body.appendChild(addBtn); // ── Timeout input ── const timeoutRow = document.createElement('div'); timeoutRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:8px;'; const timeoutLabel = document.createElement('span'); timeoutLabel.style.cssText = 'color:color-mix(in srgb, var(--fg) 55%, transparent);font-size:0.82em;'; timeoutLabel.textContent = 'Timeout:'; const timeoutInput = document.createElement('input'); timeoutInput.type = 'number'; timeoutInput.min = '5'; timeoutInput.max = '300'; timeoutInput.value = String(state._timeout); timeoutInput.style.cssText = 'width:60px;padding:4px 8px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;font-size:0.82em;text-align:center;-moz-appearance:textfield;'; const timeoutSuffix = document.createElement('span'); timeoutSuffix.style.cssText = 'color:color-mix(in srgb, var(--fg) 55%, transparent);font-size:0.82em;'; timeoutSuffix.textContent = 'seconds'; timeoutRow.appendChild(timeoutLabel); timeoutRow.appendChild(timeoutInput); timeoutRow.appendChild(timeoutSuffix); // Scoreboard button const scoreBtn = document.createElement('button'); scoreBtn.type = 'button'; scoreBtn.innerHTML = 'Scoreboard'; scoreBtn.style.cssText = 'margin-left:auto;padding:4px 10px;background:transparent;color:var(--fg);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:0.82em;opacity:0.7;position:relative;top:-5px;'; scoreBtn.addEventListener('mouseenter', () => { scoreBtn.style.opacity = '1'; }); scoreBtn.addEventListener('mouseleave', () => { scoreBtn.style.opacity = '0.7'; }); scoreBtn.addEventListener('click', () => showScoreboard()); timeoutRow.appendChild(scoreBtn); body.appendChild(timeoutRow); content.appendChild(body); // ── Footer with action buttons ── const footer = document.createElement('div'); footer.className = 'modal-footer'; footer.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;padding:14px 16px 10px;border-top:1px solid var(--border);'; // Cancel button removed — the overlay's X / outside-click / Esc all // dismiss the popup, so the footer Cancel was redundant. const startBtn = document.createElement('button'); startBtn.innerHTML = _CMP_START_LABEL; startBtn.className = 'research-start-btn'; startBtn.disabled = true; // Pin to the same 30px box as Cancel so both buttons sit on the same line. startBtn.style.cssText = 'opacity:0.4;height:30px;box-sizing:border-box;align-items:center;'; footer.appendChild(startBtn); content.appendChild(footer); overlay.appendChild(content); document.body.appendChild(overlay); // Make draggable via header if (themeModule && themeModule.makeDraggable) { themeModule.makeDraggable(content, header); } function cleanup(result) { overlay.remove(); // Remove any body-appended picker dropdowns so they don't orphan. document.querySelectorAll('.cmp-picker-dropdown').forEach(d => d.remove()); if (result) { state._selectedModels = selections.filter(Boolean); state._timeout = Math.max(5, parseInt(timeoutInput.value) || 30); // Persist selections for next time (save filtered, non-null entries) _persistSelections(); } resolve(result); } // (cancelBtn removed — overlay X / outside-click / Esc still call cleanup) startBtn.addEventListener('click', async () => { if (!_modelsLoaded) return; let selected = selections.filter(Boolean); // Auto-populate any null selections from available models if (selected.length < selections.length) { const avail = state._compareMode === 'search' ? [] : filteredModels(); selections.forEach((s, i) => { if (!s && avail.length > 0) { const fb = avail[Math.min(i, avail.length - 1)]; selections[i] = { model: fb.id, endpoint: fb.url, endpointId: fb.endpointId, name: fb.name }; } }); selected = selections.filter(Boolean); } if (selected.length < 1) return; // For search mode, probe the synthesis LLM models instead of providers const modelsToProbe = (state._compareMode === 'search') ? (state._searchSynthModels || []).filter(Boolean) : selected; if (modelsToProbe.length < 1) { cleanup(true); return; } // ── Skip probe if all models already probed, go straight to start ── const allAlreadyProbed = modelsToProbe.every(m => state._probed.has(m.model)); if (allAlreadyProbed) { cleanup(true); return; } // ── Check selected models before starting ── startBtn.disabled = true; startBtn.style.opacity = '0.6'; const isBlind = state._blindMode || _shuffled; // Show probe overlay as a fixed modal const probeOverlay = document.createElement('div'); probeOverlay.className = 'compare-probe-overlay'; const probeCard = document.createElement('div'); probeCard.className = 'compare-probe-card'; probeCard.innerHTML = '
Checking models...
'; let _probeSkipped = false; const probeList = document.createElement('div'); probeList.className = 'compare-probe-list'; modelsToProbe.forEach((m, i) => { const row = document.createElement('div'); row.className = 'compare-probe-row'; row.dataset.model = m.model; row.dataset.idx = i; // In blind mode, hide name until failure — only show slot letter const name = m.name || m.model.split('/').pop(); const displayName = isBlind ? `Model ${_slotChar(i)}` : escapeHtml(name); row._realName = name; row.innerHTML = `▁▂▃${displayName}`; const waveEl = row.querySelector('.compare-probe-spinner'); const waveFrames = WAVE_FRAMES; let waveIdx = 0; row._waveInterval = setInterval(() => { waveIdx = (waveIdx + 1) % waveFrames.length; if (waveEl && !waveEl.classList.contains('ok') && !waveEl.classList.contains('fail')) { waveEl.textContent = waveFrames[waveIdx]; } }, 100); probeList.appendChild(row); }); probeCard.appendChild(probeList); const skipBtn = document.createElement('button'); skipBtn.textContent = 'Skip'; skipBtn.className = 'cmp-btn-secondary'; skipBtn.style.cssText = 'padding:4px 14px;font-size:11px;opacity:0.5;transition:opacity 0.15s;margin-top:8px;'; skipBtn.addEventListener('mouseenter', () => { skipBtn.style.opacity = '1'; }); skipBtn.addEventListener('mouseleave', () => { skipBtn.style.opacity = '0.5'; }); skipBtn.addEventListener('click', () => { _probeSkipped = true; _clearProbeWaves(); probeOverlay.remove(); cleanup(true); }); probeCard.appendChild(skipBtn); probeOverlay.appendChild(probeCard); // The CSS z-index for .compare-probe-overlay is 300, but modalManager // bumps each opened tool modal above that on every focus (_modalTopZ // starts at 300 and increments). So the compare modal often ends up // ABOVE the probe overlay, hiding it. Recompute from the compare // modal's current effective z-index so probe always sits one above. const _cmpModal = document.getElementById('compare-model-overlay'); if (_cmpModal) { const _cmpZ = parseInt(getComputedStyle(_cmpModal).zIndex, 10) || 0; probeOverlay.style.setProperty('z-index', String(_cmpZ + 1), 'important'); } document.body.appendChild(probeOverlay); // ESC to close probe overlay (stopPropagation prevents closing model selector too) const _probeEsc = (e) => { if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); _probeSkipped = true; _clearProbeWaves(); probeOverlay.remove(); document.removeEventListener('keydown', _probeEsc, false); startBtn.disabled = false; startBtn.innerHTML = _CMP_START_LABEL; startBtn.style.opacity = '1'; } }; document.addEventListener('keydown', _probeEsc, false); // Helper: probe a single model (skip image models — they use a different API) const _imageModelPrefixes = ['dall-e', 'gpt-image', 'chatgpt-image', 'stable-diffusion', 'sdxl', 'flux', 'midjourney']; function _isImageModel(modelId) { const lower = (modelId || '').toLowerCase(); return _imageModelPrefixes.some(p => lower.includes(p)); } async function _probeOne(m) { if (_isImageModel(m.model)) { return { status: 'ok', model: m.model, skipped: true, skipReason: 'Image' }; } // Search mode — probe the LLM model normally (don't skip) if (state._compareMode === 'search' && !m.model) { return { status: 'ok', model: m.model, skipped: true, skipReason: 'No model' }; } const res = await fetch(`${state.API_BASE}/api/probe-selected`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ models: [{ endpoint_id: m.endpointId || '', model: m.model, endpoint: m.endpoint || '', with_tools: state._compareMode === 'agent' }] }), }); const data = await res.json(); return (data.results || [])[0] || { status: 'fail', error: 'No response' }; } // Helper: update a probe row's visual state function _updateRow(idx, result) { const row = probeList.querySelector(`[data-idx="${idx}"]`); if (!row) return; // Stop wave animation if (row._waveInterval) { clearInterval(row._waveInterval); row._waveInterval = null; } const spinner = row.querySelector('.compare-probe-spinner'); const status = row.querySelector('.compare-probe-status'); if (result.status === 'ok') { spinner.textContent = '\u2713'; spinner.classList.remove('fail'); spinner.classList.add('ok'); status.textContent = result.skipped ? (result.skipReason || 'Skipped') : (result.latency_ms ? `${result.latency_ms}ms` : 'OK'); status.classList.remove('fail'); status.classList.add('ok'); row.classList.remove('fail'); // Track as probed if (result.model) state._probed.add(result.model); } else { spinner.textContent = '\u2717'; spinner.classList.remove('ok'); spinner.classList.add('fail'); status.textContent = ''; status.classList.remove('ok'); row.classList.add('fail'); // Reveal real model name on failure (even in blind mode) if (isBlind && row._realName) { const nameEl = row.querySelector('.compare-probe-name'); if (nameEl) nameEl.textContent = row._realName; } // Remove old detail/actions if retrying const oldDetail = row.nextElementSibling; if (oldDetail && oldDetail.classList.contains('compare-probe-detail')) oldDetail.remove(); // Error + actions below the row const detail = document.createElement('div'); detail.className = 'compare-probe-detail'; detail.style.cssText = 'grid-column:1/-1;display:flex;align-items:flex-start;gap:6px;padding:4px 10px 6px;font-size:10px;opacity:0.6;background:color-mix(in srgb, var(--color-error, #f44) 5%, transparent);border-radius:4px;margin-top:-2px;'; const errSpan = document.createElement('span'); // Truncate long error messages const errText = (result.error || 'Failed'); errSpan.textContent = errText.length > 80 ? errText.slice(0, 80) + '...' : errText; errSpan.title = errText; errSpan.style.cssText = 'flex:1;line-height:1.4;'; detail.appendChild(errSpan); // Track timeout for retry doubling if (!row._probeTimeout) row._probeTimeout = 15000; if (result.error === 'Timeout') row._probeTimeout = Math.min(row._probeTimeout * 2, 120000); const retryBtn = document.createElement('button'); retryBtn.className = 'compare-probe-action-btn'; const retryLabel = result.error === 'Timeout' ? `Retry ${Math.round(row._probeTimeout / 1000)}s` : 'Retry'; retryBtn.textContent = retryLabel; retryBtn.addEventListener('click', async (e) => { e.stopPropagation(); detail.remove(); if (isBlind) { const nameEl = row.querySelector('.compare-probe-name'); if (nameEl) nameEl.textContent = `Model ${_slotChar(idx)}`; } const waveFrames2 = WAVE_FRAMES; let w2 = 0; spinner.classList.remove('ok', 'fail'); spinner.style.color = ''; row._waveInterval = setInterval(() => { w2 = (w2 + 1) % waveFrames2.length; spinner.textContent = waveFrames2[w2]; }, 100); row.classList.remove('fail'); const r2 = await Promise.race([_probeOne(modelsToProbe[idx]), new Promise(r => setTimeout(() => r({ status: 'fail', error: 'Timeout' }), row._probeTimeout))]); _updateRow(idx, r2); }); const swapBtn = document.createElement('button'); swapBtn.className = 'compare-probe-action-btn'; swapBtn.textContent = 'Swap'; swapBtn.addEventListener('click', (e) => { e.stopPropagation(); _clearProbeWaves(); probeOverlay.remove(); _probeSkipped = true; startBtn.disabled = false; startBtn.innerHTML = _CMP_START_LABEL; startBtn.style.opacity = '1'; }); detail.appendChild(retryBtn); detail.appendChild(swapBtn); row.after(detail); } } try { // Probe all in parallel (with 15s timeout per model) const results = await Promise.all(modelsToProbe.map(m => Promise.race([ _probeOne(m), new Promise(r => setTimeout(() => r({ status: 'fail', error: 'Timeout' }), 15000)) ]) )); if (_probeSkipped) return; let allOk = true; let failCount = 0; for (let i = 0; i < results.length; i++) { _updateRow(i, results[i]); if (results[i].status !== 'ok') { allOk = false; failCount++; } } // In shuffle/blind mode: auto-swap failed models silently (not for search/research) if (!allOk && _shuffled && state._compareMode !== 'search' && state._compareMode !== 'research') { const excluded = getExcludedModels(); const usedModels = new Set(selections.filter(Boolean).map(m => m.model)); const pool = filteredModels().filter(m => !excluded.includes(m.id) && !usedModels.has(m.id)); let poolIdx = 0; for (let i = 0; i < results.length; i++) { if (results[i].status !== 'ok') { const row = probeList.querySelector(`[data-idx="${i}"]`); // Restart wave in red to show swapping if (row) { const spinner = row.querySelector('.compare-probe-spinner'); const status = row.querySelector('.compare-probe-status'); if (spinner) { spinner.classList.remove('ok', 'fail'); spinner.style.color = 'var(--color-error, #f44)'; const waveFrames = WAVE_FRAMES; let wIdx = 0; row._waveInterval = setInterval(() => { wIdx = (wIdx + 1) % waveFrames.length; spinner.textContent = waveFrames[wIdx]; }, 100); } if (status) status.textContent = 'Swapping...'; } // Try up to 3 replacements with 10s timeout each let swapped = false; for (let attempt = 0; attempt < 3 && poolIdx < pool.length; attempt++) { const replacement = pool[poolIdx++]; const probePromise = _probeOne({ model: replacement.id, endpoint: replacement.url, endpointId: replacement.endpointId }); const timeoutPromise = new Promise(r => setTimeout(() => r({ status: 'timeout', error: 'Swap timed out' }), 10000)); const probeResult = await Promise.race([probePromise, timeoutPromise]); if (probeResult.status === 'ok') { selections[i] = { model: replacement.id, endpoint: replacement.url, endpointId: replacement.endpointId, name: replacement.name }; usedModels.add(replacement.id); if (row && row._waveInterval) { clearInterval(row._waveInterval); row._waveInterval = null; } _updateRow(i, probeResult); swapped = true; break; } } if (!swapped) { if (row && row._waveInterval) { clearInterval(row._waveInterval); row._waveInterval = null; } if (row) { const spinner = row.querySelector('.compare-probe-spinner'); const status = row.querySelector('.compare-probe-status'); if (spinner) { spinner.textContent = '\u2717'; spinner.classList.add('fail'); spinner.style.color = ''; } if (status) { status.textContent = 'No replacement'; } } } } } // Re-check if all are ok now const finalToProbe = (state._compareMode === 'search') ? (state._searchSynthModels || []).filter(Boolean) : selections.filter(Boolean); const finalResults = await Promise.all(finalToProbe.map(m => _probeOne(m))); allOk = finalResults.every(r => r.status === 'ok'); failCount = finalResults.filter(r => r.status !== 'ok').length; } // ── Phase 2: For search/research, also check search providers ── if (allOk && (state._compareMode === 'search' || state._compareMode === 'research')) { const providers = state._compareMode === 'search' ? selected.map(s => ({ id: s.model, label: s.name })) : (state._searchSynthModels || []).map(p => typeof p === 'string' ? { id: p, label: p } : null).filter(Boolean); if (providers.length > 0) { const titleEl = probeOverlay.querySelector('.compare-probe-title'); titleEl.textContent = 'Checking search providers...'; // Add provider rows const providerRows = []; providers.forEach((p, i) => { const row = document.createElement('div'); row.className = 'compare-probe-row'; row.dataset.idx = 'p' + i; row.innerHTML = `▁▂▃${p.label || p.id}`; const waveEl = row.querySelector('.compare-probe-spinner'); const waveFrames = WAVE_FRAMES; let wIdx = 0; row._waveInterval = setInterval(() => { wIdx = (wIdx + 1) % waveFrames.length; if (waveEl && !waveEl.classList.contains('ok') && !waveEl.classList.contains('fail')) waveEl.textContent = waveFrames[wIdx]; }, 100); probeList.appendChild(row); providerRows.push(row); }); // Probe each provider with a test query const provResults = await Promise.all(providers.map(async (p) => { try { const fd = new FormData(); fd.append('query', 'test'); fd.append('provider', p.id); fd.append('count', '1'); const r = await fetch(`${state.API_BASE}/api/search/query`, { method: 'POST', body: fd, credentials: 'same-origin' }); const d = await r.json(); return { status: d.error ? 'fail' : 'ok', error: d.error }; } catch (e) { return { status: 'fail', error: e.message }; } })); let searchAllOk = true; provResults.forEach((result, i) => { const row = providerRows[i]; if (row._waveInterval) { clearInterval(row._waveInterval); row._waveInterval = null; } const spinner = row.querySelector('.compare-probe-spinner'); const status = row.querySelector('.compare-probe-status'); if (result.status === 'ok') { spinner.textContent = '\u2713'; spinner.classList.add('ok'); status.textContent = 'OK'; status.classList.add('ok'); } else { spinner.textContent = '\u2717'; spinner.classList.add('fail'); status.textContent = result.error || 'Failed'; status.classList.add('fail'); row.classList.add('fail'); searchAllOk = false; } }); if (!searchAllOk) { allOk = false; failCount += provResults.filter(r => r.status !== 'ok').length; } } } if (allOk) { // Don't hide the Skip button here — collapsing its space made the // card shrink and the title + rows jump ("quick cut"). On success the // whole overlay fades out a moment later, so just leave it in place. probeOverlay.querySelector('.compare-probe-title').textContent = 'All ready!'; setTimeout(() => { probeOverlay.style.transition = 'opacity 0.3s ease'; probeOverlay.style.opacity = '0'; setTimeout(() => { _clearProbeWaves(); probeOverlay.remove(); cleanup(true); if (window._updateCheckBtnState) window._updateCheckBtnState(); }, 300); }, 400); } else { // Failed — the Skip button is replaced by the Go Back / Start Anyway row. skipBtn.style.display = 'none'; // Some failed — show which ones const failedNames = []; probeList.querySelectorAll('.compare-probe-row.fail').forEach(row => { failedNames.push(row.querySelector('.compare-probe-name').textContent); }); const titleEl = probeOverlay.querySelector('.compare-probe-title'); titleEl.textContent = failedNames.length <= 2 ? failedNames.join(' & ') + ' failed' : `${failCount} models failed`; const btnRow = document.createElement('div'); btnRow.style.cssText = 'display:flex;gap:8px;justify-content:center;margin-top:12px;'; const goBackBtn = document.createElement('button'); goBackBtn.innerHTML = 'Go Back'; goBackBtn.className = 'cmp-btn-secondary'; goBackBtn.style.cssText = 'padding:5px 12px;font-size:12px;display:inline-flex;align-items:center;'; goBackBtn.addEventListener('click', () => { _clearProbeWaves(); probeOverlay.remove(); startBtn.disabled = false; startBtn.innerHTML = _CMP_START_LABEL; startBtn.style.opacity = '1'; }); const startAnywayBtn = document.createElement('button'); startAnywayBtn.textContent = 'Start Anyway'; startAnywayBtn.className = 'cmp-btn-primary'; startAnywayBtn.style.cssText = 'padding:5px 12px;font-size:12px;'; startAnywayBtn.addEventListener('click', () => { _clearProbeWaves(); probeOverlay.remove(); cleanup(true); }); btnRow.appendChild(goBackBtn); btnRow.appendChild(startAnywayBtn); probeCard.appendChild(btnRow); } } catch (e) { // Probe failed entirely — let user start anyway console.error('Compare probe error:', e); _clearProbeWaves(); probeOverlay.remove(); startBtn.disabled = false; startBtn.innerHTML = _CMP_START_LABEL; startBtn.style.opacity = '1'; cleanup(true); } }); // ── Fetch models in background ── fetchModels().then(fetched => { models = fetched; state._cachedModels = fetched; _modelsLoaded = true; if (models.length < 1) { listContainer.innerHTML = '
No models available
'; return; } // Validate saved selections against available models if (_needsValidation && selections.length > 0) { selections = selections.map(sel => { if (!sel) return null; // Prefer exact match (model + endpoint), fall back to model ID only const exact = models.find(m => m.id === sel.model && m.url === sel.endpoint); if (exact) return { ...sel, endpoint: exact.url, endpointId: exact.endpointId, endpointName: exact.endpointName || sel.endpointName || '' }; const byId = models.find(m => m.id === sel.model); if (byId) return { model: byId.id, endpoint: byId.url, endpointId: byId.endpointId, name: byId.name, endpointName: byId.endpointName || '' }; return null; }); // Keep nulls in place so slot positions are preserved if (!selections.some(s => s !== null)) selections = [null, null]; _needsValidation = false; } if (!selections.length) selections = [null, null]; startBtn.disabled = false; startBtn.style.opacity = '1'; addBtn.style.display = 'flex'; renderModelRows(); }).catch(e => { console.error('Failed to fetch models for compare:', e); listContainer.innerHTML = '
Failed to load models
'; }); }); } export { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator };