` : '',
};
}
return { label: '', commandHtml: '' };
}
function _suppressRawToolOutput(tool, ok) {
if (!ok) return false;
const lower = String(tool || '').toLowerCase();
if (lower === 'manage_calendar' || lower.endsWith('manage_calendar')) return true;
return (
lower.includes('email')
&& (
lower.includes('list_emails')
|| lower.includes('search_emails')
|| lower.includes('read_email')
|| lower.includes('download_attachment')
|| lower.includes('scan_spam')
|| lower.includes('scan_email_unsubscribes')
)
);
}
function _makeActionBtn(className, title, text, handler) {
const btn = document.createElement('button');
btn.className = className;
btn.type = 'button';
btn.title = title;
btn.textContent = text;
btn.addEventListener('click', handler);
return btn;
}
// Attachment card helpers
function _attachIcon(mimeOrName) {
const s = (mimeOrName || '').toLowerCase();
if (s.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(s))
return '';
if (s.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|webm)$/i.test(s))
return '';
if (s === 'application/pdf' || /\.pdf$/i.test(s))
return '';
// Default: generic document
return '';
}
function _formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
// Build the `.attach-cards` element for a message's attachment list. Shared by
// addMessage and updateMessageAttachments so a live (optimistic) user bubble
// can be re-rendered with real upload ids once the upload resolves.
export function buildAttachCards(attachments) {
const attachWrap = document.createElement('div');
attachWrap.className = 'attach-cards';
for (const att of attachments) {
const isImage = (att.mime || '').startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(att.name || '');
if (isImage) {
// Image preview. Shown for both uploaded (att.id present) and still-
// uploading attachments. A shimmering skeleton + whirlpool fills the
// space until either the upload resolves (no id yet) or the thumbnail
// image finishes loading, so the photo doesn't pop in abruptly.
const imgWrap = document.createElement('div');
imgWrap.className = 'attach-image-preview';
imgWrap.style.cursor = att.id ? 'zoom-in' : 'default';
if (att.id) imgWrap.dataset.fileId = att.id;
if (att.id) {
imgWrap.addEventListener('click', (e) => {
// Tapping the corner OCR button shouldn't also open the lightbox.
if (e.target.closest('.attach-ocr-btn')) return;
_openImageLightbox(att);
});
}
let skel = null;
let sp = null;
if (!att.previewUrl) {
// Skeleton placeholder with a centered whirlpool. Self-stops when removed.
skel = document.createElement('div');
skel.className = 'attach-image-skeleton';
// Match the photo's aspect ratio when the backend knew it at upload
// time, so the skeleton doesn't sit at a 4:3 default and then snap to
// a portrait shape when the image arrives.
if (att.width && att.height) {
skel.style.aspectRatio = att.width + ' / ' + att.height;
skel.style.width = 'auto';
skel.style.height = 'auto';
skel.style.maxWidth = '300px';
skel.style.maxHeight = '200px';
skel.style.minWidth = '80px';
}
sp = spinnerModule.createWhirlpool(20);
skel.appendChild(sp.element);
imgWrap.appendChild(skel);
}
if (att.id || att.previewUrl) {
const img = document.createElement('img');
// Small cached thumbnail — the preview is tiny, no need to pull the
// full-resolution photo. Click still opens the full image.
img.alt = att.name || 'Image';
img.loading = 'lazy';
img.style.cssText = 'max-width:300px;max-height:200px;border-radius:6px;display:' + (att.previewUrl ? 'block' : 'none') + ';';
let _revealed = false;
let _revealTimer = null;
const _reveal = () => {
if (_revealed) return;
_revealed = true;
if (_revealTimer) { clearTimeout(_revealTimer); _revealTimer = null; }
img.style.display = 'block';
try { sp && sp.stop(); } catch {}
if (skel) skel.remove();
};
img.addEventListener('load', _reveal);
img.addEventListener('error', _reveal);
img.src = att.previewUrl || `/api/upload/${att.id}?thumb=1`;
// Cached images can be complete before the load listener attaches.
if (img.complete && img.naturalWidth) _reveal();
// Failsafe: if neither load nor error fires within 8s, reveal anyway.
// The timer is cleared on reveal AND when updateMessageAttachments
// replaces the card (which scrubs the img / skel from the DOM), so
// repeated re-renders don't accumulate stranded timers.
if (!att.previewUrl) _revealTimer = setTimeout(_reveal, 8000);
imgWrap.appendChild(img);
if (att.id) {
// Small corner button → opens the vision/OCR editor so the user can
// correct what the vision model extracted. The edit is cached on the
// server keyed by file id, so any later message referencing this same
// image picks up the corrected text instead of re-running the model.
const ocrBtn = document.createElement('button');
ocrBtn.type = 'button';
ocrBtn.className = 'attach-ocr-btn';
ocrBtn.title = 'View / edit OCR text';
ocrBtn.innerHTML = 'Caption';
ocrBtn.addEventListener('click', (e) => {
e.stopPropagation();
_openVisionEditor(att, ocrBtn.closest('.msg'));
});
imgWrap.appendChild(ocrBtn);
}
}
if (att.vision_model) {
const visionLabel = document.createElement('div');
visionLabel.className = 'attach-vision-model';
visionLabel.textContent = 'Vision: ' + String(att.vision_model).split('/').pop();
imgWrap.appendChild(visionLabel);
}
if (att.name) {
const label = document.createElement('div');
label.className = 'attach-image-name';
label.textContent = att.name;
imgWrap.appendChild(label);
}
attachWrap.appendChild(imgWrap);
} else {
// Non-image file card
const card = document.createElement('div');
card.className = 'attach-card';
card.dataset.name = att.name;
if (att.id) {
card.dataset.fileId = att.id;
card.style.cursor = 'pointer';
card.addEventListener('click', () => {
// PDFs & text/code/markdown → open in the Documents viewer
// (others fall back to the raw file).
if (window.chatModule?.openAttachment) window.chatModule.openAttachment(att, false);
else window.open(`/api/upload/${att.id}`, '_blank');
});
}
const icon = _attachIcon(att.mime || att.name);
const nameSpan = document.createElement('span');
nameSpan.className = 'attach-card-name';
nameSpan.textContent = att.name;
card.innerHTML = icon;
card.appendChild(nameSpan);
if (att.size) {
const sizeSpan = document.createElement('span');
sizeSpan.className = 'attach-card-size';
sizeSpan.textContent = _formatSize(att.size);
card.appendChild(sizeSpan);
}
attachWrap.appendChild(card);
}
}
return attachWrap;
}
// Re-render the attachment cards of an already-rendered message. Used to swap
// in real upload ids (and image thumbnails) on the optimistic user bubble once
// uploadPending() resolves — otherwise image previews only appear after a
// refresh, because the bubble is rendered before the upload assigns ids.
export function updateMessageAttachments(msgWrap, attachments) {
if (!msgWrap || !attachments?.length) return;
const body = msgWrap.querySelector('.body') || msgWrap;
const existing = body.querySelector('.attach-cards');
const fresh = buildAttachCards(attachments);
if (existing) existing.replaceWith(fresh);
else body.appendChild(fresh);
}
// Quick full-size preview when the user taps a chat photo thumbnail. Just an
// overlay with the original image centered — no Gallery panel, no editor.
function _openImageLightbox(att) {
if (!att?.id) return;
const overlay = document.createElement('div');
overlay.className = 'attach-lightbox';
// Show the cached thumb immediately so the overlay doesn't sit blank
// while a 25MB original streams in. The full image swaps in once loaded;
// if the full load fails (404 / network), we keep the thumb + show an
// error label rather than a blank overlay forever.
const img = document.createElement('img');
img.alt = att.name || '';
img.src = `/api/upload/${att.id}?thumb=1`;
overlay.appendChild(img);
const full = new Image();
full.addEventListener('load', () => { img.src = full.src; });
full.addEventListener('error', () => {
const err = document.createElement('div');
err.className = 'attach-lightbox-err';
err.textContent = 'Failed to load full-resolution image.';
overlay.appendChild(err);
});
full.src = `/api/upload/${att.id}`;
const _onKey = (e) => { if (e.key === 'Escape') _close(); };
const _close = () => {
document.removeEventListener('keydown', _onKey);
if (_overlayObs) { try { _overlayObs.disconnect(); } catch {} }
overlay.remove();
};
// If the overlay is removed via any path other than our close handler
// (session switch, parent re-render, external cleanup), still drop the
// document-level keydown listener so it doesn't leak.
let _overlayObs = null;
try {
_overlayObs = new MutationObserver(() => {
if (!document.body.contains(overlay)) {
document.removeEventListener('keydown', _onKey);
_overlayObs.disconnect();
}
});
_overlayObs.observe(document.body, { childList: true, subtree: false });
} catch {}
overlay.addEventListener('click', _close);
document.addEventListener('keydown', _onKey);
document.body.appendChild(overlay);
}
// Vision/OCR editor modal — opened from the corner "Aa" button on a chat photo
// thumbnail. Lets the user view and correct the text the vision model fed to
// the LLM (e.g. when OCR misreads a word). Persists to the server's vision
// cache (PUT /api/upload/{id}/vision), so any subsequent message that
// references the same file picks up the corrected text.
let _visionEditorEl = null;
let _visionEditorEsc = null;
function _closeVisionEditor() {
if (_visionEditorEsc) { document.removeEventListener('keydown', _visionEditorEsc); _visionEditorEsc = null; }
if (_visionEditorEl) { _visionEditorEl.remove(); _visionEditorEl = null; }
}
function _openVisionEditor(att, userMsgEl) {
if (!att?.id) return;
_closeVisionEditor();
const overlay = document.createElement('div');
overlay.className = 'vision-editor-overlay';
overlay.addEventListener('click', (e) => { if (e.target === overlay) _closeVisionEditor(); });
const panel = document.createElement('div');
panel.className = 'vision-editor-panel';
const title = document.createElement('div');
title.className = 'vision-editor-title';
// Eye icon matches the one in Settings → Vision so users recognise where
// this text originates.
title.innerHTML = 'Vision text';
panel.appendChild(title);
const desc = document.createElement('div');
desc.className = 'vision-editor-desc';
desc.textContent = 'Edit text and save, new chats will have the new context. Regenerate or continue from there.';
panel.appendChild(desc);
const ta = document.createElement('textarea');
ta.className = 'vision-editor-text';
ta.rows = 10;
ta.placeholder = 'Loading…';
ta.disabled = true;
panel.appendChild(ta);
const actions = document.createElement('div');
actions.className = 'vision-editor-actions';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'vision-editor-btn';
closeBtn.innerHTML = 'Close';
closeBtn.addEventListener('click', _closeVisionEditor);
const _saveVisionText = async () => {
const res = await fetch(`/api/upload/${att.id}/vision`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ text: ta.value }),
});
if (!res.ok) throw new Error('save failed');
};
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'vision-editor-btn vision-editor-btn-primary';
saveBtn.innerHTML = 'Save';
saveBtn.disabled = true;
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
saveBtn.innerHTML = 'Saving…';
try {
await _saveVisionText();
if (uiModule?.showToast) uiModule.showToast('Saved');
_closeVisionEditor();
} catch (e) {
saveBtn.disabled = false;
saveBtn.innerHTML = 'Save';
if (uiModule?.showError) uiModule.showError('Failed to save OCR text');
}
});
// Regenerate-message: save the edited text, close, then trigger a resend of
// the user message so the new AI reply uses the edit immediately.
const regenBtn = document.createElement('button');
regenBtn.type = 'button';
regenBtn.className = 'vision-editor-btn vision-editor-btn-primary';
regenBtn.title = 'Save and regenerate the message';
regenBtn.innerHTML = 'Regenerate message';
regenBtn.disabled = true;
regenBtn.addEventListener('click', async () => {
regenBtn.disabled = true;
saveBtn.disabled = true;
try {
await _saveVisionText();
_closeVisionEditor();
if (userMsgEl && window.chatModule?.resendUserMessage) {
window.chatModule.resendUserMessage(userMsgEl, { replaceFromHere: true });
} 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;
// Strip fenced tool-call blocks that look like structured invocations, not
// regular code examples. The tool tags are NOT hard-coded here — they are the
// backend's authoritative TOOL_TAGS set, fetched once from GET /api/tools and
// built into EXEC_FENCE_RE at load. TOOL_TAGS (src/agent_tools/__init__.py) is
// thus the single source: the live-strip list can never drift from the backend
// or miss a future tool (#3993). bash/python are carved out on purpose — they
// are languages a user may legitimately have asked the model to show, not tool
// invocations.
//
// Until the fetch resolves, EXEC_FENCE_RE stays null and exec fences aren't
// stripped — normally a sub-second window before the first stream. If the fetch
// fails it stays null for the rest of the session (logged below), so live exec
// fences won't be stripped until reload. Either way the backend already strips
// persisted history (src/tool_parsing.py builds the same regex from TOOL_TAGS),
// so a reload always renders clean.
let EXEC_FENCE_RE = null;
const EXEC_FENCE_NON_TOOL = new Set(['bash', 'python']);
function escapeRegex(source) {
return String(source).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function stripExecutedFence(match, tag, inline, body) {
const inlineArgs = (inline || '').trim();
if (!inlineArgs) return '';
const bodyText = (body || '').trim();
const content = bodyText ? `${inlineArgs}\n${bodyText}` : inlineArgs;
try {
JSON.parse(content);
} catch {
return match;
}
return '';
}
async function loadExecFenceRegex() {
try {
// Shared with admin.js, and — more to the point — with the other copies of
// this module: chatRenderer.js is imported under three different ?v= query
// strings, so it is instantiated three times per load and used to issue
// three identical /api/tools requests. appConfig.js is imported by one
// specifier from all of them, so they now share a single fetch.
const data = await getTools();
const tags = (data.tools || [])
.map((t) => t.id)
.filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id));
if (tags.length) {
EXEC_FENCE_RE = new RegExp(
'```(' + tags.map(escapeRegex).join('|') + ')(?![\\w-])' +
'[ \\t]*([\\[{][^\\n]*?)?[ \\t]*(?=\\r?\\n|```)' +
'\\r?\\n?([\\s\\S]*?)```',
'gi'
);
}
} catch (err) {
// Surface the failure rather than swallowing it: EXEC_FENCE_RE stays null,
// so this session won't strip live exec fences until reload (persisted path
// stays clean regardless).
console.warn('chatRenderer: /api/tools fetch failed; live exec-fence stripping disabled until reload', err);
}
}
loadExecFenceRegex();
// XML-style tool calls: , , , bare
const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi;
const XML_INVOKE_RE = /[\s\S]*?<\/invoke>/gi;
// DeepSeek "DSML" tool-call markup (fullwidth-pipe | or ascii | delimited) that
// leaks into content when the model emits a text tool call instead of a native
// one. Strip the whole block; the second pattern catches stray/partial tags
// (e.g. mid-stream before the closing tag arrives).
const DSML_TOOL_RE = /<\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>|$)/gi;
const DSML_STRAY_RE = /<\s*\/?\s*[||]+\s*DSML\s*[||]+[^>]*>/gi;
const DSML_INVOKE_RE = /<\s*[||]+\s*DSML\s*[||]+\s*invoke\b[^>]*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*invoke\s*>|$)/gi;
const RAW_OPENAI_TOOL_JSON_RE = /(?:\[\s*)?\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"[^"]*"\s*,\s*"type"\s*:\s*"function"\s*\}\s*\]?/gi;
const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi;
// Keep in sync with _QWEN_BARE_MARKER_RE in src/tool_parsing.py. At least one
// pipe is required around `end`: with both optional (`\|?end\|?`) this also ate
// a bare `end` on its own line, breaking Ruby/Lua/shell snippets (#5547).
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|(?:^|[\r\n])[ \t]*assistan(?:t)?[ \t]*(?=[\r\n]|$)/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;
}
function modelValue(name) {
if (name == null) return '';
return String(name).trim();
}
export function sameModelName(left, right) {
const a = modelValue(left);
const b = modelValue(right);
if (!a || !b) return false;
return a.toLowerCase() === b.toLowerCase()
|| shortModel(a).toLowerCase() === shortModel(b).toLowerCase();
}
function shortEndpointLabel(label) {
const value = modelValue(label);
if (!value) return '';
return value.length > 18 ? value.slice(0, 17) + '…' : value;
}
export function modelRouteLabel(
requestedModel,
actualModel,
requestedEndpointLabel = '',
actualEndpointLabel = '',
requestedEndpointId = '',
actualEndpointId = '',
) {
const requested = modelValue(requestedModel);
const actual = modelValue(actualModel) || requested;
const requestedRoute = modelValue(requestedEndpointId || requestedEndpointLabel);
const actualRoute = modelValue(actualEndpointId || actualEndpointLabel);
const routeChanged = Boolean(
actualRoute
&& requestedRoute
&& actualRoute !== requestedRoute
);
if (!requested || sameModelName(requested, actual)) {
const model = shortModel(actual || requested);
if (!routeChanged) return model;
const from = shortEndpointLabel(requestedEndpointLabel || 'Selected route');
const to = shortEndpointLabel(actualEndpointLabel || actualEndpointId);
return model + ' (' + from + ' -> ' + to + ')';
}
return shortModel(requested) + ' -> ' + shortModel(actual);
}
export function replyModelPair(modelName, metadata) {
const meta = metadata || {};
const actualFromMeta = modelValue(meta.model || meta.actual_model);
const requestedFromMeta = modelValue(meta.requested_model || meta.selected_model);
if (actualFromMeta || requestedFromMeta) {
const actual = actualFromMeta || requestedFromMeta || modelValue(modelName);
const requested = requestedFromMeta || actual;
return {
requestedModel: requested,
actualModel: actual,
requestedEndpointId: meta.requested_endpoint_id || null,
requestedEndpointLabel: meta.requested_endpoint_label || 'Selected route',
actualEndpointId: meta.endpoint_id || null,
actualEndpointLabel: meta.endpoint_label || meta.requested_endpoint_label || 'Selected route',
};
}
const fallback = modelValue(modelName);
return {
requestedModel: fallback,
actualModel: fallback,
requestedEndpointId: null,
requestedEndpointLabel: 'Selected route',
actualEndpointId: null,
actualEndpointLabel: 'Selected route',
};
}
/**
* Generate a consistent HSL color for a model name.
* Returns an hsl() string. The hue is derived from a string hash,
* saturation and lightness are fixed for readability on dark/light themes.
*/
export function modelColor(name) {
if (!name) return null;
const key = name.toLowerCase();
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0;
}
const hue = ((hash % 360) + 360) % 360;
return `hsl(${hue}, 55%, 65%)`;
}
/** Look up model info (pricing + context) by substring match */
export function getModelInfo(modelName) {
if (!modelName) return null;
const key = matchModelKey(modelName, Object.keys(MODEL_INFO));
return key ? { key, ...MODEL_INFO[key] } : null;
}
function _fmtCtx(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
return Math.round(n / 1000) + 'K';
}
/**
* Apply model color to a role element (sets color + dot color).
*/
export function applyModelColor(roleEl, modelName) {
if (!modelName) return;
const color = modelColor(modelName);
if (color) {
roleEl.style.color = color;
roleEl.style.setProperty('--model-dot', color);
}
// Replace generic dot with provider logo if available
const logo = providerLogo(modelName);
const existingLogo = roleEl.querySelector('.role-provider-logo');
if (!logo) {
if (existingLogo) existingLogo.remove();
roleEl.classList.remove('has-logo');
} else if (!existingLogo) {
const span = document.createElement('span');
span.className = 'role-provider-logo';
span.innerHTML = logo;
roleEl.classList.add('has-logo');
roleEl.prepend(span);
}
// Click to show model info popup
if (!roleEl._hasInfoClick) {
roleEl._hasInfoClick = true;
roleEl.style.cursor = 'pointer';
roleEl.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
const info = getModelInfo(modelName);
const short = shortModel(modelName);
const logoHtml = providerLogo(modelName);
const popup = document.createElement('div');
popup.className = 'ctx-popup';
let html = '
';
if (logoHtml) html += '' + logoHtml + '';
html += uiModule.esc(short) + '
';
html += '
Model ' + uiModule.esc(modelName.split('/').pop()) + '
';
// Provider = the serving endpoint, distinct from the model vendor/logo
// (e.g. the same model via OpenRouter vs Copilot vs Anthropic direct).
const _epUrl = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
? window.sessionModule.getCurrentEndpointUrl() : null;
const _provLabel = providerLabel(_epUrl);
if (_provLabel) html += '
Provider ' + uiModule.esc(_provLabel) + '
';
// Show static context initially, then fetch real from server
const _realCtx = window._realContextLengths && window._realContextLengths[modelName];
if (_realCtx) {
html += '
Context ' + _fmtCtx(_realCtx) + ' tokens';
if (info && info.ctx && info.ctx !== _realCtx) html += ' (spec: ' + _fmtCtx(info.ctx) + ')';
html += '
';
} else if (info && info.ctx) {
html += '
Context' + _fmtCtx(info.ctx) + ' tokens
';
}
// Fetch real context from server async
if (!_realCtx && window.sessionModule) {
const _sid = window.sessionModule.getCurrentSessionId();
if (_sid) {
fetch('/api/session/' + _sid + '/context_info').then(r => r.ok ? r.json() : null).then(d => {
if (d && d.context_length) {
if (!window._realContextLengths) window._realContextLengths = {};
window._realContextLengths[modelName] = d.context_length;
const el = document.getElementById('_ctx-val');
if (el) {
el.innerHTML = _fmtCtx(d.context_length) + ' tokens';
if (info && info.ctx && info.ctx !== d.context_length) {
el.innerHTML += ' (spec: ' + _fmtCtx(info.ctx) + ')';
}
}
}
}).catch(() => {});
}
}
// Show configured max tokens if set
if (window.presetsModule) {
const _pid = window.presetsModule.getSelectedPreset();
const _preset = _pid ? window.presetsModule.getPreset(_pid) : null;
const _mt = _preset?.max_tokens;
if (_mt && _mt > 0 && _mt <= 8192) {
html += '
Max tokens ' + _mt.toLocaleString() + ' (configured)
';
}
}
if (isCostTrackedEndpoint(_epUrl)) {
if (info && info.input != null) html += '
Input $' + info.input.toFixed(2) + ' / 1M
';
if (info && info.output != null) html += '
Output $' + info.output.toFixed(2) + ' / 1M
';
if (!info) html += '
No pricing data available
';
}
popup.innerHTML = html;
const rect = roleEl.getBoundingClientRect();
popup.style.top = (rect.bottom + 4) + 'px';
popup.style.left = rect.left + 'px';
document.body.appendChild(popup);
const pr = popup.getBoundingClientRect();
if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px';
if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px';
bindMenuDismiss(popup, () => popup.remove());
});
}
}
export function getModelCost(modelName, inputTokens, outputTokens) {
if (!modelName) return null;
const key = matchModelKey(modelName, Object.keys(MODEL_PRICING));
if (!key) return null;
const price = MODEL_PRICING[key];
return (inputTokens * price.input + outputTokens * price.output) / 1_000_000;
}
/**
* Is this endpoint a local / self-hosted model server (vLLM, Ollama, …)?
* Local models are free, so we must NOT bill them at cloud rates — the
* pricing table matches on a name substring, so a local `qwen2.5-coder`
* would otherwise be charged like cloud `qwen2.5`. When the serving host is
* loopback, a private LAN range, Tailscale CGNAT (100.64–100.127.x), a
* `.local` name, or the app's own host, the model is local → free.
* Unknown / missing endpoint also counts as local (bias to not over-bill).
*/
export function isLocalEndpoint(url) {
if (!url) return true;
let host;
try { host = new URL(url).hostname; } catch (_e) { return true; }
if (!host) return true;
if (host === 'localhost' || host === '0.0.0.0' || host === 'host.docker.internal' || host.endsWith('.local')) return true;
if (typeof window !== 'undefined' && window.location && host === window.location.hostname) return true;
// A single-label hostname (no dot) is an internal/Docker service name
// (e.g. "nim-nano", "llamaswap", "nemotron-super-49b") or a LAN shortname —
// never a public API, which always needs an FQDN. Treat as local → free.
// (Without this, container-name endpoints get billed at cloud rates because
// the pricing table matches on a name substring, e.g. "nemotron".)
if (!host.includes('.')) return true;
if (/^127\./.test(host)) return true;
if (/^10\./.test(host)) return true;
if (/^192\.168\./.test(host)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true;
const cg = host.match(/^100\.(\d+)\./); // Tailscale CGNAT
if (cg && +cg[1] >= 64 && +cg[1] <= 127) return true;
return false;
}
export function isSubscriptionEndpoint(url) {
if (!url) return false;
try {
const parsed = new URL(url);
const path = parsed.pathname.replace(/\/+$/, '');
return parsed.hostname === 'chatgpt.com'
&& (path === '/backend-api/codex' || path.startsWith('/backend-api/codex/'));
} catch (_e) {
return false;
}
}
function _currentEndpointUrl() {
return (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
? window.sessionModule.getCurrentEndpointUrl() : null;
}
export function isCostTrackedEndpoint(url) {
return !isLocalEndpoint(url) && !isSubscriptionEndpoint(url);
}
/** Cost for the current turn, returning null for non-billable endpoints. */
function _billableCost(model, inputTokens, outputTokens, endpointCostTracked, selectedEndpointUrl) {
// Foreground fallback can answer on a different endpoint than the session's
// selected route. Prefer the backend's non-secret actual-route
// classification; retain the selected-endpoint check for older history.
if (endpointCostTracked === false) return null;
const selectedUrl = selectedEndpointUrl === undefined
? _currentEndpointUrl()
: selectedEndpointUrl;
if (endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)) {
return null;
}
return getModelCost(model, inputTokens, outputTokens);
}
/** Sum cost using the route/model that produced each Agent round. */
function _metricsBillableCost(metrics, model, inputTokens, outputTokens, selectedEndpointUrl) {
const buckets = Array.isArray(metrics.usage_buckets) ? metrics.usage_buckets : [];
if (!buckets.length) {
return _billableCost(
model,
inputTokens,
outputTokens,
metrics.endpoint_cost_tracked,
selectedEndpointUrl,
);
}
let total = 0;
let hasPricedUsage = false;
for (const bucket of buckets) {
if (!bucket || typeof bucket !== 'object') continue;
const bucketCost = _billableCost(
bucket.model || model,
Number(bucket.input_tokens) || 0,
Number(bucket.output_tokens) || 0,
bucket.endpoint_cost_tracked,
selectedEndpointUrl,
);
if (bucketCost === null) continue;
total += bucketCost;
hasPricedUsage = true;
}
return hasPricedUsage ? total : null;
}
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';
const _COST_RUNS_KEY = 'ody-session-cost-runs';
const _MAX_COST_RUNS_PER_SESSION = 256;
const _COST_LEDGER_LOCK = 'odysseus-session-cost-ledger';
/** 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) || '{}');
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
const recordedRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
? Object.values(runCosts[sid])
: [];
return (costs[sid] || 0) + recordedRuns.reduce(
(total, value) => total + (Number(value) || 0),
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));
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
delete runCosts[sid];
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
} 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;
// The ledger records billable work already performed in this session. A
// selected local endpoint does not erase cost from a paid fallback route.
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';
}
}
/** Record one metrics payload in a session ledger at most once. */
export function recordSessionMetricsCost(metrics, sessionId, selectedEndpointUrl) {
if (!metrics || typeof metrics !== 'object') return null;
const cost = _metricsBillableCost(
metrics,
metrics.model || 'Unknown',
metrics.input_tokens || 0,
metrics.output_tokens || 0,
selectedEndpointUrl,
);
if (metrics._fromHistory) return cost;
const sid = sessionId || (
window.sessionModule && window.sessionModule.getCurrentSessionId()
);
if (!sid || cost === null) return cost;
const runId = typeof metrics._costRecordId === 'string'
? metrics._costRecordId.trim()
: '';
if ((metrics._costRecorded || metrics._costRecordPending) && !runId) return cost;
// Recorded is only set once the write actually runs; pending covers the
// window while the write waits on the cross-tab lock, so a replay in that
// window cannot double-add and a tab closed mid-queue never claims recorded.
metrics._costRecordPending = true;
const writeCost = () => {
if (runId) {
try {
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
const sessionRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
? runCosts[sid]
: {};
// Assigning by detached-run identity is replay-idempotent even when a
// refresh produces a fresh metrics object. The Web Lock around this
// read/modify/write also keeps distinct runs from two tabs from
// overwriting one another's stale snapshot.
sessionRuns[runId] = cost;
const entries = Object.entries(sessionRuns);
if (entries.length > _MAX_COST_RUNS_PER_SESSION) {
const overflow = entries.slice(0, entries.length - _MAX_COST_RUNS_PER_SESSION);
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
costs[sid] = (costs[sid] || 0) + overflow.reduce(
(total, entry) => total + (Number(entry[1]) || 0),
0,
);
overflow.forEach(([oldRunId]) => delete sessionRuns[oldRunId]);
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
}
runCosts[sid] = sessionRuns;
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
} catch (_e) { /* ignore */ }
} else {
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 */ }
}
metrics._costRecorded = true;
metrics._costRecordPending = false;
const currentSid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (currentSid === sid) updateSessionCostUI();
};
let writeStarted = false;
const guardedWrite = () => {
writeStarted = true;
writeCost();
};
try {
if (
typeof navigator !== 'undefined'
&& navigator.locks
&& typeof navigator.locks.request === 'function'
) {
const pendingWrite = navigator.locks.request(_COST_LEDGER_LOCK, guardedWrite);
if (pendingWrite && typeof pendingWrite.catch === 'function') {
pendingWrite.catch(() => {
if (!writeStarted) guardedWrite();
});
}
} else {
guardedWrite();
}
} catch (_e) {
if (!writeStarted) guardedWrite();
}
return cost;
}
/** 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;
}
function userModePill(metadata) {
const mode = String(metadata?.interaction_mode || metadata?.mode || '').toLowerCase();
if (mode !== 'agent' && mode !== 'chat' && mode !== 'research') return null;
const pill = document.createElement('span');
pill.className = 'user-mode-pill user-mode-pill-' + mode;
if (mode === 'agent' || mode === 'chat') {
pill.innerHTML = mode === 'agent' ? USER_MODE_AGENT_ICON : USER_MODE_CHAT_ICON;
pill.setAttribute('aria-label', mode === 'agent' ? 'Agent mode' : 'Chat mode');
} else {
pill.textContent = 'research';
}
pill.title = metadata?.auto_escalated
? 'Auto-promoted from chat for this tool request'
: `Sent in ${mode} mode`;
if (metadata?.auto_escalated) pill.dataset.auto = 'true';
return pill;
}
export function setUserModePill(messageEl, mode, autoEscalated) {
if (!messageEl) return;
const role = messageEl.querySelector?.('.role');
if (!role) return;
role.querySelectorAll('.user-mode-pill').forEach(el => el.remove());
const pill = userModePill({
interaction_mode: mode,
auto_escalated: !!autoEscalated,
});
if (pill) role.appendChild(pill);
}
/**
* Strip tool invocation blocks from text before rendering.
*/
export function stripToolBlocks(text) {
let cleaned = text.replace(TOOL_CALL_RE, '');
if (EXEC_FENCE_RE) cleaned = cleaned.replace(EXEC_FENCE_RE, stripExecutedFence);
cleaned = cleaned.replace(DSML_TOOL_RE, '');
cleaned = cleaned.replace(DSML_INVOKE_RE, '');
cleaned = cleaned.replace(DSML_STRAY_RE, '');
cleaned = cleaned.replace(XML_TOOL_CALL_RE, '');
cleaned = cleaned.replace(XML_INVOKE_RE, '');
cleaned = cleaned.replace(RAW_OPENAI_TOOL_JSON_RE, '');
cleaned = cleaned.replace(QWEN_ROLE_MARKER_RE, '');
cleaned = cleaned.replace(QWEN_BARE_MARKER_RE, ' ');
cleaned = cleaned.replace(TOOL_NARRATION_RE, '');
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
return cleaned.trim();
}
/**
* Plain-text payload for the message copy buttons: the reply as the renderer
* displays it — tool blocks and reasoning stripped. dataset.raw keeps
* the full model output (chat.js even embeds the elapsed time into the
* tag for reload persistence), so copying it verbatim leaks the
* thinking block (#3722). Falls back to the raw text when stripping leaves
* nothing (e.g. turns interrupted mid-thinking).
*/
export function copyMessageText(msgElement) {
const raw = msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || '';
const { content } = markdownModule.extractThinkingBlocks(stripToolBlocks(raw));
return content || raw;
}
/**
* Build a collapsible sources box (used by both research and web search).
*/
export function buildSourcesBox(sources, type, expanded) {
var esc = uiModule.esc;
var id = 'sources-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
var count = sources.length;
var label = type === 'research' ? 'Research sources' : 'Web sources';
var lines = '';
for (var i = 0; i < count; i++) {
var s = sources[i];
var domain = '';
try { domain = new URL(s.url).hostname.replace('www.', ''); } catch(e) { domain = s.url; }
var title = esc(s.title || domain || '');
var safeUrl = _safeHref(s.url);
lines += ''
+ '' + (i + 1) + ''
+ '' + title + ''
+ '' + esc(domain) + ''
+ '';
}
var arrow = expanded ? 'down' : 'right';
var expandedClass = expanded ? ' expanded' : '';
return '
'
+ '
'
+ '
' + SEARCH_ICON + '' + count + ' ' + label + '
'
+ ''
+ '
'
+ '
'
+ '
' + lines + '
'
+ '
';
}
/**
* Build the RAG "Sources (N documents)" box — mirrors the live render in
* chat.js so persisted rag_sources survive a refresh. Items carry a
* filename, similarity %, and snippet (not URLs, unlike web sources).
* @param {Array<{filename, similarity, snippet}>} sources
*/
export function buildRagSourcesBox(sources) {
if (!sources || !sources.length) return '';
var esc = uiModule.esc;
var items = '';
for (var i = 0; i < sources.length; i++) {
var s = sources[i] || {};
var pct = (typeof s.similarity === 'number') ? (s.similarity * 100).toFixed(1) + '%' : '';
items += '
`;
// 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);
if (pendingAskUser) {
// Session history is rendered oldest-to-newest. A later user message
// removes this card; if there is none, the pending choice survives a
// refresh. Avoid stealing focus while the history is loading.
renderAskUserCard(pendingAskUser, { focus: false, scroll: false });
}
return lastWrap;
}
// --- Wake-task / supervisor system check-in ---
// The self-wake mechanism injects "Did you finish?" as a user message
// (or persisted history shows a "[Task] Self-check: " envelope)
// so the agent loop re-enters and re-checks status. Render as a
// normal user-style bubble — same chrome as a real user message,
// just with role "Supervisor" and a short summary body — instead of
// a slim system chip. Matches chat style and integrates cleanly
// into the conversation flow.
let _isWakeCheck = !!(metadata?.wake_check_in || metadata?.hidden_from_user_view);
if (!_isWakeCheck && typeof textRaw === 'string') {
// Also catch historical messages persisted as "[Task] Self-check: "
// (older wake tasks that didn't set wake_check_in metadata).
if (/^\s*\[Task\]\s+Self-check:/i.test(textRaw)) {
_isWakeCheck = true;
}
}
if (_isWakeCheck) {
// Supervisor self-check messages are an internal control signal —
// skip rendering entirely so they don't show up in the conversation.
return null;
}
// Discuss sessions carry the full research report in a seeded system
// message. Render it through the same thinking block used by model
// reasoning, rather than introducing a second collapsible UI.
if (role === 'system' && metadata?.research_spinoff_from) {
const wrap = document.createElement('div');
wrap.className = 'msg msg-ai';
const roleEl = document.createElement('div');
roleEl.className = 'role';
roleEl.textContent = 'Research Context';
const body = document.createElement('div');
body.className = 'content';
body.innerHTML = markdownModule.createCollapsible(String(textRaw || ''), 'Research Context', true);
const hint = document.createElement('div');
hint.className = 'research-context-continue-hint';
hint.innerHTML = 'Continue conversation below ';
body.appendChild(hint);
wrap.appendChild(roleEl);
wrap.appendChild(body);
box.appendChild(wrap);
return wrap;
}
// --- 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 replyModels = replyModelPair(modelName, metadata);
const resolvedModel = replyModels.actualModel || replyModels.requestedModel;
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(
replyModels.requestedModel,
resolvedModel,
replyModels.requestedEndpointLabel,
replyModels.actualEndpointLabel,
replyModels.requestedEndpointId,
replyModels.actualEndpointId,
);
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 && getShowPersonaName() && role !== 'user' && !isSlash && !isCompacted) {
_roleText = metadata.character_name;
}
r.textContent = _roleText;
if (role === 'user') {
const pill = userModePill(metadata);
if (pill) r.appendChild(pill);
}
if (role !== 'user') {
const endpointChanged = Boolean(
replyModels.requestedEndpointId
&& replyModels.actualEndpointId
&& replyModels.requestedEndpointId !== replyModels.actualEndpointId
);
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && (!sameModelName(replyModels.requestedModel, resolvedModel) || endpointChanged)) {
r.title = replyModels.requestedModel + ' -> ' + resolvedModel
+ ' (' + replyModels.requestedEndpointLabel + ' -> ' + replyModels.actualEndpointLabel + ')';
}
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 || ''));
if (role === 'assistant') {
text = resolveDocumentPlaceholderLinks(text, metadata);
}
// For user messages, pull out vision-model image descriptions ([Image: name]\n
// ) into a collapsible "image description" section. Done for
// ALL user messages (not just ones with attachment metadata) so it rebuilds
// from the stored text even after a browser restart drops the cached attachments.
const attachments = metadata?.attachments;
const _visionBlocks = [];
if (role === 'user') {
text = text.replace(
/\n*\[Image: ([^\]]+)\]\n([\s\S]*?)(?=\n*\[Image: |\n*\[Image attached: |\n*=== File: |\n*\[PDF content\]:|$)/g,
(_m, name, desc) => { const d = desc.trim(); if (d) _visionBlocks.push({ name: name, desc: d }); return ''; }
);
}
// With attachments present, also strip the embedded file/PDF/image-marker text.
if (role === 'user' && attachments?.length) {
// Strip === File: ... === blocks, [PDF content]: blocks, and [Image attached: ...] lines
text = text
.replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+```[\s\S]*?```/g, '')
.replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+[\s\S]*?(?=\n*=== File:|$)/g, '')
.replace(/\n*\[PDF content\]:[\s\S]*?(?=\n*\[PDF content\]|\n*=== File:|$)/g, '')
.replace(/\n*\[Image attached: [^\]]+\]/g, '')
.replace(/\n*\[Attached (?:document|non-text) file\]/g, '')
.trim();
}
wrap.dataset.raw = text;
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
// Prepend sources box if saved in metadata
var sourcesPrefix = '';
var findingsSuffix = '';
if (role === 'assistant' && metadata?.research_sources?.length) {
sourcesPrefix = buildSourcesBox(metadata.research_sources, 'research');
} else if (role === 'assistant' && metadata?.web_sources?.length) {
sourcesPrefix = buildSourcesBox(metadata.web_sources, 'web');
}
if (role === 'assistant' && metadata?.research_findings?.length) {
findingsSuffix = buildFindingsBox(metadata.research_findings);
}
// RAG document sources — restored from metadata so they survive refresh.
if (role === 'assistant' && metadata?.rag_sources?.length) {
findingsSuffix += buildRagSourcesBox(metadata.rag_sources);
}
// If thinking is stored in metadata (not in text), reconstruct the full display.
// Persona/character chats suppress the thinking UI so the persona answer stays
// in-character; the metadata remains saved for debugging/export.
if (role === 'assistant' && metadata?.thinking && !metadata?.character_name) {
const thinkTime = metadata.thinking_time || null;
const thinkHtml = markdownModule.processWithThinking(
'' + metadata.thinking + '\n\n' + text
);
b.innerHTML = sourcesPrefix + thinkHtml + findingsSuffix;
} else {
b.innerHTML = sourcesPrefix + _renderAssistantBody(text, role === 'assistant' ? metadata : null) + findingsSuffix;
}
b.dataset.raw = text;
// The vision/OCR caption is stripped from the displayed text above (so the
// bubble doesn't show the raw model output) but no longer rendered as an
// inline collapsible — the user can still view/edit it via the "Caption"
// button on the photo thumbnail. _visionBlocks is intentionally left unused
// so the parsing-and-strip side-effect on `text` still happens.
void _visionBlocks;
// Add "Open Visual Report" button for persisted research messages
if (role === 'assistant' && metadata?.research) {
var _sid = window.sessionModule?.getCurrentSessionId?.();
if (_sid) _appendReportButton(b, _sid);
}
// Style [Doc edit: ...] prefix in user messages
if (role === 'user') {
// Match compact format: [Doc edit: line X] instruction
b.innerHTML = b.innerHTML.replace(
/\[Doc edit: (lines? [\d–\-]+)\]\s*/,
'Doc edit: $1 '
);
// Match raw format: "In the document, edit this specific text (line X):\n```\n...\n```\n\nInstruction: ..."
// After markdown processing this becomes a
+
block +
Instruction: text
const rawDocMatch = b.innerHTML.match(/In the document, edit this specific text \((lines? [\d–\-]+)\)/);
if (rawDocMatch) {
const lineRef = rawDocMatch[1];
// Extract instruction text (after "Instruction: ")
const instrMatch = b.textContent.match(/Instruction:\s*([\s\S]*)$/);
const instrText = instrMatch ? instrMatch[1].trim() : '';
b.innerHTML = 'Doc edit: ' + lineRef + ' ' + markdownModule.processWithThinking(instrText);
}
// Render attachment cards
if (attachments?.length) {
b.appendChild(buildAttachCards(attachments));
}
}
wrap.appendChild(r);
wrap.appendChild(b);
// Add stopped indicator + continue button for messages that were stopped by user
if (role === 'assistant' && metadata?.stopped) {
const stoppedIndicator = document.createElement('div');
stoppedIndicator.className = 'stopped-indicator';
const stoppedLabel = document.createElement('span');
// Differentiate between "stopped mid-stream" (had content, can continue)
// and "cancelled before any content" — the latter has no Continue affordance.
stoppedLabel.textContent = metadata.cancelled
? '[Cancelled by user]'
: '[Message interrupted]';
stoppedIndicator.appendChild(stoppedLabel);
// Continue button only makes sense when there's partial content to
// resume from \u2014 skip it for fully-cancelled (empty) turns.
if (!metadata.cancelled) {
const continueBtn = document.createElement('button');
continueBtn.className = 'continue-btn resume-btn';
continueBtn.title = 'Resume response';
continueBtn.innerHTML = 'Resume';
continueBtn.addEventListener('click', () => {
stoppedIndicator.remove();
if (window.chatModule) {
window.chatModule.setHideUserBubble();
window.chatModule.setPendingContinue(wrap);
const msgInput = document.getElementById('message');
if (msgInput) {
msgInput.value = 'Continue from where you left 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,
sameModelName,
modelRouteLabel,
replyModelPair,
modelColor,
applyModelColor,
getModelCost,
isCostTrackedEndpoint,
isSubscriptionEndpoint,
getImageCost,
getSessionCost,
resetSessionCost,
updateSessionCostUI,
recordSessionMetricsCost,
roleTimestamp,
stripToolBlocks,
copyMessageText,
safeToolScreenshotSrc,
safeDisplayImageSrc,
removeAskUserCards,
renderAskUserCard,
buildSourcesBox,
buildFindingsBox,
buildRagSourcesBox,
appendReportButton,
buildImageBubble,
hideWelcomeScreen,
showWelcomeScreen,
createMsgFooter,
displayMetrics,
addMessage,
buildAttachCards,
updateMessageAttachments,
};
export default chatRenderer;