mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-22 17:04:19 +02:00
* fix(agent): allow remaining actions for an approved task * fix(agent): make approval continuation control-only * fix(ci): preserve approval taint and cache-buster contract * fix(ui): keep tool approvals in current chat * fix(ui): route tool approvals through chat submit * test(ui): pin approval submit routing * fix(agent): complete approval denial flow * fix(ui): avoid duplicate ask-user close icon * fix(agent): retain approved tool in continuation set * revert(ui): keep PR 6113 scoped to approval continuation * fix(agent): add task and chat approval scopes * fix(ui): prevent duplicate ask-user close icon * feat(ui): add ask-user option shortcuts * fix(compare): route ask-user choices per pane * fix(agent): keep skill-test approvals to a single action The chat card now reuses the wire value `approve` to mean chat-session scope, and `consume()` returned `allow_remaining_actions=True` for it unconditionally. The skill-test approval route was never updated: it still sends `approve` meaning "once", and its button still reads "Allow once", but the grant it got back set `approval_gate_bypassed` for the rest of the resumed run. That surface wraps the skill body and every transcript byte as untrusted context, so it is the last place where one click should ungate everything that follows. Give `consume()` an explicit `allow_continuation` flag. Callers that own a resumable chat keep the scope the user picked; callers that do not — the skill tester, unattended audits — get SINGLE_ACTION and the gate re-arms behind the sealed action, which is what their label promises. * fix(ui): cache-bust every module the approval click depends on chatStream.js, compare/index.js and compare/stream.js all changed behaviour but kept their old `?v=`, while chat.js and chatRenderer.js were bumped. A returning browser therefore serves the new chat.js — which now deliberately leaves the composer empty and clicks the send button — next to the cached chatStream.js that has no interceptor. With an empty composer that button sits at `data-mode="newchat"`, so the click opens a new chat and the approval is dropped. Bump the three, and version compare/stream.js's chatRenderer import to match everyone else's so the ask_user keydown listener binds to one module instance instead of two. * fix(ui): keep the digit shortcuts off tool approval cards With an approval card on screen and focus anywhere outside an input, a bare `1` fired `approve_task` — the widest of the three grants — with no modifier and no confirmation. That card is the one control whose entire purpose is deliberate consent after untrusted context influenced the run, and Deny sits at 3. Label the card with its kind and skip the shortcut for approvals. Ordinary ask_user questions keep 1-3. * fix(compare): restore a pane's ask_user card instead of dropping the choice renderAskUserCard removes the card as soon as onSubmit accepts, but the resume loop gave up silently after 10s if the originating stream still owned the pane. The user saw the click land, the card vanish, and nothing happen, with no way to get it back. Re-render the card on that deadline and say why. The reroll case still returns without sending — that choice belongs to a stream that no longer exists. * refactor(chat): drop the unreachable deny branch `if decision != "deny"` is always true — the deny path returns a StreamingResponse a few lines above. It reads as if deny still falls through to the toggle restore. --------- Co-authored-by: Léo <leograndcontact@gmail.com>
1545 lines
71 KiB
JavaScript
1545 lines
71 KiB
JavaScript
// compare/index.js — orchestrator module (public API)
|
||
/**
|
||
* Model A/B Comparison module.
|
||
* Builds its own multi-pane grid layout (up to 8 models).
|
||
* Sends same prompt to all models in parallel, lets user vote.
|
||
*
|
||
* Uses show/hide on the original container children instead of
|
||
* innerHTML replacement, so event listeners on the input bar,
|
||
* compare button, mode toggle, etc. are preserved.
|
||
*/
|
||
|
||
// ── Submodule imports ──
|
||
import state from './state.js';
|
||
import { EVAL_PROMPTS, WAVE_FRAMES,
|
||
ICON_DICE, ICON_EXPAND, ICON_COLLAPSE, ICON_CLOSE,
|
||
ICON_REROLL, ICON_COPY, ICON_PLAY, ICON_CODE,
|
||
ICON_PARALLEL, ICON_SEQUENTIAL,
|
||
EYE_OPEN, EYE_CLOSED, SAVE_ICON, CHAT_ICON,
|
||
SEND_SVG, VOTES_STORAGE_KEY,
|
||
} from './icons.js';
|
||
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
|
||
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
|
||
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
|
||
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js?v=20260819approvalcontrol1';
|
||
import {
|
||
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
|
||
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
|
||
_showModelSwapDropdown, _createAndAppendPane, _autoPreviewHtml,
|
||
registerPaneActions,
|
||
} from './panes.js';
|
||
import { handleVote, buildVoteBar, addFinishBadge, spawnConfetti, _saveVote, registerCompareActions } from './vote.js';
|
||
import { showScoreboard } from './scoreboard.js';
|
||
|
||
// ── External dependency imports ──
|
||
import Storage from '../storage.js';
|
||
import uiModule from '../ui.js';
|
||
import sessionModule from '../sessions.js';
|
||
import spinnerModule from '../spinner.js';
|
||
import themeModule from '../theme.js';
|
||
import presetsModule from '../presets.js';
|
||
import markdownModule from '../markdown.js';
|
||
import { bindMenuDismiss } from '../escMenuStack.js';
|
||
|
||
var escapeHtml = uiModule.esc;
|
||
|
||
/** Slot label: letters (A, B) in parallel, numbers (1, 2) in sequential */
|
||
function _slotChar(i) { return state._parallel ? String.fromCharCode(65 + i) : String(i + 1); }
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── Toolbar indicator sync ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── init ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
function init(apiBase) {
|
||
state.API_BASE = apiBase;
|
||
// Clean up unsaved compare sessions on page close/refresh
|
||
window.addEventListener('beforeunload', () => {
|
||
if (!state._saveOnClose && state._paneSessionIds.length > 0) {
|
||
// sendBeacon uses POST — use the bulk delete endpoint
|
||
navigator.sendBeacon(
|
||
`${state.API_BASE}/api/sessions/bulk-delete`,
|
||
new Blob([JSON.stringify({ ids: state._paneSessionIds })], { type: 'application/json' })
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── isCompareActive ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
function isCompareActive() {
|
||
return state.isActive;
|
||
}
|
||
|
||
function _compareModeLabel() {
|
||
return ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
|
||
}
|
||
|
||
function _setToolbarMode(mode, syncModeTools = !state.isActive) {
|
||
const target = mode === 'agent' ? 'agent' : 'chat';
|
||
const toggleState = Storage.loadToggleState();
|
||
toggleState.mode = target;
|
||
Storage.saveToggleState(toggleState);
|
||
const agentBtn = document.getElementById('mode-agent-btn');
|
||
const chatBtn = document.getElementById('mode-chat-btn');
|
||
const modeToggle = agentBtn?.closest('.mode-toggle') || chatBtn?.closest('.mode-toggle') || document.querySelector('.mode-toggle');
|
||
if (agentBtn && chatBtn) {
|
||
agentBtn.classList.toggle('active', target === 'agent');
|
||
chatBtn.classList.toggle('active', target === 'chat');
|
||
agentBtn.setAttribute('aria-pressed', target === 'agent' ? 'true' : 'false');
|
||
chatBtn.setAttribute('aria-pressed', target === 'chat' ? 'true' : 'false');
|
||
}
|
||
if (modeToggle) {
|
||
modeToggle.classList.toggle('mode-chat', target === 'chat');
|
||
modeToggle.classList.toggle('mode-right', target === 'chat');
|
||
}
|
||
if (syncModeTools) {
|
||
document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = target === 'agent' ? '' : 'none'; });
|
||
}
|
||
}
|
||
|
||
function _syncCompareModeFromToolbar(mode) {
|
||
if (!state.isActive) return;
|
||
state._compareMode = mode === 'agent' ? 'agent' : 'chat';
|
||
_setToolbarMode(state._compareMode, false);
|
||
const headerLabel = document.querySelector('.compare-header-label');
|
||
if (headerLabel) {
|
||
headerLabel.textContent = 'Comparing' + _compareModeLabel() + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
|
||
}
|
||
const evalWrap = document.getElementById('cmp-eval-wrap');
|
||
if (evalWrap && typeof evalWrap._renderItems === 'function') evalWrap._renderItems();
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── closeCompare ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
/** Close compare mode (public API for toolbar indicator). */
|
||
function closeCompare() {
|
||
if (state.isActive) deactivate(true);
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── toggleMode ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
/** Toggle compare mode — shows model selector, then builds UI. */
|
||
async function toggleMode() {
|
||
if (state.isActive) {
|
||
deactivate(true);
|
||
return false;
|
||
}
|
||
if (state._openingSelector) return false;
|
||
|
||
state._openingSelector = true;
|
||
try {
|
||
const confirmed = await showModelSelector();
|
||
if (!confirmed) return false;
|
||
|
||
state.isActive = true;
|
||
_syncToolbarIndicator(true);
|
||
await _buildCompareUI();
|
||
return true;
|
||
} catch (err) {
|
||
console.error('Compare toggleMode error:', err);
|
||
return false;
|
||
} finally {
|
||
state._openingSelector = false;
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── deactivate ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
async function deactivate(teardown) {
|
||
// Abort any in-flight streams
|
||
state._abortControllers.forEach(ac => { if (ac) ac.abort(); });
|
||
state._abortControllers = [];
|
||
|
||
// Move sessions to compare folder if saving
|
||
if (state._saveOnClose && state._paneSessionIds.length > 0) {
|
||
const modelShorts = _modelDisplayNames(state._selectedModels);
|
||
const folderName = 'Compare: ' + modelShorts.join(' vs ');
|
||
await Promise.all(state._paneSessionIds.map(sid =>
|
||
fetch(`${state.API_BASE}/api/session/${sid}`, {
|
||
method: 'PATCH', body: new URLSearchParams({ folder: folderName })
|
||
}).catch(() => {})
|
||
));
|
||
}
|
||
|
||
// Capture session IDs to delete before resetting state
|
||
const sessionIdsToDelete = (!state._saveOnClose && teardown && state._paneSessionIds.length > 0)
|
||
? [...state._paneSessionIds] : [];
|
||
|
||
removeOverlays();
|
||
state.isActive = false;
|
||
state._streaming = false;
|
||
state._paneSessionIds = [];
|
||
state._paneMetrics = [];
|
||
state._finishOrder = 0;
|
||
state._paneElapsed = [];
|
||
state._saveOnClose = false;
|
||
state._continueChat = false;
|
||
state._probed.clear();
|
||
state._expectedAnswer = '';
|
||
_syncToolbarIndicator(false);
|
||
|
||
// Restore main textarea placeholder
|
||
const msgTA = document.getElementById('message');
|
||
if (msgTA) msgTA.placeholder = '';
|
||
|
||
// Restore toolbar indicator display states and pointer events
|
||
Object.entries(state._savedIndicatorDisplay).forEach(([id, display]) => {
|
||
const el = document.getElementById(id);
|
||
if (el) { el.style.display = display; el.style.pointerEvents = ''; }
|
||
});
|
||
state._savedIndicatorDisplay = {};
|
||
|
||
// Unlock mode toggle
|
||
const _modeToggleR = document.querySelector('.mode-toggle');
|
||
if (_modeToggleR) { _modeToggleR.style.pointerEvents = ''; _modeToggleR.style.opacity = ''; }
|
||
|
||
// Restore tool toggle pointer events
|
||
['overflow-plus-btn', 'web-toggle-btn', 'bash-toggle-btn'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.pointerEvents = '';
|
||
});
|
||
|
||
// Restore agent/chat mode to what it was before compare
|
||
_setToolbarMode(state._savedMode, true);
|
||
|
||
// Delete unsaved sessions, then reload
|
||
if (teardown) {
|
||
if (sessionIdsToDelete.length > 0) {
|
||
// keepalive ensures requests complete even during page navigation
|
||
await Promise.all(sessionIdsToDelete.map(sid =>
|
||
fetch(`${state.API_BASE}/api/session/${sid}`, { method: 'DELETE', keepalive: true }).catch(() => {})
|
||
));
|
||
}
|
||
location.href = location.pathname;
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── _buildCompareUI ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
/** Build the compare UI: sessions, header bar, grid of panes, vote bar, eval dropdown. */
|
||
async function _buildCompareUI() {
|
||
if (state._selectedModels.length < 1) {
|
||
if (uiModule) uiModule.showError('Select at least 1 model');
|
||
return;
|
||
}
|
||
|
||
const n = state._selectedModels.length;
|
||
const modelShorts = _modelDisplayNames(state._selectedModels);
|
||
_persistSelections();
|
||
|
||
// 1. Create sessions (skip for search mode — no LLM sessions needed)
|
||
if (state._compareMode !== 'search') {
|
||
const sessionIds = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const m = state._selectedModels[i];
|
||
const fd = new FormData();
|
||
// Blind mode: name the session by its neutral slot so the sidebar /
|
||
// GET /api/sessions can't de-anonymize the comparison (issue #1285).
|
||
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(i) : modelShorts[i]));
|
||
fd.append('endpoint_url', m.endpoint || '');
|
||
fd.append('model', m.model || '');
|
||
if (m.endpointId) {
|
||
fd.append('endpoint_id', m.endpointId);
|
||
fd.append('skip_validation', 'true');
|
||
}
|
||
const res = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd });
|
||
if (!res.ok) throw new Error('Failed to create session for ' + modelShorts[i]);
|
||
const data = await res.json();
|
||
sessionIds.push(data.id);
|
||
}
|
||
state._paneSessionIds = sessionIds;
|
||
} else {
|
||
state._paneSessionIds = [];
|
||
}
|
||
state._paneMetrics = state._selectedModels.map(() => null);
|
||
state._abortControllers = state._selectedModels.map(() => null);
|
||
|
||
// 2. Auto-collapse sidebar if many panes
|
||
if (n > 3) {
|
||
const sidebar = document.getElementById('sidebar');
|
||
if (sidebar && !sidebar.classList.contains('hidden')) {
|
||
sidebar.classList.add('hidden');
|
||
state._sidebarWasHidden = true;
|
||
const iconRail = document.getElementById('icon-rail');
|
||
if (iconRail) iconRail.classList.remove('rail-hidden');
|
||
if (typeof window.syncRailSide === 'function') window.syncRailSide();
|
||
}
|
||
}
|
||
|
||
// 3. Hide mobile new-chat button during compare
|
||
const _mobileNewBtn = document.getElementById('mobile-new-chat-btn');
|
||
if (_mobileNewBtn) {
|
||
_mobileNewBtn.dataset.cmpWasDisplay = _mobileNewBtn.style.display;
|
||
_mobileNewBtn.style.display = 'none';
|
||
}
|
||
|
||
// 4. Save toolbar indicator display states before hiding
|
||
const indicatorIds = ['overflow-tts-btn', 'overflow-attach-btn', 'overflow-rag-btn', 'overflow-research-btn', 'overflow-doc-btn', 'rag-indicator-btn', 'research-toggle-btn'];
|
||
state._savedIndicatorDisplay = {};
|
||
indicatorIds.forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) state._savedIndicatorDisplay[id] = el.style.display;
|
||
});
|
||
|
||
// 5. Save current mode and seed the toolbar for this compare type.
|
||
const _toggleState = Storage.loadToggleState();
|
||
state._savedMode = _toggleState.mode || 'chat';
|
||
const _targetMode = (state._compareMode === 'agent') ? 'agent' : 'chat';
|
||
_setToolbarMode(_targetMode, false);
|
||
const _ab = document.getElementById('mode-agent-btn'), _cb = document.getElementById('mode-chat-btn');
|
||
let _modeCleanup = null;
|
||
const _onCompareModeClick = (ev) => {
|
||
ev.stopPropagation();
|
||
ev.stopImmediatePropagation();
|
||
_syncCompareModeFromToolbar(ev.currentTarget === _ab ? 'agent' : 'chat');
|
||
};
|
||
if (_ab && _cb) {
|
||
_ab.addEventListener('click', _onCompareModeClick, true);
|
||
_cb.addEventListener('click', _onCompareModeClick, true);
|
||
_modeCleanup = document.createElement('span');
|
||
_modeCleanup.style.display = 'none';
|
||
_modeCleanup._cleanup = () => {
|
||
_ab.removeEventListener('click', _onCompareModeClick, true);
|
||
_cb.removeEventListener('click', _onCompareModeClick, true);
|
||
};
|
||
}
|
||
const _modeToggle = document.querySelector('.mode-toggle');
|
||
if (_modeToggle) { _modeToggle.style.pointerEvents = ''; _modeToggle.style.opacity = ''; }
|
||
|
||
// 6. Force tool toggles per compare mode
|
||
disableToolToggles();
|
||
if (state._compareMode === 'search') {
|
||
const webChk = document.getElementById('web-toggle');
|
||
if (webChk && !webChk.checked) { webChk.checked = true; webChk.dispatchEvent(new Event('change')); }
|
||
const webBtn = document.getElementById('web-toggle-btn');
|
||
if (webBtn) webBtn.classList.add('active');
|
||
} else if (state._compareMode === 'research') {
|
||
const resChk = document.getElementById('research-toggle');
|
||
if (resChk && !resChk.checked) { resChk.checked = true; resChk.dispatchEvent(new Event('change')); }
|
||
const resBtn = document.getElementById('research-toggle-btn');
|
||
if (resBtn) { resBtn.style.display = ''; resBtn.classList.add('active'); }
|
||
}
|
||
|
||
// 7. Hide existing chat container children (preserves event listeners)
|
||
const container = document.getElementById('chat-container');
|
||
state._compareElements = [];
|
||
if (_modeCleanup) state._compareElements.push(_modeCleanup);
|
||
Array.from(container.children).forEach(child => {
|
||
if (child.style.display === 'none') return;
|
||
child.dataset.cmpHidden = '1';
|
||
child.style.display = 'none';
|
||
});
|
||
container.classList.add('compare-active');
|
||
|
||
// 8. Header bar
|
||
const cols = Math.min(n, 4);
|
||
const headerBar = document.createElement('div');
|
||
headerBar.className = 'compare-header-bar';
|
||
headerBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:6px 10px;flex-shrink:0;';
|
||
const headerLabel = document.createElement('span');
|
||
headerLabel.className = 'compare-header-label';
|
||
headerLabel.style.cssText = 'font-size:10px;font-weight:400;color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;';
|
||
headerLabel.textContent = 'Comparing' + _compareModeLabel() + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
|
||
// Left side: the Compare tool icon (two side-by-side panes, matching the
|
||
// rail/sidebar icon) + the label. Other tool headers carry their icon; this
|
||
// one was missing it.
|
||
const headerLeft = document.createElement('div');
|
||
headerLeft.style.cssText = 'display:flex;align-items:center;min-width:0;';
|
||
const headerIcon = document.createElement('span');
|
||
headerIcon.style.cssText = 'display:inline-flex;flex-shrink:0;margin-right:6px;opacity:0.85;';
|
||
headerIcon.innerHTML = '<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>';
|
||
headerLeft.appendChild(headerIcon);
|
||
headerLeft.appendChild(headerLabel);
|
||
headerBar.appendChild(headerLeft);
|
||
|
||
const headerActions = document.createElement('div');
|
||
headerActions.style.cssText = 'display:flex;align-items:center;gap:2px;';
|
||
|
||
const _btnCSS = 'background:none;border:1px solid var(--border);color:var(--fg);cursor:pointer;padding:3px 10px;font-size:11px;font-weight:600;opacity:0.7;transition:all 0.15s;line-height:1;border-radius:4px;display:inline-flex;align-items:center;font-family:inherit;';
|
||
|
||
const checkBtn = document.createElement('button');
|
||
checkBtn.id = 'compare-check-btn';
|
||
checkBtn.innerHTML = '<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>';
|
||
checkBtn.title = 'Probe unverified models with a small test request';
|
||
checkBtn.style.cssText = _btnCSS;
|
||
checkBtn.addEventListener('click', () => _checkUnprobed());
|
||
headerActions.appendChild(checkBtn);
|
||
|
||
// Check button is dynamic: only visible when at least one selected model
|
||
// hasn't been probed yet. Show right after add/change, hide after success.
|
||
window._updateCheckBtnState = function() {
|
||
const btn = document.getElementById('compare-check-btn');
|
||
if (!btn) return;
|
||
const hasUnprobed = state._selectedModels.some(m => !state._probed.has(m.model));
|
||
btn.style.display = hasUnprobed ? '' : 'none';
|
||
};
|
||
|
||
// (Scoreboard button moved into the vote bar, next to Tie — see vote.js.)
|
||
|
||
const exportWrap = document.createElement('div');
|
||
exportWrap.style.cssText = 'position:relative;display:inline-flex;';
|
||
const exportBtn = document.createElement('button');
|
||
exportBtn.id = 'compare-export-btn';
|
||
exportBtn.innerHTML = '<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>';
|
||
exportBtn.title = 'Export options';
|
||
exportBtn.style.cssText = _btnCSS;
|
||
exportBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
_toggleExportMenu(exportBtn);
|
||
});
|
||
exportWrap.appendChild(exportBtn);
|
||
headerActions.appendChild(exportWrap);
|
||
|
||
const shuffleBtn = document.createElement('button');
|
||
shuffleBtn.id = 'compare-shuffle-btn';
|
||
shuffleBtn.innerHTML = ICON_DICE + '<span style="font-size:11px;margin-left:3px;">Shuffle</span>';
|
||
shuffleBtn.title = 'Shuffle pane positions';
|
||
shuffleBtn.style.cssText = _btnCSS;
|
||
shuffleBtn.addEventListener('click', () => shufflePanePositions());
|
||
headerActions.appendChild(shuffleBtn);
|
||
|
||
const addBtn = document.createElement('button');
|
||
addBtn.id = 'compare-add-btn';
|
||
addBtn.innerHTML = '<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>';
|
||
addBtn.title = 'Add model pane';
|
||
addBtn.style.cssText = _btnCSS;
|
||
addBtn.addEventListener('click', () => _addPane(addBtn));
|
||
headerActions.appendChild(addBtn);
|
||
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.className = 'compare-close-btn';
|
||
closeBtn.innerHTML = '<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>';
|
||
closeBtn.title = 'Close compare mode';
|
||
// Match Export/Score/Shuffle/Model styling so the X sits flush with
|
||
// the rest of the toolbar instead of being a 24×24 bordered square.
|
||
closeBtn.style.cssText = _btnCSS;
|
||
closeBtn.addEventListener('click', () => deactivate(true));
|
||
headerActions.appendChild(closeBtn);
|
||
|
||
// Move Export to the far left of the action cluster (per user preference).
|
||
headerActions.insertBefore(exportWrap, headerActions.firstChild);
|
||
|
||
headerBar.appendChild(headerActions);
|
||
container.appendChild(headerBar);
|
||
state._compareElements.push(headerBar);
|
||
|
||
// Initial visibility — hidden if all current models are already probed
|
||
window._updateCheckBtnState();
|
||
|
||
// 9. Grid of panes
|
||
const grid = document.createElement('div');
|
||
grid.className = 'compare-grid';
|
||
grid.dataset.cols = String(cols);
|
||
for (let i = 0; i < n; i++) {
|
||
const label = state._blindMode ? 'Model ' + _slotChar(i) : modelShorts[i];
|
||
const pane = document.createElement('div');
|
||
pane.className = 'compare-pane';
|
||
pane.dataset.pane = String(i);
|
||
pane.innerHTML =
|
||
'<div class="pane-header">' +
|
||
'<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>' +
|
||
'<span class="pane-timer" id="cmp-timer-' + i + '"></span>' +
|
||
'<span class="pane-finish-badge" id="cmp-badge-' + i + '"></span>' +
|
||
'<div class="pane-actions">' +
|
||
'<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>' +
|
||
'<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>' +
|
||
'<button class="pane-action-btn" data-action="reroll" data-pane="' + i + '" title="Re-roll">' + ICON_REROLL + '</button>' +
|
||
'<button class="pane-action-btn" data-action="copy" data-pane="' + i + '" title="Copy">' + ICON_COPY + '</button>' +
|
||
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
|
||
'<button class="pane-action-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane">' + ICON_CLOSE + '</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="chat-history" id="cmp-history-' + i + '"></div>' +
|
||
'<iframe class="compare-pane-iframe" id="cmp-iframe-' + i + '" sandbox="allow-scripts" style="display:none;"></iframe>' +
|
||
'<div class="pane-vote-footer">' +
|
||
'<button class="pane-vote-btn" data-pane="' + i + '" type="button" disabled style="opacity:0.4;">' +
|
||
'<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>' +
|
||
'<span class="pane-vote-label">Vote ' + escapeHtml(label) + '</span>' +
|
||
'</button>' +
|
||
'</div>';
|
||
grid.appendChild(pane);
|
||
}
|
||
grid.addEventListener('click', (e) => {
|
||
const voteBtn = e.target.closest('.pane-vote-btn');
|
||
if (voteBtn) {
|
||
e.stopPropagation();
|
||
if (voteBtn.disabled) return;
|
||
const idx = parseInt(voteBtn.dataset.pane);
|
||
handleVote(idx);
|
||
return;
|
||
}
|
||
const actionBtn = e.target.closest('.pane-action-btn');
|
||
if (actionBtn) {
|
||
e.stopPropagation();
|
||
const action = actionBtn.dataset.action;
|
||
const idx = parseInt(actionBtn.dataset.pane);
|
||
if (action === 'stop') stopPane(idx);
|
||
else if (action === 'copy') copyPaneResponse(idx);
|
||
else if (action === 'reroll') rerollPane(idx);
|
||
else if (action === 'expand') toggleExpandPane(idx, actionBtn);
|
||
else if (action === 'preview') togglePanePreview(idx);
|
||
else if (action === 'close') _removePane(idx);
|
||
return;
|
||
}
|
||
const titleBtn = e.target.closest('.pane-title-btn');
|
||
if (titleBtn) {
|
||
e.stopPropagation();
|
||
const idx = parseInt(titleBtn.dataset.pane);
|
||
_showModelSwapDropdown(idx, titleBtn);
|
||
}
|
||
});
|
||
container.appendChild(grid);
|
||
state._compareElements.push(grid);
|
||
|
||
// 10. Vote bar placeholder
|
||
const voteBar = document.createElement('div');
|
||
voteBar.id = 'compare-vote-bar';
|
||
voteBar.className = 'compare-vote-bar';
|
||
container.appendChild(voteBar);
|
||
state._compareElements.push(voteBar);
|
||
buildVoteBar(n);
|
||
|
||
if (state._blindMode && n > 1) shufflePanePositions();
|
||
|
||
// 11. Move chat input bar to the bottom of the container
|
||
const inputBar = document.querySelector('.chat-input-bar');
|
||
if (inputBar) {
|
||
inputBar.style.display = '';
|
||
if (inputBar.dataset.cmpHidden) delete inputBar.dataset.cmpHidden;
|
||
container.appendChild(inputBar);
|
||
}
|
||
const msgTA = document.getElementById('message');
|
||
if (msgTA) {
|
||
msgTA.placeholder = window.matchMedia('(max-width: 767px)').matches ? '' : 'Enter prompt for all models...';
|
||
requestAnimationFrame(() => msgTA.focus());
|
||
}
|
||
|
||
// Eval-prompts picker — sits inside the message box at top-right (where
|
||
// model-picker normally lives). Model-picker is irrelevant during compare,
|
||
// so hide it and restore on deactivate via the wrap's _cleanup.
|
||
_setupEvalPicker();
|
||
|
||
// 12. Hide tool buttons that don't apply during compare
|
||
['overflow-tts-btn', 'overflow-attach-btn', 'overflow-rag-btn', 'overflow-research-btn', 'overflow-doc-btn', 'rag-indicator-btn', 'web-toggle-btn', 'bash-toggle-btn', 'overflow-plus-btn'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) { el.style.display = 'none'; el.style.pointerEvents = 'none'; }
|
||
});
|
||
if (state._compareMode !== 'research') {
|
||
const resBtn = document.getElementById('research-toggle-btn');
|
||
if (resBtn) { resBtn.style.display = 'none'; resBtn.style.pointerEvents = 'none'; }
|
||
}
|
||
document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = 'none'; });
|
||
|
||
_setSendBtn('send');
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── _setSendBtn ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
function _setSendBtn(mode) {
|
||
const btn = document.querySelector('.send-btn');
|
||
if (!btn) return;
|
||
if (mode === 'stop') {
|
||
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
|
||
btn.title = 'Stop all models';
|
||
btn.dataset.mode = 'streaming';
|
||
btn.classList.remove('mic-mode', 'newchat-mode');
|
||
} else {
|
||
btn.dataset.mode = '';
|
||
btn.innerHTML = SEND_SVG;
|
||
btn.style.color = '';
|
||
btn.title = 'Send to all models';
|
||
btn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded');
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── handleCompareSubmit ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Handle submit from the main chat input while compare is active.
|
||
* Called by app.js submit guard.
|
||
*/
|
||
function handleCompareSubmit(e) {
|
||
// If streaming, act as stop button
|
||
if (state._streaming) {
|
||
stopAll();
|
||
return;
|
||
}
|
||
const input = document.getElementById('message');
|
||
const message = input ? input.value.trim() : '';
|
||
if (!message) return;
|
||
input.value = '';
|
||
// Reset textarea height
|
||
input.style.height = '';
|
||
// Notify input listeners (eval-picker visibility, autosize, etc.) that the
|
||
// textarea is empty again — programmatic clears don't fire `input` natively.
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
// Mobile: dismiss the on-screen keyboard after the prompt is sent so the
|
||
// user sees the streaming output instead of the typing area. A plain blur()
|
||
// is often ignored on Firefox mobile, so toggle readonly around it (and blur
|
||
// the active element too) to reliably collapse the keyboard.
|
||
// Mobile keyboard dismiss — use the SAME proven logic as the main chat send
|
||
// (chat.js handleChatSubmit). Compare returns early in that flow, so it never
|
||
// reached this code; replicating it here is what actually works on Firefox
|
||
// mobile (readonly + blur, then drop readonly only once the blur is confirmed
|
||
// or the user taps to type again — avoids the keyboard bouncing back up).
|
||
if (window.innerWidth <= 768) {
|
||
try {
|
||
input.setAttribute('readonly', 'readonly');
|
||
input.blur();
|
||
// Setting readonly on an ALREADY-FOCUSED textarea doesn't dismiss the
|
||
// keyboard on Firefox, and blur() is often ignored — so the readonly-only
|
||
// approach works only when the input happened not to be focused at send
|
||
// time (inconsistent between 1st/2nd prompt). Deterministically pull focus
|
||
// off the textarea by focusing a throwaway readonly input, then drop it.
|
||
const tmp = document.createElement('input');
|
||
tmp.setAttribute('readonly', 'readonly');
|
||
tmp.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;border:0;padding:0;';
|
||
document.body.appendChild(tmp);
|
||
tmp.focus();
|
||
setTimeout(() => { try { tmp.blur(); tmp.remove(); } catch {} }, 50);
|
||
const _dropReadonly = () => { try { input.removeAttribute('readonly'); } catch {} };
|
||
setTimeout(() => {
|
||
if (document.activeElement === input) {
|
||
input.addEventListener('pointerdown', _dropReadonly, { once: true });
|
||
input.addEventListener('focus', _dropReadonly, { once: true });
|
||
} else {
|
||
_dropReadonly();
|
||
}
|
||
}, 120);
|
||
} catch {}
|
||
}
|
||
_executeCompare(message);
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── _executeCompare ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Send prompt to all panes, stream responses.
|
||
* Works for both first and follow-up messages.
|
||
*/
|
||
async function _executeCompare(message) {
|
||
if (state._streaming) return;
|
||
if (state._selectedModels.length < 1) return;
|
||
|
||
// New round — allow voting again and clear the previous round's win/lose/tie
|
||
// styling (pane highlight + the Winner!/= title decorations), otherwise the
|
||
// old result stays stuck on the panes through the next prompt.
|
||
state._voted = false;
|
||
for (let i = 0; i < state._selectedModels.length; i++) {
|
||
const pane = document.querySelector('.compare-pane[data-pane="' + i + '"]');
|
||
if (pane) {
|
||
pane.classList.remove('winner', 'loser');
|
||
// Clear the previous round's Failed/Timeout badge and the eval ✓/✗ grade.
|
||
pane.querySelector('.pane-grade-badge')?.remove();
|
||
}
|
||
const fb = document.getElementById('cmp-badge-' + i);
|
||
if (fb) { fb.textContent = ''; fb.style.color = ''; }
|
||
const titleEl = document.getElementById('cmp-title-' + i);
|
||
if (titleEl) {
|
||
const label = state._blindMode
|
||
? 'Model ' + _slotChar(i)
|
||
: ((state._selectedModels[i] && state._selectedModels[i].name) || 'Model ' + _slotChar(i));
|
||
titleEl.innerHTML = escapeHtml(label) + ' <span class="pane-title-caret">▾</span>';
|
||
}
|
||
}
|
||
|
||
state._streaming = true;
|
||
state._lastPrompt = message;
|
||
_setSendBtn('stop');
|
||
// Disable header buttons during streaming
|
||
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
|
||
b.disabled = true; b.style.opacity = '0.25'; b.style.pointerEvents = 'none';
|
||
});
|
||
|
||
// ── Search mode: direct API calls, no SSE streaming ──
|
||
if (state._compareMode === 'search') {
|
||
try {
|
||
const n = state._selectedModels.length;
|
||
|
||
// Clear previous vote buttons on follow-up
|
||
const voteBar = document.getElementById('compare-vote-bar');
|
||
if (voteBar) voteBar.innerHTML = '';
|
||
|
||
// Add user query + spinner to each pane
|
||
for (let i = 0; i < n; i++) {
|
||
const hist = document.getElementById('cmp-history-' + i);
|
||
if (!hist) continue;
|
||
const userMsg = document.createElement('div');
|
||
userMsg.className = 'msg msg-user';
|
||
userMsg.innerHTML = '<div class="role">You</div><div class="body"></div>';
|
||
userMsg.querySelector('.body').textContent = message;
|
||
hist.appendChild(userMsg);
|
||
|
||
const aiMsg = document.createElement('div');
|
||
aiMsg.className = 'msg msg-ai';
|
||
aiMsg.innerHTML = '<div class="role">Search</div><div class="body"></div>';
|
||
const aiBody = aiMsg.querySelector('.body');
|
||
if (spinnerModule) {
|
||
const spinner = spinnerModule.create('Searching...', 'right');
|
||
aiBody.appendChild(spinner.createElement());
|
||
spinner.start();
|
||
}
|
||
hist.appendChild(aiMsg);
|
||
hist.scrollTop = hist.scrollHeight;
|
||
}
|
||
|
||
// Fire searches — parallel or sequential based on _parallel setting
|
||
const t0 = performance.now();
|
||
state._abortControllers = state._selectedModels.map(() => new AbortController());
|
||
|
||
async function _searchOne(m, i) {
|
||
const fd = new FormData();
|
||
fd.append('query', message);
|
||
fd.append('provider', m.model);
|
||
fd.append('count', '10');
|
||
try {
|
||
const res = await fetch(`${state.API_BASE}/api/search/query`, { method: 'POST', body: fd, signal: state._abortControllers[i].signal });
|
||
const data = await res.json();
|
||
return { idx: i, data };
|
||
} catch (err) {
|
||
return { idx: i, data: { results: [], error: err.name === 'AbortError' ? 'Stopped' : err.message } };
|
||
}
|
||
}
|
||
|
||
let results;
|
||
const _seqSynthDone = new Set();
|
||
if (state._parallel) {
|
||
results = await Promise.all(state._selectedModels.map((m, i) => _searchOne(m, i)));
|
||
} else {
|
||
// Sequential — run one at a time, dim waiting panes
|
||
results = [];
|
||
const panes = document.querySelectorAll('.compare-pane');
|
||
panes.forEach((p, i) => { if (i > 0) p.style.opacity = '0.4'; });
|
||
for (let i = 0; i < state._selectedModels.length; i++) {
|
||
const pane = panes[i];
|
||
if (pane) pane.style.opacity = '1';
|
||
results.push(await _searchOne(state._selectedModels[i], i));
|
||
// Render this result immediately
|
||
const { idx, data } = results[results.length - 1];
|
||
const hist = document.getElementById('cmp-history-' + idx);
|
||
if (hist) {
|
||
const aiMsg = hist.querySelector('.msg-ai:last-child');
|
||
if (aiMsg) {
|
||
const aiBody = aiMsg.querySelector('.body');
|
||
aiBody.innerHTML = '';
|
||
if (data.error) {
|
||
aiBody.innerHTML = '<div style="color:var(--color-error);font-size:0.85em;">Error: ' + escapeHtml(data.error) + '</div>';
|
||
} else if (!data.results || data.results.length === 0) {
|
||
aiBody.innerHTML = '<div style="color:color-mix(in srgb, var(--fg) 50%, transparent);font-size:0.85em;font-style:italic;">No results found</div>';
|
||
} else {
|
||
aiBody.appendChild(_renderSearchResults(data));
|
||
}
|
||
const footer = document.createElement('div'); footer.className = 'msg-footer';
|
||
const span = document.createElement('span'); span.className = 'response-metrics';
|
||
const parts = [];
|
||
if (data.results) parts.push(data.results.length + ' results');
|
||
if (data.time) parts.push(data.time + 's');
|
||
span.textContent = parts.join(' | '); footer.appendChild(span); aiMsg.appendChild(footer);
|
||
hist.scrollTop = hist.scrollHeight;
|
||
const _pe = document.querySelector(`.compare-pane[data-pane="${idx}"]`);
|
||
if (_pe) _pe.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||
}
|
||
}
|
||
// Sequential: run synthesis for this pane immediately before moving to next
|
||
_seqSynthDone.add(idx);
|
||
if (!data.error && data.results && data.results.length > 0) {
|
||
const modelToUse = state._searchSynthModels?.[idx] || null;
|
||
if (modelToUse) {
|
||
const seqHist = document.getElementById('cmp-history-' + idx);
|
||
if (seqHist) {
|
||
const synthMsg = document.createElement('div');
|
||
synthMsg.className = 'msg msg-ai';
|
||
synthMsg.innerHTML = '<div class="role">Analysis</div><div class="body"></div>';
|
||
const synthBody = synthMsg.querySelector('.body');
|
||
let spinner = null;
|
||
if (spinnerModule) { spinner = spinnerModule.create('Analyzing...', 'right'); synthBody.appendChild(spinner.createElement()); spinner.start(); }
|
||
seqHist.appendChild(synthMsg);
|
||
seqHist.scrollTop = seqHist.scrollHeight;
|
||
const resultsText = data.results.map((r, ri) => `[${ri + 1}] ${r.title}\n${r.snippet || ''}\nURL: ${r.url}`).join('\n\n');
|
||
const synthPrompt = `Analyze these search results for the query "${message}". Summarize the key findings, note any consensus or conflicting information, and provide a brief synthesis.\n\nSearch Results:\n${resultsText}`;
|
||
await _runSynthForPane(modelToUse, synthPrompt, synthBody, spinner, seqHist);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Reset opacity
|
||
panes.forEach(p => { p.style.opacity = ''; });
|
||
}
|
||
// Render results into each pane
|
||
for (const { idx, data } of results) {
|
||
const hist = document.getElementById('cmp-history-' + idx);
|
||
if (!hist) continue;
|
||
const aiMsg = hist.querySelector('.msg-ai:last-child');
|
||
if (!aiMsg) continue;
|
||
const aiBody = aiMsg.querySelector('.body');
|
||
aiBody.innerHTML = '';
|
||
|
||
if (data.error) {
|
||
aiBody.innerHTML = '<div style="color:var(--color-error);font-size:0.85em;">Error: ' + escapeHtml(data.error) + '</div>';
|
||
} else if (!data.results || data.results.length === 0) {
|
||
aiBody.innerHTML = '<div style="color:color-mix(in srgb, var(--fg) 50%, transparent);font-size:0.85em;font-style:italic;">No results found</div>';
|
||
} else {
|
||
aiBody.appendChild(_renderSearchResults(data));
|
||
}
|
||
|
||
// Footer metrics
|
||
const footer = document.createElement('div');
|
||
footer.className = 'msg-footer';
|
||
const span = document.createElement('span');
|
||
span.className = 'response-metrics';
|
||
const parts = [];
|
||
if (data.results) parts.push(data.results.length + ' results');
|
||
if (data.time) parts.push(data.time + 's');
|
||
span.textContent = parts.join(' | ');
|
||
footer.appendChild(span);
|
||
aiMsg.appendChild(footer);
|
||
|
||
hist.scrollTop = hist.scrollHeight;
|
||
// Show reroll/copy buttons for search results
|
||
const _paneEl = document.querySelector(`.compare-pane[data-pane="${idx}"]`);
|
||
if (_paneEl) _paneEl.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||
}
|
||
|
||
// ── Synthesis: send results to LLM for analysis (respects _parallel setting) ──
|
||
if (state._searchSynthModels) {
|
||
// Build list of synthesis tasks
|
||
const synthTasks = [];
|
||
for (let i = 0; i < results.length; i++) {
|
||
const { idx, data } = results[i];
|
||
// Skip panes already synthesized in sequential mode
|
||
if (_seqSynthDone.has(idx)) continue;
|
||
if (data.error || !data.results || data.results.length === 0) continue;
|
||
|
||
const modelToUse = state._searchSynthModels?.[idx] || null;
|
||
if (!modelToUse) continue;
|
||
|
||
const hist = document.getElementById('cmp-history-' + idx);
|
||
if (!hist) continue;
|
||
|
||
// Add synthesis message with spinner
|
||
const synthMsg = document.createElement('div');
|
||
synthMsg.className = 'msg msg-ai';
|
||
synthMsg.innerHTML = '<div class="role">Analysis</div><div class="body"></div>';
|
||
const synthBody = synthMsg.querySelector('.body');
|
||
let spinner = null;
|
||
if (spinnerModule) {
|
||
spinner = spinnerModule.create('Analyzing...', 'right');
|
||
synthBody.appendChild(spinner.createElement());
|
||
spinner.start();
|
||
}
|
||
hist.appendChild(synthMsg);
|
||
// Auto-scroll to show the Analysis message
|
||
hist.scrollTop = hist.scrollHeight;
|
||
|
||
// Build synthesis prompt
|
||
const resultsText = data.results.map((r, ri) =>
|
||
`[${ri + 1}] ${r.title}\n${r.snippet || ''}\nURL: ${r.url}`
|
||
).join('\n\n');
|
||
|
||
const synthPrompt = `Analyze these search results for the query "${message}". Summarize the key findings, note any consensus or conflicting information, and provide a brief synthesis.\n\nSearch Results:\n${resultsText}`;
|
||
|
||
synthTasks.push({ idx, modelToUse, synthBody, synthMsg, spinner, hist, synthPrompt });
|
||
}
|
||
|
||
// Run synthesis streams (parallel or sequential based on _parallel flag)
|
||
const runSynthesis = async (task) => _runSynthForPane(task.modelToUse, task.synthPrompt, task.synthBody, task.spinner, task.hist);
|
||
|
||
if (state._parallel) {
|
||
await Promise.all(synthTasks.map(runSynthesis));
|
||
} else {
|
||
for (const task of synthTasks) {
|
||
await runSynthesis(task);
|
||
}
|
||
}
|
||
}
|
||
|
||
buildVoteBar(n);
|
||
} catch (err) {
|
||
console.error('Search compare error:', err);
|
||
if (uiModule) uiModule.showError('Search compare failed: ' + err.message);
|
||
} finally {
|
||
state._streaming = false;
|
||
_setSendBtn('send');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ── Chat / Image mode ──
|
||
const isFollowUp = document.getElementById('cmp-history-0')?.querySelector('.msg-ai');
|
||
|
||
try {
|
||
const n = state._selectedModels.length;
|
||
|
||
if (isFollowUp) {
|
||
const voteBar = document.getElementById('compare-vote-bar');
|
||
if (voteBar) {
|
||
voteBar.innerHTML = '';
|
||
voteBar.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// ── Add user + AI bubbles to each pane ──
|
||
const aiElements = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const hist = document.getElementById('cmp-history-' + i);
|
||
if (!hist) { aiElements.push(null); continue; }
|
||
|
||
const userMsg = document.createElement('div');
|
||
userMsg.className = 'msg msg-user';
|
||
userMsg.innerHTML = '<div class="role">You</div><div class="body"></div>';
|
||
userMsg.querySelector('.body').textContent = message;
|
||
hist.appendChild(userMsg);
|
||
|
||
const aiMsg = document.createElement('div');
|
||
aiMsg.className = 'msg msg-ai';
|
||
aiMsg.innerHTML = '<div class="role">AI</div><div class="body"></div>';
|
||
const aiBody = aiMsg.querySelector('.body');
|
||
if (spinnerModule) {
|
||
// In sequential mode, only first pane says "Processing", rest say "Waiting"
|
||
const label = (!state._parallel && i > 0)
|
||
? 'Waiting for Model ' + _slotChar(i - 1) + '...'
|
||
: 'Processing...';
|
||
const spinner = spinnerModule.create(label, 'right');
|
||
aiBody.appendChild(spinner.createElement());
|
||
spinner.start();
|
||
aiMsg._spinner = spinner;
|
||
}
|
||
hist.appendChild(aiMsg);
|
||
hist.scrollTop = hist.scrollHeight;
|
||
aiElements.push(aiMsg);
|
||
}
|
||
|
||
// ── Auto-extend timeout ──
|
||
const researchChk = document.getElementById('research-toggle');
|
||
const webChkT = document.getElementById('web-toggle');
|
||
const noTimeLimit = state._compareMode === 'research' || (researchChk && researchChk.checked);
|
||
const needsLongTimeout = state._compareMode === 'agent' || (webChkT && webChkT.checked);
|
||
const runTimeout = noTimeLimit ? 999999 : needsLongTimeout ? Math.max(state._timeout, 300) : state._timeout;
|
||
|
||
// ── Pre-search if web toggle is on (share same results across all panes) ──
|
||
let sharedSearchContext = null;
|
||
let sharedSearchSources = null;
|
||
const webChk = document.getElementById('web-toggle');
|
||
const isAgentMode = state._compareMode === 'agent';
|
||
const webOn = webChk && webChk.checked;
|
||
// In agent mode, web_search is a tool (handled per-pane); in chat mode, pre-search and share
|
||
if (webOn && !isAgentMode) {
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('query', message);
|
||
const searchRes = await fetch(`${state.API_BASE}/api/search`, { method: 'POST', body: fd });
|
||
if (searchRes.ok) {
|
||
const searchData = await searchRes.json();
|
||
if (searchData.context) sharedSearchContext = searchData.context;
|
||
if (searchData.sources) sharedSearchSources = searchData.sources;
|
||
}
|
||
} catch (err) {
|
||
console.warn('Compare pre-search failed, panes will search individually:', err);
|
||
}
|
||
}
|
||
|
||
// ── Show vote bar immediately so user can vote anytime ──
|
||
buildVoteBar(n);
|
||
|
||
// ── Stream all panes (parallel or sequential based on _parallel flag) ──
|
||
state._finishOrder = 0;
|
||
state._paneElapsed = new Array(n).fill(null);
|
||
state._paneMetrics = new Array(n).fill(null);
|
||
state._abortControllers = new Array(n).fill(null);
|
||
|
||
if (state._parallel) {
|
||
// Run all panes at once
|
||
await Promise.all(state._paneSessionIds.map((sid, i) =>
|
||
streamToPane(i, sid, message, aiElements[i], { searchContext: sharedSearchContext, timeout: runTimeout })
|
||
));
|
||
} else {
|
||
// Run one pane at a time (sequential) — active pane full opacity, others dimmed
|
||
const allPanes = document.querySelectorAll('.compare-pane');
|
||
allPanes.forEach(p => { p.style.transition = 'opacity 0.4s ease'; });
|
||
// Dim all except first
|
||
allPanes.forEach((p, idx) => { p.style.opacity = idx === 0 ? '1' : '0.35'; });
|
||
|
||
for (let i = 0; i < state._paneSessionIds.length; i++) {
|
||
// Update spinner
|
||
if (aiElements[i] && aiElements[i]._spinner) {
|
||
aiElements[i]._spinner.updateLabel('Processing...');
|
||
}
|
||
|
||
await streamToPane(i, state._paneSessionIds[i], message, aiElements[i], { searchContext: sharedSearchContext, timeout: runTimeout });
|
||
|
||
// Swap opacity: dim current, brighten next
|
||
if (allPanes[i]) allPanes[i].style.opacity = '0.35';
|
||
if (i + 1 < allPanes.length && allPanes[i + 1]) {
|
||
allPanes[i + 1].style.opacity = '1';
|
||
}
|
||
}
|
||
|
||
// Restore all pane opacities when done
|
||
allPanes.forEach(p => { p.style.opacity = ''; p.style.transition = ''; });
|
||
}
|
||
|
||
// Re-focus main input for follow-up
|
||
if (state._continueChat) {
|
||
const ta = document.getElementById('message');
|
||
if (ta) ta.focus();
|
||
}
|
||
|
||
} catch (err) {
|
||
console.error('Compare error:', err);
|
||
if (uiModule) uiModule.showError('Compare failed: ' + err.message);
|
||
} finally {
|
||
// A pane may have started its own ask_user/approval continuation while the
|
||
// original all-pane Promise was settling. Keep Compare busy until every
|
||
// pane-owned controller is gone instead of exposing a second broadcast send.
|
||
const compareStillStreaming = state._abortControllers.some(Boolean);
|
||
state._streaming = compareStillStreaming;
|
||
_setSendBtn(compareStillStreaming ? 'stop' : 'send');
|
||
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
|
||
button.disabled = compareStillStreaming;
|
||
button.style.opacity = compareStillStreaming ? '0.25' : '0.7';
|
||
button.style.pointerEvents = compareStillStreaming ? 'none' : '';
|
||
});
|
||
}
|
||
}
|
||
|
||
// showModelSelector imported from ./selector.js
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── cleanupResults / removeOverlays ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Build a markdown comparison of the current panes (prompt + each model's
|
||
* response + metrics + grade) and copy it to the clipboard. Lets users save
|
||
* or share a side-by-side at a glance.
|
||
*/
|
||
// Build the comparison markdown string. Shared by all export paths.
|
||
function _buildComparisonMarkdown() {
|
||
const grid = document.querySelector('.compare-grid');
|
||
if (!grid) return null;
|
||
const panes = grid.querySelectorAll('.compare-pane');
|
||
if (!panes.length) return null;
|
||
const prompt = state._lastPrompt || '(no prompt yet — run a comparison first)';
|
||
const expected = state._expectedAnswer || '';
|
||
const date = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||
let md = '# Compare\n\n';
|
||
md += '**When:** ' + date + '\n';
|
||
md += '**Type:** ' + (state._compareMode || 'chat') + (state._blindMode ? ' (blind)' : '') + '\n';
|
||
md += '**Prompt:**\n\n```\n' + prompt + '\n```\n\n';
|
||
if (expected) md += '**Expected answer:** `' + expected + '`\n\n';
|
||
panes.forEach((pane, i) => {
|
||
const m = state._selectedModels[i];
|
||
const name = m ? (m.name || m.model) + (m.endpointName ? ' (' + m.endpointName + ')' : '') : 'Model ' + (i + 1);
|
||
const body = pane.querySelector('.compare-text-content, .msg-body, .body');
|
||
const text = body ? (body.innerText || body.textContent || '').trim() : '';
|
||
const metrics = state._paneMetrics[i];
|
||
const grade = pane.querySelector('.pane-grade-badge');
|
||
const gradeMark = grade ? (grade.classList.contains('pass') ? ' ✓' : ' ✗') : '';
|
||
md += '## ' + name + gradeMark + '\n\n';
|
||
if (metrics) {
|
||
const bits = [];
|
||
if (metrics.output_tokens != null) bits.push(metrics.output_tokens + ' tokens');
|
||
if (metrics.tokens_per_second != null) bits.push(metrics.tokens_per_second + ' tok/s');
|
||
if (metrics.response_time != null) bits.push(metrics.response_time + 's');
|
||
if (bits.length) md += '_' + bits.join(' · ') + '_\n\n';
|
||
}
|
||
md += text ? text + '\n\n' : '_(no response)_\n\n';
|
||
md += '---\n\n';
|
||
});
|
||
return md;
|
||
}
|
||
|
||
let _exportMenuEl = null;
|
||
let _closeExportMenu = () => {};
|
||
function _toggleExportMenu(btn) {
|
||
if (_exportMenuEl) { _closeExportMenu(); return; }
|
||
const r = btn.getBoundingClientRect();
|
||
const m = document.createElement('div');
|
||
m.className = 'compare-export-menu';
|
||
m.style.cssText = 'position:fixed;z-index:10001;top:' + (r.bottom + 4) + 'px;left:' + r.left + 'px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;display:flex;flex-direction:column;min-width:170px;';
|
||
const opts = [
|
||
{ label: 'Copy as Markdown', fn: () => _exportCopyMarkdown(btn) },
|
||
{ label: 'Download .md', fn: () => _exportDownloadMarkdown() },
|
||
{ label: 'Print / Save PDF', fn: () => _exportPrint() },
|
||
];
|
||
for (const o of opts) {
|
||
const item = document.createElement('button');
|
||
item.type = 'button';
|
||
item.textContent = o.label;
|
||
item.style.cssText = 'background:none;border:none;color:var(--fg);text-align:left;padding:8px 12px;border-radius:6px;cursor:pointer;font:inherit;font-size:12px;';
|
||
item.addEventListener('mouseenter', () => { item.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
|
||
item.addEventListener('mouseleave', () => { item.style.background = 'none'; });
|
||
item.addEventListener('click', () => { _closeExportMenu(); o.fn(); });
|
||
m.appendChild(item);
|
||
}
|
||
document.body.appendChild(m);
|
||
_exportMenuEl = m;
|
||
_closeExportMenu = bindMenuDismiss(m, () => {
|
||
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
||
}, (ev) => !m.contains(ev.target));
|
||
}
|
||
|
||
async function _exportCopyMarkdown(_btn) {
|
||
const md = _buildComparisonMarkdown();
|
||
if (!md) return;
|
||
try {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
await navigator.clipboard.writeText(md);
|
||
} else {
|
||
// Avoid the focus-stealing textarea fallback when the modern API
|
||
// is available — that path briefly flashes the page as the
|
||
// textarea is added/focused/removed.
|
||
const ta = document.createElement('textarea');
|
||
ta.value = md;
|
||
ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;';
|
||
document.body.appendChild(ta);
|
||
ta.select(); document.execCommand('copy'); ta.remove();
|
||
}
|
||
try { window.uiModule?.showToast?.('Copied comparison to clipboard'); } catch {}
|
||
} catch (e) {
|
||
try { window.uiModule?.showToast?.('Copy failed'); } catch {}
|
||
}
|
||
}
|
||
|
||
function _exportDownloadMarkdown() {
|
||
const md = _buildComparisonMarkdown();
|
||
if (!md) return;
|
||
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
||
const blob = new Blob([md], { type: 'text/markdown' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'compare-' + ts + '.md';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
}
|
||
|
||
function _exportPrint() {
|
||
const md = _buildComparisonMarkdown();
|
||
if (!md) return;
|
||
// Render the markdown as a quick HTML view in a new window and trigger
|
||
// the system print dialog — user can pick "Save as PDF" from there.
|
||
const w = window.open('', '_blank');
|
||
if (!w) return;
|
||
try { w.opener = null; } catch (_) {}
|
||
const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
const html = '<!doctype html><meta charset="utf-8"><title>Compare export</title>' +
|
||
'<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}' +
|
||
'pre{background:#f5f5f5;border-radius:6px;padding:10px;white-space:pre-wrap}' +
|
||
'h1{margin-top:0}h2{border-bottom:1px solid #ddd;padding-bottom:4px;margin-top:32px}' +
|
||
'hr{border:none;border-top:1px solid #ccc;margin:24px 0}' +
|
||
'</style><body><pre style="background:none;padding:0">' + escape(md) + '</pre>' +
|
||
'<script>window.onload=()=>setTimeout(()=>window.print(),100)<\/script>';
|
||
w.document.write(html);
|
||
w.document.close();
|
||
}
|
||
|
||
async function _exportComparison(btn) {
|
||
const grid = document.querySelector('.compare-grid');
|
||
if (!grid) return;
|
||
const panes = grid.querySelectorAll('.compare-pane');
|
||
if (!panes.length) return;
|
||
|
||
const prompt = state._lastPrompt || '(no prompt yet — run a comparison first)';
|
||
const expected = state._expectedAnswer || '';
|
||
const date = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||
|
||
let md = '# Compare\n\n';
|
||
md += '**When:** ' + date + '\n';
|
||
md += '**Type:** ' + (state._compareMode || 'chat') + (state._blindMode ? ' (blind)' : '') + '\n';
|
||
md += '**Prompt:**\n\n```\n' + prompt + '\n```\n\n';
|
||
if (expected) md += '**Expected answer:** `' + expected + '`\n\n';
|
||
|
||
panes.forEach((pane, i) => {
|
||
const m = state._selectedModels[i];
|
||
const name = m ? (m.name || m.model) + (m.endpointName ? ' (' + m.endpointName + ')' : '') : 'Model ' + (i + 1);
|
||
const body = pane.querySelector('.compare-text-content, .msg-body, .body');
|
||
const text = body ? (body.innerText || body.textContent || '').trim() : '';
|
||
const metrics = state._paneMetrics[i];
|
||
const grade = pane.querySelector('.pane-grade-badge');
|
||
const gradeMark = grade ? (grade.classList.contains('pass') ? ' ✓' : ' ✗') : '';
|
||
|
||
md += '## ' + name + gradeMark + '\n\n';
|
||
if (metrics) {
|
||
const bits = [];
|
||
if (metrics.output_tokens != null) bits.push(metrics.output_tokens + ' tokens');
|
||
if (metrics.tokens_per_second != null) bits.push(metrics.tokens_per_second + ' tok/s');
|
||
if (metrics.response_time != null) bits.push(metrics.response_time + 's');
|
||
if (bits.length) md += '_' + bits.join(' · ') + '_\n\n';
|
||
}
|
||
md += text ? text + '\n\n' : '_(no response)_\n\n';
|
||
md += '---\n\n';
|
||
});
|
||
|
||
// Copy to clipboard
|
||
const origLabel = btn ? btn.innerHTML : '';
|
||
try {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
await navigator.clipboard.writeText(md);
|
||
} else {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = md; document.body.appendChild(ta);
|
||
ta.select(); document.execCommand('copy'); ta.remove();
|
||
}
|
||
if (btn) {
|
||
btn.innerHTML = '<span style="font-size:11px;">Copied!</span>';
|
||
setTimeout(() => { btn.innerHTML = origLabel; }, 1500);
|
||
}
|
||
} catch (e) {
|
||
if (btn) {
|
||
btn.innerHTML = '<span style="font-size:11px;color:var(--color-error);">Failed</span>';
|
||
setTimeout(() => { btn.innerHTML = origLabel; }, 2000);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build the eval-prompts picker — shown only during compare. Mirrors the
|
||
* absolute-positioned model-picker location (top-right of .chat-input-top)
|
||
* and is auto-cleaned up by the standard _compareElements teardown.
|
||
*/
|
||
function _setupEvalPicker() {
|
||
const inputTop = document.querySelector('.chat-input-top');
|
||
if (!inputTop) return;
|
||
|
||
const escapeHtml = uiModule.esc;
|
||
|
||
// Hide the model-picker so eval-prompts can occupy the same slot
|
||
const modelWrap = document.getElementById('model-picker-wrap');
|
||
const prevModelDisplay = modelWrap ? modelWrap.style.display : '';
|
||
if (modelWrap) modelWrap.style.display = 'none';
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'cmp-eval-wrap';
|
||
wrap.id = 'cmp-eval-wrap';
|
||
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.id = 'cmp-eval-btn';
|
||
btn.className = 'cmp-eval-btn';
|
||
btn.title = 'Insert an evaluation prompt';
|
||
btn.innerHTML =
|
||
'<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>'
|
||
+ '<span class="cmp-eval-label">Eval prompts</span>'
|
||
+ '<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>';
|
||
|
||
const menu = document.createElement('div');
|
||
menu.className = 'cmp-eval-menu hidden';
|
||
menu.id = 'cmp-eval-menu';
|
||
|
||
function _renderItems() {
|
||
const mode = state._compareMode || 'chat';
|
||
const label = btn.querySelector('.cmp-eval-label');
|
||
if (label) {
|
||
label.textContent = ({
|
||
agent: 'Agent prompts',
|
||
chat: 'Chat prompts',
|
||
search: 'Search prompts',
|
||
research: 'Research prompts'
|
||
}[mode] || 'Eval prompts');
|
||
}
|
||
// research/html aren't first-class compare types — fall back gracefully
|
||
const key = EVAL_PROMPTS[mode] ? mode
|
||
: (mode === 'research' ? 'search' : 'chat');
|
||
const list = EVAL_PROMPTS[key] || [];
|
||
|
||
if (!list.length) {
|
||
menu.innerHTML = '<div class="cmp-eval-empty">No prompts for this type</div>';
|
||
return;
|
||
}
|
||
// Group by sub-category in original order
|
||
const order = [];
|
||
const groups = {};
|
||
for (const p of list) {
|
||
const sub = p.sub || 'Other';
|
||
if (!groups[sub]) { groups[sub] = []; order.push(sub); }
|
||
groups[sub].push(p);
|
||
}
|
||
let html = '';
|
||
for (const sub of order) {
|
||
html += '<div class="cmp-eval-group-label">' + escapeHtml(sub) + '</div>';
|
||
for (const p of groups[sub]) {
|
||
const data = encodeURIComponent(p.prompt);
|
||
const ans = p.answer ? ' data-answer="' + encodeURIComponent(p.answer) + '"' : '';
|
||
const checkMark = p.answer ? '<span class="cmp-eval-item-tick" title="Has expected answer">✓</span>' : '';
|
||
html += '<button type="button" class="cmp-eval-item" data-prompt="' + data + '"' + ans + '>'
|
||
+ escapeHtml(p.label) + checkMark + '</button>';
|
||
}
|
||
}
|
||
menu.innerHTML = html;
|
||
menu.querySelectorAll('.cmp-eval-item').forEach(item => {
|
||
item.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const ta = document.getElementById('message');
|
||
if (ta) {
|
||
ta.value = decodeURIComponent(item.dataset.prompt);
|
||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||
ta.focus();
|
||
}
|
||
const ans = item.dataset.answer ? decodeURIComponent(item.dataset.answer) : '';
|
||
_showExpectedAnswer(ans);
|
||
menu.classList.add('hidden');
|
||
});
|
||
});
|
||
}
|
||
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
if (menu.classList.contains('hidden')) {
|
||
_renderItems();
|
||
menu.classList.remove('hidden');
|
||
} else {
|
||
menu.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
const _onDocClick = (e) => {
|
||
if (!wrap.contains(e.target)) menu.classList.add('hidden');
|
||
};
|
||
document.addEventListener('click', _onDocClick);
|
||
|
||
_renderItems();
|
||
wrap.appendChild(btn);
|
||
wrap.appendChild(menu);
|
||
wrap._renderItems = _renderItems;
|
||
inputTop.appendChild(wrap);
|
||
|
||
// Expected-answer chip — placed above the chat-input-bar (outside it), so
|
||
// it floats over the compare grid right before the message box. Shows when
|
||
// a graded prompt is picked so the eval-runner can verify model output.
|
||
const hintChip = document.createElement('div');
|
||
hintChip.className = 'cmp-eval-expected hidden';
|
||
hintChip.id = 'cmp-eval-expected';
|
||
hintChip.innerHTML =
|
||
'<span class="cmp-eval-expected-label">Expected:</span>'
|
||
+ ' <strong class="cmp-eval-expected-value"></strong>'
|
||
+ ' <button type="button" class="cmp-eval-expected-close" title="Dismiss">×</button>';
|
||
// Anchor the floating panel against the input bar (needs position:relative
|
||
// — added via CSS rule on .chat-input-bar:has(.cmp-eval-expected) below).
|
||
const inputBar = document.querySelector('.chat-input-bar');
|
||
if (inputBar) {
|
||
inputBar.appendChild(hintChip);
|
||
} else {
|
||
inputTop.appendChild(hintChip);
|
||
}
|
||
hintChip.querySelector('.cmp-eval-expected-close').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
hintChip.classList.add('hidden');
|
||
state._expectedAnswer = '';
|
||
});
|
||
|
||
function _showExpectedAnswer(answer) {
|
||
state._expectedAnswer = answer || '';
|
||
if (!answer) {
|
||
hintChip.classList.add('hidden');
|
||
return;
|
||
}
|
||
hintChip.querySelector('.cmp-eval-expected-value').textContent = answer;
|
||
hintChip.classList.remove('hidden');
|
||
}
|
||
|
||
// Hide the picker when the textarea has any user text (it's only useful
|
||
// when starting fresh). Reappears when cleared. The expected-answer
|
||
// chip stays put across sends — clearing it on every empty-textarea
|
||
// tick wiped state._expectedAnswer before grading could read it, so
|
||
// pane ✓/✗ badges never appeared. The chip is only cleared via its
|
||
// own dismiss button (or when the user picks a new eval).
|
||
const ta = document.getElementById('message');
|
||
const _syncEvalVisibility = () => {
|
||
const hasText = ta && ta.value.trim().length > 0;
|
||
wrap.style.display = hasText ? 'none' : '';
|
||
if (hasText) menu.classList.add('hidden');
|
||
};
|
||
if (ta) ta.addEventListener('input', _syncEvalVisibility);
|
||
_syncEvalVisibility();
|
||
|
||
// Stash cleanup so cleanupResults() can detach the doc listener and
|
||
// restore the model-picker when compare deactivates.
|
||
wrap._cleanup = () => {
|
||
document.removeEventListener('click', _onDocClick);
|
||
if (ta) ta.removeEventListener('input', _syncEvalVisibility);
|
||
if (modelWrap) modelWrap.style.display = prevModelDisplay || '';
|
||
if (hintChip.parentNode) hintChip.remove();
|
||
};
|
||
state._compareElements.push(wrap);
|
||
}
|
||
|
||
/** Remove compare UI elements and restore original view. */
|
||
function cleanupResults() {
|
||
// Remove all compare elements
|
||
state._compareElements.forEach(el => {
|
||
if (el._cleanup) el._cleanup();
|
||
if (el._cleanupInput) el._cleanupInput();
|
||
if (el.parentNode) el.remove();
|
||
});
|
||
state._compareElements = [];
|
||
|
||
// Remove any stray compare/probe overlays
|
||
document.querySelectorAll('.compare-probe-overlay').forEach(el => el.remove());
|
||
|
||
// Restore sidebar
|
||
if (state._sidebarWasHidden) {
|
||
const sidebar = document.getElementById('sidebar');
|
||
if (sidebar) sidebar.classList.remove('hidden');
|
||
state._sidebarWasHidden = false;
|
||
}
|
||
const _mobileNewRestore = document.getElementById('mobile-new-chat-btn');
|
||
if (_mobileNewRestore && _mobileNewRestore.dataset.cmpWasDisplay !== undefined) {
|
||
_mobileNewRestore.style.display = _mobileNewRestore.dataset.cmpWasDisplay;
|
||
delete _mobileNewRestore.dataset.cmpWasDisplay;
|
||
}
|
||
state._hasVisibleResults = false;
|
||
|
||
// Hard reload the page to cleanly restore all UI state
|
||
window.location.reload();
|
||
}
|
||
|
||
function removeOverlays() {
|
||
const bar = document.getElementById('compare-vote-bar');
|
||
if (bar) bar.remove();
|
||
const modal = document.getElementById('compare-model-overlay');
|
||
if (modal) modal.remove();
|
||
const probe = document.querySelector('.compare-probe-overlay');
|
||
if (probe) probe.remove();
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── showShufflePoolEditor ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
/** Shuffle pool editor — lets users exclude broken models from the dice. */
|
||
async function showShufflePoolEditor() {
|
||
let models;
|
||
try { models = await fetchModels(); } catch (e) {
|
||
if (uiModule) uiModule.showError('Failed to load models');
|
||
return;
|
||
}
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal';
|
||
overlay.id = 'shuffle-pool-overlay';
|
||
|
||
const content = document.createElement('div');
|
||
content.className = 'modal-content';
|
||
content.style.width = '420px';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'modal-header';
|
||
header.innerHTML = '<h4>Shuffle Pool</h4>';
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.className = 'close-btn';
|
||
closeBtn.innerHTML = '✖';
|
||
closeBtn.addEventListener('click', () => overlay.remove());
|
||
header.appendChild(closeBtn);
|
||
content.appendChild(header);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'modal-body';
|
||
body.style.padding = '12px 16px';
|
||
|
||
const desc = document.createElement('p');
|
||
desc.style.cssText = 'color:color-mix(in srgb, var(--fg) 55%, transparent);font-size:0.85em;margin:0 0 12px;';
|
||
desc.textContent = 'Uncheck models to exclude them from random shuffle. They can still be picked manually.';
|
||
body.appendChild(desc);
|
||
|
||
const list = document.createElement('div');
|
||
list.style.cssText = 'max-height:400px;overflow-y:auto;';
|
||
|
||
const excluded = getExcludedModels();
|
||
|
||
// Group by type
|
||
const groups = { chat: [], image: [] };
|
||
models.forEach(m => { if (groups[m.type]) groups[m.type].push(m); });
|
||
|
||
Object.entries(groups).forEach(([type, items]) => {
|
||
if (items.length === 0) return;
|
||
const heading = document.createElement('div');
|
||
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;';
|
||
heading.textContent = type === 'chat' ? 'Chat Models' : 'Image Models';
|
||
list.appendChild(heading);
|
||
|
||
items.forEach(m => {
|
||
const row = document.createElement('label');
|
||
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;';
|
||
row.addEventListener('mouseenter', () => { row.style.background = 'color-mix(in srgb, var(--fg) 4%, transparent)'; });
|
||
row.addEventListener('mouseleave', () => { row.style.background = ''; });
|
||
const chk = document.createElement('input');
|
||
chk.type = 'checkbox';
|
||
chk.checked = !excluded.includes(m.id);
|
||
chk.addEventListener('change', () => {
|
||
const exc = getExcludedModels();
|
||
if (chk.checked) {
|
||
const idx = exc.indexOf(m.id);
|
||
if (idx >= 0) exc.splice(idx, 1);
|
||
} else {
|
||
if (!exc.includes(m.id)) exc.push(m.id);
|
||
}
|
||
setExcludedModels(exc);
|
||
});
|
||
const label = document.createElement('span');
|
||
label.textContent = m.endpointName ? m.name + ' (' + m.endpointName + ')' : m.name;
|
||
row.appendChild(chk);
|
||
row.appendChild(label);
|
||
list.appendChild(row);
|
||
});
|
||
});
|
||
|
||
body.appendChild(list);
|
||
content.appendChild(body);
|
||
overlay.appendChild(content);
|
||
document.body.appendChild(overlay);
|
||
|
||
if (themeModule && themeModule.makeDraggable) {
|
||
themeModule.makeDraggable(content, header);
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── Register cross-module callbacks ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
registerCompareActions({ stopAll, resetCompare });
|
||
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });
|
||
registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels });
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// ── Public API ──
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
export { EVAL_PROMPTS, showScoreboard, handleCompareSubmit };
|
||
|
||
const compareModule = {
|
||
init,
|
||
toggleMode,
|
||
handleCompareSubmit,
|
||
isActive: isCompareActive,
|
||
hasVisibleResults: () => state._hasVisibleResults,
|
||
deactivate,
|
||
closeCompare,
|
||
cleanupResults,
|
||
showShufflePoolEditor,
|
||
showScoreboard,
|
||
};
|
||
|
||
export default compareModule;
|
||
window.compareModule = compareModule;
|