PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/chatRenderer.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:18 UTC 111,219 bytes, 2,409 lines download raw bytes

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) || '&nbsp;'}</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.