// 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';
import { matchModelKey } from './model/matchKey.js';
const SEARCH_ICON = '';
const REPORT_ICON = '';
const CHAT_ABOUT_ICON = '';
const COPY_ICON = '';
const CHECK_ICON = '';
/** Sanitize a URL for use in href — only allow http(s) and protocol-relative. */
function _safeHref(url) {
if (!url) return '#';
try {
var parsed = new URL(url, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return uiModule.esc(url);
} catch(e) { /* invalid URL */ }
return '#';
}
function _makeActionBtn(className, title, text, handler) {
const btn = document.createElement('button');
btn.className = className;
btn.type = 'button';
btn.title = title;
btn.textContent = text;
btn.addEventListener('click', handler);
return btn;
}
// Attachment card helpers
function _attachIcon(mimeOrName) {
const s = (mimeOrName || '').toLowerCase();
if (s.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(s))
return '';
if (s.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|webm)$/i.test(s))
return '';
if (s === 'application/pdf' || /\.pdf$/i.test(s))
return '';
// Default: generic document
return '';
}
function _formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
// Build the `.attach-cards` element for a message's attachment list. Shared by
// addMessage and updateMessageAttachments so a live (optimistic) user bubble
// can be re-rendered with real upload ids once the upload resolves.
function buildAttachCards(attachments) {
const attachWrap = document.createElement('div');
attachWrap.className = 'attach-cards';
for (const att of attachments) {
const isImage = (att.mime || '').startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(att.name || '');
if (isImage) {
// Image preview. Shown for both uploaded (att.id present) and still-
// uploading attachments. A shimmering skeleton + whirlpool fills the
// space until either the upload resolves (no id yet) or the thumbnail
// image finishes loading, so the photo doesn't pop in abruptly.
const imgWrap = document.createElement('div');
imgWrap.className = 'attach-image-preview';
imgWrap.style.cursor = att.id ? 'zoom-in' : 'default';
if (att.id) imgWrap.dataset.fileId = att.id;
if (att.id) {
imgWrap.addEventListener('click', (e) => {
// Tapping the corner OCR button shouldn't also open the lightbox.
if (e.target.closest('.attach-ocr-btn')) return;
_openImageLightbox(att);
});
}
let skel = null;
let sp = null;
if (!att.previewUrl) {
// Skeleton placeholder with a centered whirlpool. Self-stops when removed.
skel = document.createElement('div');
skel.className = 'attach-image-skeleton';
// Match the photo's aspect ratio when the backend knew it at upload
// time, so the skeleton doesn't sit at a 4:3 default and then snap to
// a portrait shape when the image arrives.
if (att.width && att.height) {
skel.style.aspectRatio = att.width + ' / ' + att.height;
skel.style.width = 'auto';
skel.style.height = 'auto';
skel.style.maxWidth = '300px';
skel.style.maxHeight = '200px';
skel.style.minWidth = '80px';
}
sp = spinnerModule.createWhirlpool(20);
skel.appendChild(sp.element);
imgWrap.appendChild(skel);
}
if (att.id || att.previewUrl) {
const img = document.createElement('img');
// Small cached thumbnail — the preview is tiny, no need to pull the
// full-resolution photo. Click still opens the full image.
img.alt = att.name || 'Image';
img.loading = 'lazy';
img.style.cssText = 'max-width:300px;max-height:200px;border-radius:6px;display:' + (att.previewUrl ? 'block' : 'none') + ';';
let _revealed = false;
let _revealTimer = null;
const _reveal = () => {
if (_revealed) return;
_revealed = true;
if (_revealTimer) { clearTimeout(_revealTimer); _revealTimer = null; }
img.style.display = 'block';
try { sp && sp.stop(); } catch {}
if (skel) skel.remove();
};
img.addEventListener('load', _reveal);
img.addEventListener('error', _reveal);
img.src = att.previewUrl || `/api/upload/${att.id}?thumb=1`;
// Cached images can be complete before the load listener attaches.
if (img.complete && img.naturalWidth) _reveal();
// Failsafe: if neither load nor error fires within 8s, reveal anyway.
// The timer is cleared on reveal AND when updateMessageAttachments
// replaces the card (which scrubs the img / skel from the DOM), so
// repeated re-renders don't accumulate stranded timers.
if (!att.previewUrl) _revealTimer = setTimeout(_reveal, 8000);
imgWrap.appendChild(img);
if (att.id) {
// Small corner button → opens the vision/OCR editor so the user can
// correct what the vision model extracted. The edit is cached on the
// server keyed by file id, so any later message referencing this same
// image picks up the corrected text instead of re-running the model.
const ocrBtn = document.createElement('button');
ocrBtn.type = 'button';
ocrBtn.className = 'attach-ocr-btn';
ocrBtn.title = 'View / edit OCR text';
ocrBtn.innerHTML = 'Caption';
ocrBtn.addEventListener('click', (e) => {
e.stopPropagation();
_openVisionEditor(att, ocrBtn.closest('.msg'));
});
imgWrap.appendChild(ocrBtn);
}
}
if (att.vision_model) {
const visionLabel = document.createElement('div');
visionLabel.className = 'attach-vision-model';
visionLabel.textContent = 'Vision: ' + String(att.vision_model).split('/').pop();
imgWrap.appendChild(visionLabel);
}
if (att.name) {
const label = document.createElement('div');
label.className = 'attach-image-name';
label.textContent = att.name;
imgWrap.appendChild(label);
}
attachWrap.appendChild(imgWrap);
} else {
// Non-image file card
const card = document.createElement('div');
card.className = 'attach-card';
card.dataset.name = att.name;
if (att.id) {
card.dataset.fileId = att.id;
card.style.cursor = 'pointer';
card.addEventListener('click', () => {
// PDFs & text/code/markdown → open in the Documents viewer
// (others fall back to the raw file).
if (window.chatModule?.openAttachment) window.chatModule.openAttachment(att, false);
else window.open(`/api/upload/${att.id}`, '_blank');
});
}
const icon = _attachIcon(att.mime || att.name);
const nameSpan = document.createElement('span');
nameSpan.className = 'attach-card-name';
nameSpan.textContent = att.name;
card.innerHTML = icon;
card.appendChild(nameSpan);
if (att.size) {
const sizeSpan = document.createElement('span');
sizeSpan.className = 'attach-card-size';
sizeSpan.textContent = _formatSize(att.size);
card.appendChild(sizeSpan);
}
attachWrap.appendChild(card);
}
}
return attachWrap;
}
// Re-render the attachment cards of an already-rendered message. Used to swap
// in real upload ids (and image thumbnails) on the optimistic user bubble once
// uploadPending() resolves — otherwise image previews only appear after a
// refresh, because the bubble is rendered before the upload assigns ids.
export function updateMessageAttachments(msgWrap, attachments) {
if (!msgWrap || !attachments?.length) return;
const body = msgWrap.querySelector('.body') || msgWrap;
const existing = body.querySelector('.attach-cards');
const fresh = buildAttachCards(attachments);
if (existing) existing.replaceWith(fresh);
else body.appendChild(fresh);
}
// Quick full-size preview when the user taps a chat photo thumbnail. Just an
// overlay with the original image centered — no Gallery panel, no editor.
function _openImageLightbox(att) {
if (!att?.id) return;
const overlay = document.createElement('div');
overlay.className = 'attach-lightbox';
// Show the cached thumb immediately so the overlay doesn't sit blank
// while a 25MB original streams in. The full image swaps in once loaded;
// if the full load fails (404 / network), we keep the thumb + show an
// error label rather than a blank overlay forever.
const img = document.createElement('img');
img.alt = att.name || '';
img.src = `/api/upload/${att.id}?thumb=1`;
overlay.appendChild(img);
const full = new Image();
full.addEventListener('load', () => { img.src = full.src; });
full.addEventListener('error', () => {
const err = document.createElement('div');
err.className = 'attach-lightbox-err';
err.textContent = 'Failed to load full-resolution image.';
overlay.appendChild(err);
});
full.src = `/api/upload/${att.id}`;
const _onKey = (e) => { if (e.key === 'Escape') _close(); };
const _close = () => {
document.removeEventListener('keydown', _onKey);
if (_overlayObs) { try { _overlayObs.disconnect(); } catch {} }
overlay.remove();
};
// If the overlay is removed via any path other than our close handler
// (session switch, parent re-render, external cleanup), still drop the
// document-level keydown listener so it doesn't leak.
let _overlayObs = null;
try {
_overlayObs = new MutationObserver(() => {
if (!document.body.contains(overlay)) {
document.removeEventListener('keydown', _onKey);
_overlayObs.disconnect();
}
});
_overlayObs.observe(document.body, { childList: true, subtree: false });
} catch {}
overlay.addEventListener('click', _close);
document.addEventListener('keydown', _onKey);
document.body.appendChild(overlay);
}
// Vision/OCR editor modal — opened from the corner "Aa" button on a chat photo
// thumbnail. Lets the user view and correct the text the vision model fed to
// the LLM (e.g. when OCR misreads a word). Persists to the server's vision
// cache (PUT /api/upload/{id}/vision), so any subsequent message that
// references the same file picks up the corrected text.
let _visionEditorEl = null;
let _visionEditorEsc = null;
function _closeVisionEditor() {
if (_visionEditorEsc) { document.removeEventListener('keydown', _visionEditorEsc); _visionEditorEsc = null; }
if (_visionEditorEl) { _visionEditorEl.remove(); _visionEditorEl = null; }
}
function _openVisionEditor(att, userMsgEl) {
if (!att?.id) return;
_closeVisionEditor();
const overlay = document.createElement('div');
overlay.className = 'vision-editor-overlay';
overlay.addEventListener('click', (e) => { if (e.target === overlay) _closeVisionEditor(); });
const panel = document.createElement('div');
panel.className = 'vision-editor-panel';
const title = document.createElement('div');
title.className = 'vision-editor-title';
// Eye icon matches the one in Settings → Vision so users recognise where
// this text originates.
title.innerHTML = 'Vision text';
panel.appendChild(title);
const desc = document.createElement('div');
desc.className = 'vision-editor-desc';
desc.textContent = 'Edit text and save, new chats will have the new context. Regenerate or continue from there.';
panel.appendChild(desc);
const ta = document.createElement('textarea');
ta.className = 'vision-editor-text';
ta.rows = 10;
ta.placeholder = 'Loading…';
ta.disabled = true;
panel.appendChild(ta);
const actions = document.createElement('div');
actions.className = 'vision-editor-actions';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'vision-editor-btn';
closeBtn.innerHTML = 'Close';
closeBtn.addEventListener('click', _closeVisionEditor);
const _saveVisionText = async () => {
const res = await fetch(`/api/upload/${att.id}/vision`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ text: ta.value }),
});
if (!res.ok) throw new Error('save failed');
};
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'vision-editor-btn vision-editor-btn-primary';
saveBtn.innerHTML = 'Save';
saveBtn.disabled = true;
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
saveBtn.innerHTML = 'Saving…';
try {
await _saveVisionText();
if (uiModule?.showToast) uiModule.showToast('Saved');
_closeVisionEditor();
} catch (e) {
saveBtn.disabled = false;
saveBtn.innerHTML = 'Save';
if (uiModule?.showError) uiModule.showError('Failed to save OCR text');
}
});
// Regenerate-message: save the edited text, close, then trigger a resend of
// the user message so the new AI reply uses the edit immediately.
const regenBtn = document.createElement('button');
regenBtn.type = 'button';
regenBtn.className = 'vision-editor-btn vision-editor-btn-primary';
regenBtn.title = 'Save and regenerate the message';
regenBtn.innerHTML = 'Regenerate message';
regenBtn.disabled = true;
regenBtn.addEventListener('click', async () => {
regenBtn.disabled = true;
saveBtn.disabled = true;
try {
await _saveVisionText();
_closeVisionEditor();
if (userMsgEl && window.chatModule?.resendUserMessage) {
window.chatModule.resendUserMessage(userMsgEl);
} else if (uiModule?.showToast) {
uiModule.showToast('Saved');
}
} catch (e) {
regenBtn.disabled = false;
saveBtn.disabled = false;
if (uiModule?.showError) uiModule.showError('Failed to save OCR text');
}
});
actions.appendChild(closeBtn);
actions.appendChild(saveBtn);
actions.appendChild(regenBtn);
panel.appendChild(actions);
overlay.appendChild(panel);
document.body.appendChild(overlay);
_visionEditorEl = overlay;
// ESC closes the popup. Registered on document so it works regardless of
// focus (the textarea swallows the event otherwise).
_visionEditorEsc = (e) => { if (e.key === 'Escape') _closeVisionEditor(); };
document.addEventListener('keydown', _visionEditorEsc);
fetch(`/api/upload/${att.id}/vision`, { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : Promise.reject(r))
.then(data => {
ta.value = data.text || '';
ta.placeholder = '';
ta.disabled = false;
saveBtn.disabled = false;
regenBtn.disabled = !userMsgEl;
ta.focus();
})
.catch(() => {
ta.value = '';
ta.placeholder = 'Could not load OCR text — type your correction and save.';
ta.disabled = false;
saveBtn.disabled = false;
regenBtn.disabled = !userMsgEl;
});
}
// Tool call syntax patterns to strip from displayed text
const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi;
// Only strip fenced tool-call blocks that look like structured invocations, not regular code examples
const EXEC_FENCE_RE = /```(?:web_search|read_file|write_file|create_document|edit_document|update_document)\s*\n[\s\S]*?```/gi;
// XML-style tool calls: , , , bare
const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi;
const XML_INVOKE_RE = /[\s\S]*?<\/invoke>/gi;
// DeepSeek "DSML" tool-call markup (fullwidth-pipe | or ascii | delimited) that
// leaks into content when the model emits a text tool call instead of a native
// one. Strip the whole block; the second pattern catches stray/partial tags
// (e.g. mid-stream before the closing tag arrives).
const DSML_TOOL_RE = /<\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>|$)/gi;
const DSML_STRAY_RE = /<\s*\/?\s*[||]+\s*DSML\s*[||]+[^>]*>/gi;
// Self-narration about tool results (model echoing stdout/exit_code)
const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
// Model pricing table — per million tokens
// Model info: pricing (per 1M tokens) + context window length
const MODEL_INFO = {
// --- Anthropic ---
'claude-sonnet-4-5': { input: 3.00, output: 15.00, ctx: 200000 },
'claude-sonnet-4-6': { input: 3.00, output: 15.00, ctx: 200000 },
'claude-sonnet-4': { input: 3.00, output: 15.00, ctx: 200000 },
'claude-opus-4': { input: 15.00, output: 75.00, ctx: 200000 },
'claude-opus-4-6': { input: 15.00, output: 75.00, ctx: 200000 },
'claude-haiku-4': { input: 0.80, output: 4.00, ctx: 200000 },
'claude-haiku-3-5': { input: 0.80, output: 4.00, ctx: 200000 },
'claude-3-5-sonnet': { input: 3.00, output: 15.00, ctx: 200000 },
'claude-3-5-haiku': { input: 0.80, output: 4.00, ctx: 200000 },
'claude-3-opus': { input: 15.00, output: 75.00, ctx: 200000 },
'claude-3-sonnet': { input: 3.00, output: 15.00, ctx: 200000 },
'claude-3-haiku': { input: 0.25, output: 1.25, ctx: 200000 },
// --- OpenAI ---
'gpt-5': { input: 2.00, output: 8.00, ctx: 400000 },
'gpt-4.1': { input: 2.00, output: 8.00, ctx: 1047576 },
'gpt-4.1-mini': { input: 0.40, output: 1.60, ctx: 1047576 },
'gpt-4.1-nano': { input: 0.10, output: 0.40, ctx: 1047576 },
'gpt-4o': { input: 2.50, output: 10.00, ctx: 128000 },
'gpt-4o-mini': { input: 0.15, output: 0.60, ctx: 128000 },
'gpt-4-turbo': { input: 10.00, output: 30.00, ctx: 128000 },
'o1': { input: 15.00, output: 60.00, ctx: 200000 },
'o1-mini': { input: 3.00, output: 12.00, ctx: 128000 },
'o1-pro': { input: 150.0, output: 600.0, ctx: 200000 },
'o3': { input: 2.00, output: 8.00, ctx: 200000 },
'o3-mini': { input: 1.10, output: 4.40, ctx: 200000 },
'o4-mini': { input: 1.10, output: 4.40, ctx: 200000 },
// --- DeepSeek ---
'deepseek-chat': { input: 0.27, output: 1.10, ctx: 64000 },
'deepseek-coder': { input: 0.27, output: 1.10, ctx: 64000 },
'deepseek-reasoner': { input: 0.55, output: 2.19, ctx: 64000 },
'deepseek-r1': { input: 0.55, output: 2.19, ctx: 64000 },
'deepseek-v3': { input: 0.27, output: 1.10, ctx: 64000 },
'deepseek-v2': { input: 0.14, output: 0.28, ctx: 64000 },
// --- Google ---
'gemini-2.5-pro': { input: 1.25, output: 10.00, ctx: 1048576 },
'gemini-2.5-flash': { input: 0.15, output: 0.60, ctx: 1048576 },
'gemini-2.0-flash': { input: 0.10, output: 0.40, ctx: 1048576 },
'gemini-1.5-pro': { input: 1.25, output: 5.00, ctx: 1048576 },
'gemini-1.5-flash': { input: 0.075, output: 0.30, ctx: 1048576 },
'gemma-3': { input: 0.10, output: 0.10, ctx: 128000 },
// --- Mistral ---
'mistral-large': { input: 2.00, output: 6.00, ctx: 128000 },
'mistral-medium': { input: 2.00, output: 6.00, ctx: 32000 },
'mistral-small': { input: 0.20, output: 0.60, ctx: 32000 },
'mistral-nemo': { input: 0.15, output: 0.15, ctx: 128000 },
'mixtral': { input: 0.24, output: 0.24, ctx: 32000 },
'codestral': { input: 0.30, output: 0.90, ctx: 32000 },
'pixtral': { input: 2.00, output: 6.00, ctx: 128000 },
// --- xAI ---
'grok-4': { input: 3.00, output: 15.00, ctx: 131072 },
'grok-3': { input: 3.00, output: 15.00, ctx: 131072 },
'grok-2': { input: 2.00, output: 10.00, ctx: 131072 },
// --- Meta ---
'llama-4': { input: 0.20, output: 0.20, ctx: 1048576 },
'llama-3.3': { input: 0.20, output: 0.20, ctx: 131072 },
'llama-3.2': { input: 0.20, output: 0.20, ctx: 131072 },
'llama-3.1': { input: 0.20, output: 0.20, ctx: 131072 },
'llama-3': { input: 0.20, output: 0.20, ctx: 131072 },
// --- Qwen ---
'qwen3': { input: 0.30, output: 1.20, ctx: 131072 },
'qwen2.5': { input: 0.30, output: 1.20, ctx: 131072 },
'qwq': { input: 0.30, output: 1.20, ctx: 32768 },
// --- Cohere ---
'command-a': { input: 2.50, output: 10.00, ctx: 256000 },
'command-r-plus': { input: 2.50, output: 10.00, ctx: 128000 },
'command-r': { input: 0.15, output: 0.60, ctx: 128000 },
// --- Perplexity ---
'sonar-pro': { input: 3.00, output: 15.00, ctx: 200000 },
'sonar': { input: 1.00, output: 1.00, ctx: 128000 },
// --- MiniMax ---
'minimax': { input: 0.70, output: 0.70, ctx: 1000000 },
// --- Kimi / Moonshot ---
'moonshot': { input: 1.00, output: 1.00, ctx: 128000 },
'kimi': { input: 1.00, output: 1.00, ctx: 128000 },
// --- Microsoft ---
'phi-4': { input: 0.07, output: 0.14, ctx: 16000 },
'phi-3': { input: 0.07, output: 0.14, ctx: 128000 },
// --- Nvidia ---
'nemotron': { input: 0.30, output: 1.20, ctx: 131072 },
// --- Nous ---
'hermes': { input: 0.20, output: 0.20, ctx: 131072 },
};
// Compat alias
const MODEL_PRICING = MODEL_INFO;
// Image generation cost lookup (per-image, by model × quality × size)
const IMAGE_PRICING = {
'gpt-image-1.5': { 'low': { '1024x1024': 0.009, '1024x1536': 0.013, '1536x1024': 0.013 }, 'medium': { '1024x1024': 0.034, '1024x1536': 0.05, '1536x1024': 0.05 }, 'high': { '1024x1024': 0.133, '1024x1536': 0.2, '1536x1024': 0.2 } },
'gpt-image-1': { 'low': { '1024x1024': 0.011, '1024x1536': 0.016, '1536x1024': 0.016 }, 'medium': { '1024x1024': 0.042, '1024x1536': 0.063, '1536x1024': 0.063 }, 'high': { '1024x1024': 0.167, '1024x1536': 0.25, '1536x1024': 0.25 } },
'gpt-image-1-mini': { 'low': { '1024x1024': 0.005, '1024x1536': 0.006, '1536x1024': 0.006 }, 'medium': { '1024x1024': 0.011, '1024x1536': 0.015, '1536x1024': 0.015 }, 'high': { '1024x1024': 0.036, '1024x1536': 0.052, '1536x1024': 0.052 } },
};
export function shortModel(name) {
if (!name) return '...';
if (typeof name !== 'string') name = String(name);
let short = name.split('/').pop();
// Strip .gguf extension
short = short.replace(/\.gguf$/i, '');
// Strip quantization suffixes (Q4_K_M, Q8_0, etc.) and shard numbers
short = short.replace(/-0000\d-of-\d+$/, '');
short = short.replace(/[-_](Q\d[_A-Z\d]*|F16|F32|BF16|fp16|fp32)$/i, '');
// Truncate if still too long (keep first meaningful part)
if (short.length > 25) {
// Try to find a natural break point (dash after model size like -35B or -7B)
const sizeMatch = short.match(/^(.+?-\d+[BbMm])/);
if (sizeMatch) short = sizeMatch[1];
else short = short.substring(0, 22) + '…';
}
return short;
}
/**
* Generate a consistent HSL color for a model name.
* Returns an hsl() string. The hue is derived from a string hash,
* saturation and lightness are fixed for readability on dark/light themes.
*/
export function modelColor(name) {
if (!name) return null;
const key = name.toLowerCase();
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0;
}
const hue = ((hash % 360) + 360) % 360;
return `hsl(${hue}, 55%, 65%)`;
}
/** Look up model info (pricing + context) by substring match */
export function getModelInfo(modelName) {
if (!modelName) return null;
const key = matchModelKey(modelName, Object.keys(MODEL_INFO));
return key ? { key, ...MODEL_INFO[key] } : null;
}
function _fmtCtx(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
return Math.round(n / 1000) + 'K';
}
/**
* Apply model color to a role element (sets color + dot color).
*/
export function applyModelColor(roleEl, modelName) {
if (!modelName) return;
const color = modelColor(modelName);
if (color) {
roleEl.style.color = color;
roleEl.style.setProperty('--model-dot', color);
}
// Replace generic dot with provider logo if available
const logo = providerLogo(modelName);
if (logo && !roleEl.querySelector('.role-provider-logo')) {
const span = document.createElement('span');
span.className = 'role-provider-logo';
span.innerHTML = logo;
roleEl.classList.add('has-logo');
roleEl.prepend(span);
}
// Click to show model info popup
if (!roleEl._hasInfoClick) {
roleEl._hasInfoClick = true;
roleEl.style.cursor = 'pointer';
roleEl.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
const info = getModelInfo(modelName);
const short = shortModel(modelName);
const logoHtml = providerLogo(modelName);
const popup = document.createElement('div');
popup.className = 'ctx-popup';
let html = '
';
if (logoHtml) html += '' + logoHtml + '';
html += short + '
';
html += '
Model ' + modelName.split('/').pop() + '
';
// Show static context initially, then fetch real from server
const _realCtx = window._realContextLengths && window._realContextLengths[modelName];
if (_realCtx) {
html += '
Context ' + _fmtCtx(_realCtx) + ' tokens';
if (info && info.ctx && info.ctx !== _realCtx) html += ' (spec: ' + _fmtCtx(info.ctx) + ')';
html += '
';
} else if (info && info.ctx) {
html += '
Context' + _fmtCtx(info.ctx) + ' tokens
';
}
// Fetch real context from server async
if (!_realCtx && window.sessionModule) {
const _sid = window.sessionModule.getCurrentSessionId();
if (_sid) {
fetch('/api/session/' + _sid + '/context_info').then(r => r.ok ? r.json() : null).then(d => {
if (d && d.context_length) {
if (!window._realContextLengths) window._realContextLengths = {};
window._realContextLengths[modelName] = d.context_length;
const el = document.getElementById('_ctx-val');
if (el) {
el.innerHTML = _fmtCtx(d.context_length) + ' tokens';
if (info && info.ctx && info.ctx !== d.context_length) {
el.innerHTML += ' (spec: ' + _fmtCtx(info.ctx) + ')';
}
}
}
}).catch(() => {});
}
}
// Show configured max tokens if set
if (window.presetsModule) {
const _pid = window.presetsModule.getSelectedPreset();
const _preset = _pid ? window.presetsModule.getPreset(_pid) : null;
const _mt = _preset?.max_tokens;
if (_mt && _mt > 0 && _mt <= 8192) {
html += '
Max tokens ' + _mt.toLocaleString() + ' (configured)
';
}
}
if (info && info.input != null) html += '
Input $' + info.input.toFixed(2) + ' / 1M
';
if (info && info.output != null) html += '
Output $' + info.output.toFixed(2) + ' / 1M
';
if (!info) html += '
No pricing data available
';
popup.innerHTML = html;
const rect = roleEl.getBoundingClientRect();
popup.style.top = (rect.bottom + 4) + 'px';
popup.style.left = rect.left + 'px';
document.body.appendChild(popup);
const pr = popup.getBoundingClientRect();
if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px';
if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px';
bindMenuDismiss(popup, () => popup.remove());
});
}
}
export function getModelCost(modelName, inputTokens, outputTokens) {
if (!modelName) return null;
const key = matchModelKey(modelName, Object.keys(MODEL_PRICING));
if (!key) return null;
const price = MODEL_PRICING[key];
return (inputTokens * price.input + outputTokens * price.output) / 1_000_000;
}
/**
* Is this endpoint a local / self-hosted model server (vLLM, Ollama, …)?
* Local models are free, so we must NOT bill them at cloud rates — the
* pricing table matches on a name substring, so a local `qwen2.5-coder`
* would otherwise be charged like cloud `qwen2.5`. When the serving host is
* loopback, a private LAN range, Tailscale CGNAT (100.64–100.127.x), a
* `.local` name, or the app's own host, the model is local → free.
* Unknown / missing endpoint also counts as local (bias to not over-bill).
*/
export function isLocalEndpoint(url) {
if (!url) return true;
let host;
try { host = new URL(url).hostname; } catch (_e) { return true; }
if (!host) return true;
if (host === 'localhost' || host === '0.0.0.0' || host === 'host.docker.internal' || host.endsWith('.local')) return true;
if (typeof window !== 'undefined' && window.location && host === window.location.hostname) return true;
// A single-label hostname (no dot) is an internal/Docker service name
// (e.g. "nim-nano", "llamaswap", "nemotron-super-49b") or a LAN shortname —
// never a public API, which always needs an FQDN. Treat as local → free.
// (Without this, container-name endpoints get billed at cloud rates because
// the pricing table matches on a name substring, e.g. "nemotron".)
if (!host.includes('.')) return true;
if (/^127\./.test(host)) return true;
if (/^10\./.test(host)) return true;
if (/^192\.168\./.test(host)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true;
const cg = host.match(/^100\.(\d+)\./); // Tailscale CGNAT
if (cg && +cg[1] >= 64 && +cg[1] <= 127) return true;
return false;
}
/** Cost for the current turn, returning null (free) for local endpoints. */
function _billableCost(model, inputTokens, outputTokens) {
const url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
? window.sessionModule.getCurrentEndpointUrl() : null;
if (isLocalEndpoint(url)) return null;
return getModelCost(model, inputTokens, outputTokens);
}
export function getImageCost(model, quality, size) {
if (!model) return null;
const m = model.toLowerCase();
for (const [key, quals] of Object.entries(IMAGE_PRICING)) {
if (m.includes(key)) {
const q = quals[(quality || 'medium').toLowerCase()] || quals['medium'];
return q ? (q[size] || q['1024x1024'] || null) : null;
}
}
return null;
}
/* ── Session cost helpers ─────────────────────────────────────────── */
const _COST_KEY = 'ody-session-cost';
/** Return the accumulated cost for the current (or given) session. */
export function getSessionCost(sessionId) {
const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId());
if (!sid) return 0;
try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
return costs[sid] || 0;
} catch (_e) { return 0; }
}
/** Reset session cost for the given session (defaults to current). */
export function resetSessionCost(sessionId) {
const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId());
if (!sid) return;
try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
delete costs[sid];
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
} catch (_e) { /* ignore */ }
updateSessionCostUI();
}
/** Update the persistent session-cost badge in the input bar. */
export function updateSessionCostUI() {
const el = document.getElementById('session-cost-display');
if (!el) return;
// Local model? It's free — hide the badge and clear any stale cost that a
// previous (buggy) cloud-rate billing left in localStorage for this session.
const _url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
? window.sessionModule.getCurrentEndpointUrl() : null;
if (isLocalEndpoint(_url)) {
const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (sid && getSessionCost(sid) > 0) {
try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
delete costs[sid];
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
} catch (_e) { /* ignore */ }
}
el.style.display = 'none';
return;
}
const cost = getSessionCost();
if (cost > 0) {
el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2));
el.style.display = '';
} else {
el.style.display = 'none';
}
}
/** Create a timestamp span for role labels.
* Pass an ISO string / Date / epoch-ms to render the message's own time
* (used when replaying history). Falls back to "now" when no value is given. */
export function roleTimestamp(when) {
const ts = document.createElement('span');
ts.className = 'role-timestamp';
let d;
if (when instanceof Date) d = when;
else if (typeof when === 'number') d = new Date(when);
else if (typeof when === 'string' && when) d = new Date(when);
else d = new Date();
if (isNaN(d.getTime())) d = new Date();
ts.textContent = d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
ts.title = d.toLocaleString();
return ts;
}
/**
* Strip tool invocation blocks from text before rendering.
*/
export function stripToolBlocks(text) {
let cleaned = text.replace(TOOL_CALL_RE, '');
cleaned = cleaned.replace(EXEC_FENCE_RE, '');
cleaned = cleaned.replace(DSML_TOOL_RE, '');
cleaned = cleaned.replace(DSML_STRAY_RE, '');
cleaned = cleaned.replace(XML_TOOL_CALL_RE, '');
cleaned = cleaned.replace(XML_INVOKE_RE, '');
cleaned = cleaned.replace(TOOL_NARRATION_RE, '');
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
return cleaned.trim();
}
/**
* Build a collapsible sources box (used by both research and web search).
*/
export function buildSourcesBox(sources, type, expanded) {
var esc = uiModule.esc;
var id = 'sources-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
var count = sources.length;
var label = type === 'research' ? 'Research sources' : 'Web sources';
var lines = '';
for (var i = 0; i < count; i++) {
var s = sources[i];
var domain = '';
try { domain = new URL(s.url).hostname.replace('www.', ''); } catch(e) { domain = s.url; }
var title = esc(s.title || domain || '');
var safeUrl = _safeHref(s.url);
lines += ''
+ '' + (i + 1) + ''
+ '' + title + ''
+ '' + esc(domain) + ''
+ '';
}
var arrow = expanded ? 'down' : 'right';
var expandedClass = expanded ? ' expanded' : '';
return '
'
+ '
'
+ '
' + SEARCH_ICON + '' + count + ' ' + label + '
'
+ ''
+ '
'
+ '
'
+ '
' + lines + '
'
+ '
';
}
/**
* Build the RAG "Sources (N documents)" box — mirrors the live render in
* chat.js so persisted rag_sources survive a refresh. Items carry a
* filename, similarity %, and snippet (not URLs, unlike web sources).
* @param {Array<{filename, similarity, snippet}>} sources
*/
export function buildRagSourcesBox(sources) {
if (!sources || !sources.length) return '';
var esc = uiModule.esc;
var items = '';
for (var i = 0; i < sources.length; i++) {
var s = sources[i] || {};
var pct = (typeof s.similarity === 'number') ? (s.similarity * 100).toFixed(1) + '%' : '';
items += '