// compare/index.js — orchestrator module (public API) /** * Model A/B Comparison module. * Builds its own multi-pane grid layout (up to 8 models). * Sends same prompt to all models in parallel, lets user vote. * * Uses show/hide on the original container children instead of * innerHTML replacement, so event listeners on the input bar, * compare button, mode toggle, etc. are preserved. */ // ── Submodule imports ── import state from './state.js'; import { EVAL_PROMPTS, WAVE_FRAMES, ICON_DICE, ICON_EXPAND, ICON_COLLAPSE, ICON_CLOSE, ICON_REROLL, ICON_COPY, ICON_PLAY, ICON_CODE, ICON_PARALLEL, ICON_SEQUENTIAL, EYE_OPEN, EYE_CLOSED, SAVE_ICON, CHAT_ICON, SEND_SVG, VOTES_STORAGE_KEY, } from './icons.js'; import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js'; import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js'; import { _checkUnprobed, _clearProbeWaves } from './probe.js'; import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js'; import { stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare, _addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse, _showModelSwapDropdown, _createAndAppendPane, _autoPreviewHtml, registerPaneActions, } from './panes.js'; import { handleVote, buildVoteBar, addFinishBadge, spawnConfetti, _saveVote, registerCompareActions } from './vote.js'; import { showScoreboard } from './scoreboard.js'; // ── External dependency imports ── import Storage from '../storage.js'; import uiModule from '../ui.js'; import sessionModule from '../sessions.js'; import spinnerModule from '../spinner.js'; import themeModule from '../theme.js'; import presetsModule from '../presets.js'; import markdownModule from '../markdown.js'; var escapeHtml = uiModule.esc; /** 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); } // ──────────────────────────────────────────────────────────────────────────── // ── Toolbar indicator sync ── // ──────────────────────────────────────────────────────────────────────────── // ── init ── // ──────────────────────────────────────────────────────────────────────────── function init(apiBase) { state.API_BASE = apiBase; // Clean up unsaved compare sessions on page close/refresh window.addEventListener('beforeunload', () => { if (!state._saveOnClose && state._paneSessionIds.length > 0) { // sendBeacon uses POST — use the bulk delete endpoint navigator.sendBeacon( `${state.API_BASE}/api/sessions/bulk-delete`, new Blob([JSON.stringify({ ids: state._paneSessionIds })], { type: 'application/json' }) ); } }); } // ──────────────────────────────────────────────────────────────────────────── // ── isCompareActive ── // ──────────────────────────────────────────────────────────────────────────── function isCompareActive() { return state.isActive; } // ──────────────────────────────────────────────────────────────────────────── // ── closeCompare ── // ──────────────────────────────────────────────────────────────────────────── /** Close compare mode (public API for toolbar indicator). */ function closeCompare() { if (state.isActive) deactivate(true); } // ──────────────────────────────────────────────────────────────────────────── // ── toggleMode ── // ──────────────────────────────────────────────────────────────────────────── /** Toggle compare mode — shows model selector, then builds UI. */ async function toggleMode() { if (state.isActive) { deactivate(true); return false; } if (state._openingSelector) return false; state._openingSelector = true; try { const confirmed = await showModelSelector(); if (!confirmed) return false; state.isActive = true; _syncToolbarIndicator(true); await _buildCompareUI(); return true; } catch (err) { console.error('Compare toggleMode error:', err); return false; } finally { state._openingSelector = false; } } // ──────────────────────────────────────────────────────────────────────────── // ── deactivate ── // ──────────────────────────────────────────────────────────────────────────── async function deactivate(teardown) { // Abort any in-flight streams state._abortControllers.forEach(ac => { if (ac) ac.abort(); }); state._abortControllers = []; // Move sessions to compare folder if saving if (state._saveOnClose && state._paneSessionIds.length > 0) { const modelShorts = _modelDisplayNames(state._selectedModels); const folderName = 'Compare: ' + modelShorts.join(' vs '); await Promise.all(state._paneSessionIds.map(sid => fetch(`${state.API_BASE}/api/session/${sid}`, { method: 'PATCH', body: new URLSearchParams({ folder: folderName }) }).catch(() => {}) )); } // Capture session IDs to delete before resetting state const sessionIdsToDelete = (!state._saveOnClose && teardown && state._paneSessionIds.length > 0) ? [...state._paneSessionIds] : []; removeOverlays(); state.isActive = false; state._streaming = false; state._paneSessionIds = []; state._paneMetrics = []; state._finishOrder = 0; state._paneElapsed = []; state._saveOnClose = false; state._continueChat = false; state._probed.clear(); state._expectedAnswer = ''; _syncToolbarIndicator(false); // Restore main textarea placeholder const msgTA = document.getElementById('message'); if (msgTA) msgTA.placeholder = ''; // Restore toolbar indicator display states and pointer events Object.entries(state._savedIndicatorDisplay).forEach(([id, display]) => { const el = document.getElementById(id); if (el) { el.style.display = display; el.style.pointerEvents = ''; } }); state._savedIndicatorDisplay = {}; // Unlock mode toggle const _modeToggleR = document.querySelector('.mode-toggle'); if (_modeToggleR) { _modeToggleR.style.pointerEvents = ''; _modeToggleR.style.opacity = ''; } // Restore tool toggle pointer events ['overflow-plus-btn', 'web-toggle-btn', 'bash-toggle-btn'].forEach(id => { const el = document.getElementById(id); if (el) el.style.pointerEvents = ''; }); // Restore agent/chat mode to what it was before compare const _ts = Storage.loadToggleState(); _ts.mode = state._savedMode; Storage.saveToggleState(_ts); const _ab2 = document.getElementById('mode-agent-btn'), _cb2 = document.getElementById('mode-chat-btn'); if (_ab2 && _cb2) { _ab2.classList.toggle('active', state._savedMode === 'agent'); _cb2.classList.toggle('active', state._savedMode === 'chat'); } document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = state._savedMode === 'agent' ? '' : 'none'; }); // Delete unsaved sessions, then reload if (teardown) { if (sessionIdsToDelete.length > 0) { // keepalive ensures requests complete even during page navigation await Promise.all(sessionIdsToDelete.map(sid => fetch(`${state.API_BASE}/api/session/${sid}`, { method: 'DELETE', keepalive: true }).catch(() => {}) )); } location.href = location.pathname; } } // ──────────────────────────────────────────────────────────────────────────── // ── _buildCompareUI ── // ──────────────────────────────────────────────────────────────────────────── /** Build the compare UI: sessions, header bar, grid of panes, vote bar, eval dropdown. */ async function _buildCompareUI() { if (state._selectedModels.length < 1) { if (uiModule) uiModule.showError('Select at least 1 model'); return; } const n = state._selectedModels.length; const modelShorts = _modelDisplayNames(state._selectedModels); _persistSelections(); // 1. Create sessions (skip for search mode — no LLM sessions needed) if (state._compareMode !== 'search') { const sessionIds = []; for (let i = 0; i < n; i++) { const m = state._selectedModels[i]; const fd = new FormData(); fd.append('name', '[CMP] ' + modelShorts[i]); fd.append('endpoint_url', m.endpoint || ''); fd.append('model', m.model || ''); if (m.endpointId) { fd.append('endpoint_id', m.endpointId); fd.append('skip_validation', 'true'); } const res = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd }); if (!res.ok) throw new Error('Failed to create session for ' + modelShorts[i]); const data = await res.json(); sessionIds.push(data.id); } state._paneSessionIds = sessionIds; } else { state._paneSessionIds = []; } state._paneMetrics = state._selectedModels.map(() => null); state._abortControllers = state._selectedModels.map(() => null); // 2. Auto-collapse sidebar if many panes if (n > 3) { const sidebar = document.getElementById('sidebar'); if (sidebar && !sidebar.classList.contains('hidden')) { sidebar.classList.add('hidden'); state._sidebarWasHidden = true; const iconRail = document.getElementById('icon-rail'); if (iconRail) iconRail.classList.remove('rail-hidden'); if (typeof window.syncRailSide === 'function') window.syncRailSide(); } } // 3. Hide mobile new-chat button during compare const _mobileNewBtn = document.getElementById('mobile-new-chat-btn'); if (_mobileNewBtn) { _mobileNewBtn.dataset.cmpWasDisplay = _mobileNewBtn.style.display; _mobileNewBtn.style.display = 'none'; } // 4. Save toolbar indicator display states before hiding const indicatorIds = ['overflow-tts-btn', 'overflow-attach-btn', 'overflow-rag-btn', 'overflow-research-btn', 'overflow-doc-btn', 'rag-indicator-btn', 'research-toggle-btn']; state._savedIndicatorDisplay = {}; indicatorIds.forEach(id => { const el = document.getElementById(id); if (el) state._savedIndicatorDisplay[id] = el.style.display; }); // 5. Save current mode and lock to the right one for this compare type const _toggleState = Storage.loadToggleState(); state._savedMode = _toggleState.mode || 'chat'; const _targetMode = (state._compareMode === 'agent') ? 'agent' : 'chat'; _toggleState.mode = _targetMode; Storage.saveToggleState(_toggleState); const _ab = document.getElementById('mode-agent-btn'), _cb = document.getElementById('mode-chat-btn'); if (_ab && _cb) { _ab.classList.toggle('active', _targetMode === 'agent'); _cb.classList.toggle('active', _targetMode === 'chat'); } const _modeToggle = document.querySelector('.mode-toggle'); if (_modeToggle) { _modeToggle.style.pointerEvents = 'none'; _modeToggle.style.opacity = '0.4'; } // 6. Force tool toggles per compare mode disableToolToggles(); if (state._compareMode === 'search') { const webChk = document.getElementById('web-toggle'); if (webChk && !webChk.checked) { webChk.checked = true; webChk.dispatchEvent(new Event('change')); } const webBtn = document.getElementById('web-toggle-btn'); if (webBtn) webBtn.classList.add('active'); } else if (state._compareMode === 'research') { const resChk = document.getElementById('research-toggle'); if (resChk && !resChk.checked) { resChk.checked = true; resChk.dispatchEvent(new Event('change')); } const resBtn = document.getElementById('research-toggle-btn'); if (resBtn) { resBtn.style.display = ''; resBtn.classList.add('active'); } } // 7. Hide existing chat container children (preserves event listeners) const container = document.getElementById('chat-container'); state._compareElements = []; Array.from(container.children).forEach(child => { if (child.style.display === 'none') return; child.dataset.cmpHidden = '1'; child.style.display = 'none'; }); container.classList.add('compare-active'); // 8. Header bar const cols = Math.min(n, 4); const headerBar = document.createElement('div'); headerBar.className = 'compare-header-bar'; headerBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:6px 10px;flex-shrink:0;'; const headerLabel = document.createElement('span'); headerLabel.style.cssText = 'font-size:10px;font-weight:400;color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;'; const _modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models'); headerLabel.textContent = 'Comparing' + _modeLabel + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout'; // Left side: the Compare tool icon (two side-by-side panes, matching the // rail/sidebar icon) + the label. Other tool headers carry their icon; this // one was missing it. const headerLeft = document.createElement('div'); headerLeft.style.cssText = 'display:flex;align-items:center;min-width:0;'; const headerIcon = document.createElement('span'); headerIcon.style.cssText = 'display:inline-flex;flex-shrink:0;margin-right:6px;opacity:0.85;'; headerIcon.innerHTML = ''; headerLeft.appendChild(headerIcon); headerLeft.appendChild(headerLabel); headerBar.appendChild(headerLeft); const headerActions = document.createElement('div'); headerActions.style.cssText = 'display:flex;align-items:center;gap:2px;'; const _btnCSS = 'background:none;border:1px solid var(--border);color:var(--fg);cursor:pointer;padding:3px 10px;font-size:11px;font-weight:600;opacity:0.7;transition:all 0.15s;line-height:1;border-radius:4px;display:inline-flex;align-items:center;font-family:inherit;'; const checkBtn = document.createElement('button'); checkBtn.id = 'compare-check-btn'; checkBtn.innerHTML = 'Probe'; checkBtn.title = 'Probe unverified models with a small test request'; checkBtn.style.cssText = _btnCSS; checkBtn.addEventListener('click', () => _checkUnprobed()); headerActions.appendChild(checkBtn); // Check button is dynamic: only visible when at least one selected model // hasn't been probed yet. Show right after add/change, hide after success. window._updateCheckBtnState = function() { const btn = document.getElementById('compare-check-btn'); if (!btn) return; const hasUnprobed = state._selectedModels.some(m => !state._probed.has(m.model)); btn.style.display = hasUnprobed ? '' : 'none'; }; // (Scoreboard button moved into the vote bar, next to Tie — see vote.js.) const exportWrap = document.createElement('div'); exportWrap.style.cssText = 'position:relative;display:inline-flex;'; const exportBtn = document.createElement('button'); exportBtn.id = 'compare-export-btn'; exportBtn.innerHTML = 'Export'; exportBtn.title = 'Export options'; exportBtn.style.cssText = _btnCSS; exportBtn.addEventListener('click', (e) => { e.stopPropagation(); _toggleExportMenu(exportBtn); }); exportWrap.appendChild(exportBtn); headerActions.appendChild(exportWrap); const shuffleBtn = document.createElement('button'); shuffleBtn.id = 'compare-shuffle-btn'; shuffleBtn.innerHTML = ICON_DICE + 'Shuffle'; shuffleBtn.title = 'Shuffle pane positions'; shuffleBtn.style.cssText = _btnCSS; shuffleBtn.addEventListener('click', () => shufflePanePositions()); headerActions.appendChild(shuffleBtn); const addBtn = document.createElement('button'); addBtn.id = 'compare-add-btn'; addBtn.innerHTML = 'Add'; addBtn.title = 'Add model pane'; addBtn.style.cssText = _btnCSS; addBtn.addEventListener('click', () => _addPane(addBtn)); headerActions.appendChild(addBtn); const closeBtn = document.createElement('button'); closeBtn.className = 'compare-close-btn'; closeBtn.innerHTML = ''; closeBtn.title = 'Close compare mode'; // Match Export/Score/Shuffle/Model styling so the X sits flush with // the rest of the toolbar instead of being a 24×24 bordered square. closeBtn.style.cssText = _btnCSS; closeBtn.addEventListener('click', () => deactivate(true)); headerActions.appendChild(closeBtn); // Move Export to the far left of the action cluster (per user preference). headerActions.insertBefore(exportWrap, headerActions.firstChild); headerBar.appendChild(headerActions); container.appendChild(headerBar); state._compareElements.push(headerBar); // Initial visibility — hidden if all current models are already probed window._updateCheckBtnState(); // 9. Grid of panes const grid = document.createElement('div'); grid.className = 'compare-grid'; grid.dataset.cols = String(cols); for (let i = 0; i < n; i++) { const label = state._blindMode ? 'Model ' + _slotChar(i) : modelShorts[i]; const pane = document.createElement('div'); pane.className = 'compare-pane'; pane.dataset.pane = String(i); pane.innerHTML = '
' + '' + '' + '' + '
' + '' + '' + '' + '' + '' + '' + '
' + '
' + '
' + '' + ''; grid.appendChild(pane); } grid.addEventListener('click', (e) => { const voteBtn = e.target.closest('.pane-vote-btn'); if (voteBtn) { e.stopPropagation(); if (voteBtn.disabled) return; const idx = parseInt(voteBtn.dataset.pane); handleVote(idx); return; } const actionBtn = e.target.closest('.pane-action-btn'); if (actionBtn) { e.stopPropagation(); const action = actionBtn.dataset.action; const idx = parseInt(actionBtn.dataset.pane); if (action === 'stop') stopPane(idx); else if (action === 'copy') copyPaneResponse(idx); else if (action === 'reroll') rerollPane(idx); else if (action === 'expand') toggleExpandPane(idx, actionBtn); else if (action === 'preview') togglePanePreview(idx); else if (action === 'close') _removePane(idx); return; } const titleBtn = e.target.closest('.pane-title-btn'); if (titleBtn) { e.stopPropagation(); const idx = parseInt(titleBtn.dataset.pane); _showModelSwapDropdown(idx, titleBtn); } }); container.appendChild(grid); state._compareElements.push(grid); // 10. Vote bar placeholder const voteBar = document.createElement('div'); voteBar.id = 'compare-vote-bar'; voteBar.className = 'compare-vote-bar'; container.appendChild(voteBar); state._compareElements.push(voteBar); buildVoteBar(n); if (state._blindMode && n > 1) shufflePanePositions(); // 11. Move chat input bar to the bottom of the container const inputBar = document.querySelector('.chat-input-bar'); if (inputBar) { inputBar.style.display = ''; if (inputBar.dataset.cmpHidden) delete inputBar.dataset.cmpHidden; container.appendChild(inputBar); } const msgTA = document.getElementById('message'); if (msgTA) { msgTA.placeholder = 'Enter prompt for all models...'; requestAnimationFrame(() => msgTA.focus()); } // Eval-prompts picker — sits inside the message box at top-right (where // model-picker normally lives). Model-picker is irrelevant during compare, // so hide it and restore on deactivate via the wrap's _cleanup. _setupEvalPicker(); // 12. Hide tool buttons that don't apply during compare ['overflow-tts-btn', 'overflow-attach-btn', 'overflow-rag-btn', 'overflow-research-btn', 'overflow-doc-btn', 'rag-indicator-btn', 'web-toggle-btn', 'bash-toggle-btn', 'overflow-plus-btn'].forEach(id => { const el = document.getElementById(id); if (el) { el.style.display = 'none'; el.style.pointerEvents = 'none'; } }); if (state._compareMode !== 'research') { const resBtn = document.getElementById('research-toggle-btn'); if (resBtn) { resBtn.style.display = 'none'; resBtn.style.pointerEvents = 'none'; } } document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = 'none'; }); _setSendBtn('send'); } // ──────────────────────────────────────────────────────────────────────────── // ── _setSendBtn ── // ──────────────────────────────────────────────────────────────────────────── function _setSendBtn(mode) { const btn = document.querySelector('.send-btn'); if (!btn) return; if (mode === 'stop') { btn.innerHTML = ''; btn.title = 'Stop all models'; btn.dataset.mode = 'streaming'; btn.classList.remove('mic-mode', 'newchat-mode'); } else { btn.dataset.mode = ''; btn.innerHTML = SEND_SVG; btn.style.color = ''; btn.title = 'Send to all models'; btn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded'); } } // ──────────────────────────────────────────────────────────────────────────── // ── handleCompareSubmit ── // ──────────────────────────────────────────────────────────────────────────── /** * Handle submit from the main chat input while compare is active. * Called by app.js submit guard. */ function handleCompareSubmit(e) { // If streaming, act as stop button if (state._streaming) { stopAll(); return; } const input = document.getElementById('message'); const message = input ? input.value.trim() : ''; if (!message) return; input.value = ''; // Reset textarea height input.style.height = ''; // Notify input listeners (eval-picker visibility, autosize, etc.) that the // textarea is empty again — programmatic clears don't fire `input` natively. input.dispatchEvent(new Event('input', { bubbles: true })); // Mobile: dismiss the on-screen keyboard after the prompt is sent so the // user sees the streaming output instead of the typing area. A plain blur() // is often ignored on Firefox mobile, so toggle readonly around it (and blur // the active element too) to reliably collapse the keyboard. // Mobile keyboard dismiss — use the SAME proven logic as the main chat send // (chat.js handleChatSubmit). Compare returns early in that flow, so it never // reached this code; replicating it here is what actually works on Firefox // mobile (readonly + blur, then drop readonly only once the blur is confirmed // or the user taps to type again — avoids the keyboard bouncing back up). if (window.innerWidth <= 768) { try { input.setAttribute('readonly', 'readonly'); input.blur(); // Setting readonly on an ALREADY-FOCUSED textarea doesn't dismiss the // keyboard on Firefox, and blur() is often ignored — so the readonly-only // approach works only when the input happened not to be focused at send // time (inconsistent between 1st/2nd prompt). Deterministically pull focus // off the textarea by focusing a throwaway readonly input, then drop it. const tmp = document.createElement('input'); tmp.setAttribute('readonly', 'readonly'); tmp.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;border:0;padding:0;'; document.body.appendChild(tmp); tmp.focus(); setTimeout(() => { try { tmp.blur(); tmp.remove(); } catch {} }, 50); const _dropReadonly = () => { try { input.removeAttribute('readonly'); } catch {} }; setTimeout(() => { if (document.activeElement === input) { input.addEventListener('pointerdown', _dropReadonly, { once: true }); input.addEventListener('focus', _dropReadonly, { once: true }); } else { _dropReadonly(); } }, 120); } catch {} } _executeCompare(message); } // ──────────────────────────────────────────────────────────────────────────── // ── _executeCompare ── // ──────────────────────────────────────────────────────────────────────────── /** * Send prompt to all panes, stream responses. * Works for both first and follow-up messages. */ async function _executeCompare(message) { if (state._streaming) return; if (state._selectedModels.length < 1) return; // New round — allow voting again and clear the previous round's win/lose/tie // styling (pane highlight + the Winner!/= title decorations), otherwise the // old result stays stuck on the panes through the next prompt. state._voted = false; for (let i = 0; i < state._selectedModels.length; i++) { const pane = document.querySelector('.compare-pane[data-pane="' + i + '"]'); if (pane) { pane.classList.remove('winner', 'loser'); // Clear the previous round's Failed/Timeout badge and the eval ✓/✗ grade. pane.querySelector('.pane-grade-badge')?.remove(); } const fb = document.getElementById('cmp-badge-' + i); if (fb) { fb.textContent = ''; fb.style.color = ''; } const titleEl = document.getElementById('cmp-title-' + i); if (titleEl) { const label = state._blindMode ? 'Model ' + _slotChar(i) : ((state._selectedModels[i] && state._selectedModels[i].name) || 'Model ' + _slotChar(i)); titleEl.innerHTML = escapeHtml(label) + ' '; } } state._streaming = true; state._lastPrompt = message; _setSendBtn('stop'); // Disable header buttons during streaming document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => { b.disabled = true; b.style.opacity = '0.25'; b.style.pointerEvents = 'none'; }); // ── Search mode: direct API calls, no SSE streaming ── if (state._compareMode === 'search') { try { const n = state._selectedModels.length; // Clear previous vote buttons on follow-up const voteBar = document.getElementById('compare-vote-bar'); if (voteBar) voteBar.innerHTML = ''; // Add user query + spinner to each pane for (let i = 0; i < n; i++) { const hist = document.getElementById('cmp-history-' + i); if (!hist) continue; const userMsg = document.createElement('div'); userMsg.className = 'msg msg-user'; userMsg.innerHTML = '
You
'; userMsg.querySelector('.body').textContent = message; hist.appendChild(userMsg); const aiMsg = document.createElement('div'); aiMsg.className = 'msg msg-ai'; aiMsg.innerHTML = '
Search
'; const aiBody = aiMsg.querySelector('.body'); if (spinnerModule) { const spinner = spinnerModule.create('Searching...', 'right'); aiBody.appendChild(spinner.createElement()); spinner.start(); } hist.appendChild(aiMsg); hist.scrollTop = hist.scrollHeight; } // Fire searches — parallel or sequential based on _parallel setting const t0 = performance.now(); state._abortControllers = state._selectedModels.map(() => new AbortController()); async function _searchOne(m, i) { const fd = new FormData(); fd.append('query', message); fd.append('provider', m.model); fd.append('count', '10'); try { const res = await fetch(`${state.API_BASE}/api/search/query`, { method: 'POST', body: fd, signal: state._abortControllers[i].signal }); const data = await res.json(); return { idx: i, data }; } catch (err) { return { idx: i, data: { results: [], error: err.name === 'AbortError' ? 'Stopped' : err.message } }; } } let results; const _seqSynthDone = new Set(); if (state._parallel) { results = await Promise.all(state._selectedModels.map((m, i) => _searchOne(m, i))); } else { // Sequential — run one at a time, dim waiting panes results = []; const panes = document.querySelectorAll('.compare-pane'); panes.forEach((p, i) => { if (i > 0) p.style.opacity = '0.4'; }); for (let i = 0; i < state._selectedModels.length; i++) { const pane = panes[i]; if (pane) pane.style.opacity = '1'; results.push(await _searchOne(state._selectedModels[i], i)); // Render this result immediately const { idx, data } = results[results.length - 1]; const hist = document.getElementById('cmp-history-' + idx); if (hist) { const aiMsg = hist.querySelector('.msg-ai:last-child'); if (aiMsg) { const aiBody = aiMsg.querySelector('.body'); aiBody.innerHTML = ''; if (data.error) { aiBody.innerHTML = '
Error: ' + escapeHtml(data.error) + '
'; } else if (!data.results || data.results.length === 0) { aiBody.innerHTML = '
No results found
'; } else { aiBody.appendChild(_renderSearchResults(data)); } const footer = document.createElement('div'); footer.className = 'msg-footer'; const span = document.createElement('span'); span.className = 'response-metrics'; const parts = []; if (data.results) parts.push(data.results.length + ' results'); if (data.time) parts.push(data.time + 's'); span.textContent = parts.join(' | '); footer.appendChild(span); aiMsg.appendChild(footer); hist.scrollTop = hist.scrollHeight; const _pe = document.querySelector(`.compare-pane[data-pane="${idx}"]`); if (_pe) _pe.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = ''); } } // Sequential: run synthesis for this pane immediately before moving to next _seqSynthDone.add(idx); if (!data.error && data.results && data.results.length > 0) { const modelToUse = state._searchSynthModels?.[idx] || null; if (modelToUse) { const seqHist = document.getElementById('cmp-history-' + idx); if (seqHist) { const synthMsg = document.createElement('div'); synthMsg.className = 'msg msg-ai'; synthMsg.innerHTML = '
Analysis
'; const synthBody = synthMsg.querySelector('.body'); let spinner = null; if (spinnerModule) { spinner = spinnerModule.create('Analyzing...', 'right'); synthBody.appendChild(spinner.createElement()); spinner.start(); } seqHist.appendChild(synthMsg); seqHist.scrollTop = seqHist.scrollHeight; const resultsText = data.results.map((r, ri) => `[${ri + 1}] ${r.title}\n${r.snippet || ''}\nURL: ${r.url}`).join('\n\n'); const synthPrompt = `Analyze these search results for the query "${message}". Summarize the key findings, note any consensus or conflicting information, and provide a brief synthesis.\n\nSearch Results:\n${resultsText}`; await _runSynthForPane(modelToUse, synthPrompt, synthBody, spinner, seqHist); } } } } // Reset opacity panes.forEach(p => { p.style.opacity = ''; }); } // Render results into each pane for (const { idx, data } of results) { const hist = document.getElementById('cmp-history-' + idx); if (!hist) continue; const aiMsg = hist.querySelector('.msg-ai:last-child'); if (!aiMsg) continue; const aiBody = aiMsg.querySelector('.body'); aiBody.innerHTML = ''; if (data.error) { aiBody.innerHTML = '
Error: ' + escapeHtml(data.error) + '
'; } else if (!data.results || data.results.length === 0) { aiBody.innerHTML = '
No results found
'; } else { aiBody.appendChild(_renderSearchResults(data)); } // Footer metrics const footer = document.createElement('div'); footer.className = 'msg-footer'; const span = document.createElement('span'); span.className = 'response-metrics'; const parts = []; if (data.results) parts.push(data.results.length + ' results'); if (data.time) parts.push(data.time + 's'); span.textContent = parts.join(' | '); footer.appendChild(span); aiMsg.appendChild(footer); hist.scrollTop = hist.scrollHeight; // Show reroll/copy buttons for search results const _paneEl = document.querySelector(`.compare-pane[data-pane="${idx}"]`); if (_paneEl) _paneEl.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = ''); } // ── Synthesis: send results to LLM for analysis (respects _parallel setting) ── if (state._searchSynthModels) { // Build list of synthesis tasks const synthTasks = []; for (let i = 0; i < results.length; i++) { const { idx, data } = results[i]; // Skip panes already synthesized in sequential mode if (_seqSynthDone.has(idx)) continue; if (data.error || !data.results || data.results.length === 0) continue; const modelToUse = state._searchSynthModels?.[idx] || null; if (!modelToUse) continue; const hist = document.getElementById('cmp-history-' + idx); if (!hist) continue; // Add synthesis message with spinner const synthMsg = document.createElement('div'); synthMsg.className = 'msg msg-ai'; synthMsg.innerHTML = '
Analysis
'; const synthBody = synthMsg.querySelector('.body'); let spinner = null; if (spinnerModule) { spinner = spinnerModule.create('Analyzing...', 'right'); synthBody.appendChild(spinner.createElement()); spinner.start(); } hist.appendChild(synthMsg); // Auto-scroll to show the Analysis message hist.scrollTop = hist.scrollHeight; // Build synthesis prompt const resultsText = data.results.map((r, ri) => `[${ri + 1}] ${r.title}\n${r.snippet || ''}\nURL: ${r.url}` ).join('\n\n'); const synthPrompt = `Analyze these search results for the query "${message}". Summarize the key findings, note any consensus or conflicting information, and provide a brief synthesis.\n\nSearch Results:\n${resultsText}`; synthTasks.push({ idx, modelToUse, synthBody, synthMsg, spinner, hist, synthPrompt }); } // Run synthesis streams (parallel or sequential based on _parallel flag) const runSynthesis = async (task) => _runSynthForPane(task.modelToUse, task.synthPrompt, task.synthBody, task.spinner, task.hist); if (state._parallel) { await Promise.all(synthTasks.map(runSynthesis)); } else { for (const task of synthTasks) { await runSynthesis(task); } } } buildVoteBar(n); } catch (err) { console.error('Search compare error:', err); if (uiModule) uiModule.showError('Search compare failed: ' + err.message); } finally { state._streaming = false; _setSendBtn('send'); } return; } // ── Chat / Image mode ── const isFollowUp = document.getElementById('cmp-history-0')?.querySelector('.msg-ai'); try { const n = state._selectedModels.length; if (isFollowUp) { const voteBar = document.getElementById('compare-vote-bar'); if (voteBar) { voteBar.innerHTML = ''; voteBar.classList.add('hidden'); } } // ── Add user + AI bubbles to each pane ── const aiElements = []; for (let i = 0; i < n; i++) { const hist = document.getElementById('cmp-history-' + i); if (!hist) { aiElements.push(null); continue; } const userMsg = document.createElement('div'); userMsg.className = 'msg msg-user'; userMsg.innerHTML = '
You
'; userMsg.querySelector('.body').textContent = message; hist.appendChild(userMsg); const aiMsg = document.createElement('div'); aiMsg.className = 'msg msg-ai'; aiMsg.innerHTML = '
AI
'; const aiBody = aiMsg.querySelector('.body'); if (spinnerModule) { // In sequential mode, only first pane says "Processing", rest say "Waiting" const label = (!state._parallel && i > 0) ? 'Waiting for Model ' + _slotChar(i - 1) + '...' : 'Processing...'; const spinner = spinnerModule.create(label, 'right'); aiBody.appendChild(spinner.createElement()); spinner.start(); aiMsg._spinner = spinner; } hist.appendChild(aiMsg); hist.scrollTop = hist.scrollHeight; aiElements.push(aiMsg); } // ── Auto-extend timeout ── const researchChk = document.getElementById('research-toggle'); const webChkT = document.getElementById('web-toggle'); const noTimeLimit = state._compareMode === 'research' || (researchChk && researchChk.checked); const needsLongTimeout = state._compareMode === 'agent' || (webChkT && webChkT.checked); const runTimeout = noTimeLimit ? 999999 : needsLongTimeout ? Math.max(state._timeout, 300) : state._timeout; // ── Pre-search if web toggle is on (share same results across all panes) ── let sharedSearchContext = null; let sharedSearchSources = null; const webChk = document.getElementById('web-toggle'); const toggleState = Storage.loadToggleState(); const isAgentMode = (toggleState.mode || 'chat') === 'agent'; const webOn = webChk && webChk.checked; // In agent mode, web_search is a tool (handled per-pane); in chat mode, pre-search and share if (webOn && !isAgentMode) { try { const fd = new FormData(); fd.append('query', message); const searchRes = await fetch(`${state.API_BASE}/api/search`, { method: 'POST', body: fd }); if (searchRes.ok) { const searchData = await searchRes.json(); if (searchData.context) sharedSearchContext = searchData.context; if (searchData.sources) sharedSearchSources = searchData.sources; } } catch (err) { console.warn('Compare pre-search failed, panes will search individually:', err); } } // ── Show vote bar immediately so user can vote anytime ── buildVoteBar(n); // ── Stream all panes (parallel or sequential based on _parallel flag) ── state._finishOrder = 0; state._paneElapsed = new Array(n).fill(null); state._paneMetrics = new Array(n).fill(null); state._abortControllers = new Array(n).fill(null); if (state._parallel) { // Run all panes at once await Promise.all(state._paneSessionIds.map((sid, i) => streamToPane(i, sid, message, aiElements[i], { searchContext: sharedSearchContext, timeout: runTimeout }) )); } else { // Run one pane at a time (sequential) — active pane full opacity, others dimmed const allPanes = document.querySelectorAll('.compare-pane'); allPanes.forEach(p => { p.style.transition = 'opacity 0.4s ease'; }); // Dim all except first allPanes.forEach((p, idx) => { p.style.opacity = idx === 0 ? '1' : '0.35'; }); for (let i = 0; i < state._paneSessionIds.length; i++) { // Update spinner if (aiElements[i] && aiElements[i]._spinner) { aiElements[i]._spinner.updateLabel('Processing...'); } await streamToPane(i, state._paneSessionIds[i], message, aiElements[i], { searchContext: sharedSearchContext, timeout: runTimeout }); // Swap opacity: dim current, brighten next if (allPanes[i]) allPanes[i].style.opacity = '0.35'; if (i + 1 < allPanes.length && allPanes[i + 1]) { allPanes[i + 1].style.opacity = '1'; } } // Restore all pane opacities when done allPanes.forEach(p => { p.style.opacity = ''; p.style.transition = ''; }); } // Re-focus main input for follow-up if (state._continueChat) { const ta = document.getElementById('message'); if (ta) ta.focus(); } } catch (err) { console.error('Compare error:', err); if (uiModule) uiModule.showError('Compare failed: ' + err.message); } finally { state._streaming = false; _setSendBtn('send'); // Re-enable header buttons document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => { b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = ''; }); } } // showModelSelector imported from ./selector.js // ──────────────────────────────────────────────────────────────────────────── // ── cleanupResults / removeOverlays ── // ──────────────────────────────────────────────────────────────────────────── /** * Build a markdown comparison of the current panes (prompt + each model's * response + metrics + grade) and copy it to the clipboard. Lets users save * or share a side-by-side at a glance. */ // Build the comparison markdown string. Shared by all export paths. function _buildComparisonMarkdown() { const grid = document.querySelector('.compare-grid'); if (!grid) return null; const panes = grid.querySelectorAll('.compare-pane'); if (!panes.length) return null; const prompt = state._lastPrompt || '(no prompt yet — run a comparison first)'; const expected = state._expectedAnswer || ''; const date = new Date().toISOString().slice(0, 19).replace('T', ' '); let md = '# Compare\n\n'; md += '**When:** ' + date + '\n'; md += '**Type:** ' + (state._compareMode || 'chat') + (state._blindMode ? ' (blind)' : '') + '\n'; md += '**Prompt:**\n\n```\n' + prompt + '\n```\n\n'; if (expected) md += '**Expected answer:** `' + expected + '`\n\n'; panes.forEach((pane, i) => { const m = state._selectedModels[i]; const name = m ? (m.name || m.model) + (m.endpointName ? ' (' + m.endpointName + ')' : '') : 'Model ' + (i + 1); const body = pane.querySelector('.compare-text-content, .msg-body, .body'); const text = body ? (body.innerText || body.textContent || '').trim() : ''; const metrics = state._paneMetrics[i]; const grade = pane.querySelector('.pane-grade-badge'); const gradeMark = grade ? (grade.classList.contains('pass') ? ' ✓' : ' ✗') : ''; md += '## ' + name + gradeMark + '\n\n'; if (metrics) { const bits = []; if (metrics.output_tokens != null) bits.push(metrics.output_tokens + ' tokens'); if (metrics.tokens_per_second != null) bits.push(metrics.tokens_per_second + ' tok/s'); if (metrics.response_time != null) bits.push(metrics.response_time + 's'); if (bits.length) md += '_' + bits.join(' · ') + '_\n\n'; } md += text ? text + '\n\n' : '_(no response)_\n\n'; md += '---\n\n'; }); return md; } let _exportMenuEl = null; function _toggleExportMenu(btn) { if (_exportMenuEl) { _closeExportMenu(); return; } const r = btn.getBoundingClientRect(); const m = document.createElement('div'); m.className = 'compare-export-menu'; m.style.cssText = 'position:fixed;z-index:10001;top:' + (r.bottom + 4) + 'px;left:' + r.left + 'px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;display:flex;flex-direction:column;min-width:170px;'; const opts = [ { label: 'Copy as Markdown', fn: () => _exportCopyMarkdown(btn) }, { label: 'Download .md', fn: () => _exportDownloadMarkdown() }, { label: 'Print / Save PDF', fn: () => _exportPrint() }, ]; for (const o of opts) { const item = document.createElement('button'); item.type = 'button'; item.textContent = o.label; item.style.cssText = 'background:none;border:none;color:var(--fg);text-align:left;padding:8px 12px;border-radius:6px;cursor:pointer;font:inherit;font-size:12px;'; item.addEventListener('mouseenter', () => { item.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; }); item.addEventListener('mouseleave', () => { item.style.background = 'none'; }); item.addEventListener('click', () => { _closeExportMenu(); o.fn(); }); m.appendChild(item); } document.body.appendChild(m); _exportMenuEl = m; setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0); } function _closeExportMenu() { if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; } } async function _exportCopyMarkdown(_btn) { const md = _buildComparisonMarkdown(); if (!md) return; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(md); } else { // Avoid the focus-stealing textarea fallback when the modern API // is available — that path briefly flashes the page as the // textarea is added/focused/removed. const ta = document.createElement('textarea'); ta.value = md; ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } try { window.uiModule?.showToast?.('Copied comparison to clipboard'); } catch {} } catch (e) { try { window.uiModule?.showToast?.('Copy failed'); } catch {} } } function _exportDownloadMarkdown() { const md = _buildComparisonMarkdown(); if (!md) return; const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); const blob = new Blob([md], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'compare-' + ts + '.md'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function _exportPrint() { const md = _buildComparisonMarkdown(); if (!md) return; // Render the markdown as a quick HTML view in a new window and trigger // the system print dialog — user can pick "Save as PDF" from there. const w = window.open('', '_blank'); if (!w) return; const escape = (s) => s.replace(/&/g, '&').replace(//g, '>'); const html = 'Compare export' + '
' + escape(md) + '
' + '