// static/js/chatRenderer.js // Extracted from chat.js — message rendering, sources, images, metrics import uiModule from './ui.js'; import markdownModule from './markdown.js'; import { addAITTSButton } from './tts-ai.js'; import { providerLogo, providerLabel } from './providers.js'; import settingsModule from './settings.js'; import spinnerModule from './spinner.js'; import { bindMenuDismiss } from './escMenuStack.js'; import { matchModelKey } from './model/matchKey.js'; const SEARCH_ICON = ''; const REPORT_ICON = ''; const CHAT_ABOUT_ICON = ''; const COPY_ICON = ''; const CHECK_ICON = ''; /** Sanitize a URL for use in href — only allow http(s) and protocol-relative. */ function _safeHref(url) { if (!url) return '#'; try { var parsed = new URL(url, window.location.origin); if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return uiModule.esc(url); } catch(e) { /* invalid URL */ } return '#'; } export function safeToolScreenshotSrc(raw) { const src = String(raw || '').trim(); if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) { return src; } return ''; } export function safeDisplayImageSrc(raw) { const src = String(raw || '').trim(); if (!src) return ''; if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) { return src; } try { const parsed = new URL(src, window.location.origin); if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { return parsed.href; } } catch (_) {} return ''; } function _makeActionBtn(className, title, text, handler) { const btn = document.createElement('button'); btn.className = className; btn.type = 'button'; btn.title = title; btn.textContent = text; btn.addEventListener('click', handler); return btn; } // Attachment card helpers function _attachIcon(mimeOrName) { const s = (mimeOrName || '').toLowerCase(); if (s.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(s)) return ''; if (s.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|webm)$/i.test(s)) return ''; if (s === 'application/pdf' || /\.pdf$/i.test(s)) return ''; // Default: generic document return ''; } function _formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / 1048576).toFixed(1) + ' MB'; } // Build the `.attach-cards` element for a message's attachment list. Shared by // addMessage and updateMessageAttachments so a live (optimistic) user bubble // can be re-rendered with real upload ids once the upload resolves. function buildAttachCards(attachments) { const attachWrap = document.createElement('div'); attachWrap.className = 'attach-cards'; for (const att of attachments) { const isImage = (att.mime || '').startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(att.name || ''); if (isImage) { // Image preview. Shown for both uploaded (att.id present) and still- // uploading attachments. A shimmering skeleton + whirlpool fills the // space until either the upload resolves (no id yet) or the thumbnail // image finishes loading, so the photo doesn't pop in abruptly. const imgWrap = document.createElement('div'); imgWrap.className = 'attach-image-preview'; imgWrap.style.cursor = att.id ? 'zoom-in' : 'default'; if (att.id) imgWrap.dataset.fileId = att.id; if (att.id) { imgWrap.addEventListener('click', (e) => { // Tapping the corner OCR button shouldn't also open the lightbox. if (e.target.closest('.attach-ocr-btn')) return; _openImageLightbox(att); }); } let skel = null; let sp = null; if (!att.previewUrl) { // Skeleton placeholder with a centered whirlpool. Self-stops when removed. skel = document.createElement('div'); skel.className = 'attach-image-skeleton'; // Match the photo's aspect ratio when the backend knew it at upload // time, so the skeleton doesn't sit at a 4:3 default and then snap to // a portrait shape when the image arrives. if (att.width && att.height) { skel.style.aspectRatio = att.width + ' / ' + att.height; skel.style.width = 'auto'; skel.style.height = 'auto'; skel.style.maxWidth = '300px'; skel.style.maxHeight = '200px'; skel.style.minWidth = '80px'; } sp = spinnerModule.createWhirlpool(20); skel.appendChild(sp.element); imgWrap.appendChild(skel); } if (att.id || att.previewUrl) { const img = document.createElement('img'); // Small cached thumbnail — the preview is tiny, no need to pull the // full-resolution photo. Click still opens the full image. img.alt = att.name || 'Image'; img.loading = 'lazy'; img.style.cssText = 'max-width:300px;max-height:200px;border-radius:6px;display:' + (att.previewUrl ? 'block' : 'none') + ';'; let _revealed = false; let _revealTimer = null; const _reveal = () => { if (_revealed) return; _revealed = true; if (_revealTimer) { clearTimeout(_revealTimer); _revealTimer = null; } img.style.display = 'block'; try { sp && sp.stop(); } catch {} if (skel) skel.remove(); }; img.addEventListener('load', _reveal); img.addEventListener('error', _reveal); img.src = att.previewUrl || `/api/upload/${att.id}?thumb=1`; // Cached images can be complete before the load listener attaches. if (img.complete && img.naturalWidth) _reveal(); // Failsafe: if neither load nor error fires within 8s, reveal anyway. // The timer is cleared on reveal AND when updateMessageAttachments // replaces the card (which scrubs the img / skel from the DOM), so // repeated re-renders don't accumulate stranded timers. if (!att.previewUrl) _revealTimer = setTimeout(_reveal, 8000); imgWrap.appendChild(img); if (att.id) { // Small corner button → opens the vision/OCR editor so the user can // correct what the vision model extracted. The edit is cached on the // server keyed by file id, so any later message referencing this same // image picks up the corrected text instead of re-running the model. const ocrBtn = document.createElement('button'); ocrBtn.type = 'button'; ocrBtn.className = 'attach-ocr-btn'; ocrBtn.title = 'View / edit OCR text'; ocrBtn.innerHTML = 'Caption'; ocrBtn.addEventListener('click', (e) => { e.stopPropagation(); _openVisionEditor(att, ocrBtn.closest('.msg')); }); imgWrap.appendChild(ocrBtn); } } if (att.vision_model) { const visionLabel = document.createElement('div'); visionLabel.className = 'attach-vision-model'; visionLabel.textContent = 'Vision: ' + String(att.vision_model).split('/').pop(); imgWrap.appendChild(visionLabel); } if (att.name) { const label = document.createElement('div'); label.className = 'attach-image-name'; label.textContent = att.name; imgWrap.appendChild(label); } attachWrap.appendChild(imgWrap); } else { // Non-image file card const card = document.createElement('div'); card.className = 'attach-card'; card.dataset.name = att.name; if (att.id) { card.dataset.fileId = att.id; card.style.cursor = 'pointer'; card.addEventListener('click', () => { // PDFs & text/code/markdown → open in the Documents viewer // (others fall back to the raw file). if (window.chatModule?.openAttachment) window.chatModule.openAttachment(att, false); else window.open(`/api/upload/${att.id}`, '_blank'); }); } const icon = _attachIcon(att.mime || att.name); const nameSpan = document.createElement('span'); nameSpan.className = 'attach-card-name'; nameSpan.textContent = att.name; card.innerHTML = icon; card.appendChild(nameSpan); if (att.size) { const sizeSpan = document.createElement('span'); sizeSpan.className = 'attach-card-size'; sizeSpan.textContent = _formatSize(att.size); card.appendChild(sizeSpan); } attachWrap.appendChild(card); } } return attachWrap; } // Re-render the attachment cards of an already-rendered message. Used to swap // in real upload ids (and image thumbnails) on the optimistic user bubble once // uploadPending() resolves — otherwise image previews only appear after a // refresh, because the bubble is rendered before the upload assigns ids. export function updateMessageAttachments(msgWrap, attachments) { if (!msgWrap || !attachments?.length) return; const body = msgWrap.querySelector('.body') || msgWrap; const existing = body.querySelector('.attach-cards'); const fresh = buildAttachCards(attachments); if (existing) existing.replaceWith(fresh); else body.appendChild(fresh); } // Quick full-size preview when the user taps a chat photo thumbnail. Just an // overlay with the original image centered — no Gallery panel, no editor. function _openImageLightbox(att) { if (!att?.id) return; const overlay = document.createElement('div'); overlay.className = 'attach-lightbox'; // Show the cached thumb immediately so the overlay doesn't sit blank // while a 25MB original streams in. The full image swaps in once loaded; // if the full load fails (404 / network), we keep the thumb + show an // error label rather than a blank overlay forever. const img = document.createElement('img'); img.alt = att.name || ''; img.src = `/api/upload/${att.id}?thumb=1`; overlay.appendChild(img); const full = new Image(); full.addEventListener('load', () => { img.src = full.src; }); full.addEventListener('error', () => { const err = document.createElement('div'); err.className = 'attach-lightbox-err'; err.textContent = 'Failed to load full-resolution image.'; overlay.appendChild(err); }); full.src = `/api/upload/${att.id}`; const _onKey = (e) => { if (e.key === 'Escape') _close(); }; const _close = () => { document.removeEventListener('keydown', _onKey); if (_overlayObs) { try { _overlayObs.disconnect(); } catch {} } overlay.remove(); }; // If the overlay is removed via any path other than our close handler // (session switch, parent re-render, external cleanup), still drop the // document-level keydown listener so it doesn't leak. let _overlayObs = null; try { _overlayObs = new MutationObserver(() => { if (!document.body.contains(overlay)) { document.removeEventListener('keydown', _onKey); _overlayObs.disconnect(); } }); _overlayObs.observe(document.body, { childList: true, subtree: false }); } catch {} overlay.addEventListener('click', _close); document.addEventListener('keydown', _onKey); document.body.appendChild(overlay); } // Vision/OCR editor modal — opened from the corner "Aa" button on a chat photo // thumbnail. Lets the user view and correct the text the vision model fed to // the LLM (e.g. when OCR misreads a word). Persists to the server's vision // cache (PUT /api/upload/{id}/vision), so any subsequent message that // references the same file picks up the corrected text. let _visionEditorEl = null; let _visionEditorEsc = null; function _closeVisionEditor() { if (_visionEditorEsc) { document.removeEventListener('keydown', _visionEditorEsc); _visionEditorEsc = null; } if (_visionEditorEl) { _visionEditorEl.remove(); _visionEditorEl = null; } } function _openVisionEditor(att, userMsgEl) { if (!att?.id) return; _closeVisionEditor(); const overlay = document.createElement('div'); overlay.className = 'vision-editor-overlay'; overlay.addEventListener('click', (e) => { if (e.target === overlay) _closeVisionEditor(); }); const panel = document.createElement('div'); panel.className = 'vision-editor-panel'; const title = document.createElement('div'); title.className = 'vision-editor-title'; // Eye icon matches the one in Settings → Vision so users recognise where // this text originates. title.innerHTML = 'Vision text'; panel.appendChild(title); const desc = document.createElement('div'); desc.className = 'vision-editor-desc'; desc.textContent = 'Edit text and save, new chats will have the new context. Regenerate or continue from there.'; panel.appendChild(desc); const ta = document.createElement('textarea'); ta.className = 'vision-editor-text'; ta.rows = 10; ta.placeholder = 'Loading…'; ta.disabled = true; panel.appendChild(ta); const actions = document.createElement('div'); actions.className = 'vision-editor-actions'; const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'vision-editor-btn'; closeBtn.innerHTML = 'Close'; closeBtn.addEventListener('click', _closeVisionEditor); const _saveVisionText = async () => { const res = await fetch(`/api/upload/${att.id}/vision`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ text: ta.value }), }); if (!res.ok) throw new Error('save failed'); }; const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'vision-editor-btn vision-editor-btn-primary'; saveBtn.innerHTML = 'Save'; saveBtn.disabled = true; saveBtn.addEventListener('click', async () => { saveBtn.disabled = true; saveBtn.innerHTML = 'Saving…'; try { await _saveVisionText(); if (uiModule?.showToast) uiModule.showToast('Saved'); _closeVisionEditor(); } catch (e) { saveBtn.disabled = false; saveBtn.innerHTML = 'Save'; if (uiModule?.showError) uiModule.showError('Failed to save OCR text'); } }); // Regenerate-message: save the edited text, close, then trigger a resend of // the user message so the new AI reply uses the edit immediately. const regenBtn = document.createElement('button'); regenBtn.type = 'button'; regenBtn.className = 'vision-editor-btn vision-editor-btn-primary'; regenBtn.title = 'Save and regenerate the message'; regenBtn.innerHTML = 'Regenerate message'; regenBtn.disabled = true; regenBtn.addEventListener('click', async () => { regenBtn.disabled = true; saveBtn.disabled = true; try { await _saveVisionText(); _closeVisionEditor(); if (userMsgEl && window.chatModule?.resendUserMessage) { window.chatModule.resendUserMessage(userMsgEl); } else if (uiModule?.showToast) { uiModule.showToast('Saved'); } } catch (e) { regenBtn.disabled = false; saveBtn.disabled = false; if (uiModule?.showError) uiModule.showError('Failed to save OCR text'); } }); actions.appendChild(closeBtn); actions.appendChild(saveBtn); actions.appendChild(regenBtn); panel.appendChild(actions); overlay.appendChild(panel); document.body.appendChild(overlay); _visionEditorEl = overlay; // ESC closes the popup. Registered on document so it works regardless of // focus (the textarea swallows the event otherwise). _visionEditorEsc = (e) => { if (e.key === 'Escape') _closeVisionEditor(); }; document.addEventListener('keydown', _visionEditorEsc); fetch(`/api/upload/${att.id}/vision`, { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : Promise.reject(r)) .then(data => { ta.value = data.text || ''; ta.placeholder = ''; ta.disabled = false; saveBtn.disabled = false; regenBtn.disabled = !userMsgEl; ta.focus(); }) .catch(() => { ta.value = ''; ta.placeholder = 'Could not load OCR text — type your correction and save.'; ta.disabled = false; saveBtn.disabled = false; regenBtn.disabled = !userMsgEl; }); } // Tool call syntax patterns to strip from displayed text const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi; // Only strip fenced tool-call blocks that look like structured invocations, not regular code examples const EXEC_FENCE_RE = /```(?:web_search|read_file|write_file|create_document|edit_document|update_document)\s*\n[\s\S]*?```/gi; // XML-style tool calls: , , , bare const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi; const XML_INVOKE_RE = /[\s\S]*?<\/invoke>/gi; // DeepSeek "DSML" tool-call markup (fullwidth-pipe | or ascii | delimited) that // leaks into content when the model emits a text tool call instead of a native // one. Strip the whole block; the second pattern catches stray/partial tags // (e.g. mid-stream before the closing tag arrives). const DSML_TOOL_RE = /<\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>|$)/gi; const DSML_STRAY_RE = /<\s*\/?\s*[||]+\s*DSML\s*[||]+[^>]*>/gi; // Self-narration about tool results (model echoing stdout/exit_code) const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi; // Model pricing table — per million tokens // Model info: pricing (per 1M tokens) + context window length const MODEL_INFO = { // --- Anthropic --- 'claude-sonnet-4-5': { input: 3.00, output: 15.00, ctx: 200000 }, 'claude-sonnet-4-6': { input: 3.00, output: 15.00, ctx: 200000 }, 'claude-sonnet-4': { input: 3.00, output: 15.00, ctx: 200000 }, 'claude-opus-4': { input: 15.00, output: 75.00, ctx: 200000 }, 'claude-opus-4-6': { input: 15.00, output: 75.00, ctx: 200000 }, 'claude-haiku-4': { input: 0.80, output: 4.00, ctx: 200000 }, 'claude-haiku-3-5': { input: 0.80, output: 4.00, ctx: 200000 }, 'claude-3-5-sonnet': { input: 3.00, output: 15.00, ctx: 200000 }, 'claude-3-5-haiku': { input: 0.80, output: 4.00, ctx: 200000 }, 'claude-3-opus': { input: 15.00, output: 75.00, ctx: 200000 }, 'claude-3-sonnet': { input: 3.00, output: 15.00, ctx: 200000 }, 'claude-3-haiku': { input: 0.25, output: 1.25, ctx: 200000 }, // --- OpenAI --- 'gpt-5': { input: 2.00, output: 8.00, ctx: 400000 }, 'gpt-4.1': { input: 2.00, output: 8.00, ctx: 1047576 }, 'gpt-4.1-mini': { input: 0.40, output: 1.60, ctx: 1047576 }, 'gpt-4.1-nano': { input: 0.10, output: 0.40, ctx: 1047576 }, 'gpt-4o': { input: 2.50, output: 10.00, ctx: 128000 }, 'gpt-4o-mini': { input: 0.15, output: 0.60, ctx: 128000 }, 'gpt-4-turbo': { input: 10.00, output: 30.00, ctx: 128000 }, 'o1': { input: 15.00, output: 60.00, ctx: 200000 }, 'o1-mini': { input: 3.00, output: 12.00, ctx: 128000 }, 'o1-pro': { input: 150.0, output: 600.0, ctx: 200000 }, 'o3': { input: 2.00, output: 8.00, ctx: 200000 }, 'o3-mini': { input: 1.10, output: 4.40, ctx: 200000 }, 'o4-mini': { input: 1.10, output: 4.40, ctx: 200000 }, // --- DeepSeek --- 'deepseek-chat': { input: 0.27, output: 1.10, ctx: 64000 }, 'deepseek-coder': { input: 0.27, output: 1.10, ctx: 64000 }, 'deepseek-reasoner': { input: 0.55, output: 2.19, ctx: 64000 }, 'deepseek-r1': { input: 0.55, output: 2.19, ctx: 64000 }, 'deepseek-v3': { input: 0.27, output: 1.10, ctx: 64000 }, 'deepseek-v2': { input: 0.14, output: 0.28, ctx: 64000 }, // --- Google --- 'gemini-2.5-pro': { input: 1.25, output: 10.00, ctx: 1048576 }, 'gemini-2.5-flash': { input: 0.15, output: 0.60, ctx: 1048576 }, 'gemini-2.0-flash': { input: 0.10, output: 0.40, ctx: 1048576 }, 'gemini-1.5-pro': { input: 1.25, output: 5.00, ctx: 1048576 }, 'gemini-1.5-flash': { input: 0.075, output: 0.30, ctx: 1048576 }, 'gemma-3': { input: 0.10, output: 0.10, ctx: 128000 }, // --- Mistral --- 'mistral-large': { input: 2.00, output: 6.00, ctx: 128000 }, 'mistral-medium': { input: 2.00, output: 6.00, ctx: 32000 }, 'mistral-small': { input: 0.20, output: 0.60, ctx: 32000 }, 'mistral-nemo': { input: 0.15, output: 0.15, ctx: 128000 }, 'mixtral': { input: 0.24, output: 0.24, ctx: 32000 }, 'codestral': { input: 0.30, output: 0.90, ctx: 32000 }, 'pixtral': { input: 2.00, output: 6.00, ctx: 128000 }, // --- xAI --- 'grok-4': { input: 3.00, output: 15.00, ctx: 131072 }, 'grok-3': { input: 3.00, output: 15.00, ctx: 131072 }, 'grok-2': { input: 2.00, output: 10.00, ctx: 131072 }, // --- Meta --- 'llama-4': { input: 0.20, output: 0.20, ctx: 1048576 }, 'llama-3.3': { input: 0.20, output: 0.20, ctx: 131072 }, 'llama-3.2': { input: 0.20, output: 0.20, ctx: 131072 }, 'llama-3.1': { input: 0.20, output: 0.20, ctx: 131072 }, 'llama-3': { input: 0.20, output: 0.20, ctx: 131072 }, // --- Qwen --- 'qwen3': { input: 0.30, output: 1.20, ctx: 131072 }, 'qwen2.5': { input: 0.30, output: 1.20, ctx: 131072 }, 'qwq': { input: 0.30, output: 1.20, ctx: 32768 }, // --- Cohere --- 'command-a': { input: 2.50, output: 10.00, ctx: 256000 }, 'command-r-plus': { input: 2.50, output: 10.00, ctx: 128000 }, 'command-r': { input: 0.15, output: 0.60, ctx: 128000 }, // --- Perplexity --- 'sonar-pro': { input: 3.00, output: 15.00, ctx: 200000 }, 'sonar': { input: 1.00, output: 1.00, ctx: 128000 }, // --- MiniMax --- 'minimax': { input: 0.70, output: 0.70, ctx: 1000000 }, // --- Kimi / Moonshot --- 'moonshot': { input: 1.00, output: 1.00, ctx: 128000 }, 'kimi': { input: 1.00, output: 1.00, ctx: 128000 }, // --- Microsoft --- 'phi-4': { input: 0.07, output: 0.14, ctx: 16000 }, 'phi-3': { input: 0.07, output: 0.14, ctx: 128000 }, // --- Nvidia --- 'nemotron': { input: 0.30, output: 1.20, ctx: 131072 }, // --- Nous --- 'hermes': { input: 0.20, output: 0.20, ctx: 131072 }, }; // Compat alias const MODEL_PRICING = MODEL_INFO; // Image generation cost lookup (per-image, by model × quality × size) const IMAGE_PRICING = { 'gpt-image-1.5': { 'low': { '1024x1024': 0.009, '1024x1536': 0.013, '1536x1024': 0.013 }, 'medium': { '1024x1024': 0.034, '1024x1536': 0.05, '1536x1024': 0.05 }, 'high': { '1024x1024': 0.133, '1024x1536': 0.2, '1536x1024': 0.2 } }, 'gpt-image-1': { 'low': { '1024x1024': 0.011, '1024x1536': 0.016, '1536x1024': 0.016 }, 'medium': { '1024x1024': 0.042, '1024x1536': 0.063, '1536x1024': 0.063 }, 'high': { '1024x1024': 0.167, '1024x1536': 0.25, '1536x1024': 0.25 } }, 'gpt-image-1-mini': { 'low': { '1024x1024': 0.005, '1024x1536': 0.006, '1536x1024': 0.006 }, 'medium': { '1024x1024': 0.011, '1024x1536': 0.015, '1536x1024': 0.015 }, 'high': { '1024x1024': 0.036, '1024x1536': 0.052, '1536x1024': 0.052 } }, }; export function shortModel(name) { if (!name) return '...'; if (typeof name !== 'string') name = String(name); let short = name.split('/').pop(); // Strip .gguf extension short = short.replace(/\.gguf$/i, ''); // Strip quantization suffixes (Q4_K_M, Q8_0, etc.) and shard numbers short = short.replace(/-0000\d-of-\d+$/, ''); short = short.replace(/[-_](Q\d[_A-Z\d]*|F16|F32|BF16|fp16|fp32)$/i, ''); // Truncate if still too long (keep first meaningful part) if (short.length > 25) { // Try to find a natural break point (dash after model size like -35B or -7B) const sizeMatch = short.match(/^(.+?-\d+[BbMm])/); if (sizeMatch) short = sizeMatch[1]; else short = short.substring(0, 22) + '…'; } return short; } /** * Generate a consistent HSL color for a model name. * Returns an hsl() string. The hue is derived from a string hash, * saturation and lightness are fixed for readability on dark/light themes. */ export function modelColor(name) { if (!name) return null; const key = name.toLowerCase(); let hash = 0; for (let i = 0; i < key.length; i++) { hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0; } const hue = ((hash % 360) + 360) % 360; return `hsl(${hue}, 55%, 65%)`; } /** Look up model info (pricing + context) by substring match */ export function getModelInfo(modelName) { if (!modelName) return null; const key = matchModelKey(modelName, Object.keys(MODEL_INFO)); return key ? { key, ...MODEL_INFO[key] } : null; } function _fmtCtx(n) { if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; return Math.round(n / 1000) + 'K'; } /** * Apply model color to a role element (sets color + dot color). */ export function applyModelColor(roleEl, modelName) { if (!modelName) return; const color = modelColor(modelName); if (color) { roleEl.style.color = color; roleEl.style.setProperty('--model-dot', color); } // Replace generic dot with provider logo if available const logo = providerLogo(modelName); if (logo && !roleEl.querySelector('.role-provider-logo')) { const span = document.createElement('span'); span.className = 'role-provider-logo'; span.innerHTML = logo; roleEl.classList.add('has-logo'); roleEl.prepend(span); } // Click to show model info popup if (!roleEl._hasInfoClick) { roleEl._hasInfoClick = true; roleEl.style.cursor = 'pointer'; roleEl.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); }); const info = getModelInfo(modelName); const short = shortModel(modelName); const logoHtml = providerLogo(modelName); const popup = document.createElement('div'); popup.className = 'ctx-popup'; let html = '
'; if (logoHtml) html += ''; html += short + '
'; html += '
Model ' + modelName.split('/').pop() + '
'; // Provider = the serving endpoint, distinct from the model vendor/logo // (e.g. the same model via OpenRouter vs Copilot vs Anthropic direct). const _epUrl = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl) ? window.sessionModule.getCurrentEndpointUrl() : null; const _provLabel = providerLabel(_epUrl); if (_provLabel) html += '
Provider ' + uiModule.esc(_provLabel) + '
'; // Show static context initially, then fetch real from server const _realCtx = window._realContextLengths && window._realContextLengths[modelName]; if (_realCtx) { html += '
Context ' + _fmtCtx(_realCtx) + ' tokens'; if (info && info.ctx && info.ctx !== _realCtx) html += ' (spec: ' + _fmtCtx(info.ctx) + ')'; html += '
'; } else if (info && info.ctx) { html += '
Context ' + _fmtCtx(info.ctx) + ' tokens
'; } // Fetch real context from server async if (!_realCtx && window.sessionModule) { const _sid = window.sessionModule.getCurrentSessionId(); if (_sid) { fetch('/api/session/' + _sid + '/context_info').then(r => r.ok ? r.json() : null).then(d => { if (d && d.context_length) { if (!window._realContextLengths) window._realContextLengths = {}; window._realContextLengths[modelName] = d.context_length; const el = document.getElementById('_ctx-val'); if (el) { el.innerHTML = _fmtCtx(d.context_length) + ' tokens'; if (info && info.ctx && info.ctx !== d.context_length) { el.innerHTML += ' (spec: ' + _fmtCtx(info.ctx) + ')'; } } } }).catch(() => {}); } } // Show configured max tokens if set if (window.presetsModule) { const _pid = window.presetsModule.getSelectedPreset(); const _preset = _pid ? window.presetsModule.getPreset(_pid) : null; const _mt = _preset?.max_tokens; if (_mt && _mt > 0 && _mt <= 8192) { html += '
Max tokens ' + _mt.toLocaleString() + ' (configured)
'; } } if (info && info.input != null) html += '
Input $' + info.input.toFixed(2) + ' / 1M
'; if (info && info.output != null) html += '
Output $' + info.output.toFixed(2) + ' / 1M
'; if (!info) html += '
No pricing data available
'; popup.innerHTML = html; const rect = roleEl.getBoundingClientRect(); popup.style.top = (rect.bottom + 4) + 'px'; popup.style.left = rect.left + 'px'; document.body.appendChild(popup); const pr = popup.getBoundingClientRect(); if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px'; if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px'; bindMenuDismiss(popup, () => popup.remove()); }); } } export function getModelCost(modelName, inputTokens, outputTokens) { if (!modelName) return null; const key = matchModelKey(modelName, Object.keys(MODEL_PRICING)); if (!key) return null; const price = MODEL_PRICING[key]; return (inputTokens * price.input + outputTokens * price.output) / 1_000_000; } /** * Is this endpoint a local / self-hosted model server (vLLM, Ollama, …)? * Local models are free, so we must NOT bill them at cloud rates — the * pricing table matches on a name substring, so a local `qwen2.5-coder` * would otherwise be charged like cloud `qwen2.5`. When the serving host is * loopback, a private LAN range, Tailscale CGNAT (100.64–100.127.x), a * `.local` name, or the app's own host, the model is local → free. * Unknown / missing endpoint also counts as local (bias to not over-bill). */ export function isLocalEndpoint(url) { if (!url) return true; let host; try { host = new URL(url).hostname; } catch (_e) { return true; } if (!host) return true; if (host === 'localhost' || host === '0.0.0.0' || host === 'host.docker.internal' || host.endsWith('.local')) return true; if (typeof window !== 'undefined' && window.location && host === window.location.hostname) return true; // A single-label hostname (no dot) is an internal/Docker service name // (e.g. "nim-nano", "llamaswap", "nemotron-super-49b") or a LAN shortname — // never a public API, which always needs an FQDN. Treat as local → free. // (Without this, container-name endpoints get billed at cloud rates because // the pricing table matches on a name substring, e.g. "nemotron".) if (!host.includes('.')) return true; if (/^127\./.test(host)) return true; if (/^10\./.test(host)) return true; if (/^192\.168\./.test(host)) return true; if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true; const cg = host.match(/^100\.(\d+)\./); // Tailscale CGNAT if (cg && +cg[1] >= 64 && +cg[1] <= 127) return true; return false; } /** Cost for the current turn, returning null (free) for local endpoints. */ function _billableCost(model, inputTokens, outputTokens) { const url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl) ? window.sessionModule.getCurrentEndpointUrl() : null; if (isLocalEndpoint(url)) return null; return getModelCost(model, inputTokens, outputTokens); } export function getImageCost(model, quality, size) { if (!model) return null; const m = model.toLowerCase(); for (const [key, quals] of Object.entries(IMAGE_PRICING)) { if (m.includes(key)) { const q = quals[(quality || 'medium').toLowerCase()] || quals['medium']; return q ? (q[size] || q['1024x1024'] || null) : null; } } return null; } /* ── Session cost helpers ─────────────────────────────────────────── */ const _COST_KEY = 'ody-session-cost'; /** Return the accumulated cost for the current (or given) session. */ export function getSessionCost(sessionId) { const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId()); if (!sid) return 0; try { const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); return costs[sid] || 0; } catch (_e) { return 0; } } /** Reset session cost for the given session (defaults to current). */ export function resetSessionCost(sessionId) { const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId()); if (!sid) return; try { const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); delete costs[sid]; localStorage.setItem(_COST_KEY, JSON.stringify(costs)); } catch (_e) { /* ignore */ } updateSessionCostUI(); } /** Update the persistent session-cost badge in the input bar. */ export function updateSessionCostUI() { const el = document.getElementById('session-cost-display'); if (!el) return; // Local model? It's free — hide the badge and clear any stale cost that a // previous (buggy) cloud-rate billing left in localStorage for this session. const _url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl) ? window.sessionModule.getCurrentEndpointUrl() : null; if (isLocalEndpoint(_url)) { const sid = window.sessionModule && window.sessionModule.getCurrentSessionId(); if (sid && getSessionCost(sid) > 0) { try { const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); delete costs[sid]; localStorage.setItem(_COST_KEY, JSON.stringify(costs)); } catch (_e) { /* ignore */ } } el.style.display = 'none'; return; } const cost = getSessionCost(); if (cost > 0) { el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2)); el.style.display = ''; } else { el.style.display = 'none'; } } /** Create a timestamp span for role labels. * Pass an ISO string / Date / epoch-ms to render the message's own time * (used when replaying history). Falls back to "now" when no value is given. */ export function roleTimestamp(when) { const ts = document.createElement('span'); ts.className = 'role-timestamp'; let d; if (when instanceof Date) d = when; else if (typeof when === 'number') d = new Date(when); else if (typeof when === 'string' && when) d = new Date(when); else d = new Date(); if (isNaN(d.getTime())) d = new Date(); ts.textContent = d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); ts.title = d.toLocaleString(); return ts; } /** * Strip tool invocation blocks from text before rendering. */ export function stripToolBlocks(text) { let cleaned = text.replace(TOOL_CALL_RE, ''); cleaned = cleaned.replace(EXEC_FENCE_RE, ''); cleaned = cleaned.replace(DSML_TOOL_RE, ''); cleaned = cleaned.replace(DSML_STRAY_RE, ''); cleaned = cleaned.replace(XML_TOOL_CALL_RE, ''); cleaned = cleaned.replace(XML_INVOKE_RE, ''); cleaned = cleaned.replace(TOOL_NARRATION_RE, ''); cleaned = cleaned.replace(/\n{3,}/g, '\n\n'); return cleaned.trim(); } /** * Build a collapsible sources box (used by both research and web search). */ export function buildSourcesBox(sources, type, expanded) { var esc = uiModule.esc; var id = 'sources-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5); var count = sources.length; var label = type === 'research' ? 'Research sources' : 'Web sources'; var lines = ''; for (var i = 0; i < count; i++) { var s = sources[i]; var domain = ''; try { domain = new URL(s.url).hostname.replace('www.', ''); } catch(e) { domain = s.url; } var title = esc(s.title || domain || ''); var safeUrl = _safeHref(s.url); lines += '' + '' + (i + 1) + '' + '' + title + '' + '' + esc(domain) + '' + ''; } var arrow = expanded ? 'down' : 'right'; var expandedClass = expanded ? ' expanded' : ''; return '
' + '
' + '
' + SEARCH_ICON + '' + count + ' ' + label + '
' + '' + '
' + '
' + '
' + lines + '
' + '
'; } /** * Build the RAG "Sources (N documents)" box — mirrors the live render in * chat.js so persisted rag_sources survive a refresh. Items carry a * filename, similarity %, and snippet (not URLs, unlike web sources). * @param {Array<{filename, similarity, snippet}>} sources */ export function buildRagSourcesBox(sources) { if (!sources || !sources.length) return ''; var esc = uiModule.esc; var items = ''; for (var i = 0; i < sources.length; i++) { var s = sources[i] || {}; var pct = (typeof s.similarity === 'number') ? (s.similarity * 100).toFixed(1) + '%' : ''; items += '
' + esc(s.filename || '') + '' + (pct ? ' ' + pct + '' : '') + '
' + esc(s.snippet || '') + '
'; } return '
Sources (' + sources.length + ' documents)' + items + '
'; } /** * Build a collapsible "Raw collected findings" section, styled like the sources box. * @param {Array<{url, title, summary}>} findings * @param {boolean} [expanded=false] */ export function buildFindingsBox(findings, expanded) { if (!findings || !findings.length) return ''; var esc = uiModule.esc; var id = 'findings-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5); var count = findings.length; var lines = ''; for (var i = 0; i < count; i++) { var f = findings[i]; var domain = ''; try { domain = new URL(f.url).hostname.replace('www.', ''); } catch(e) { domain = f.url; } var title = esc(f.title || domain || ''); var summary = esc(f.summary || ''); var safeUrl = _safeHref(f.url); lines += ''; } var FINDINGS_ICON = ''; var arrow = expanded ? 'down' : 'right'; var expandedClass = expanded ? ' expanded' : ''; return '
' + '
' + '
' + FINDINGS_ICON + '' + count + ' Raw collected findings
' + '' + '
' + '
' + '
' + lines + '
' + '
'; } /** Append report button + continue research prompt. */ export function appendReportButton(container, sessionId) { _appendReportButton(container, sessionId); _appendContinuePrompt(container); } function _appendContinuePrompt(container) { var wrap = document.createElement('div'); wrap.className = 'continue-research-wrap'; wrap.innerHTML = '
' + '' + 'Dig deeper? Activate Research again and type a follow-up question to continue this research.' + '
'; container.appendChild(wrap); } function _appendReportButton(container, sessionId) { var apiBase = window.API_BASE || ''; // Wrapper holds report button + chat-about button var wrap = document.createElement('div'); wrap.className = 'report-btn-wrap'; var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'view-report-btn'; btn.innerHTML = REPORT_ICON + ' Open Visual Report'; var reportUrl = apiBase + '/api/research/report/' + sessionId; btn.addEventListener('click', function() { window.open(reportUrl, '_blank'); }); wrap.appendChild(btn); var chatBtn = document.createElement('button'); chatBtn.type = 'button'; chatBtn.className = 'view-report-btn chat-about-btn'; chatBtn.innerHTML = CHAT_ABOUT_ICON + ' Discuss'; chatBtn.addEventListener('click', async function() { if (chatBtn.disabled) return; var origLabel = chatBtn.innerHTML; chatBtn.disabled = true; chatBtn.innerHTML = CHAT_ABOUT_ICON + ' Creating…'; try { var res = await fetch(apiBase + '/api/research/spinoff/' + sessionId, { method: 'POST' }); if (!res.ok) { var detail = ''; try { detail = (await res.json()).detail || ''; } catch {} throw new Error(detail || ('HTTP ' + res.status)); } var payload = await res.json(); if (window.sessionModule && payload.session_id) { await window.sessionModule.loadSessions().catch(() => {}); await window.sessionModule.selectSession(payload.session_id); } } catch (e) { chatBtn.disabled = false; chatBtn.innerHTML = origLabel; if (window.uiModule && uiModule.showError) { uiModule.showError('Could not start follow-up chat: ' + e.message); } else { alert('Could not start follow-up chat: ' + e.message); } } }); wrap.appendChild(chatBtn); container.appendChild(wrap); } window.toggleSources = function(id) { // Debounce to prevent double-fire from both inline onclick and delegation var now = Date.now(); if (window._lastSourcesToggle && now - window._lastSourcesToggle < 100) return; window._lastSourcesToggle = now; var content = document.getElementById(id); var toggle = document.getElementById(id + '-toggle'); if (content && toggle) { var expanded = content.classList.contains('expanded'); content.classList.toggle('expanded', !expanded); toggle.dataset.arrow = expanded ? 'right' : 'down'; } }; // Event delegation for sources toggle (capture phase, handles SVG targets) document.addEventListener('click', function(e) { // Walk up from target manually to handle SVG elements that may not support closest() var el = e.target; while (el && el !== document) { if (el.classList && el.classList.contains('sources-header') && el.dataset && el.dataset.sourcesId) { e.stopPropagation(); window.toggleSources(el.dataset.sourcesId); return; } el = el.parentElement || el.parentNode; } }, true); // Jump-to-entity anchors — the agent emits links like // [New Chat](#session-89effa28) // [Notes](#document-abc123) // [Reminder](#note-42) // and the chat-history click delegate turns them into navigation // instead of default in-page anchor jumps. Each prefix routes to the // matching module via a dynamic import (avoids circular deps — // sessions.js itself imports chatRenderer.js). document.addEventListener('click', function(e) { const a = e.target && e.target.closest && e.target.closest('a[href]'); if (!a) return; const href = a.getAttribute('href') || ''; if (!href.startsWith('#')) return; const m = href.match(/^#(session|document|note|image|email|event|task|skill|research)-(.+)$/); if (!m) return; e.preventDefault(); e.stopPropagation(); const [, kind, id] = m; if (kind === 'session') { import('./sessions.js').then(mod => { const fn = mod.selectSession || (mod.default && mod.default.selectSession); if (fn) fn(id); }); } else if (kind === 'document') { import('./document.js').then(mod => { const open = mod.loadDocument || mod.openDocument || (mod.default && (mod.default.loadDocument || mod.default.openDocument)); if (open) open(id); }).catch(() => {}); } else if (kind === 'note') { import('./notes.js').then(mod => { const open = mod.openNote || (mod.default && mod.default.openNote); if (open) open(id); }).catch(() => {}); } else if (kind === 'image') { import('./gallery.js').then(mod => { const open = mod.openGalleryImage || (mod.default && mod.default.openGalleryImage); if (open) open(id); }).catch(() => {}); } else if (kind === 'email') { import('./emailLibrary.js').then(mod => { const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary); if (open) open({ uid: id }); }).catch(() => {}); } else if (kind === 'event') { import('./calendar.js').then(mod => { const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo); if (open) open(id); }).catch(() => {}); } else if (kind === 'task') { import('./tasks.js').then(mod => { const open = mod.openTasks || (mod.default && mod.default.openTasks); if (open) open(id); else { const b = document.getElementById('tasks-btn'); if (b) b.click(); } }).catch(() => { const b = document.getElementById('tasks-btn'); if (b) b.click(); }); } else if (kind === 'skill') { import('./skills.js').then(mod => { const open = mod.openSkill || (mod.default && mod.default.openSkill); if (open) open(id); }).catch(() => {}); } else if (kind === 'research') { import('./research/panel.js').then(mod => { const open = mod.openPanel || (mod.default && mod.default.openPanel); if (open) open(id); }).catch(() => {}); } }); /** * Build a generated-image bubble element. */ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId) { var esc = uiModule.esc; const wrap = document.createElement('div'); wrap.className = 'msg msg-ai generated-image-wrap'; const role = document.createElement('div'); role.className = 'role'; role.textContent = (model || 'image').split('/').pop(); wrap.appendChild(role); const body = document.createElement('div'); body.className = 'body'; const safeImageUrl = safeDisplayImageSrc(imageUrl); if (!safeImageUrl) { body.textContent = '[Image unavailable]'; wrap.appendChild(body); return wrap; } const img = document.createElement('img'); img.className = 'generated-image'; img.alt = prompt || 'Generated image'; img.title = prompt || 'Generated image'; img.src = safeImageUrl; img.addEventListener('click', () => { window.open(safeImageUrl, '_blank', 'noopener,noreferrer'); }); body.appendChild(img); if (prompt) { const caption = document.createElement('div'); caption.className = 'generated-image-caption'; caption.textContent = prompt; body.appendChild(caption); } wrap.appendChild(body); 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.innerHTML = COPY_ICON; copyBtn.addEventListener('click', (e) => { e.stopPropagation(); uiModule.copyToClipboard(prompt || ''); copyBtn.innerHTML = CHECK_ICON; setTimeout(() => { copyBtn.innerHTML = COPY_ICON; }, 1500); }); 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(imageUrl); const blob = await resp.blob(); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (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); const editBtn = document.createElement('button'); editBtn.className = 'footer-copy-btn'; editBtn.type = 'button'; editBtn.title = 'Edit in image editor'; editBtn.innerHTML = ''; editBtn.addEventListener('click', async (e) => { e.stopPropagation(); try { const [galleryMod, editorMod] = await Promise.all([ import('./gallery.js'), import('./galleryEditor.js'), ]); // Ensure the Gallery modal is open so the editor has a container // to render into; switch its tabs to the Edit tab. galleryMod.default.openGallery(); const modal = document.getElementById('gallery-modal'); if (modal) { modal.querySelectorAll('.gallery-tab').forEach(t => t.classList.remove('active')); modal.querySelector('.gallery-tab[data-tab="editor"]')?.classList.add('active'); } const imagesContainer = document.getElementById('gallery-images-container'); const albumsContainer = document.getElementById('gallery-albums-container'); if (imagesContainer) imagesContainer.style.display = 'none'; if (albumsContainer) albumsContainer.style.display = 'none'; const editorContainer = document.getElementById('gallery-editor-container'); if (editorContainer) editorContainer.style.display = 'flex'; const label = (prompt || '').trim().slice(0, 60) || 'Generated image'; editorMod.openEditor(imageUrl, null, null, label); } catch (err) { console.error('[chat] open in editor failed', err); } }); actions.appendChild(editBtn); const delBtn = document.createElement('button'); delBtn.className = 'footer-copy-btn footer-delete-btn'; delBtn.type = 'button'; delBtn.title = 'Delete image'; delBtn.innerHTML = ''; delBtn.addEventListener('click', async (e) => { e.stopPropagation(); const ok = await uiModule.styledConfirm('Delete this image?', { confirmText: 'Delete', cancelText: 'Cancel', danger: true, }); if (!ok) return; // If we have a gallery id, delete server-side; otherwise just remove // the bubble from chat (e.g. external DALL-E url that wasn't saved). if (imageId) { try { const res = await fetch(`/api/gallery/${encodeURIComponent(imageId)}`, { method: 'DELETE', credentials: 'same-origin', }); if (!res.ok && res.status !== 404) { uiModule.showToast?.('Delete failed', 4000); return; } window.dispatchEvent(new CustomEvent('gallery-refresh')); } catch (_) { uiModule.showToast?.('Delete failed', 4000); return; } } wrap.remove(); }); actions.appendChild(delBtn); footer.appendChild(actions); const metrics = document.createElement('span'); metrics.className = 'response-metrics'; const parts = []; if (model) parts.push(model.split('/').pop()); if (size) parts.push(size); if (quality) parts.push(quality); const cost = getImageCost(model, quality, size); if (cost !== null) parts.push('$' + (cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3))); metrics.textContent = parts.join(' \u00B7 '); footer.appendChild(metrics); wrap.appendChild(footer); return wrap; } export function hideWelcomeScreen() { const ws = document.getElementById('welcome-screen'); const cc = document.getElementById('chat-container'); if (ws) ws.classList.add('hidden'); if (cc) cc.classList.remove('welcome-active'); // Update send button — switches from muted arrow to + Chat if (window._updateSendBtnIcon) setTimeout(window._updateSendBtnIcon, 50); const ib = document.getElementById('incognito-btn'); if (ib) ib.style.display = ib.classList.contains('active') ? '' : 'none'; } export function showWelcomeScreen() { const ws = document.getElementById('welcome-screen'); const cc = document.getElementById('chat-container'); if (ws) ws.classList.remove('hidden'); if (cc) cc.classList.add('welcome-active'); // Entering the New Chat / welcome state: discard any stale draft left in the // composer from the previous session so the input starts empty (issue #1343). // Switching between existing sessions loads them directly and does NOT call // this, so genuine drafts are not erased. Reset the autosized height and fire // an `input` event so the send button + autosize listeners update. const _msg = document.getElementById('message'); if (_msg) { _msg.value = ''; _msg.style.height = ''; _msg.dispatchEvent(new Event('input', { bubbles: true })); } // Re-trigger the L→R clip-wipe reveal on the welcome name each time the // welcome screen is shown (new session, deleted last session, etc.) — without // this, the CSS animation only fires on initial DOM insertion. const wn = document.querySelector('.welcome-name'); if (wn) { wn.style.animation = 'none'; // force reflow so the next assignment registers as a new animation void wn.offsetHeight; wn.style.animation = ''; } // Update send button — switches from + Chat to muted arrow on empty session if (window._updateSendBtnIcon) setTimeout(window._updateSendBtnIcon, 50); const ib = document.getElementById('incognito-btn'); const _researchChk = document.getElementById('research-toggle'); if (ib && !(_researchChk && _researchChk.checked)) ib.style.display = ''; if (window.innerWidth > 768) { const msg = document.getElementById('message'); if (msg) msg.focus(); } } // ── Dynamic action buttons (show 3 most recent, rest under ···) ── const _ACTION_RECENTS_KEY = 'odysseus-msg-actions-recent'; const _MAX_VISIBLE = 2; function _getRecentActions() { try { return JSON.parse(localStorage.getItem(_ACTION_RECENTS_KEY) || '[]'); } catch { return []; } } function _trackAction(id) { let recent = _getRecentActions().filter(x => x !== id); recent.unshift(id); if (recent.length > 10) recent.length = 10; localStorage.setItem(_ACTION_RECENTS_KEY, JSON.stringify(recent)); } /** * Create a footer row for an AI message with timestamp and action buttons. */ export function createMsgFooter(msgElement) { const footer = document.createElement('div'); footer.className = 'msg-footer'; const actions = document.createElement('span'); actions.className = 'msg-actions'; // Define all available actions: { id, icon, title, className, handler } const allActions = [ { id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) { e.stopPropagation(); const btn = e.currentTarget; uiModule.copyToClipboard(msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || ''); btn.innerHTML = CHECK_ICON; setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500); }}, { id: 'edit', icon: '\u270E', title: 'Edit', cls: 'msg-action-btn', handler(e) { e.stopPropagation(); if (window.chatModule?.editAIMessage) window.chatModule.editAIMessage(msgElement); }}, { id: 'regen', icon: '\u21BB', title: 'Regenerate from here', cls: 'msg-action-btn', handler(e) { e.stopPropagation(); if (window.chatModule?.regenerateFrom) window.chatModule.regenerateFrom(msgElement); }}, { id: 'shorten', icon: '\u2702', title: 'Rewrite shorter', cls: 'msg-action-btn', handler(e) { e.stopPropagation(); if (window.chatModule?.rewriteWith) window.chatModule.rewriteWith(msgElement, 'Rewrite your last response to be shorter and more concise. Keep the key information but cut the fluff.'); }}, { id: 'explain', icon: '?', title: 'Explain simpler', cls: 'msg-action-btn', handler(e) { e.stopPropagation(); if (window.chatModule?.rewriteWith) window.chatModule.rewriteWith(msgElement, 'Explain your last response in simpler terms. Use plain language and short sentences.'); }}, { id: 'fork', icon: '\u2ADD', title: 'Fork conversation', cls: 'msg-action-btn', handler(e) { e.stopPropagation(); if (window.chatModule?.forkFrom) window.chatModule.forkFrom(msgElement); }}, { id: 'delete', icon: '\u2715', title: 'Delete message', cls: 'msg-action-btn msg-delete-btn', handler(e) { e.stopPropagation(); if (window.chatModule?.deleteMessage) window.chatModule.deleteMessage(msgElement); }}, ]; // Filter out unavailable actions (e.g. TTS when not enabled) const availableActions = allActions.filter(a => !a.available || a.available()); // Determine which 3 to show: use recent order, fallback to defaults const recent = _getRecentActions(); const defaults = ['copy', 'delete', 'fork']; const order = recent.length > 0 ? recent : defaults; const sorted = [...availableActions].sort((a, b) => { const ai = order.indexOf(a.id), bi = order.indexOf(b.id); if (ai >= 0 && bi >= 0) return ai - bi; if (ai >= 0) return -1; if (bi >= 0) return 1; return 0; }); const visible = sorted.slice(0, _MAX_VISIBLE); const overflow = sorted.slice(_MAX_VISIBLE); // Render visible buttons function _addBtn(action, container) { const btn = _makeActionBtn(action.cls, action.title, action.html ? '' : action.icon, (e) => { _trackAction(action.id); action.handler(e); }); if (action.html) btn.innerHTML = action.icon; btn.dataset.action = action.id; container.appendChild(btn); } visible.forEach(a => _addBtn(a, actions)); // Overflow "···" button if (overflow.length > 0) { const moreBtn = document.createElement('button'); moreBtn.className = 'msg-action-btn msg-more-btn'; moreBtn.type = 'button'; moreBtn.title = 'More actions'; moreBtn.textContent = '\u00B7\u00B7\u00B7'; moreBtn.addEventListener('click', (e) => { e.stopPropagation(); // Toggle overflow menu — close any existing one first (through its own // dismiss so the Escape registry entry goes with it). const existing = document.querySelector('.msg-overflow-menu'); if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); if (existing._trigger === moreBtn) return; } const menu = document.createElement('div'); menu.className = 'msg-overflow-menu'; let closeMenu = () => menu.remove(); overflow.forEach(a => { const item = document.createElement('button'); item.className = 'msg-overflow-item'; item.type = 'button'; item.title = a.title; item.innerHTML = `${a.icon} ${a.title}`; item.addEventListener('click', (ev) => { ev.stopPropagation(); _trackAction(a.id); closeMenu(); a.handler(ev); }); menu.appendChild(item); }); menu._trigger = moreBtn; document.body.appendChild(menu); // Position fixed relative to the ··· button const btnRect = moreBtn.getBoundingClientRect(); menu.style.top = (btnRect.top - menu.offsetHeight - 4) + 'px'; menu.style.left = btnRect.left + 'px'; // Flip down if above viewport if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px'; // Keep within right edge const mr = menu.getBoundingClientRect(); if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px'; // Close on outside click or Escape. The trigger button is treated as // "inside" so its own click toggles rather than double-fires. closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); }); actions.appendChild(moreBtn); } // Memory-used indicator pill const mems = msgElement._memoriesUsed; if (mems && mems.length > 0) { const pill = document.createElement('button'); pill.className = 'memory-used-pill'; pill.type = 'button'; const pinnedCount = mems.filter(m => m.type === 'pinned').length; const recalledCount = mems.filter(m => m.type === 'recalled').length; const parts = []; if (pinnedCount) parts.push(`${pinnedCount} pinned`); if (recalledCount) parts.push(`${recalledCount} recalled`); pill.innerHTML = `${parts.join(', ')}`; pill.title = mems.map(m => `[${m.type}] ${m.text}`).join('\n'); pill.addEventListener('click', (e) => { e.stopPropagation(); let detail = pill._openDetail || document.querySelector('.memory-used-detail'); if (detail) { if (typeof detail._dismiss === 'function') detail._dismiss(); else { detail.remove(); pill._openDetail = null; } return; } detail = document.createElement('div'); detail.className = 'memory-used-detail'; let closeDetail = () => { detail.remove(); pill._openDetail = null; }; mems.forEach(m => { const row = document.createElement('div'); row.className = 'memory-used-row'; row.style.cursor = 'pointer'; row.title = 'Click to open memory manager'; const badge = document.createElement('span'); badge.className = 'memory-used-badge ' + (m.type === 'pinned' ? 'pinned' : 'recalled'); badge.textContent = m.type === 'pinned' ? '\u25CF' : '\u21BB'; const text = document.createElement('span'); text.className = 'memory-used-text'; text.textContent = m.text; row.appendChild(badge); row.appendChild(text); row.addEventListener('click', (ev) => { ev.stopPropagation(); closeDetail(); const memModal = document.getElementById('memory-modal'); if (memModal) memModal.classList.remove('hidden'); }); detail.appendChild(row); }); detail.style.visibility = 'hidden'; document.body.appendChild(detail); const pillRect = pill.getBoundingClientRect(); const detailRect = detail.getBoundingClientRect(); const spaceAbove = pillRect.top; const spaceBelow = window.innerHeight - pillRect.bottom; if (spaceAbove >= detailRect.height + 8 || spaceAbove > spaceBelow) { detail.style.top = (pillRect.top - detailRect.height - 8) + 'px'; } else { detail.style.top = (pillRect.bottom + 8) + 'px'; } detail.style.left = pillRect.left + 'px'; if (pillRect.left + detailRect.width > window.innerWidth - 8) { detail.style.left = (window.innerWidth - detailRect.width - 8) + 'px'; } if (parseFloat(detail.style.left) < 8) detail.style.left = '8px'; detail.style.visibility = ''; pill._openDetail = detail; // Close on outside click or Escape (pill click toggles, so it's inside). closeDetail = bindMenuDismiss(detail, () => { detail.remove(); pill._openDetail = null; }, (ev) => !detail.contains(ev.target) && ev.target !== pill); }); footer.appendChild(pill); } footer.appendChild(actions); return footer; } /** * Create a footer row for a user message with action buttons (same system as AI footer). */ const _USER_ACTION_RECENTS_KEY = 'odysseus-user-actions-recent'; function _getUserRecentActions() { try { return JSON.parse(localStorage.getItem(_USER_ACTION_RECENTS_KEY) || '[]'); } catch { return []; } } function _trackUserAction(id) { let recent = _getUserRecentActions().filter(x => x !== id); recent.unshift(id); if (recent.length > 10) recent.length = 10; localStorage.setItem(_USER_ACTION_RECENTS_KEY, JSON.stringify(recent)); } export function createUserMsgFooter(msgElement) { const footer = document.createElement('div'); footer.className = 'msg-footer'; const actions = document.createElement('span'); actions.className = 'msg-actions'; const allActions = [ { id: 'edit', icon: '\u270E', title: 'Edit message', cls: 'msg-action-btn', handler(e) { e.stopPropagation(); if (window.chatModule?.editUserMessage) window.chatModule.editUserMessage(msgElement); }}, { id: 'delete', icon: '\u2715', title: 'Delete message', cls: 'msg-action-btn msg-delete-btn', handler(e) { e.stopPropagation(); if (window.chatModule?.deleteMessage) window.chatModule.deleteMessage(msgElement); }}, { id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) { e.stopPropagation(); const btn = e.currentTarget; uiModule.copyToClipboard(msgElement.querySelector('.body')?.textContent || ''); btn.innerHTML = CHECK_ICON; setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500); }}, { id: 'resend', icon: '\u21BB', title: 'Resend message', cls: 'msg-action-btn', handler(e) { e.stopPropagation(); if (window.chatModule?.resendUserMessage) window.chatModule.resendUserMessage(msgElement); }}, ]; const recent = _getUserRecentActions(); const defaults = ['edit', 'delete', 'copy']; const order = recent.length > 0 ? recent : defaults; const sorted = [...allActions].sort((a, b) => { const ai = order.indexOf(a.id), bi = order.indexOf(b.id); if (ai >= 0 && bi >= 0) return ai - bi; if (ai >= 0) return -1; if (bi >= 0) return 1; return 0; }); const visible = sorted.slice(0, _MAX_VISIBLE); const overflow = sorted.slice(_MAX_VISIBLE); visible.forEach(a => { const btn = _makeActionBtn(a.cls, a.title, a.html ? '' : a.icon, (ev) => { _trackUserAction(a.id); a.handler(ev); }); if (a.html) btn.innerHTML = a.icon; btn.dataset.action = a.id; actions.appendChild(btn); }); if (overflow.length > 0) { const moreBtn = document.createElement('button'); moreBtn.className = 'msg-action-btn msg-more-btn'; moreBtn.type = 'button'; moreBtn.title = 'More actions'; moreBtn.textContent = '\u00B7\u00B7\u00B7'; moreBtn.addEventListener('click', (e) => { e.stopPropagation(); const existing = document.querySelector('.msg-overflow-menu'); if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); if (existing._trigger === moreBtn) return; } const menu = document.createElement('div'); menu.className = 'msg-overflow-menu'; let closeMenu = () => menu.remove(); overflow.forEach(a => { const item = document.createElement('button'); item.className = 'msg-overflow-item'; item.type = 'button'; item.title = a.title; item.innerHTML = `${a.icon} ${a.title}`; item.addEventListener('click', (ev) => { ev.stopPropagation(); _trackUserAction(a.id); closeMenu(); a.handler(ev); }); menu.appendChild(item); }); menu._trigger = moreBtn; document.body.appendChild(menu); const btnRect = moreBtn.getBoundingClientRect(); menu.style.top = (btnRect.top - menu.offsetHeight - 4) + 'px'; menu.style.left = btnRect.left + 'px'; if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px'; const mr = menu.getBoundingClientRect(); if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px'; closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); }); actions.appendChild(moreBtn); } footer.appendChild(actions); return footer; } /** * Display performance metrics for a message. */ export function displayMetrics(messageElement, metrics) { const existingMetrics = messageElement.querySelector('.response-metrics'); if (existingMetrics) existingMetrics.remove(); const metricsContainer = document.createElement('span'); metricsContainer.className = 'response-metrics'; const responseTime = metrics.response_time; const inputTokens = metrics.input_tokens || 0; const outputTokens = metrics.output_tokens || 0; const tps = metrics.tokens_per_second; const isReal = metrics.usage_source === 'real'; const ctxPct = metrics.context_percent; const model = metrics.model || 'Unknown'; const cost = _billableCost(model, inputTokens, outputTokens); // Nothing useful to show — bail out (only if ALL metrics are missing) if (!responseTime && !outputTokens && tps == null && !ctxPct) return; // Accumulate session cost (only on fresh metrics, not history reload) if (!metrics._fromHistory) { const _sid = window.sessionModule && window.sessionModule.getCurrentSessionId(); if (_sid && cost !== null) { try { const _costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); _costs[_sid] = (_costs[_sid] || 0) + cost; localStorage.setItem(_COST_KEY, JSON.stringify(_costs)); } catch (_e) { /* ignore */ } updateSessionCostUI(); } } // Default: show tok/s if available, else fall back to other stats const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null; const metricsLabel = tps != null && tps !== 'undefined' ? `${tps} tok/s` : costStr0 ? `${outputTokens} tok · ${costStr0}` : outputTokens ? `${outputTokens} tok · ${responseTime != null ? responseTime + 's' : ''}` : responseTime != null ? `${responseTime}s` : ''; if (!metricsLabel) return; metricsContainer.textContent = metricsLabel; metricsContainer.style.cursor = 'pointer'; metricsContainer.title = 'Click for details'; const metricsDivider = document.createElement('span'); metricsDivider.textContent = ' | '; metricsDivider.style.color = 'var(--color-muted-alt)'; metricsDivider.style.pointerEvents = 'none'; metricsContainer.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); }); const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : 'n/a'; const speedStr = tps != null && tps !== 'undefined' ? `${tps} tok/s` : 'n/a'; const totalTok = inputTokens + outputTokens; const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--color-muted-alt, #6b7280)'; const prepTime = metrics.agent_prep_time; const modelWaitTime = metrics.agent_model_wait_time; const prepBreakdown = metrics.agent_prep_breakdown || null; const prepDetails = prepBreakdown ? Object.entries(prepBreakdown).map(([k, v]) => `${k}: ${v}s`).join('
') : ''; // Session total cost let sessionCostStr = ''; const sc = getSessionCost(); if (sc > 0) { sessionCostStr = `
Session $${sc < 0.01 ? sc.toFixed(4) : sc.toFixed(3)}
`; } const popup = document.createElement('div'); popup.className = 'ctx-popup'; popup.innerHTML = `
Message Stats
Model ${model.split('/').pop()}
Input ${inputTokens.toLocaleString()} tokens${isReal ? '' : '~'}
Output ${outputTokens.toLocaleString()} tokens${isReal ? '' : '~'}
Total ${totalTok.toLocaleString()} tokens
Speed ${speedStr}
Time ${responseTime}s
${prepTime != null ? `
Prep ${prepTime}s
` : ''} ${modelWaitTime != null ? `
Model wait ${modelWaitTime}s
` : ''}
Cost ${costStr}
${sessionCostStr} ${prepDetails ? `
Agent prep
${prepDetails}
` : ''} ${ctxPct !== undefined && ctxPct > 0 ? `
Context ${ctxPct}% used
` : ''} ${isReal ? '' : '
~ estimated token count
'} `; const rect = metricsContainer.getBoundingClientRect(); popup.style.left = rect.left + 'px'; popup.style.visibility = 'hidden'; document.body.appendChild(popup); const pr = popup.getBoundingClientRect(); const spaceAbove = rect.top; const spaceBelow = window.innerHeight - rect.bottom; if (spaceAbove >= pr.height + 8 || spaceAbove > spaceBelow) { popup.style.top = (rect.top - pr.height - 8) + 'px'; } else { popup.style.top = (rect.bottom + 8) + 'px'; } if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px'; if (parseFloat(popup.style.left) < 8) popup.style.left = '8px'; popup.style.visibility = ''; bindMenuDismiss(popup, () => popup.remove()); }); // Store real context length for model info popup if (metrics.context_length && metrics.model) { if (!window._realContextLengths) window._realContextLengths = {}; window._realContextLengths[metrics.model] = metrics.context_length; } // Context usage ring let ctxRing = null; const ctxLen = metrics.context_length || 0; if (ctxPct !== undefined && ctxPct > 0) { const r = 6, stroke = 1.5; const circ = 2 * Math.PI * r; const fill = circ * (ctxPct / 100); const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--green, #98c379)'; ctxRing = document.createElement('span'); ctxRing.className = 'ctx-ring'; ctxRing.title = `${ctxPct}% context used — click for details`; ctxRing.style.cursor = 'pointer'; ctxRing.style.setProperty('--ctx-color', ctxColor); ctxRing.innerHTML = ` ${Math.round(ctxPct)}%`; ctxRing.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.ctx-detail-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); }); const usedTokens = inputTokens || 0; const totalCtx = ctxLen || 0; const modelShort = model.split('/').pop(); const fmtNum = n => n ? n.toLocaleString() : '?'; const popup = document.createElement('div'); popup.className = 'ctx-detail-popup'; popup.innerHTML = `
Context Window
${fmtNum(usedTokens)} used ${fmtNum(totalCtx)} total
Model ${modelShort}
Usage ${ctxPct}%
Window ${fmtNum(totalCtx)} tokens
${ctxPct >= 70 ? `` : ''} `; const compactBtn = popup.querySelector('.ctx-compact-btn'); if (compactBtn) { compactBtn.addEventListener('click', async (e) => { e.stopPropagation(); const sid = window.sessionModule && window.sessionModule.getCurrentSessionId(); if (!sid) return; popup.remove(); // Add a spinner bubble at the bottom of chat const chatBox = document.getElementById('chat-history'); if (!chatBox) return; const compactMsg = document.createElement('div'); compactMsg.className = 'msg msg-ai'; const compactRole = document.createElement('div'); compactRole.className = 'role'; compactRole.textContent = 'Odysseus'; const compactBody = document.createElement('div'); compactBody.className = 'body'; compactBody.innerHTML = 'Compacting context ▁▂▃▅▂▁'; compactMsg.appendChild(compactRole); compactMsg.appendChild(compactBody); chatBox.appendChild(compactMsg); chatBox.scrollTop = chatBox.scrollHeight; // Animate the wave const waveFrames = ['▁▂▃▅▂▁', '▂▃▅▃▂▁', '▃▅▃▂▁▂', '▅▃▂▁▂▃', '▃▂▁▂▃▅', '▂▁▂▃▅▃']; let frame = 0; const waveEl = compactBody.querySelector('.compact-wave'); const waveInterval = setInterval(() => { frame = (frame + 1) % waveFrames.length; if (waveEl) waveEl.textContent = waveFrames[frame]; }, 150); try { const res = await fetch(window.location.origin + '/api/session/' + sid + '/compact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); clearInterval(waveInterval); if (res.ok) { const data = await res.json(); // Reload session — the compacted history will show if (window.sessionModule) await window.sessionModule.selectSession(sid); // Scroll to the compacted message (first msg with compacted metadata) setTimeout(() => { const msgs = document.querySelectorAll('#chat-history .msg'); for (const m of msgs) { if (m.querySelector('.body')?.textContent.includes('Conversation compacted')) { m.scrollIntoView({ behavior: 'smooth', block: 'center' }); break; } } }, 200); } else { compactBody.innerHTML = 'Compaction failed. Try again later.'; } } catch (err) { clearInterval(waveInterval); console.warn('compact failed:', err); compactBody.innerHTML = 'Compaction failed: ' + err.message + ''; } }); } const rect = ctxRing.getBoundingClientRect(); popup.style.visibility = 'hidden'; document.body.appendChild(popup); const pr = popup.getBoundingClientRect(); // Position above the ring, right-aligned popup.style.left = Math.max(8, rect.right - pr.width) + 'px'; const spaceAbove = rect.top; if (spaceAbove >= pr.height + 8) { popup.style.top = (rect.top - pr.height - 8) + 'px'; } else { popup.style.top = (rect.bottom + 8) + 'px'; } popup.style.visibility = ''; bindMenuDismiss(popup, () => popup.remove(), (ev) => !popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target)); }); } let footer = messageElement.querySelector('.msg-footer'); if (footer) { const actions = footer.querySelector('.msg-actions'); if (actions) { footer.insertBefore(metricsDivider, actions); footer.insertBefore(metricsContainer, metricsDivider); } else { footer.appendChild(metricsContainer); footer.appendChild(metricsDivider); } if (ctxRing) { const ctxDiv = document.createElement('span'); ctxDiv.textContent = ' | '; ctxDiv.style.color = 'var(--color-muted-alt)'; ctxDiv.style.pointerEvents = 'none'; ctxDiv.className = 'ctx-divider'; footer.appendChild(ctxDiv); footer.appendChild(ctxRing); } } else { messageElement.appendChild(metricsContainer); if (ctxRing) messageElement.appendChild(ctxRing); } if (uiModule) uiModule.scrollHistory(); } /** * Add a message to the chat history. */ export function addMessage(role, content, modelName, metadata) { try { hideWelcomeScreen(); const box = document.getElementById('chat-history'); if (!box) { console.error('Chat history element not found'); return; } var esc = uiModule.esc; const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content; // --- Agent multi-bubble reconstruction from saved metadata --- if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) { const roundTexts = metadata.round_texts || []; const toolEvents = metadata.tool_events; let lastWrap = null; let firstMsgAi = null; let lastMsgAi = null; const toolsByRound = {}; for (const ev of toolEvents) { const r = ev.round || 1; if (!toolsByRound[r]) toolsByRound[r] = []; toolsByRound[r].push(ev); } const maxRound = Math.max(...Object.keys(toolsByRound).map(Number), roundTexts.length); for (let r = 0; r < maxRound; r++) { const roundNum = r + 1; const txt = (roundTexts[r] || '').trim(); if (txt) { const wrap = document.createElement('div'); wrap.className = 'msg msg-ai' + (r > 0 ? ' msg-continuation' : ''); const roleEl = document.createElement('div'); roleEl.className = 'role'; const contModel = modelName || metadata?.model; roleEl.textContent = shortModel(contModel); applyModelColor(roleEl, contModel); if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp)); wrap.appendChild(roleEl); const body = document.createElement('div'); body.className = 'body'; // Check if this is the last text round — sources go on top of final response var agentSourcesPrefix = ''; var isLastTextRound = true; for (let rr = r + 1; rr < maxRound; rr++) { if ((roundTexts[rr] || '').trim()) { isLastTextRound = false; break; } } var agentFindingsSuffix = ''; if (isLastTextRound && metadata?.web_sources?.length) { agentSourcesPrefix = buildSourcesBox(metadata.web_sources, 'web'); } else if (isLastTextRound && metadata?.research_sources?.length) { agentSourcesPrefix = buildSourcesBox(metadata.research_sources, 'research'); } if (isLastTextRound && metadata?.research_findings?.length) { agentFindingsSuffix = buildFindingsBox(metadata.research_findings); } // RAG document sources — restored on the final text round. if (isLastTextRound && metadata?.rag_sources?.length) { agentFindingsSuffix += buildRagSourcesBox(metadata.rag_sources); } body.innerHTML = agentSourcesPrefix + markdownModule.processWithThinking(markdownModule.squashOutsideCode(txt)) + agentFindingsSuffix; wrap.appendChild(body); wrap.dataset.raw = txt; if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id; box.appendChild(wrap); lastWrap = wrap; if (!firstMsgAi) firstMsgAi = wrap; lastMsgAi = wrap; } const roundTools = toolsByRound[roundNum] || []; if (roundTools.length > 0) { // Reuse previous thread if no text separated us (merge consecutive tool rounds) let threadWrap = null; if (!txt && lastWrap && lastWrap.classList.contains('agent-thread')) { threadWrap = lastWrap; } else { threadWrap = document.createElement('div'); threadWrap.className = 'agent-thread'; // Extend line up if there's a chat bubble above if (txt) threadWrap.classList.add('has-top'); box.appendChild(threadWrap); } for (const ev of roundTools) { const ok = (ev.exit_code === 0 || ev.exit_code == null); let outHtml = ''; if (ev.output && ev.output.trim()) { outHtml = `
Output
${esc(ev.output)}
`; } const screenshotSrc = safeToolScreenshotSrc(ev.screenshot); if (screenshotSrc) { outHtml += `
Screenshot
`; } // File-write/edit diff (persisted in the tool event) \u2014 re-render it // so it survives reload, matching the live stream. let evDiffHtml = ''; if (ev.diff && ev.diff.text) { const d = ev.diff; const stat = [ d.new_file ? 'new' : '', d.added ? `+${d.added}` : '', d.removed ? `\u2212${d.removed}` : '', ].filter(Boolean).join(' '); const rows = d.text.split('\n').map(line => { let cls = 'diff-ctx', text = line; if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta'; else if (line.startsWith('@@')) cls = 'diff-hunk'; // Drop the leading diff marker (+/-/space) — colour encodes add/del. else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); } else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); } else if (line.startsWith(' ')) { text = line.slice(1); } return `${esc(text) || ' '}`; }).join(''); // spans are display:block \u2014 a literal \n would double-space evDiffHtml = `
${esc(d.file || 'diff')} ${stat}
${rows}
`; } const node = document.createElement('div'); node.className = 'agent-thread-node' + (ok ? '' : ' error'); // Hide the raw JSON command when a diff says it better (same as live). const evCmdHtml = (ev.command && !(ev.diff && ev.diff.text)) ? `
${esc(ev.command)}
` : ''; node.innerHTML = `
${ok ? '\u2713' : '\u2717'}${esc(ev.tool)}${ok ? 'done' : 'failed'}\u25B6
${evCmdHtml}${outHtml}${evDiffHtml}
`; // Click handling is delegated globally \u2014 see chat.js init. threadWrap.appendChild(node); } // Check if next round has text — extend line down to connect const nextTxt = (roundTexts[r + 1] || '').trim(); if (nextTxt) threadWrap.classList.add('has-bottom'); lastWrap = threadWrap; for (const ev of roundTools) { if (ev.image_url) { box.appendChild(buildImageBubble(ev.image_url, ev.image_prompt, ev.image_model, ev.image_size, ev.image_quality, ev.image_id)); } } } } const firstWrap = lastMsgAi || lastWrap; if (firstWrap && firstWrap.classList.contains('msg-ai')) { if (metadata?.memories_used?.length) firstWrap._memoriesUsed = metadata.memories_used; firstWrap.appendChild(createMsgFooter(firstWrap)); if (metadata) displayMetrics(firstWrap, metadata); } if (window.hljs) { box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b)); } if (markdownModule.renderMermaid) markdownModule.renderMermaid(box); return lastWrap; } // --- Standard single-bubble message --- const wrap = document.createElement('div'); wrap.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai'); const r = document.createElement('div'); r.className = 'role'; const isSlash = metadata?.source === 'slash'; const isCompacted = metadata?.compacted; const resolvedModel = modelName || metadata?.model; var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : shortModel(resolvedModel); if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) { _roleText += ' (Research)'; } if (metadata?.group_model && role !== 'user') { _roleText = metadata.group_model; } else if (metadata?.character_name && role !== 'user' && !isSlash && !isCompacted) { _roleText = metadata.character_name; } r.textContent = _roleText; if (role !== 'user') { if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel); r.appendChild(roleTimestamp(metadata?.timestamp)); } const b = document.createElement('div'); b.className = 'body'; let text = markdownModule.squashOutsideCode(stripToolBlocks(textRaw || '')); // For user messages, pull out vision-model image descriptions ([Image: name]\n // ) into a collapsible "image description" section. Done for // ALL user messages (not just ones with attachment metadata) so it rebuilds // from the stored text even after a browser restart drops the cached attachments. const attachments = metadata?.attachments; const _visionBlocks = []; if (role === 'user') { text = text.replace( /\n*\[Image: ([^\]]+)\]\n([\s\S]*?)(?=\n*\[Image: |\n*\[Image attached: |\n*=== File: |\n*\[PDF content\]:|$)/g, (_m, name, desc) => { const d = desc.trim(); if (d) _visionBlocks.push({ name: name, desc: d }); return ''; } ); } // With attachments present, also strip the embedded file/PDF/image-marker text. if (role === 'user' && attachments?.length) { // Strip === File: ... === blocks, [PDF content]: blocks, and [Image attached: ...] lines text = text .replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+```[\s\S]*?```/g, '') .replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+[\s\S]*?(?=\n*=== File:|$)/g, '') .replace(/\n*\[PDF content\]:[\s\S]*?(?=\n*\[PDF content\]|\n*=== File:|$)/g, '') .replace(/\n*\[Image attached: [^\]]+\]/g, '') .replace(/\n*\[Attached (?:document|non-text) file\]/g, '') .trim(); } wrap.dataset.raw = text; if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id; // Prepend sources box if saved in metadata var sourcesPrefix = ''; var findingsSuffix = ''; if (role === 'assistant' && metadata?.research_sources?.length) { sourcesPrefix = buildSourcesBox(metadata.research_sources, 'research'); } else if (role === 'assistant' && metadata?.web_sources?.length) { sourcesPrefix = buildSourcesBox(metadata.web_sources, 'web'); } if (role === 'assistant' && metadata?.research_findings?.length) { findingsSuffix = buildFindingsBox(metadata.research_findings); } // RAG document sources — restored from metadata so they survive refresh. if (role === 'assistant' && metadata?.rag_sources?.length) { findingsSuffix += buildRagSourcesBox(metadata.rag_sources); } // If thinking is stored in metadata (not in text), reconstruct the full display if (role === 'assistant' && metadata?.thinking) { const thinkTime = metadata.thinking_time || null; const thinkHtml = markdownModule.processWithThinking( '' + metadata.thinking + '\n\n' + text ); b.innerHTML = sourcesPrefix + thinkHtml + findingsSuffix; } else { b.innerHTML = sourcesPrefix + markdownModule.processWithThinking(text) + findingsSuffix; } // The vision/OCR caption is stripped from the displayed text above (so the // bubble doesn't show the raw model output) but no longer rendered as an // inline collapsible — the user can still view/edit it via the "Caption" // button on the photo thumbnail. _visionBlocks is intentionally left unused // so the parsing-and-strip side-effect on `text` still happens. void _visionBlocks; // Add "Open Visual Report" button for persisted research messages if (role === 'assistant' && metadata?.research) { var _sid = window.sessionModule?.getCurrentSessionId?.(); if (_sid) _appendReportButton(b, _sid); } // Style [Doc edit: ...] prefix in user messages if (role === 'user') { // Match compact format: [Doc edit: line X] instruction b.innerHTML = b.innerHTML.replace( /\[Doc edit: (lines? [\d–\-]+)\]\s*/, 'Doc edit: $1 ' ); // Match raw format: "In the document, edit this specific text (line X):\n```\n...\n```\n\nInstruction: ..." // After markdown processing this becomes a

+

 block + 

Instruction: text

const rawDocMatch = b.innerHTML.match(/In the document, edit this specific text \((lines? [\d–\-]+)\)/); if (rawDocMatch) { const lineRef = rawDocMatch[1]; // Extract instruction text (after "Instruction: ") const instrMatch = b.textContent.match(/Instruction:\s*([\s\S]*)$/); const instrText = instrMatch ? instrMatch[1].trim() : ''; b.innerHTML = 'Doc edit: ' + lineRef + ' ' + markdownModule.processWithThinking(instrText); } // Render attachment cards if (attachments?.length) { b.appendChild(buildAttachCards(attachments)); } } wrap.appendChild(r); wrap.appendChild(b); // Add stopped indicator + continue button for messages that were stopped by user if (role === 'assistant' && metadata?.stopped) { const stoppedIndicator = document.createElement('div'); stoppedIndicator.className = 'stopped-indicator'; const stoppedLabel = document.createElement('span'); // Differentiate between "stopped mid-stream" (had content, can continue) // and "cancelled before any content" — the latter has no Continue affordance. stoppedLabel.textContent = metadata.cancelled ? '[Cancelled by user]' : '[Message interrupted]'; stoppedIndicator.appendChild(stoppedLabel); // Continue button only makes sense when there's partial content to // resume from \u2014 skip it for fully-cancelled (empty) turns. if (!metadata.cancelled) { const continueBtn = document.createElement('button'); continueBtn.className = 'continue-btn'; continueBtn.title = 'Continue'; continueBtn.textContent = '\u25B8'; continueBtn.addEventListener('click', () => { stoppedIndicator.remove(); if (window.chatModule) { window.chatModule.setHideUserBubble(); window.chatModule.setPendingContinue(wrap); const rawText = wrap.dataset.raw || wrap.querySelector('.body')?.textContent || ''; const cutoff = rawText; const msgInput = document.getElementById('message'); if (msgInput) { msgInput.value = 'Your previous response was interrupted. It ended with:\n\n' + cutoff.slice(-500) + '\n\nDo NOT repeat what you already said. Continue exactly from where you were cut off.'; const sb = document.querySelector('.send-btn'); if (sb) sb.click(); } } }); stoppedIndicator.appendChild(continueBtn); } b.appendChild(stoppedIndicator); } if (metadata?.edited) { const editedIndicator = document.createElement('div'); editedIndicator.className = 'edited-indicator'; editedIndicator.textContent = '[Message edited]'; b.appendChild(editedIndicator); } // Restore variant navigation from saved metadata if (role === 'assistant' && metadata?.variants && metadata.variants.length > 1) { wrap.dataset.variants = JSON.stringify(metadata.variants); const idx = metadata.variantIndex ?? metadata.variants.length - 1; wrap.dataset.variantIndex = String(idx); // Re-render from `raw` markdown rather than trusting cached `v.html`. // Variants ride through localStorage / chat export-import; cached HTML // would let an attacker-controlled session JSON inject markup. const _renderVariant = (v) => (v && v.raw) ? markdownModule.processWithThinking(markdownModule.squashOutsideCode(v.raw)) : (v && v.html) || ''; // Show the selected variant's content const v = metadata.variants[idx]; if (v) { b.innerHTML = _renderVariant(v); wrap.dataset.raw = v.raw; } // Render nav const nav = document.createElement('span'); nav.className = 'variant-nav'; nav.addEventListener('click', (e) => e.stopPropagation()); const divider = document.createElement('span'); divider.className = 'variant-divider'; divider.textContent = '|'; nav.appendChild(divider); const tagLabel = document.createElement('span'); const _icons = { regen: '\u21BB', shorter: '\u2702', simpler: '?', original: '\u25CB' }; const _tl0 = metadata.variants[idx]?.label; tagLabel.className = 'variant-tag' + (_tl0 === 'shorter' ? ' variant-tag-scissors' : ''); tagLabel.textContent = _icons[_tl0] || ''; nav.appendChild(tagLabel); const prevBtn = document.createElement('button'); prevBtn.className = 'variant-btn'; prevBtn.textContent = '<'; prevBtn.disabled = idx === 0; nav.appendChild(prevBtn); const numLeft = document.createElement('button'); numLeft.className = 'variant-num'; numLeft.textContent = String(idx + 1); numLeft.disabled = idx === 0; nav.appendChild(numLeft); const slash = document.createElement('span'); slash.className = 'variant-slash'; slash.textContent = '/'; nav.appendChild(slash); const numRight = document.createElement('button'); numRight.className = 'variant-num'; numRight.textContent = String(metadata.variants.length); numRight.disabled = idx === metadata.variants.length - 1; nav.appendChild(numRight); const nextBtn = document.createElement('button'); nextBtn.className = 'variant-btn'; nextBtn.textContent = '>'; nextBtn.disabled = idx === metadata.variants.length - 1; nav.appendChild(nextBtn); const switchFn = (newIdx) => { const vars = metadata.variants; if (newIdx < 0 || newIdx >= vars.length) return; const sv = vars[newIdx]; b.innerHTML = _renderVariant(sv); wrap.dataset.raw = sv.raw; wrap.dataset.variantIndex = String(newIdx); if (window.hljs) wrap.querySelectorAll('pre code').forEach(bl => window.hljs.highlightElement(bl)); tagLabel.textContent = _icons[sv.label] || ''; tagLabel.className = 'variant-tag' + (sv.label === 'shorter' ? ' variant-tag-scissors' : ''); numLeft.textContent = String(newIdx + 1); numLeft.disabled = newIdx === 0; numRight.disabled = newIdx === vars.length - 1; prevBtn.disabled = newIdx === 0; nextBtn.disabled = newIdx === vars.length - 1; }; prevBtn.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) - 1); }); numLeft.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) - 1); }); numRight.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) + 1); }); nextBtn.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) + 1); }); r.appendChild(nav); } if (role === 'assistant') { // The "N pinned" / "N recalled" pill in the footer reads from // wrap._memoriesUsed — propagate it from saved metadata so the pill // survives a page refresh (live-stream path sets it via SSE, but // history reloads need this assignment). if (metadata?.memories_used?.length) wrap._memoriesUsed = metadata.memories_used; wrap.appendChild(createMsgFooter(wrap)); if (metadata) displayMetrics(wrap, metadata); } else { // Add timestamp to user header (like AI messages) r.appendChild(roleTimestamp(metadata?.timestamp)); wrap.appendChild(createUserMsgFooter(wrap)); } box.appendChild(wrap); // TTS is now part of the msg-actions system if (role === 'assistant' && markdownModule.renderMermaid) { markdownModule.renderMermaid(wrap); } return wrap; } catch (error) { console.error('Error in addMessage:', error); if (uiModule) uiModule.showError('Failed to add message: ' + error.message); } } const chatRenderer = { shortModel, modelColor, applyModelColor, getModelCost, getImageCost, getSessionCost, resetSessionCost, updateSessionCostUI, roleTimestamp, stripToolBlocks, safeToolScreenshotSrc, safeDisplayImageSrc, buildSourcesBox, buildFindingsBox, appendReportButton, buildImageBubble, hideWelcomeScreen, showWelcomeScreen, createMsgFooter, displayMetrics, addMessage, updateMessageAttachments, }; export default chatRenderer;