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