Files
odysseus/static/js/chatRenderer.js
nsgds a857d2016d fix: don't bill self-hosted models reached by a container/service hostname (#596)
* fix(cost): treat dotless container hostnames as local (free)

getModelCost() substring-matches model names against a cloud price table, so a self-hosted 'nemotron'/'llama' model was billed at cloud rates. isLocalEndpoint() only recognized IPs / localhost / .local, not bare Docker service names (nim-nano, llamaswap), so the local-is-free guard missed them. A single-label hostname (no dot) can never be a public API -> treat as local.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cost): isLocalEndpoint classifies service names local, cloud FQDNs billable

Covers @pewdiepie-archdaemon's requested cases: llamaswap/nim-nano + localhost/private-IPs/.local => local (free); api.openai.com/openrouter.ai/etc => not local. Drives the real function via node --input-type=module (same approach as test_reply_recipients_js.py), skips when node is absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:47:58 +09:00

2289 lines
103 KiB
JavaScript
Raw Blame History

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