Odysseus v1.0
This commit is contained in:
77
static/js/compare/icons.js
Normal file
77
static/js/compare/icons.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// compare/icons.js — SVG icons, prompt templates, and constants
|
||||
|
||||
// ── SVG Icons ──
|
||||
|
||||
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 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>';
|
||||
export const ICON_COLLAPSE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||||
export const ICON_DICE = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="3"/><circle cx="8" cy="8" r="1.5" fill="currentColor"/><circle cx="16" cy="8" r="1.5" fill="currentColor"/><circle cx="8" cy="16" r="1.5" fill="currentColor"/><circle cx="16" cy="16" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
|
||||
export const ICON_PLAY = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5,3 19,12 5,21"/></svg>';
|
||||
export const ICON_CODE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>';
|
||||
export const ICON_CLOSE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>';
|
||||
// Parallel = lines side by side, Sequential = numbered list
|
||||
export const ICON_PARALLEL = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>';
|
||||
export const ICON_SEQUENTIAL = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="8" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="18" x2="20" y2="18"/><circle cx="4" cy="6" r="1.5" fill="currentColor"/><circle cx="4" cy="12" r="1.5" fill="currentColor"/><circle cx="4" cy="18" r="1.5" fill="currentColor"/></svg>';
|
||||
export const SEND_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
|
||||
|
||||
// ── Animation ──
|
||||
|
||||
export const WAVE_FRAMES = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
|
||||
|
||||
// ── Storage keys & limits ──
|
||||
|
||||
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 ──
|
||||
//
|
||||
// Five high-signal prompts per category — each picked to differentiate models
|
||||
// on a distinct capability. The Visual / SVG-render prompt in `chat` ends with
|
||||
// the subject as the last words, so swapping "a pelican riding a bicycle" for
|
||||
// anything else is a one-line edit.
|
||||
|
||||
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: '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: '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.' },
|
||||
],
|
||||
code: [
|
||||
{ sub: 'Algorithms', label: 'LRU cache', prompt: 'Implement an LRU cache with O(1) get and put operations. Support a configurable max capacity. Write it in any language with full comments.' },
|
||||
{ sub: 'Debugging', label: 'Race condition', prompt: 'This Go code has a race condition. Find it, explain why it happens, and fix it:\n\nvar counter int\nfunc increment(wg *sync.WaitGroup) {\n defer wg.Done()\n for i := 0; i < 1000; i++ {\n counter++\n }\n}' },
|
||||
{ sub: 'Debugging', label: 'Security review', prompt: 'Review this code for bugs, security issues, and performance problems:\n\napp.get("/user/:id", (req, res) => {\n const query = `SELECT * FROM users WHERE id = ${req.params.id}`;\n db.query(query, (err, result) => {\n res.json(result[0]);\n });\n});' },
|
||||
{ sub: 'Architecture', label: 'URL shortener', prompt: 'Design a URL shortener service. Cover the API, database schema, and how you would handle 1000 requests per second.' },
|
||||
{ sub: 'Refactoring', label: 'Clean up', prompt: 'Refactor this code to be more idiomatic and efficient:\n\nresults = []\nfor i in range(len(data)):\n if data[i]["status"] == "active":\n if data[i]["score"] > 50:\n results.append(data[i]["name"].upper())' },
|
||||
],
|
||||
agent: [
|
||||
{ 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: '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: '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.' },
|
||||
],
|
||||
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.' },
|
||||
{ sub: 'Games', label: 'Breakout', prompt: 'Output a complete HTML file (```html block) for a Breakout brick breaker game. ONLY use vanilla HTML, CSS, and JavaScript — no libraries, no Python, no imports, no external files. Canvas-based, colorful gradient brick rows, glowing paddle, ball with trail, score + lives, particle explosions on break. Skip any explanation, just output the code.' },
|
||||
{ sub: 'Animation', label: 'Solar system', prompt: 'Output a complete HTML file (```html block) for an animated solar system. ONLY use vanilla HTML, CSS, and JavaScript — no libraries, no Python, no imports, no external files. Canvas-based, glowing Sun center, 8 planets orbiting at correct relative speeds with real colors, orbit trails, starfield background, labels on hover. Skip any explanation, just output the code.' },
|
||||
{ sub: 'Animation', label: 'Matrix rain', prompt: 'Output a complete HTML file (```html block) for the Matrix digital rain effect. ONLY use vanilla HTML, CSS, and JavaScript — no libraries, no Python, no imports, no external files. Full-screen canvas, green katakana characters falling at varying speeds, glowing heads, fading trails, scan-line overlay. Skip any explanation, just output the code.' },
|
||||
{ sub: 'Generative', label: 'Fractal tree', prompt: 'Output a complete HTML file (```html block) for an interactive fractal tree. ONLY use vanilla HTML, CSS, and JavaScript — no libraries, no Python, no imports, no external files. Canvas-based, tree grows from bottom with recursive branches, sliders for angle/depth/length/wind, gradient colors from brown trunk to green leaves. Skip any explanation, just output the code.' },
|
||||
],
|
||||
search: [
|
||||
{ sub: 'Factual', label: 'Current events', prompt: 'latest AI regulation news 2025' },
|
||||
{ sub: 'Technical', label: 'Programming', prompt: 'Rust vs Go performance benchmarks 2025' },
|
||||
{ sub: 'Research', label: 'Academic', prompt: 'transformer architecture improvements since attention is all you need' },
|
||||
{ sub: 'Comparison', label: 'GPU providers', prompt: 'cloud GPU providers pricing comparison 2025' },
|
||||
{ sub: 'Factual', label: 'Science', prompt: 'CRISPR gene therapy breakthroughs' },
|
||||
],
|
||||
};
|
||||
1476
static/js/compare/index.js
Normal file
1476
static/js/compare/index.js
Normal file
File diff suppressed because it is too large
Load Diff
103
static/js/compare/models.js
Normal file
103
static/js/compare/models.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// compare/models.js — model classification, fetching, display names, persistence
|
||||
import Storage from '../storage.js';
|
||||
import state from './state.js';
|
||||
import uiModule from '../ui.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
// ── Model classification constants ──
|
||||
const NON_CHAT_PREFIXES = ['tts-', 'whisper-', 'text-embedding-', 'text-moderation-', 'moderation-', 'embedding'];
|
||||
const NON_CHAT_SUFFIXES = ['deep-research', '-online'];
|
||||
const IMAGE_PREFIXES = ['dall-e-3', 'gpt-image', 'chatgpt-image'];
|
||||
const DEPRECATED_IMAGE = ['dall-e-2'];
|
||||
|
||||
function classifyModel(id) {
|
||||
const lower = id.toLowerCase();
|
||||
if (DEPRECATED_IMAGE.some(p => lower.startsWith(p))) return 'other';
|
||||
if (IMAGE_PREFIXES.some(p => lower.startsWith(p))) return 'image';
|
||||
if (NON_CHAT_PREFIXES.some(p => lower.startsWith(p))) return 'other';
|
||||
if (NON_CHAT_SUFFIXES.some(p => lower.endsWith(p) || lower.includes(p))) return 'other';
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
/** Build display names for selected models, adding endpoint name when the same model appears from multiple providers. */
|
||||
function _modelDisplayNames(models) {
|
||||
const nameCount = {};
|
||||
for (const m of models) {
|
||||
const short = m.name || m.model.split('/').pop();
|
||||
nameCount[short] = (nameCount[short] || 0) + 1;
|
||||
}
|
||||
return models.map(m => {
|
||||
const short = m.name || m.model.split('/').pop();
|
||||
if (nameCount[short] > 1 && m.endpointName) return short + ' (' + escapeHtml(m.endpointName) + ')';
|
||||
return short;
|
||||
});
|
||||
}
|
||||
|
||||
/** Save selected models and synth models to localStorage, keyed by compare mode. */
|
||||
function _persistSelections() {
|
||||
if (state._selectedModels.length > 0) {
|
||||
Storage.setJSON('odysseus-compare-selections-' + (state._compareMode || 'chat'), state._selectedModels);
|
||||
}
|
||||
if ((state._compareMode === 'search' || state._compareMode === 'research') && state._searchSynthModels) {
|
||||
Storage.setJSON('odysseus-compare-synth-' + state._compareMode, state._searchSynthModels);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model fetching with cache ──
|
||||
const MODELS_CACHE_TTL = 30000; // 30 seconds
|
||||
|
||||
/** Fetch available models from API. */
|
||||
async function fetchModels() {
|
||||
const now = Date.now();
|
||||
if (state._fetchModelsCache && (now - state._fetchModelsCacheTime) < MODELS_CACHE_TTL) {
|
||||
return state._fetchModelsCache;
|
||||
}
|
||||
const res = await fetch(`${state.API_BASE}/api/models`);
|
||||
const data = await res.json();
|
||||
const models = [];
|
||||
if (data.items && data.items.length > 0) {
|
||||
data.items.forEach(item => {
|
||||
const displayNames = item.models_display || item.models || [];
|
||||
const extraDisplay = item.models_extra_display || item.models_extra || [];
|
||||
// Curated list (item.models) takes priority; non-curated extras come
|
||||
// after so newer/uncatalogued models (e.g. deepseek-v4-pro) still show.
|
||||
(item.models || []).forEach((mid, i) => {
|
||||
models.push({
|
||||
id: mid,
|
||||
url: item.url,
|
||||
name: (displayNames[i] || mid).split('/').pop(),
|
||||
endpointId: item.endpoint_id || null,
|
||||
endpointName: item.endpoint_name || '',
|
||||
type: classifyModel(mid),
|
||||
});
|
||||
});
|
||||
(item.models_extra || []).forEach((mid, i) => {
|
||||
models.push({
|
||||
id: mid,
|
||||
url: item.url,
|
||||
name: (extraDisplay[i] || mid).split('/').pop(),
|
||||
endpointId: item.endpoint_id || null,
|
||||
endpointName: item.endpoint_name || '',
|
||||
type: classifyModel(mid),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
state._fetchModelsCache = models;
|
||||
state._fetchModelsCacheTime = now;
|
||||
return models;
|
||||
}
|
||||
|
||||
// ── Shuffle pool persistence ──
|
||||
const POOL_STORAGE_KEY = 'odysseus-shuffle-pool-excluded';
|
||||
|
||||
function getExcludedModels() {
|
||||
return Storage.getJSON(POOL_STORAGE_KEY, []);
|
||||
}
|
||||
|
||||
function setExcludedModels(arr) {
|
||||
Storage.setJSON(POOL_STORAGE_KEY, arr);
|
||||
}
|
||||
|
||||
export { classifyModel, _modelDisplayNames, fetchModels, _persistSelections, getExcludedModels, setExcludedModels };
|
||||
826
static/js/compare/panes.js
Normal file
826
static/js/compare/panes.js
Normal file
@@ -0,0 +1,826 @@
|
||||
// compare/panes.js — pane lifecycle, actions, layout
|
||||
import state from './state.js';
|
||||
import { _persistSelections } from './models.js';
|
||||
import { buildVoteBar } from './vote.js';
|
||||
import {
|
||||
ICON_REROLL, ICON_COPY, ICON_EXPAND, ICON_COLLAPSE, ICON_CLOSE,
|
||||
ICON_PLAY, ICON_CODE, SEND_SVG,
|
||||
} from './icons.js';
|
||||
import { _clearProbeWaves } from './probe.js';
|
||||
import Storage from '../storage.js';
|
||||
import uiModule from '../ui.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
// ── Lazy-registered functions from compare.js (avoids circular imports) ──
|
||||
let _setSendBtn = null;
|
||||
let _deactivate = null;
|
||||
let _streamToPane = null;
|
||||
let _renderSearchResults = null;
|
||||
let _fetchModels = null;
|
||||
|
||||
/** Register external functions that live in compare.js or sibling modules. */
|
||||
function registerPaneActions({ setSendBtn, deactivate, streamToPane, renderSearchResults, fetchModels }) {
|
||||
if (setSendBtn) _setSendBtn = setSendBtn;
|
||||
if (deactivate) _deactivate = deactivate;
|
||||
if (streamToPane) _streamToPane = streamToPane;
|
||||
if (renderSearchResults) _renderSearchResults = renderSearchResults;
|
||||
if (fetchModels) _fetchModels = fetchModels;
|
||||
}
|
||||
|
||||
/** 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); }
|
||||
|
||||
// ── Stop / reroll ──
|
||||
|
||||
function stopAll() {
|
||||
state._abortControllers.forEach(ac => { if (ac) ac.abort(); });
|
||||
state._abortControllers = [];
|
||||
state._streaming = false;
|
||||
if (_setSendBtn) _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 = '';
|
||||
});
|
||||
}
|
||||
|
||||
function stopPane(paneIdx) {
|
||||
const ac = state._abortControllers[paneIdx];
|
||||
if (ac) {
|
||||
ac.abort();
|
||||
state._abortControllers[paneIdx] = null;
|
||||
}
|
||||
// Hide stop button, show reroll
|
||||
const pane = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (pane) {
|
||||
const stopBtn = pane.querySelector('.pane-stop-btn');
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
pane.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||||
}
|
||||
// Remove spinner if present
|
||||
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||
if (hist) {
|
||||
const lastAi = hist.querySelector('.msg-ai:last-child');
|
||||
if (lastAi && lastAi._spinner) { lastAi._spinner.destroy(); lastAi._spinner = null; }
|
||||
const body = lastAi && lastAi.querySelector('.body');
|
||||
if (body && !body.textContent.trim()) {
|
||||
body.innerHTML = '<span style="opacity:0.4;font-style:italic;">Stopped</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function rerollPane(paneIdx, overrideTimeout) {
|
||||
// Allow reroll even while other panes stream — just stop this pane first
|
||||
if (state._abortControllers[paneIdx]) stopPane(paneIdx);
|
||||
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||
// Reset preview state
|
||||
const _ri = document.getElementById('cmp-iframe-' + paneIdx);
|
||||
if (_ri) { _ri.srcdoc = ''; _ri.style.display = 'none'; _ri._htmlCode = null; }
|
||||
const _rp = document.getElementById('cmp-preview-' + paneIdx);
|
||||
if (_rp) { _rp.style.display = 'none'; _rp.classList.remove('active'); }
|
||||
if (hist) hist.style.display = '';
|
||||
if (!hist) return;
|
||||
const userBodies = hist.querySelectorAll('.msg-user .body');
|
||||
const firstUserText = userBodies.length > 0 ? userBodies[0].textContent : '';
|
||||
if (!firstUserText) return;
|
||||
|
||||
// Clear all messages and start fresh
|
||||
hist.innerHTML = '';
|
||||
const userMsg = document.createElement('div');
|
||||
userMsg.className = 'msg msg-user';
|
||||
userMsg.innerHTML = '<div class="role">You</div><div class="body">' + escapeHtml(firstUserText) + '</div>';
|
||||
hist.appendChild(userMsg);
|
||||
|
||||
// Reset badge and timer
|
||||
const badge = document.getElementById('cmp-badge-' + paneIdx);
|
||||
if (badge) { badge.textContent = ''; badge.style.color = ''; }
|
||||
const timer = document.getElementById('cmp-timer-' + paneIdx);
|
||||
if (timer) timer.textContent = '';
|
||||
|
||||
// Search mode: re-query the search provider
|
||||
if (state._compareMode === 'search') {
|
||||
const aiMsg = document.createElement('div');
|
||||
aiMsg.className = 'msg msg-ai';
|
||||
aiMsg.innerHTML = '<div class="role">Search</div><div class="body"></div>';
|
||||
const aiBody = aiMsg.querySelector('.body');
|
||||
if (spinnerModule) {
|
||||
const spinner = spinnerModule.create('Searching...', 'right');
|
||||
aiBody.appendChild(spinner.createElement());
|
||||
spinner.start();
|
||||
}
|
||||
hist.appendChild(aiMsg);
|
||||
hist.scrollTop = hist.scrollHeight;
|
||||
|
||||
const m = state._selectedModels[paneIdx];
|
||||
const fd = new FormData();
|
||||
fd.append('query', firstUserText);
|
||||
fd.append('provider', m.model);
|
||||
fd.append('count', '10');
|
||||
try {
|
||||
const ac = new AbortController();
|
||||
state._abortControllers[paneIdx] = ac;
|
||||
const t0 = performance.now();
|
||||
const res = await fetch(`${state.API_BASE}/api/search/query`, { method: 'POST', body: fd, signal: ac.signal });
|
||||
const data = await res.json();
|
||||
const elapsed = ((performance.now() - t0) / 1000).toFixed(2);
|
||||
aiBody.innerHTML = '';
|
||||
if (data.error) {
|
||||
aiBody.innerHTML = '<div style="color:var(--color-error);font-size:0.85em;">Error: ' + escapeHtml(data.error) + '</div>';
|
||||
} else if (!data.results || data.results.length === 0) {
|
||||
aiBody.innerHTML = '<div style="color:color-mix(in srgb, var(--fg) 50%, transparent);font-size:0.85em;font-style:italic;">No results found</div>';
|
||||
} else {
|
||||
aiBody.appendChild(_renderSearchResults(data));
|
||||
}
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'msg-footer';
|
||||
const span = document.createElement('span');
|
||||
span.className = 'response-metrics';
|
||||
const parts = [];
|
||||
if (data.results) parts.push(data.results.length + ' results');
|
||||
parts.push(elapsed + 's');
|
||||
span.textContent = parts.join(' | ');
|
||||
footer.appendChild(span);
|
||||
aiMsg.appendChild(footer);
|
||||
} catch (err) {
|
||||
aiBody.innerHTML = '<div style="color:var(--color-error);font-size:0.85em;">Error: ' + escapeHtml(err.message) + '</div>';
|
||||
}
|
||||
state._abortControllers[paneIdx] = null;
|
||||
hist.scrollTop = hist.scrollHeight;
|
||||
return;
|
||||
}
|
||||
|
||||
// Chat/agent mode: stream via session
|
||||
const aiMsg = document.createElement('div');
|
||||
aiMsg.className = 'msg msg-ai';
|
||||
aiMsg.innerHTML = '<div class="role">AI</div><div class="body"></div>';
|
||||
const aiBody = aiMsg.querySelector('.body');
|
||||
if (spinnerModule) {
|
||||
const label = overrideTimeout ? 'Retrying (' + overrideTimeout + 's)...' : 'Re-rolling...';
|
||||
const spinner = spinnerModule.create(label, 'right');
|
||||
aiBody.appendChild(spinner.createElement());
|
||||
spinner.start();
|
||||
aiMsg._spinner = spinner;
|
||||
}
|
||||
hist.appendChild(aiMsg);
|
||||
hist.scrollTop = hist.scrollHeight;
|
||||
|
||||
const opts = { skipBadge: true };
|
||||
if (overrideTimeout) opts.timeout = overrideTimeout;
|
||||
await _streamToPane(paneIdx, state._paneSessionIds[paneIdx], firstUserText, aiMsg, opts);
|
||||
}
|
||||
|
||||
// ── Expand / preview / copy ──
|
||||
|
||||
function toggleExpandPane(paneIdx, btn) {
|
||||
const grid = document.querySelector('.compare-grid');
|
||||
if (!grid) return;
|
||||
const panes = grid.querySelectorAll('.compare-pane');
|
||||
const target = panes[paneIdx];
|
||||
if (!target) return;
|
||||
|
||||
if (target.classList.contains('expanded')) {
|
||||
target.classList.remove('expanded');
|
||||
panes.forEach(p => { p.style.display = ''; });
|
||||
if (btn) btn.innerHTML = ICON_EXPAND;
|
||||
} else {
|
||||
target.classList.add('expanded');
|
||||
panes.forEach((p, i) => { if (i !== paneIdx) p.style.display = 'none'; });
|
||||
if (btn) btn.innerHTML = ICON_COLLAPSE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After streaming finishes, check for HTML code in the response.
|
||||
* If found, show the play button in the header. User clicks to run.
|
||||
*/
|
||||
function _autoPreviewHtml(paneIdx, accumulated) {
|
||||
if (!accumulated) return;
|
||||
const htmlCode = _extractHtmlFromText(accumulated);
|
||||
if (!htmlCode) return;
|
||||
|
||||
const iframe = document.getElementById('cmp-iframe-' + paneIdx);
|
||||
const previewBtn = document.getElementById('cmp-preview-' + paneIdx);
|
||||
if (!iframe || !previewBtn) return;
|
||||
|
||||
// Store the HTML on the iframe for when user clicks play
|
||||
iframe._htmlCode = htmlCode;
|
||||
|
||||
// Show the play button
|
||||
previewBtn.style.display = '';
|
||||
previewBtn.innerHTML = ICON_PLAY;
|
||||
previewBtn.title = 'Run preview';
|
||||
}
|
||||
|
||||
/** Toggle between iframe preview and code view for a pane. */
|
||||
function togglePanePreview(paneIdx) {
|
||||
const iframe = document.getElementById('cmp-iframe-' + paneIdx);
|
||||
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||
const btn = document.getElementById('cmp-preview-' + paneIdx);
|
||||
if (!iframe || !hist || !btn) return;
|
||||
|
||||
const showingPreview = iframe.style.display !== 'none';
|
||||
if (showingPreview) {
|
||||
// Switch to code view
|
||||
iframe.style.display = 'none';
|
||||
hist.style.display = '';
|
||||
btn.innerHTML = ICON_PLAY;
|
||||
btn.title = 'Run preview';
|
||||
btn.classList.remove('active');
|
||||
} else {
|
||||
// Switch to preview — load on first click
|
||||
if (iframe._htmlCode) iframe.srcdoc = iframe._htmlCode;
|
||||
iframe.style.display = '';
|
||||
hist.style.display = 'none';
|
||||
btn.innerHTML = ICON_CODE;
|
||||
btn.title = 'Show code';
|
||||
btn.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract full HTML document from raw accumulated text. */
|
||||
function _extractHtmlFromText(text) {
|
||||
// 1. Try markdown code fences
|
||||
const fenceRe = /`{3,}(?:html)?\s*\r?\n([\s\S]*?)`{3,}/gi;
|
||||
let match;
|
||||
while ((match = fenceRe.exec(text)) !== null) {
|
||||
const code = match[1].trim();
|
||||
if (/<!doctype\s+html|<html[\s>]/i.test(code)) return code;
|
||||
}
|
||||
// 2. Bare HTML
|
||||
const bare = text.match(/(<!doctype\s+html[\s\S]*<\/html>)/i)
|
||||
|| text.match(/(<html[\s>][\s\S]*<\/html>)/i);
|
||||
if (bare) return bare[1].trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
async function copyPaneResponse(paneIdx) {
|
||||
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||
if (!hist) return;
|
||||
const aiMsgs = hist.querySelectorAll('.msg-ai');
|
||||
if (aiMsgs.length === 0) return;
|
||||
const lastAi = aiMsgs[aiMsgs.length - 1];
|
||||
// For image panes, copy the prompt text
|
||||
const text = lastAi._imageData ? (lastAi._imageData.prompt || '') : (lastAi.querySelector('.body')?.textContent || '');
|
||||
try { await navigator.clipboard.writeText(text); }
|
||||
catch (e) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text; document.body.appendChild(ta); ta.select();
|
||||
document.execCommand('copy'); ta.remove();
|
||||
}
|
||||
if (uiModule) uiModule.showToast(lastAi._imageData ? 'Prompt copied!' : 'Copied!');
|
||||
}
|
||||
|
||||
// ── Add / create / remove panes ──
|
||||
|
||||
/** Show a model picker dropdown anchored to the "+" button in the pane header. */
|
||||
async function _addPane(anchorBtn) {
|
||||
if (state._streaming) return;
|
||||
const _effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode;
|
||||
const filtered = state._cachedModels.filter(m => m.type === _effectiveType);
|
||||
if (!filtered.length) return;
|
||||
|
||||
// Toggle existing dropdown
|
||||
const existing = document.querySelector('.add-pane-dropdown');
|
||||
if (existing) { existing.remove(); return; }
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'add-pane-dropdown';
|
||||
|
||||
// Search input for large model lists
|
||||
if (filtered.length >= 5) {
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.type = 'text';
|
||||
searchInput.placeholder = 'Search models\u2026';
|
||||
searchInput.className = 'add-pane-search';
|
||||
searchInput.addEventListener('input', () => {
|
||||
const q = searchInput.value.toLowerCase().trim();
|
||||
dropdown.querySelectorAll('.pane-model-item').forEach(item => {
|
||||
item.style.display = item.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
searchInput.addEventListener('click', (e) => e.stopPropagation());
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const first = dropdown.querySelector('.pane-model-item:not([style*="display: none"])');
|
||||
if (first) first.click();
|
||||
}
|
||||
});
|
||||
dropdown.appendChild(searchInput);
|
||||
// Desktop: auto-focus the search box so the user can start typing.
|
||||
// Mobile: skip — auto-focus pops the on-screen keyboard and covers
|
||||
// the model list. The user can tap the search box if they want to
|
||||
// filter, otherwise they just tap a model directly.
|
||||
if (window.innerWidth > 768) setTimeout(() => searchInput.focus(), 0);
|
||||
}
|
||||
|
||||
filtered.forEach(m => {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'pane-model-item';
|
||||
const label = m.endpointName ? m.name + ' (' + m.endpointName + ')' : m.name;
|
||||
item.textContent = label;
|
||||
const alreadyUsed = state._selectedModels.some(s => s.model === m.id && s.endpointId === m.endpointId);
|
||||
if (alreadyUsed) item.classList.add('current');
|
||||
|
||||
item.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
await _createAndAppendPane(m);
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
// Position dropdown relative to the viewport (position: fixed) so it
|
||||
// can't end up off-screen even when the toolbar has scrolled or the
|
||||
// chat-container is wider than the viewport.
|
||||
const btnRect = anchorBtn.getBoundingClientRect();
|
||||
dropdown.style.position = 'fixed';
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const margin = 8;
|
||||
// Render off-screen first so we can measure the dropdown's actual size.
|
||||
// Clamp the width to the viewport up front so long model names can't push
|
||||
// the dropdown off the screen edge, and lift z-index above the panes.
|
||||
dropdown.style.left = '-9999px';
|
||||
dropdown.style.top = '0';
|
||||
dropdown.style.maxWidth = (vw - margin * 2) + 'px';
|
||||
dropdown.style.zIndex = '100000';
|
||||
document.body.appendChild(dropdown);
|
||||
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].
|
||||
let left = btnRect.right - ddW;
|
||||
if (left + ddW > vw - margin) left = vw - margin - ddW;
|
||||
if (left < margin) left = margin;
|
||||
// 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) {
|
||||
top = Math.min(btnRect.bottom + 4, vh - margin - Math.min(ddH, vh - margin * 2));
|
||||
} else {
|
||||
top = Math.max(margin, btnRect.top - 4 - ddH);
|
||||
}
|
||||
dropdown.style.left = left + 'px';
|
||||
dropdown.style.top = top + 'px';
|
||||
dropdown.style.right = 'auto';
|
||||
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);
|
||||
}
|
||||
|
||||
/** Create a new pane for the given model and append it to the compare grid. */
|
||||
async function _createAndAppendPane(m) {
|
||||
const i = state._selectedModels.length; // New index
|
||||
|
||||
// Create session
|
||||
const fd = new FormData();
|
||||
fd.append('name', '[CMP] ' + 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');
|
||||
}
|
||||
const res = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
// 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._paneMetrics.push(null);
|
||||
state._abortControllers.push(null);
|
||||
_persistSelections();
|
||||
if (window._updateCheckBtnState) window._updateCheckBtnState();
|
||||
|
||||
// Build pane DOM
|
||||
const label = state._blindMode ? 'Model ' + _slotChar(i) : m.name;
|
||||
const pane = document.createElement('div');
|
||||
pane.className = 'compare-pane';
|
||||
pane.dataset.pane = String(i);
|
||||
pane.innerHTML =
|
||||
'<div class="pane-header">' +
|
||||
'<button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">▾</span></button>' +
|
||||
'<span class="pane-timer" id="cmp-timer-' + i + '"></span>' +
|
||||
'<span class="pane-finish-badge" id="cmp-badge-' + i + '"></span>' +
|
||||
'<div class="pane-actions">' +
|
||||
'<button class="pane-action-btn pane-preview-btn" data-action="preview" data-pane="' + i + '" id="cmp-preview-' + i + '" title="Run preview" style="display:none;">' + ICON_PLAY + '</button>' +
|
||||
'<button class="pane-action-btn" data-action="reroll" data-pane="' + i + '" title="Re-roll">' + ICON_REROLL + '</button>' +
|
||||
'<button class="pane-action-btn" data-action="copy" data-pane="' + i + '" title="Copy">' + ICON_COPY + '</button>' +
|
||||
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
|
||||
'<button class="pane-action-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane">' + ICON_CLOSE + '</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="chat-history" id="cmp-history-' + i + '"></div>' +
|
||||
'<iframe class="compare-pane-iframe" id="cmp-iframe-' + i + '" sandbox="allow-scripts" style="display:none;"></iframe>' +
|
||||
'<div class="pane-vote-footer">' +
|
||||
'<button class="pane-vote-btn" data-pane="' + i + '" type="button" disabled style="opacity:0.4;">' +
|
||||
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;vertical-align:-2px;"><polyline points="20 6 9 17 4 12"/></svg>' +
|
||||
'<span class="pane-vote-label">Vote ' + escapeHtml(label) + '</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
|
||||
// Append to grid
|
||||
const grid = document.querySelector('.compare-grid');
|
||||
grid.appendChild(pane);
|
||||
|
||||
// Update grid columns
|
||||
const n = state._selectedModels.length;
|
||||
grid.dataset.cols = String(Math.min(n, 4));
|
||||
|
||||
// Update header label
|
||||
const headerSpan = document.querySelector('.compare-active > div:first-child span');
|
||||
if (headerSpan) {
|
||||
const modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
|
||||
headerSpan.textContent = 'Comparing' + modeLabel +
|
||||
(state._blindMode ? ' (blind)' : '') + ' \u00b7 ' + state._timeout + 's timeout';
|
||||
}
|
||||
|
||||
// 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. */
|
||||
function _removePane(paneIdx) {
|
||||
if (state._streaming) return;
|
||||
|
||||
// Abort if streaming
|
||||
if (state._abortControllers[paneIdx]) state._abortControllers[paneIdx].abort();
|
||||
|
||||
// Delete the session
|
||||
const sid = state._paneSessionIds[paneIdx];
|
||||
if (sid) {
|
||||
fetch(`${state.API_BASE}/api/session/${sid}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
|
||||
// Remove from arrays
|
||||
state._selectedModels.splice(paneIdx, 1);
|
||||
state._paneSessionIds.splice(paneIdx, 1);
|
||||
state._paneMetrics.splice(paneIdx, 1);
|
||||
state._abortControllers.splice(paneIdx, 1);
|
||||
_persistSelections();
|
||||
if (window._updateCheckBtnState) window._updateCheckBtnState();
|
||||
|
||||
// If no panes left, exit compare mode
|
||||
if (state._selectedModels.length === 0) {
|
||||
if (_deactivate) _deactivate(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild pane DOM — re-index all panes so IDs stay consistent
|
||||
const grid = document.querySelector('.compare-grid');
|
||||
grid.querySelectorAll('.compare-pane').forEach(p => p.remove());
|
||||
|
||||
const n = state._selectedModels.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const label = state._blindMode ? 'Model ' + _slotChar(i) : state._selectedModels[i].name;
|
||||
const pane = document.createElement('div');
|
||||
pane.className = 'compare-pane';
|
||||
pane.dataset.pane = String(i);
|
||||
pane.innerHTML =
|
||||
'<div class="pane-header">' +
|
||||
'<button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">▾</span></button>' +
|
||||
'<span class="pane-timer" id="cmp-timer-' + i + '"></span>' +
|
||||
'<span class="pane-finish-badge" id="cmp-badge-' + i + '"></span>' +
|
||||
'<div class="pane-actions">' +
|
||||
'<button class="pane-action-btn pane-stop-btn" data-action="stop" data-pane="' + i + '" title="Stop" style="display:none;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>' +
|
||||
'<button class="pane-action-btn pane-preview-btn" data-action="preview" data-pane="' + i + '" id="cmp-preview-' + i + '" title="Run preview" style="display:none;">' + ICON_PLAY + '</button>' +
|
||||
'<button class="pane-action-btn 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>' +
|
||||
'</div>' +
|
||||
'<div class="chat-history" id="cmp-history-' + i + '"></div>' +
|
||||
'<iframe class="compare-pane-iframe" id="cmp-iframe-' + i + '" sandbox="allow-scripts" style="display:none;"></iframe>' +
|
||||
'<div class="pane-vote-footer">' +
|
||||
'<button class="pane-vote-btn" data-pane="' + i + '" type="button" disabled style="opacity:0.4;">' +
|
||||
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;vertical-align:-2px;"><polyline points="20 6 9 17 4 12"/></svg>' +
|
||||
'<span class="pane-vote-label">Vote ' + escapeHtml(label) + '</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
grid.appendChild(pane);
|
||||
}
|
||||
|
||||
// Update grid columns
|
||||
grid.dataset.cols = String(Math.min(n, 4));
|
||||
|
||||
// Update header label
|
||||
const headerSpan = document.querySelector('.compare-active > div:first-child span');
|
||||
if (headerSpan) {
|
||||
const modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
|
||||
headerSpan.textContent = 'Comparing' + modeLabel +
|
||||
(state._blindMode ? ' (blind)' : '') + ' \u00b7 ' + state._timeout + 's timeout';
|
||||
}
|
||||
|
||||
// Rebuild vote bar
|
||||
buildVoteBar(n);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
// Remove any existing dropdown
|
||||
const existing = document.querySelector('.pane-model-dropdown');
|
||||
if (existing) { existing.remove(); return; }
|
||||
|
||||
const _effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode;
|
||||
const filtered = state._cachedModels.filter(m => m.type === _effectiveType);
|
||||
if (filtered.length === 0) return;
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'pane-model-dropdown';
|
||||
|
||||
filtered.forEach(m => {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'pane-model-item';
|
||||
const label = m.endpointName ? m.name + ' (' + m.endpointName + ')' : m.name;
|
||||
item.textContent = label;
|
||||
// Highlight current model
|
||||
if (state._selectedModels[paneIdx] && state._selectedModels[paneIdx].model === m.id
|
||||
&& state._selectedModels[paneIdx].endpointId === m.endpointId) {
|
||||
item.classList.add('current');
|
||||
}
|
||||
item.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
|
||||
// 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);
|
||||
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');
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
state._paneSessionIds[paneIdx] = data.id;
|
||||
} catch (err) {
|
||||
console.error('Failed to create session for swapped model:', err);
|
||||
}
|
||||
|
||||
// Update title display
|
||||
const titleEl = document.getElementById('cmp-title-' + paneIdx);
|
||||
if (titleEl) {
|
||||
const displayName = state._blindMode
|
||||
? 'Model ' + _slotChar(paneIdx)
|
||||
: m.name;
|
||||
titleEl.innerHTML = escapeHtml(displayName) + ' <span class="pane-title-caret">▾</span>';
|
||||
}
|
||||
|
||||
// Clear pane history for fresh start
|
||||
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||
if (hist) { hist.innerHTML = ''; hist.style.display = ''; }
|
||||
const iframe = document.getElementById('cmp-iframe-' + paneIdx);
|
||||
if (iframe) { iframe.srcdoc = ''; iframe.style.display = 'none'; iframe._htmlCode = null; }
|
||||
const previewBtn = document.getElementById('cmp-preview-' + paneIdx);
|
||||
if (previewBtn) { previewBtn.style.display = 'none'; previewBtn.classList.remove('active'); }
|
||||
const badge = document.getElementById('cmp-badge-' + paneIdx);
|
||||
if (badge) { badge.textContent = ''; badge.style.color = ''; }
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
// Position relative to the viewport (fixed) and append to document.body so
|
||||
// the dropdown can't be clipped by the narrow pane's overflow or run off the
|
||||
// screen edge on mobile (matches the "+" add-pane picker behaviour).
|
||||
const rect = titleBtn.getBoundingClientRect();
|
||||
const vw = window.innerWidth, vh = window.innerHeight, margin = 8;
|
||||
dropdown.style.position = 'fixed';
|
||||
dropdown.style.zIndex = '100000';
|
||||
dropdown.style.maxWidth = (vw - margin * 2) + 'px';
|
||||
dropdown.style.overflowY = 'auto';
|
||||
dropdown.style.left = '-9999px';
|
||||
dropdown.style.top = '0';
|
||||
document.body.appendChild(dropdown);
|
||||
const ddRect = dropdown.getBoundingClientRect();
|
||||
const ddW = ddRect.width, ddH = ddRect.height;
|
||||
let left = rect.left;
|
||||
if (left + ddW > vw - margin) left = vw - margin - ddW;
|
||||
if (left < margin) left = margin;
|
||||
const spaceBelow = vh - rect.bottom, spaceAbove = rect.top;
|
||||
let top;
|
||||
if (spaceBelow >= ddH + margin || spaceBelow >= spaceAbove) {
|
||||
top = Math.min(rect.bottom + 4, vh - margin - Math.min(ddH, vh - margin * 2));
|
||||
} else {
|
||||
top = Math.max(margin, rect.top - 4 - ddH);
|
||||
}
|
||||
dropdown.style.left = left + 'px';
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Shuffle / reset ──
|
||||
|
||||
function shufflePanePositions() {
|
||||
if (state._streaming) return;
|
||||
// Remove shuffle prompt bubble if present
|
||||
const shuffleBtn = document.getElementById('compare-shuffle-btn');
|
||||
if (shuffleBtn) { const b = shuffleBtn.querySelector('div'); if (b) b.remove(); }
|
||||
const n = state._selectedModels.length;
|
||||
if (n < 2) return;
|
||||
|
||||
// Fisher-Yates shuffle to get new order
|
||||
const indices = Array.from({ length: n }, (_, i) => i);
|
||||
for (let i = indices.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[indices[i], indices[j]] = [indices[j], indices[i]];
|
||||
}
|
||||
|
||||
// Reorder internal state
|
||||
const newModels = indices.map(i => state._selectedModels[i]);
|
||||
const newSessionIds = indices.map(i => state._paneSessionIds[i]);
|
||||
const newMetrics = indices.map(i => state._paneMetrics[i]);
|
||||
|
||||
// Collect pane contents (HTML) before swapping
|
||||
const paneContents = [];
|
||||
const paneClasses = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const hist = document.getElementById('cmp-history-' + i);
|
||||
paneContents.push(hist ? hist.innerHTML : '');
|
||||
const pane = document.querySelector(`.compare-pane[data-pane="${i}"]`);
|
||||
paneClasses.push(pane ? { winner: pane.classList.contains('winner'), loser: pane.classList.contains('loser') } : {});
|
||||
}
|
||||
|
||||
// Apply shuffled state
|
||||
state._selectedModels = newModels;
|
||||
state._paneSessionIds = newSessionIds;
|
||||
state._paneMetrics = newMetrics;
|
||||
|
||||
// Spin the shuffle button dice icon
|
||||
const shuffleBtn2 = document.getElementById('compare-shuffle-btn');
|
||||
if (shuffleBtn2) {
|
||||
const diceSvg = shuffleBtn2.querySelector('svg');
|
||||
if (diceSvg) {
|
||||
diceSvg.style.transition = 'transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||||
diceSvg.style.transform = 'rotate(360deg)';
|
||||
setTimeout(() => { diceSvg.style.transition = ''; diceSvg.style.transform = ''; }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Shake panes and flash titles
|
||||
for (let i = 0; i < n; i++) {
|
||||
const pane = document.querySelector(`.compare-pane[data-pane="${i}"]`);
|
||||
if (pane) {
|
||||
pane.style.animation = 'pane-shake 0.3s ease';
|
||||
pane.addEventListener('animationend', () => { pane.style.animation = ''; }, { once: true });
|
||||
}
|
||||
const titleEl = document.getElementById('cmp-title-' + i);
|
||||
if (titleEl) {
|
||||
titleEl.style.transition = 'opacity 0.12s ease, transform 0.12s ease';
|
||||
titleEl.style.opacity = '0.3';
|
||||
titleEl.style.transform = 'scale(0.9)';
|
||||
titleEl.innerHTML = '?';
|
||||
}
|
||||
const hist = document.getElementById('cmp-history-' + i);
|
||||
if (hist) {
|
||||
hist.style.transition = 'opacity 0.15s ease';
|
||||
hist.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const hist = document.getElementById('cmp-history-' + i);
|
||||
const pane = document.querySelector(`.compare-pane[data-pane="${i}"]`);
|
||||
const titleEl = document.getElementById('cmp-title-' + i);
|
||||
const badge = document.getElementById('cmp-badge-' + i);
|
||||
const src = indices[i];
|
||||
|
||||
if (hist) hist.innerHTML = paneContents[src];
|
||||
if (titleEl) {
|
||||
const lbl = state._blindMode ? 'Model ' + _slotChar(i) : state._selectedModels[i].name;
|
||||
titleEl.innerHTML = escapeHtml(lbl) + ' <span class="pane-title-caret">▾</span>';
|
||||
titleEl.style.transition = 'opacity 0.25s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||||
titleEl.style.opacity = '1';
|
||||
titleEl.style.transform = 'scale(1)';
|
||||
}
|
||||
if (badge) { badge.textContent = ''; badge.style.color = ''; }
|
||||
if (pane) {
|
||||
pane.classList.toggle('winner', !!paneClasses[src].winner);
|
||||
pane.classList.toggle('loser', !!paneClasses[src].loser);
|
||||
}
|
||||
if (hist) {
|
||||
hist.style.transition = 'opacity 0.25s ease';
|
||||
hist.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// Re-enable blind mode after shuffle
|
||||
state._blindMode = true;
|
||||
|
||||
// Rebuild vote bar with new labels
|
||||
setTimeout(() => buildVoteBar(n), 250);
|
||||
}
|
||||
|
||||
function resetCompare() {
|
||||
if (state._streaming) stopAll();
|
||||
const n = state._selectedModels.length;
|
||||
|
||||
// Clear last prompt so vote buttons are disabled until next prompt
|
||||
state._lastPrompt = '';
|
||||
|
||||
// Reset finish badges, titles, winner/loser state
|
||||
state._finishOrder = 0;
|
||||
state._paneMetrics = new Array(n).fill(null);
|
||||
const panes = document.querySelectorAll('.compare-pane');
|
||||
for (let i = 0; i < n; i++) {
|
||||
const badge = document.getElementById('cmp-badge-' + i);
|
||||
if (badge) { badge.textContent = ''; badge.style.color = ''; }
|
||||
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'); }
|
||||
|
||||
// Clear all messages from pane history
|
||||
const hist = document.getElementById('cmp-history-' + i);
|
||||
if (hist) { hist.innerHTML = ''; hist.style.display = ''; }
|
||||
|
||||
// Reset iframe preview
|
||||
const iframe = document.getElementById('cmp-iframe-' + i);
|
||||
if (iframe) { iframe.srcdoc = ''; iframe.style.display = 'none'; iframe._htmlCode = null; }
|
||||
const previewBtn = document.getElementById('cmp-preview-' + i);
|
||||
if (previewBtn) { previewBtn.style.display = 'none'; previewBtn.classList.remove('active'); }
|
||||
}
|
||||
|
||||
// Re-enable vote bar
|
||||
buildVoteBar(n);
|
||||
|
||||
// Focus input for next prompt
|
||||
const ta = document.getElementById('message');
|
||||
if (ta) ta.focus();
|
||||
}
|
||||
|
||||
export {
|
||||
registerPaneActions,
|
||||
stopAll,
|
||||
stopPane,
|
||||
rerollPane,
|
||||
toggleExpandPane,
|
||||
togglePanePreview,
|
||||
_autoPreviewHtml,
|
||||
_extractHtmlFromText,
|
||||
copyPaneResponse,
|
||||
_addPane,
|
||||
_createAndAppendPane,
|
||||
_removePane,
|
||||
_showModelSwapDropdown,
|
||||
shufflePanePositions,
|
||||
resetCompare,
|
||||
};
|
||||
78
static/js/compare/probe.js
Normal file
78
static/js/compare/probe.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// compare/probe.js — model probe/check system
|
||||
import state from './state.js';
|
||||
import { WAVE_FRAMES } from './icons.js';
|
||||
import uiModule from '../ui.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
|
||||
function _clearProbeWaves() {
|
||||
const rows = document.querySelectorAll('.compare-probe-row');
|
||||
rows.forEach(r => { if (r._waveInterval) { clearInterval(r._waveInterval); r._waveInterval = null; } });
|
||||
}
|
||||
|
||||
async function _checkUnprobed() {
|
||||
const unprobed = state._selectedModels.filter(m => !state._probed.has(m.model));
|
||||
if (unprobed.length === 0) {
|
||||
if (uiModule) uiModule.showToast('All models verified');
|
||||
return;
|
||||
}
|
||||
|
||||
// Whirlpool loader on the Probe button while the check runs.
|
||||
const _btn = document.getElementById('compare-check-btn');
|
||||
let _btnHTML = null, _wp = null;
|
||||
if (_btn) {
|
||||
_btnHTML = _btn.innerHTML;
|
||||
_btn.disabled = true;
|
||||
_btn.style.opacity = '0.7';
|
||||
try {
|
||||
_wp = spinnerModule.createWhirlpool(14);
|
||||
_btn.innerHTML = '';
|
||||
_btn.appendChild(_wp.element);
|
||||
} catch (_) { /* spinner best-effort */ }
|
||||
}
|
||||
|
||||
// Quick inline probe — show toast with results
|
||||
const isBlind = state._blindMode;
|
||||
let ok = 0, fail = 0;
|
||||
try {
|
||||
for (const m of unprobed) {
|
||||
try {
|
||||
const _imageModelPrefixes = ['dall-e', 'gpt-image', 'chatgpt-image', 'stable-diffusion', 'sdxl', 'flux', 'midjourney'];
|
||||
if (_imageModelPrefixes.some(p => m.model.toLowerCase().includes(p))) {
|
||||
state._probed.add(m.model);
|
||||
ok++;
|
||||
continue;
|
||||
}
|
||||
const res = await fetch(`${state.API_BASE}/api/probe-selected`, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ models: [{ endpoint_id: m.endpointId || '', model: m.model, endpoint: m.endpoint || '' }] }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const result = (data.results || [])[0];
|
||||
if (result && result.status === 'ok') {
|
||||
state._probed.add(m.model);
|
||||
ok++;
|
||||
} else {
|
||||
fail++;
|
||||
const name = isBlind ? 'a model' : (m.name || m.model.split('/').pop());
|
||||
if (uiModule) uiModule.showToast(`${name} failed: ${result?.error || 'unknown'}`, 5000);
|
||||
}
|
||||
} catch (e) {
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
if (fail === 0) {
|
||||
if (uiModule) uiModule.showToast(`${ok} model${ok > 1 ? 's' : ''} verified`);
|
||||
}
|
||||
} finally {
|
||||
// Restore the Probe button (its label/visibility is refreshed below).
|
||||
if (_btn) {
|
||||
_btn.disabled = false;
|
||||
_btn.style.opacity = '';
|
||||
if (_btnHTML !== null) _btn.innerHTML = _btnHTML;
|
||||
}
|
||||
if (window._updateCheckBtnState) window._updateCheckBtnState();
|
||||
}
|
||||
}
|
||||
|
||||
export { _clearProbeWaves, _checkUnprobed };
|
||||
223
static/js/compare/scoreboard.js
Normal file
223
static/js/compare/scoreboard.js
Normal file
@@ -0,0 +1,223 @@
|
||||
// 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';
|
||||
|
||||
const escapeHtml = uiModule.esc;
|
||||
|
||||
// Type icons for the mode tabs — match the Compare selector's tab icons.
|
||||
const _TYPE_ICONS = {
|
||||
chat: '<svg width="15" height="15" 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>',
|
||||
agent: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
|
||||
search: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
|
||||
research: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
|
||||
};
|
||||
|
||||
/** Detect search provider names to fix legacy votes without mode. */
|
||||
const _searchProviderNames = new Set(['brave search', 'duckduckgo', 'google', 'searxng', 'bing', 'tavily']);
|
||||
|
||||
/** Guess the compare mode for a vote record (legacy votes lack a mode field). */
|
||||
function _guessVoteMode(v) {
|
||||
if (v.mode) return v.mode;
|
||||
// Legacy vote — check if models look like search providers
|
||||
if (v.models && v.models.some(m => _searchProviderNames.has(m.toLowerCase()))) return 'search';
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
export function showScoreboard() {
|
||||
// Remove existing overlay if present
|
||||
const existing = document.getElementById('scoreboard-overlay');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const votes = Storage.getJSON(VOTES_STORAGE_KEY, []);
|
||||
|
||||
// Build modal
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'scoreboard-overlay';
|
||||
overlay.className = 'modal';
|
||||
overlay.style.zIndex = '10001';
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
// Esc handling lives in the global "close topmost popup" handler (app.js)
|
||||
// so the scoreboard closes first without also dismissing the compare
|
||||
// window beneath it.
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-content';
|
||||
content.style.maxWidth = '520px';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'modal-header';
|
||||
const title = document.createElement('h3');
|
||||
title.innerHTML = '<svg width="16" height="16" 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="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';
|
||||
title.style.margin = '0';
|
||||
const closeX = document.createElement('button');
|
||||
closeX.className = 'close-btn';
|
||||
closeX.innerHTML = '✖';
|
||||
closeX.addEventListener('click', () => overlay.remove());
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeX);
|
||||
content.appendChild(header);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'modal-body';
|
||||
body.style.padding = '12px 16px';
|
||||
// Mobile: add bottom padding so the Clear History button isn't hidden behind
|
||||
// Firefox's bottom URL bar / the home-indicator safe area.
|
||||
if (window.innerWidth <= 768) {
|
||||
body.style.paddingBottom = 'calc(env(safe-area-inset-bottom, 0px) + 72px)';
|
||||
body.style.overflowY = 'auto';
|
||||
}
|
||||
|
||||
// Mode tabs
|
||||
const modes = ['chat', 'agent', 'search', 'research'];
|
||||
const modeLabels = { chat: 'Chat', agent: 'Agent', search: 'Search', research: 'Research' };
|
||||
const tabBar = document.createElement('div');
|
||||
tabBar.className = 'compare-mode-tabs';
|
||||
tabBar.style.marginBottom = '12px';
|
||||
let activeMode = 'chat';
|
||||
|
||||
function renderScoreTable() {
|
||||
// Clear previous table
|
||||
const prev = body.querySelector('.scoreboard-wrap');
|
||||
if (prev) {
|
||||
// The Clear button was moved INTO the wrap on a prior render — rescue it
|
||||
// back to the body before removing the wrap, otherwise it's destroyed
|
||||
// with the wrap and never re-found (it vanished after visiting an empty
|
||||
// mode like Images and switching back).
|
||||
const clr = prev.querySelector('.scoreboard-clear-btn');
|
||||
if (clr) body.appendChild(clr);
|
||||
prev.remove();
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'scoreboard-wrap';
|
||||
|
||||
const filtered = votes.filter(v => _guessVoteMode(v) === activeMode);
|
||||
|
||||
// Aggregate
|
||||
const stats = {};
|
||||
for (const v of filtered) {
|
||||
for (let mi = 0; mi < v.models.length; mi++) {
|
||||
const m = v.models[mi];
|
||||
if (!stats[m]) stats[m] = { wins: 0, losses: 0, ties: 0, games: 0, totalCost: 0, costCount: 0 };
|
||||
stats[m].games++;
|
||||
if (v.winner === 'tie') stats[m].ties++;
|
||||
else if (v.winner === m) stats[m].wins++;
|
||||
else stats[m].losses++;
|
||||
if (v.costs && v.costs[mi] != null) {
|
||||
stats[m].totalCost += v.costs[mi];
|
||||
stats[m].costCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const sorted = Object.entries(stats).sort((a, b) => {
|
||||
const rateA = a[1].games ? a[1].wins / a[1].games : 0;
|
||||
const rateB = b[1].games ? b[1].wins / b[1].games : 0;
|
||||
return rateB - rateA;
|
||||
});
|
||||
|
||||
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.textContent = 'No ' + activeMode + ' votes yet. Run a comparison and vote!';
|
||||
wrap.appendChild(empty);
|
||||
} else {
|
||||
const table = document.createElement('table');
|
||||
table.className = 'scoreboard-table';
|
||||
const thead = document.createElement('thead');
|
||||
thead.innerHTML = '<tr><th>Model</th><th>Win%</th><th>W</th><th>L</th><th>T</th><th>Games</th><th>$/1k</th></tr>';
|
||||
table.appendChild(thead);
|
||||
const tbody = document.createElement('tbody');
|
||||
for (const [name, s] of sorted) {
|
||||
const pct = s.games ? Math.round((s.wins / s.games) * 100) : 0;
|
||||
const avgCost = s.costCount ? (s.totalCost / s.costCount) * 1000 : null;
|
||||
const costStr = avgCost !== null ? ('$' + (avgCost < 1 ? avgCost.toFixed(2) : avgCost.toFixed(0))) : '—';
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td class="scoreboard-model">' + escapeHtml(name) + '</td>' +
|
||||
'<td class="scoreboard-pct"><strong>' + pct + '%</strong></td>' +
|
||||
'<td>' + s.wins + '</td><td>' + s.losses + '</td><td>' + s.ties + '</td>' +
|
||||
'<td>' + s.games + '</td>' +
|
||||
'<td style="color:var(--color-success, #4caf50);" title="Avg estimated cost per 1,000 responses">' + costStr + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
wrap.appendChild(table);
|
||||
}
|
||||
|
||||
const total = document.createElement('div');
|
||||
total.style.cssText = 'font-size:0.8em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:12px;text-align:center;';
|
||||
total.textContent = filtered.length + ' vote' + (filtered.length !== 1 ? 's' : '') + ' recorded';
|
||||
wrap.appendChild(total);
|
||||
|
||||
// Move clear button into wrap so it stays at bottom
|
||||
const existingClear = body.querySelector('.scoreboard-clear-btn');
|
||||
if (existingClear) wrap.appendChild(existingClear);
|
||||
|
||||
body.appendChild(wrap);
|
||||
}
|
||||
|
||||
modes.forEach(mode => {
|
||||
const tab = document.createElement('button');
|
||||
tab.type = 'button';
|
||||
tab.className = 'compare-mode-tab' + (mode === activeMode ? ' active' : '');
|
||||
tab.innerHTML = (_TYPE_ICONS[mode] || '') + '<span class="compare-toggle-label">' + modeLabels[mode] + '</span>';
|
||||
tab.addEventListener('click', () => {
|
||||
activeMode = mode;
|
||||
tabBar.querySelectorAll('.compare-mode-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
renderScoreTable();
|
||||
});
|
||||
tabBar.appendChild(tab);
|
||||
});
|
||||
body.appendChild(tabBar);
|
||||
renderScoreTable();
|
||||
|
||||
// Clear history button
|
||||
const clearBtn = document.createElement('button');
|
||||
clearBtn.className = 'scoreboard-clear-btn';
|
||||
clearBtn.textContent = 'Clear History';
|
||||
clearBtn.style.cssText = 'display:block;margin:16px 0 4px auto;padding:4px 12px;background:none;border:1px solid var(--border);color:var(--fg);border-radius:4px;cursor:pointer;font-size:11px;opacity:0.4;transition:opacity 0.15s;';
|
||||
clearBtn.addEventListener('mouseenter', () => { clearBtn.style.opacity = '1'; });
|
||||
clearBtn.addEventListener('mouseleave', () => { clearBtn.style.opacity = '0.6'; });
|
||||
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);';
|
||||
const confirmLabel = document.createElement('span');
|
||||
confirmLabel.style.cssText = 'font-size:12px;opacity:0.7;';
|
||||
confirmLabel.textContent = 'Clear all vote history?';
|
||||
const yesBtn = document.createElement('button');
|
||||
yesBtn.textContent = 'Clear';
|
||||
yesBtn.style.cssText = 'padding:4px 12px;background:var(--red);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:12px;font-weight:600;';
|
||||
yesBtn.addEventListener('click', () => {
|
||||
Storage.setJSON(VOTES_STORAGE_KEY, []);
|
||||
overlay.remove();
|
||||
showScoreboard();
|
||||
});
|
||||
const noBtn = document.createElement('button');
|
||||
noBtn.textContent = 'Cancel';
|
||||
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);
|
||||
// Replace button with confirmation
|
||||
clearBtn.style.display = 'none';
|
||||
clearBtn.parentElement.appendChild(confirmRow);
|
||||
});
|
||||
body.appendChild(clearBtn);
|
||||
|
||||
content.appendChild(body);
|
||||
overlay.appendChild(content);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
if (themeModule && themeModule.makeDraggable) {
|
||||
themeModule.makeDraggable(content, header);
|
||||
}
|
||||
}
|
||||
|
||||
export default { showScoreboard };
|
||||
1335
static/js/compare/selector.js
Normal file
1335
static/js/compare/selector.js
Normal file
File diff suppressed because it is too large
Load Diff
56
static/js/compare/state.js
Normal file
56
static/js/compare/state.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// compare/state.js — shared mutable state for compare modules
|
||||
const state = {
|
||||
API_BASE: '',
|
||||
isActive: false,
|
||||
_streaming: false,
|
||||
_blindMode: true,
|
||||
_saveOnClose: false,
|
||||
_continueChat: false,
|
||||
_timeout: 300, // seconds
|
||||
_finishOrder: 0,
|
||||
_paneElapsed: [], // per-pane total ms; populated on finish so the
|
||||
// Fastest badge can be awarded by actual time
|
||||
// (sequential mode otherwise always picks pane 1)
|
||||
_selectedModels: [], // [{model, endpoint, endpointId, name}, ...]
|
||||
_paneSessionIds: [], // session IDs for each pane
|
||||
_paneMetrics: [], // metrics per pane from last round
|
||||
_abortControllers: [], // per-pane abort controllers
|
||||
_sidebarWasHidden: false,
|
||||
_compareElements: [], // elements we added to container (for cleanup)
|
||||
_savedToggles: null, // tool toggle states saved before compare
|
||||
_savedIndicatorDisplay: {}, // display state of toolbar indicators before compare
|
||||
_savedMode: 'chat', // agent/chat mode saved before compare
|
||||
_hasVisibleResults: false, // compare results still on screen after close
|
||||
_compareMode: 'chat', // 'chat', 'agent', 'search', or 'research'
|
||||
_lastPrompt: '', // last prompt sent (for rematch)
|
||||
_cachedModels: [], // cached model list for pane dropdowns
|
||||
_probed: new Set(), // model IDs that have been successfully probed
|
||||
_cachedProviders: null, // cached search providers for search mode
|
||||
_searchSynthModels: null, // per-pane synthesis models for search mode
|
||||
_parallel: true, // true = run all panes at once, false = one at a time
|
||||
_fetchModelsCache: null,
|
||||
_fetchModelsCacheTime: 0,
|
||||
_expectedAnswer: '', // when an eval prompt with `answer` is picked,
|
||||
// stream.js reads this and stamps ✓/✗ per pane
|
||||
};
|
||||
|
||||
/** Reset transient state to defaults — useful for clean restarts. */
|
||||
export function reset() {
|
||||
state._streaming = false;
|
||||
state._finishOrder = 0;
|
||||
state._paneElapsed = [];
|
||||
state._abortControllers.forEach(c => { if (c) c.abort(); });
|
||||
state._abortControllers = [];
|
||||
state._paneSessionIds = [];
|
||||
state._paneMetrics = [];
|
||||
state._compareElements = [];
|
||||
state._hasVisibleResults = false;
|
||||
state._lastPrompt = '';
|
||||
state._cachedModels = [];
|
||||
state._probed = new Set();
|
||||
state._cachedProviders = null;
|
||||
state._fetchModelsCache = null;
|
||||
state._fetchModelsCacheTime = 0;
|
||||
}
|
||||
|
||||
export default state;
|
||||
695
static/js/compare/stream.js
Normal file
695
static/js/compare/stream.js
Normal file
@@ -0,0 +1,695 @@
|
||||
// compare/stream.js — SSE streaming to panes
|
||||
import state from './state.js';
|
||||
import { addFinishBadge } from './vote.js';
|
||||
import { getModelCost } from '../chatRenderer.js';
|
||||
import markdownModule from '../markdown.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import uiModule from '../ui.js';
|
||||
import presetsModule from '../presets.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
const WAVE_FRAMES = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
|
||||
|
||||
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
|
||||
let _rerollPane = null;
|
||||
let _autoPreviewHtml = null;
|
||||
|
||||
/** Register external functions that live in compare.js. */
|
||||
function registerStreamActions({ rerollPane, autoPreviewHtml }) {
|
||||
_rerollPane = rerollPane;
|
||||
_autoPreviewHtml = autoPreviewHtml;
|
||||
}
|
||||
|
||||
/** Format milliseconds as human-readable duration (e.g. "120ms", "1.23s", "4.5s"). */
|
||||
function _formatMs(ms) {
|
||||
if (ms < 1000) return Math.round(ms) + 'ms';
|
||||
if (ms < 10000) return (ms / 1000).toFixed(2) + 's';
|
||||
return (ms / 1000).toFixed(1) + 's';
|
||||
}
|
||||
|
||||
/** Build a DOM container of search-result cards from a search response. Returns an HTMLElement. */
|
||||
function _renderSearchResults(data) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'compare-search-results';
|
||||
(data.results || []).forEach(r => {
|
||||
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';
|
||||
titleLink.className = 'search-result-title';
|
||||
titleLink.textContent = r.title || 'Untitled';
|
||||
card.appendChild(titleLink);
|
||||
if (r.snippet) {
|
||||
const s = document.createElement('div');
|
||||
s.className = 'search-result-snippet';
|
||||
s.textContent = r.snippet;
|
||||
card.appendChild(s);
|
||||
}
|
||||
if (r.url) {
|
||||
const u = document.createElement('div');
|
||||
u.className = 'search-result-url';
|
||||
u.textContent = r.url;
|
||||
card.appendChild(u);
|
||||
}
|
||||
container.appendChild(card);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
/** Run synthesis for a search pane — sends search results to an LLM for analysis. */
|
||||
async function _runSynthForPane(modelToUse, synthPrompt, synthBody, spinner, hist) {
|
||||
// Create temp session for synthesis
|
||||
const fd = new FormData();
|
||||
fd.append('name', 'Synthesis');
|
||||
fd.append('endpoint_url', modelToUse.endpoint || '');
|
||||
fd.append('model', modelToUse.model || '');
|
||||
if (modelToUse.endpointId) {
|
||||
fd.append('endpoint_id', modelToUse.endpointId);
|
||||
fd.append('skip_validation', 'true');
|
||||
}
|
||||
|
||||
try {
|
||||
const createRes = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd });
|
||||
if (!createRes.ok) {
|
||||
const errData = await createRes.json().catch(() => ({}));
|
||||
throw new Error(errData.detail || 'Failed to create session');
|
||||
}
|
||||
const createData = await createRes.json();
|
||||
|
||||
const synthAc = new AbortController();
|
||||
state._abortControllers.push(synthAc);
|
||||
const streamRes = await fetch(`${state.API_BASE}/api/chat_stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session: createData.id, message: synthPrompt }),
|
||||
signal: synthAc.signal,
|
||||
});
|
||||
|
||||
if (spinner) spinner.stop();
|
||||
synthBody.innerHTML = '';
|
||||
const reader = streamRes.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let synthText = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
|
||||
try {
|
||||
const d = JSON.parse(line.slice(6));
|
||||
if (d.delta) {
|
||||
synthText += d.delta;
|
||||
if (markdownModule && synthText.trim()) {
|
||||
synthBody.innerHTML = markdownModule.processWithThinking(
|
||||
markdownModule.squashOutsideCode(synthText)
|
||||
);
|
||||
} else {
|
||||
synthBody.textContent = synthText;
|
||||
}
|
||||
hist.scrollTop = hist.scrollHeight;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final highlight
|
||||
if (window.hljs) synthBody.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
||||
|
||||
// Cleanup temp session
|
||||
fetch(`${state.API_BASE}/api/session/${createData.id}`, { method: 'DELETE' }).catch(() => {});
|
||||
} catch (e) {
|
||||
if (spinner) spinner.stop();
|
||||
synthBody.innerHTML = '<div style="color:var(--color-error);font-size:0.85em;">Synthesis failed: ' + escapeHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/** Stream an SSE response into a compare pane. Handles text, tool blocks, images, metrics. */
|
||||
async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
opts = opts || {};
|
||||
const aiBody = aiMsgEl ? aiMsgEl.querySelector('.body') : null;
|
||||
const hist = aiMsgEl ? aiMsgEl.parentElement : null;
|
||||
if (!aiBody) return;
|
||||
|
||||
const ac = new AbortController();
|
||||
state._abortControllers[paneIdx] = ac;
|
||||
|
||||
// Show stop button for this pane
|
||||
const _paneEl = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (_paneEl) {
|
||||
const _stopBtn = _paneEl.querySelector('.pane-stop-btn');
|
||||
if (_stopBtn) _stopBtn.style.display = '';
|
||||
}
|
||||
|
||||
let accumulated = '';
|
||||
let metrics = null;
|
||||
let timedOut = false;
|
||||
let streamOk = 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
|
||||
// active. opts.timeout may still tighten this for specific paths.
|
||||
const effectiveTimeout = opts.timeout || state._timeout;
|
||||
let timeoutId = setTimeout(() => { timedOut = true; ac.abort(); }, effectiveTimeout * 1000);
|
||||
const _resetIdleTimeout = () => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => { timedOut = true; ac.abort(); }, effectiveTimeout * 1000);
|
||||
};
|
||||
|
||||
// Live timer
|
||||
const _timerStart = performance.now();
|
||||
let _ttft = 0; // time to first token
|
||||
let _timerDone = false;
|
||||
const _timerEl = document.getElementById('cmp-timer-' + paneIdx);
|
||||
let _rafId = 0;
|
||||
function _tickTimer() {
|
||||
if (_timerDone) return;
|
||||
const elapsed = performance.now() - _timerStart;
|
||||
if (_timerEl) _timerEl.textContent = _formatMs(elapsed);
|
||||
_rafId = requestAnimationFrame(_tickTimer);
|
||||
}
|
||||
_rafId = requestAnimationFrame(_tickTimer);
|
||||
|
||||
// Throttled markdown render — re-rendering the entire growing buffer on
|
||||
// every token is O(n²) total work. Coalesce updates so we paint at most
|
||||
// every ~80ms. The final render still runs at end-of-stream for quality.
|
||||
let _renderPending = false;
|
||||
let _renderLastAt = 0;
|
||||
const _RENDER_THROTTLE_MS = 80;
|
||||
function _scheduleLiveRender(target) {
|
||||
if (_renderPending) return;
|
||||
const now = performance.now();
|
||||
const elapsed = now - _renderLastAt;
|
||||
const delay = elapsed >= _RENDER_THROTTLE_MS ? 0 : _RENDER_THROTTLE_MS - elapsed;
|
||||
_renderPending = true;
|
||||
setTimeout(() => {
|
||||
_renderPending = false;
|
||||
_renderLastAt = performance.now();
|
||||
if (markdownModule && accumulated.trim()) {
|
||||
target.innerHTML = markdownModule.processWithThinking(
|
||||
markdownModule.squashOutsideCode(accumulated)
|
||||
);
|
||||
} else {
|
||||
target.textContent = accumulated;
|
||||
}
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
}, delay);
|
||||
}
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('message', message);
|
||||
fd.append('session', sessionId);
|
||||
|
||||
// Compare mode determines what tools/features are enabled
|
||||
const isAgent = state._compareMode === 'agent';
|
||||
const isResearch = state._compareMode === 'research';
|
||||
|
||||
// Agent mode: enable all tools (web, bash, etc.)
|
||||
if (isAgent) {
|
||||
fd.append('mode', 'agent');
|
||||
fd.append('allow_web_search', 'true');
|
||||
fd.append('allow_bash', 'true');
|
||||
} else if (isResearch) {
|
||||
fd.append('use_research', 'true');
|
||||
} else {
|
||||
// Chat/Image: pure chat only — no tools, no search, no bash, no RAG.
|
||||
// Explicitly send mode='chat' so the backend's compare_mode strip
|
||||
// (chat_routes.py line 385) actually triggers — otherwise the form
|
||||
// field was missing and chat_mode defaulted to "", which meant
|
||||
// bash/python/web_search were never added to disabled_tools and
|
||||
// models would still attempt to run Python.
|
||||
fd.append('mode', 'chat');
|
||||
fd.append('use_rag', 'false');
|
||||
}
|
||||
const incognitoChk = document.getElementById('incognito-toggle');
|
||||
if (incognitoChk && incognitoChk.checked) {
|
||||
fd.append('incognito', 'true');
|
||||
}
|
||||
// Disable document tool and memory injection in compare mode
|
||||
fd.append('no_documents', 'true');
|
||||
fd.append('no_memory', 'true');
|
||||
// Tell backend this is compare mode — strip all non-toggled tools
|
||||
fd.append('compare_mode', 'true');
|
||||
// Forward preset if selected
|
||||
if (presetsModule && presetsModule.getSelectedPreset()) {
|
||||
fd.append('preset_id', presetsModule.getSelectedPreset());
|
||||
}
|
||||
|
||||
const response = await fetch(`${state.API_BASE}/api/chat_stream`, {
|
||||
method: 'POST', body: fd, signal: ac.signal
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
_resetIdleTimeout(); // any chunk = stream is alive
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') break;
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.type === 'metrics') {
|
||||
metrics = json.data;
|
||||
|
||||
// ── Research progress (spinner updates) ──
|
||||
} else if (json.type === 'research_progress') {
|
||||
const rp = json.data;
|
||||
const spinner = aiMsgEl._spinner;
|
||||
if (spinner) {
|
||||
if (rp.phase === 'searching') {
|
||||
const q = rp.queries ? `${rp.queries} queries` : '';
|
||||
const s = rp.total_sources ? ` · ${rp.total_sources} sources` : '';
|
||||
spinner.updateMessage(`R${rp.round || '?'}: Searching${q ? ' (' + q + ')' : ''}${s}`);
|
||||
} else if (rp.phase === 'reading') {
|
||||
spinner.updateMessage(`R${rp.round || '?'}: Reading ${rp.new_sources || ''} pages`);
|
||||
} else if (rp.phase === 'analyzing') {
|
||||
spinner.updateMessage(`R${rp.round || '?'}: Analyzing ${rp.total_findings || 0} findings`);
|
||||
} else if (rp.phase === 'writing') {
|
||||
spinner.updateMessage(`Writing report · ${rp.total_sources || 0} sources`);
|
||||
} else if (rp.phase === 'error') {
|
||||
spinner.updateMessage(rp.message || 'Research error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Research sources / Web sources (compact sources box) ──
|
||||
} else if (json.type === 'research_sources' || json.type === 'web_sources') {
|
||||
const sources = json.data || [];
|
||||
if (sources.length > 0) {
|
||||
const label = json.type === 'research_sources' ? 'Research' : 'Web';
|
||||
const box = document.createElement('div');
|
||||
box.className = 'compare-sources-box';
|
||||
box.innerHTML = '<span class="sources-label">' + sources.length + ' ' + label + ' sources</span>';
|
||||
box.title = sources.map(s => s.title || s.url).join('\n');
|
||||
// Replace spinner with sources + new spinner
|
||||
aiBody.innerHTML = '';
|
||||
aiBody.appendChild(box);
|
||||
if (spinnerModule) {
|
||||
const newSpinner = spinnerModule.create('Generating response...', 'right');
|
||||
aiBody.appendChild(newSpinner.createElement());
|
||||
newSpinner.start();
|
||||
aiMsgEl._spinner = newSpinner;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool start (bash, web search agent tool) ──
|
||||
} else if (json.type === 'tool_start') {
|
||||
// Finalize any accumulated text before the tool block
|
||||
if (accumulated.trim() && aiMsgEl._textEl) {
|
||||
if (markdownModule) {
|
||||
aiMsgEl._textEl.innerHTML = markdownModule.processWithThinking(
|
||||
markdownModule.squashOutsideCode(accumulated));
|
||||
if (window.hljs) aiMsgEl._textEl.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
||||
}
|
||||
}
|
||||
// Destroy spinner if still present
|
||||
if (aiMsgEl._spinner && aiMsgEl._spinner.element) {
|
||||
aiMsgEl._spinner.destroy();
|
||||
aiMsgEl._spinner = null;
|
||||
// Clean up spinner element but keep sources box + text
|
||||
const spinnerEl = aiBody.querySelector('.spinner-wrapper, .mini-spinner');
|
||||
if (spinnerEl) spinnerEl.remove();
|
||||
}
|
||||
const toolName = json.tool || 'tool';
|
||||
const cmd = json.command || '';
|
||||
// Image generation: show ASCII spinner instead of compact tool block
|
||||
if (toolName === 'generate_image' && spinnerModule) {
|
||||
aiBody.innerHTML = '';
|
||||
const imgSpinner = spinnerModule.create('Generating image...', 'right');
|
||||
aiBody.appendChild(imgSpinner.createElement());
|
||||
imgSpinner.start();
|
||||
aiMsgEl._imgSpinner = imgSpinner;
|
||||
currentToolBlock = null;
|
||||
} else {
|
||||
// Agent thread node — matches main chat style
|
||||
const _toolLabels = { bash: 'Terminal', python: 'Python', web_search: 'Web Search', read_file: 'Read File', write_file: 'Write File' };
|
||||
const toolLabel = _toolLabels[toolName.toLowerCase()] || toolName;
|
||||
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.querySelector('.agent-thread-header').addEventListener('click', () => node.classList.toggle('open'));
|
||||
// Animate wave
|
||||
const waveEl = node.querySelector('.agent-thread-wave');
|
||||
if (waveEl) {
|
||||
const waveFrames = WAVE_FRAMES;
|
||||
let waveIdx = 0;
|
||||
node._waveInterval = setInterval(() => { waveIdx = (waveIdx + 1) % waveFrames.length; waveEl.textContent = waveFrames[waveIdx]; }, 100);
|
||||
}
|
||||
aiBody.appendChild(node);
|
||||
currentToolBlock = node;
|
||||
}
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
|
||||
// ── Tool output (image or non-image) ──
|
||||
} else if (json.type === 'tool_output') {
|
||||
if (json.image_url) {
|
||||
// Stop image spinner and render generated image in pane
|
||||
if (aiMsgEl._imgSpinner) { aiMsgEl._imgSpinner.destroy(); aiMsgEl._imgSpinner = null; }
|
||||
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);
|
||||
}
|
||||
// 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; }
|
||||
const ok = (json.exit_code === 0 || json.exit_code == null);
|
||||
const cmd = json.command || '';
|
||||
const _toolLabels2 = { bash: 'Terminal', python: 'Python', web_search: 'Web Search', read_file: 'Read File', write_file: 'Write File' };
|
||||
const tLabel = _toolLabels2[(json.tool || '').toLowerCase()] || json.tool || '';
|
||||
let outHtml = '';
|
||||
if (json.output && json.output.trim()) {
|
||||
outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${escapeHtml(json.output)}</pre></details>`;
|
||||
}
|
||||
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>`;
|
||||
currentToolBlock.querySelector('.agent-thread-header').addEventListener('click', () => currentToolBlock.classList.toggle('open'));
|
||||
currentToolBlock = null;
|
||||
// Reset text element so next deltas create a fresh container
|
||||
aiMsgEl._textEl = null;
|
||||
accumulated = '';
|
||||
}
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
} else if (json.delta) {
|
||||
// Skip text deltas if we already rendered an image
|
||||
if (aiMsgEl._imageData) continue;
|
||||
// Capture TTFT on very first text delta
|
||||
if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart;
|
||||
// On first delta, destroy spinner and prepare text area
|
||||
if (!accumulated && aiMsgEl._spinner) {
|
||||
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
|
||||
aiMsgEl._spinner = null;
|
||||
// Keep sources box if present, clear everything else
|
||||
const srcBox = aiBody.querySelector('.compare-sources-box');
|
||||
aiBody.innerHTML = '';
|
||||
if (srcBox) aiBody.appendChild(srcBox);
|
||||
// Add text container
|
||||
const textEl = document.createElement('div');
|
||||
textEl.className = 'compare-text-content';
|
||||
aiBody.appendChild(textEl);
|
||||
aiMsgEl._textEl = textEl;
|
||||
}
|
||||
// After a tool block, create a new text container for continuing text
|
||||
if (!accumulated && !aiMsgEl._textEl) {
|
||||
const textEl = document.createElement('div');
|
||||
textEl.className = 'compare-text-content';
|
||||
aiBody.appendChild(textEl);
|
||||
aiMsgEl._textEl = textEl;
|
||||
}
|
||||
accumulated += json.delta;
|
||||
const target = aiMsgEl._textEl || aiBody;
|
||||
_scheduleLiveRender(target);
|
||||
}
|
||||
} catch (e) { console.warn('Compare stream render error:', e); }
|
||||
}
|
||||
}
|
||||
|
||||
streamOk = true;
|
||||
// Destroy any remaining spinner
|
||||
if (aiMsgEl._spinner && aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
|
||||
aiMsgEl._spinner = null;
|
||||
// Final render
|
||||
const finalTarget = aiMsgEl._textEl || aiBody;
|
||||
if (markdownModule && accumulated.trim()) {
|
||||
finalTarget.innerHTML = markdownModule.processWithThinking(
|
||||
markdownModule.squashOutsideCode(accumulated)
|
||||
);
|
||||
}
|
||||
if (window.hljs) {
|
||||
finalTarget.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
||||
}
|
||||
|
||||
// ── Show play button if response contains HTML ──
|
||||
if (_autoPreviewHtml) _autoPreviewHtml(paneIdx, accumulated);
|
||||
|
||||
// Metrics footer
|
||||
if (aiMsgEl && aiMsgEl._imageData) {
|
||||
// Image-specific footer with actions + metrics
|
||||
const imgD = aiMsgEl._imageData;
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'msg-footer';
|
||||
|
||||
// Action buttons (copy prompt + download)
|
||||
const actions = document.createElement('span');
|
||||
actions.className = 'msg-actions';
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'footer-copy-btn';
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.title = 'Copy prompt';
|
||||
copyBtn.textContent = '\u2398';
|
||||
copyBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const txt = imgD.prompt || '';
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(txt).catch(() => {});
|
||||
else { const ta = document.createElement('textarea'); ta.value = txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
|
||||
copyBtn.textContent = '\u2713';
|
||||
setTimeout(() => { copyBtn.textContent = '\u2398'; }, 1500);
|
||||
if (uiModule) uiModule.showToast('Prompt copied!');
|
||||
});
|
||||
actions.appendChild(copyBtn);
|
||||
|
||||
const dlBtn = document.createElement('button');
|
||||
dlBtn.className = 'footer-copy-btn';
|
||||
dlBtn.type = 'button';
|
||||
dlBtn.title = 'Download image';
|
||||
dlBtn.textContent = '\u2913';
|
||||
dlBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const resp = await fetch(imgD.url);
|
||||
const blob = await resp.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = (imgD.prompt || 'image').slice(0, 40).replace(/[^a-zA-Z0-9 ]/g, '') + '.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(a.href);
|
||||
dlBtn.textContent = '\u2713';
|
||||
setTimeout(() => { dlBtn.textContent = '\u2913'; }, 1500);
|
||||
} catch { dlBtn.textContent = '\u2717'; setTimeout(() => { dlBtn.textContent = '\u2913'; }, 1500); }
|
||||
});
|
||||
actions.appendChild(dlBtn);
|
||||
|
||||
footer.appendChild(actions);
|
||||
|
||||
// Metrics — hide in blind mode to avoid revealing model identity
|
||||
if (!state._blindMode) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'response-metrics';
|
||||
const parts = [];
|
||||
if (imgD.model) parts.push(imgD.model.split('/').pop());
|
||||
if (imgD.size) parts.push(imgD.size);
|
||||
if (imgD.quality) parts.push(imgD.quality);
|
||||
if (metrics && metrics.response_time) parts.push(metrics.response_time + 's');
|
||||
const costFn = window.chatModule && window.chatModule.getImageCost;
|
||||
if (costFn) {
|
||||
const cost = costFn(imgD.model, imgD.quality, imgD.size);
|
||||
if (cost !== null) parts.push('$' + (cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)));
|
||||
}
|
||||
span.textContent = parts.join(' \u00b7 ');
|
||||
footer.appendChild(span);
|
||||
}
|
||||
aiMsgEl.appendChild(footer);
|
||||
} else if (metrics && aiMsgEl) {
|
||||
const footer = document.createElement('div');
|
||||
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';
|
||||
// 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);
|
||||
// Build the metrics span with optional cost and context
|
||||
span.textContent = text;
|
||||
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';
|
||||
span.appendChild(costSpan);
|
||||
}
|
||||
if (metrics.context_percent > 0) {
|
||||
const ctx = document.createElement('span');
|
||||
ctx.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);
|
||||
}
|
||||
footer.appendChild(span);
|
||||
aiMsgEl.appendChild(footer);
|
||||
}
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
if (timedOut) {
|
||||
if (accumulated.trim()) {
|
||||
if (markdownModule) {
|
||||
aiBody.innerHTML = markdownModule.processWithThinking(
|
||||
markdownModule.squashOutsideCode(accumulated));
|
||||
}
|
||||
}
|
||||
const notice = document.createElement('div');
|
||||
notice.style.cssText = 'color:#ff9800;font-size:0.8em;margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;';
|
||||
const text = document.createElement('span');
|
||||
text.style.fontStyle = 'italic';
|
||||
text.textContent = 'Timed out after ' + effectiveTimeout + 's' + (accumulated.trim() ? ' \u2014 response may be incomplete' : '');
|
||||
notice.appendChild(text);
|
||||
const retryBtn = document.createElement('button');
|
||||
retryBtn.textContent = 'Retry +' + effectiveTimeout + 's';
|
||||
retryBtn.style.cssText = 'background:rgba(255,152,0,0.15);border:1px solid #ff9800;color:#ff9800;border-radius:4px;cursor:pointer;padding:2px 8px;font-size:0.9em;white-space:nowrap;transition:all 0.15s;';
|
||||
retryBtn.addEventListener('mouseenter', () => { retryBtn.style.background = 'rgba(255,152,0,0.3)'; });
|
||||
retryBtn.addEventListener('mouseleave', () => { retryBtn.style.background = 'rgba(255,152,0,0.15)'; });
|
||||
retryBtn.addEventListener('click', () => { if (_rerollPane) _rerollPane(paneIdx, effectiveTimeout * 2); });
|
||||
notice.appendChild(retryBtn);
|
||||
aiBody.appendChild(notice);
|
||||
} else {
|
||||
if (!accumulated.trim()) aiBody.innerHTML = '<div style="color:#f0ad4e;font-size:0.9em;">Cancelled.</div>';
|
||||
}
|
||||
} else {
|
||||
console.error('Compare stream error:', error);
|
||||
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(error.message) + '</span>';
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
_timerDone = true;
|
||||
cancelAnimationFrame(_rafId);
|
||||
// Show final time with TTFT
|
||||
const _totalMs = performance.now() - _timerStart;
|
||||
if (_timerEl) {
|
||||
// TTFT removed from the header per user request — just show total time.
|
||||
_timerEl.textContent = _formatMs(_totalMs);
|
||||
}
|
||||
state._abortControllers[paneIdx] = null;
|
||||
// Hide stop button, show response action buttons
|
||||
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (_paneElFinal) {
|
||||
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
|
||||
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
|
||||
if (accumulated.trim()) {
|
||||
_paneElFinal.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||||
}
|
||||
}
|
||||
state._paneMetrics[paneIdx] = metrics;
|
||||
state._paneElapsed[paneIdx] = _totalMs;
|
||||
if (!opts.skipBadge) {
|
||||
if (streamOk) {
|
||||
state._finishOrder++;
|
||||
if (state._parallel) {
|
||||
// Parallel: all panes started at the same instant, so first
|
||||
// to finish is genuinely the fastest.
|
||||
if (state._finishOrder === 1) addFinishBadge(paneIdx);
|
||||
} else {
|
||||
// Sequential: panes run one after another, so "first to
|
||||
// finish" is meaningless (it's just whoever ran first).
|
||||
// Wait until all panes are done, then badge whichever had
|
||||
// the lowest measured per-pane elapsed time.
|
||||
const total = state._selectedModels.length;
|
||||
const finished = state._paneElapsed.filter(v => typeof v === 'number').length;
|
||||
if (finished >= total) {
|
||||
let winnerIdx = -1, winnerMs = Infinity;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const v = state._paneElapsed[i];
|
||||
if (typeof v === 'number' && v < winnerMs) { winnerMs = v; winnerIdx = i; }
|
||||
}
|
||||
if (winnerIdx >= 0) addFinishBadge(winnerIdx);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Timed out or errored — show failed badge
|
||||
const badge = document.getElementById('cmp-badge-' + paneIdx);
|
||||
if (badge) { badge.textContent = timedOut ? 'Timeout' : 'Failed'; badge.style.color = 'var(--color-error)'; }
|
||||
}
|
||||
}
|
||||
// Auto-grade against expected answer — stamps ✓ or ✗ on the pane header.
|
||||
if (streamOk && 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 = '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-grade a pane's response against the eval prompt's expected answer.
|
||||
* Heuristic: lowercased substring match, plus a number-extraction fallback
|
||||
* so "the answer is 882" matches expected "882".
|
||||
* Skips meta answers like "count the words yourself…".
|
||||
*/
|
||||
function _stampGradeBadge(paneIdx, response, expected) {
|
||||
const norm = (s) => String(s).toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
const r = norm(response);
|
||||
const e = norm(expected);
|
||||
if (!r || !e) return;
|
||||
// Skip non-checkable instructions
|
||||
if (e.includes('yourself') || e.includes('verify') || e.length > 120) return;
|
||||
|
||||
let pass = r.includes(e);
|
||||
if (!pass) {
|
||||
// Numeric fallback — find first number in expected, look for it standalone in response
|
||||
const m = expected.match(/-?\d[\d,]*(?:\.\d+)?/);
|
||||
if (m) {
|
||||
const n = m[0].replace(/,/g, '');
|
||||
const re = new RegExp('(?<![\\d.])' + n.replace('.', '\\.') + '(?![\\d.])');
|
||||
pass = re.test(response);
|
||||
}
|
||||
}
|
||||
|
||||
const paneEl = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (!paneEl) return;
|
||||
const header = paneEl.querySelector('.pane-header');
|
||||
if (!header) return;
|
||||
// Remove any prior grade badge (re-roll case)
|
||||
const prev = header.querySelector('.pane-grade-badge');
|
||||
if (prev) prev.remove();
|
||||
const badge = document.createElement('span');
|
||||
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
|
||||
const finBadge = header.querySelector('.pane-finish-badge');
|
||||
if (finBadge) header.insertBefore(badge, finBadge);
|
||||
else header.appendChild(badge);
|
||||
}
|
||||
|
||||
export { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions };
|
||||
254
static/js/compare/vote.js
Normal file
254
static/js/compare/vote.js
Normal file
@@ -0,0 +1,254 @@
|
||||
// compare/vote.js — voting, revealing, confetti
|
||||
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';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
// ── Helpers imported lazily to avoid circular deps ──
|
||||
// stopAll and resetCompare live in compare.js; caller must register them.
|
||||
let _stopAll = null;
|
||||
let _resetCompare = null;
|
||||
|
||||
/** Register external functions that live in compare.js (avoids circular imports). */
|
||||
function registerCompareActions({ stopAll, resetCompare }) {
|
||||
_stopAll = stopAll;
|
||||
_resetCompare = resetCompare;
|
||||
}
|
||||
|
||||
function _slotChar(i) { return state._parallel ? String.fromCharCode(65 + i) : String(i + 1); }
|
||||
|
||||
function addFinishBadge(paneIdx) {
|
||||
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||
if (!hist) return;
|
||||
// Find the last AI message's footer
|
||||
const lastAi = hist.querySelector('.msg-ai:last-of-type');
|
||||
const footer = lastAi && lastAi.querySelector('.msg-footer');
|
||||
if (footer) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'pane-finish-badge';
|
||||
badge.textContent = ' · Fastest';
|
||||
footer.querySelector('.response-metrics')?.appendChild(badge);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build vote/action bar. The per-model "vote for this" buttons live
|
||||
* inside each pane's footer now — this bar carries only the shared
|
||||
* actions (Tie, Reveal, Reset). */
|
||||
function buildVoteBar(n) {
|
||||
const bar = document.getElementById('compare-vote-bar');
|
||||
if (!bar) return;
|
||||
bar.classList.remove('hidden');
|
||||
|
||||
bar.innerHTML = '';
|
||||
// Vote buttons are disabled until a prompt has been sent.
|
||||
const noPrompt = !state._lastPrompt;
|
||||
|
||||
// Sync per-pane vote button state to match the prompt-sent / blind-mode
|
||||
// state — these elements were created when the panes were built, but
|
||||
// their enabled/labelled state needs to refresh whenever this bar is
|
||||
// (re)built (e.g. after sending the first prompt or revealing models).
|
||||
for (let i = 0; i < n; i++) {
|
||||
const paneBtn = document.querySelector('.compare-pane[data-pane="' + i + '"] .pane-vote-btn');
|
||||
if (!paneBtn) continue;
|
||||
paneBtn.disabled = noPrompt;
|
||||
paneBtn.style.opacity = noPrompt ? '0.4' : '';
|
||||
const label = state._blindMode
|
||||
? 'Vote ' + _slotChar(i)
|
||||
: 'Vote ' + state._selectedModels[i].name;
|
||||
paneBtn.querySelector('.pane-vote-label').textContent = label;
|
||||
}
|
||||
|
||||
const tieBtn = document.createElement('button');
|
||||
tieBtn.className = 'compare-vote-btn compare-vote-tie';
|
||||
tieBtn.textContent = 'Tie';
|
||||
if (noPrompt) { tieBtn.disabled = true; tieBtn.style.opacity = '0.25'; }
|
||||
tieBtn.addEventListener('click', () => handleVote(-1));
|
||||
bar.appendChild(tieBtn);
|
||||
|
||||
// Scoreboard button — sits next to Tie. Stays enabled even after a vote (and
|
||||
// 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.title = 'Scoreboard';
|
||||
scoreBtn.addEventListener('click', () => showScoreboard());
|
||||
bar.insertBefore(scoreBtn, tieBtn); // furthest left, before Tie
|
||||
|
||||
if (state._blindMode) {
|
||||
const revealBtn = document.createElement('button');
|
||||
revealBtn.className = 'compare-vote-btn';
|
||||
revealBtn.style.opacity = noPrompt ? '0.25' : '0.5';
|
||||
revealBtn.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;"><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>Reveal';
|
||||
if (noPrompt) revealBtn.disabled = true;
|
||||
revealBtn.addEventListener('click', () => handleVote(-2));
|
||||
bar.appendChild(revealBtn);
|
||||
}
|
||||
|
||||
// Add Model button
|
||||
|
||||
// Reset button (always)
|
||||
const resetBtn = document.createElement('button');
|
||||
resetBtn.className = 'compare-vote-btn compare-rematch-btn';
|
||||
resetBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:3px;"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>Reset';
|
||||
resetBtn.addEventListener('click', () => { if (_resetCompare) _resetCompare(); });
|
||||
bar.appendChild(resetBtn);
|
||||
}
|
||||
|
||||
/** Persist a vote record to localStorage and fire-and-forget to backend. */
|
||||
function _saveVote(winnerIdx) {
|
||||
const modelNames = _modelDisplayNames(state._selectedModels);
|
||||
const winner = winnerIdx === -1 ? 'tie' : modelNames[winnerIdx];
|
||||
// Calculate per-model costs
|
||||
const costs = state._selectedModels.map((m, i) => {
|
||||
const pm = state._paneMetrics[i];
|
||||
if (!pm) return null;
|
||||
return getModelCost(pm.model || m.model, pm.input_tokens || 0, pm.output_tokens || 0);
|
||||
});
|
||||
const record = {
|
||||
models: modelNames,
|
||||
winner: winner,
|
||||
prompt: state._lastPrompt,
|
||||
blind: state._blindMode,
|
||||
mode: state._compareMode || 'chat',
|
||||
timestamp: Date.now(),
|
||||
costs: costs,
|
||||
};
|
||||
|
||||
// localStorage persistence
|
||||
const votes = Storage.getJSON(VOTES_STORAGE_KEY, []);
|
||||
votes.push(record);
|
||||
if (votes.length > VOTES_MAX) votes.splice(0, votes.length - VOTES_MAX);
|
||||
Storage.setJSON(VOTES_STORAGE_KEY, votes);
|
||||
|
||||
// Fire-and-forget POST to backend
|
||||
try {
|
||||
fetch(`${state.API_BASE}/api/compare/record`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: state._lastPrompt,
|
||||
models: modelNames,
|
||||
winner: winner,
|
||||
is_blind: state._blindMode,
|
||||
}),
|
||||
}).catch(() => {}); // silently ignore errors
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/** Reveal model names in pane headers. Highlights winner if one was picked. */
|
||||
function handleVote(winnerIdx) {
|
||||
const displayNames = _modelDisplayNames(state._selectedModels);
|
||||
|
||||
// Reveal only — just show names, keep vote buttons active
|
||||
if (winnerIdx === -2) {
|
||||
for (let i = 0; i < state._selectedModels.length; i++) {
|
||||
const el = document.getElementById('cmp-title-' + i);
|
||||
if (el) el.innerHTML = '<strong>' + escapeHtml(displayNames[i]) + '</strong> <span class="pane-title-caret">▾</span>';
|
||||
const hist = document.getElementById('cmp-history-' + i);
|
||||
if (hist) hist.querySelectorAll('.msg-ai .role').forEach(roleEl => {
|
||||
if (roleEl.textContent.trim() === 'AI') roleEl.textContent = displayNames[i];
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard against double-voting — the per-pane vote buttons (.pane-vote-btn)
|
||||
// aren't covered by the .compare-vote-btn disable below, so without this a
|
||||
// user could spam a pane's vote button and record a score on every click.
|
||||
if (state._voted) return;
|
||||
state._voted = true;
|
||||
|
||||
// Persist vote
|
||||
_saveVote(winnerIdx);
|
||||
|
||||
// Stop any still-streaming panes (user voted early)
|
||||
if (state._streaming && _stopAll) _stopAll();
|
||||
|
||||
const panes = document.querySelectorAll('.compare-pane');
|
||||
|
||||
for (let i = 0; i < state._selectedModels.length; i++) {
|
||||
const el = document.getElementById('cmp-title-' + i);
|
||||
const pane = panes[i];
|
||||
if (!el) continue;
|
||||
const name = displayNames[i];
|
||||
const isWinner = winnerIdx === i;
|
||||
const isTie = winnerIdx === -1;
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
if (pane) {
|
||||
if (isWinner) { pane.classList.add('winner'); }
|
||||
else if (winnerIdx >= 0) pane.classList.add('loser'); }
|
||||
}
|
||||
|
||||
// Swap "AI" role labels to real model names in each pane's messages
|
||||
for (let i = 0; i < state._selectedModels.length; i++) {
|
||||
const hist = document.getElementById('cmp-history-' + i);
|
||||
if (!hist) continue;
|
||||
hist.querySelectorAll('.msg-ai .role').forEach(roleEl => {
|
||||
if (roleEl.textContent.trim() === 'AI') {
|
||||
roleEl.textContent = displayNames[i];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Disable vote buttons but keep reset active — include the per-pane vote
|
||||
// buttons (.pane-vote-btn) so they can't be spammed after a vote.
|
||||
document.querySelectorAll('.compare-vote-btn:not(.compare-rematch-btn):not(.compare-score-btn), .pane-vote-btn').forEach(b => {
|
||||
b.disabled = true; b.style.opacity = '0.4';
|
||||
});
|
||||
|
||||
// Confetti burst at the winner's pane header
|
||||
if (winnerIdx >= 0) {
|
||||
const titleEl = document.getElementById('cmp-title-' + winnerIdx);
|
||||
if (titleEl) {
|
||||
const rect = titleEl.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
spawnConfetti(cx, cy, 50);
|
||||
setTimeout(() => spawnConfetti(cx - 30, cy, 25), 150);
|
||||
setTimeout(() => spawnConfetti(cx + 30, cy, 25), 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Spawn confetti particles from a point. */
|
||||
function spawnConfetti(cx, cy, count) {
|
||||
const colors = ['#ffd700', '#ff6b6b', '#5b8def', '#51cf66', '#ff922b', '#cc5de8', '#22b8cf', '#fff'];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'confetti-piece';
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
const size = 5 + Math.random() * 8;
|
||||
const isCircle = Math.random() > 0.5;
|
||||
el.style.width = size + 'px';
|
||||
el.style.height = (isCircle ? size : size * 0.6) + 'px';
|
||||
el.style.background = color;
|
||||
el.style.borderRadius = isCircle ? '50%' : '2px';
|
||||
el.style.left = cx + 'px';
|
||||
el.style.top = cy + 'px';
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 60 + Math.random() * 160;
|
||||
const dx = Math.cos(angle) * speed;
|
||||
const dy = Math.sin(angle) * speed - 100;
|
||||
const duration = 1.0 + Math.random() * 1.0;
|
||||
el.animate([
|
||||
{ transform: 'translate(0, 0) rotate(0deg) scale(1)', opacity: 1 },
|
||||
{ transform: `translate(${dx}px, ${dy + 200}px) rotate(${400 + Math.random() * 400}deg) scale(0)`, opacity: 0 }
|
||||
], { duration: duration * 1000, easing: 'cubic-bezier(0.15, 0.6, 0.35, 1)', fill: 'forwards' });
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), duration * 1000 + 50);
|
||||
}
|
||||
}
|
||||
|
||||
export { _saveVote, handleVote, buildVoteBar, addFinishBadge, spawnConfetti, registerCompareActions };
|
||||
Reference in New Issue
Block a user