1// static/js/chatRenderer.js 2// Extracted from chat.js â message rendering, sources, images, metrics 3 4import uiModule from './ui.js'; 5import markdownModule from './markdown.js'; 6import { addAITTSButton } from './tts-ai.js'; 7import { providerLogo, providerLabel } from './providers.js'; 8import settingsModule from './settings.js'; 9import spinnerModule from './spinner.js'; 10import { bindMenuDismiss } from './escMenuStack.js'; 11import { matchModelKey } from './model/matchKey.js'; 12 13const SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>'; 14const REPORT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>'; 15const CHAT_ABOUT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'; 16const COPY_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; 17const CHECK_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 18 19/** Sanitize a URL for use in href â only allow http(s) and protocol-relative. */ 20function _safeHref(url) { 21 if (!url) return '#'; 22 try { 23 var parsed = new URL(url, window.location.origin); 24 if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return uiModule.esc(url); 25 } catch(e) { /* invalid URL */ } 26 return '#'; 27} 28 29export function safeToolScreenshotSrc(raw) { 30 const src = String(raw || '').trim(); 31 if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) { 32 return src; 33 } 34 return ''; 35} 36 37export function safeDisplayImageSrc(raw) { 38 const src = String(raw || '').trim(); 39 if (!src) return ''; 40 if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) { 41 return src; 42 } 43 try { 44 const parsed = new URL(src, window.location.origin); 45 if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { 46 return parsed.href; 47 } 48 } catch (_) {} 49 return ''; 50} 51 52function _makeActionBtn(className, title, text, handler) { 53 const btn = document.createElement('button'); 54 btn.className = className; 55 btn.type = 'button'; 56 btn.title = title; 57 btn.textContent = text; 58 btn.addEventListener('click', handler); 59 return btn; 60} 61 62// Attachment card helpers 63function _attachIcon(mimeOrName) { 64 const s = (mimeOrName || '').toLowerCase(); 65 if (s.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(s)) 66 return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>'; 67 if (s.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|webm)$/i.test(s)) 68 return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>'; 69 if (s === 'application/pdf' || /\.pdf$/i.test(s)) 70 return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'; 71 // Default: generic document 72 return '<svg class="attach-card-icon" width="16" height="16" viewB
72ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'; 73} 74function _formatSize(bytes) { 75 if (bytes < 1024) return bytes + ' B'; 76 if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; 77 return (bytes / 1048576).toFixed(1) + ' MB'; 78} 79 80// Build the `.attach-cards` element for a message's attachment list. Shared by 81// addMessage and updateMessageAttachments so a live (optimistic) user bubble 82// can be re-rendered with real upload ids once the upload resolves. 83function buildAttachCards(attachments) { 84 const attachWrap = document.createElement('div'); 85 attachWrap.className = 'attach-cards'; 86 for (const att of attachments) { 87 const isImage = (att.mime || '').startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(att.name || ''); 88 if (isImage) { 89 // Image preview. Shown for both uploaded (att.id present) and still- 90 // uploading attachments. A shimmering skeleton + whirlpool fills the 91 // space until either the upload resolves (no id yet) or the thumbnail 92 // image finishes loading, so the photo doesn't pop in abruptly. 93 const imgWrap = document.createElement('div'); 94 imgWrap.className = 'attach-image-preview'; 95 imgWrap.style.cursor = att.id ? 'zoom-in' : 'default';
96 if (att.id) imgWrap.dataset.fileId = att.id; 97 if (att.id) { 98 imgWrap.addEventListener('click', (e) => { 99 // Tapping the corner OCR button shouldn't also open the lightbox. 100 if (e.target.closest('.attach-ocr-btn')) return; 101 _openImageLightbox(att); 102 }); 103 } 104 105 let skel = null; 106 let sp = null; 107 if (!att.previewUrl) { 108 // Skeleton placeholder with a centered whirlpool. Self-stops when removed. 109 skel = document.createElement('div'); 110 skel.className = 'attach-image-skeleton'; 111 // Match the photo's aspect ratio when the backend knew it at upload 112 // time, so the skeleton doesn't sit at a 4:3 default and then snap to 113 // a portrait shape when the image arrives. 114 if (att.width && att.height) { 115 skel.style.aspectRatio = att.width + ' / ' + att.height; 116 skel.style.width = 'auto'; 117 skel.style.height = 'auto'; 118 skel.style.maxWidth = '300px'; 119 skel.style.maxHeight = '200px'; 120 skel.style.minWidth = '80px'; 121 } 122 sp = spinnerModule.createWhirlpool(20); 123 skel.appendChild(sp.element); 124 imgWrap.appendChild(skel); 125 } 126 127 if (att.id || att.previewUrl) { 128 const img = document.createElement('img'); 129 // Small cached thumbnail â the preview is tiny, no need to pull the 130 // full-resolution photo. Click still opens the full image. 131 img.alt = att.name || 'Image'; 132 img.loading = 'lazy'; 133 img.style.cssText = 'max-width:300px;max-height:200px;border-radius:6px;display:' + (att.previewUrl ? 'block' : 'none') + ';'; 134 let _revealed = false; 135 let _revealTimer = null; 136 const _reveal = () => { 137 if (_revealed) return; 138 _revealed = true; 139 if (_revealTimer) { clearTimeout(_revealTimer); _revealTimer = null; } 140 img.style.display = 'block'; 141 try { sp && sp.stop(); } catch {} 142 if (skel) skel.remove(); 143 }; 144 img.addEventListener('load', _reveal); 145 img.addEventListener('error', _reveal); 146 img.src = att.previewUrl || `/api/upload/${att.id}?thumb=1`; 147 // Cached images can be complete before the load listener attaches. 148 if (img.complete && img.naturalWidth) _reveal(); 149 // Failsafe: if neither load nor error fires within 8s, reveal anyway. 150 // The timer is cleared on reveal AND when updateMessageAttachments 151 // replaces the card (which scrubs the img / skel from the DOM), so 152 // repeated re-renders don't accumulate stranded timers. 153 if (!att.previewUrl) _revealTimer = setTimeout(_reveal, 8000); 154 imgWrap.appendChild(img); 155 156 if (att.id) { 157 // Small corner button â opens the vision/OCR editor so the user can 158 // correct what the vision model extracted. The edit is cached on the 159 // server keyed by file id, so any later message referencing this same 160 // image picks up the corrected text instead of re-running the model. 161 const ocrBtn = document.createElement('button'); 162 ocrBtn.type = 'button'; 163 ocrBtn.className = 'attach-ocr-btn'; 164 ocrBtn.title = 'View / edit OCR text'; 165 ocrBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg><span class="attach-ocr-label">Caption</span>'; 166 ocrBtn.addEventListener('click', (e) => { 167 e.stopPropagation(); 168 _openVisionEditor(att, ocrBtn.closest('.msg')); 169 }); 170 imgWrap.appendChild(ocrBtn); 171 } 172 } 173 174 if (att.vision_model) { 175 const visionLabel = document.createElement('div'); 176 visionLabel.className = 'attach-vision-model'; 177 visionLabel.textContent = 'Vision: ' + String(att.vision_model).split('/').pop(); 178 imgWrap.appendChild(visionLabel); 179 } 180 if (att.name) { 181 const label = document.createElement('div'); 182 label.className = 'attach-image-name'; 183 label.textContent = att.name; 184 imgWrap.appendChild(label); 185 } 186 attachWrap.appendChild(imgWrap); 187 } else { 188 // Non-image file card 189 const card = document.createElement('div'); 190 card.className = 'attach-card'; 191 card.dataset.name = att.name; 192 if (att.id) {
193 card.dataset.fileId = att.id; 194 card.style.cursor = 'pointer'; 195 card.addEventListener('click', () => { 196 // PDFs & text/code/markdown â open in the Documents viewer 197 // (others fall back to the raw file). 198 if (window.chatModule?.openAttachment) window.chatModule.openAttachment(att, false); 199 else window.open(`/api/upload/${att.id}`, '_blank'); 200 }); 201 } 202 const icon = _attachIcon(att.mime || att.name); 203 const nameSpan = document.createElement('span'); 204 nameSpan.className = 'attach-card-name'; 205 nameSpan.textContent = att.name; 206 card.innerHTML = icon; 207 card.appendChild(nameSpan); 208 if (att.size) { 209 const sizeSpan = document.createElement('span'); 210 sizeSpan.className = 'attach-card-size'; 211 sizeSpan.textContent = _formatSize(att.size); 212 card.appendChild(sizeSpan); 213 } 214 attachWrap.appendChild(card); 215 } 216 } 217 return attachWrap; 218} 219 220// Re-render the attachment cards of an already-rendered message. Used to swap 221// in real upload ids (and image thumbnails) on the optimistic user bubble once 222// uploadPending() resolves â otherwise image previews only appear after a 223// refresh, because the bubble is rendered before the upload assigns ids. 224export function updateMessageAttachments(msgWrap, attachments) { 225 if (!msgWrap || !attachments?.length) return; 226 const body = msgWrap.querySelector('.body') || msgWrap; 227 const existing = body.querySelector('.attach-cards'); 228 const fresh = buildAttachCards(attachments); 229 if (existing) existing.replaceWith(fresh); 230 else body.appendChild(fresh); 231} 232 233// Quick full-size preview when the user taps a chat photo thumbnail. Just an 234// overlay with the original image centered â no Gallery panel, no editor. 235function _openImageLightbox(att) { 236 if (!att?.id) return; 237 const overlay = document.createElement('div'); 238 overlay.className = 'attach-lightbox'; 239 // Show the cached thumb immediately so the overlay doesn't sit blank 240 // while a 25MB original streams in. The full image swaps in once loaded; 241 // if the full load fails (404 / network), we keep the thumb + show an 242 // error label rather than a blank overlay forever. 243 const img = document.createElement('img'); 244 img.alt = att.name || ''; 245 img.src = `/api/upload/${att.id}?thumb=1`; 246 overlay.appendChild(img); 247 const full = new Image(); 248 full.addEventListener('load', () => { img.src = full.src; }); 249 full.addEventListener('error', () => { 250 const err = document.createElement('div'); 251 err.className = 'attach-lightbox-err'; 252 err.textContent = 'Failed to load full-resolution image.'; 253 overlay.appendChild(err); 254 }); 255 full.src = `/api/upload/${att.id}`; 256 257 const _onKey = (e) => { if (e.key === 'Escape') _close(); }; 258 const _close = () => { 259 document.removeEventListener('keydown', _onKey); 260 if (_overlayObs) { try { _overlayObs.disconnect(); } catch {} } 261 overlay.remove(); 262 }; 263 // If the overlay is removed via any path other than our close handler 264 // (session switch, parent re-render, external cleanup), still drop the 265 // document-level keydown listener so it doesn't leak. 266 let _overlayObs = null; 267 try { 268 _overlayObs = new MutationObserver(() => { 269 if (!document.body.contains(overlay)) { 270 document.removeEventListener('keydown', _onKey); 271 _overlayObs.disconnect(); 272 } 273 }); 274 _overlayObs.observe(document.body, { childList: true, subtree: false }); 275 } catch {} 276 overlay.addEventListener('click', _close); 277 document.addEventListener('keydown', _onKey); 278 document.body.appendChild(overlay); 279} 280 281// Vision/OCR editor modal â opened from the corner "Aa" button on a chat photo 282// thumbnail. Lets the user view and correct the text the vision model fed to 283// the LLM (e.g. when OCR misreads a word). Persists to the server's vision 284// cache (PUT /api/upload/{id}/vision), so any subsequent message that 285// references the same file picks up the corrected text. 286let _visionEditorEl = null; 287let _visionEditorEsc = null; 288function _closeVisionEditor() { 289 if (_visionEditorEsc) { document.removeEventListener('keydown', _visionEditorEsc); _visionEditorEsc = null; } 290 if (_visionEditorEl) { _visionEditorEl.remove(); _visionEditorEl = null; } 291} 292function _openVisionEditor(att, userMsgEl) { 293 if (!att?.id) return; 294 _closeVisionEditor();
295 const overlay = document.createElement('div'); 296 overlay.className = 'vision-editor-overlay'; 297 overlay.addEventListener('click', (e) => { if (e.target === overlay) _closeVisionEditor(); }); 298 const panel = document.createElement('div'); 299 panel.className = 'vision-editor-panel'; 300 const title = document.createElement('div'); 301 title.className = 'vision-editor-title'; 302 // Eye icon matches the one in Settings â Vision so users recognise where 303 // this text originates. 304 title.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;flex-shrink:0"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span>Vision text</span>'; 305 panel.appendChild(title); 306 const desc = document.createElement('div'); 307 desc.className = 'vision-editor-desc'; 308 desc.textContent = 'Edit text and save, new chats will have the new context. Regenerate or continue from there.'; 309 panel.appendChild(desc); 310 const ta = document.createElement('textarea'); 311 ta.className = 'vision-editor-text'; 312 ta.rows = 10; 313 ta.placeholder = 'Loadingâ¦'; 314 ta.disabled = true; 315 panel.appendChild(ta); 316 const actions = document.createElement('div'); 317 actions.className = 'vision-editor-actions'; 318 const closeBtn = document.createElement('button'); 319 closeBtn.type = 'button'; 320 closeBtn.className = 'vision-editor-btn'; 321 closeBtn.innerHTML = '<span class="vision-btn-label">Close</span>'; 322 closeBtn.addEventListener('click', _closeVisionEditor); 323 const _saveVisionText = async () => { 324 const res = await fetch(`/api/upload/${att.id}/vision`, { 325 method: 'PUT', 326 headers: { 'Content-Type': 'application/json' }, 327 credentials: 'same-origin', 328 body: JSON.stringify({ text: ta.value }), 329 }); 330 if (!res.ok) throw new Error('save failed'); 331 }; 332 const saveBtn = document.createElement('button'); 333 saveBtn.type = 'button'; 334 saveBtn.className = 'vision-editor-btn vision-editor-btn-primary'; 335 saveBtn.innerHTML = '<span class="vision-btn-label">Save</span>'; 336 saveBtn.disabled = true; 337 saveBtn.addEventListener('click', async () => { 338 saveBtn.disabled = true; 339 saveBtn.innerHTML = '<span class="vision-btn-label">Savingâ¦</span>'; 340 try { 341 await _saveVisionText(); 342 if (uiModule?.showToast) uiModule.showToast('Saved'); 343 _closeVisionEditor(); 344 } catch (e) { 345 saveBtn.disabled = false; 346 saveBtn.innerHTML = '<span class="vision-btn-label">Save</span>'; 347 if (uiModule?.showError) uiModule.showError('Failed to save OCR text'); 348 } 349 }); 350 // Regenerate-message: save the edited text, close, then trigger a resend of 351 // the user message so the new AI reply uses the edit immediately. 352 const regenBtn = document.createElement('button'); 353 regenBtn.type = 'button'; 354 regenBtn.className = 'vision-editor-btn vision-editor-btn-primary'; 355 regenBtn.title = 'Save and regenerate the message'; 356 regenBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.74 9.74 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg><span class="vision-btn-label">Regenerate message</span>'; 357 regenBtn.disabled = true; 358 regenBtn.addEventListener('click', async () => { 359 regenBtn.disabled = true; 360 saveBtn.disabled = true; 361 try { 362 await _saveVisionText(); 363 _closeVisionEditor(); 364 if (userMsgEl && window.chatModule?.resendUserMessage) { 365 window.chatModule.resendUserMessage(userMsgEl); 366 } else if (uiModule?.showToast) { 367 uiModule.showToast('Saved'); 368 } 369 } catch (e) { 370 regenBtn.disabled = false; 371 saveBtn.disabled = false; 372 if (uiModule?.showError) uiModule.showError('Failed to save OCR text'); 373 } 374 }); 375 actions.appendChild(closeBtn); 376 actions.appendChild(saveBtn); 377 actions.appendChild(regenBtn); 378 panel.appendChild(actions); 379 overlay.appendChild(panel); 380 document.body.appendChild(overlay); 381 _visionEditorEl = overlay; 382 383 // ESC closes the popup. Registered on document so it works regardless of 384 // focus (the textarea swallows the event otherwise). 385 _visionEditorEsc = (e) => { if (e.key === 'Escape') _closeVisionEditor(); }; 386 document.addEventListener('keydown', _visionEditorEsc); 387 388 fetch(`/api/upload/${att.id}/vision`, { credentials: 'same-origin' }) 389 .then(r => r.ok ? r.json() : Promise.reject(r)) 390 .then(data => { 391 ta.value = data.text || ''; 392 ta.placeholder = ''; 393 ta.disabled = false;
394 saveBtn.disabled = false; 395 regenBtn.disabled = !userMsgEl; 396 ta.focus(); 397 }) 398 .catch(() => { 399 ta.value = ''; 400 ta.placeholder = 'Could not load OCR text â type your correction and save.'; 401 ta.disabled = false; 402 saveBtn.disabled = false; 403 regenBtn.disabled = !userMsgEl; 404 }); 405} 406 407// Tool call syntax patterns to strip from displayed text 408const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi; 409// Only strip fenced tool-call blocks that look like structured invocations, not regular code examples 410const EXEC_FENCE_RE = /```(?:web_search|read_file|write_file|create_document|edit_document|update_document)\s*\n[\s\S]*?```/gi; 411// XML-style tool calls: <minimax:tool_call>, <tool_call>, <function_call>, bare <invoke> 412const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi; 413const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi; 414// DeepSeek "DSML" tool-call markup (fullwidth-pipe ï½ or ascii | delimited) that 415// leaks into content when the model emits a text tool call instead of a native 416// one. Strip the whole block; the second pattern catches stray/partial tags 417// (e.g. mid-stream before the closing tag arrives). 418const DSML_TOOL_RE = /<\s*[ï½|]+\s*DSML\s*[ï½|]+\s*tool_calls\s*>[\s\S]*?(?:<\s*\/\s*[ï½|]+\s*DSML\s*[ï½|]+\s*tool_calls\s*>|$)/gi; 419const DSML_STRAY_RE = /<\s*\/?\s*[ï½|]+\s*DSML\s*[ï½|]+[^>]*>/gi; 420// Self-narration about tool results (model echoing stdout/exit_code) 421const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi; 422 423 424// Model pricing table â per million tokens 425// Model info: pricing (per 1M tokens) + context window length 426const MODEL_INFO = { 427 // --- Anthropic --- 428 'claude-sonnet-4-5': { input: 3.00, output: 15.00, ctx: 200000 }, 429 'claude-sonnet-4-6': { input: 3.00, output: 15.00, ctx: 200000 }, 430 'claude-sonnet-4': { input: 3.00, output: 15.00, ctx: 200000 }, 431 'claude-opus-4': { input: 15.00, output: 75.00, ctx: 200000 }, 432 'claude-opus-4-6': { input: 15.00, output: 75.00, ctx: 200000 }, 433 'claude-haiku-4': { input: 0.80, output: 4.00, ctx: 200000 }, 434 'claude-haiku-3-5': { input: 0.80, output: 4.00, ctx: 200000 }, 435 'claude-3-5-sonnet': { input: 3.00, output: 15.00, ctx: 200000 }, 436 'claude-3-5-haiku': { input: 0.80, output: 4.00, ctx: 200000 }, 437 'claude-3-opus': { input: 15.00, output: 75.00, ctx: 200000 }, 438 'claude-3-sonnet': { input: 3.00, output: 15.00, ctx: 200000 }, 439 'claude-3-haiku': { input: 0.25, output: 1.25, ctx: 200000 }, 440 // --- OpenAI --- 441 'gpt-5': { input: 2.00, output: 8.00, ctx: 400000 }, 442 'gpt-4.1': { input: 2.00, output: 8.00, ctx: 1047576 }, 443 'gpt-4.1-mini': { input: 0.40, output: 1.60, ctx: 1047576 }, 444 'gpt-4.1-nano': { input: 0.10, output: 0.40, ctx: 1047576 }, 445 'gpt-4o': { input: 2.50, output: 10.00, ctx: 128000 }, 446 'gpt-4o-mini': { input: 0.15, output: 0.60, ctx: 128000 }, 447 'gpt-4-turbo': { input: 10.00, output: 30.00, ctx: 128000 }, 448 'o1': { input: 15.00, output: 60.00, ctx: 200000 }, 449 'o1-mini': { input: 3.00, output: 12.00, ctx: 128000 }, 450 'o1-pro': { input: 150.0, output: 600.0, ctx: 200000 }, 451 'o3': { input: 2.00, output: 8.00, ctx: 200000 }, 452 'o3-mini': { input: 1.10, output: 4.40, ctx: 200000 }, 453 'o4-mini': { input: 1.10, output: 4.40, ctx: 200000 }, 454 // --- DeepSeek --- 455 'deepseek-chat': { input: 0.27, output: 1.10, ctx: 64000 }, 456 'deepseek-coder': { input: 0.27, output: 1.10, ctx: 64000 }, 457 'deepseek-reasoner': { input: 0.55, output: 2.19, ctx: 64000 }, 458 'deepseek-r1': { input: 0.55, output: 2.19, ctx: 64000 }, 459 'deepseek-v3': { input: 0.27, output: 1.10, ctx: 64000 }, 460 'deepseek-v2': { input: 0.14, output: 0.28, ctx: 64000 }, 461 // --- Google ---
462 'gemini-2.5-pro': { input: 1.25, output: 10.00, ctx: 1048576 }, 463 'gemini-2.5-flash': { input: 0.15, output: 0.60, ctx: 1048576 }, 464 'gemini-2.0-flash': { input: 0.10, output: 0.40, ctx: 1048576 }, 465 'gemini-1.5-pro': { input: 1.25, output: 5.00, ctx: 1048576 }, 466 'gemini-1.5-flash': { input: 0.075, output: 0.30, ctx: 1048576 }, 467 'gemma-3': { input: 0.10, output: 0.10, ctx: 128000 }, 468 // --- Mistral --- 469 'mistral-large': { input: 2.00, output: 6.00, ctx: 128000 }, 470 'mistral-medium': { input: 2.00, output: 6.00, ctx: 32000 }, 471 'mistral-small': { input: 0.20, output: 0.60, ctx: 32000 }, 472 'mistral-nemo': { input: 0.15, output: 0.15, ctx: 128000 }, 473 'mixtral': { input: 0.24, output: 0.24, ctx: 32000 }, 474 'codestral': { input: 0.30, output: 0.90, ctx: 32000 }, 475 'pixtral': { input: 2.00, output: 6.00, ctx: 128000 }, 476 // --- xAI --- 477 'grok-4': { input: 3.00, output: 15.00, ctx: 131072 }, 478 'grok-3': { input: 3.00, output: 15.00, ctx: 131072 }, 479 'grok-2': { input: 2.00, output: 10.00, ctx: 131072 }, 480 // --- Meta --- 481 'llama-4': { input: 0.20, output: 0.20, ctx: 1048576 }, 482 'llama-3.3': { input: 0.20, output: 0.20, ctx: 131072 }, 483 'llama-3.2': { input: 0.20, output: 0.20, ctx: 131072 }, 484 'llama-3.1': { input: 0.20, output: 0.20, ctx: 131072 }, 485 'llama-3': { input: 0.20, output: 0.20, ctx: 131072 }, 486 // --- Qwen --- 487 'qwen3': { input: 0.30, output: 1.20, ctx: 131072 }, 488 'qwen2.5': { input: 0.30, output: 1.20, ctx: 131072 }, 489 'qwq': { input: 0.30, output: 1.20, ctx: 32768 }, 490 // --- Cohere --- 491 'command-a': { input: 2.50, output: 10.00, ctx: 256000 }, 492 'command-r-plus': { input: 2.50, output: 10.00, ctx: 128000 }, 493 'command-r': { input: 0.15, output: 0.60, ctx: 128000 }, 494 // --- Perplexity --- 495 'sonar-pro': { input: 3.00, output: 15.00, ctx: 200000 }, 496 'sonar': { input: 1.00, output: 1.00, ctx: 128000 }, 497 // --- MiniMax --- 498 'minimax': { input: 0.70, output: 0.70, ctx: 1000000 }, 499 // --- Kimi / Moonshot --- 500 'moonshot': { input: 1.00, output: 1.00, ctx: 128000 }, 501 'kimi': { input: 1.00, output: 1.00, ctx: 128000 }, 502 // --- Microsoft --- 503 'phi-4': { input: 0.07, output: 0.14, ctx: 16000 }, 504 'phi-3': { input: 0.07, output: 0.14, ctx: 128000 }, 505 // --- Nvidia --- 506 'nemotron': { input: 0.30, output: 1.20, ctx: 131072 }, 507 // --- Nous --- 508 'hermes': { input: 0.20, output: 0.20, ctx: 131072 }, 509}; 510 511// Compat alias 512const MODEL_PRICING = MODEL_INFO; 513 514// Image generation cost lookup (per-image, by model à quality à size) 515const IMAGE_PRICING = { 516 '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 } }, 517 '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 } }, 518 '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 } }, 519}; 520 521export function shortModel(name) { 522 if (!name) return '...'; 523 if (typeof name !== 'string') name = String(name); 524 let short = name.split('/').pop(); 525 // Strip .gguf extension 526 short = short.replace(/\.gguf$/i, ''); 527 // Strip quantization suffixes (Q4_K_M, Q8_0, etc.) and shard numbers 528 short = short.replace(/-0000\d-of-\d+$/, ''); 529 short = short.replace(/[-_](Q\d[_A-Z\d]*|F16|F32|BF16|fp16|fp32)$/i, ''); 530 // Truncate if still too long (keep first meaningful part) 531 if (short.length > 25) { 532 // Try to find a natural break point (dash after model size like -35B or -7B) 533 const sizeMatch = short.match(/^(.+?-\d+[BbMm])/); 534 if (sizeMatch) short = sizeMatch[1]; 535 else short = short.substring(0, 22) + 'â¦'; 536 } 537 return short; 538} 539 540function modelValue(name) { 541 if (name == null) return ''; 542 return String(name).trim(); 543} 544 545export function sameModelName(left, right) { 546 const a = modelValue(left); 547 const b = modelValue(right); 548 if (!a || !b) return false;
549 return a.toLowerCase() === b.toLowerCase() 550 || shortModel(a).toLowerCase() === shortModel(b).toLowerCase(); 551} 552 553export function modelRouteLabel(requestedModel, actualModel) { 554 const requested = modelValue(requestedModel); 555 const actual = modelValue(actualModel) || requested; 556 if (!requested || sameModelName(requested, actual)) return shortModel(actual || requested); 557 return shortModel(requested) + ' -> ' + shortModel(actual); 558} 559 560export function replyModelPair(modelName, metadata) { 561 const meta = metadata || {}; 562 const actualFromMeta = modelValue(meta.model || meta.actual_model); 563 const requestedFromMeta = modelValue(meta.requested_model || meta.selected_model); 564 if (actualFromMeta || requestedFromMeta) { 565 const actual = actualFromMeta || requestedFromMeta || modelValue(modelName); 566 const requested = requestedFromMeta || actual; 567 return { requestedModel: requested, actualModel: actual }; 568 } 569 const fallback = modelValue(modelName); 570 return { requestedModel: fallback, actualModel: fallback }; 571} 572 573/** 574 * Generate a consistent HSL color for a model name. 575 * Returns an hsl() string. The hue is derived from a string hash, 576 * saturation and lightness are fixed for readability on dark/light themes. 577 */ 578export function modelColor(name) { 579 if (!name) return null; 580 const key = name.toLowerCase(); 581 let hash = 0; 582 for (let i = 0; i < key.length; i++) { 583 hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0; 584 } 585 const hue = ((hash % 360) + 360) % 360; 586 return `hsl(${hue}, 55%, 65%)`; 587} 588 589/** Look up model info (pricing + context) by substring match */ 590export function getModelInfo(modelName) { 591 if (!modelName) return null; 592 const key = matchModelKey(modelName, Object.keys(MODEL_INFO)); 593 return key ? { key, ...MODEL_INFO[key] } : null; 594} 595 596function _fmtCtx(n) { 597 if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; 598 return Math.round(n / 1000) + 'K'; 599} 600 601/** 602 * Apply model color to a role element (sets color + dot color). 603 */ 604export function applyModelColor(roleEl, modelName) { 605 if (!modelName) return; 606 const color = modelColor(modelName); 607 if (color) { 608 roleEl.style.color = color; 609 roleEl.style.setProperty('--model-dot', color); 610 } 611 // Replace generic dot with provider logo if available 612 const logo = providerLogo(modelName); 613 const existingLogo = roleEl.querySelector('.role-provider-logo'); 614 if (!logo) { 615 if (existingLogo) existingLogo.remove(); 616 roleEl.classList.remove('has-logo'); 617 } else if (!existingLogo) { 618 const span = document.createElement('span'); 619 span.className = 'role-provider-logo'; 620 span.innerHTML = logo; 621 roleEl.classList.add('has-logo'); 622 roleEl.prepend(span); 623 } 624 // Click to show model info popup 625 if (!roleEl._hasInfoClick) { 626 roleEl._hasInfoClick = true; 627 roleEl.style.cursor = 'pointer'; 628 roleEl.addEventListener('click', (e) => { 629 e.stopPropagation(); 630 document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); }); 631 const info = getModelInfo(modelName); 632 const short = shortModel(modelName); 633 const logoHtml = providerLogo(modelName); 634 const popup = document.createElement('div'); 635 popup.className = 'ctx-popup'; 636 let html = '<div style="font-weight:600;margin-bottom:6px;color:var(--fg);display:flex;align-items:center;gap:6px;">'; 637 if (logoHtml) html += '<span class="role-provider-logo" style="opacity:0.7">' + logoHtml + '</span>'; 638 html += short + '</div>'; 639 html += '<div><span class="ctx-label">Model</span> ' + modelName.split('/').pop() + '</div>'; 640 // Provider = the serving endpoint, distinct from the model vendor/logo 641 // (e.g. the same model via OpenRouter vs Copilot vs Anthropic direct). 642 const _epUrl = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl) 643 ? window.sessionModule.getCurrentEndpointUrl() : null; 644 const _provLabel = providerLabel(_epUrl); 645 if (_provLabel) html += '<div><span class="ctx-label">Provider</span> ' + uiModule.esc(_provLabel) + '</div>'; 646 // Show static context initially, then fetch real from server 647 const _realCtx = window._realContextLengths && window._realContextLengths[modelName]; 648 if (_realCtx) { 649 html += '<div><span class="ctx-label">Context</span> ' + _fmtCtx(_realCtx) + ' tokens'; 650 if (info && info.ctx && info.ctx !== _realCtx) html += ' <span style="opacity:0.35">(spec: ' + _fmtCtx(info.ctx) + ')</span>'; 651 html += '</div>'; 652 } else if (info && info.ctx) { 653 html += '<div><span class="ctx-label">Context</span> <span id="_ctx-val">' + _fmtCtx(info.ctx) + ' tokens</span></div>'; 654 } 655 // Fetch real context from server async 656 if (!_realCtx && window.sessionModule) { 657 const _sid = window.sessionModule.getCurrentSessionId(); 658 if (_sid) { 659 fetch('/api/session/' + _sid + '/context_info').then(r => r.ok ? r.json() : null).then(d => { 660 if (d && d.context_length) { 661 if (!window._realContextLengths) window._realContextLengths = {}; 662 window._realContextLengths[modelName] = d.context_length; 663 const el = document.getElementById('_ctx-val'); 664 if (el) { 665 el.innerHTML = _fmtCtx(d.context_length) + ' tokens'; 666 if (info && info.ctx && info.ctx !== d.context_length) { 667 el.innerHTML += ' <span style="opacity:0.35">(spec: ' + _fmtCtx(info.ctx) + ')</span>'; 668 } 669 } 670 } 671 }).catch(() => {}); 672 } 673 } 674 // Show configured max tokens if set 675 if (window.presetsModule) { 676 const _pid = window.presetsModule.getSelectedPreset(); 677 const _preset = _pid ? window.presetsModule.getPreset(_pid) : null; 678 const _mt = _preset?.max_tokens; 679 if (_mt && _mt > 0 && _mt <= 8192) { 680 html += '<div><span class="ctx-label">Max tokens</span>
680 ' + _mt.toLocaleString() + ' <span style="opacity:0.4">(configured)</span></div>'; 681 } 682 } 683 if (info && info.input != null) html += '<div><span class="ctx-label">Input</span> $' + info.input.toFixed(2) + ' / 1M</div>'; 684 if (info && info.output != null) html += '<div><span class="ctx-label">Output</span> $' + info.output.toFixed(2) + ' / 1M</div>'; 685 if (!info) html += '<div style="opacity:0.4;font-size:0.85em;margin-top:4px;">No pricing data available</div>'; 686 popup.innerHTML = html; 687 const rect = roleEl.getBoundingClientRect(); 688 popup.style.top = (rect.bottom + 4) + 'px'; 689 popup.style.left = rect.left + 'px'; 690 document.body.appendChild(popup); 691 const pr = popup.getBoundingClientRect(); 692 if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px'; 693 if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px'; 694 bindMenuDismiss(popup, () => popup.remove()); 695 }); 696 } 697} 698 699export function getModelCost(modelName, inputTokens, outputTokens) { 700 if (!modelName) return null; 701 const key = matchModelKey(modelName, Object.keys(MODEL_PRICING)); 702 if (!key) return null; 703 const price = MODEL_PRICING[key]; 704 return (inputTokens * price.input + outputTokens * price.output) / 1_000_000; 705} 706 707/** 708 * Is this endpoint a local / self-hosted model server (vLLM, Ollama, â¦)? 709 * Local models are free, so we must NOT bill them at cloud rates â the 710 * pricing table matches on a name substring, so a local `qwen2.5-coder` 711 * would otherwise be charged like cloud `qwen2.5`. When the serving host is 712 * loopback, a private LAN range, Tailscale CGNAT (100.64â100.127.x), a 713 * `.local` name, or the app's own host, the model is local â free. 714 * Unknown / missing endpoint also counts as local (bias to not over-bill). 715 */ 716export function isLocalEndpoint(url) { 717 if (!url) return true; 718 let host; 719 try { host = new URL(url).hostname; } catch (_e) { return true; } 720 if (!host) return true; 721 if (host === 'localhost' || host === '0.0.0.0' || host === 'host.docker.internal' || host.endsWith('.local')) return true; 722 if (typeof window !== 'undefined' && window.location && host === window.location.hostname) return true; 723 // A single-label hostname (no dot) is an internal/Docker service name 724 // (e.g. "nim-nano", "llamaswap", "nemotron-super-49b") or a LAN shortname â 725 // never a public API, which always needs an FQDN. Treat as local â free. 726 // (Without this, container-name endpoints get billed at cloud rates because 727 // the pricing table matches on a name substring, e.g. "nemotron".) 728 if (!host.includes('.')) return true; 729 if (/^127\./.test(host)) return true; 730 if (/^10\./.test(host)) return true; 731 if (/^192\.168\./.test(host)) return true; 732 if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true; 733 const cg = host.match(/^100\.(\d+)\./); // Tailscale CGNAT 734 if (cg && +cg[1] >= 64 && +cg[1] <= 127) return true; 735 return false; 736} 737 738/** Cost for the current turn, returning null (free) for local endpoints. */ 739function _billableCost(model, inputTokens, outputTokens) { 740 const url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl) 741 ? window.sessionModule.getCurrentEndpointUrl() : null; 742 if (isLocalEndpoint(url)) return null; 743 return getModelCost(model, inputTokens, outputTokens); 744} 745 746export function getImageCost(model, quality, size) { 747 if (!model) return null; 748 const m = model.toLowerCase(); 749 for (const [key, quals] of Object.entries(IMAGE_PRICING)) { 750 if (m.includes(key)) { 751 const q = quals[(quality || 'medium').toLowerCase()] || quals['medium']; 752 return q ? (q[size] || q['1024x1024'] || null) : null; 753 } 754 } 755 return null; 756} 757 758/* ââ Session cost helpers âââââââââââââââââââââââââââââââââââââââââââ */ 759const _COST_KEY = 'ody-session-cost'; 760 761/** Return the accumulated cost for the current (or given) session. */ 762export function getSessionCost(sessionId) { 763 const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId()); 764 if (!sid) return 0; 765 try { 766 const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); 767 return costs[sid] || 0; 768 } catch (_e) { return 0; } 769} 770 771/** Reset session cost for the given session (defaults to current). */ 772export function resetSessionCost(sessionId) { 773 const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId()); 774 if (!sid) return; 775 try { 776 const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); 777 delete costs[sid]; 778 localStorage.setItem(_COST_KEY, JSON.stringify(costs)); 779 } catch (_e) { /* ignore */ } 780 updateSessionCostUI(); 781} 782 783/** Update the persistent session-cost badge in the input bar. */ 784export function updateSessionCostUI() { 785 const el = document.getElementById('session-cost-display'); 786 if (!el) return; 787 // Local model? It's free â hide the badge and clear any stale c
787ost that a 788 // previous (buggy) cloud-rate billing left in localStorage for this session. 789 const _url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl) 790 ? window.sessionModule.getCurrentEndpointUrl() : null; 791 if (isLocalEndpoint(_url)) { 792 const sid = window.sessionModule && window.sessionModule.getCurrentSessionId(); 793 if (sid && getSessionCost(sid) > 0) { 794 try { 795 const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); 796 delete costs[sid]; 797 localStorage.setItem(_COST_KEY, JSON.stringify(costs)); 798 } catch (_e) { /* ignore */ } 799 } 800 el.style.display = 'none'; 801 return; 802 } 803 const cost = getSessionCost(); 804 if (cost > 0) { 805 el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2)); 806 el.style.display = ''; 807 } else { 808 el.style.display = 'none'; 809 } 810} 811 812/** Create a timestamp span for role labels. 813 * Pass an ISO string / Date / epoch-ms to render the message's own time 814 * (used when replaying history). Falls back to "now" when no value is given. */ 815export function roleTimestamp(when) { 816 const ts = document.createElement('span'); 817 ts.className = 'role-timestamp'; 818 let d; 819 if (when instanceof Date) d = when; 820 else if (typeof when === 'number') d = new Date(when); 821 else if (typeof when === 'string' && when) d = new Date(when); 822 else d = new Date(); 823 if (isNaN(d.getTime())) d = new Date(); 824 ts.textContent = d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); 825 ts.title = d.toLocaleString(); 826 return ts; 827} 828 829/** 830 * Strip tool invocation blocks from text before rendering. 831 */ 832export function stripToolBlocks(text) { 833 let cleaned = text.replace(TOOL_CALL_RE, ''); 834 cleaned = cleaned.replace(EXEC_FENCE_RE, ''); 835 cleaned = cleaned.replace(DSML_TOOL_RE, ''); 836 cleaned = cleaned.replace(DSML_STRAY_RE, ''); 837 cleaned = cleaned.replace(XML_TOOL_CALL_RE, ''); 838 cleaned = cleaned.replace(XML_INVOKE_RE, ''); 839 cleaned = cleaned.replace(TOOL_NARRATION_RE, ''); 840 cleaned = cleaned.replace(/\n{3,}/g, '\n\n'); 841 return cleaned.trim(); 842} 843 844/** 845 * Build a collapsible sources box (used by both research and web search). 846 */ 847export function buildSourcesBox(sources, type, expanded) { 848 var esc = uiModule.esc; 849 var id = 'sources-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5); 850 var count = sources.length; 851 var label = type === 'research' ? 'Research sources' : 'Web sources'; 852 var lines = ''; 853 for (var i = 0; i < count; i++) { 854 var s = sources[i]; 855 var domain = ''; 856 try { domain = new URL(s.url).hostname.replace('www.', ''); } catch(e) { domain = s.url; } 857 var title = esc(s.title || domain || ''); 858 var safeUrl = _safeHref(s.url); 859 lines += '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer" class="source-link">' 860 + '<span class="source-num">' + (i + 1) + '</span>' 861 + '<span class="source-title">' + title + '</span>' 862 + '<span class="source-domain">' + esc(domain) + '</span>' 863 + '</a>'; 864 } 865 var arrow = expanded ? 'down' : 'right'; 866 var expandedClass = expanded ? ' expanded' : ''; 867 return '<div class="sources-section">' 868 + '<div class="sources-header" data-sources-id="' + id + '" onclick="window.toggleSources(\'' + id + '\')">' 869 + '<div class="sources-header-left">' + SEARCH_ICON + '<span>' + count + ' ' + label + '</span></div>' 870 + '<span class="sources-toggle" id="' + id + '-toggle" data-arrow="' + arrow + '"></span>' 871 + '</div>' 872 + '<div class="sources-content' + expandedClass + '" id="' + id + '">' 873 + '<div class="sources-content-inner">' + lines + '</div>' 874 + '</div></div>'; 875} 876 877/** 878 * Build the RAG "Sources (N documents)" box â mirrors the live render in 879 * chat.js so persisted rag_sources survive a refresh. Items carry a 880 * filename, similarity %, and snippet (not URLs, unlike web sources). 881 * @param {Array<{filename, similarity, snippet}>} sources 882 */ 883export function buildRagSourcesBox(sources) { 884 if (!sources || !sources.length) return ''; 885 var esc = uiModule.esc; 886 var items = ''; 887 for (var i = 0; i < sources.length; i++) { 888 var s = sources[i] || {}; 889 var pct = (typeof s.similarity === 'number') ? (s.similarity * 100).toFixed(1) + '%' : ''; 890 items += '<div class="rag-source-item"><strong>' + esc(s.filename || '') + '</strong>' 891 + (pct ? ' <span class="rag-similarity">' + pct + '</span>' : '') 892 + '<div class="rag-snippet">' + esc(s.snippet || '') + '</div></div>'; 893 } 894 return '<details class="rag-sources"><summary>Sources (' + sources.length + ' documents)</summary>' + items + '</details>'; 895} 896
897/** 898 * Build a collapsible "Raw collected findings" section, styled like the sources box. 899 * @param {Array<{url, title, summary}>} findings 900 * @param {boolean} [expanded=false] 901 */ 902export function buildFindingsBox(findings, expanded) { 903 if (!findings || !findings.length) return ''; 904 var esc = uiModule.esc; 905 var id = 'findings-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5); 906 var count = findings.length; 907 var lines = ''; 908 for (var i = 0; i < count; i++) { 909 var f = findings[i]; 910 var domain = ''; 911 try { domain = new URL(f.url).hostname.replace('www.', ''); } catch(e) { domain = f.url; } 912 var title = esc(f.title || domain || ''); 913 var summary = esc(f.summary || ''); 914 var safeUrl = _safeHref(f.url); 915 lines += '<div class="finding-item">' 916 + '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer" class="source-link">' 917 + '<span class="source-num">' + (i + 1) + '</span>' 918 + '<span class="source-title">' + title + '</span>' 919 + '<span class="source-domain">' + esc(domain) + '</span>' 920 + '</a>' 921 + '<div class="finding-summary">' + summary + '</div>' 922 + '</div>'; 923 } 924 var FINDINGS_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'; 925 var arrow = expanded ? 'down' : 'right'; 926 var expandedClass = expanded ? ' expanded' : ''; 927 return '<div class="sources-section">' 928 + '<div class="sources-header" data-sources-id="' + id + '" onclick="window.toggleSources(\'' + id + '\')">' 929 + '<div class="sources-header-left">' + FINDINGS_ICON + '<span>' + count + ' Raw collected findings</span></div>' 930 + '<span class="sources-toggle" id="' + id + '-toggle" data-arrow="' + arrow + '"></span>' 931 + '</div>' 932 + '<div class="sources-content' + expandedClass + '" id="' + id + '">' 933 + '<div class="sources-content-inner">' + lines + '</div>' 934 + '</div></div>'; 935} 936 937/** Append report button + continue research prompt. */
938export function appendReportButton(container, sessionId) { 939 _appendReportButton(container, sessionId); 940 _appendContinuePrompt(container); 941} 942 943function _appendContinuePrompt(container) { 944 var wrap = document.createElement('div'); 945 wrap.className = 'continue-research-wrap'; 946 wrap.innerHTML = 947 '<div class="continue-research-hint">' 948 + '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>' 949 + '<span>Dig deeper? Activate Research again and type a follow-up question to continue this research.</span>' 950 + '</div>'; 951 container.appendChild(wrap); 952} 953function _appendReportButton(container, sessionId) { 954 var apiBase = window.API_BASE || ''; 955 956 // Wrapper holds report button + chat-about button 957 var wrap = document.createElement('div'); 958 wrap.className = 'report-btn-wrap'; 959 960 var btn = document.createElement('button'); 961 btn.type = 'button'; 962 btn.className = 'view-report-btn'; 963 btn.innerHTML = REPORT_ICON + ' Open Visual Report'; 964 965 var reportUrl = apiBase + '/api/research/report/' + sessionId; 966 btn.addEventListener('click', function() { 967 window.open(reportUrl, '_blank'); 968 }); 969 wrap.appendChild(btn); 970 971 var chatBtn = document.createElement('button'); 972 chatBtn.type = 'button'; 973 chatBtn.className = 'view-report-btn chat-about-btn'; 974 chatBtn.innerHTML = CHAT_ABOUT_ICON + ' Discuss'; 975 chatBtn.addEventListener('click', async function() { 976 if (chatBtn.disabled) return; 977 var origLabel = chatBtn.innerHTML; 978 chatBtn.disabled = true; 979 chatBtn.innerHTML = CHAT_ABOUT_ICON + ' Creatingâ¦'; 980 try { 981 var res = await fetch(apiBase + '/api/research/spinoff/' + sessionId, { method: 'POST' }); 982 if (!res.ok) { 983 var detail = ''; 984 try { detail = (await res.json()).detail || ''; } catch {} 985 throw new Error(detail || ('HTTP ' + res.status)); 986 } 987 var payload = await res.json(); 988 if (window.sessionModule && payload.session_id) { 989 await window.sessionModule.loadSessions().catch(() => {}); 990 await window.sessionModule.selectSession(payload.session_id); 991 } 992 } catch (e) { 993 chatBtn.disabled = false; 994 chatBtn.innerHTML = origLabel; 995 if (window.uiModule && uiModule.showError) { 996 uiModule.showError('Could not start follow-up chat: ' + e.message); 997 } else { 998 alert('Could not start follow-up chat: ' + e.message); 999 } 1000 } 1001 }); 1002 wrap.appendChild(chatBtn); 1003 1004 container.appendChild(wrap); 1005} 1006 1007window.toggleSources = function(id) { 1008 // Debounce to prevent double-fire from both inline onclick and delegation 1009 var now = Date.now(); 1010 if (window._lastSourcesToggle && now - window._lastSourcesToggle < 100) return; 1011 window._lastSourcesToggle = now; 1012 1013 var content = document.getElementById(id); 1014 var toggle = document.getElementById(id + '-toggle'); 1015 if (content && toggle) { 1016 var expanded = content.classList.contains('expanded'); 1017 content.classList.toggle('expanded', !expanded); 1018 toggle.dataset.arrow = expanded ? 'right' : 'down'; 1019 } 1020}; 1021 1022// Event delegation for sources toggle (capture phase, handles SVG targets) 1023document.addEventListener('click', function(e) { 1024 // Walk up from target manually to handle SVG elements that may not support closest() 1025 var el = e.target; 1026 while (el && el !== document) { 1027 if (el.classList && el.classList.contains('sources-header') && el.dataset && el.dataset.sourcesId) { 1028 e.stopPropagation(); 1029 window.toggleSources(el.dataset.sourcesId); 1030 return; 1031 } 1032 el = el.parentElement || el.parentNode; 1033 } 1034}, true); 1035 1036// Jump-to-entity anchors â the agent emits links like 1037// [New Chat](#session-89effa28) 1038// [Notes](#document-abc123) 1039// [Reminder](#note-42) 1040// and the chat-history click delegate turns them into navigation 1041// instead of default in-page anchor jumps. Each prefix routes to the 1042// matching module via a dynamic import (avoids circular deps â 1043// sessions.js itself imports chatRenderer.js). 1044document.addEventListener('click', function(e) { 1045 // Walk past Text nodes â clicking link text yields a Text node target 1046 // whose .closest is undefined, so preventDefault never fires and the 1047 // browser performs a default hash-navigation that resets the session. 1048 let _t = e.target; 1049 while (_t && _t.nodeType === Node.TEXT_NODE) _t = _t.parentElement; 1050 const a = _t && _t.closest && _t.closest('a[href]'); 1051 if (!a) return; 1052 const href = a.getAttribute('href') || ''; 1053 if (!href.startsWith('#')) return; 1054 const m = href.match(/^#(session|document|note|image|email|event|task|skill|research)-(.+)$/); 1055 if (!m) return; 1056 e.preventDefault(); 1057 e.stopPropagation(); 1058 const [, kind, id] = m; 1059 if (kind === 'session') { 1060 import('./sessions.js').then(mod => { 1061 const fn = mod.selectSession || (mod.default && mod.default.selectSession); 1062 if (fn) fn(id); 1063 }); 1064 } else if (kind === 'document') { 1065 import('./document.js').then(mod => { 1066 const open = mod.loadDocument 1067 || mod.openDocument 1068 || (mod.default && (mod.default.loadDocument || mod.default.openDocument)); 1069 if (open) open(id); 1070 }).catch(() => {}); 1071 } else if (kind === 'note') { 1072 import('./notes.js').then(mod => { 1073 const open = mod.openNote || (mod.default && mod.default.openNote); 1074 if (open) open(id); 1075 }).catch(() => {}); 1076 } else if (kind === 'image') { 1077 import('./gallery.js').then(mod => { 1078 const open = mod.openGalleryImage || (mod.default && mod.default.openGalleryImage); 1079 if (open) open(id); 1080 }).catch(() => {}); 1081 } else if (kind === 'email') { 1082 import('./emailLibrary.js').then(mod => { 1083 const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary); 1084 if (open) open({ uid: id }); 1085 }).catch(() => {}); 1086 } else if (kind === 'event') { 1087 import('./calendar.js').then(mod => { 1088 const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo); 1089 if (open) open(id); 1090 }).catch(() => {}); 1091 } else if (kind === 'task') { 1092 import('./tasks.js').then(mod => { 1093 const open = mod.openTasks || (mod.default && mod.default.openTasks); 1094 if (open) open(id); 1095 else { const b = document.getElementById('tasks-btn'); if (b) b.click(); } 1096 }).catch(() => { const b = document.getElementById('tasks-btn'); if (b) b.click(); }); 1097 } else if (kind === 'skill') { 1098 import('./skills.js').then(mod => { 1099 const open = mod.openSkill || (mod.default && mod.default.openSkill); 1100 if (open) open(id); 1101 }).catch(() => {}); 1102 } else if (kind === 'research') { 1103 import('./research/panel.js').then(mod => { 1104 const open = mod.openPanel || (mod.default && mod.default.openPanel); 1105 if (open) open(id); 1106 }).catch(() => {}); 1107 } 1108}); 1109 1110/**
1111 * Build a generated-image bubble element. 1112 */ 1113export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId) { 1114 var esc = uiModule.esc; 1115 const wrap = document.createElement('div'); 1116 wrap.className = 'msg msg-ai generated-image-wrap'; 1117 1118 const role = document.createElement('div'); 1119 role.className = 'role'; 1120 role.textContent = (model || 'image').split('/').pop(); 1121 wrap.appendChild(role); 1122 1123 const body = document.createElement('div'); 1124 body.className = 'body'; 1125 1126 const safeImageUrl = safeDisplayImageSrc(imageUrl); 1127 if (!safeImageUrl) { 1128 body.textContent = '[Image unavailable]'; 1129 wrap.appendChild(body); 1130 return wrap; 1131 } 1132 1133 const img = document.createElement('img'); 1134 img.className = 'generated-image'; 1135 img.alt = prompt || 'Generated image'; 1136 img.title = prompt || 'Generated image'; 1137 img.src = safeImageUrl; 1138 img.addEventListener('click', () => { window.open(safeImageUrl, '_blank', 'noopener,noreferrer'); }); 1139 body.appendChild(img); 1140 1141 if (prompt) { 1142 const caption = document.createElement('div'); 1143 caption.className = 'generated-image-caption'; 1144 caption.textContent = prompt; 1145 body.appendChild(caption); 1146 } 1147 1148 wrap.appendChild(body); 1149 1150 const footer = document.createElement('div'); 1151 footer.className = 'msg-footer'; 1152 1153 const actions = document.createElement('span'); 1154 actions.className = 'msg-actions'; 1155 1156 const copyBtn = document.createElement('button'); 1157 copyBtn.className = 'footer-copy-btn'; 1158 copyBtn.type = 'button'; 1159 copyBtn.title = 'Copy prompt'; 1160 copyBtn.innerHTML = COPY_ICON; 1161 copyBtn.addEventListener('click', (e) => { 1162 e.stopPropagation(); 1163 uiModule.copyToClipboard(prompt || ''); 1164 copyBtn.innerHTML = CHECK_ICON; 1165 setTimeout(() => { copyBtn.innerHTML = COPY_ICON; }, 1500); 1166 }); 1167 actions.appendChild(copyBtn); 1168 1169 const dlBtn = document.createElement('button'); 1170 dlBtn.className = 'footer-copy-btn'; 1171 dlBtn.type = 'button'; 1172 dlBtn.title = 'Download image'; 1173 dlBtn.textContent = '\u2913'; 1174 dlBtn.addEventListener('click', async (e) => { 1175 e.stopPropagation(); 1176 try { 1177 const resp = await fetch(imageUrl); 1178 const blob = await resp.blob(); 1179 const a = document.createElement('a'); 1180 a.href = URL.createObjectURL(blob); 1181 a.download = (prompt || 'image').slice(0, 40).replace(/[^a-zA-Z0-9 ]/g, '') + '.png'; 1182 document.body.appendChild(a); 1183 a.click(); 1184 a.remove(); 1185 URL.revokeObjectURL(a.href); 1186 dlBtn.textContent = '\u2713'; 1187 setTimeout(() => { dlBtn.textContent = '\u2913'; }, 1500); 1188 } catch { dlBtn.textContent = '\u2717'; setTimeout(() => { dlBtn.textContent = '\u2913'; }, 1500); } 1189 }); 1190 actions.appendChild(dlBtn); 1191 1192 const editBtn = document.createElement('button'); 1193 editBtn.className = 'footer-copy-btn'; 1194 editBtn.type = 'button'; 1195 editBtn.title = 'Edit in image editor'; 1196 editBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>'; 1197 editBtn.addEventListener('click', async (e) => { 1198 e.stopPropagation(); 1199 try { 1200 const [galleryMod, editorMod] = await Promise.all([ 1201 import('./gallery.js'), 1202 import('./galleryEditor.js'), 1203 ]); 1204 // Ensure the Gallery modal is open so the editor has a container 1205 // to render into; switch its tabs to the Edit tab. 1206 galleryMod.default.openGallery(); 1207 const modal = document.getElementById('gallery-modal'); 1208 if (modal) { 1209 modal.querySelectorAll('.gallery-tab').forEach(t => t.classList.remove('active')); 1210 modal.querySelector('.gallery-tab[data-tab="editor"]')?.classList.add('active'); 1211 } 1212 const imagesContainer = document.getElementById('gallery-images-container'); 1213 const albumsContainer = document.getElementById('gallery-albums-container'); 1214 if (imagesContainer) imagesContainer.style.display = 'none'; 1215 if (albumsContainer) albumsContainer.style.display = 'none'; 1216 const editorContainer = document.getElementById('gallery-editor-container'); 1217 if (editorContainer) editorContainer.style.display = 'flex'; 1218 const label = (prompt || '').trim().slice(0, 60) || 'Generated image'; 1219 editorMod.openEditor(imageUrl, null, null, label); 1220 } catch (err) { 1221 console.error('[chat] open in editor failed', err); 1222 } 1223 }); 1224 actions.appendChild(editBtn); 1225 1226 const delBtn = document.createElement('button'); 1227 delBtn.className = 'footer-copy-btn footer-delete-btn'; 1228 delBtn.type = 'button'; 1229 delBtn.title = 'Delete image'; 1230 delBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>'; 1231 delBtn.addEventListener('click', async (e) => { 1232 e.stopPropagation(); 1233 const ok = await uiModule.styledConfirm('Delete this image?', { 1234 confirmText: 'Delete', 1235 cancelText: 'Cancel', 1236 danger: true, 1237 }); 1238 if (!ok) return; 1239 // If we have a gallery id, delete server-side; otherwise just remove 1240 // the bubble from chat (e.g. external DALL-E url that wasn't saved). 1241 if (imageId) {
1242 try { 1243 const res = await fetch(`/api/gallery/${encodeURIComponent(imageId)}`, { 1244 method: 'DELETE', credentials: 'same-origin', 1245 }); 1246 if (!res.ok && res.status !== 404) { 1247 uiModule.showToast?.('Delete failed', 4000); 1248 return; 1249 } 1250 window.dispatchEvent(new CustomEvent('gallery-refresh')); 1251 } catch (_) { 1252 uiModule.showToast?.('Delete failed', 4000); 1253 return; 1254 } 1255 } 1256 wrap.remove(); 1257 }); 1258 actions.appendChild(delBtn); 1259 1260 footer.appendChild(actions); 1261 1262 const metrics = document.createElement('span'); 1263 metrics.className = 'response-metrics'; 1264 const parts = []; 1265 if (model) parts.push(model.split('/').pop()); 1266 if (size) parts.push(size); 1267 if (quality) parts.push(quality); 1268 const cost = getImageCost(model, quality, size); 1269 if (cost !== null) parts.push('$' + (cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3))); 1270 metrics.textContent = parts.join(' \u00B7 '); 1271 footer.appendChild(metrics); 1272 1273 wrap.appendChild(footer); 1274 return wrap; 1275} 1276 1277export function hideWelcomeScreen() { 1278 const ws = document.getElementById('welcome-screen'); 1279 const cc = document.getElementById('chat-container'); 1280 if (ws) ws.classList.add('hidden'); 1281 if (cc) cc.classList.remove('welcome-active'); 1282 // Update send button â switches from muted arrow to + Chat 1283 if (window._updateSendBtnIcon) setTimeout(window._updateSendBtnIcon, 50); 1284 const ib = document.getElementById('incognito-btn'); 1285 if (ib) ib.style.display = ib.classList.contains('active') ? '' : 'none'; 1286} 1287 1288export function showWelcomeScreen() { 1289 const ws = document.getElementById('welcome-screen'); 1290 const cc = document.getElementById('chat-container'); 1291 if (ws) ws.classList.remove('hidden'); 1292 if (cc) cc.classList.add('welcome-active'); 1293 // Entering the New Chat / welcome state: discard any stale draft left in the 1294 // composer from the previous session so the input starts empty (issue #1343). 1295 // Switching between existing sessions loads them directly and does NOT call 1296 // this, so genuine drafts are not erased. Reset the autosized height and fire 1297 // an `input` event so the send button + autosize listeners update. 1298 const _msg = document.getElementById('message'); 1299 if (_msg) { 1300 _msg.value = ''; 1301 _msg.style.height = ''; 1302 _msg.dispatchEvent(new Event('input', { bubbles: true })); 1303 } 1304 // Re-trigger the LâR clip-wipe reveal on the welcome name each time the 1305 // welcome screen is shown (new session, deleted last session, etc.) â without 1306 // this, the CSS animation only fires on initial DOM insertion. 1307 const wn = document.querySelector('.welcome-name'); 1308 if (wn) {
1309 wn.style.animation = 'none'; 1310 // force reflow so the next assignment registers as a new animation 1311 void wn.offsetHeight; 1312 wn.style.animation = ''; 1313 } 1314 // Update send button â switches from + Chat to muted arrow on empty session 1315 if (window._updateSendBtnIcon) setTimeout(window._updateSendBtnIcon, 50); 1316 const ib = document.getElementById('incognito-btn'); 1317 const _researchChk = document.getElementById('research-toggle'); 1318 if (ib && !(_researchChk && _researchChk.checked)) ib.style.display = ''; 1319 if (window.innerWidth > 768) { 1320 const msg = document.getElementById('message'); 1321 if (msg) msg.focus(); 1322 } 1323} 1324 1325// ââ Dynamic action buttons (show 3 most recent, rest under ···) ââ 1326const _ACTION_RECENTS_KEY = 'odysseus-msg-actions-recent'; 1327const _MAX_VISIBLE = 2; 1328 1329function _getRecentActions() { 1330 try { return JSON.parse(localStorage.getItem(_ACTION_RECENTS_KEY) || '[]'); } catch { return []; } 1331} 1332function _trackAction(id) { 1333 let recent = _getRecentActions().filter(x => x !== id); 1334 recent.unshift(id); 1335 if (recent.length > 10) recent.length = 10; 1336 localStorage.setItem(_ACTION_RECENTS_KEY, JSON.stringify(recent)); 1337} 1338 1339/** 1340 * Create a footer row for an AI message with timestamp and action buttons. 1341 */ 1342export function createMsgFooter(msgElement) { 1343 const footer = document.createElement('div'); 1344 footer.className = 'msg-footer'; 1345 1346 const actions = document.createElement('span'); 1347 actions.className = 'msg-actions'; 1348 1349 // Define all available actions: { id, icon, title, className, handler } 1350 const allActions = [ 1351 { id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) { 1352 e.stopPropagation(); 1353 const btn = e.currentTarget; 1354 uiModule.copyToClipboard(msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || ''); 1355 btn.innerHTML = CHECK_ICON; 1356 setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500); 1357 }}, 1358 { id: 'edit', icon: '\u270E', title: 'Edit', cls: 'msg-action-btn', handler(e) { 1359 e.stopPropagation(); 1360 if (window.chatModule?.editAIMessage) window.chatModule.editAIMessage(msgElement); 1361 }}, 1362 { id: 'regen', icon: '\u21BB', title: 'Regenerate from here', cls: 'msg-action-btn', handler(e) { 1363 e.stopPropagation(); 1364 if (window.chatModule?.regenerateFrom) window.chatModule.regenerateFrom(msgElement); 1365 }}, 1366 { id: 'shorten', icon: '\u2702', title: 'Rewrite shorter', cls: 'msg-action-btn', handler(e) { 1367 e.stopPropagation(); 1368 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.'); 1369 }}, 1370 { id: 'explain', icon: '?', title: 'Explain simpler', cls: 'msg-action-btn', handler(e) { 1371 e.stopPropagation(); 1372 if (window.chatModule?.rewriteWith) window.chatModule.rewriteWith(msgElement, 'Explain your last response in simpler terms. Use plain language and short sentences.'); 1373 }}, 1374 { id: 'fork', icon: '\u2ADD', title: 'Fork conversation', cls: 'msg-action-btn', handler(e) { 1375 e.stopPropagation(); 1376 if (window.chatModule?.forkFrom) window.chatModule.forkFrom(msgElement); 1377 }}, 1378 { id: 'delete', icon: '\u2715', title: 'Delete message', cls: 'msg-action-btn msg-delete-btn', handler(e) { 1379 e.stopPropagation(); 1380 if (window.chatModule?.deleteMessage) window.chatModule.deleteMessage(msgElement); 1381 }}, 1382 ]; 1383 1384 // Filter out unavailable actions (e.g. TTS when not enabled) 1385 const availableActions = allActions.filter(a => !a.available || a.available()); 1386 1387 // Determine which 3 to show: use recent order, fallback to defaults 1388 const recent = _getRecentActions(); 1389 const defaults = ['copy', 'delete', 'fork']; 1390 const order = recent.length > 0 ? recent : defaults; 1391 const sorted = [...availableActions].sort((a, b) => { 1392 const ai = order.indexOf(a.id), bi = order.indexOf(b.id); 1393 if (ai >= 0 && bi >= 0) return ai - bi; 1394 if (ai >= 0) return -1; 1395 if (bi >= 0) return 1; 1396 return 0; 1397 }); 1398 const visible = sorted.slice(0, _MAX_VISIBLE); 1399 const overflow = sorted.slice(_MAX_VISIBLE); 1400 1401 // Render visible buttons 1402 function _addBtn(action, container) { 1403 const btn = _makeActionBtn(action.cls, action.title, action.html ? '' : action.icon, (e) => { 1404 _trackAction(action.id); 1405 action.handler(e); 1406 }); 1407 if (action.html) btn.innerHTML = action.icon; 1408 btn.dataset.action = action.id; 1409 container.appendChild(btn); 1410 } 1411 1412 visible.forEach(a => _addBtn(a, actions)); 1413 1414 // Overflow "···" button 1415 if (overflow.length > 0) { 1416 const moreBtn = document.createElement('button'); 1417 moreBtn.className = 'msg-action-btn msg-more-btn'; 1418 moreBtn.type = 'button'; 1419 moreBtn.title = 'More actions'; 1420 moreBtn.textContent = '\u00B7\u00B7\u00B7'; 1421 moreBtn.addEventListener('click', (e) => { 1422 e.stopPropagation(); 1423 // Toggle overflow menu â close any existing one first (through its own 1424 // dismiss so the Escape registry entry goes with it). 1425 const existing = document.querySelector('.msg-overflow-menu'); 1426 if (existing) { 1427 if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); 1428 if (existing._trigger === moreBtn) return; 1429 } 1430 1431 const menu = document.createElement('div'); 1432 menu.className = 'msg-overflow-menu'; 1433 let closeMenu = () => menu.remove(); 1434 overflow.forEach(a => { 1435 const item = document.createElement('button'); 1436 item.className = 'msg-overflow-item'; 1437 item.type = 'button'; 1438 item.title = a.title; 1439 item.innerHTML = `<span class="overflow-icon">${a.icon}</span> ${a.title}`; 1440 item.addEventListener('click', (ev) => { 1441 ev.stopPropagation(); 1442 _trackAction(a.id); 1443 closeMenu(); 1444 a.handler(ev); 1445 }); 1446 menu.appendChild(item); 1447 }); 1448 menu._trigger = moreBtn; 1449 document.body.appendChild(menu); 1450 // Position fixed relative to the ··· button 1451 const btnRect = moreBtn.getBoundingClientRect(); 1452 menu.style.top = (btnRect.top - menu.offsetHeight - 4) + 'px'; 1453 menu.style.left = btnRect.left + 'px'; 1454 // Flip down if above viewport 1455 if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px'; 1456 // Keep within right edge 1457 const mr = menu.getBoundingClientRect(); 1458 if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px'; 1459 // Close on outside click or Escape. The trigger button is treated as 1460 // "inside" so its own click toggles rather than double-fires. 1461 closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); }); 1462 actions.appendChild(moreBtn); 1463 } 1464 1465 // Memory-used indicator pill 1466 const mems = msgElement._memoriesUsed; 1467 if (mems && mems.length > 0) { 1468 const pill = document.createElement('button'); 1469 pill.className = 'memory-used-pill'; 1470 pill.type = 'button'; 1471 const pinnedCount = mems.filter(m => m.type === 'pinned').length; 1472 const recalledCount = mems.filter(m => m.type === 'recalled').length; 1473 const parts = []; 1474 if (pinnedCount) parts.push(`${pinnedCount} pinned`); 1475 if (recalledCount) parts.push(`${recalledCount} recalled`); 1476 pill.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.8-3.5 6-.3.2-.5.5-.5.9V18h-6v-2.1c0-.4-.2-.7-.5-.9C6.3 13.8 5 11.5 5 9a7 7 0 0 1 7-7z"/><path d="M9 18h6v1a3 3 0 0 1-6 0v-1z"/><path d="M12 2v7"/><path d="M8.5 6.5L12 9l3.5-2.5"/></svg><span class="memory-used-pill-text">${parts.join(', ')}</span>`;
1477 pill.title = mems.map(m => `[${m.type}] ${m.text}`).join('\n'); 1478 1479 pill.addEventListener('click', (e) => { 1480 e.stopPropagation(); 1481 let detail = pill._openDetail || document.querySelector('.memory-used-detail'); 1482 if (detail) { 1483 if (typeof detail._dismiss === 'function') detail._dismiss(); 1484 else { detail.remove(); pill._openDetail = null; } 1485 return; 1486 } 1487 detail = document.createElement('div'); 1488 detail.className = 'memory-used-detail'; 1489 let closeDetail = () => { detail.remove(); pill._openDetail = null; }; 1490 mems.forEach(m => { 1491 const row = document.createElement('div'); 1492 row.className = 'memory-used-row'; 1493 row.style.cursor = 'pointer'; 1494 row.title = 'Click to open memory manager'; 1495 const badge = document.createElement('span'); 1496 badge.className = 'memory-used-badge ' + (m.type === 'pinned' ? 'pinned' : 'recalled'); 1497 badge.textContent = m.type === 'pinned' ? '\u25CF' : '\u21BB'; 1498 const text = document.createElement('span'); 1499 text.className = 'memory-used-text'; 1500 text.textContent = m.text; 1501 row.appendChild(badge); 1502 row.appendChild(text); 1503 row.addEventListener('click', (ev) => { 1504 ev.stopPropagation(); 1505 closeDetail(); 1506 const memModal = document.getElementById('memory-modal'); 1507 if (memModal) memModal.classList.remove('hidden'); 1508 }); 1509 detail.appendChild(row); 1510 }); 1511 detail.style.visibility = 'hidden'; 1512 document.body.appendChild(detail); 1513 const pillRect = pill.getBoundingClientRect(); 1514 const detailRect = detail.getBoundingClientRect(); 1515 const spaceAbove = pillRect.top; 1516 const spaceBelow = window.innerHeight - pillRect.bottom; 1517 if (spaceAbove >= detailRect.height + 8 || spaceAbove > spaceBelow) { 1518 detail.style.top = (pillRect.top - detailRect.height - 8) + 'px'; 1519 } else { 1520 detail.style.top = (pillRect.bottom + 8) + 'px'; 1521 } 1522 detail.style.left = pillRect.left + 'px'; 1523 if (pillRect.left + detailRect.width > window.innerWidth - 8) { 1524 detail.style.left = (window.innerWidth - detailRect.width - 8) + 'px'; 1525 } 1526 if (parseFloat(detail.style.left) < 8) detail.style.left = '8px'; 1527 detail.style.visibility = ''; 1528 pill._openDetail = detail; 1529 // Close on outside click or Escape (pill click toggles, so it's inside). 1530 closeDetail = bindMenuDismiss(detail, () => { detail.remove(); pill._openDetail = null; }, (ev) => !detail.contains(ev.target) && ev.target !== pill); }); 1531 1532 footer.appendChild(pill); 1533 } 1534 1535 footer.appendChild(actions); 1536 return footer; 1537} 1538 1539/** 1540 * Create a footer row for a user message with action buttons (same system as AI footer). 1541 */ 1542const _USER_ACTION_RECENTS_KEY = 'odysseus-user-actions-recent'; 1543 1544function _getUserRecentActions() { 1545 try { return JSON.parse(localStorage.getItem(_USER_ACTION_RECENTS_KEY) || '[]'); } catch { return []; } 1546} 1547function _trackUserAction(id) { 1548 let recent = _getUserRecentActions().filter(x => x !== id); 1549 recent.unshift(id); 1550 if (recent.length > 10) recent.length = 10; 1551 localStorage.setItem(_USER_ACTION_RECENTS_KEY, JSON.stringify(recent)); 1552} 1553 1554export function createUserMsgFooter(msgElement) { 1555 const footer = document.createElement('div'); 1556 footer.className = 'msg-footer'; 1557 1558 const actions = document.createElement('span'); 1559 actions.className = 'msg-actions'; 1560 1561 const allActions = [ 1562 { id: 'edit', icon: '\u270E', title: 'Edit message', cls: 'msg-action-btn', handler(e) { 1563 e.stopPropagation(); 1564 if (window.chatModule?.editUserMessage) window.chatModule.editUserMessage(msgElement); 1565 }}, 1566 { id: 'delete', icon: '\u2715', title: 'Delete message', cls: 'msg-action-btn msg-delete-btn', handler(e) { 1567 e.stopPropagation(); 1568 if (window.chatModule?.deleteMessage) window.chatModule.deleteMessage(msgElement); 1569 }}, 1570 { id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) { 1571 e.stopPropagation(); 1572 const btn = e.currentTarget;
1573 uiModule.copyToClipboard(msgElement.querySelector('.body')?.textContent || ''); 1574 btn.innerHTML = CHECK_ICON; 1575 setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500); 1576 }}, 1577 { id: 'resend', icon: '\u21BB', title: 'Resend message', cls: 'msg-action-btn', handler(e) { 1578 e.stopPropagation(); 1579 if (window.chatModule?.resendUserMessage) window.chatModule.resendUserMessage(msgElement); 1580 }}, 1581 ]; 1582 1583 const recent = _getUserRecentActions(); 1584 const defaults = ['edit', 'delete', 'copy']; 1585 const order = recent.length > 0 ? recent : defaults; 1586 const sorted = [...allActions].sort((a, b) => { 1587 const ai = order.indexOf(a.id), bi = order.indexOf(b.id); 1588 if (ai >= 0 && bi >= 0) return ai - bi; 1589 if (ai >= 0) return -1; 1590 if (bi >= 0) return 1; 1591 return 0; 1592 }); 1593 const visible = sorted.slice(0, _MAX_VISIBLE); 1594 const overflow = sorted.slice(_MAX_VISIBLE); 1595 1596 visible.forEach(a => { 1597 const btn = _makeActionBtn(a.cls, a.title, a.html ? '' : a.icon, (ev) => { 1598 _trackUserAction(a.id); 1599 a.handler(ev); 1600 }); 1601 if (a.html) btn.innerHTML = a.icon; 1602 btn.dataset.action = a.id; 1603 actions.appendChild(btn); 1604 }); 1605 1606 if (overflow.length > 0) { 1607 const moreBtn = document.createElement('button'); 1608 moreBtn.className = 'msg-action-btn msg-more-btn'; 1609 moreBtn.type = 'button'; 1610 moreBtn.title = 'More actions'; 1611 moreBtn.textContent = '\u00B7\u00B7\u00B7'; 1612 moreBtn.addEventListener('click', (e) => { 1613 e.stopPropagation(); 1614 const existing = document.querySelector('.msg-overflow-menu'); 1615 if (existing) { 1616 if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); 1617 if (existing._trigger === moreBtn) return; 1618 } 1619 1620 const menu = document.createElement('div'); 1621 menu.className = 'msg-overflow-menu'; 1622 let closeMenu = () => menu.remove(); 1623 overflow.forEach(a => { 1624 const item = document.createElement('button'); 1625 item.className = 'msg-overflow-item'; 1626 item.type = 'button'; 1627 item.title = a.title; 1628 item.innerHTML = `<span class="overflow-icon">${a.icon}</span> ${a.title}`; 1629 item.addEventListener('click', (ev) => { 1630 ev.stopPropagation(); 1631 _trackUserAction(a.id); 1632 closeMenu(); 1633 a.handler(ev); 1634 }); 1635 menu.appendChild(item); 1636 }); 1637 menu._trigger = moreBtn; 1638 document.body.appendChild(menu); 1639 const btnRect = moreBtn.getBoundingClientRect(); 1640 menu.style.top = (btnRect.top - menu.offsetHeight - 4) + 'px'; 1641 menu.style.left = btnRect.left + 'px'; 1642 if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px'; 1643 const mr = menu.getBoundingClientRect(); 1644 if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px'; 1645 closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); }); 1646 actions.appendChild(moreBtn); 1647 } 1648 1649 footer.appendChild(actions); 1650 return footer; 1651} 1652 1653/** 1654 * Display performance metrics for a message. 1655 */ 1656export function displayMetrics(messageElement, metrics) { 1657 const existingMetrics = messageElement.querySelector('.response-metrics'); 1658 if (existingMetrics) existingMetrics.remove(); 1659 1660 const metricsContainer = document.createElement('span'); 1661 metricsContainer.className = 'response-metrics'; 1662 1663 const responseTime = metrics.response_time; 1664 const inputTokens = metrics.input_tokens || 0; 1665 const outputTokens = metrics.output_tokens || 0; 1666 const tps = metrics.tokens_per_second; 1667 const isReal = metrics.usage_source === 'real'; 1668 const ctxPct = metrics.context_percent; 1669 const model = metrics.model || 'Unknown'; 1670 const cost = _billableCost(model, inputTokens, outputTokens); 1671 1672 // Nothing useful to show â bail out (only if ALL metrics are missing) 1673 if (!responseTime && !outputTokens && tps == null && !ctxPct) return; 1674 1675 // Accumulate session cost (only on fresh metrics, not history reload) 1676 if (!metrics._fromHistory) { 1677 const _sid = window.sessionModule && window.sessionModule.getCurrentSessionId(); 1678 if (_sid && cost !== null) {
1679 try { 1680 const _costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); 1681 _costs[_sid] = (_costs[_sid] || 0) + cost; 1682 localStorage.setItem(_COST_KEY, JSON.stringify(_costs)); 1683 } catch (_e) { /* ignore */ } 1684 updateSessionCostUI(); 1685 } 1686 } 1687 1688 // Default: show tok/s if available, else fall back to other stats 1689 const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null; 1690 const metricsLabel = tps != null && tps !== 'undefined' 1691 ? `${tps} tok/s` 1692 : costStr0 1693 ? `${outputTokens} tok · ${costStr0}` 1694 : outputTokens 1695 ? `${outputTokens} tok · ${responseTime != null ? responseTime + 's' : ''}` 1696 : responseTime != null 1697 ? `${responseTime}s` 1698 : ''; 1699 if (!metricsLabel) return; 1700 metricsContainer.textContent = metricsLabel; 1701 metricsContainer.style.cursor = 'pointer'; 1702 metricsContainer.title = 'Click for details'; 1703 const metricsDivider = document.createElement('span'); 1704 metricsDivider.textContent = ' | '; 1705 metricsDivider.style.color = 'var(--color-muted-alt)'; 1706 metricsDivider.style.pointerEvents = 'none'; 1707 metricsContainer.addEventListener('click', (e) => { 1708 e.stopPropagation(); 1709 document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); }); 1710 1711 const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : 'n/a'; 1712 const speedStr = tps != null && tps !== 'undefined' ? `${tps} tok/s` : 'n/a'; 1713 const totalTok = inputTokens + outputTokens; 1714 const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--color-muted-alt, #6b7280)'; 1715 const prepTime = metrics.agent_prep_time; 1716 const modelWaitTime = metrics.agent_model_wait_time; 1717 const prepBreakdown = metrics.agent_prep_breakdown || null; 1718 const prepDetails = prepBreakdown 1719 ? Object.entries(prepBreakdown).map(([k, v]) => `${k}: ${v}s`).join('<br>') 1720 : ''; 1721 1722 // Session total cost 1723 let sessionCostStr = ''; 1724 const sc = getSessionCost(); 1725 if (sc > 0) { 1726 sessionCostStr = `<div><span class="ctx-label">Session</span> $${sc < 0.01 ? sc.toFixed(4) : sc.toFixed(3)}</div>`; 1727 } 1728 1729 const popup = document.createElement('div'); 1730 popup.className = 'ctx-popup'; 1731 popup.innerHTML = ` 1732 <div style="font-weight:600;margin-bottom:6px;color:var(--fg);">Message Stats</div> 1733 <div><span class="ctx-label">Model</span> ${model.split('/').pop()}</div> 1734 <div><span class="ctx-label">Input</span> ${inputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</div> 1735 <div><span class="ctx-label">Output</span> ${outputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</div> 1736 <div><span class="ctx-label">Total</span> ${totalTok.toLocaleString()} tokens</div> 1737 <div><span class="ctx-label">
1737Speed</span> ${speedStr}</div> 1738 <div><span class="ctx-label">Time</span> ${responseTime}s</div> 1739 ${prepTime != null ? `<div><span class="ctx-label">Prep</span> ${prepTime}s</div>` : ''} 1740 ${modelWaitTime != null ? `<div><span class="ctx-label">Model wait</span> ${modelWaitTime}s</div>` : ''} 1741 <div><span class="ctx-label">Cost</span> ${costStr}</div> 1742 ${sessionCostStr} 1743 ${prepDetails ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);font-size:0.85em;opacity:0.8;"> 1744 <div style="font-weight:600;margin-bottom:4px;color:var(--fg);">Agent prep</div> 1745 ${prepDetails} 1746 </div>` : ''} 1747 ${ctxPct !== undefined && ctxPct > 0 ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);"> 1748 <span class="ctx-label">Context</span> <span style="color:${ctxColor};font-weight:600;">${ctxPct}%</span> used 1749 </div>` : ''} 1750 ${isReal ? '' : '<div style="margin-top:4px;font-size:0.8em;opacity:0.4;">~ estimated token count</div>'} 1751 `; 1752 1753 const rect = metricsContainer.getBoundingClientRect(); 1754 popup.style.left = rect.left + 'px'; 1755 popup.style.visibility = 'hidden'; 1756 document.body.appendChild(popup); 1757 const pr = popup.getBoundingClientRect(); 1758 const spaceAbove = rect.top; 1759 const spaceBelow = window.innerHeight - rect.bottom; 1760 if (spaceAbove >= pr.height + 8 || spaceAbove > spaceBelow) { 1761 popup.style.top = (rect.top - pr.height - 8) + 'px'; 1762 } else { 1763 popup.style.top = (rect.bottom + 8) + 'px'; 1764 } 1765 if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px'; 1766 if (parseFloat(popup.style.left) < 8) popup.style.left = '8px'; 1767 popup.style.visibility = ''; 1768 1769 bindMenuDismiss(popup, () => popup.remove()); 1770 }); 1771 1772 // Store real context length for model info popup 1773 if (metrics.context_length && metrics.model) { 1774 if (!window._realContextLengths) window._realContextLengths = {}; 1775 window._realContextLengths[metrics.model] = metrics.context_length; 1776 } 1777 1778 // Context usage ring 1779 let ctxRing = null; 1780 const ctxLen = metrics.context_length || 0; 1781 if (ctxPct !== undefined && ctxPct > 0) { 1782 const r = 6, stroke = 1.5; 1783 const circ = 2 * Math.PI * r; 1784 const fill = circ * (ctxPct / 100); 1785 const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--green, #98c379)'; 1786 ctxRing = document.createElement('span'); 1787 ctxRing.className = 'ctx-ring'; 1788 ctxRing.title = `${ctxPct}% context used â click for details`; 1789 ctxRing.style.cursor = 'pointer'; 1790 ctxRing.style.setProperty('--ctx-color', ctxColor); 1791 ctxRing.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14"> 1792 <circle cx="7" cy="7" r="${r}" fill="none" stroke="var(--border, #333)" stroke-width="${stroke}" opacity="0.3"/> 1793 <circle cx="7" cy="7" r="${r}" fill="none" stroke="var(--ctx-stroke)" stroke-width="${stroke}" 1794 stroke-dasharray="${fill} ${circ - fill}" stroke-dashoffset="${circ * 0.25}" 1795 stroke-linecap="round" transform="rotate(-90 7 7)"/> 1796 </svg><span class="ctx-ring-pct">${Math.round(ctxPct)}%</span>`; 1797 1798 ctxRing.addEventListener('click', (e) => { 1799 e.stopPropagation(); 1800 document.querySelectorAll('.ctx-detail-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); }); 1801 1802 const usedTokens = inputTokens || 0; 1803 const totalCtx = ctxLen || 0; 1804 const modelShort = model.split('/').pop(); 1805 const fmtNum = n => n ? n.toLocaleString() : '?'; 1806 1807 const popup = document.createElement('div'); 1808 popup.className = 'ctx-detail-popup'; 1809 popup.innerHTML = ` 1810 <div style="font-weight:600;margin-bottom:8px;color:var(--fg);">Context Window</div> 1811 <div class="ctx-bar-wrap"> 1812 <div class="ctx-bar-fill" style="width:${Math.min(ctxPct, 100)}%;background:${ctxColor};"></div> 1813 </div> 1814 <div style="display:flex;justify-content:space-between;font-size:0.75rem;margin-top:4px;opacity:0.6;"> 1815 <span>${fmtNum(usedTokens)} used</span>
1816 <span>${fmtNum(totalCtx)} total</span> 1817 </div> 1818 <div style="margin-top:8px;font-size:0.8rem;"> 1819 <div><span class="ctx-label">Model</span> ${modelShort}</div> 1820 <div><span class="ctx-label">Usage</span> <span style="color:${ctxColor};font-weight:600;">${ctxPct}%</span></div> 1821 <div><span class="ctx-label">Window</span> ${fmtNum(totalCtx)} tokens</div> 1822 </div> 1823 ${ctxPct >= 70 ? `<button class="ctx-compact-btn" title="Summarize older messages to free up context">Compact context</button>` : ''} 1824 `; 1825 1826 const compactBtn = popup.querySelector('.ctx-compact-btn'); 1827 if (compactBtn) { 1828 compactBtn.addEventListener('click', async (e) => { 1829 e.stopPropagation(); 1830 const sid = window.sessionModule && window.sessionModule.getCurrentSessionId(); 1831 if (!sid) return; 1832 popup.remove(); 1833 1834 // Add a spinner bubble at the bottom of chat 1835 const chatBox = document.getElementById('chat-history'); 1836 if (!chatBox) return; 1837 const compactMsg = document.createElement('div'); 1838 compactMsg.className = 'msg msg-ai'; 1839 const compactRole = document.createElement('div'); 1840 compactRole.className = 'role'; 1841 compactRole.textContent = 'Odysseus'; 1842 const compactBody = document.createElement('div'); 1843 compactBody.className = 'body'; 1844 compactBody.innerHTML = 'Compacting context <span class="compact-wave">ââââ ââ</span>'; 1845 compactMsg.appendChild(compactRole); 1846 compactMsg.appendChild(compactBody); 1847 chatBox.appendChild(compactMsg); 1848 chatBox.scrollTop = chatBox.scrollHeight; 1849 1850 // Animate the wave 1851 const waveFrames = ['ââââ ââ', 'âââ âââ', 'ââ ââââ', 'â âââââ', 'ââââââ ', 'âââââ â']; 1852 let frame = 0; 1853 const waveEl = compactBody.querySelector('.compact-wave'); 1854 const waveInterval = setInterval(() => { 1855 frame = (frame + 1) % waveFrames.length; 1856 if (waveEl) waveEl.textContent = waveFrames[frame]; 1857 }, 150); 1858 1859 try { 1860 const res = await fetch(window.location.origin + '/api/session/' + sid + '/compact', { 1861 method: 'POST', 1862 headers: { 'Content-Type': 'application/json' }, 1863 }); 1864 clearInterval(waveInterval); 1865 if (res.ok) { 1866 const data = await res.json(); 1867 // Reload session â the compacted history will show 1868 if (window.sessionModule) await window.sessionModule.selectSession(sid); 1869 // Scroll to the compacted message (first msg with compacted metadata) 1870 setTimeout(() => { 1871 const msgs = document.querySelectorAll('#chat-history .msg'); 1872 for (const m of msgs) { 1873 if (m.querySelector('.body')?.textContent.includes('Conversation compacted')) { 1874 m.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1875 break; 1876 } 1877 } 1878 }, 200); 1879 } else { 1880 compactBody.innerHTML = '<span style="color:var(--red);">Compaction failed. Try again later.</span>'; 1881 } 1882 } catch (err) { 1883 clearInterval(waveInterval); 1884 console.warn('compact failed:', err); 1885 compactBody.innerHTML = '<span style="color:var(--red);">Compaction failed: ' + err.message + '</span>'; 1886 } 1887 }); 1888 } 1889 1890 const rect = ctxRing.getBoundingClientRect(); 1891 popup.style.visibility = 'hidden'; 1892 document.body.appendChild(popup); 1893 const pr = popup.getBoundingClientRect(); 1894 // Position above the ring, right-aligned 1895 popup.style.left = Math.max(8, rect.right - pr.width) + 'px'; 1896 const spaceAbove = rect.top; 1897 if (spaceAbove >= pr.height + 8) { 1898 popup.style.top = (rect.top - pr.height - 8) + 'px'; 1899 } else { 1900 popup.style.top = (rect.bottom + 8) + 'px'; 1901 } 1902 popup.style.visibility = ''; 1903
1904 bindMenuDismiss(popup, () => popup.remove(), (ev) => !popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target)); 1905 }); 1906 } 1907 1908 let footer = messageElement.querySelector('.msg-footer'); 1909 if (footer) { 1910 const actions = footer.querySelector('.msg-actions'); 1911 if (actions) { 1912 footer.insertBefore(metricsDivider, actions); 1913 footer.insertBefore(metricsContainer, metricsDivider); 1914 } else { 1915 footer.appendChild(metricsContainer); 1916 footer.appendChild(metricsDivider); 1917 } 1918 if (ctxRing) { 1919 const ctxDiv = document.createElement('span'); 1920 ctxDiv.textContent = ' | '; 1921 ctxDiv.style.color = 'var(--color-muted-alt)'; 1922 ctxDiv.style.pointerEvents = 'none'; 1923 ctxDiv.className = 'ctx-divider'; 1924 footer.appendChild(ctxDiv); 1925 footer.appendChild(ctxRing); 1926 } 1927 } else { 1928 messageElement.appendChild(metricsContainer); 1929 if (ctxRing) messageElement.appendChild(ctxRing); 1930 } 1931 1932 if (uiModule) uiModule.scrollHistory(); 1933} 1934 1935/** 1936 * Add a message to the chat history. 1937 */ 1938export function addMessage(role, content, modelName, metadata) { 1939 try { 1940 hideWelcomeScreen(); 1941 const box = document.getElementById('chat-history'); 1942 if (!box) { console.error('Chat history element not found'); return; } 1943 1944 var esc = uiModule.esc; 1945 const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content; 1946 1947 // --- Agent multi-bubble reconstruction from saved metadata --- 1948 if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) { 1949 const roundTexts = metadata.round_texts || []; 1950 const toolEvents = metadata.tool_events; 1951 let lastWrap = null; 1952 let firstMsgAi = null; 1953 let lastMsgAi = null; 1954 1955 const toolsByRound = {}; 1956 for (const ev of toolEvents) { 1957 const r = ev.round || 1; 1958 if (!toolsByRound[r]) toolsByRound[r] = []; 1959 toolsByRound[r].push(ev); 1960 } 1961 1962 const maxRound = Math.max(...Object.keys(toolsByRound).map(Number), roundTexts.length); 1963 1964 for (let r = 0; r < maxRound; r++) { 1965 const roundNum = r + 1; 1966 const txt = (roundTexts[r] || '').trim(); 1967 1968 if (txt) { 1969 const wrap = document.createElement('div'); 1970 wrap.className = 'msg msg-ai' + (r > 0 ? ' msg-continuation' : ''); 1971 const roleEl = document.createElement('div'); 1972 roleEl.className = 'role'; 1973 const pair = replyModelPair(modelName, metadata); 1974 const contModel = pair.actualModel || pair.requestedModel; 1975 roleEl.textContent = modelRouteLabel(pair.requestedModel, contModel); 1976 if (pair.requestedModel && contModel && !sameModelName(pair.requestedModel, contModel)) { 1977 roleEl.title = pair.requestedModel + ' -> ' + contModel; 1978 } 1979 applyModelColor(roleEl, contModel); 1980 if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp)); 1981 wrap.appendChild(roleEl); 1982 const body = document.createElement('div'); 1983 body.className = 'body'; 1984 // Check if this is the last text round â sources go on top of final response 1985 var agentSourcesPrefix = ''; 1986 var isLastTextRound = true; 1987 for (let rr = r + 1; rr < maxRound; rr++) { 1988 if ((roundTexts[rr] || '').trim()) { isLastTextRound = false; break; } 1989 } 1990 var agentFindingsSuffix = ''; 1991 if (isLastTextRound && metadata?.web_sources?.length) { 1992 agentSourcesPrefix = buildSourcesBox(metadata.web_sources, 'web'); 1993 } else if (isLastTextRound && metadata?.research_sources?.length) { 1994 agentSourcesPrefix = buildSourcesBox(metadata.research_sources, 'research'); 1995 } 1996 if (isLastTextRound && metadata?.research_findings?.length) { 1997 agentFindingsSuffix = buildFindingsBox(metadata.research_findings); 1998 } 1999 // RAG document sources â restored on the final text round. 2000 if (isLastTextRound && metadata?.rag_sources?.length) { 2001 agentFindingsSuffix += buildRagSourcesBox(metadata.rag_sources); 2002 } 2003 body.innerHTML = agentSourcesPrefix + markdownModule.processWithThinking(markdownModule.squashOutsideCode(txt)) + agentFindingsSuffix; 2004 wrap.appendChild(body); 2005 wrap.dataset.raw = txt; 2006 if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id; 2007 box.appendChild(wrap); 2008 lastWrap = wrap; 2009 if (!firstMsgAi) firstMsgAi = wrap; 2010 lastMsgAi = wrap; 2011 } 2012 2013 const roundTools = toolsByRound[roundNum] || []; 2014 if (roundTools.length > 0) { 2015 // Reuse previous thread if no text separated us (merge consecutive tool rounds) 2016 let threadWrap = null; 2017 if (!txt && lastWrap && lastWrap.classList.contains('agent-thread')) { 2018 threadWrap = lastWrap; 2019 } else { 2020 threadWrap = document.createElement('div'); 2021 threadWrap.className = 'agent-thread'; 2022 // Extend line up if there's a chat bubble above 2023 if (txt) threadWrap.classList.add('has-top'); 2024 box.appendChild(threadWrap); 2025 } 2026 for (const ev of roundTools) { 2027 const ok = (ev.exit_code === 0 || ev.exit_code == null); 2028 let outHtml = ''; 2029 if (ev.output && ev.output.trim()) { 2030 outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(ev.output)}</pre></details>`; 2031 } 2032 const screenshotSrc = safeToolScreenshotSrc(ev.screenshot); 2033 if (screenshotSrc) { 2034 outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(screenshotSrc)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`; 2035 } 2036 // File-write/edit diff (persisted in the tool event) \u2014 re-render it 2037 // so it survives reload, matching the live stream. 2038 let evDiffHtml = ''; 2039 if (ev.diff && ev.diff.text) { 2040 const d = ev.diff; 2041 const stat = [ 2042 d.new_file ? '<span class="diff-stat-new">new</span>' : '', 2043 d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '', 2044 d.removed ? `<span class="diff-stat-del">\u2212${d.removed}</span>` : '',
2045 ].filter(Boolean).join(' '); 2046 const rows = d.text.split('\n').map(line => { 2047 let cls = 'diff-ctx', text = line; 2048 if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta'; 2049 else if (line.startsWith('@@')) cls = 'diff-hunk'; 2050 // Drop the leading diff marker (+/-/space) â colour encodes add/del. 2051 else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); } 2052 else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); } 2053 else if (line.startsWith(' ')) { text = line.slice(1); } 2054 return `<span class="${cls}">${esc(text) || ' '}</span>`; 2055 }).join(''); // spans are display:block \u2014 a literal \n would double-space 2056 evDiffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`; 2057 } 2058 const node = document.createElement('div'); 2059 node.className = 'agent-thread-node' + (ok ? '' : ' error'); 2060 // Hide the raw JSON command when a diff says it better (same as live). 2061 const evCmdHtml = (ev.command && !(ev.diff && ev.diff.text)) ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : ''; 2062 node.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">${esc(ev.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}${evDiffHtml}</div>`; 2063 // Click handling is delegated globally \u2014 see chat.js init. 2064 threadWrap.appendChild(node); 2065 } 2066 // Check if next round has text â extend line down to connect 2067 const nextTxt = (roundTexts[r + 1] || '').trim(); 2068 if (nextTxt) threadWrap.classList.add('has-bottom'); 2069 lastWrap = threadWrap; 2070 2071 for (const ev of roundTools) { 2072 if (ev.image_url) { 2073 box.appendChild(buildImageBubble(ev.image_url, ev.image_prompt, ev.image_model, ev.image_size, ev.image_quality, ev.image_id)); 2074 } 2075 } 2076 } 2077 } 2078 2079 const firstWrap = lastMsgAi || lastWrap; 2080 if (firstWrap && firstWrap.classList.contains('msg-ai')) { 2081 if (metadata?.memories_used?.length) firstWrap._memoriesUsed = metadata.memories_used; 2082 firstWrap.appendChild(createMsgFooter(firstWrap)); 2083 if (metadata) displayMetrics(firstWrap, metadata); 2084 } 2085 2086 if (window.hljs) { 2087 box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b)); 2088 } 2089 if (markdownModule.renderMermaid) markdownModule.renderMermaid(box); 2090 return lastWrap; 2091 } 2092 2093 // --- Standard single-bubble message --- 2094 const wrap = document.createElement('div'); 2095 wrap.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai'); 2096 2097 const r = document.createElement('div'); 2098 r.className = 'role'; 2099 const isSlash = metadata?.source === 'slash'; 2100 const isCompacted = metadata?.compacted; 2101 const replyModels = replyModelPair(modelName, metadata); 2102 const resolvedModel = replyModels.actualModel || replyModels.requestedModel; 2103 var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(replyModels.requestedModel, resolvedModel); 2104 if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) { 2105 _roleText += ' (Research)'; 2106 } 2107 if (metadata?.group_model && role !== 'user') { 2108 _roleText = metadata.group_model; 2109 } else if (metadata?.character_name && role !== 'user' && !isSlash && !isCompacted) { 2110 _roleText = metadata.character_name; 2111 } 2112 r.textContent = _roleText; 2113 if (role !== 'user') {
2114 if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && !sameModelName(replyModels.requestedModel, resolvedModel)) { 2115 r.title = replyModels.requestedModel + ' -> ' + resolvedModel; 2116 } 2117 if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel); 2118 r.appendChild(roleTimestamp(metadata?.timestamp)); 2119 } 2120 2121 const b = document.createElement('div'); 2122 b.className = 'body'; 2123 2124 let text = markdownModule.squashOutsideCode(stripToolBlocks(textRaw || '')); 2125 2126 // For user messages, pull out vision-model image descriptions ([Image: name]\n 2127 // <multi-line desc>) into a collapsible "image description" section. Done for 2128 // ALL user messages (not just ones with attachment metadata) so it rebuilds 2129 // from the stored text even after a browser restart drops the cached attachments. 2130 const attachments = metadata?.attachments; 2131 const _visionBlocks = []; 2132 if (role === 'user') { 2133 text = text.replace( 2134 /\n*\[Image: ([^\]]+)\]\n([\s\S]*?)(?=\n*\[Image: |\n*\[Image attached: |\n*=== File: |\n*\[PDF content\]:|$)/g, 2135 (_m, name, desc) => { const d = desc.trim(); if (d) _visionBlocks.push({ name: name, desc: d }); return ''; } 2136 ); 2137 } 2138 // With attachments present, also strip the embedded file/PDF/image-marker text. 2139 if (role === 'user' && attachments?.length) { 2140 // Strip === File: ... === blocks, [PDF content]: blocks, and [Image attached: ...] lines 2141 text = text 2142 .replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+```[\s\S]*?```/g, '') 2143 .replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+[\s\S]*?(?=\n*=== File:|$)/g, '') 2144 .replace(/\n*\[PDF content\]:[\s\S]*?(?=\n*\[PDF content\]|\n*=== File:|$)/g, '') 2145 .replace(/\n*\[Image attached: [^\]]+\]/g, '') 2146 .replace(/\n*\[Attached (?:document|non-text) file\]/g, '') 2147 .trim(); 2148 } 2149 2150 wrap.dataset.raw = text; 2151 if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id; 2152 // Prepend sources box if saved in metadata 2153 var sourcesPrefix = ''; 2154 var findingsSuffix = ''; 2155 if (role === 'assistant' && metadata?.research_sources?.length) { 2156 sourcesPrefix = buildSourcesBox(metadata.research_sources, 'research'); 2157 } else if (role === 'assistant' && metadata?.web_sources?.length) { 2158 sourcesPrefix = buildSourcesBox(metadata.web_sources, 'web'); 2159 } 2160 if (role === 'assistant' && metadata?.research_findings?.length) { 2161 findingsSuffix = buildFindingsBox(metadata.research_findings); 2162 } 2163 // RAG document sources â restored from metadata so they survive refresh. 2164 if (role === 'assistant' && metadata?.rag_sources?.length) { 2165 findingsSuffix += buildRagSourcesBox(metadata.rag_sources); 2166 } 2167 // If thinking is stored in metadata (not in text), reconstruct the full display 2168 if (role === 'assistant' && metadata?.thinking) { 2169 const thinkTime = metadata.thinking_time || null; 2170 const thinkHtml = markdownModule.processWithThinking( 2171 '<think' + (thinkTime ? ` time="${thinkTime}"` : '') + '>' + metadata.thinking + '</think>\n\n' + text 2172 ); 2173 b.innerHTML = sourcesPrefix + thinkHtml + findingsSuffix; 2174 } else { 2175 b.innerHTML = sourcesPrefix + markdownModule.processWithThinking(text) + findingsSuffix; 2176 } 2177 2178 // The vision/OCR caption is stripped from the displayed text above (so the 2179 // bubble doesn't show the raw model output) but no longer rendered as an 2180 // inline collapsible â the user can still view/edit it via the "Caption" 2181 // button on the photo thumbnail. _visionBlocks is intentionally left unused 2182 // so the parsing-and-strip side-effect on `text` still happens. 2183 void _visionBlocks; 2184 2185 // Add "Open Visual Report" button for persisted research messages 2186 if (role === 'assistant' && metadata?.research) { 2187 var _sid = window.sessionModule?.getCurrentSessionId?.(); 2188 if (_sid) _appendReportButton(b, _sid); 2189 } 2190 2191 // Style [Doc edit: ...] prefix in user messages 2192 if (role === 'user') { 2193 // Match compact format: [Doc edit: line X] instruction 2194 b.innerHTML = b.innerHTML.replace( 2195 /\[Doc edit: (lines? [\dâ\-]+)\]\s*/, 2196 '<span class="doc-edit-tag">Doc edit: $1</span> ' 2197 ); 2198 // Match raw format: "In the document, edit this specific text (line X):\n```\n...\n```\n\nInstruction: ..." 2199 // After markdown processing this becomes a <p> + <pre><code> block + <p>Instruction: text</p> 2200 const rawDocMatch = b.innerHTML.match(/In the document, edit this specific text \((lines? [\dâ\-]+)\)/); 2201 if (rawDocMatch) { 2202 const lineRef = rawDocMatch[1]; 2203 // Extract instruction text (after "Instruction: ") 2204 const instrMatch = b.textContent.match(/Instruction:\s*([\s\S]*)$/); 2205 const instrText = instrMatch ? instrMatch[1].trim() : ''; 2206 b.innerHTML = '<span class="doc-edit-tag">Doc edit: ' + lineRef + '</span> ' + markdownModule.processWithThinking(instrText); 2207 } 2208 2209 // Render attachment cards 2210 if (attachments?.length) { 2211 b.appendChild(buildAttachCards(attachments)); 2212 } 2213 } 2214 2215 wrap.appendChild(r); 2216 wrap.appendChild(b); 2217 2218 // Add stopped indicator + continue button for messages that were stopped by user 2219 if (role === 'assistant' && metadata?.stopped) { 2220 const stoppedIndicator = document.createElement('div'); 2221 stoppedIndicator.className = 'stopped-indicator'; 2222 const stoppedLabel = document.createElement('span'); 2223 // Differentiate between "stopped mid-stream" (had content, can continue) 2224 // and "cancelled before any content" â the latter has no Continue affordance. 2225 stoppedLabel.textContent = metadata.cancelled 2226 ? '[Cancelled by user]' 2227 : '[Message interrupted]'; 2228 stoppedIndicator.appendChild(stoppedLabel); 2229 // Continue button only makes sense when there's partial content to 2230 // resume from \u2014 skip it for fully-cancelled (empty) turns. 2231 if (!metadata.cancelled) { 2232 const continueBtn = document.createElement('button'); 2233 continueBtn.className = 'continue-btn'; 2234 continueBtn.title = 'Continue'; 2235 continueBtn.textContent = '\u25B8'; 2236 continueBtn.addEventListener('click', () => { 2237 stoppedIndicator.remove(); 2238 if (window.chatModule) { 2239 window.chatModule.setHideUserBubble(); 2240 window.chatModule.setPendingContinue(wrap); 2241 const rawText = wrap.dataset.raw || wrap.querySelector('.body')?.textContent || ''; 2242 const cutoff = rawText; 2243 const msgInput = document.getElementById('message'); 2244 if (msgInput) { 2245 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.'; 2246 const sb = document.querySelector('.send-btn'); 2247 if (sb) sb.click(); 2248 } 2249 } 2250 }); 2251 stoppedIndicator.appendChild(continueBtn); 2252 } 2253 b.appendChild(stoppedIndicator); 2254 } 2255 2256 if (metadata?.edited) { 2257 const editedIndicator = document.createElement('div'); 2258 editedIndicator.className = 'edited-indicator'; 2259 editedIndicator.textContent = '[Message edited]'; 2260 b.appendChild(editedIndicator); 2261 } 2262 2263 // Restore variant navigation from saved metadata 2264 if (role === 'assistant' && metadata?.variants && metadata.variants.length > 1) { 2265 wrap.dataset.variants = JSON.stringify(metadata.variants); 2266 const idx = metadata.variantIndex ?? metadata.variants.length - 1; 2267 wrap.dataset.variantIndex = String(idx); 2268 2269 // Re-render from `raw` markdown rather than trusting cached `v.html`. 2270 // Variants ride through localStorage / chat export-import; cached HTML 2271 // would let an attacker-controlled session JSON inject markup. 2272 const _renderVariant = (v) => (v && v.raw) 2273 ? markdownModule.processWithThinking(markdownModule.squashOutsideCode(v.raw)) 2274 : (v && v.html) || ''; 2275 2276 // Show the selected variant's content 2277 const v = metadata.variants[idx]; 2278 if (v) { 2279 b.innerHTML = _renderVariant(v); 2280 wrap.dataset.raw = v.raw; 2281 } 2282 2283 // Render nav 2284 const nav = document.createElement('span'); 2285 nav.className = 'variant-nav'; 2286 nav.addEventListener('click', (e) => e.stopPropagation()); 2287 2288 const divider = document.createElement('span'); 2289 divider.className = 'variant-divider'; 2290 divider.textContent = '|'; 2291 nav.appendChild(divider); 2292 2293 const tagLabel = document.createElement('span'); 2294 const _icons = { regen: '\u21BB', shorter: '\u2702', simpler: '?', original: '\u25CB' }; 2295 const _tl0 = metadata.variants[idx]?.label; 2296 tagLabel.className = 'variant-tag' + (_tl0 === 'shorter' ? ' variant-tag-scissors' : ''); 2297 tagLabel.textContent = _icons[_tl0] || ''; 2298 nav.appendChild(tagLabel); 2299 2300 const prevBtn = document.createElement('button'); 2301 prevBtn.className = 'variant-btn'; 2302 prevBtn.textContent = '<'; 2303 prevBtn.disabled = idx === 0; 2304 nav.appendChild(prevBtn); 2305 2306 const numLeft = document.createElement('button'); 2307 numLeft.className = 'variant-num'; 2308 numLeft.textContent = String(idx + 1); 2309 numLeft.disabled = idx === 0; 2310 nav.appendChild(numLeft); 2311 2312 const slash = document.createElement('span'); 2313 slash.className = 'variant-slash'; 2314 slash.textContent = '/'; 2315 nav.appendChild(slash); 2316 2317 const numRight = document.createElement('button'); 2318 numRight.className = 'variant-num'; 2319 numRight.textContent = String(metadata.variants.length); 2320 numRight.disabled = idx === metadata.variants.length - 1; 2321 nav.appendChild(numRight); 2322 2323 const nextBtn = document.createElement('button'); 2324 nextBtn.className = 'variant-btn'; 2325 nextBtn.textContent = '>'; 2326 nextBtn.disabled = idx === metadata.variants.length - 1; 2327 nav.appendChild(nextBtn); 2328 2329 const switchFn = (newIdx) => { 2330 const vars = metadata.variants; 2331 if (newIdx < 0 || newIdx >= vars.length) return; 2332 const sv = vars[newIdx]; 2333 b.innerHTML = _renderVariant(sv); 2334 wrap.dataset.raw = sv.raw; 2335 wrap.dataset.variantIndex = String(newIdx); 2336 if (window.hljs) wrap.querySelectorAll('pre code').forEach(bl => window.hljs.highlightElement(bl)); 2337 tagLabel.textContent = _icons[sv.label] || ''; 2338 tagLabel.className = 'variant-tag' + (sv.label === 'shorter' ? ' variant-tag-scissors' : ''); 2339 numLeft.textContent = String(newIdx + 1); 2340 numLeft.disabled = newIdx === 0; 2341 numRight.disabled = newIdx === vars.length - 1; 2342 prevBtn.disabled = newIdx === 0; 2343 nextBtn.disabled = newIdx === vars.length - 1; 2344 }; 2345 prevBtn.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) - 1); }); 2346 numLeft.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) - 1); }); 2347 numRight.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) + 1); }); 2348 nextBtn.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) + 1); }); 2349 2350 r.appendChild(nav); 2351 } 2352 2353 if (role === 'assistant') { 2354 // The "N pinned" / "N recalled" pill in the footer reads from 2355 // wrap._memoriesUsed â propagate it from saved metadata so the pill 2356 // survives a page refresh (live-stream path sets it via SSE, but 2357 // history reloads need this assignment). 2358 if (metadata?.memories_used?.length) wrap._memoriesUsed = metadata.memories_used; 2359 wrap.appendChild(createMsgFooter(wrap)); 2360 if (metadata) displayMetrics(wrap, metadata); 2361 } else { 2362 // Add timestamp to user header (like AI messages) 2363 r.appendChild(roleTimestamp(metadata?.timestamp)); 2364 2365 wrap.appendChild(createUserMsgFooter(wrap)); 2366 } 2367 2368 box.appendChild(wrap); 2369 2370 // TTS is now part of the msg-actions system 2371 if (role === 'assistant' && markdownModule.renderMermaid) { 2372 markdownModule.renderMermaid(wrap); 2373 } 2374 return wrap; 2375 } catch (error) { 2376 console.error('Error in addMessage:', error); 2377 if (uiModule) uiModule.showError('Failed to add message: ' + error.message); 2378 } 2379} 2380 2381const chatRenderer = { 2382 shortModel, 2383 sameModelName, 2384 modelRouteLabel, 2385 replyModelPair, 2386 modelColor, 2387 applyModelColor, 2388 getModelCost, 2389 getImageCost, 2390 getSessionCost, 2391 resetSessionCost, 2392 updateSessionCostUI, 2393 roleTimestamp, 2394 stripToolBlocks, 2395 safeToolScreenshotSrc, 2396 safeDisplayImageSrc, 2397 buildSourcesBox, 2398 buildFindingsBox, 2399 appendReportButton, 2400 buildImageBubble, 2401 hideWelcomeScreen, 2402 showWelcomeScreen, 2403 createMsgFooter, 2404 displayMetrics, 2405 addMessage, 2406 updateMessageAttachments, 2407}; 2408 2409export default chatRenderer;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.