1// compare/index.js â orchestrator module (public API) 2/** 3 * Model A/B Comparison module. 4 * Builds its own multi-pane grid layout (up to 8 models). 5 * Sends same prompt to all models in parallel, lets user vote. 6 * 7 * Uses show/hide on the original container children instead of 8 * innerHTML replacement, so event listeners on the input bar, 9 * compare button, mode toggle, etc. are preserved. 10 */ 11 12// ââ Submodule imports ââ 13import state from './state.js'; 14import { EVAL_PROMPTS, WAVE_FRAMES, 15 ICON_DICE, ICON_EXPAND, ICON_COLLAPSE, ICON_CLOSE, 16 ICON_REROLL, ICON_COPY, ICON_PLAY, ICON_CODE, 17 ICON_PARALLEL, ICON_SEQUENTIAL, 18 EYE_OPEN, EYE_CLOSED, SAVE_ICON, CHAT_ICON, 19 SEND_SVG, VOTES_STORAGE_KEY, 20} from './icons.js'; 21import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js'; 22import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js'; 23import { _checkUnprobed, _clearProbeWaves } from './probe.js'; 24import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js'; 25import { 26 stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare, 27 _addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse, 28 _showModelSwapDropdown, _createAndAppendPane, _autoPreviewHtml, 29 registerPaneActions, 30} from './panes.js'; 31import { handleVote, buildVoteBar, addFinishBadge, spawnConfetti, _saveVote, registerCompareActions } from './vote.js'; 32import { showScoreboard } from './scoreboard.js'; 33
34// ââ External dependency imports ââ 35import Storage from '../storage.js'; 36import uiModule from '../ui.js'; 37import sessionModule from '../sessions.js'; 38import spinnerModule from '../spinner.js'; 39import themeModule from '../theme.js'; 40import presetsModule from '../presets.js'; 41import markdownModule from '../markdown.js'; 42 43var escapeHtml = uiModule.esc; 44 45/** Slot label: letters (A, B) in parallel, numbers (1, 2) in sequential */ 46function _slotChar(i) { return state._parallel ? String.fromCharCode(65 + i) : String(i + 1); } 47 48// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 49// ââ Toolbar indicator sync ââ 50// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 51// ââ init ââ 52// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 53 54function init(apiBase) { 55 state.API_BASE = apiBase; 56 // Clean up unsaved compare sessions on page close/refresh 57 window.addEventListener('beforeunload', () => { 58 if (!state._saveOnClose && state._paneSessionIds.length > 0) { 59 // sendBeacon uses POST â use the bulk delete endpoint 60 navigator.sendBeacon( 61 `${state.API_BASE}/api/sessions/bulk-delete`, 62 new Blob([JSON.stringify({ ids: state._paneSessionIds })], { type: 'application/json' }) 63 ); 64 } 65 }); 66} 67 68// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 69// ââ isCompareActive ââ 70// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 71 72function isCompareActive() { 73 return state.isActive; 74} 75 76// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 77// ââ closeCompare ââ 78// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 79 80/** Close compare mode (public API for toolbar indicator). */ 81function closeCompare() { 82 if (state.isActive) deactivate(true); 83} 84 85// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 86// ââ toggleMode ââ 87// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 88 89/** Toggle compare mode â shows model selector, then builds UI. */ 90async function toggleMode() { 91 if (state.isActive) { 92 deactivate(true); 93 return false; 94 } 95 if (state._openingSelector) return false; 96 97 state._openingSelector = true; 98 try { 99 const confirmed = await showModelSelector(); 100 if (!confirmed) return false; 101 102 state.isActive = true; 103 _syncToolbarIndicator(true); 104 await _buildCompareUI(); 105 return true; 106 } catch (err) { 107 console.error('Compare toggleMode error:', err); 108 return false; 109 } finally { 110 state._openingSelector = false; 111 } 112} 113 114// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 115// ââ deactivate ââ 116// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 117 118async function deactivate(teardown) { 119 // Abort any in-flight streams
120 state._abortControllers.forEach(ac => { if (ac) ac.abort(); }); 121 state._abortControllers = []; 122 123 // Move sessions to compare folder if saving 124 if (state._saveOnClose && state._paneSessionIds.length > 0) { 125 const modelShorts = _modelDisplayNames(state._selectedModels); 126 const folderName = 'Compare: ' + modelShorts.join(' vs '); 127 await Promise.all(state._paneSessionIds.map(sid => 128 fetch(`${state.API_BASE}/api/session/${sid}`, { 129 method: 'PATCH', body: new URLSearchParams({ folder: folderName }) 130 }).catch(() => {}) 131 )); 132 } 133 134 // Capture session IDs to delete before resetting state 135 const sessionIdsToDelete = (!state._saveOnClose && teardown && state._paneSessionIds.length > 0) 136 ? [...state._paneSessionIds] : []; 137 138 removeOverlays(); 139 state.isActive = false; 140 state._streaming = false; 141 state._paneSessionIds = []; 142 state._paneMetrics = []; 143 state._finishOrder = 0; 144 state._paneElapsed = []; 145 state._saveOnClose = false; 146 state._continueChat = false; 147 state._probed.clear(); 148 state._expectedAnswer = ''; 149 _syncToolbarIndicator(false); 150 151 // Restore main textarea placeholder 152 const msgTA = document.getElementById('message'); 153 if (msgTA) msgTA.placeholder = ''; 154 155 // Restore toolbar indicator display states and pointer events
156 Object.entries(state._savedIndicatorDisplay).forEach(([id, display]) => { 157 const el = document.getElementById(id); 158 if (el) { el.style.display = display; el.style.pointerEvents = ''; } 159 }); 160 state._savedIndicatorDisplay = {}; 161 162 // Unlock mode toggle 163 const _modeToggleR = document.querySelector('.mode-toggle'); 164 if (_modeToggleR) { _modeToggleR.style.pointerEvents = ''; _modeToggleR.style.opacity = ''; } 165 166 // Restore tool toggle pointer events 167 ['overflow-plus-btn', 'web-toggle-btn', 'bash-toggle-btn'].forEach(id => { 168 const el = document.getElementById(id); 169 if (el) el.style.pointerEvents = ''; 170 }); 171 172 // Restore agent/chat mode to what it was before compare 173 const _ts = Storage.loadToggleState(); 174 _ts.mode = state._savedMode; 175 Storage.saveToggleState(_ts); 176 const _ab2 = document.getElementById('mode-agent-btn'), _cb2 = document.getElementById('mode-chat-btn'); 177 if (_ab2 && _cb2) { _ab2.classList.toggle('active', state._savedMode === 'agent'); _cb2.classList.toggle('active', state._savedMode === 'chat'); } 178 document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = state._savedMode === 'agent' ? '' : 'none'; }); 179 180 // Delete unsaved sessions, then reload 181 if (teardown) { 182 if (sessionIdsToDelete.length > 0) { 183 // keepalive ensures requests complete even during page navigation 184 await Promise.all(sessionIdsToDelete.map(sid => 185 fetch(`${state.API_BASE}/api/session/${sid}`, { method: 'DELETE', keepalive: true }).catch(() => {}) 186 )); 187 } 188 location.href = location.pathname; 189 } 190} 191 192// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 193// ââ _buildCompareUI ââ 194// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 195 196/** Build the compare UI: sessions, header bar, grid of panes, vote bar, eval dropdown. */ 197async function _buildCompareUI() { 198 if (state._selectedModels.length < 1) { 199 if (uiModule) uiModule.showError('Select at least 1 model'); 200 return; 201 } 202 203 const n = state._selectedModels.length; 204 const modelShorts = _modelDisplayNames(state._selectedModels); 205 _persistSelections(); 206 207 // 1. Create sessions (skip for search mode â no LLM sessions needed) 208 if (state._compareMode !== 'search') { 209 const sessionIds = []; 210 for (let i = 0; i < n; i++) { 211 const m = state._selectedModels[i]; 212 const fd = new FormData(); 213 // Blind mode: name the session by its neutral slot so the sidebar / 214 // GET /api/sessions can't de-anonymize the comparison (issue #1285). 215 fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(i) : modelShorts[i])); 216 fd.append('endpoint_url', m.endpoint || ''); 217 fd.append('model', m.model || ''); 218 if (m.endpointId) { 219 fd.append('endpoint_id', m.endpointId); 220 fd.append('skip_validation', 'true'); 221 } 222 const res = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd }); 223 if (!res.ok) throw new Error('Failed to create session for ' + modelShorts[i]); 224 const data = await res.json(); 225 sessionIds.push(data.id); 226 } 227 state._paneSessionIds = sessionIds; 228 } else { 229 state._paneSessionIds = []; 230 } 231 state._paneMetrics = state._selectedModels.map(() => null); 232 state._abortControllers = state._selectedModels.map(() => null); 233 234 // 2. Auto-collapse sidebar if many panes 235 if (n > 3) { 236 const sidebar = document.getElementById('sidebar'); 237 if (sidebar && !sidebar.classList.contains('hidden')) { 238 sidebar.classList.add('hidden'); 239 state._sidebarWasHidden = true; 240 const iconRail = document.getElementById('icon-rail'); 241 if (iconRail) iconRail.classList.remove('rail-hidden'); 242 if (typeof window.syncRailSide === 'function') window.syncRailSide(); 243 } 244 } 245 246 // 3. Hide mobile new-chat button during compare 247 const _mobileNewBtn = document.getElementById('mobile-new-chat-btn'); 248 if (_mobileNewBtn) { 249 _mobileNewBtn.dataset.cmpWasDisplay = _mobileNewBtn.style.display; 250 _mobileNewBtn.style.display = 'none'; 251 } 252 253 // 4. Save toolbar indicator display states before hiding 254 const indicatorIds = ['overflow-tts-btn', 'overflow-attach-btn', 'overflow-rag-btn', 'overflow-research-btn', 'overflow-doc-btn', 'rag-indicator-btn', 'research-toggle-btn']; 255 state._savedIndicatorDisplay = {}; 256 indicatorIds.forEach(id => { 257 const el = document.getElementById(id); 258 if (el) state._savedIndicatorDisplay[id] = el.style.display; 259 }); 260 261 // 5. Save current mode and lock to the right one for this compare type 262 const _toggleState = Storage.loadToggleState(); 263 state._savedMode = _toggleState.mode || 'chat'; 264 const _targetMode = (state._compareMode === 'agent') ? 'agent' : 'chat'; 265 _toggleState.mode = _targetMode; 266 Storage.saveToggleState(_toggleState); 267 const _ab = document.getElementById('mode-agent-btn'), _cb = document.getElementById('mode-chat-btn'); 268 if (_ab && _cb) { 269 _ab.classList.toggle('active', _targetMode === 'agent'); 270 _cb.classList.toggle('active', _targetMode === 'chat'); 271 } 272 const _modeToggle = document.querySelector('.mode-toggle'); 273 if (_modeToggle) { _modeToggle.style.pointerEvents = 'none'; _modeToggle.style.opacity = '0.4'; } 274 275 // 6. Force tool toggles per compare mode 276 disableToolToggles(); 277 if (state._compareMode === 'search') { 278 const webChk = document.getElementById('web-toggle'); 279 if (webChk && !webChk.checked) { webChk.checked = true; webChk.dispatchEvent(new Event('change')); } 280 const webBtn = document.getElementById('web-toggle-btn'); 281 if (webBtn) webBtn.classList.add('active'); 282 } else if (state._compareMode === 'research') { 283 const resChk = document.getElementById('research-toggle'); 284 if (resChk && !resChk.checked) { resChk.checked = true; resChk.dispatchEvent(new Event('change')); } 285 const resBtn = document.getElementById('research-toggle-btn'); 286 if (resBtn) { resBtn.style.display = ''; resBtn.classList.add('active'); } 287 } 288 289 // 7. Hide existing chat container children (preserves event listeners) 290 const container = document.getElementById('chat-container'); 291 state._compareElements = [];
292 Array.from(container.children).forEach(child => { 293 if (child.style.display === 'none') return; 294 child.dataset.cmpHidden = '1'; 295 child.style.display = 'none'; 296 }); 297 container.classList.add('compare-active'); 298 299 // 8. Header bar 300 const cols = Math.min(n, 4); 301 const headerBar = document.createElement('div'); 302 headerBar.className = 'compare-header-bar'; 303 headerBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:6px 10px;flex-shrink:0;'; 304 const headerLabel = document.createElement('span'); 305 headerLabel.style.cssText = 'font-size:10px;font-weight:400;color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;'; 306 const _modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models'); 307 headerLabel.textContent = 'Comparing' + _modeLabel + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout'; 308 // Left side: the Compare tool icon (two side-by-side panes, matching the 309 // rail/sidebar icon) + the label. Other tool headers carry their icon; this 310 // one was missing it. 311 const headerLeft = document.createElement('div'); 312 headerLeft.style.cssText = 'display:flex;align-items:center;min-width:0;'; 313 const headerIcon = document.createElement('span'); 314 headerIcon.style.cssText = 'display:inline-flex;flex-shrink:0;margin-right:6px;opacity:0.85;'; 315 headerIcon.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>'; 316 headerLeft.appendChild(headerIcon); 317 headerLeft.appendChild(headerLabel); 318 headerBar.appendChild(headerLeft); 319 320 const headerActions = document.createElement('div'); 321 headerActions.style.cssText = 'display:flex;align-items:center;gap:2px;'; 322 323 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;'; 324 325 const checkBtn = document.createElement('button'); 326 checkBtn.id = 'compare-check-btn'; 327 checkBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg><span style="font-size:11px;margin-left:3px;">Probe</span>'; 328 checkBtn.title = 'Probe unverified models with a small test request'; 329 checkBtn.style.cssText = _btnCSS; 330 checkBtn.addEventListener('click', () => _checkUnprobed()); 331 headerActions.appendChild(checkBtn); 332 333 // Check button is dynamic: only visible when at least one selected model 334 // hasn't been probed yet. Show right after add/change, hide after success. 335 window._updateCheckBtnState = function() { 336 const btn = document.getElementById('compare-check-btn'); 337 if (!btn) return; 338 const hasUnprobed = state._selectedModels.some(m => !state._probed.has(m.model)); 339 btn.style.display = hasUnprobed ? '' : 'none'; 340 }; 341 342 // (Scoreboard button moved into the vote bar, next to Tie â see vote.js.) 343 344 const exportWrap = document.createElement('div'); 345 exportWrap.style.cssText = 'position:relative;display:inline-flex;'; 346 const exportBtn = document.createElement('button'); 347 exportBtn.id = 'compare-export-btn'; 348 exportBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span style="font-size:11px;margin-left:3px;">Export</span>'; 349 exportBtn.title = 'Export options'; 350 exportBtn.style.cssText = _btnCSS; 351 exportBtn.addEventListener('click', (e) => { 352 e.stopPropagation(); 353 _toggleExportMenu(exportBtn); 354 }); 355 exportWrap.appendChild(exportBtn); 356 headerActions.appendChild(exportWrap); 357 358 const shuffleBtn = document.createElement('button'); 359 shuffleBtn.id = 'compare-shuffle-btn'; 360 shuffleBtn.innerHTML = ICON_DICE + '<span style="font-size:11px;margin-left:3px;">Shuffle</span>'; 361 shuffleBtn.title = 'Shuffle pane positions'; 362 shuffleBtn.style.cssText = _btnCSS; 363 shuffleBtn.addEventListener('click', () => shufflePanePositions()); 364 headerActions.appendChild(shuffleBtn); 365 366 const addBtn = document.createElement('button'); 367 addBtn.id = 'compare-add-btn'; 368 addBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span style="font-size:11px;margin-left:3px;">Add</span>'; 369 addBtn.title = 'Add model pane'; 370 addBtn.style.cssText = _btnCSS; 371 addBtn.addEventListener('click', () => _addPane(addBtn)); 372 headerActions.appendChild(addBtn); 373 374 const closeBtn = document.createElement('button'); 375 closeBtn.className = 'compare-close-btn'; 376 closeBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'; 377 closeBtn.title = 'Close compare mode'; 378 // Match Export/Score/Shuffle/Model styling so the X sits flush with 379 // the rest of the toolbar instead of being a 24Ã24 bordered square. 380 closeBtn.style.cssText = _btnCSS; 381 closeBtn.addEventListener('click', () => deactivate(true)); 382 headerActions.appendChild(closeBtn); 383 384 // Move Export to the far left of the action cluster (per user preference). 385 headerActions.insertBefore(exportWrap, headerActions.firstChild); 386 387 headerBar.appendChild(headerActions); 388 container.appendChild(headerBar); 389 state._compareElements.push(headerBar); 390 391 // Initial visibility â hidden if all current models are already probed 392 window._updateCheckBtnState(); 393 394 // 9. Grid of panes 395 const grid = document.createElement('div'); 396 grid.className = 'compare-grid'; 397 grid.dataset.cols = String(cols); 398 for (let i = 0; i < n; i++) { 399 const label = state._blindMode ? 'Model ' + _slotChar(i) : modelShorts[i]; 400 const pane = document.createElement('div'); 401 pane.className = 'compare-pane'; 402 pane.dataset.pane = String(i); 403 pane.innerHTML = 404 '<div class="pane-header">' + 405 '<button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">▾</span></button>' + 406 '<span class="pane-timer" id="cmp-timer-' + i + '"></span>' + 407 '<span class="pane-finish-badge" id="cmp-badge-' + i + '"></span>' + 408 '<div class="pane-actions">' + 409 '<button class="pane-action-btn pane-stop-btn" data-action="stop" data-pane="' + i + '" title="Stop" style="display:none;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>' + 410 '<button class="pane-action-btn pane-preview-btn" data-action="preview" data-pane="' + i + '" id="cmp-preview-' + i + '" title="Run preview" style="display:none;">' + ICON_PLAY + '</button>' + 411 '<button class="pane-action-btn" data-action="reroll" data-pane="' + i + '" title="Re-roll">' + ICON_REROLL + '</button>' + 412 '<button class="pane-action-btn" data-action="copy" data-pane="' + i + '" title="Copy">' + ICON_COPY + '</button>' + 413 '<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' + 414 '<button class="pane-action-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane">
414' + ICON_CLOSE + '</button>' + 415 '</div>' + 416 '</div>' + 417 '<div class="chat-history" id="cmp-history-' + i + '"></div>' + 418 '<iframe class="compare-pane-iframe" id="cmp-iframe-' + i + '" sandbox="allow-scripts" style="display:none;"></iframe>' + 419 '<div class="pane-vote-footer">' + 420 '<button class="pane-vote-btn" data-pane="' + i + '" type="button" disabled style="opacity:0.4;">' + 421 '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;vertical-align:-2px;"><polyline points="20 6 9 17 4 12"/></svg>' + 422 '<span class="pane-vote-label">Vote ' + escapeHtml(label) + '</span>' + 423 '</button>' + 424 '</div>'; 425 grid.appendChild(pane); 426 } 427 grid.addEventListener('click', (e) => { 428 const voteBtn = e.target.closest('.pane-vote-btn'); 429 if (voteBtn) { 430 e.stopPropagation(); 431 if (voteBtn.disabled) return; 432 const idx = parseInt(voteBtn.dataset.pane); 433 handleVote(idx); 434 return; 435 } 436 const actionBtn = e.target.closest('.pane-action-btn'); 437 if (actionBtn) { 438 e.stopPropagation(); 439 const action = actionBtn.dataset.action; 440 const idx = parseInt(actionBtn.dataset.pane); 441 if (action === 'stop') stopPane(idx); 442 else if (action === 'copy') copyPaneResponse(idx); 443 else if (action === 'reroll') rerollPane(idx); 444 else if (action === 'expand') toggleExpandPane(idx, actionBtn); 445 else if (action === 'preview') togglePanePreview(idx); 446 else if (action === 'close') _removePane(idx); 447 return; 448 } 449 const titleBtn = e.target.closest('.pane-title-btn'); 450 if (titleBtn) { 451 e.stopPropagation(); 452 const idx = parseInt(titleBtn.dataset.pane); 453 _showModelSwapDropdown(idx, titleBtn); 454 } 455 }); 456 container.appendChild(grid); 457 state._compareElements.push(grid); 458 459 // 10. Vote bar placeholder 460 const voteBar = document.createElement('div'); 461 voteBar.id = 'compare-vote-bar'; 462 voteBar.className = 'compare-vote-bar'; 463 container.appendChild(voteBar); 464 state._compareElements.push(voteBar); 465 buildVoteBar(n); 466 467 if (state._blindMode && n > 1) shufflePanePositions(); 468 469 // 11. Move chat input bar to the bottom of the container 470 const inputBar = document.querySelector('.chat-input-bar'); 471 if (inputBar) { 472 inputBar.style.display = ''; 473 if (inputBar.dataset.cmpHidden) delete inputBar.dataset.cmpHidden; 474 container.appendChild(inputBar); 475 } 476 const msgTA = document.getElementById('message'); 477 if (msgTA) { 478 msgTA.placeholder = 'Enter prompt for all models...'; 479 requestAnimationFrame(() => msgTA.focus()); 480 } 481 482 // Eval-prompts picker â sits inside the message box at top-right (where 483 // model-picker normally lives). Model-picker is irrelevant during compare, 484 // so hide it and restore on deactivate via the wrap's _cleanup. 485 _setupEvalPicker(); 486 487 // 12. Hide tool buttons that don't apply during compare 488 ['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 => { 489 const el = document.getElementById(id); 490 if (el) { el.style.display = 'none'; el.style.pointerEvents = 'none'; } 491 }); 492 if (state._compareMode !== 'research') { 493 const resBtn = document.getElementById('research-toggle-btn'); 494 if (resBtn) { resBtn.style.display = 'none'; resBtn.style.pointerEvents = 'none'; } 495 } 496 document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = 'none'; }); 497 498 _setSendBtn('send'); 499} 500 501// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 502// ââ _setSendBtn ââ 503// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 504 505function _setSendBtn(mode) { 506 const btn = document.querySelector('.send-btn'); 507 if (!btn) return; 508 if (mode === 'stop') { 509 btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>'; 510 btn.title = 'Stop all models'; 511 btn.dataset.mode = 'streaming'; 512 btn.classList.remove('mic-mode', 'newchat-mode'); 513 } else { 514 btn.dataset.mode = ''; 515 btn.innerHTML = SEND_SVG; 516 btn.style.color = ''; 517 btn.title = 'Send to all models'; 518 btn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded'); 519 } 520} 521 522// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 523// ââ handleCompareSubmit ââ 524// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 525 526/** 527 * Handle submit from the main chat input while compare is active. 528 * Called by app.js submit guard. 529 */ 530function handleCompareSubmit(e) { 531 // If streaming, act as stop button 532 if (state._streaming) { 533 stopAll(); 534 return; 535 } 536 const input = document.getElementById('message'); 537 const message = input ? input.value.trim() : ''; 538 if (!message) return; 539 input.value = ''; 540 // Reset textarea height 541 input.style.height = ''; 542 // Notify input listeners (eval-picker visibility, autosize, etc.) that the 543 // textarea is empty again â programmatic clears don't fire `input` natively. 544 input.dispatchEvent(new Event('input', { bubbles: true })); 545 // Mobile: dismiss the on-screen keyboard after the prompt is sent so the 546 // user sees the streaming output instead of the typing area. A plain blur() 547 // is often ignored on Firefox mobile, so toggle readonly around it (and blur 548 // the active element too) to reliably collapse the keyboard. 549 // Mobile keyboard dismiss â use the SAME proven logic as the main chat send 550 // (chat.js handleChatSubmit). Compare returns early in that flow, so it never 551 // reached this code; replicating it here is what actually works on Firefox 552 // mobile (readonly + blur, then drop readonly only once the blur is confirmed 553 // or the user taps to type again â avoids the keyboard bouncing back up). 554 if (window.innerWidth <= 768) { 555 try { 556 input.setAttribute('readonly', 'readonly'); 557 input.blur(); 558 // Setting readonly on an ALREADY-FOCUSED textarea doesn't dismiss the 559 // keyboard on Firefox, and blur() is often ignored â so the readonly-only 560 // approach works only when the input happened not to be focused at send 561 // time (inconsistent between 1st/2nd prompt). Deterministically pull focus
562 // off the textarea by focusing a throwaway readonly input, then drop it. 563 const tmp = document.createElement('input'); 564 tmp.setAttribute('readonly', 'readonly'); 565 tmp.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;border:0;padding:0;'; 566 document.body.appendChild(tmp); 567 tmp.focus(); 568 setTimeout(() => { try { tmp.blur(); tmp.remove(); } catch {} }, 50); 569 const _dropReadonly = () => { try { input.removeAttribute('readonly'); } catch {} }; 570 setTimeout(() => { 571 if (document.activeElement === input) { 572 input.addEventListener('pointerdown', _dropReadonly, { once: true }); 573 input.addEventListener('focus', _dropReadonly, { once: true }); 574 } else { 575 _dropReadonly(); 576 } 577 }, 120); 578 } catch {} 579 } 580 _executeCompare(message); 581} 582 583// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 584// ââ _executeCompare ââ 585// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 586 587/** 588 * Send prompt to all panes, stream responses. 589 * Works for both first and follow-up messages. 590 */ 591async function _executeCompare(message) { 592 if (state._streaming) return; 593 if (state._selectedModels.length < 1) return; 594 595 // New round â allow voting again and clear the previous round's win/lose/tie 596 // styling (pane highlight + the Winner!/= title decorations), otherwise the 597 // old result stays stuck on the panes through the next prompt. 598 state._voted = false; 599 for (let i = 0; i < state._selectedModels.length; i++) { 600 const pane = document.querySelector('.compare-pane[data-pane="' + i + '"]'); 601 if (pane) { 602 pane.classList.remove('winner', 'loser'); 603 // Clear the previous round's Failed/Timeout badge and the eval â/â grade. 604 pane.querySelector('.pane-grade-badge')?.remove(); 605 } 606 const fb = document.getElementById('cmp-badge-' + i); 607 if (fb) { fb.textContent = ''; fb.style.color = ''; } 608 const titleEl = document.getElementById('cmp-title-' + i); 609 if (titleEl) { 610 const label = state._blindMode 611 ? 'Model ' + _slotChar(i) 612 : ((state._selectedModels[i] && state._selectedModels[i].name) || 'Model ' + _slotChar(i)); 613 titleEl.innerHTML = escapeHtml(label) + ' <span class="pane-title-caret">▾</span>'; 614 } 615 } 616 617 state._streaming = true; 618 state._lastPrompt = message; 619 _setSendBtn('stop'); 620 // Disable header buttons during streaming 621 document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => { 622 b.disabled = true; b.style.opacity = '0.25'; b.style.pointerEvents = 'none'; 623 }); 624 625 // ââ Search mode: direct API calls, no SSE streaming ââ 626 if (state._compareMode === 'search') { 627 try { 628 const n = state._selectedModels.length; 629 630 // Clear previous vote buttons on follow-up 631 const voteBar = document.getElementById('compare-vote-bar'); 632 if (voteBar) voteBar.innerHTML = ''; 633 634 // Add user query + spinner to each pane 635 for (let i = 0; i < n; i++) { 636 const hist = document.getElementById('cmp-history-' + i); 637 if (!hist) continue; 638 const userMsg = document.createElement('div'); 639 userMsg.className = 'msg msg-user'; 640 userMsg.innerHTML = '<div class="role">You</div><div class="body"></div>'; 641 userMsg.querySelector('.body').textContent = message; 642 hist.appendChild(userMsg); 643 644 const aiMsg = document.createElement('div'); 645 aiMsg.className = 'msg msg-ai'; 646 aiMsg.innerHTML = '<div class="role">Search</div><div class="body"></div>'; 647 const aiBody = aiMsg.querySelector('.body'); 648 if (spinnerModule) { 649 const spinner = spinnerModule.create('Searching...', 'right'); 650 aiBody.appendChild(spinner.createElement()); 651 spinner.start(); 652 } 653 hist.appendChild(aiMsg); 654 hist.scrollTop = hist.scrollHeight; 655 } 656 657 // Fire searches â parallel or sequential based on _parallel setting 658 const t0 = performance.now(); 659 state._abortControllers = state._selectedModels.map(() => new AbortController()); 660 661 async function _searchOne(m, i) { 662 const fd = new FormData(); 663 fd.append('query', message); 664 fd.append('provider', m.model); 665 fd.append('count', '10'); 666 try { 667 const res = await fetch(`${state.API_BASE}/api/search/query`, { method: 'POST', body: fd, signal: state._abortControllers[i].signal }); 668 const data = await res.json(); 669 return { idx: i, data }; 670 } catch (err) { 671 return { idx: i, data: { results: [], error: err.name === 'AbortError' ? 'Stopped' : err.message } }; 672 } 673 } 674 675 let results; 676 const _seqSynthDone = new Set(); 677 if (state._parallel) { 678 results = await Promise.all(state._selectedModels.map((m, i) => _searchOne(m, i))); 679 } else { 680 // Sequential â run one at a time, dim waiting panes 681 results = []; 682 const panes = document.querySelectorAll('.compare-pane');
683 panes.forEach((p, i) => { if (i > 0) p.style.opacity = '0.4'; }); 684 for (let i = 0; i < state._selectedModels.length; i++) { 685 const pane = panes[i]; 686 if (pane) pane.style.opacity = '1'; 687 results.push(await _searchOne(state._selectedModels[i], i)); 688 // Render this result immediately 689 const { idx, data } = results[results.length - 1]; 690 const hist = document.getElementById('cmp-history-' + idx); 691 if (hist) { 692 const aiMsg = hist.querySelector('.msg-ai:last-child'); 693 if (aiMsg) { 694 const aiBody = aiMsg.querySelector('.body'); 695 aiBody.innerHTML = ''; 696 if (data.error) { 697 aiBody.innerHTML = '<div style="color:var(--color-error);font-size:0.85em;">Error: ' + escapeHtml(data.error) + '</div>'; 698 } else if (!data.results || data.results.length === 0) { 699 aiBody.innerHTML = '<div style="color:color-mix(in srgb, var(--fg) 50%, transparent);font-size:0.85em;font-style:italic;">No results found</div>'; 700 } else { 701 aiBody.appendChild(_renderSearchResults(data)); 702 } 703 const footer = document.createElement('div'); footer.className = 'msg-footer'; 704 const span = document.createElement('span'); span.className = 'response-metrics'; 705 const parts = []; 706 if (data.results) parts.push(data.results.length + ' results'); 707 if (data.time) parts.push(data.time + 's'); 708 span.textContent = parts.join(' | '); footer.appendChild(span); aiMsg.appendChild(footer); 709 hist.scrollTop = hist.scrollHeight; 710 const _pe = document.querySelector(`.compare-pane[data-pane="${idx}"]`); 711 if (_pe) _pe.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = ''); 712 } 713 } 714 // Sequential: run synthesis for this pane immediately before moving to next 715 _seqSynthDone.add(idx); 716 if (!data.error && data.results && data.results.length > 0) { 717 const modelToUse = state._searchSynthModels?.[idx] || null; 718 if (modelToUse) { 719 const seqHist = document.getElementById('cmp-history-' + idx); 720 if (seqHist) { 721 const synthMsg = document.createElement('div'); 722 synthMsg.className = 'msg msg-ai'; 723 synthMsg.innerHTML = '<div class="role">Analysis</div><div class="body"></div>'; 724 const synthBody = synthMsg.querySelector('.body'); 725 let spinner = null; 726 if (spinnerModule) { spinner = spinnerModule.create('Analyzing...', 'right'); synthBody.appendChild(spinner.createElement()); spinner.start(); } 727 seqHist.appendChild(synthMsg); 728 seqHist.scrollTop = seqHist.scrollHeight; 729 const resultsText = data.results.map((r, ri) => `[${ri + 1}] ${r.title}\n${r.snippet || ''}\nURL: ${r.url}`).join('\n\n'); 730 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}`; 731 await _runSynthForPane(modelToUse, synthPrompt, synthBody, spinner, seqHist); 732 } 733 } 734 } 735 } 736 // Reset opacity 737 panes.forEach(p => { p.style.opacity = ''; }); 738 } 739 // Render results into each pane 740 for (const { idx, data } of results) { 741 const hist = document.getElementById('cmp-history-' + idx); 742 if (!hist) continue; 743 const aiMsg = hist.querySelector('.msg-ai:last-child'); 744 if (!aiMsg) continue; 745 const aiBody = aiMsg.querySelector('.body'); 746 aiBody.innerHTML = ''; 747 748 if (data.error) { 749 aiBody.innerHTML = '<div style="color:var(--color-error);font-size:0.85em;">Error: ' + escapeHtml(data.error) + '</div>'; 750 } else if (!data.results || data.results.length === 0) { 751 aiBody.innerHTML = '<div style="color:color-mix(in srgb, var(--fg) 50%, transparent);font-size:0.85em;font-style:italic;">No results found</div>'; 752 } else {
753 aiBody.appendChild(_renderSearchResults(data)); 754 } 755 756 // Footer metrics 757 const footer = document.createElement('div'); 758 footer.className = 'msg-footer'; 759 const span = document.createElement('span'); 760 span.className = 'response-metrics'; 761 const parts = []; 762 if (data.results) parts.push(data.results.length + ' results'); 763 if (data.time) parts.push(data.time + 's'); 764 span.textContent = parts.join(' | '); 765 footer.appendChild(span); 766 aiMsg.appendChild(footer); 767 768 hist.scrollTop = hist.scrollHeight; 769 // Show reroll/copy buttons for search results 770 const _paneEl = document.querySelector(`.compare-pane[data-pane="${idx}"]`); 771 if (_paneEl) _paneEl.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = ''); 772 } 773 774 // ââ Synthesis: send results to LLM for analysis (respects _parallel setting) ââ 775 if (state._searchSynthModels) { 776 // Build list of synthesis tasks 777 const synthTasks = []; 778 for (let i = 0; i < results.length; i++) { 779 const { idx, data } = results[i]; 780 // Skip panes already synthesized in sequential mode 781 if (_seqSynthDone.has(idx)) continue; 782 if (data.error || !data.results || data.results.length === 0) continue; 783 784 const modelToUse = state._searchSynthModels?.[idx] || null; 785 if (!modelToUse) continue; 786 787 const hist = document.getElementById('cmp-history-' + idx); 788 if (!hist) continue; 789 790 // Add synthesis message with spinner 791 const synthMsg = document.createElement('div'); 792 synthMsg.className = 'msg msg-ai'; 793 synthMsg.innerHTML = '<div class="role">Analysis</div><div class="body"></div>'; 794 const synthBody = synthMsg.querySelector('.body'); 795 let spinner = null; 796 if (spinnerModule) { 797 spinner = spinnerModule.create('Analyzing...', 'right'); 798 synthBody.appendChild(spinner.createElement()); 799 spinner.start(); 800 } 801 hist.appendChild(synthMsg); 802 // Auto-scroll to show the Analysis message 803 hist.scrollTop = hist.scrollHeight; 804 805 // Build synthesis prompt 806 const resultsText = data.results.map((r, ri) => 807 `[${ri + 1}] ${r.title}\n${r.snippet || ''}\nURL: ${r.url}` 808 ).join('\n\n'); 809 810 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}`; 811 812 synthTasks.push({ idx, modelToUse, synthBody, synthMsg, spinner, hist, synthPrompt }); 813 } 814 815 // Run synthesis streams (parallel or sequential based on _parallel flag) 816 const runSynthesis = async (task) => _runSynthForPane(task.modelToUse, task.synthPrompt, task.synthBody, task.spinner, task.hist); 817 818 if (state._parallel) { 819 await Promise.all(synthTasks.map(runSynthesis)); 820 } else { 821 for (const task of synthTasks) { 822 await runSynthesis(task); 823 } 824 } 825 } 826 827 buildVoteBar(n); 828 } catch (err) { 829 console.error('Search compare error:', err); 830 if (uiModule) uiModule.showError('Search compare failed: ' + err.message); 831 } finally { 832 state._streaming = false; 833 _setSendBtn('send'); 834 } 835 return; 836 } 837 838 // ââ Chat / Image mode ââ 839 const isFollowUp = document.getElementById('cmp-history-0')?.querySelector('.msg-ai'); 840 841 try { 842 const n = state._selectedModels.length; 843 844 if (isFollowUp) { 845 const voteBar = document.getElementById('compare-vote-bar'); 846 if (voteBar) { 847 voteBar.innerHTML = ''; 848 voteBar.classList.add('hidden'); 849 } 850 } 851 852 // ââ Add user + AI bubbles to each pane ââ 853 const aiElements = []; 854 for (let i = 0; i < n; i++) { 855 const hist = document.getElementById('cmp-history-' + i); 856 if (!hist) { aiElements.push(null); continue; } 857 858 const userMsg = document.createElement('div'); 859 userMsg.className = 'msg msg-user'; 860 userMsg.innerHTML = '<div class="role">You</div><div class="body"></div>'; 861 userMsg.querySelector('.body').textContent = message; 862 hist.appendChild(userMsg); 863 864 const aiMsg = document.createElement('div'); 865 aiMsg.className = 'msg msg-ai'; 866 aiMsg.innerHTML = '<div class="role">AI</div><div class="body"></div>'; 867 const aiBody = aiMsg.querySelector('.body'); 868 if (spinnerModule) { 869 // In sequential mode, only first pane says "Processing", rest say "Waiting" 870 const label = (!state._parallel && i > 0) 871 ? 'Waiting for Model ' + _slotChar(i - 1) + '...' 872 : 'Processing...'; 873 const spinner = spinnerModule.create(label, 'right'); 874 aiBody.appendChild(spinner.createElement()); 875 spinner.start(); 876 aiMsg._spinner = spinner; 877 } 878 hist.appendChild(aiMsg); 879 hist.scrollTop = hist.scrollHeight; 880 aiElements.push(aiMsg); 881 } 882 883 // ââ Auto-extend timeout ââ 884 const researchChk = document.getElementById('research-toggle'); 885 const webChkT = document.getElementById('web-toggle'); 886 const noTimeLimit = state._compareMode === 'research' || (researchChk && researchChk.checked); 887 const needsLongTimeout = state._compareMode === 'agent' || (webChkT && webChkT.checked); 888 const runTimeout = noTimeLimit ? 999999 : needsLongTimeout ? Math.max(state._timeout, 300) : state._timeout; 889 890 // ââ Pre-search if web toggle is on (share same results across all panes) ââ 891 let sharedSearchContext = null; 892 let sharedSearchSources = null; 893 const webChk = document.getElementById('web-toggle'); 894 const toggleState = Storage.loadToggleState(); 895 const isAgentMode = (toggleState.mode || 'chat') === 'agent'; 896 const webOn = webChk && webChk.checked; 897 // In agent mode, web_search is a tool (handled per-pane); in chat mode, pre-search and share 898 if (webOn && !isAgentMode) {
899 try { 900 const fd = new FormData(); 901 fd.append('query', message); 902 const searchRes = await fetch(`${state.API_BASE}/api/search`, { method: 'POST', body: fd }); 903 if (searchRes.ok) { 904 const searchData = await searchRes.json(); 905 if (searchData.context) sharedSearchContext = searchData.context; 906 if (searchData.sources) sharedSearchSources = searchData.sources; 907 } 908 } catch (err) { 909 console.warn('Compare pre-search failed, panes will search individually:', err); 910 } 911 } 912 913 // ââ Show vote bar immediately so user can vote anytime ââ 914 buildVoteBar(n); 915 916 // ââ Stream all panes (parallel or sequential based on _parallel flag) ââ 917 state._finishOrder = 0; 918 state._paneElapsed = new Array(n).fill(null); 919 state._paneMetrics = new Array(n).fill(null); 920 state._abortControllers = new Array(n).fill(null); 921 922 if (state._parallel) { 923 // Run all panes at once 924 await Promise.all(state._paneSessionIds.map((sid, i) => 925 streamToPane(i, sid, message, aiElements[i], { searchContext: sharedSearchContext, timeout: runTimeout }) 926 )); 927 } else { 928 // Run one pane at a time (sequential) â active pane full opacity, others dimmed 929 const allPanes = document.querySelectorAll('.compare-pane'); 930 allPanes.forEach(p => { p.style.transition = 'opacity 0.4s ease'; }); 931 // Dim all except first 932 allPanes.forEach((p, idx) => { p.style.opacity = idx === 0 ? '1' : '0.35'; }); 933 934 for (let i = 0; i < state._paneSessionIds.length; i++) { 935 // Update spinner 936 if (aiElements[i] && aiElements[i]._spinner) { 937 aiElements[i]._spinner.updateLabel('Processing...'); 938 } 939 940 await streamToPane(i, state._paneSessionIds[i], message, aiElements[i], { searchContext: sharedSearchContext, timeout: runTimeout }); 941 942 // Swap opacity: dim current, brighten next 943 if (allPanes[i]) allPanes[i].style.opacity = '0.35'; 944 if (i + 1 < allPanes.length && allPanes[i + 1]) { 945 allPanes[i + 1].style.opacity = '1'; 946 } 947 } 948 949 // Restore all pane opacities when done 950 allPanes.forEach(p => { p.style.opacity = ''; p.style.transition = ''; }); 951 } 952 953 // Re-focus main input for follow-up 954 if (state._continueChat) { 955 const ta = document.getElementById('message'); 956 if (ta) ta.focus(); 957 } 958 959 } catch (err) { 960 console.error('Compare error:', err); 961 if (uiModule) uiModule.showError('Compare failed: ' + err.message); 962 } finally { 963 state._streaming = false; 964 _setSendBtn('send'); 965 // Re-enable header buttons 966 document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => { 967 b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = ''; 968 }); 969 } 970} 971 972// showModelSelector imported from ./selector.js 973 974// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 975// ââ cleanupResults / removeOverlays ââ 976// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 977 978/** 979 * Build a markdown comparison of the current panes (prompt + each model's 980 * response + metrics + grade) and copy it to the clipboard. Lets users save 981 * or share a side-by-side at a glance. 982 */ 983// Build the comparison markdown string. Shared by all export paths. 984function _buildComparisonMarkdown() { 985 const grid = document.querySelector('.compare-grid'); 986 if (!grid) return null; 987 const panes = grid.querySelectorAll('.compare-pane'); 988 if (!panes.length) return null; 989 const prompt = state._lastPrompt || '(no prompt yet â run a comparison first)'; 990 const expected = state._expectedAnswer || ''; 991 const date = new Date().toISOString().slice(0, 19).replace('T', ' '); 992 let md = '# Compare\n\n'; 993 md += '**When:** ' + date + '\n'; 994 md += '**Type:** ' + (state._compareMode || 'chat') + (state._blindMode ? ' (blind)' : '') + '\n'; 995 md += '**Prompt:**\n\n```\n' + prompt + '\n```\n\n'; 996 if (expected) md += '**Expected answer:** `' + expected + '`\n\n';
997 panes.forEach((pane, i) => { 998 const m = state._selectedModels[i]; 999 const name = m ? (m.name || m.model) + (m.endpointName ? ' (' + m.endpointName + ')' : '') : 'Model ' + (i + 1); 1000 const body = pane.querySelector('.compare-text-content, .msg-body, .body'); 1001 const text = body ? (body.innerText || body.textContent || '').trim() : ''; 1002 const metrics = state._paneMetrics[i]; 1003 const grade = pane.querySelector('.pane-grade-badge'); 1004 const gradeMark = grade ? (grade.classList.contains('pass') ? ' â' : ' â') : ''; 1005 md += '## ' + name + gradeMark + '\n\n'; 1006 if (metrics) { 1007 const bits = []; 1008 if (metrics.output_tokens != null) bits.push(metrics.output_tokens + ' tokens'); 1009 if (metrics.tokens_per_second != null) bits.push(metrics.tokens_per_second + ' tok/s'); 1010 if (metrics.response_time != null) bits.push(metrics.response_time + 's'); 1011 if (bits.length) md += '_' + bits.join(' · ') + '_\n\n'; 1012 } 1013 md += text ? text + '\n\n' : '_(no response)_\n\n'; 1014 md += '---\n\n'; 1015 }); 1016 return md; 1017} 1018 1019let _exportMenuEl = null; 1020function _toggleExportMenu(btn) { 1021 if (_exportMenuEl) { _closeExportMenu(); return; } 1022 const r = btn.getBoundingClientRect(); 1023 const m = document.createElement('div'); 1024 m.className = 'compare-export-menu'; 1025 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;'; 1026 const opts = [ 1027 { label: 'Copy as Markdown', fn: () => _exportCopyMarkdown(btn) }, 1028 { label: 'Download .md', fn: () => _exportDownloadMarkdown() }, 1029 { label: 'Print / Save PDF', fn: () => _exportPrint() }, 1030 ]; 1031 for (const o of opts) { 1032 const item = document.createElement('button'); 1033 item.type = 'button'; 1034 item.textContent = o.label; 1035 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;'; 1036 item.addEventListener('mouseenter', () => { item.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; }); 1037 item.addEventListener('mouseleave', () => { item.style.background = 'none'; }); 1038 item.addEventListener('click', () => { _closeExportMenu(); o.fn(); }); 1039 m.appendChild(item); 1040 } 1041 document.body.appendChild(m); 1042 _exportMenuEl = m; 1043 setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0); 1044} 1045function _closeExportMenu() { 1046 if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; } 1047} 1048 1049async function _exportCopyMarkdown(_btn) { 1050 const md = _buildComparisonMarkdown(); 1051 if (!md) return; 1052 try { 1053 if (navigator.clipboard && navigator.clipboard.writeText) { 1054 await navigator.clipboard.writeText(md); 1055 } else { 1056 // Avoid the focus-stealing textarea fallback when the modern API 1057 // is available â that path briefly flashes the page as the 1058 // textarea is added/focused/removed. 1059 const ta = document.createElement('textarea'); 1060 ta.value = md; 1061 ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;'; 1062 document.body.appendChild(ta); 1063 ta.select(); document.execCommand('copy'); ta.remove(); 1064 } 1065 try { window.uiModule?.showToast?.('Copied comparison to clipboard'); } catch {} 1066 } catch (e) { 1067 try { window.uiModule?.showToast?.('Copy failed'); } catch {} 1068 } 1069} 1070 1071function _exportDownloadMarkdown() { 1072 const md = _buildComparisonMarkdown(); 1073 if (!md) return; 1074 const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); 1075 const blob = new Blob([md], { type: 'text/markdown' }); 1076 const url = URL.createObjectURL(blob); 1077 const a = document.createElement('a'); 1078 a.href = url; 1079 a.download = 'compare-' + ts + '.md'; 1080 document.body.appendChild(a); 1081 a.click(); 1082 a.remove(); 1083 setTimeout(() => URL.revokeObjectURL(url), 1000); 1084} 1085 1086function _exportPrint() { 1087 const md = _buildComparisonMarkdown(); 1088 if (!md) return; 1089 // Render the markdown as a quick HTML view in a new window and trigger 1090 // the system print dialog â user can pick "Save as PDF" from there. 1091 const w = window.open('', '_blank'); 1092 if (!w) return; 1093 try { w.opener = null; } catch (_) {} 1094 const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 1095 const html = '<!doctype html><meta charset="utf-8"><title>Compare export</title>' + 1096 '<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:780px;margin:32px auto;padding:0 24px;line-height:1.55;color:#222}' + 1097 'pre{background:#f5f5f5;border-radius:6px;padding:10px;white-space:pre-wrap}' + 1098 'h1{margin-top:0}h2{border-bottom:1px solid #ddd;padding-bottom:4px;margin-top:32px}' + 1099 'hr{border:none;border-top:1px solid #ccc;margin:24px 0}' + 1100 '</style><body><pre style="background:none;padding:0">' + escape(md) + '</pre>' + 1101 '<script>window.onload=()=>setTimeout(()=>window.print(),100)<\/script>'; 1102 w.document.write(html); 1103 w.document.close(); 1104} 1105 1106async function _exportComparison(btn) { 1107 const grid = document.querySelector('.compare-grid'); 1108 if (!grid) return; 1109 const panes = grid.querySelectorAll('.compare-pane'); 1110 if (!panes.length) return; 1111 1112 const prompt = state._lastPrompt || '(no prompt yet â run a comparison first)'; 1113 const expected = state._expectedAnswer || ''; 1114 const date = new Date().toISOString().slice(0, 19).replace('T', ' '); 1115 1116 let md = '# Compare\n\n'; 1117 md += '**When:** ' + date + '\n'; 1118 md += '**Type:** ' + (state._compareMode || 'chat') + (state._blindMode ? ' (blind)' : '') + '\n'; 1119 md += '**Prompt:**\n\n```\n' + prompt + '\n```\n\n'; 1120 if (expected) md += '**Expected answer:** `' + expected + '`\n\n'; 1121
1122 panes.forEach((pane, i) => { 1123 const m = state._selectedModels[i]; 1124 const name = m ? (m.name || m.model) + (m.endpointName ? ' (' + m.endpointName + ')' : '') : 'Model ' + (i + 1); 1125 const body = pane.querySelector('.compare-text-content, .msg-body, .body'); 1126 const text = body ? (body.innerText || body.textContent || '').trim() : ''; 1127 const metrics = state._paneMetrics[i]; 1128 const grade = pane.querySelector('.pane-grade-badge'); 1129 const gradeMark = grade ? (grade.classList.contains('pass') ? ' â' : ' â') : ''; 1130 1131 md += '## ' + name + gradeMark + '\n\n'; 1132 if (metrics) { 1133 const bits = []; 1134 if (metrics.output_tokens != null) bits.push(metrics.output_tokens + ' tokens'); 1135 if (metrics.tokens_per_second != null) bits.push(metrics.tokens_per_second + ' tok/s'); 1136 if (metrics.response_time != null) bits.push(metrics.response_time + 's'); 1137 if (bits.length) md += '_' + bits.join(' · ') + '_\n\n'; 1138 } 1139 md += text ? text + '\n\n' : '_(no response)_\n\n'; 1140 md += '---\n\n'; 1141 }); 1142 1143 // Copy to clipboard 1144 const origLabel = btn ? btn.innerHTML : ''; 1145 try { 1146 if (navigator.clipboard && navigator.clipboard.writeText) { 1147 await navigator.clipboard.writeText(md); 1148 } else { 1149 const ta = document.createElement('textarea'); 1150 ta.value = md; document.body.appendChild(ta); 1151 ta.select(); document.execCommand('copy'); ta.remove(); 1152 } 1153 if (btn) { 1154 btn.innerHTML = '<span style="font-size:11px;">Copied!</span>'; 1155 setTimeout(() => { btn.innerHTML = origLabel; }, 1500); 1156 } 1157 } catch (e) { 1158 if (btn) { 1159 btn.innerHTML = '<span style="font-size:11px;color:var(--color-error);">Failed</span>'; 1160 setTimeout(() => { btn.innerHTML = origLabel; }, 2000); 1161 } 1162 } 1163} 1164 1165/** 1166 * Build the eval-prompts picker â shown only during compare. Mirrors the 1167 * absolute-positioned model-picker location (top-right of .chat-input-top) 1168 * and is auto-cleaned up by the standard _compareElements teardown. 1169 */ 1170function _setupEvalPicker() { 1171 const inputTop = document.querySelector('.chat-input-top'); 1172 if (!inputTop) return; 1173 1174 const escapeHtml = uiModule.esc; 1175 1176 // Hide the model-picker so eval-prompts can occupy the same slot 1177 const modelWrap = document.getElementById('model-picker-wrap'); 1178 const prevModelDisplay = modelWrap ? modelWrap.style.display : ''; 1179 if (modelWrap) modelWrap.style.display = 'none'; 1180 1181 const wrap = document.createElement('div'); 1182 wrap.className = 'cmp-eval-wrap'; 1183 wrap.id = 'cmp-eval-wrap'; 1184 1185 const btn = document.createElement('button'); 1186 btn.type = 'button'; 1187 btn.id = 'cmp-eval-btn'; 1188 btn.className = 'cmp-eval-btn'; 1189 btn.title = 'Insert an evaluation prompt'; 1190 btn.innerHTML = 1191 '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>' 1192 + '<span class="cmp-eval-label">Eval prompts</span>' 1193 + '<svg class="cmp-eval-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>'; 1194 1195 const menu = document.createElement('div'); 1196 menu.className = 'cmp-eval-menu hidden'; 1197 menu.id = 'cmp-eval-menu'; 1198 1199 function _renderItems() { 1200 const mode = state._compareMode || 'chat'; 1201 // research/html aren't first-class compare types â fall back gracefully 1202 const key = EVAL_PROMPTS[mode] ? mode 1203 : (mode === 'research' ? 'search' : 'chat'); 1204 const list = EVAL_PROMPTS[key] || []; 1205 1206 if (!list.length) { 1207 menu.innerHTML = '<div class="cmp-eval-empty">No prompts for this type</div>'; 1208 return; 1209 } 1210 // Group by sub-category in original order 1211 const order = []; 1212 const groups = {}; 1213 for (const p of list) { 1214 const sub = p.sub || 'Other'; 1215 if (!groups[sub]) { groups[sub] = []; order.push(sub); } 1216 groups[sub].push(p); 1217 } 1218 let html = ''; 1219 for (const sub of order) { 1220 html += '<div class="cmp-eval-group-label">' + escapeHtml(sub) + '</div>'; 1221 for (const p of groups[sub]) { 1222 const data = encodeURIComponent(p.prompt); 1223 const ans = p.answer ? ' data-answer="' + encodeURIComponent(p.answer) + '"' : ''; 1224 const checkMark = p.answer ? '<span class="cmp-eval-item-tick" title="Has expected answer">â</span>' : ''; 1225 html += '<button type="button" class="cmp-eval-item" data-prompt="' + data + '"' + ans + '>' 1226 + escapeHtml(p.label) + checkMark + '</button>'; 1227 } 1228 } 1229 menu.innerHTML = html; 1230 menu.querySelectorAll('.cmp-eval-item').forEach(item => { 1231 item.addEventListener('click', (e) => { 1232 e.stopPropagation(); 1233 const ta = document.getElementById('message'); 1234 if (ta) { 1235 ta.value = decodeURIComponent(item.dataset.prompt); 1236 ta.dispatchEvent(new Event('input', { bubbles: true })); 1237 ta.focus(); 1238 } 1239 const ans = item.dataset.answer ? decodeURIComponent(item.dataset.answer) : ''; 1240 _showExpectedAnswer(ans); 1241 menu.classList.add('hidden'); 1242 }); 1243 }); 1244 } 1245 1246 btn.addEventListener('click', (e) => { 1247 e.stopPropagation(); 1248 if (menu.classList.contains('hidden')) { 1249 _renderItems(); 1250 menu.classList.remove('hidden'); 1251 } else { 1252 menu.classList.add('hidden'); 1253 } 1254 }); 1255 1256 const _onDocClick = (e) => { 1257 if (!wrap.contains(e.target)) menu.classList.add('hidden'); 1258 }; 1259 document.addEventListener('click', _onDocClick); 1260 1261 wrap.appendChild(btn); 1262 wrap.appendChild(menu); 1263 inputTop.appendChild(wrap); 1264 1265 // Expected-answer chip â placed above the chat-input-bar (outside it), so 1266 // it floats over the compare grid right before the message box. Shows when 1267 // a graded prompt is picked so the eval-runner can verify model output. 1268 const hintChip = document.createElement('div'); 1269 hintChip.className = 'cmp-eval-expected hidden'; 1270 hintChip.id = 'cmp-eval-expected'; 1271 hintChip.innerHTML = 1272 '<span class="cmp-eval-expected-label">Expected:</span>' 1273 + ' <strong class="cmp-eval-expected-value"></strong>' 1274 + ' <button type="button" class="cmp-eval-expected-close" title="Dismiss">Ã</button>'; 1275 // Anchor the floating panel against the input bar (needs position:relative 1276 // â added via CSS rule on .chat-input-bar:has(.cmp-eval-expected) below). 1277 const inputBar = document.querySelector('.chat-input-bar'); 1278 if (inputBar) { 1279 inputBar.appendChild(hintChip); 1280 } else { 1281 inputTop.appendChild(hintChip); 1282 } 1283 hintChip.querySelector('.cmp-eval-expected-close').addEventListener('click', (e) => { 1284 e.stopPropagation(); 1285 hintChip.classList.add('hidden'); 1286 state._expectedAnswer = ''; 1287 }); 1288 1289 function _showExpectedAnswer(answer) { 1290 state._expectedAnswer = answer || ''; 1291 if (!answer) { 1292 hintChip.classList.add('hidden'); 1293 return; 1294 } 1295 hintChip.querySelector('.cmp-eval-expected-value').textContent = answer; 1296 hintChip.classList.remove('hidden'); 1297 } 1298 1299 // Hide the picker when the textarea has any user text (it's only useful 1300 // when starting fresh). Reappears when cleared. The expected-answer 1301 // chip stays put across sends â clearing it on every empty-textarea 1302 // tick wiped state._expectedAnswer before grading could read it, so 1303 // pane â/â badges never appeared. The chip is only cleared via its 1304 // own dismiss button (or when the user picks a new eval). 1305 const ta = document.getElementById('message'); 1306 const _syncEvalVisibility = () => { 1307 const hasText = ta && ta.value.trim().length > 0; 1308 wrap.style.display = hasText ? 'none' : ''; 1309 if (hasText) menu.classList.add('hidden'); 1310 }; 1311 if (ta) ta.addEventListener('input', _syncEvalVisibility); 1312 _syncEvalVisibility(); 1313 1314 // Stash cleanup so cleanupResults() can detach the doc listener and 1315 // restore the model-picker when compare deactivates. 1316 wrap._cleanup = () => { 1317 document.removeEventListener('click', _onDocClick); 1318 if (ta) ta.removeEventListener('input', _syncEvalVisibility); 1319 if (modelWrap) modelWrap.style.display = prevModelDisplay || ''; 1320 if (hintChip.parentNode) hintChip.remove(); 1321 }; 1322 state._compareElements.push(wrap); 1323} 1324
1325/** Remove compare UI elements and restore original view. */ 1326function cleanupResults() { 1327 // Remove all compare elements 1328 state._compareElements.forEach(el => { 1329 if (el._cleanup) el._cleanup(); 1330 if (el._cleanupInput) el._cleanupInput(); 1331 if (el.parentNode) el.remove(); 1332 }); 1333 state._compareElements = []; 1334 1335 // Remove any stray compare/probe overlays 1336 document.querySelectorAll('.compare-probe-overlay').forEach(el => el.remove()); 1337 1338 // Restore sidebar 1339 if (state._sidebarWasHidden) { 1340 const sidebar = document.getElementById('sidebar'); 1341 if (sidebar) sidebar.classList.remove('hidden'); 1342 state._sidebarWasHidden = false; 1343 } 1344 const _mobileNewRestore = document.getElementById('mobile-new-chat-btn'); 1345 if (_mobileNewRestore && _mobileNewRestore.dataset.cmpWasDisplay !== undefined) { 1346 _mobileNewRestore.style.display = _mobileNewRestore.dataset.cmpWasDisplay; 1347 delete _mobileNewRestore.dataset.cmpWasDisplay; 1348 } 1349 state._hasVisibleResults = false; 1350 1351 // Hard reload the page to cleanly restore all UI state 1352 window.location.reload(); 1353} 1354 1355function removeOverlays() { 1356 const bar = document.getElementById('compare-vote-bar'); 1357 if (bar) bar.remove(); 1358 const modal = document.getElementById('compare-model-overlay'); 1359 if (modal) modal.remove(); 1360 const probe = document.querySelector('.compare-probe-overlay'); 1361 if (probe) probe.remove(); 1362} 1363 1364// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1365// ââ showShufflePoolEditor ââ 1366// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1367 1368/** Shuffle pool editor â lets users exclude broken models from the dice. */ 1369async function showShufflePoolEditor() { 1370 let models; 1371 try { models = await fetchModels(); } catch (e) { 1372 if (uiModule) uiModule.showError('Failed to load models'); 1373 return; 1374 } 1375 1376 const overlay = document.createElement('div'); 1377 overlay.className = 'modal'; 1378 overlay.id = 'shuffle-pool-overlay'; 1379 1380 const content = document.createElement('div'); 1381 content.className = 'modal-content'; 1382 content.style.width = '420px'; 1383 1384 const header = document.createElement('div'); 1385 header.className = 'modal-header'; 1386 header.innerHTML = '<h4>Shuffle Pool</h4>'; 1387 const closeBtn = document.createElement('button'); 1388 closeBtn.className = 'close-btn'; 1389 closeBtn.innerHTML = '✖'; 1390 closeBtn.addEventListener('click', () => overlay.remove()); 1391 header.appendChild(closeBtn); 1392 content.appendChild(header); 1393 1394 const body = document.createElement('div'); 1395 body.className = 'modal-body'; 1396 body.style.padding = '12px 16px'; 1397 1398 const desc = document.createElement('p'); 1399 desc.style.cssText = 'color:color-mix(in srgb, var(--fg) 55%, transparent);font-size:0.85em;margin:0 0 12px;'; 1400 desc.textContent = 'Uncheck models to exclude them from random shuffle. They can still be picked manually.'; 1401 body.appendChild(desc); 1402 1403 const list = document.createElement('div'); 1404 list.style.cssText = 'max-height:400px;overflow-y:auto;'; 1405 1406 const excluded = getExcludedModels(); 1407 1408 // Group by type 1409 const groups = { chat: [], image: [] }; 1410 models.forEach(m => { if (groups[m.type]) groups[m.type].push(m); }); 1411 1412 Object.entries(groups).forEach(([type, items]) => { 1413 if (items.length === 0) return; 1414 const heading = document.createElement('div'); 1415 heading.style.cssText = 'font-size:0.78em;font-weight:600;color:color-mix(in srgb, var(--fg) 50%, transparent);text-transform:uppercase;letter-spacing:0.5px;padding:8px 4px 4px;'; 1416 heading.textContent = type === 'chat' ? 'Chat Models' : 'Image Models'; 1417 list.appendChild(heading); 1418 1419 items.forEach(m => { 1420 const row = document.createElement('label'); 1421 row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:5px 4px;cursor:pointer;font-size:0.85em;color:var(--fg);border-radius:4px;'; 1422 row.addEventListener('mouseenter', () => { row.style.background = 'color-mix(in srgb, var(--fg) 4%, transparent)'; }); 1423 row.addEventListener('mouseleave', () => { row.style.background = ''; }); 1424 const chk = document.createElement('input'); 1425 chk.type = 'checkbox'; 1426 chk.checked = !excluded.includes(m.id); 1427 chk.addEventListener('change', () => { 1428 const exc = getExcludedModels(); 1429 if (chk.checked) { 1430 const idx = exc.indexOf(m.id); 1431 if (idx >= 0) exc.splice(idx, 1); 1432 } else { 1433 if (!exc.includes(m.id)) exc.push(m.id); 1434 } 1435 setExcludedModels(exc); 1436 }); 1437 const label = document.createElement('span'); 1438 label.textContent = m.endpointName ? m.name + ' (' + m.endpointName + ')' : m.name; 1439 row.appendChild(chk); 1440 row.appendChild(label); 1441 list.appendChild(row); 1442 }); 1443 }); 1444 1445 body.appendChild(list); 1446 content.appendChild(body); 1447 overlay.appendChild(content); 1448 document.body.appendChild(overlay); 1449 1450 if (themeModule && themeModule.makeDraggable) { 1451 themeModule.makeDraggable(content, header); 1452 } 1453} 1454 1455// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1456// ââ Register cross-module callbacks ââ 1457// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1458 1459registerCompareActions({ stopAll, resetCompare }); 1460registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml });
1461registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels }); 1462 1463// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1464// ââ Public API ââ 1465// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1466 1467export { EVAL_PROMPTS, showScoreboard, handleCompareSubmit }; 1468 1469const compareModule = { 1470 init, 1471 toggleMode, 1472 handleCompareSubmit, 1473 isActive: isCompareActive, 1474 hasVisibleResults: () => state._hasVisibleResults, 1475 deactivate, 1476 closeCompare, 1477 cleanupResults, 1478 showShufflePoolEditor, 1479 showScoreboard, 1480}; 1481 1482export default compareModule; 1483window.compareModule = compareModule;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.