mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-02 21:12:21 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
export const EYE_OPEN = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||
export const EYE_CLOSED = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
|
||||
export const SAVE_ICON = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>';
|
||||
export const CHAT_ICON = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
export const CHAT_ICON = '<svg class="section-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
export const ICON_COPY = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
export const ICON_REROLL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
|
||||
export const ICON_EXPAND = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||||
@@ -29,7 +29,7 @@ export const VOTES_STORAGE_KEY = 'odysseus-compare-votes';
|
||||
export const VOTES_MAX = 200;
|
||||
export const POOL_STORAGE_KEY = 'odysseus-shuffle-pool-excluded';
|
||||
|
||||
// ── Evaluation prompt templates ──
|
||||
// ── Comparison prompt templates ──
|
||||
//
|
||||
// Five high-signal prompts per category — each picked to differentiate models
|
||||
// on a distinct capability. The Visual / SVG-render prompt in `chat` ends with
|
||||
@@ -40,9 +40,12 @@ export const EVAL_PROMPTS = {
|
||||
chat: [
|
||||
// ── ★ Featured — prompts that have actually broken frontier models ──
|
||||
{ sub: '★ Featured', label: 'Sum digits 2^100', answer: '115', prompt: 'Compute the sum of the decimal digits of 2^100. Do NOT use code execution — work it out by reasoning about the number. Show every step, then end with the final number on its own line.' },
|
||||
{ sub: '★ Featured', label: 'Three jugs', answer: '4 pours: 7→5, 5→3, 3→7, 5→3', prompt: 'You have three jugs of capacities 7, 5, and 3 liters. The 7-liter jug starts full; the others empty. Using only pouring (no markings), produce the shortest sequence of pours that leaves exactly 2 liters in the 3-liter jug. Output each step as `pour A → B` on its own line. Then state the total number of pours on a final line.' },
|
||||
{ sub: '★ Featured', label: 'Three jugs', answer: '2 pours: 7→5, 7→3', prompt: 'You have three jugs of capacities 7, 5, and 3 liters. The 7-liter jug starts full; the others empty. Using only pouring (no markings), produce the shortest sequence of pours that leaves exactly 2 liters in the 3-liter jug. Output each step as `pour A → B` on its own line. Then state the total number of pours on a final line.' },
|
||||
{ sub: '★ Featured', label: 'Clock angle', answer: '100°', prompt: 'At exactly 7:20, what is the smaller angle between the hour hand and minute hand of an analog clock? Explain the movement of both hands precisely. End with only the angle, including the degree symbol, on its own line.' },
|
||||
{ sub: '★ Featured', label: 'Painted cube', answer: '24', prompt: 'A 4×4×4 cube is painted on every outside face, then cut into 64 unit cubes. How many unit cubes have exactly two painted faces? Explain how you count them, then end with the final number on its own line.' },
|
||||
|
||||
{ sub: 'Visual', label: 'Draw SVG', prompt: 'Output a complete self-contained HTML file (```html block, no explanation, no other text) that centers a single SVG illustration on a simple background. The SVG must use only inline shapes — no <img>, no external assets, no JavaScript. Make it expressive and detailed. The SVG should depict: a friendly robot' },
|
||||
{ sub: 'Visual explain', label: 'TCP handshake', prompt: 'Output one complete self-contained HTML file in a single ```html block with no prose outside it. Visually explain a TCP connection from client to server using a clear inline SVG sequence: SYN, SYN-ACK, ACK, data transfer, and FIN. Show packet direction, sequence/acknowledgment numbers, and a short plain-language annotation at every step. Use theme-aware CSS variables with a readable dark default, responsive layout, no external assets, and no JavaScript.' },
|
||||
{ sub: 'Visual explain', label: 'Black hole HTML', prompt: 'Output a complete HTML file (```html block, no explanation outside the code) that visually explains how a black hole forms. Use four labeled "frames" laid out left-to-right (or stacked on small screens) showing: 1) a glowing massive star, 2) the star going supernova with shockwave rings, 3) collapse into a singularity, 4) the final black hole with a curved accretion disk and bent light around it. Use only vanilla HTML, CSS, and inline SVG — no JavaScript, no images. Each frame should have a one-sentence caption.' },
|
||||
{ sub: 'Visual explain', label: 'Butterfly ASCII', prompt: 'Explain the butterfly lifecycle using ASCII art. Produce four separate frames in fenced code blocks, in order: egg, caterpillar, chrysalis, adult butterfly. Each frame must be drawn with monospace ASCII characters only and be visually recognizable as the creature/stage. Below each frame add one playful one-line caption (no longer than 15 words) describing what is happening at that stage.' },
|
||||
],
|
||||
@@ -57,8 +60,16 @@ export const EVAL_PROMPTS = {
|
||||
{ sub: 'Web tasks', label: 'Multi-step', prompt: 'Search the web for the current population of the 3 largest cities in the world, then calculate what percentage of the world\'s total population lives in those cities.', toggles: ['web'] },
|
||||
{ sub: 'Web tasks', label: 'Fact check', prompt: 'Fact-check these claims: 1) The Great Wall of China is visible from space. 2) Humans only use 10% of their brains. 3) Lightning never strikes the same place twice. Cite sources.', toggles: ['web'] },
|
||||
{ sub: 'Web tasks', label: 'Compare prices', prompt: 'Find and compare the pricing, features, and limitations of the top 3 cloud GPU providers for machine learning training. Create a markdown comparison table.', toggles: ['web'] },
|
||||
{ sub: 'Web tasks', label: 'Primary source', prompt: 'Find the latest official release notes for Python and summarize the most important developer-facing changes. Prefer the official Python documentation over blogs. Include source links.', toggles: ['web'] },
|
||||
{ sub: 'Web tasks', label: 'JS-heavy page', prompt: 'Find a reliable source for the latest stable Chrome version, then verify it against at least one secondary source. Explain any mismatch clearly and cite both sources.', toggles: ['web'] },
|
||||
{ sub: 'Research', label: 'Paper trail', prompt: 'Find two recent papers about long-context language models, extract each paper\'s claimed contribution, and compare the evidence quality in a short table. Cite the papers.', toggles: ['web'] },
|
||||
{ sub: 'Research', label: 'Source quality', prompt: 'Research whether AI coding assistants improve developer productivity. Reject shallow listicles, use at least one empirical study, and state the strongest caveat.', toggles: ['web'] },
|
||||
{ sub: 'Code tasks', label: 'Script + run', prompt: 'Write a Python script that generates a bar chart of the 5 most common programming languages in 2025 and save it as chart.png. Then run it.' },
|
||||
{ sub: 'Code tasks', label: 'Debug + test', prompt: 'Create a minimal failing test for a function that should parse ISO dates, implement the parser, then run the test and report the result.' },
|
||||
{ sub: 'Code tasks', label: 'Inspect files', prompt: 'Inspect the current project files, identify the main frontend entry point, and summarize the boot order with exact file names.' },
|
||||
{ sub: 'Code tasks', label: 'CLI summarize', prompt: 'Use shell commands to find the 10 largest JavaScript files in this repo and explain which one looks most worth splitting first.' },
|
||||
{ sub: 'Math', label: 'Proof + verify', prompt: 'Prove that the square root of 2 is irrational. Then write a Python program that approximates it using Newton\'s method to 50 decimal places and verify.' },
|
||||
{ sub: 'Math', label: 'Monte Carlo', prompt: 'Estimate pi with a Monte Carlo simulation in Python, run it, then explain how sample size changes the error.' },
|
||||
],
|
||||
html: [
|
||||
{ sub: 'Games', label: 'Snake', prompt: 'Output a complete HTML file (```html block) for a Snake game. ONLY use vanilla HTML, CSS, and JavaScript — no libraries, no Python, no imports, no external files. Canvas-based, neon green snake on dark grid, glowing food, score counter, speed increases, game over + restart. Skip any explanation, just output the code.' },
|
||||
|
||||
+278
-76
@@ -17,28 +17,31 @@ import { EVAL_PROMPTS, WAVE_FRAMES,
|
||||
ICON_PARALLEL, ICON_SEQUENTIAL,
|
||||
EYE_OPEN, EYE_CLOSED, SAVE_ICON, CHAT_ICON,
|
||||
SEND_SVG, VOTES_STORAGE_KEY,
|
||||
} from './icons.js';
|
||||
} from './icons.js?v=20260908compareprompts1';
|
||||
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
|
||||
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js';
|
||||
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260903compareprobe4';
|
||||
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
|
||||
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
|
||||
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js?v=20260908panestatspopup2';
|
||||
import {
|
||||
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
|
||||
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
|
||||
_showModelSwapDropdown, _createAndAppendPane, _autoPreviewHtml,
|
||||
mountMobilePaneTabs, syncShuffleButtonPlacement,
|
||||
paneSettingsButtonHtml, togglePaneSettings,
|
||||
registerPaneActions,
|
||||
} from './panes.js';
|
||||
import { handleVote, buildVoteBar, addFinishBadge, spawnConfetti, _saveVote, registerCompareActions } from './vote.js';
|
||||
import { showScoreboard } from './scoreboard.js';
|
||||
} from './panes.js?v=20260908compareheader1';
|
||||
import { handleVote, buildVoteBar, addFinishBadge, spawnConfetti, _saveVote, registerCompareActions } from './vote.js?v=20260828resendcaldrag1';
|
||||
import { showScoreboard } from './scoreboard.js?v=20260909voteconfirmalign1';
|
||||
|
||||
// ── External dependency imports ──
|
||||
import Storage from '../storage.js';
|
||||
import uiModule from '../ui.js';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import sessionModule from '../sessions.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import themeModule from '../theme.js';
|
||||
import presetsModule from '../presets.js';
|
||||
import themeModule from '../theme.js?v=20260909effectspeed1';
|
||||
import presetsModule from '../presets.js?v=20260908personaname1';
|
||||
import markdownModule from '../markdown.js';
|
||||
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -73,6 +76,97 @@ function isCompareActive() {
|
||||
return state.isActive;
|
||||
}
|
||||
|
||||
function _compareModeLabel() {
|
||||
return ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
|
||||
}
|
||||
|
||||
function _paneModeBadgeHtml(paneIdx) {
|
||||
const mode = String(state._compareMode || 'chat');
|
||||
if (mode !== 'search') return '';
|
||||
const label = 'Search';
|
||||
const detail = ({ agent: 'tools', search: 'web', research: 'sources' }[mode] || 'plain');
|
||||
return '<span class="pane-mode-badge pane-mode-' + escapeHtml(mode) + '" title="' + escapeHtml(label + ' mode') + '">' +
|
||||
'<span class="pane-mode-dot" aria-hidden="true"></span>' +
|
||||
'<span class="pane-mode-label">' + escapeHtml(label) + '</span>' +
|
||||
(mode === 'agent' ? '' : '<span class="pane-mode-detail">' + escapeHtml(detail) + '</span>') +
|
||||
'</span>';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
document.querySelectorAll('.compare-pane .pane-mode-badge').forEach((badge) => {
|
||||
const template = document.createElement('template');
|
||||
const paneIdx = Number(badge.closest('.compare-pane')?.dataset.pane || 0);
|
||||
template.innerHTML = _paneModeBadgeHtml(paneIdx);
|
||||
const replacement = template.content.firstElementChild;
|
||||
if (replacement) badge.replaceWith(replacement);
|
||||
else badge.remove();
|
||||
});
|
||||
const evalWrap = document.getElementById('cmp-eval-wrap');
|
||||
if (evalWrap && typeof evalWrap._renderItems === 'function') evalWrap._renderItems();
|
||||
}
|
||||
|
||||
function _showPaneStatsPopup(summary) {
|
||||
document.querySelectorAll('.pane-stats-popup').forEach((el) => el._dismiss ? el._dismiss() : el.remove());
|
||||
const values = String(summary.textContent || '').split(' · ').filter(Boolean);
|
||||
if (!values.length) return;
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'pane-stats-popup';
|
||||
popup.setAttribute('role', 'dialog');
|
||||
popup.setAttribute('aria-label', 'Response statistics');
|
||||
popup.innerHTML = '<div class="pane-stats-popup-title">Response stats</div>' + values.map((value) => {
|
||||
let label = 'Value';
|
||||
let display = value;
|
||||
if (value.startsWith('TTFT ')) { label = 'First token'; display = value.slice(5); }
|
||||
else if (value.endsWith(' tok')) { label = 'Output'; display = value.replace(/ tok$/, ' tokens'); }
|
||||
else if (value.endsWith('/s')) label = 'Speed';
|
||||
else if (value.endsWith('% ctx')) { label = 'Context'; display = value.replace(/ ctx$/, ''); }
|
||||
else if (value.startsWith('$')) label = 'Cost';
|
||||
return '<div class="pane-stats-popup-row"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(display) + '</strong></div>';
|
||||
}).join('');
|
||||
document.body.appendChild(popup);
|
||||
const rect = summary.getBoundingClientRect();
|
||||
const popupRect = popup.getBoundingClientRect();
|
||||
popup.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - popupRect.width - 8)) + 'px';
|
||||
popup.style.top = (rect.bottom + popupRect.height + 6 <= window.innerHeight
|
||||
? rect.bottom + 6
|
||||
: Math.max(8, rect.top - popupRect.height - 6)) + 'px';
|
||||
summary.setAttribute('aria-expanded', 'true');
|
||||
bindMenuDismiss(popup, () => {
|
||||
popup.remove();
|
||||
summary.setAttribute('aria-expanded', 'false');
|
||||
}, (event) => !popup.contains(event.target) && event.target !== summary);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// ── closeCompare ──
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -92,7 +186,9 @@ async function toggleMode() {
|
||||
deactivate(true);
|
||||
return false;
|
||||
}
|
||||
if (state._openingSelector) return false;
|
||||
|
||||
state._openingSelector = true;
|
||||
try {
|
||||
const confirmed = await showModelSelector();
|
||||
if (!confirmed) return false;
|
||||
@@ -104,6 +200,8 @@ async function toggleMode() {
|
||||
} catch (err) {
|
||||
console.error('Compare toggleMode error:', err);
|
||||
return false;
|
||||
} finally {
|
||||
state._openingSelector = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,12 +264,7 @@ async function deactivate(teardown) {
|
||||
});
|
||||
|
||||
// Restore agent/chat mode to what it was before compare
|
||||
const _ts = Storage.loadToggleState();
|
||||
_ts.mode = state._savedMode;
|
||||
Storage.saveToggleState(_ts);
|
||||
const _ab2 = document.getElementById('mode-agent-btn'), _cb2 = document.getElementById('mode-chat-btn');
|
||||
if (_ab2 && _cb2) { _ab2.classList.toggle('active', state._savedMode === 'agent'); _cb2.classList.toggle('active', state._savedMode === 'chat'); }
|
||||
document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = state._savedMode === 'agent' ? '' : 'none'; });
|
||||
_setToolbarMode(state._savedMode, true);
|
||||
|
||||
// Delete unsaved sessions, then reload
|
||||
if (teardown) {
|
||||
@@ -206,7 +299,9 @@ async function _buildCompareUI() {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const m = state._selectedModels[i];
|
||||
const fd = new FormData();
|
||||
fd.append('name', '[CMP] ' + modelShorts[i]);
|
||||
// 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) {
|
||||
@@ -219,8 +314,12 @@ async function _buildCompareUI() {
|
||||
sessionIds.push(data.id);
|
||||
}
|
||||
state._paneSessionIds = sessionIds;
|
||||
state._paneGenerationSettings = sessionIds.map(() => ({
|
||||
thinking_mode: '', temperature_override: null, max_tokens_override: null,
|
||||
}));
|
||||
} else {
|
||||
state._paneSessionIds = [];
|
||||
state._paneGenerationSettings = [];
|
||||
}
|
||||
state._paneMetrics = state._selectedModels.map(() => null);
|
||||
state._abortControllers = state._selectedModels.map(() => null);
|
||||
@@ -252,19 +351,30 @@ async function _buildCompareUI() {
|
||||
if (el) state._savedIndicatorDisplay[id] = el.style.display;
|
||||
});
|
||||
|
||||
// 5. Save current mode and lock to the right one for this compare type
|
||||
// 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';
|
||||
_toggleState.mode = _targetMode;
|
||||
Storage.saveToggleState(_toggleState);
|
||||
_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.classList.toggle('active', _targetMode === 'agent');
|
||||
_cb.classList.toggle('active', _targetMode === 'chat');
|
||||
_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 = 'none'; _modeToggle.style.opacity = '0.4'; }
|
||||
if (_modeToggle) { _modeToggle.style.pointerEvents = ''; _modeToggle.style.opacity = ''; }
|
||||
|
||||
// 6. Force tool toggles per compare mode
|
||||
disableToolToggles();
|
||||
@@ -283,6 +393,7 @@ async function _buildCompareUI() {
|
||||
// 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';
|
||||
@@ -296,9 +407,9 @@ async function _buildCompareUI() {
|
||||
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;';
|
||||
const _modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
|
||||
headerLabel.textContent = 'Comparing' + _modeLabel + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
|
||||
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.
|
||||
@@ -306,7 +417,7 @@ async function _buildCompareUI() {
|
||||
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="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>';
|
||||
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);
|
||||
@@ -318,11 +429,10 @@ async function _buildCompareUI() {
|
||||
|
||||
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.innerHTML = '<svg class="compare-check-icon" 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 class="compare-check-label">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.
|
||||
@@ -336,6 +446,7 @@ async function _buildCompareUI() {
|
||||
// (Scoreboard button moved into the vote bar, next to Tie — see vote.js.)
|
||||
|
||||
const exportWrap = document.createElement('div');
|
||||
exportWrap.className = 'compare-export-wrap';
|
||||
exportWrap.style.cssText = 'position:relative;display:inline-flex;';
|
||||
const exportBtn = document.createElement('button');
|
||||
exportBtn.id = 'compare-export-btn';
|
||||
@@ -347,15 +458,41 @@ async function _buildCompareUI() {
|
||||
_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);
|
||||
shuffleBtn.addEventListener('click', () => {
|
||||
shufflePanePositions();
|
||||
syncShuffleButtonPlacement(false);
|
||||
});
|
||||
|
||||
const moreWrap = document.createElement('div');
|
||||
moreWrap.className = 'compare-more-wrap';
|
||||
const moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'compare-more-btn';
|
||||
moreBtn.type = 'button';
|
||||
moreBtn.title = 'Compare actions';
|
||||
moreBtn.setAttribute('aria-label', 'Compare actions');
|
||||
moreBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>';
|
||||
const moreMenu = document.createElement('div');
|
||||
moreMenu.className = 'compare-more-menu';
|
||||
moreMenu.append(exportWrap, shuffleBtn, checkBtn);
|
||||
moreBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
moreMenu.classList.toggle('is-open');
|
||||
moreBtn.setAttribute('aria-expanded', moreMenu.classList.contains('is-open') ? 'true' : 'false');
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!moreWrap.contains(e.target)) {
|
||||
moreMenu.classList.remove('is-open');
|
||||
moreBtn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}, true);
|
||||
moreWrap.append(moreBtn, moreMenu);
|
||||
headerActions.appendChild(moreWrap);
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.id = 'compare-add-btn';
|
||||
@@ -363,21 +500,24 @@ async function _buildCompareUI() {
|
||||
addBtn.title = 'Add model pane';
|
||||
addBtn.style.cssText = _btnCSS;
|
||||
addBtn.addEventListener('click', () => _addPane(addBtn));
|
||||
headerActions.appendChild(addBtn);
|
||||
addBtn.className = 'compare-add-flap';
|
||||
addBtn.setAttribute('aria-label', 'Add model pane');
|
||||
|
||||
const addMenuBtn = document.createElement('button');
|
||||
addMenuBtn.id = 'compare-add-menu-btn';
|
||||
addMenuBtn.type = 'button';
|
||||
addMenuBtn.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>Add model</span>';
|
||||
addMenuBtn.title = 'Add model pane';
|
||||
addMenuBtn.addEventListener('click', () => _addPane(addMenuBtn));
|
||||
moreMenu.append(addMenuBtn, shuffleBtn, checkBtn, exportWrap);
|
||||
|
||||
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.className = 'close-btn compare-close-btn';
|
||||
closeBtn.innerHTML = '';
|
||||
closeBtn.title = 'Close compare mode';
|
||||
// Match Export/Score/Shuffle/Model styling so the X sits flush with
|
||||
// the rest of the toolbar instead of being a 24×24 bordered square.
|
||||
closeBtn.style.cssText = _btnCSS;
|
||||
closeBtn.addEventListener('click', () => deactivate(true));
|
||||
headerActions.appendChild(closeBtn);
|
||||
|
||||
// Move Export to the far left of the action cluster (per user preference).
|
||||
headerActions.insertBefore(exportWrap, headerActions.firstChild);
|
||||
|
||||
headerBar.appendChild(headerActions);
|
||||
container.appendChild(headerBar);
|
||||
state._compareElements.push(headerBar);
|
||||
@@ -396,16 +536,26 @@ async function _buildCompareUI() {
|
||||
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">' +
|
||||
'<div class="pane-header-row pane-header-primary">' +
|
||||
'<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>' +
|
||||
'<div class="pane-primary-actions">' +
|
||||
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
|
||||
paneSettingsButtonHtml(i) +
|
||||
'<button class="close-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane"></button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="pane-header-row pane-header-secondary">' +
|
||||
'<div class="pane-stats">' + _paneModeBadgeHtml(i) +
|
||||
'<span class="pane-timer" id="cmp-timer-' + i + '"></span>' +
|
||||
'<span class="pane-summary" id="cmp-summary-' + i + '" role="button" tabindex="0" aria-label="Show response metrics"></span>' +
|
||||
'<span class="pane-finish-badge" id="cmp-badge-' + i + '"></span>' +
|
||||
'</div>' +
|
||||
'<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>' +
|
||||
'<button class="pane-action-btn pane-needs-response" data-action="reroll" data-pane="' + i + '" title="Re-roll" style="display:none;">' + ICON_REROLL + '</button>' +
|
||||
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="chat-history" id="cmp-history-' + i + '"></div>' +
|
||||
@@ -419,6 +569,11 @@ async function _buildCompareUI() {
|
||||
grid.appendChild(pane);
|
||||
}
|
||||
grid.addEventListener('click', (e) => {
|
||||
const summary = e.target.closest('.pane-summary');
|
||||
if (summary && summary.textContent.trim()) {
|
||||
_showPaneStatsPopup(summary);
|
||||
return;
|
||||
}
|
||||
const voteBtn = e.target.closest('.pane-vote-btn');
|
||||
if (voteBtn) {
|
||||
e.stopPropagation();
|
||||
@@ -427,7 +582,7 @@ async function _buildCompareUI() {
|
||||
handleVote(idx);
|
||||
return;
|
||||
}
|
||||
const actionBtn = e.target.closest('.pane-action-btn');
|
||||
const actionBtn = e.target.closest('[data-action]');
|
||||
if (actionBtn) {
|
||||
e.stopPropagation();
|
||||
const action = actionBtn.dataset.action;
|
||||
@@ -437,6 +592,7 @@ async function _buildCompareUI() {
|
||||
else if (action === 'reroll') rerollPane(idx);
|
||||
else if (action === 'expand') toggleExpandPane(idx, actionBtn);
|
||||
else if (action === 'preview') togglePanePreview(idx);
|
||||
else if (action === 'settings') togglePaneSettings(idx, actionBtn);
|
||||
else if (action === 'close') _removePane(idx);
|
||||
return;
|
||||
}
|
||||
@@ -447,7 +603,17 @@ async function _buildCompareUI() {
|
||||
_showModelSwapDropdown(idx, titleBtn);
|
||||
}
|
||||
});
|
||||
grid.addEventListener('keydown', (e) => {
|
||||
const summary = e.target.closest('.pane-summary');
|
||||
if (summary && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
_showPaneStatsPopup(summary);
|
||||
}
|
||||
});
|
||||
container.appendChild(grid);
|
||||
grid.appendChild(addBtn);
|
||||
const mobileTabs = mountMobilePaneTabs(container, grid, (anchor) => _addPane(anchor));
|
||||
state._compareElements.push(mobileTabs);
|
||||
state._compareElements.push(grid);
|
||||
|
||||
// 10. Vote bar placeholder
|
||||
@@ -469,8 +635,8 @@ async function _buildCompareUI() {
|
||||
}
|
||||
const msgTA = document.getElementById('message');
|
||||
if (msgTA) {
|
||||
msgTA.placeholder = 'Enter prompt for all models...';
|
||||
requestAnimationFrame(() => msgTA.focus());
|
||||
msgTA.placeholder = window.matchMedia('(max-width: 767px)').matches ? '' : 'Enter prompt for all models...';
|
||||
if (window.innerWidth > 768) requestAnimationFrame(() => msgTA.focus());
|
||||
}
|
||||
|
||||
// Eval-prompts picker — sits inside the message box at top-right (where
|
||||
@@ -885,8 +1051,7 @@ async function _executeCompare(message) {
|
||||
let sharedSearchContext = null;
|
||||
let sharedSearchSources = null;
|
||||
const webChk = document.getElementById('web-toggle');
|
||||
const toggleState = Storage.loadToggleState();
|
||||
const isAgentMode = (toggleState.mode || 'chat') === 'agent';
|
||||
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) {
|
||||
@@ -954,11 +1119,16 @@ async function _executeCompare(message) {
|
||||
console.error('Compare error:', err);
|
||||
if (uiModule) uiModule.showError('Compare failed: ' + err.message);
|
||||
} finally {
|
||||
state._streaming = false;
|
||||
_setSendBtn('send');
|
||||
// Re-enable header buttons
|
||||
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
|
||||
b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
|
||||
// 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' : '';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1011,33 +1181,38 @@ function _buildComparisonMarkdown() {
|
||||
}
|
||||
|
||||
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 inCompareMenu = !!btn.closest('.compare-more-menu');
|
||||
const menuLeft = inCompareMenu ? r.right + 6 : r.left;
|
||||
const menuTop = inCompareMenu ? r.top : r.bottom + 4;
|
||||
m.style.cssText = 'position:fixed;z-index:10001;top:' + menuTop + 'px;left:' + menuLeft + 'px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3),0 0 0 1px color-mix(in srgb,var(--fg) 5%,transparent);padding:6px;font-size:12px;display:flex;flex-direction:column;gap:2px;min-width:170px;backdrop-filter:blur(12px);';
|
||||
const opts = [
|
||||
{ label: 'Copy as Markdown', fn: () => _exportCopyMarkdown(btn) },
|
||||
{ label: 'Download .md', fn: () => _exportDownloadMarkdown() },
|
||||
{ label: 'Print / Save PDF', fn: () => _exportPrint() },
|
||||
{ label: 'Copy as Markdown', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>', fn: () => _exportCopyMarkdown(btn) },
|
||||
{ label: 'Download .md', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>', fn: () => _exportDownloadMarkdown() },
|
||||
{ label: 'Print / Save PDF', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>', 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.className = 'compare-export-item';
|
||||
item.innerHTML = o.icon + '<span>' + o.label + '</span>';
|
||||
item.style.cssText = 'background:none;border:1px solid transparent;color:var(--fg);text-align:left;padding:8px 10px;border-radius:6px;cursor:pointer;font:inherit;font-size:11px;line-height:1.3;';
|
||||
item.addEventListener('click', () => { _closeExportMenu(); o.fn(); });
|
||||
m.appendChild(item);
|
||||
}
|
||||
document.body.appendChild(m);
|
||||
const mr = m.getBoundingClientRect();
|
||||
if (mr.right > window.innerWidth - 8) m.style.left = Math.max(8, r.left - mr.width - 6) + 'px';
|
||||
if (mr.bottom > window.innerHeight - 8) m.style.top = Math.max(8, window.innerHeight - mr.height - 8) + 'px';
|
||||
_exportMenuEl = m;
|
||||
setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0);
|
||||
}
|
||||
function _closeExportMenu() {
|
||||
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
||||
_closeExportMenu = bindMenuDismiss(m, () => {
|
||||
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
||||
}, (ev) => !m.contains(ev.target));
|
||||
}
|
||||
|
||||
async function _exportCopyMarkdown(_btn) {
|
||||
@@ -1084,6 +1259,7 @@ function _exportPrint() {
|
||||
// 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}' +
|
||||
@@ -1179,10 +1355,10 @@ function _setupEvalPicker() {
|
||||
btn.type = 'button';
|
||||
btn.id = 'cmp-eval-btn';
|
||||
btn.className = 'cmp-eval-btn';
|
||||
btn.title = 'Insert an evaluation prompt';
|
||||
btn.title = 'Insert a comparison 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>'
|
||||
+ '<span class="cmp-eval-label">Prompt sets</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');
|
||||
@@ -1191,6 +1367,15 @@ function _setupEvalPicker() {
|
||||
|
||||
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');
|
||||
@@ -1226,8 +1411,9 @@ function _setupEvalPicker() {
|
||||
const ta = document.getElementById('message');
|
||||
if (ta) {
|
||||
ta.value = decodeURIComponent(item.dataset.prompt);
|
||||
ta.dataset.comparePromptPreset = 'true';
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
ta.focus();
|
||||
if (window.innerWidth > 768) ta.focus();
|
||||
}
|
||||
const ans = item.dataset.answer ? decodeURIComponent(item.dataset.answer) : '';
|
||||
_showExpectedAnswer(ans);
|
||||
@@ -1251,13 +1437,14 @@ function _setupEvalPicker() {
|
||||
};
|
||||
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.
|
||||
// Expected-answer chip placed above the input when a prompt includes a
|
||||
// known answer, making side-by-side comparison easier.
|
||||
const hintChip = document.createElement('div');
|
||||
hintChip.className = 'cmp-eval-expected hidden';
|
||||
hintChip.id = 'cmp-eval-expected';
|
||||
@@ -1296,9 +1483,23 @@ function _setupEvalPicker() {
|
||||
// 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 clearPresetBtn = document.createElement('button');
|
||||
clearPresetBtn.type = 'button';
|
||||
clearPresetBtn.className = 'cmp-eval-clear';
|
||||
clearPresetBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Clear prompt</span>';
|
||||
clearPresetBtn.addEventListener('click', () => {
|
||||
if (!ta) return;
|
||||
ta.value = '';
|
||||
delete ta.dataset.comparePromptPreset;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
_showExpectedAnswer('');
|
||||
if (window.innerWidth > 768) ta.focus();
|
||||
});
|
||||
inputTop.appendChild(clearPresetBtn);
|
||||
const _syncEvalVisibility = () => {
|
||||
const hasText = ta && ta.value.trim().length > 0;
|
||||
wrap.style.display = hasText ? 'none' : '';
|
||||
clearPresetBtn.style.display = hasText && ta.dataset.comparePromptPreset === 'true' ? '' : 'none';
|
||||
if (hasText) menu.classList.add('hidden');
|
||||
};
|
||||
if (ta) ta.addEventListener('input', _syncEvalVisibility);
|
||||
@@ -1307,6 +1508,7 @@ function _setupEvalPicker() {
|
||||
// Stash cleanup so cleanupResults() can detach the doc listener and
|
||||
// restore the model-picker when compare deactivates.
|
||||
wrap._cleanup = () => {
|
||||
clearPresetBtn.remove();
|
||||
document.removeEventListener('click', _onDocClick);
|
||||
if (ta) ta.removeEventListener('input', _syncEvalVisibility);
|
||||
if (modelWrap) modelWrap.style.display = prevModelDisplay || '';
|
||||
@@ -1450,7 +1652,7 @@ async function showShufflePoolEditor() {
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
registerCompareActions({ stopAll, resetCompare });
|
||||
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml });
|
||||
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });
|
||||
registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels });
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// compare/models.js — model classification, fetching, display names, persistence
|
||||
import Storage from '../storage.js';
|
||||
import state from './state.js';
|
||||
import uiModule from '../ui.js';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import { sortModelObjects } from '../modelSort.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -84,9 +85,9 @@ async function fetchModels() {
|
||||
});
|
||||
});
|
||||
}
|
||||
state._fetchModelsCache = models;
|
||||
state._fetchModelsCache = sortModelObjects(models);
|
||||
state._fetchModelsCacheTime = now;
|
||||
return models;
|
||||
return state._fetchModelsCache;
|
||||
}
|
||||
|
||||
// ── Shuffle pool persistence ──
|
||||
|
||||
+450
-78
@@ -1,15 +1,16 @@
|
||||
// compare/panes.js — pane lifecycle, actions, layout
|
||||
import state from './state.js';
|
||||
import { _persistSelections } from './models.js';
|
||||
import { buildVoteBar } from './vote.js';
|
||||
import { buildVoteBar } from './vote.js?v=20260828resendcaldrag1';
|
||||
import {
|
||||
ICON_REROLL, ICON_COPY, ICON_EXPAND, ICON_COLLAPSE, ICON_CLOSE,
|
||||
ICON_PLAY, ICON_CODE, SEND_SVG,
|
||||
} from './icons.js';
|
||||
ICON_PLAY, ICON_CODE, SEND_SVG, ICON_DICE,
|
||||
} from './icons.js?v=20260908compareprompts1';
|
||||
import { _clearProbeWaves } from './probe.js';
|
||||
import Storage from '../storage.js';
|
||||
import uiModule from '../ui.js';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -32,6 +33,342 @@ function registerPaneActions({ setSendBtn, deactivate, streamToPane, renderSearc
|
||||
/** Slot label: A/B/C in parallel mode, 1/2/3 in sequential. */
|
||||
function _slotChar(i) { return state._parallel ? String.fromCharCode(65 + i) : String(i + 1); }
|
||||
|
||||
/** Keep Shuffle compact until a larger comparison makes it useful as a
|
||||
* direct action. It returns to the kebab after being used. */
|
||||
function syncShuffleButtonPlacement(showDirect = state._selectedModels.length > 2) {
|
||||
const shuffleBtn = document.getElementById('compare-shuffle-btn');
|
||||
const moreMenu = document.querySelector('.compare-more-menu');
|
||||
const moreWrap = document.querySelector('.compare-more-wrap');
|
||||
const headerActions = moreWrap?.parentElement;
|
||||
if (!shuffleBtn || !moreMenu || !moreWrap || !headerActions) return;
|
||||
if (showDirect) {
|
||||
if (shuffleBtn.parentElement !== headerActions) headerActions.insertBefore(shuffleBtn, moreWrap);
|
||||
} else if (shuffleBtn.parentElement !== moreMenu) {
|
||||
moreMenu.appendChild(shuffleBtn);
|
||||
}
|
||||
}
|
||||
|
||||
function _paneModeBadgeHtml(paneIdx) {
|
||||
const mode = String(state._compareMode || 'chat');
|
||||
if (mode !== 'search') return '';
|
||||
const label = ({ agent: 'Agent', search: 'Search', research: 'Research' }[mode] || 'Chat');
|
||||
const detail = ({ agent: 'tools', search: 'web', research: 'sources' }[mode] || 'plain');
|
||||
return '<span class="pane-mode-badge pane-mode-' + escapeHtml(mode) + '" title="' + escapeHtml(label + ' mode') + '">' +
|
||||
'<span class="pane-mode-dot" aria-hidden="true"></span>' +
|
||||
'<span class="pane-mode-label">' + escapeHtml(label) + '</span>' +
|
||||
(mode === 'agent' ? '' : '<span class="pane-mode-detail">' + escapeHtml(detail) + '</span>') +
|
||||
'</span>';
|
||||
}
|
||||
|
||||
const PANE_SETTINGS_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>';
|
||||
|
||||
function paneSettingsButtonHtml(paneIdx) {
|
||||
return '<button type="button" class="pane-action-btn pane-settings-btn" data-action="settings" data-pane="' + Number(paneIdx) + '" title="Inference settings" aria-label="Inference settings" aria-haspopup="dialog" aria-expanded="false"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>';
|
||||
}
|
||||
|
||||
function _paneModelRequiresThinking(paneIdx) {
|
||||
const modelId = String(state._selectedModels[paneIdx]?.model || '').toLowerCase().split(':', 1)[0];
|
||||
return modelId === 'x-ai/grok-4.5' || modelId === 'grok-4.5';
|
||||
}
|
||||
|
||||
async function _savePaneGenerationSettings(paneIdx, change) {
|
||||
const sid = state._paneSessionIds[paneIdx];
|
||||
if (!sid) return false;
|
||||
const current = state._paneGenerationSettings[paneIdx] || {};
|
||||
const next = {
|
||||
thinking_mode: current.thinking_mode || '',
|
||||
temperature_override: current.temperature_override ?? null,
|
||||
max_tokens_override: current.max_tokens_override ?? null,
|
||||
...change,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${state.API_BASE}/api/session/${encodeURIComponent(sid)}/generation-settings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
state._paneGenerationSettings[paneIdx] = { ...next, ...await res.json() };
|
||||
return true;
|
||||
} catch (err) {
|
||||
uiModule.showError(`Could not save pane settings: ${err.message || err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePaneSettings(paneIdx, anchorBtn) {
|
||||
const existing = document.querySelector('.compare-pane-settings-popup');
|
||||
if (existing) {
|
||||
const same = existing.dataset.pane === String(paneIdx);
|
||||
if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
|
||||
if (same) return;
|
||||
}
|
||||
const settings = state._paneGenerationSettings[paneIdx] || {
|
||||
thinking_mode: '', temperature_override: null, max_tokens_override: null,
|
||||
};
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'chat-context-popup compare-pane-settings-popup';
|
||||
popup.dataset.pane = String(paneIdx);
|
||||
popup.setAttribute('role', 'dialog');
|
||||
popup.setAttribute('aria-label', 'Inference settings');
|
||||
popup.innerHTML = '<div class="chat-context-popup-title">Inference settings</div>';
|
||||
|
||||
const thinkingRequired = _paneModelRequiresThinking(paneIdx);
|
||||
const thinkingOn = thinkingRequired || settings.thinking_mode === 'on';
|
||||
const thinkingRow = document.createElement('div');
|
||||
thinkingRow.className = 'chat-context-toggle-row';
|
||||
thinkingRow.innerHTML = '<div class="chat-context-toggle-copy"><span>Thinking</span><span class="chat-context-toggle-state">' + (thinkingRequired ? 'Required' : (thinkingOn ? 'On' : 'Off')) + '</span></div>';
|
||||
const thinkingToggle = document.createElement('button');
|
||||
thinkingToggle.type = 'button';
|
||||
thinkingToggle.className = `chat-context-toggle${thinkingOn ? ' active' : ''}`;
|
||||
thinkingToggle.setAttribute('role', 'switch');
|
||||
thinkingToggle.setAttribute('aria-checked', thinkingOn ? 'true' : 'false');
|
||||
thinkingToggle.disabled = thinkingRequired;
|
||||
if (thinkingRequired) thinkingToggle.title = 'This model requires reasoning';
|
||||
thinkingToggle.addEventListener('click', async () => {
|
||||
if (thinkingRequired) return;
|
||||
const next = !thinkingToggle.classList.contains('active');
|
||||
thinkingToggle.disabled = true;
|
||||
if (await _savePaneGenerationSettings(paneIdx, { thinking_mode: next ? 'on' : 'off' })) {
|
||||
thinkingToggle.classList.toggle('active', next);
|
||||
thinkingToggle.setAttribute('aria-checked', next ? 'true' : 'false');
|
||||
thinkingRow.querySelector('.chat-context-toggle-state').textContent = next ? 'On' : 'Off';
|
||||
}
|
||||
thinkingToggle.disabled = false;
|
||||
});
|
||||
thinkingRow.appendChild(thinkingToggle);
|
||||
popup.appendChild(thinkingRow);
|
||||
|
||||
const addSlider = (label, value, min, max, step, formatter, key, normalize) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'chat-context-threshold-row';
|
||||
row.innerHTML = '<div class="chat-context-threshold-top"><span>' + label + '</span><span>' + formatter(value) + '</span></div>';
|
||||
const input = document.createElement('input');
|
||||
Object.assign(input, { type: 'range', min: String(min), max: String(max), step: String(step), value: String(value), className: 'chat-context-threshold-slider preset-range' });
|
||||
input.addEventListener('input', () => { row.querySelector('.chat-context-threshold-top span:last-child').textContent = formatter(Number(input.value)); });
|
||||
input.addEventListener('change', async () => {
|
||||
input.disabled = true;
|
||||
await _savePaneGenerationSettings(paneIdx, { [key]: normalize(Number(input.value)) });
|
||||
input.disabled = false;
|
||||
});
|
||||
row.appendChild(input);
|
||||
popup.appendChild(row);
|
||||
};
|
||||
addSlider('Temperature', settings.temperature_override ?? 1, 0, 2, 0.1, value => Number(value).toFixed(1), 'temperature_override', value => value);
|
||||
addSlider('Max tokens', settings.max_tokens_override ?? 8448, 256, 8448, 256, value => value > 8192 ? 'No limit' : Number(value).toLocaleString(), 'max_tokens_override', value => value > 8192 ? null : value);
|
||||
|
||||
document.body.appendChild(popup);
|
||||
const rect = anchorBtn.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const popupRect = popup.getBoundingClientRect();
|
||||
popup.style.left = Math.max(margin, Math.min(rect.left, window.innerWidth - popupRect.width - margin)) + 'px';
|
||||
popup.style.top = Math.max(margin, Math.min(rect.bottom + 5, window.innerHeight - popupRect.height - margin)) + 'px';
|
||||
anchorBtn.setAttribute('aria-expanded', 'true');
|
||||
bindMenuDismiss(popup, () => {
|
||||
popup.remove();
|
||||
anchorBtn.setAttribute('aria-expanded', 'false');
|
||||
}, event => !popup.contains(event.target) && event.target !== anchorBtn);
|
||||
}
|
||||
|
||||
function _isMobileCompare() {
|
||||
return window.matchMedia('(max-width: 768px)').matches;
|
||||
}
|
||||
|
||||
function _mobilePaneLabel(index) {
|
||||
if (state._blindMode) return 'Model ' + _slotChar(index);
|
||||
return state._selectedModels[index]?.name || 'Model ' + (index + 1);
|
||||
}
|
||||
|
||||
/** Keep the phone tab strip and its single visible pane in sync. */
|
||||
function refreshMobilePaneTabs(preferredIndex = state._activeMobilePane) {
|
||||
const tabs = document.querySelector('.compare-mobile-tabs');
|
||||
const grid = document.querySelector('.compare-grid');
|
||||
if (!tabs || !grid) return;
|
||||
|
||||
const panes = Array.from(grid.querySelectorAll(':scope > .compare-pane'));
|
||||
if (!panes.length) {
|
||||
tabs.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
state._activeMobilePane = Math.max(0, Math.min(Number(preferredIndex) || 0, panes.length - 1));
|
||||
const retained = new Set();
|
||||
panes.forEach((pane, index) => {
|
||||
const paneId = 'cmp-pane-' + index;
|
||||
const tabId = 'cmp-mobile-tab-' + index;
|
||||
pane.id = paneId;
|
||||
pane.setAttribute('role', 'tabpanel');
|
||||
pane.setAttribute('aria-labelledby', tabId);
|
||||
|
||||
let tab = tabs.querySelector(`[data-pane="${index}"]`);
|
||||
if (!tab) {
|
||||
tab = document.createElement('button');
|
||||
tab.type = 'button';
|
||||
tab.className = 'compare-mobile-tab';
|
||||
tab.setAttribute('role', 'tab');
|
||||
tab.innerHTML = '<span class="compare-mobile-tab-slot"></span><span class="compare-mobile-tab-label"></span><span class="compare-mobile-tab-state" aria-hidden="true"></span>';
|
||||
}
|
||||
retained.add(tab);
|
||||
tab.id = tabId;
|
||||
tab.dataset.pane = String(index);
|
||||
tab.setAttribute('aria-controls', paneId);
|
||||
tab.querySelector('.compare-mobile-tab-slot').textContent = _slotChar(index);
|
||||
tab.querySelector('.compare-mobile-tab-label').textContent = _mobilePaneLabel(index);
|
||||
tab.classList.toggle('is-streaming', pane.classList.contains('is-streaming'));
|
||||
tab.classList.toggle('is-awaiting-input', pane.classList.contains('is-awaiting-input'));
|
||||
tab.classList.toggle('is-done', pane.classList.contains('is-done'));
|
||||
tab.classList.toggle('is-failed', pane.classList.contains('is-failed'));
|
||||
tabs.appendChild(tab);
|
||||
});
|
||||
|
||||
tabs.querySelectorAll('.compare-mobile-tab').forEach(tab => {
|
||||
if (!retained.has(tab)) tab.remove();
|
||||
});
|
||||
|
||||
let addButton = tabs.querySelector('.compare-mobile-add');
|
||||
if (!addButton && typeof tabs._onAddPane === 'function') {
|
||||
addButton = document.createElement('button');
|
||||
addButton.type = 'button';
|
||||
addButton.className = 'compare-mobile-add';
|
||||
addButton.title = 'Add model';
|
||||
addButton.setAttribute('aria-label', 'Add model');
|
||||
addButton.innerHTML = '<span aria-hidden="true">+</span><span>Add</span>';
|
||||
addButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
tabs._onAddPane(addButton);
|
||||
});
|
||||
}
|
||||
if (addButton) {
|
||||
addButton.disabled = !!state._streaming;
|
||||
addButton.style.display = panes.length >= 8 ? 'none' : '';
|
||||
tabs.appendChild(addButton);
|
||||
}
|
||||
|
||||
const mobile = _isMobileCompare();
|
||||
panes.forEach((pane, index) => {
|
||||
const active = index === state._activeMobilePane;
|
||||
pane.classList.toggle('compare-pane-mobile-active', active);
|
||||
if (mobile) pane.setAttribute('aria-hidden', active ? 'false' : 'true');
|
||||
else pane.removeAttribute('aria-hidden');
|
||||
});
|
||||
tabs.querySelectorAll('.compare-mobile-tab').forEach((tab, index) => {
|
||||
const active = index === state._activeMobilePane;
|
||||
tab.classList.toggle('active', active);
|
||||
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
tab.tabIndex = active ? 0 : -1;
|
||||
});
|
||||
}
|
||||
|
||||
function activateMobilePane(index, { focus = false } = {}) {
|
||||
state._activeMobilePane = index;
|
||||
refreshMobilePaneTabs(index);
|
||||
const tab = document.querySelector(`.compare-mobile-tab[data-pane="${state._activeMobilePane}"]`);
|
||||
if (tab && _isMobileCompare()) {
|
||||
tab.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
|
||||
if (focus) tab.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount a horizontally scrollable tab row without unmounting inactive streams. */
|
||||
function mountMobilePaneTabs(container, grid, onAddPane) {
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'compare-mobile-tabs';
|
||||
tabs.setAttribute('role', 'tablist');
|
||||
tabs.setAttribute('aria-label', 'Compared models');
|
||||
tabs._onAddPane = onAddPane;
|
||||
container.insertBefore(tabs, grid);
|
||||
|
||||
const onClick = (event) => {
|
||||
const tab = event.target.closest('.compare-mobile-tab');
|
||||
if (tab) activateMobilePane(Number(tab.dataset.pane));
|
||||
};
|
||||
const onKeyDown = (event) => {
|
||||
if (!event.target.closest('.compare-mobile-tab')) return;
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const last = Math.max(0, state._selectedModels.length - 1);
|
||||
let next = state._activeMobilePane;
|
||||
if (event.key === 'ArrowLeft') next = Math.max(0, next - 1);
|
||||
if (event.key === 'ArrowRight') next = Math.min(last, next + 1);
|
||||
if (event.key === 'Home') next = 0;
|
||||
if (event.key === 'End') next = last;
|
||||
activateMobilePane(next, { focus: true });
|
||||
};
|
||||
const onResize = () => refreshMobilePaneTabs();
|
||||
tabs.addEventListener('click', onClick);
|
||||
tabs.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
// On phones, swipe across the response card to move to the adjacent model.
|
||||
// Keep the gesture vertical-scroll friendly and don't steal touches from
|
||||
// buttons, links, selects, or editable response content.
|
||||
let swipeStart = null;
|
||||
const onTouchStart = (event) => {
|
||||
if (!_isMobileCompare() || event.touches.length !== 1) {
|
||||
swipeStart = null;
|
||||
return;
|
||||
}
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (target?.closest('button, a, select, input, textarea, [contenteditable="true"]')) {
|
||||
swipeStart = null;
|
||||
return;
|
||||
}
|
||||
const touch = event.touches[0];
|
||||
swipeStart = { x: touch.clientX, y: touch.clientY };
|
||||
};
|
||||
const onTouchEnd = (event) => {
|
||||
if (!swipeStart || !_isMobileCompare() || event.changedTouches.length !== 1) return;
|
||||
const touch = event.changedTouches[0];
|
||||
const dx = touch.clientX - swipeStart.x;
|
||||
const dy = touch.clientY - swipeStart.y;
|
||||
swipeStart = null;
|
||||
if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy) * 1.25) return;
|
||||
const last = Math.max(0, state._selectedModels.length - 1);
|
||||
const next = dx < 0
|
||||
? Math.min(last, state._activeMobilePane + 1)
|
||||
: Math.max(0, state._activeMobilePane - 1);
|
||||
if (next !== state._activeMobilePane) activateMobilePane(next);
|
||||
};
|
||||
grid.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
grid.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
|
||||
const observer = new MutationObserver(() => refreshMobilePaneTabs());
|
||||
observer.observe(grid, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-pane'],
|
||||
});
|
||||
tabs._cleanup = () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', onResize);
|
||||
grid.removeEventListener('touchstart', onTouchStart);
|
||||
grid.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
refreshMobilePaneTabs(0);
|
||||
return tabs;
|
||||
}
|
||||
|
||||
function _showShuffleNotice() {
|
||||
const grid = document.querySelector('.compare-grid');
|
||||
if (!grid) return;
|
||||
grid.querySelector('.compare-shuffle-notice')?.remove();
|
||||
const notice = document.createElement('div');
|
||||
notice.className = 'compare-shuffle-notice';
|
||||
notice.setAttribute('role', 'status');
|
||||
notice.setAttribute('aria-live', 'polite');
|
||||
notice.innerHTML = '<span class="compare-shuffle-notice-icon" aria-hidden="true">' + ICON_DICE + '</span><span>Shuffling</span>';
|
||||
grid.appendChild(notice);
|
||||
requestAnimationFrame(() => notice.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
notice.classList.remove('show');
|
||||
notice.addEventListener('transitionend', () => notice.remove(), { once: true });
|
||||
setTimeout(() => notice.remove(), 220);
|
||||
}, 1060);
|
||||
}
|
||||
|
||||
// ── Stop / reroll ──
|
||||
|
||||
function stopAll() {
|
||||
@@ -43,6 +380,9 @@ function stopAll() {
|
||||
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
|
||||
b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
|
||||
});
|
||||
document.querySelectorAll('.compare-pane').forEach(pane => {
|
||||
pane.classList.remove('is-streaming', 'is-awaiting-input');
|
||||
});
|
||||
}
|
||||
|
||||
function stopPane(paneIdx) {
|
||||
@@ -54,6 +394,8 @@ function stopPane(paneIdx) {
|
||||
// Hide stop button, show reroll
|
||||
const pane = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (pane) {
|
||||
pane.classList.remove('is-streaming', 'is-awaiting-input', 'is-done');
|
||||
pane.classList.add('is-failed');
|
||||
const stopBtn = pane.querySelector('.pane-stop-btn');
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
pane.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||||
@@ -97,6 +439,8 @@ async function rerollPane(paneIdx, overrideTimeout) {
|
||||
if (badge) { badge.textContent = ''; badge.style.color = ''; }
|
||||
const timer = document.getElementById('cmp-timer-' + paneIdx);
|
||||
if (timer) timer.textContent = '';
|
||||
const summary = document.getElementById('cmp-summary-' + paneIdx);
|
||||
if (summary) { summary.textContent = ''; summary.title = ''; }
|
||||
|
||||
// Search mode: re-query the search provider
|
||||
if (state._compareMode === 'search') {
|
||||
@@ -282,10 +626,11 @@ async function _addPane(anchorBtn) {
|
||||
|
||||
// Toggle existing dropdown
|
||||
const existing = document.querySelector('.add-pane-dropdown');
|
||||
if (existing) { existing.remove(); return; }
|
||||
if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; }
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'add-pane-dropdown';
|
||||
let closeMenu = () => dropdown.remove();
|
||||
|
||||
// Search input for large model lists
|
||||
if (filtered.length >= 5) {
|
||||
@@ -326,7 +671,7 @@ async function _addPane(anchorBtn) {
|
||||
|
||||
item.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
closeMenu();
|
||||
await _createAndAppendPane(m);
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
@@ -337,6 +682,8 @@ async function _addPane(anchorBtn) {
|
||||
// chat-container is wider than the viewport.
|
||||
const btnRect = anchorBtn.getBoundingClientRect();
|
||||
dropdown.style.position = 'fixed';
|
||||
dropdown.style.right = 'auto';
|
||||
dropdown.style.bottom = 'auto';
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const margin = 8;
|
||||
@@ -351,16 +698,18 @@ async function _addPane(anchorBtn) {
|
||||
const ddRect = dropdown.getBoundingClientRect();
|
||||
const ddW = ddRect.width;
|
||||
const ddH = ddRect.height;
|
||||
// Horizontal: align dropdown's right edge with the button's, then
|
||||
// clamp so the dropdown stays within [margin, vw - margin].
|
||||
// Align the dropdown's right edge with the button, then clamp so it stays
|
||||
// within the viewport. This keeps the picker attached to the Add flap
|
||||
// instead of opening at an unrelated edge of the compare surface.
|
||||
let left = btnRect.right - ddW;
|
||||
if (left + ddW > vw - margin) left = vw - margin - ddW;
|
||||
if (left < margin) left = margin;
|
||||
left = Math.max(margin, Math.min(left, vw - margin - ddW));
|
||||
// Vertical: drop below the button if there's room, otherwise above.
|
||||
const spaceBelow = vh - btnRect.bottom;
|
||||
const spaceAbove = btnRect.top;
|
||||
let top;
|
||||
if (spaceBelow >= ddH + margin || spaceBelow >= spaceAbove) {
|
||||
if (anchorBtn.classList.contains('compare-add-flap')) {
|
||||
top = Math.max(margin, Math.min(btnRect.bottom + 6, vh - margin - ddH));
|
||||
} else if (spaceBelow >= ddH + margin || spaceBelow >= spaceAbove) {
|
||||
top = Math.min(btnRect.bottom + 4, vh - margin - Math.min(ddH, vh - margin * 2));
|
||||
} else {
|
||||
top = Math.max(margin, btnRect.top - 4 - ddH);
|
||||
@@ -371,15 +720,8 @@ async function _addPane(anchorBtn) {
|
||||
dropdown.style.bottom = 'auto';
|
||||
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
|
||||
|
||||
// Close on outside click
|
||||
const close = (e) => {
|
||||
if (!dropdown.contains(e.target) && e.target !== anchorBtn) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close), 0);
|
||||
}
|
||||
// Close on outside click or Escape (the latter via the registry).
|
||||
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== anchorBtn);}
|
||||
|
||||
/** Create a new pane for the given model and append it to the compare grid. */
|
||||
async function _createAndAppendPane(m) {
|
||||
@@ -387,7 +729,8 @@ async function _createAndAppendPane(m) {
|
||||
|
||||
// Create session
|
||||
const fd = new FormData();
|
||||
fd.append('name', '[CMP] ' + m.name);
|
||||
// Blind mode: neutral slot name only — never leak the model (issue #1285).
|
||||
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(i) : m.name));
|
||||
fd.append('endpoint_url', m.url || '');
|
||||
fd.append('model', m.id || '');
|
||||
if (m.endpointId) {
|
||||
@@ -401,6 +744,7 @@ async function _createAndAppendPane(m) {
|
||||
// Update arrays
|
||||
state._selectedModels.push({ model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name, endpointName: m.endpointName || '' });
|
||||
state._paneSessionIds.push(data.id);
|
||||
state._paneGenerationSettings.push({ thinking_mode: '', temperature_override: null, max_tokens_override: null });
|
||||
state._paneMetrics.push(null);
|
||||
state._abortControllers.push(null);
|
||||
_persistSelections();
|
||||
@@ -413,16 +757,17 @@ async function _createAndAppendPane(m) {
|
||||
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-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>' +
|
||||
'<div class="pane-header-row pane-header-primary"><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><div class="pane-primary-actions">' +
|
||||
'<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>' +
|
||||
paneSettingsButtonHtml(i) +
|
||||
'<button class="close-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane"></button></div></div>' +
|
||||
'<div class="pane-header-row pane-header-secondary"><div class="pane-stats">' + _paneModeBadgeHtml(i) +
|
||||
'<span class="pane-timer" id="cmp-timer-' + i + '"></span><span class="pane-summary" id="cmp-summary-' + i + '" role="button" tabindex="0" aria-label="Show response metrics"></span><span class="pane-finish-badge" id="cmp-badge-' + i + '"></span></div>' +
|
||||
'<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 pane-needs-response" data-action="reroll" data-pane="' + i + '" title="Re-roll" style="display:none;">' + ICON_REROLL + '</button>' +
|
||||
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button></div></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>' +
|
||||
@@ -440,6 +785,14 @@ async function _createAndAppendPane(m) {
|
||||
// Update grid columns
|
||||
const n = state._selectedModels.length;
|
||||
grid.dataset.cols = String(Math.min(n, 4));
|
||||
syncShuffleButtonPlacement(n > 2);
|
||||
refreshMobilePaneTabs(i);
|
||||
if (_isMobileCompare()) {
|
||||
activateMobilePane(i);
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector('.compare-mobile-add')?.scrollIntoView({ block: 'nearest', inline: 'end', behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
// Update header label
|
||||
const headerSpan = document.querySelector('.compare-active > div:first-child span');
|
||||
@@ -452,19 +805,6 @@ async function _createAndAppendPane(m) {
|
||||
// Rebuild vote bar
|
||||
buildVoteBar(n);
|
||||
|
||||
// Prompt to shuffle in blind mode — tooltip bubble next to Shuffle button
|
||||
if (state._blindMode && n > 2) {
|
||||
const shuffleBtn = document.getElementById('compare-shuffle-btn');
|
||||
if (shuffleBtn) {
|
||||
const bubble = document.createElement('div');
|
||||
bubble.style.cssText = 'position:absolute;top:100%;right:0;margin-top:6px;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:5px 10px;font-size:11px;white-space:nowrap;z-index:10000;box-shadow:0 4px 12px rgba(0,0,0,0.25);pointer-events:none;opacity:0;transition:opacity 0.2s;';
|
||||
bubble.textContent = 'Shuffle models?';
|
||||
shuffleBtn.style.position = 'relative';
|
||||
shuffleBtn.appendChild(bubble);
|
||||
requestAnimationFrame(() => { bubble.style.opacity = '1'; });
|
||||
setTimeout(() => { bubble.style.opacity = '0'; setTimeout(() => bubble.remove(), 200); }, 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a pane from the compare grid. If only 1 remains, exit compare mode. */
|
||||
@@ -483,6 +823,7 @@ function _removePane(paneIdx) {
|
||||
// Remove from arrays
|
||||
state._selectedModels.splice(paneIdx, 1);
|
||||
state._paneSessionIds.splice(paneIdx, 1);
|
||||
state._paneGenerationSettings.splice(paneIdx, 1);
|
||||
state._paneMetrics.splice(paneIdx, 1);
|
||||
state._abortControllers.splice(paneIdx, 1);
|
||||
_persistSelections();
|
||||
@@ -499,6 +840,7 @@ function _removePane(paneIdx) {
|
||||
grid.querySelectorAll('.compare-pane').forEach(p => p.remove());
|
||||
|
||||
const n = state._selectedModels.length;
|
||||
syncShuffleButtonPlacement(n > 2);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const label = state._blindMode ? 'Model ' + _slotChar(i) : state._selectedModels[i].name;
|
||||
const pane = document.createElement('div');
|
||||
@@ -506,17 +848,17 @@ function _removePane(paneIdx) {
|
||||
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-header-row pane-header-primary"><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><div class="pane-primary-actions">' +
|
||||
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
|
||||
paneSettingsButtonHtml(i) +
|
||||
'<button class="close-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane"></button></div></div>' +
|
||||
'<div class="pane-header-row pane-header-secondary"><div class="pane-stats">' + _paneModeBadgeHtml(i) +
|
||||
'<span class="pane-timer" id="cmp-timer-' + i + '"></span><span class="pane-summary" id="cmp-summary-' + i + '" role="button" tabindex="0" aria-label="Show response metrics"></span><span class="pane-finish-badge" id="cmp-badge-' + i + '"></span></div>' +
|
||||
'<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 pane-needs-response" data-action="reroll" data-pane="' + i + '" title="Re-roll" style="display:none;">' + ICON_REROLL + '</button>' +
|
||||
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + 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>' +
|
||||
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button></div></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>' +
|
||||
@@ -542,16 +884,20 @@ function _removePane(paneIdx) {
|
||||
|
||||
// Rebuild vote bar
|
||||
buildVoteBar(n);
|
||||
refreshMobilePaneTabs(Math.min(paneIdx, n - 1));
|
||||
}
|
||||
|
||||
/** Show a dropdown under the pane title to swap the model for that pane. */
|
||||
function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
// Don't allow swaps while streaming
|
||||
if (state._streaming) return;
|
||||
if (state._streaming) {
|
||||
uiModule.showToast('Stop the response or wait for it to finish before changing models.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any existing dropdown
|
||||
const existing = document.querySelector('.pane-model-dropdown');
|
||||
if (existing) { existing.remove(); return; }
|
||||
if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; }
|
||||
|
||||
const _effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode;
|
||||
const filtered = state._cachedModels.filter(m => m.type === _effectiveType);
|
||||
@@ -559,6 +905,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'pane-model-dropdown';
|
||||
let closeMenu = () => dropdown.remove();
|
||||
|
||||
filtered.forEach(m => {
|
||||
const item = document.createElement('button');
|
||||
@@ -573,34 +920,40 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
}
|
||||
item.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
closeMenu();
|
||||
|
||||
// Update the model for this pane and persist
|
||||
state._selectedModels[paneIdx] = {
|
||||
model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name,
|
||||
};
|
||||
_persistSelections();
|
||||
if (window._updateCheckBtnState) window._updateCheckBtnState();
|
||||
|
||||
// Delete old session, create new one
|
||||
const oldSid = state._paneSessionIds[paneIdx];
|
||||
if (oldSid) {
|
||||
fetch(`${state.API_BASE}/api/session/${oldSid}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append('name', '[CMP] ' + m.name);
|
||||
// Blind mode: neutral slot name only — never leak the model (issue #1285).
|
||||
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(paneIdx) : m.name));
|
||||
fd.append('endpoint_url', m.url || '');
|
||||
fd.append('model', m.id || '');
|
||||
if (m.endpointId) {
|
||||
fd.append('endpoint_id', m.endpointId);
|
||||
fd.append('skip_validation', 'true');
|
||||
}
|
||||
let newSessionId = '';
|
||||
try {
|
||||
const res = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
state._paneSessionIds[paneIdx] = data.id;
|
||||
newSessionId = data.id || '';
|
||||
if (!newSessionId) throw new Error('Missing session id');
|
||||
} catch (err) {
|
||||
console.error('Failed to create session for swapped model:', err);
|
||||
if (uiModule?.showError) uiModule.showError('Failed to swap compare model: ' + (err?.message || 'unknown'));
|
||||
return;
|
||||
}
|
||||
|
||||
const oldSid = state._paneSessionIds[paneIdx];
|
||||
state._selectedModels[paneIdx] = {
|
||||
model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name,
|
||||
};
|
||||
state._paneSessionIds[paneIdx] = newSessionId;
|
||||
await _savePaneGenerationSettings(paneIdx, {});
|
||||
_persistSelections();
|
||||
if (window._updateCheckBtnState) window._updateCheckBtnState();
|
||||
if (oldSid) {
|
||||
fetch(`${state.API_BASE}/api/session/${oldSid}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
|
||||
// Update title display
|
||||
@@ -621,6 +974,9 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
if (previewBtn) { previewBtn.style.display = 'none'; previewBtn.classList.remove('active'); }
|
||||
const badge = document.getElementById('cmp-badge-' + paneIdx);
|
||||
if (badge) { badge.textContent = ''; badge.style.color = ''; }
|
||||
const summary = document.getElementById('cmp-summary-' + paneIdx);
|
||||
if (summary) { summary.textContent = ''; summary.title = ''; }
|
||||
refreshMobilePaneTabs(paneIdx);
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
@@ -653,15 +1009,8 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
dropdown.style.top = top + 'px';
|
||||
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
|
||||
|
||||
// Close on outside click
|
||||
const close = (e) => {
|
||||
if (!dropdown.contains(e.target) && e.target !== titleBtn) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close), 0);
|
||||
}
|
||||
// Close on outside click or Escape (the latter via the registry).
|
||||
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== titleBtn);}
|
||||
|
||||
// ── Shuffle / reset ──
|
||||
|
||||
@@ -672,6 +1021,7 @@ function shufflePanePositions() {
|
||||
if (shuffleBtn) { const b = shuffleBtn.querySelector('div'); if (b) b.remove(); }
|
||||
const n = state._selectedModels.length;
|
||||
if (n < 2) return;
|
||||
_showShuffleNotice();
|
||||
|
||||
// Fisher-Yates shuffle to get new order
|
||||
const indices = Array.from({ length: n }, (_, i) => i);
|
||||
@@ -683,6 +1033,7 @@ function shufflePanePositions() {
|
||||
// Reorder internal state
|
||||
const newModels = indices.map(i => state._selectedModels[i]);
|
||||
const newSessionIds = indices.map(i => state._paneSessionIds[i]);
|
||||
const newGenerationSettings = indices.map(i => state._paneGenerationSettings[i]);
|
||||
const newMetrics = indices.map(i => state._paneMetrics[i]);
|
||||
|
||||
// Collect pane contents (HTML) before swapping
|
||||
@@ -698,6 +1049,7 @@ function shufflePanePositions() {
|
||||
// Apply shuffled state
|
||||
state._selectedModels = newModels;
|
||||
state._paneSessionIds = newSessionIds;
|
||||
state._paneGenerationSettings = newGenerationSettings;
|
||||
state._paneMetrics = newMetrics;
|
||||
|
||||
// Spin the shuffle button dice icon
|
||||
@@ -764,7 +1116,10 @@ function shufflePanePositions() {
|
||||
state._blindMode = true;
|
||||
|
||||
// Rebuild vote bar with new labels
|
||||
setTimeout(() => buildVoteBar(n), 250);
|
||||
setTimeout(() => {
|
||||
buildVoteBar(n);
|
||||
refreshMobilePaneTabs();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function resetCompare() {
|
||||
@@ -773,6 +1128,13 @@ function resetCompare() {
|
||||
|
||||
// Clear last prompt so vote buttons are disabled until next prompt
|
||||
state._lastPrompt = '';
|
||||
state._expectedAnswer = '';
|
||||
const expected = document.getElementById('cmp-eval-expected');
|
||||
if (expected) {
|
||||
expected.classList.add('hidden');
|
||||
const value = expected.querySelector('.cmp-eval-expected-value');
|
||||
if (value) value.textContent = '';
|
||||
}
|
||||
|
||||
// Reset finish badges, titles, winner/loser state
|
||||
state._finishOrder = 0;
|
||||
@@ -781,12 +1143,16 @@ function resetCompare() {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const badge = document.getElementById('cmp-badge-' + i);
|
||||
if (badge) { badge.textContent = ''; badge.style.color = ''; }
|
||||
const summary = document.getElementById('cmp-summary-' + i);
|
||||
if (summary) { summary.textContent = ''; summary.title = ''; }
|
||||
const titleEl = document.getElementById('cmp-title-' + i);
|
||||
if (titleEl) {
|
||||
const lbl = state._blindMode ? 'Model ' + _slotChar(i) : state._selectedModels[i].name;
|
||||
titleEl.innerHTML = escapeHtml(lbl) + ' <span class="pane-title-caret">▾</span>';
|
||||
}
|
||||
if (panes[i]) { panes[i].classList.remove('winner', 'loser'); }
|
||||
if (panes[i]) {
|
||||
panes[i].classList.remove('winner', 'loser', 'is-streaming', 'is-awaiting-input', 'is-done', 'is-failed');
|
||||
}
|
||||
|
||||
// Clear all messages from pane history
|
||||
const hist = document.getElementById('cmp-history-' + i);
|
||||
@@ -823,4 +1189,10 @@ export {
|
||||
_showModelSwapDropdown,
|
||||
shufflePanePositions,
|
||||
resetCompare,
|
||||
mountMobilePaneTabs,
|
||||
syncShuffleButtonPlacement,
|
||||
activateMobilePane,
|
||||
refreshMobilePaneTabs,
|
||||
paneSettingsButtonHtml,
|
||||
togglePaneSettings,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// compare/probe.js — model probe/check system
|
||||
import state from './state.js';
|
||||
import { WAVE_FRAMES } from './icons.js';
|
||||
import uiModule from '../ui.js';
|
||||
import { WAVE_FRAMES } from './icons.js?v=20260908compareprompts1';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import spinnerModule from '../spinner.js';
|
||||
|
||||
function _clearProbeWaves() {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// compare/scoreboard.js — vote history display
|
||||
import Storage from '../storage.js';
|
||||
import state from './state.js';
|
||||
import { VOTES_STORAGE_KEY } from './icons.js';
|
||||
import themeModule from '../theme.js';
|
||||
import uiModule from '../ui.js';
|
||||
import { VOTES_STORAGE_KEY } from './icons.js?v=20260908compareprompts1';
|
||||
import themeModule from '../theme.js?v=20260909effectspeed1';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
|
||||
const escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -120,7 +120,7 @@ export function showScoreboard() {
|
||||
|
||||
if (sorted.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.style.cssText = 'color:color-mix(in srgb, var(--fg) 50%, transparent);text-align:center;padding:24px 0;';
|
||||
empty.style.cssText = 'color:color-mix(in srgb, var(--fg) 50%, transparent);text-align:center;padding:24px 0;font-size:calc(1em - 1px);';
|
||||
empty.textContent = 'No ' + activeMode + ' votes yet. Run a comparison and vote!';
|
||||
wrap.appendChild(empty);
|
||||
} else {
|
||||
@@ -185,7 +185,7 @@ export function showScoreboard() {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
// Inline confirmation
|
||||
const confirmRow = document.createElement('div');
|
||||
confirmRow.style.cssText = 'display:flex;gap:8px;justify-content:center;align-items:center;margin-top:8px;padding:8px 12px;border:1px solid color-mix(in srgb, var(--red) 40%, var(--border));border-radius:6px;background:color-mix(in srgb, var(--red) 5%, transparent);';
|
||||
confirmRow.style.cssText = 'display:flex;gap:8px;align-items:center;margin-top:8px;padding:8px 12px;border:1px solid color-mix(in srgb, var(--red) 40%, var(--border));border-radius:6px;background:color-mix(in srgb, var(--red) 5%, transparent);';
|
||||
const confirmLabel = document.createElement('span');
|
||||
confirmLabel.style.cssText = 'font-size:12px;opacity:0.7;';
|
||||
confirmLabel.textContent = 'Clear all vote history?';
|
||||
@@ -202,9 +202,10 @@ export function showScoreboard() {
|
||||
noBtn.className = 'cmp-btn-secondary';
|
||||
noBtn.style.cssText = 'padding:4px 12px;border-radius:4px;font-size:12px;';
|
||||
noBtn.addEventListener('click', () => confirmRow.remove());
|
||||
confirmRow.appendChild(confirmLabel);
|
||||
confirmRow.appendChild(yesBtn);
|
||||
confirmRow.appendChild(noBtn);
|
||||
const actions = document.createElement('span');
|
||||
actions.style.cssText = 'display:inline-flex;align-items:center;gap:8px;margin-left:auto;';
|
||||
actions.append(noBtn, yesBtn);
|
||||
confirmRow.append(confirmLabel, actions);
|
||||
// Replace button with confirmation
|
||||
clearBtn.style.display = 'none';
|
||||
clearBtn.parentElement.appendChild(confirmRow);
|
||||
|
||||
+134
-49
@@ -2,12 +2,12 @@
|
||||
import state from './state.js';
|
||||
import Storage from '../storage.js';
|
||||
import { fetchModels, _persistSelections, getExcludedModels } from './models.js';
|
||||
import { showScoreboard } from './scoreboard.js';
|
||||
import { EYE_OPEN, EYE_CLOSED, ICON_DICE, ICON_PARALLEL, ICON_SEQUENTIAL, SAVE_ICON, WAVE_FRAMES, CHAT_ICON } from './icons.js';
|
||||
import { showScoreboard } from './scoreboard.js?v=20260909voteconfirmalign1';
|
||||
import { EYE_OPEN, EYE_CLOSED, ICON_DICE, ICON_PARALLEL, ICON_SEQUENTIAL, SAVE_ICON, WAVE_FRAMES, CHAT_ICON } from './icons.js?v=20260908compareprompts1';
|
||||
import { _clearProbeWaves } from './probe.js';
|
||||
import uiModule from '../ui.js';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import themeModule from '../theme.js';
|
||||
import themeModule from '../theme.js?v=20260909effectspeed1';
|
||||
|
||||
const escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -75,7 +75,7 @@ async function showModelSelector() {
|
||||
header.className = 'modal-header';
|
||||
|
||||
const title = document.createElement('h4');
|
||||
title.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>Model Comparison';
|
||||
title.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><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>Model Comparison';
|
||||
// Absorb the free space so the injected minimize (_) and close (✕) cluster
|
||||
// together on the right instead of being spread apart by space-between.
|
||||
title.style.marginRight = 'auto';
|
||||
@@ -153,7 +153,7 @@ async function showModelSelector() {
|
||||
uiModule.showToast('Mode: ' + (state._parallel ? 'Parallel' : 'Sequential'));
|
||||
_updateModeLabel();
|
||||
_setModeHint(state._parallel
|
||||
? '<span style="color:#5b8def">Parallel</span>: all models answer at once, side by side.'
|
||||
? '<span style="color:var(--accent, var(--red))">Parallel</span>: all models answer at once, side by side.'
|
||||
: '<span style="color:#e0a050">Sequential</span>: models answer one at a time.');
|
||||
});
|
||||
toggleRow.appendChild(parallelBtn);
|
||||
@@ -295,7 +295,7 @@ async function showModelSelector() {
|
||||
const parts = [];
|
||||
if (state._blindMode) parts.push('<span style="color:var(--color-blind-orange)">Blind</span>');
|
||||
parts.push(state._parallel
|
||||
? '<span style="color:#5b8def">Parallel</span>'
|
||||
? '<span style="color:var(--accent, var(--red))">Parallel</span>'
|
||||
: '<span style="color:#e0a050">Sequential</span>');
|
||||
if (_shuffled) parts.push('<span style="color:var(--red)">Shuffle</span>');
|
||||
if (state._saveOnClose) parts.push('<span style="color:var(--color-save-green)">Save</span>');
|
||||
@@ -403,6 +403,7 @@ async function showModelSelector() {
|
||||
// Validate saved selections against available models (done after models load)
|
||||
let _needsValidation = selections.length > 0;
|
||||
let addBtn = null;
|
||||
let startBtn = null;
|
||||
let _shuffled = false;
|
||||
_updateModeLabel(); // initial readout (Blind + Parallel on by default)
|
||||
|
||||
@@ -419,6 +420,68 @@ async function showModelSelector() {
|
||||
};
|
||||
}
|
||||
|
||||
function _selectionKey(sel) {
|
||||
if (!sel) return '';
|
||||
const provider = sel.searchProvider || '';
|
||||
return [sel.model || '', sel.endpointId || '', sel.endpoint || '', provider].join('|');
|
||||
}
|
||||
|
||||
function _duplicateSelectionKeys() {
|
||||
const counts = new Map();
|
||||
selections.filter(Boolean).forEach(sel => {
|
||||
const key = _selectionKey(sel);
|
||||
if (!key) return;
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
});
|
||||
return new Set([...counts.entries()].filter(([, count]) => count > 1).map(([key]) => key));
|
||||
}
|
||||
|
||||
function _appendSelectionMeta(row, sel, duplicateKeys) {
|
||||
if (!sel || _shuffled) return;
|
||||
const key = _selectionKey(sel);
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'cmp-model-meta';
|
||||
const bits = [];
|
||||
if (duplicateKeys.has(key)) {
|
||||
row.classList.add('cmp-model-row-duplicate');
|
||||
bits.push('<span class="cmp-model-meta-warning">Duplicate selection</span>');
|
||||
}
|
||||
if (!bits.length) return;
|
||||
meta.innerHTML = bits.join('');
|
||||
row.appendChild(meta);
|
||||
}
|
||||
|
||||
function _updateStartReadiness() {
|
||||
if (!startBtn || !_modelsLoaded) return;
|
||||
const duplicates = _duplicateSelectionKeys();
|
||||
// Duplicate models are valid compare inputs. Keep the warning on the
|
||||
// rows, but never block the Start flow because the user can choose to
|
||||
// proceed through the probe fallback.
|
||||
startBtn.disabled = false;
|
||||
startBtn.style.opacity = '1';
|
||||
startBtn.title = duplicates.size > 0 ? 'Duplicate selections will run as separate panes' : '';
|
||||
}
|
||||
|
||||
function _expandModelSlot(slotIdx) {
|
||||
const row = listContainer.querySelector(`.cmp-model-row[data-slot-index="${slotIdx}"]`);
|
||||
if (!row) return;
|
||||
row.classList.add('cmp-model-row-swap-target');
|
||||
row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
|
||||
const searchable = row.querySelector('.cmp-model-picker-input');
|
||||
if (searchable) {
|
||||
searchable.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const select = row.querySelector('.cmp-model-primary-select');
|
||||
if (!select) return;
|
||||
select.focus({ preventScroll: true });
|
||||
if (typeof select.showPicker === 'function') {
|
||||
try { select.showPicker(); } catch (_) { /* focus still identifies the slot */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a searchable model picker (used when >5 models) */
|
||||
function _buildSearchablePicker(modelList, currentSel, slotIdx, onSelect) {
|
||||
const wrap = document.createElement('div');
|
||||
@@ -427,7 +490,7 @@ async function showModelSelector() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.placeholder = 'Search models\u2026';
|
||||
input.className = 'cmp-form-control';
|
||||
input.className = 'cmp-form-control cmp-model-picker-input';
|
||||
input.style.cssText = 'width:100%;box-sizing:border-box;';
|
||||
// Mobile: suppress the on-screen keyboard so tapping the picker
|
||||
// opens the dropdown but doesn't shove a keyboard up over the list.
|
||||
@@ -594,6 +657,7 @@ async function showModelSelector() {
|
||||
selections.forEach((sel, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cmp-model-row';
|
||||
row.dataset.slotIndex = String(idx);
|
||||
if (_seqStepS) row.style.marginLeft = (idx * _seqStepS) + 'px';
|
||||
|
||||
// Left label: number/letter or blind eye icon
|
||||
@@ -618,7 +682,7 @@ async function showModelSelector() {
|
||||
row.appendChild(picker);
|
||||
} else {
|
||||
const modelSelect = document.createElement('select');
|
||||
modelSelect.className = 'cmp-form-control';
|
||||
modelSelect.className = 'cmp-form-control cmp-model-primary-select';
|
||||
modelSelect.style.flex = '1';
|
||||
chatModels.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
@@ -647,26 +711,29 @@ async function showModelSelector() {
|
||||
});
|
||||
provSelect.addEventListener('change', () => {
|
||||
try { selections[idx] = JSON.parse(provSelect.value); } catch (e) {}
|
||||
renderModelRows();
|
||||
});
|
||||
try { if (!selections[idx]) selections[idx] = JSON.parse(provSelect.value); } catch (e) {}
|
||||
row.appendChild(provSelect);
|
||||
_appendSelectionMeta(row, selections[idx], _duplicateSelectionKeys());
|
||||
|
||||
// X remove button when >2 slots
|
||||
if (selections.length > 2) {
|
||||
// X remove button when more than one slot remains
|
||||
if (selections.length > 1) {
|
||||
const rmBtn = document.createElement('button');
|
||||
rmBtn.type = 'button';
|
||||
rmBtn.textContent = '\u00d7';
|
||||
rmBtn.className = 'cmp-rm-btn';
|
||||
rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--color-error)'; });
|
||||
rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--fg)'; });
|
||||
rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--accent, var(--red))'; });
|
||||
rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--accent, var(--red))'; });
|
||||
rmBtn.addEventListener('click', () => { selections.splice(idx, 1); state._searchSynthModels.splice(idx, 1); renderModelRows(); });
|
||||
row.appendChild(rmBtn);
|
||||
}
|
||||
|
||||
listContainer.appendChild(row);
|
||||
});
|
||||
if (addBtn) addBtn.style.display = selections.length >= 8 ? 'none' : '';
|
||||
return;
|
||||
listContainer.appendChild(row);
|
||||
});
|
||||
if (addBtn) addBtn.style.display = selections.length >= 8 ? 'none' : '';
|
||||
_updateStartReadiness();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Chat / Image / Agent / Research mode: show model dropdowns ──
|
||||
@@ -705,6 +772,7 @@ async function showModelSelector() {
|
||||
selections.forEach((sel, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cmp-model-row';
|
||||
row.dataset.slotIndex = String(idx);
|
||||
if (_seqStep) row.style.marginLeft = (idx * _seqStep) + 'px';
|
||||
|
||||
// Left label: number/letter or blind eye icon
|
||||
@@ -727,6 +795,7 @@ async function showModelSelector() {
|
||||
const picker = _buildSearchablePicker(filtered, sel, idx, (chosen) => {
|
||||
selections[idx] = chosen;
|
||||
_remindShuffle();
|
||||
renderModelRows();
|
||||
});
|
||||
if (!selections[idx]) {
|
||||
const fallback = filtered[Math.min(idx, filtered.length - 1)];
|
||||
@@ -735,7 +804,7 @@ async function showModelSelector() {
|
||||
row.appendChild(picker);
|
||||
} else {
|
||||
const select = document.createElement('select');
|
||||
select.className = 'cmp-form-control';
|
||||
select.className = 'cmp-form-control cmp-model-primary-select';
|
||||
select.style.flex = '1';
|
||||
filtered.forEach((m, mi) => {
|
||||
const opt = buildOption(m);
|
||||
@@ -749,6 +818,7 @@ async function showModelSelector() {
|
||||
select.addEventListener('change', () => {
|
||||
try { selections[idx] = JSON.parse(select.value); } catch (e) { console.warn('Compare model select parse failed:', e); }
|
||||
_remindShuffle();
|
||||
renderModelRows();
|
||||
});
|
||||
try { if (!selections[idx]) selections[idx] = JSON.parse(select.value); } catch (e) { console.warn('Compare model init parse failed:', e); }
|
||||
row.appendChild(select);
|
||||
@@ -767,19 +837,24 @@ async function showModelSelector() {
|
||||
else if (!state._searchSynthModels[idx] && pi === 0) optEl.selected = true;
|
||||
provSelect.appendChild(optEl);
|
||||
});
|
||||
provSelect.addEventListener('change', () => { state._searchSynthModels[idx] = provSelect.value; });
|
||||
provSelect.addEventListener('change', () => {
|
||||
state._searchSynthModels[idx] = provSelect.value;
|
||||
renderModelRows();
|
||||
});
|
||||
if (!state._searchSynthModels[idx]) state._searchSynthModels[idx] = provSelect.value;
|
||||
row.appendChild(provSelect);
|
||||
}
|
||||
|
||||
// X remove button when >2 slots
|
||||
if (selections.length > 2) {
|
||||
_appendSelectionMeta(row, selections[idx], _duplicateSelectionKeys());
|
||||
|
||||
// X remove button when more than one slot remains
|
||||
if (selections.length > 1) {
|
||||
const rmBtn = document.createElement('button');
|
||||
rmBtn.type = 'button';
|
||||
rmBtn.textContent = '\u00d7';
|
||||
rmBtn.className = 'cmp-rm-btn';
|
||||
rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--color-error)'; });
|
||||
rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--fg)'; });
|
||||
rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--accent, var(--red))'; });
|
||||
rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--accent, var(--red))'; });
|
||||
rmBtn.addEventListener('click', () => { selections.splice(idx, 1); if (state._searchSynthModels.length > idx) state._searchSynthModels.splice(idx, 1); renderModelRows(); });
|
||||
row.appendChild(rmBtn);
|
||||
}
|
||||
@@ -787,6 +862,7 @@ async function showModelSelector() {
|
||||
listContainer.appendChild(row);
|
||||
});
|
||||
if (addBtn) addBtn.style.display = (selections.length >= 8) ? 'none' : '';
|
||||
_updateStartReadiness();
|
||||
}
|
||||
|
||||
// Default to 2 empty slots if no saved selections
|
||||
@@ -795,7 +871,7 @@ async function showModelSelector() {
|
||||
addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.style.cssText = 'display:none;align-items:center;gap:6px;background:none;border:1px dashed var(--border);color:var(--fg);border-radius:6px;cursor:pointer;padding:6px 12px;font-size:0.82em;opacity:0.6;transition:all 0.15s;margin-bottom:16px;width:100%;justify-content:center;';
|
||||
addBtn.textContent = '+ Add Model';
|
||||
addBtn.innerHTML = '<span style="color:var(--accent,var(--red));position:relative;left:-2px;">+</span><span>Add Model</span>';
|
||||
addBtn.addEventListener('mouseenter', () => { addBtn.style.opacity = '1'; });
|
||||
addBtn.addEventListener('mouseleave', () => { addBtn.style.opacity = '0.6'; });
|
||||
addBtn.addEventListener('click', () => {
|
||||
@@ -840,7 +916,7 @@ async function showModelSelector() {
|
||||
// Scoreboard button
|
||||
const scoreBtn = document.createElement('button');
|
||||
scoreBtn.type = 'button';
|
||||
scoreBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>Scoreboard';
|
||||
scoreBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:0px;position:relative;top:2px;margin-right:4px;"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>Scoreboard';
|
||||
scoreBtn.style.cssText = 'margin-left:auto;padding:4px 10px;background:transparent;color:var(--fg);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:0.82em;opacity:0.7;position:relative;top:-5px;';
|
||||
scoreBtn.addEventListener('mouseenter', () => { scoreBtn.style.opacity = '1'; });
|
||||
scoreBtn.addEventListener('mouseleave', () => { scoreBtn.style.opacity = '0.7'; });
|
||||
@@ -857,7 +933,7 @@ async function showModelSelector() {
|
||||
footer.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;padding:14px 16px 10px;border-top:1px solid var(--border);';
|
||||
// Cancel button removed — the overlay's X / outside-click / Esc all
|
||||
// dismiss the popup, so the footer Cancel was redundant.
|
||||
const startBtn = document.createElement('button');
|
||||
startBtn = document.createElement('button');
|
||||
startBtn.innerHTML = _CMP_START_LABEL;
|
||||
startBtn.className = 'research-start-btn';
|
||||
startBtn.disabled = true;
|
||||
@@ -924,7 +1000,11 @@ async function showModelSelector() {
|
||||
probeOverlay.className = 'compare-probe-overlay';
|
||||
const probeCard = document.createElement('div');
|
||||
probeCard.className = 'compare-probe-card';
|
||||
probeCard.innerHTML = '<div class="compare-probe-title">Checking models...</div>';
|
||||
probeCard.innerHTML = '<div class="compare-probe-title"><span class="compare-probe-title-label">Preround check:</span> <span class="compare-probe-title-status">Checking models...</span></div>';
|
||||
const _setProbeTitle = (message) => {
|
||||
const status = probeCard.querySelector('.compare-probe-title-status');
|
||||
if (status) status.textContent = message;
|
||||
};
|
||||
let _probeSkipped = false;
|
||||
const probeList = document.createElement('div');
|
||||
probeList.className = 'compare-probe-list';
|
||||
@@ -950,6 +1030,9 @@ async function showModelSelector() {
|
||||
probeList.appendChild(row);
|
||||
});
|
||||
probeCard.appendChild(probeList);
|
||||
const probeFeedback = document.createElement('div');
|
||||
probeFeedback.className = 'compare-probe-feedback';
|
||||
probeCard.appendChild(probeFeedback);
|
||||
const skipBtn = document.createElement('button');
|
||||
skipBtn.textContent = 'Skip';
|
||||
skipBtn.className = 'cmp-btn-secondary';
|
||||
@@ -1046,18 +1129,22 @@ async function showModelSelector() {
|
||||
if (nameEl) nameEl.textContent = row._realName;
|
||||
}
|
||||
// Remove old detail/actions if retrying
|
||||
const oldDetail = row.nextElementSibling;
|
||||
if (oldDetail && oldDetail.classList.contains('compare-probe-detail')) oldDetail.remove();
|
||||
// Error + actions below the row
|
||||
const oldDetail = probeFeedback.querySelector(`[data-probe-detail="${idx}"]`);
|
||||
if (oldDetail) oldDetail.remove();
|
||||
// Keep diagnostic feedback below the complete model list.
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'compare-probe-detail';
|
||||
detail.style.cssText = 'grid-column:1/-1;display:flex;align-items:flex-start;gap:6px;padding:4px 10px 6px;font-size:10px;opacity:0.6;background:color-mix(in srgb, var(--color-error, #f44) 5%, transparent);border-radius:4px;margin-top:-2px;';
|
||||
detail.dataset.probeDetail = String(idx);
|
||||
const detailIcon = document.createElement('span');
|
||||
detailIcon.className = 'compare-probe-detail-icon';
|
||||
const errSpan = document.createElement('span');
|
||||
// Truncate long error messages
|
||||
const errText = (result.error || 'Failed');
|
||||
detailIcon.textContent = /insufficient balance/i.test(errText) ? '$' : '!';
|
||||
detail.appendChild(detailIcon);
|
||||
errSpan.textContent = errText.length > 80 ? errText.slice(0, 80) + '...' : errText;
|
||||
errSpan.title = errText;
|
||||
errSpan.style.cssText = 'flex:1;line-height:1.4;';
|
||||
errSpan.className = 'compare-probe-detail-message';
|
||||
detail.appendChild(errSpan);
|
||||
// Track timeout for retry doubling
|
||||
if (!row._probeTimeout) row._probeTimeout = 15000;
|
||||
@@ -1065,7 +1152,7 @@ async function showModelSelector() {
|
||||
const retryBtn = document.createElement('button');
|
||||
retryBtn.className = 'compare-probe-action-btn';
|
||||
const retryLabel = result.error === 'Timeout' ? `Retry ${Math.round(row._probeTimeout / 1000)}s` : 'Retry';
|
||||
retryBtn.textContent = retryLabel;
|
||||
retryBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.34 5.66"/><polyline points="20 4 20 11 13 11"/></svg><span>' + escapeHtml(retryLabel) + '</span>';
|
||||
retryBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
detail.remove();
|
||||
@@ -1084,7 +1171,7 @@ async function showModelSelector() {
|
||||
});
|
||||
const swapBtn = document.createElement('button');
|
||||
swapBtn.className = 'compare-probe-action-btn';
|
||||
swapBtn.textContent = 'Swap';
|
||||
swapBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/></svg><span>Swap</span>';
|
||||
swapBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_clearProbeWaves();
|
||||
@@ -1093,10 +1180,12 @@ async function showModelSelector() {
|
||||
startBtn.disabled = false;
|
||||
startBtn.innerHTML = _CMP_START_LABEL;
|
||||
startBtn.style.opacity = '1';
|
||||
renderModelRows();
|
||||
_expandModelSlot(idx);
|
||||
});
|
||||
detail.appendChild(retryBtn);
|
||||
detail.appendChild(swapBtn);
|
||||
row.after(detail);
|
||||
probeFeedback.appendChild(detail);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1186,8 +1275,7 @@ async function showModelSelector() {
|
||||
: (state._searchSynthModels || []).map(p => typeof p === 'string' ? { id: p, label: p } : null).filter(Boolean);
|
||||
|
||||
if (providers.length > 0) {
|
||||
const titleEl = probeOverlay.querySelector('.compare-probe-title');
|
||||
titleEl.textContent = 'Checking search providers...';
|
||||
_setProbeTitle('Checking search providers...');
|
||||
|
||||
// Add provider rows
|
||||
const providerRows = [];
|
||||
@@ -1195,7 +1283,7 @@ async function showModelSelector() {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'compare-probe-row';
|
||||
row.dataset.idx = 'p' + i;
|
||||
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${p.label || p.id}</span><span class="compare-probe-status"></span>`;
|
||||
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${escapeHtml(p.label || p.id)}</span><span class="compare-probe-status"></span>`;
|
||||
const waveEl = row.querySelector('.compare-probe-spinner');
|
||||
const waveFrames = WAVE_FRAMES;
|
||||
let wIdx = 0;
|
||||
@@ -1250,7 +1338,7 @@ async function showModelSelector() {
|
||||
// Don't hide the Skip button here — collapsing its space made the
|
||||
// card shrink and the title + rows jump ("quick cut"). On success the
|
||||
// whole overlay fades out a moment later, so just leave it in place.
|
||||
probeOverlay.querySelector('.compare-probe-title').textContent = 'All ready!';
|
||||
_setProbeTitle('All ready!');
|
||||
setTimeout(() => {
|
||||
probeOverlay.style.transition = 'opacity 0.3s ease';
|
||||
probeOverlay.style.opacity = '0';
|
||||
@@ -1264,21 +1352,18 @@ async function showModelSelector() {
|
||||
probeList.querySelectorAll('.compare-probe-row.fail').forEach(row => {
|
||||
failedNames.push(row.querySelector('.compare-probe-name').textContent);
|
||||
});
|
||||
const titleEl = probeOverlay.querySelector('.compare-probe-title');
|
||||
titleEl.textContent = failedNames.length <= 2
|
||||
_setProbeTitle(failedNames.length <= 2
|
||||
? failedNames.join(' & ') + ' failed'
|
||||
: `${failCount} models failed`;
|
||||
: `${failCount} models failed`);
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.style.cssText = 'display:flex;gap:8px;justify-content:center;margin-top:12px;';
|
||||
btnRow.className = 'compare-probe-footer';
|
||||
const goBackBtn = document.createElement('button');
|
||||
goBackBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="15 18 9 12 15 6"/></svg>Go Back';
|
||||
goBackBtn.className = 'cmp-btn-secondary';
|
||||
goBackBtn.style.cssText = 'padding:5px 12px;font-size:12px;display:inline-flex;align-items:center;';
|
||||
goBackBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg><span>Go Back</span>';
|
||||
goBackBtn.className = 'cmp-btn-secondary compare-probe-footer-btn';
|
||||
goBackBtn.addEventListener('click', () => { _clearProbeWaves(); probeOverlay.remove(); startBtn.disabled = false; startBtn.innerHTML = _CMP_START_LABEL; startBtn.style.opacity = '1'; });
|
||||
const startAnywayBtn = document.createElement('button');
|
||||
startAnywayBtn.textContent = 'Start Anyway';
|
||||
startAnywayBtn.className = 'cmp-btn-primary';
|
||||
startAnywayBtn.style.cssText = 'padding:5px 12px;font-size:12px;';
|
||||
startAnywayBtn.innerHTML = _CMP_PLAY_ICON + '<span>Start Anyway</span>';
|
||||
startAnywayBtn.className = 'cmp-btn-primary compare-probe-footer-btn compare-probe-start-anyway';
|
||||
startAnywayBtn.addEventListener('click', () => { _clearProbeWaves(); probeOverlay.remove(); cleanup(true); });
|
||||
btnRow.appendChild(goBackBtn);
|
||||
btnRow.appendChild(startAnywayBtn);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const state = {
|
||||
API_BASE: '',
|
||||
isActive: false,
|
||||
_openingSelector: false, // prevents duplicate compare modals on rapid re-clicks
|
||||
_streaming: false,
|
||||
_blindMode: true,
|
||||
_saveOnClose: false,
|
||||
@@ -13,6 +14,7 @@ const state = {
|
||||
// (sequential mode otherwise always picks pane 1)
|
||||
_selectedModels: [], // [{model, endpoint, endpointId, name}, ...]
|
||||
_paneSessionIds: [], // session IDs for each pane
|
||||
_paneGenerationSettings: [], // per-pane thinking / temperature / token overrides
|
||||
_paneMetrics: [], // metrics per pane from last round
|
||||
_abortControllers: [], // per-pane abort controllers
|
||||
_sidebarWasHidden: false,
|
||||
@@ -32,20 +34,24 @@ const state = {
|
||||
_fetchModelsCacheTime: 0,
|
||||
_expectedAnswer: '', // when an eval prompt with `answer` is picked,
|
||||
// stream.js reads this and stamps ✓/✗ per pane
|
||||
_activeMobilePane: 0, // visible pane in the phone tab/card layout
|
||||
};
|
||||
|
||||
/** Reset transient state to defaults — useful for clean restarts. */
|
||||
export function reset() {
|
||||
state._openingSelector = false;
|
||||
state._streaming = false;
|
||||
state._finishOrder = 0;
|
||||
state._paneElapsed = [];
|
||||
state._abortControllers.forEach(c => { if (c) c.abort(); });
|
||||
state._abortControllers = [];
|
||||
state._paneSessionIds = [];
|
||||
state._paneGenerationSettings = [];
|
||||
state._paneMetrics = [];
|
||||
state._compareElements = [];
|
||||
state._hasVisibleResults = false;
|
||||
state._lastPrompt = '';
|
||||
state._activeMobilePane = 0;
|
||||
state._cachedModels = [];
|
||||
state._probed = new Set();
|
||||
state._cachedProviders = null;
|
||||
|
||||
+366
-42
@@ -1,24 +1,235 @@
|
||||
// compare/stream.js — SSE streaming to panes
|
||||
import state from './state.js';
|
||||
import { addFinishBadge } from './vote.js';
|
||||
import { getModelCost } from '../chatRenderer.js';
|
||||
import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
|
||||
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260910streamlinks2';
|
||||
import markdownModule from '../markdown.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import uiModule from '../ui.js';
|
||||
import presetsModule from '../presets.js';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import presetsModule from '../presets.js?v=20260908personaname1';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
const WAVE_FRAMES = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
|
||||
|
||||
function _safeHttpHref(raw) {
|
||||
try {
|
||||
const parsed = new URL(String(raw || '').trim(), window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return parsed.href;
|
||||
}
|
||||
} catch (_) {}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
|
||||
let _rerollPane = null;
|
||||
let _autoPreviewHtml = null;
|
||||
let _setSendBtn = null;
|
||||
|
||||
/** Register external functions that live in compare.js. */
|
||||
function registerStreamActions({ rerollPane, autoPreviewHtml }) {
|
||||
function registerStreamActions({ rerollPane, autoPreviewHtml, setSendBtn }) {
|
||||
_rerollPane = rerollPane;
|
||||
_autoPreviewHtml = autoPreviewHtml;
|
||||
_setSendBtn = setSendBtn;
|
||||
}
|
||||
|
||||
function _paneSessionIsCurrent(paneIdx, sessionId) {
|
||||
return Boolean(
|
||||
state.isActive
|
||||
&& state._paneSessionIds[paneIdx] === sessionId
|
||||
&& document.getElementById('cmp-history-' + paneIdx)
|
||||
);
|
||||
}
|
||||
|
||||
function _setCompareBusy(active) {
|
||||
state._streaming = Boolean(active);
|
||||
if (_setSendBtn) _setSendBtn(active ? 'stop' : 'send');
|
||||
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
|
||||
button.disabled = Boolean(active);
|
||||
button.style.opacity = active ? '0.25' : '0.7';
|
||||
button.style.pointerEvents = active ? 'none' : '';
|
||||
});
|
||||
}
|
||||
|
||||
function _syncCompareBusyFromPanes() {
|
||||
_setCompareBusy((state._abortControllers || []).some(Boolean));
|
||||
}
|
||||
|
||||
function _compareTimezoneHeaders() {
|
||||
const headers = { 'X-Tz-Offset': String(-new Date().getTimezoneOffset()) };
|
||||
try {
|
||||
headers['X-Tz-Name'] = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
} catch (_) {
|
||||
headers['X-Tz-Name'] = '';
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function _compactNumber(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '';
|
||||
if (Math.abs(num) >= 1000000) return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||
if (Math.abs(num) >= 1000) return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
|
||||
return String(Math.round(num));
|
||||
}
|
||||
|
||||
function _formatCost(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '';
|
||||
if (num < 0.001) return '<$0.001';
|
||||
return '$' + (num < 0.01 ? num.toFixed(4) : num.toFixed(3));
|
||||
}
|
||||
|
||||
function _setPaneSummary(paneIdx, metrics, cost) {
|
||||
const summary = document.getElementById('cmp-summary-' + paneIdx);
|
||||
if (!summary) return;
|
||||
if (!metrics) {
|
||||
summary.textContent = '';
|
||||
summary.title = '';
|
||||
return;
|
||||
}
|
||||
const outputTokens = metrics.output_tokens;
|
||||
const responseTime = metrics.response_time ?? metrics.total_time;
|
||||
const ttft = metrics.client_ttft ?? metrics.time_to_first_token;
|
||||
const explicitTps = metrics.tokens_per_second ?? metrics.gen_tps ?? metrics.tps;
|
||||
const numericOutput = Number(outputTokens);
|
||||
const numericTime = Number(responseTime);
|
||||
const numericTps = Number(explicitTps);
|
||||
const derivedTps = Number.isFinite(numericTps)
|
||||
? numericTps
|
||||
: (Number.isFinite(numericOutput) && Number.isFinite(numericTime) && numericTime > 0)
|
||||
? numericOutput / numericTime
|
||||
: null;
|
||||
const bits = [];
|
||||
if (Number.isFinite(Number(ttft)) && Number(ttft) > 0) bits.push('TTFT ' + Number(ttft).toFixed(3) + 's');
|
||||
if (outputTokens != null && outputTokens !== 'undefined') bits.push(_compactNumber(outputTokens) + ' tok');
|
||||
if (derivedTps != null) bits.push((derivedTps >= 100 ? String(Math.round(derivedTps)) : derivedTps.toFixed(1).replace(/\.0$/, '')) + '/s');
|
||||
if (metrics.context_percent > 0) bits.push(metrics.context_percent + '% ctx');
|
||||
if (cost !== null && cost !== undefined) bits.push(_formatCost(cost));
|
||||
summary.textContent = bits.join(' · ');
|
||||
summary.title = bits.length ? 'Response summary: ' + bits.join(', ') : '';
|
||||
}
|
||||
|
||||
function _appendPaneMessage(hist, role, text) {
|
||||
const message = document.createElement('div');
|
||||
message.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
|
||||
const roleEl = document.createElement('div');
|
||||
roleEl.className = 'role';
|
||||
roleEl.textContent = role === 'user' ? 'You' : 'AI';
|
||||
const body = document.createElement('div');
|
||||
body.className = 'body';
|
||||
body.textContent = text || '';
|
||||
message.appendChild(roleEl);
|
||||
message.appendChild(body);
|
||||
hist.appendChild(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
function _createPaneContinuationMessage(hist) {
|
||||
const message = _appendPaneMessage(hist, 'assistant', '');
|
||||
const body = message.querySelector('.body');
|
||||
if (spinnerModule) {
|
||||
const spinner = spinnerModule.create('Continuing...', 'right');
|
||||
body.appendChild(spinner.createElement());
|
||||
spinner.start();
|
||||
message._spinner = spinner;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function _restorePaneAskUserCard(paneIdx, sessionId, submission, originController) {
|
||||
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||
const restored = _renderPaneAskUserCard(
|
||||
paneIdx,
|
||||
sessionId,
|
||||
submission.payload || {},
|
||||
hist,
|
||||
null,
|
||||
originController,
|
||||
);
|
||||
if (uiModule) {
|
||||
uiModule.showError(
|
||||
restored
|
||||
? 'This pane is still streaming — choose again once it settles.'
|
||||
: 'Compare pane is still streaming; the choice was not sent.',
|
||||
);
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
function _resumePaneChoiceWhenIdle(paneIdx, sessionId, originController, submission) {
|
||||
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return false;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const resume = () => {
|
||||
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return;
|
||||
const activeController = state._abortControllers[paneIdx];
|
||||
if (activeController === originController) {
|
||||
if (Date.now() - startedAt < 10000) {
|
||||
setTimeout(resume, 25);
|
||||
return;
|
||||
}
|
||||
// The originating stream never released the pane. The card was already
|
||||
// removed when the choice was accepted, so put it back rather than
|
||||
// swallowing a decision the user made.
|
||||
_restorePaneAskUserCard(paneIdx, sessionId, submission, originController);
|
||||
return;
|
||||
}
|
||||
// A reroll/model replacement already owns this pane. Never send the stale
|
||||
// choice into that replacement stream or session UI.
|
||||
if (activeController) return;
|
||||
|
||||
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||
if (!hist) return;
|
||||
hist.querySelectorAll('.ask-user-card').forEach((card) => card.remove());
|
||||
|
||||
const isApproval = submission.kind === 'tool_approval';
|
||||
const message = isApproval ? '' : String(submission.text || submission.label || '');
|
||||
if (!isApproval) _appendPaneMessage(hist, 'user', message);
|
||||
const aiMessage = _createPaneContinuationMessage(hist);
|
||||
hist.scrollTop = hist.scrollHeight;
|
||||
|
||||
const resumeOptions = { skipBadge: true };
|
||||
if (isApproval) {
|
||||
resumeOptions.toolApproval = {
|
||||
approval_id: String(submission.approval_id || ''),
|
||||
decision: String(submission.decision || '').toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
_setCompareBusy(true);
|
||||
streamToPane(paneIdx, sessionId, message, aiMessage, resumeOptions)
|
||||
.catch((error) => {
|
||||
console.error('Compare pane continuation failed:', error);
|
||||
if (uiModule) uiModule.showError('Compare continuation failed: ' + error.message);
|
||||
})
|
||||
.finally(_syncCompareBusyFromPanes);
|
||||
};
|
||||
|
||||
setTimeout(resume, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _renderPaneAskUserCard(paneIdx, sessionId, payload, hist, aiMsgEl, originController) {
|
||||
if (!hist || !hist.isConnected || !_paneSessionIsCurrent(paneIdx, sessionId)) return null;
|
||||
if (aiMsgEl && aiMsgEl._spinner) {
|
||||
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
|
||||
aiMsgEl._spinner = null;
|
||||
}
|
||||
const card = renderAskUserCard(payload, {
|
||||
root: hist,
|
||||
onSubmit: (submission) => _resumePaneChoiceWhenIdle(
|
||||
paneIdx,
|
||||
sessionId,
|
||||
originController,
|
||||
submission,
|
||||
),
|
||||
});
|
||||
if (card) {
|
||||
card.dataset.comparePane = String(paneIdx);
|
||||
card.dataset.compareSession = String(sessionId);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Format milliseconds as human-readable duration (e.g. "120ms", "1.23s", "4.5s"). */
|
||||
@@ -36,9 +247,12 @@ function _renderSearchResults(data) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'compare-search-result';
|
||||
const titleLink = document.createElement('a');
|
||||
titleLink.href = r.url || '#';
|
||||
titleLink.target = '_blank';
|
||||
titleLink.rel = 'noopener';
|
||||
const safeUrl = _safeHttpHref(r.url);
|
||||
if (safeUrl) {
|
||||
titleLink.href = safeUrl;
|
||||
titleLink.target = '_blank';
|
||||
titleLink.rel = 'noopener noreferrer';
|
||||
}
|
||||
titleLink.className = 'search-result-title';
|
||||
titleLink.textContent = r.title || 'Untitled';
|
||||
card.appendChild(titleLink);
|
||||
@@ -143,6 +357,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
// Show stop button for this pane
|
||||
const _paneEl = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (_paneEl) {
|
||||
_paneEl.classList.remove('is-done', 'is-failed', 'is-awaiting-input');
|
||||
_paneEl.classList.add('is-streaming');
|
||||
_setPaneSummary(paneIdx, null, null);
|
||||
const _stopBtn = _paneEl.querySelector('.pane-stop-btn');
|
||||
if (_stopBtn) _stopBtn.style.display = '';
|
||||
}
|
||||
@@ -151,6 +368,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
let metrics = null;
|
||||
let timedOut = false;
|
||||
let streamOk = false;
|
||||
let awaitingChoice = false;
|
||||
let currentToolBlock = null; // track active agent tool block
|
||||
// Idle timeout — abort only if no data is received for this many seconds.
|
||||
// Long generations (SVG, big code) are fine as long as the stream stays
|
||||
@@ -206,6 +424,10 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
const fd = new FormData();
|
||||
fd.append('message', message);
|
||||
fd.append('session', sessionId);
|
||||
if (opts.toolApproval) {
|
||||
fd.append('tool_approval_id', opts.toolApproval.approval_id || '');
|
||||
fd.append('tool_approval_decision', opts.toolApproval.decision || '');
|
||||
}
|
||||
|
||||
// Compare mode determines what tools/features are enabled
|
||||
const isAgent = state._compareMode === 'agent';
|
||||
@@ -243,7 +465,10 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
|
||||
const response = await fetch(`${state.API_BASE}/api/chat_stream`, {
|
||||
method: 'POST', body: fd, signal: ac.signal
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
headers: _compareTimezoneHeaders(),
|
||||
signal: ac.signal
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
@@ -309,6 +534,38 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pane-local question / approval selector ──
|
||||
} else if (json.type === 'ask_user') {
|
||||
awaitingChoice = true;
|
||||
const paneEl = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (paneEl) paneEl.classList.add('is-awaiting-input');
|
||||
_renderPaneAskUserCard(
|
||||
paneIdx,
|
||||
sessionId,
|
||||
json.data || {},
|
||||
hist,
|
||||
aiMsgEl,
|
||||
ac,
|
||||
);
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
|
||||
// Deny ends as a tiny resolution-only stream, so replace the
|
||||
// continuation spinner with an explicit pane-local result.
|
||||
} else if (json.type === 'tool_approval_resolved') {
|
||||
if (aiMsgEl._spinner) {
|
||||
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
|
||||
aiMsgEl._spinner = null;
|
||||
}
|
||||
accumulated = json.decision === 'deny' ? 'Denied.' : 'Approval recorded.';
|
||||
let target = aiMsgEl._textEl;
|
||||
if (!target) {
|
||||
target = document.createElement('div');
|
||||
target.className = 'compare-text-content';
|
||||
aiBody.appendChild(target);
|
||||
aiMsgEl._textEl = target;
|
||||
}
|
||||
target.textContent = accumulated;
|
||||
|
||||
// ── Tool start (bash, web search agent tool) ──
|
||||
} else if (json.type === 'tool_start') {
|
||||
// Finalize any accumulated text before the tool block
|
||||
@@ -344,7 +601,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${escapeHtml(cmd)}</pre>` : '';
|
||||
const node = document.createElement('div');
|
||||
node.className = 'agent-thread-node running';
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${toolLabel}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${escapeHtml(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||
node.querySelector('.agent-thread-header').addEventListener('click', () => node.classList.toggle('open'));
|
||||
// Animate wave
|
||||
const waveEl = node.querySelector('.agent-thread-wave');
|
||||
@@ -363,28 +620,33 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
if (json.image_url) {
|
||||
// Stop image spinner and render generated image in pane
|
||||
if (aiMsgEl._imgSpinner) { aiMsgEl._imgSpinner.destroy(); aiMsgEl._imgSpinner = null; }
|
||||
const safeImageUrl = safeDisplayImageSrc(json.image_url);
|
||||
aiBody.innerHTML = '';
|
||||
const img = document.createElement('img');
|
||||
img.className = 'compare-gen-image';
|
||||
img.src = json.image_url;
|
||||
img.alt = json.image_prompt || '';
|
||||
img.title = json.image_prompt || '';
|
||||
img.addEventListener('click', () => window.open(img.src, '_blank'));
|
||||
aiBody.appendChild(img);
|
||||
if (json.image_prompt) {
|
||||
const caption = document.createElement('div');
|
||||
caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
|
||||
caption.textContent = json.image_prompt;
|
||||
aiBody.appendChild(caption);
|
||||
if (!safeImageUrl) {
|
||||
aiBody.textContent = '[Image unavailable]';
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'compare-gen-image';
|
||||
img.src = safeImageUrl;
|
||||
img.alt = json.image_prompt || '';
|
||||
img.title = json.image_prompt || '';
|
||||
img.addEventListener('click', () => window.open(safeImageUrl, '_blank', 'noopener,noreferrer'));
|
||||
aiBody.appendChild(img);
|
||||
if (json.image_prompt) {
|
||||
const caption = document.createElement('div');
|
||||
caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
|
||||
caption.textContent = json.image_prompt;
|
||||
aiBody.appendChild(caption);
|
||||
}
|
||||
// Show model name below image (hidden in blind mode until vote)
|
||||
if (json.image_model && !state._blindMode) {
|
||||
const modelLabel = document.createElement('div');
|
||||
modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
|
||||
modelLabel.textContent = json.image_model;
|
||||
aiBody.appendChild(modelLabel);
|
||||
}
|
||||
aiMsgEl._imageData = { url: safeImageUrl, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
|
||||
}
|
||||
// Show model name below image (hidden in blind mode until vote)
|
||||
if (json.image_model && !state._blindMode) {
|
||||
const modelLabel = document.createElement('div');
|
||||
modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
|
||||
modelLabel.textContent = json.image_model;
|
||||
aiBody.appendChild(modelLabel);
|
||||
}
|
||||
aiMsgEl._imageData = { url: json.image_url, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
|
||||
} else if (currentToolBlock) {
|
||||
// Stop wave animation
|
||||
if (currentToolBlock._waveInterval) { clearInterval(currentToolBlock._waveInterval); currentToolBlock._waveInterval = null; }
|
||||
@@ -398,7 +660,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${escapeHtml(cmd)}</pre>` : '';
|
||||
currentToolBlock.className = 'agent-thread-node' + (ok ? '' : ' error');
|
||||
currentToolBlock.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${escapeHtml(tLabel)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${cmdHtml}${outHtml}</div>`;
|
||||
// The chevron is drawn by CSS; leaving a literal ▶ here created
|
||||
// two arrows in the completed tool rows.
|
||||
currentToolBlock.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${escapeHtml(tLabel)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron"></span></div><div class="agent-thread-content">${cmdHtml}${outHtml}</div>`;
|
||||
currentToolBlock.querySelector('.agent-thread-header').addEventListener('click', () => currentToolBlock.classList.toggle('open'));
|
||||
currentToolBlock = null;
|
||||
// Reset text element so next deltas create a fresh container
|
||||
@@ -455,6 +719,12 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
finalTarget.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
||||
}
|
||||
|
||||
// Preserve the client-side time-to-first-token measurement with the
|
||||
// server metrics so it appears in the compare summary and footer.
|
||||
if (metrics && _ttft > 0 && metrics.client_ttft == null) {
|
||||
metrics.client_ttft = Number((_ttft / 1000).toFixed(3));
|
||||
}
|
||||
|
||||
// ── Show play button if response contains HTML ──
|
||||
if (_autoPreviewHtml) _autoPreviewHtml(paneIdx, accumulated);
|
||||
|
||||
@@ -533,23 +803,51 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
footer.className = 'msg-footer';
|
||||
const span = document.createElement('span');
|
||||
span.className = 'response-metrics';
|
||||
let text = metrics.output_tokens + ' tokens | ' + metrics.tokens_per_second + ' tok/s';
|
||||
const outputTokens = metrics.output_tokens;
|
||||
const responseTime = metrics.response_time ?? metrics.total_time;
|
||||
const explicitTps = metrics.tokens_per_second ?? metrics.gen_tps ?? metrics.tps;
|
||||
const ttft = metrics.client_ttft ?? metrics.time_to_first_token;
|
||||
const numericOutput = Number(outputTokens);
|
||||
const numericTime = Number(responseTime);
|
||||
const numericTps = Number(explicitTps);
|
||||
const derivedTps = Number.isFinite(numericTps)
|
||||
? numericTps
|
||||
: (Number.isFinite(numericOutput) && Number.isFinite(numericTime) && numericTime > 0)
|
||||
? numericOutput / numericTime
|
||||
: null;
|
||||
const tpsLabel = derivedTps != null
|
||||
? (derivedTps >= 100 ? String(Math.round(derivedTps)) : derivedTps.toFixed(2).replace(/\.?0+$/, ''))
|
||||
: null;
|
||||
const parts = [];
|
||||
if (Number.isFinite(Number(ttft)) && Number(ttft) > 0) {
|
||||
parts.push('TTFT ' + Number(ttft).toFixed(3) + 's');
|
||||
}
|
||||
if (outputTokens != null && outputTokens !== 'undefined') {
|
||||
parts.push(outputTokens + ' tokens');
|
||||
}
|
||||
if (tpsLabel != null) {
|
||||
parts.push(tpsLabel + ' tok/s');
|
||||
}
|
||||
if (responseTime != null && responseTime !== 'undefined' && parts.length === 0) {
|
||||
parts.push(responseTime + 's');
|
||||
}
|
||||
// Add per-request cost and cost per 1000
|
||||
const _model = metrics.model || (state._selectedModels[paneIdx] && state._selectedModels[paneIdx].model) || '';
|
||||
const _cost = getModelCost(_model, metrics.input_tokens || 0, metrics.output_tokens || 0);
|
||||
_setPaneSummary(paneIdx, metrics, _cost);
|
||||
// Build the metrics span with optional cost and context
|
||||
span.textContent = text;
|
||||
span.textContent = parts.join(' | ');
|
||||
if (_cost !== null) {
|
||||
const _cost1k = _cost * 1000;
|
||||
const costSpan = document.createElement('span');
|
||||
costSpan.style.color = 'var(--color-success, #4caf50)';
|
||||
costSpan.title = 'Estimated cost per 1,000 responses like this one';
|
||||
costSpan.textContent = ' | $' + (_cost1k < 1 ? _cost1k.toFixed(2) : _cost1k.toFixed(0)) + '/1k';
|
||||
costSpan.textContent = (span.textContent ? ' | ' : '') + '$' + (_cost1k < 1 ? _cost1k.toFixed(2) : _cost1k.toFixed(0)) + '/1k';
|
||||
span.appendChild(costSpan);
|
||||
}
|
||||
if (metrics.context_percent > 0) {
|
||||
const ctx = document.createElement('span');
|
||||
ctx.textContent = ' | ' + metrics.context_percent + '% ctx';
|
||||
ctx.textContent = (span.textContent ? ' | ' : '') + metrics.context_percent + '% ctx';
|
||||
if (metrics.context_percent >= 85) ctx.style.color = 'var(--color-error)';
|
||||
else if (metrics.context_percent >= 70) ctx.style.color = '#ff9900';
|
||||
span.appendChild(ctx);
|
||||
@@ -557,6 +855,21 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
footer.appendChild(span);
|
||||
aiMsgEl.appendChild(footer);
|
||||
}
|
||||
const footerMetrics = aiMsgEl?.querySelector('.msg-footer:last-child .response-metrics');
|
||||
if (footerMetrics) {
|
||||
const thinkingMode = state._paneGenerationSettings[paneIdx]?.thinking_mode;
|
||||
if (thinkingMode === 'off') {
|
||||
const thinkingState = document.createElement('span');
|
||||
thinkingState.className = 'response-thinking-state';
|
||||
thinkingState.textContent = (footerMetrics.textContent ? ' | ' : '') + 'Thinking off';
|
||||
footerMetrics.appendChild(thinkingState);
|
||||
}
|
||||
footerMetrics.dataset.action = 'settings';
|
||||
footerMetrics.dataset.pane = String(paneIdx);
|
||||
footerMetrics.setAttribute('role', 'button');
|
||||
footerMetrics.setAttribute('tabindex', '0');
|
||||
footerMetrics.title = 'Response details and inference settings';
|
||||
}
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
|
||||
} catch (error) {
|
||||
@@ -599,19 +912,25 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
// TTFT removed from the header per user request — just show total time.
|
||||
_timerEl.textContent = _formatMs(_totalMs);
|
||||
}
|
||||
state._abortControllers[paneIdx] = null;
|
||||
if (state._abortControllers[paneIdx] === ac) {
|
||||
state._abortControllers[paneIdx] = null;
|
||||
}
|
||||
// Hide stop button, show response action buttons
|
||||
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (_paneElFinal) {
|
||||
_paneElFinal.classList.remove('is-streaming');
|
||||
_paneElFinal.classList.toggle('is-awaiting-input', awaitingChoice);
|
||||
_paneElFinal.classList.toggle('is-done', streamOk && !awaitingChoice);
|
||||
_paneElFinal.classList.toggle('is-failed', !streamOk && !awaitingChoice);
|
||||
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
|
||||
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
|
||||
if (accumulated.trim()) {
|
||||
if (!awaitingChoice && accumulated.trim()) {
|
||||
_paneElFinal.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||||
}
|
||||
}
|
||||
state._paneMetrics[paneIdx] = metrics;
|
||||
state._paneElapsed[paneIdx] = _totalMs;
|
||||
if (!opts.skipBadge) {
|
||||
if (!opts.skipBadge && !awaitingChoice) {
|
||||
if (streamOk) {
|
||||
state._finishOrder++;
|
||||
if (state._parallel) {
|
||||
@@ -641,12 +960,14 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
}
|
||||
// Auto-grade against expected answer — stamps ✓ or ✗ on the pane header.
|
||||
if (streamOk && state._expectedAnswer) {
|
||||
if (streamOk && !awaitingChoice && state._expectedAnswer) {
|
||||
_stampGradeBadge(paneIdx, accumulated, state._expectedAnswer);
|
||||
}
|
||||
// Show copy/reroll buttons now that response exists
|
||||
const paneEl = document.querySelector('.compare-pane:nth-child(' + (paneIdx + 1) + ')');
|
||||
if (paneEl) paneEl.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||||
if (paneEl && !awaitingChoice && accumulated.trim()) {
|
||||
paneEl.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,9 +1007,12 @@ function _stampGradeBadge(paneIdx, response, expected) {
|
||||
badge.className = 'pane-grade-badge ' + (pass ? 'pass' : 'fail');
|
||||
badge.title = pass ? 'Response contains the expected answer' : 'Expected answer not found in response';
|
||||
badge.textContent = pass ? '✓' : '✗';
|
||||
// Insert just before the finish badge if present, else after the title
|
||||
// The two-row pane header keeps result badges inside .pane-stats.
|
||||
// Always insert relative to the finish badge's actual parent.
|
||||
const finBadge = header.querySelector('.pane-finish-badge');
|
||||
if (finBadge) header.insertBefore(badge, finBadge);
|
||||
const stats = header.querySelector('.pane-stats');
|
||||
if (finBadge?.parentNode) finBadge.parentNode.insertBefore(badge, finBadge);
|
||||
else if (stats) stats.appendChild(badge);
|
||||
else header.appendChild(badge);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import Storage from '../storage.js';
|
||||
import state from './state.js';
|
||||
import { _modelDisplayNames } from './models.js';
|
||||
import { getModelCost } from '../chatRenderer.js';
|
||||
import uiModule from '../ui.js';
|
||||
import { VOTES_STORAGE_KEY, VOTES_MAX } from './icons.js';
|
||||
import { showScoreboard } from './scoreboard.js';
|
||||
import { getModelCost } from '../chatRenderer.js?v=20260910streamlinks2';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import { VOTES_STORAGE_KEY, VOTES_MAX } from './icons.js?v=20260908compareprompts1';
|
||||
import { showScoreboard } from './scoreboard.js?v=20260909voteconfirmalign1';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -74,7 +74,7 @@ function buildVoteBar(n) {
|
||||
// before a prompt) since viewing the scoreboard is always allowed.
|
||||
const scoreBtn = document.createElement('button');
|
||||
scoreBtn.className = 'compare-vote-btn compare-score-btn';
|
||||
scoreBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>Score';
|
||||
scoreBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span class="compare-score-label">Score</span>';
|
||||
scoreBtn.title = 'Scoreboard';
|
||||
scoreBtn.addEventListener('click', () => showScoreboard());
|
||||
bar.insertBefore(scoreBtn, tieBtn); // furthest left, before Tie
|
||||
@@ -181,7 +181,7 @@ function handleVote(winnerIdx) {
|
||||
|
||||
let html = '';
|
||||
const caret = ' <span class="pane-title-caret">▾</span>';
|
||||
if (isWinner) html = '<span style="color:var(--red);margin-right:4px;">★</span><strong>' + escapeHtml(name) + '</strong> <span style="color:var(--red);font-size:0.82em;font-weight:800;text-transform:uppercase;letter-spacing:1px;position:relative;top:-2px;">Winner!</span>' + caret;
|
||||
if (isWinner) html = '<span style="color:var(--green, #50fa7b);margin-right:4px;">★</span><strong>' + escapeHtml(name) + '</strong> <span style="color:var(--green, #50fa7b);font-size:0.82em;font-weight:800;text-transform:uppercase;letter-spacing:1px;position:relative;top:0;">Winner!</span>' + caret;
|
||||
else if (isTie) html = '<span style="opacity:0.5;margin-right:4px;">=</span><strong>' + escapeHtml(name) + '</strong>' + caret;
|
||||
else html = '<strong>' + escapeHtml(name) + '</strong>' + caret;
|
||||
el.innerHTML = html;
|
||||
|
||||
Reference in New Issue
Block a user