Odysseus v1.0
This commit is contained in:
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 };
|
||||
Reference in New Issue
Block a user