|
|
| import state from './state.js';
|
| import { addFinishBadge } from './vote.js';
|
| import { getModelCost, safeDisplayImageSrc } 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 = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
|
|
|
| function _safeHttpHref(raw) {
|
| try {
|
| const parsed = new URL(String(raw || '').trim(), window.location.origin);
|
| if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
| return parsed.href;
|
| }
|
| } catch (_) {}
|
| return '';
|
| }
|
|
|
|
|
| let _rerollPane = null;
|
| let _autoPreviewHtml = null;
|
|
|
|
|
| function registerStreamActions({ rerollPane, autoPreviewHtml }) {
|
| _rerollPane = rerollPane;
|
| _autoPreviewHtml = autoPreviewHtml;
|
| }
|
|
|
|
|
| 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';
|
| }
|
|
|
|
|
| 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');
|
| const safeUrl = _safeHttpHref(r.url);
|
| if (safeUrl) {
|
| titleLink.href = safeUrl;
|
| titleLink.target = '_blank';
|
| titleLink.rel = 'noopener noreferrer';
|
| }
|
| titleLink.className = 'search-result-title';
|
| titleLink.textContent = r.title || 'Untitled';
|
| card.appendChild(titleLink);
|
| 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;
|
| }
|
|
|
|
|
| async function _runSynthForPane(modelToUse, synthPrompt, synthBody, spinner, hist) {
|
|
|
| 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) {}
|
| }
|
| }
|
| }
|
|
|
|
|
| if (window.hljs) synthBody.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
|
|
|
|
| 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>';
|
| }
|
| }
|
|
|
|
|
| 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;
|
|
|
|
|
| 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;
|
|
|
|
|
|
|
| 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);
|
| };
|
|
|
|
|
| const _timerStart = performance.now();
|
| let _ttft = 0;
|
| 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);
|
|
|
|
|
|
|
|
|
| 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);
|
|
|
|
|
| const isAgent = state._compareMode === 'agent';
|
| const isResearch = state._compareMode === 'research';
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| fd.append('mode', 'chat');
|
| fd.append('use_rag', 'false');
|
| }
|
| const incognitoChk = document.getElementById('incognito-toggle');
|
| if (incognitoChk && incognitoChk.checked) {
|
| fd.append('incognito', 'true');
|
| }
|
|
|
| fd.append('no_documents', 'true');
|
| fd.append('no_memory', 'true');
|
|
|
| fd.append('compare_mode', 'true');
|
|
|
| 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();
|
|
|
| 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;
|
|
|
|
|
| } 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');
|
| }
|
| }
|
|
|
|
|
| } 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');
|
|
|
| aiBody.innerHTML = '';
|
| aiBody.appendChild(box);
|
| if (spinnerModule) {
|
| const newSpinner = spinnerModule.create('Generating response...', 'right');
|
| aiBody.appendChild(newSpinner.createElement());
|
| newSpinner.start();
|
| aiMsgEl._spinner = newSpinner;
|
| }
|
| }
|
|
|
|
|
| } else if (json.type === 'tool_start') {
|
|
|
| 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));
|
| }
|
| }
|
|
|
| if (aiMsgEl._spinner && aiMsgEl._spinner.element) {
|
| aiMsgEl._spinner.destroy();
|
| aiMsgEl._spinner = null;
|
|
|
| const spinnerEl = aiBody.querySelector('.spinner-wrapper, .mini-spinner');
|
| if (spinnerEl) spinnerEl.remove();
|
| }
|
| const toolName = json.tool || 'tool';
|
| const cmd = json.command || '';
|
|
|
| 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 {
|
|
|
| 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">${escapeHtml(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
| node.querySelector('.agent-thread-header').addEventListener('click', () => node.classList.toggle('open'));
|
|
|
| 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;
|
|
|
|
|
| } else if (json.type === 'tool_output') {
|
| if (json.image_url) {
|
|
|
| if (aiMsgEl._imgSpinner) { aiMsgEl._imgSpinner.destroy(); aiMsgEl._imgSpinner = null; }
|
| const safeImageUrl = safeDisplayImageSrc(json.image_url);
|
| aiBody.innerHTML = '';
|
| if (!safeImageUrl) {
|
| aiBody.textContent = '[Image unavailable]';
|
| } else {
|
| const img = document.createElement('img');
|
| img.className = 'compare-gen-image';
|
| img.src = safeImageUrl;
|
| img.alt = json.image_prompt || '';
|
| img.title = json.image_prompt || '';
|
| img.addEventListener('click', () => window.open(safeImageUrl, '_blank', 'noopener,noreferrer'));
|
| aiBody.appendChild(img);
|
| if (json.image_prompt) {
|
| const caption = document.createElement('div');
|
| caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
|
| caption.textContent = json.image_prompt;
|
| aiBody.appendChild(caption);
|
| }
|
|
|
| if (json.image_model && !state._blindMode) {
|
| const modelLabel = document.createElement('div');
|
| modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
|
| modelLabel.textContent = json.image_model;
|
| aiBody.appendChild(modelLabel);
|
| }
|
| aiMsgEl._imageData = { url: safeImageUrl, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
|
| }
|
| } else if (currentToolBlock) {
|
|
|
| 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;
|
|
|
| aiMsgEl._textEl = null;
|
| accumulated = '';
|
| }
|
| if (hist) hist.scrollTop = hist.scrollHeight;
|
| } else if (json.delta) {
|
|
|
| if (aiMsgEl._imageData) continue;
|
|
|
| if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart;
|
|
|
| if (!accumulated && aiMsgEl._spinner) {
|
| if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
|
| aiMsgEl._spinner = null;
|
|
|
| const srcBox = aiBody.querySelector('.compare-sources-box');
|
| aiBody.innerHTML = '';
|
| if (srcBox) aiBody.appendChild(srcBox);
|
|
|
| const textEl = document.createElement('div');
|
| textEl.className = 'compare-text-content';
|
| aiBody.appendChild(textEl);
|
| aiMsgEl._textEl = textEl;
|
| }
|
|
|
| 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;
|
|
|
| if (aiMsgEl._spinner && aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
|
| aiMsgEl._spinner = null;
|
|
|
| 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));
|
| }
|
|
|
|
|
| if (_autoPreviewHtml) _autoPreviewHtml(paneIdx, accumulated);
|
|
|
|
|
| if (aiMsgEl && aiMsgEl._imageData) {
|
|
|
| const imgD = aiMsgEl._imageData;
|
| const footer = document.createElement('div');
|
| footer.className = 'msg-footer';
|
|
|
|
|
| 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);
|
|
|
|
|
| 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';
|
|
|
| const _model = metrics.model || (state._selectedModels[paneIdx] && state._selectedModels[paneIdx].model) || '';
|
| const _cost = getModelCost(_model, metrics.input_tokens || 0, metrics.output_tokens || 0);
|
|
|
| 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);
|
|
|
| const _totalMs = performance.now() - _timerStart;
|
| if (_timerEl) {
|
|
|
| _timerEl.textContent = _formatMs(_totalMs);
|
| }
|
| state._abortControllers[paneIdx] = null;
|
|
|
| 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) {
|
|
|
|
|
| if (state._finishOrder === 1) addFinishBadge(paneIdx);
|
| } else {
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
| const badge = document.getElementById('cmp-badge-' + paneIdx);
|
| if (badge) { badge.textContent = timedOut ? 'Timeout' : 'Failed'; badge.style.color = 'var(--color-error)'; }
|
| }
|
| }
|
|
|
| if (streamOk && state._expectedAnswer) {
|
| _stampGradeBadge(paneIdx, accumulated, state._expectedAnswer);
|
| }
|
|
|
| const paneEl = document.querySelector('.compare-pane:nth-child(' + (paneIdx + 1) + ')');
|
| if (paneEl) paneEl.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
| }
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| 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;
|
|
|
| if (e.includes('yourself') || e.includes('verify') || e.length > 120) return;
|
|
|
| let pass = r.includes(e);
|
| if (!pass) {
|
|
|
| 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;
|
|
|
| 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 ? '✓' : '✗';
|
|
|
| const finBadge = header.querySelector('.pane-finish-badge');
|
| if (finBadge) header.insertBefore(badge, finBadge);
|
| else header.appendChild(badge);
|
| }
|
|
|
| export { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions };
|
|
|