Files
odysseus/static/js/chatRenderer.js
T

3804 lines
176 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// static/js/chatRenderer.js
// Extracted from chat.js — message rendering, sources, images, metrics
import uiModule from './ui.js?v=20260908weekhoverfix1';
import markdownModule from './markdown.js';
import { svgifyEmoji } from './markdown.js';
import { addAITTSButton } from './tts-ai.js';
import { providerLogo, providerLabel } from './providers.js';
import settingsModule from './settings.js?v=20260909defaultmodelfix1';
import spinnerModule from './spinner.js';
import { bindMenuDismiss } from './escMenuStack.js';
import { loadPanel } from './panels.js?v=20260909movepicklayer1';
import { matchModelKey } from './model/matchKey.js';
import { getTools } from './appConfig.js';
import { getShowPersonaName } from './presets.js?v=20260908personaname1';
const SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
const REPORT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
const CHAT_ABOUT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const COPY_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
const CHECK_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const TRASH_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
const PAPERCLIP_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
const CALENDAR_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/></svg>';
const EMAIL_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>';
const TOOL_ICONS = {
manage_calendar: CALENDAR_ICON,
manage_memory: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/></svg>',
save_memory: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16v16H4z"/><path d="M8 4v5h8V4M8 20v-6h8v6"/></svg>',
search_memory: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg>',
manage_skills: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
manage_notes: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 3h10l4 4v14H5z"/><path d="M15 3v5h5"/><path d="M8 17.5 15.5 10l2.5 2.5L10.5 20H8z"/></svg>',
manage_tasks: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>',
trigger_research: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
manage_research: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
manage_documents: REPORT_ICON,
create_document: REPORT_ICON,
update_document: REPORT_ICON,
edit_document: REPORT_ICON,
read_file: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3h8l4 4v14H6z"/><path d="M14 3v5h5"/></svg>',
write_file: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/></svg>',
edit_file: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/></svg>',
private_browser: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>',
manage_session: CHAT_ABOUT_ICON,
manage_contact: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>',
manage_settings: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/><circle cx="12" cy="12" r="4"/></svg>',
bash: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 17 6-5-6-5M12 19h8"/></svg>',
host_shell: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 17 6-5-6-5M12 19h8"/></svg>',
python: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3c-4 0-4 2-4 4v2h8v2H8c-4 0-4 2-4 5s2 5 5 5h2v-3H9v-2h6c4 0 4-2 4-5V8c0-3-2-5-7-5Z"/><circle cx="10" cy="6" r=".7" fill="currentColor"/><circle cx="14" cy="18" r=".7" fill="currentColor"/></svg>',
generate_image: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 3 1.5 5.5L19 10l-5.5 1.5L12 17l-1.5-5.5L5 10l5.5-1.5L12 3Z"/><path d="m19 16 .7 2.3L22 19l-2.3.7L19 22l-.7-2.3L16 19l2.3-.7L19 16Z"/></svg>',
image_gen: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 3 1.5 5.5L19 10l-5.5 1.5L12 17l-1.5-5.5L5 10l5.5-1.5L12 3Z"/><path d="m19 16 .7 2.3L22 19l-2.3.7L19 22l-.7-2.3L16 19l2.3-.7L19 16Z"/></svg>',
ask_user: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 4.1 1.9c-1.1.8-1.6 1.2-1.6 2.6M12 17h.01"/></svg>',
update_plan: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16M4 12h16M4 19h10"/><circle cx="19" cy="19" r="2"/></svg>',
list_models: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="5" rx="2"/><rect x="3" y="15" width="18" height="5" rx="2"/></svg>',
youtube_tool: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="4"/><path d="m10 9 5 3-5 3V9Z"/></svg>',
list_emails: EMAIL_ICON,
read_email: EMAIL_ICON,
search_emails: EMAIL_ICON,
send_email: EMAIL_ICON,
mcp__email__list_emails: EMAIL_ICON,
mcp__email__read_email: EMAIL_ICON,
mcp__email__search_emails: EMAIL_ICON,
web_search: SEARCH_ICON,
web_fetch: SEARCH_ICON,
};
export function getToolIcon(tool) {
const name = String(tool || '').toLowerCase();
if (TOOL_ICONS[name]) return TOOL_ICONS[name];
if (name.endsWith('manage_calendar')) return TOOL_ICONS.manage_calendar;
if (name.endsWith('manage_memory')) return TOOL_ICONS.manage_memory;
if (name.endsWith('manage_skills')) return TOOL_ICONS.manage_skills;
if (name.endsWith('manage_notes')) return TOOL_ICONS.manage_notes;
if (name.endsWith('manage_tasks')) return TOOL_ICONS.manage_tasks;
if (name.includes('email')) return EMAIL_ICON;
if (name.includes('browser')) return TOOL_ICONS.private_browser;
return '';
}
const RESEND_ICON = '<svg class="resend-message-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.15" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 0 1-15.3 6.4L3 16"/><path d="M3 21v-5h5"/><path d="M3 12a9 9 0 0 1 15.3-6.4L21 8"/><path d="M21 3v5h-5"/></svg>';
const USER_MODE_AGENT_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
const USER_MODE_CHAT_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
/** 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 '#';
}
export function safeToolScreenshotSrc(raw) {
const src = String(raw || '').trim();
if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) {
return src;
}
return '';
}
export function safeDisplayImageSrc(raw) {
const src = String(raw || '').trim();
if (!src) return '';
if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) {
return src;
}
try {
const parsed = new URL(src, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.href;
}
} catch (_) {}
return '';
}
function _isPrivateBrowserTool(tool) {
const name = String(tool || '').toLowerCase();
return name === 'private_browser'
|| name.includes('private_browser')
|| name.includes('builtin_browser')
|| name.startsWith('browser_')
|| name.includes('__browser_');
}
function _privateBrowserActionLabel(command) {
const raw = String(command || '').trim();
if (!raw) return 'screenshot';
try {
const parsed = JSON.parse(raw);
const action = String(parsed && parsed.action || '').trim();
if (action) {
const target = parsed.url || parsed.selector || parsed.target || parsed.key || '';
return target ? `${action}: ${String(target).slice(0, 90)}` : action;
}
} catch (_) {}
return raw.slice(0, 110);
}
function _privateBrowserUrlFromCommand(command) {
const raw = String(command || '').trim();
if (!raw) return '';
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.url === 'string' && parsed.url) return parsed.url;
const commands = Array.isArray(parsed && parsed.commands) ? parsed.commands : [];
for (const item of commands) {
if (Array.isArray(item) && String(item[0] || '').toLowerCase() === 'open' && item[1]) return String(item[1]);
if (item && typeof item === 'object' && String(item.action || '').toLowerCase() === 'open' && item.url) return String(item.url);
}
} catch (_) {}
return '';
}
function _safeExternalToolUrl(raw) {
const value = String(raw || '').trim();
if (!value) return '';
try {
const parsed = new URL(value.includes('://') ? value : `https://${value}`);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.href;
} catch (_) {}
return '';
}
function _webFetchUrlFromCommand(command) {
const raw = String(command || '').trim();
if (!raw) return '';
try {
const args = JSON.parse(raw);
return _safeExternalToolUrl(args && args.url);
} catch (_) {
return _safeExternalToolUrl(raw.split(/\s+/)[0]);
}
}
function _webSearchQueryFromCommand(command) {
const raw = String(command || '').trim();
if (!raw) return '';
try {
const args = JSON.parse(raw);
if (args && typeof args.query === 'string') return args.query.trim();
if (args && Array.isArray(args.queries) && args.queries.length) return String(args.queries[0] || '').trim();
} catch (_) {}
return raw;
}
function _searxngSearchUrl(query) {
const q = String(query || '').trim();
if (!q) return '';
try {
const url = new URL('/search/web', window.location.origin);
url.search = '';
url.hash = '';
url.searchParams.set('q', q);
return url.href;
} catch (_) {
return '';
}
}
function _toolHeaderLinkHtml(url, title = 'Open link') {
const href = _safeExternalToolUrl(url);
if (!href) return '';
return `<a class="agent-thread-header-link" href="${uiModule.esc(href)}" target="_blank" rel="noopener noreferrer" title="${uiModule.esc(title)}" aria-label="${uiModule.esc(title)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a>`;
}
function _toolHeaderHashLinkHtml(hash, title = 'Open item', iconHtml = '', className = '') {
const href = String(hash || '').trim();
if (!/^#(?:event|note|email|document|task|research|skill)-[A-Za-z0-9_.:-]+$/.test(href)) return '';
const cls = `agent-thread-header-link${className ? ' ' + className : ''}`;
return `<a class="${uiModule.esc(cls)}" href="${uiModule.esc(href)}" title="${uiModule.esc(title)}" aria-label="${uiModule.esc(title)}">${iconHtml || '<span aria-hidden="true">↗</span>'}</a>`;
}
function _calendarEventUidFromToolData(command, data) {
const direct = String((data && data.uid) || '').trim();
if (direct) return direct;
const events = data && Array.isArray(data.events) ? data.events : [];
if (events.length === 1 && events[0] && events[0].uid) return String(events[0].uid).trim();
const text = `${command || ''}\n${(data && data.output) || ''}\n${(data && data.anchor) || ''}`;
const match = text.match(/#event-([A-Za-z0-9_.:-]+)/);
return match ? match[1] : '';
}
function _safeToolTargetId(value) {
const id = String(value || '').trim();
return /^[A-Za-z0-9_.:@-]{2,160}$/.test(id) ? id : '';
}
function _hashTargetFromText(text, kind) {
const match = String(text || '').match(new RegExp(`#${kind}-([A-Za-z0-9_.:@-]+)`));
return match ? _safeToolTargetId(match[1]) : '';
}
function _toolOutputText(data) {
if (!data) return '';
return [data.output, data.response, data.results, data.content, data.anchor]
.filter(value => value !== undefined && value !== null)
.map(String)
.join('\n');
}
function _target(kind, id, title) {
const safeId = _safeToolTargetId(id);
return safeId ? { href: `#${kind}-${safeId}`, title } : null;
}
function _emailTarget(args = {}, data = null) {
const uid = String((data && data.uid) || args.uid || '').trim();
if (!/^\d+$/.test(uid)) return null;
const folder = String((data && data.folder) || args.folder || 'INBOX').trim() || 'INBOX';
const account = String(
(data && (data.account_id || data.account)) || args.account_id || args.account || '',
).trim();
const clean = value => String(value || '').replace(/[^A-Za-z0-9_.@-]/g, '_');
return _target('email', [clean(folder), uid, clean(account)].filter(Boolean).join(':'), 'Open email');
}
/** Resolve the in-app item a tool icon should open, when the tool exposed one. */
export function getToolActionTarget(tool, command, data = null) {
const lower = String(tool || '').toLowerCase();
let args = null;
try { args = JSON.parse(command || '{}'); } catch (_) {}
const output = _toolOutputText(data);
if (lower === 'update_plan' || lower.endsWith('update_plan')) {
return { href: '#plan', title: 'Review plan' };
}
if (lower === 'web_search' || lower.endsWith('web_search') || lower.includes('__web_search')) {
return null;
}
if (lower === 'web_fetch' || lower.endsWith('web_fetch') || lower.includes('__web_fetch')) {
const href = _safeExternalToolUrl(_webFetchUrlFromCommand(command));
return href ? { href, title: 'Open fetched page', external: true } : null;
}
if (lower === 'manage_calendar' || lower.endsWith('manage_calendar')) {
const uid = _calendarEventUidFromToolData(command, data || {});
if (uid) return _target('event', uid, 'Open calendar event');
const start = args && args.start ? String(args.start).slice(0, 10) : '';
return /^\d{4}-\d{2}(?:-\d{2})?$/.test(start)
? { href: `#event-${start}`, title: 'Open calendar range' }
: { href: '#calendar', title: 'Open calendar' };
}
const targetDefinitions = [
{ test: /(?:^|__)manage_memory$|^(?:save_memory|search_memory)$/, kind: 'memory', title: 'Open memory', fields: ['memory_id', 'id'], fallback: 'memory' },
{ test: /(?:^|__)manage_notes$/, kind: 'note', title: 'Open note', fields: ['note_id', 'id'], fallback: 'notes' },
{ test: /(?:^|__)manage_tasks$/, kind: 'task', title: 'Open task', fields: ['task_id', 'id'], fallback: 'tasks' },
{ test: /(?:^|__)manage_documents$|^(?:create|update|edit)_document$/, kind: 'document', title: 'Open document', fields: ['doc_id', 'document_id', 'id'], fallback: 'documents' },
{ test: /(?:^|__)manage_skills$/, kind: 'skill', title: 'Open skill', fields: ['skill_name', 'name', 'skill_id'], fallback: 'skills' },
{ test: /(?:^|__)manage_research$|^trigger_research$/, kind: 'research', title: 'Open research', fields: ['research_session_id', 'session_id', 'research_id', 'id'], fallback: 'research' },
];
const definition = targetDefinitions.find(item => item.test.test(lower));
if (definition) {
for (const field of definition.fields) {
const candidate = data && data[field] !== undefined ? data[field] : args && args[field];
const result = _target(definition.kind, candidate, definition.title);
if (result) return result;
}
const fromAnchor = _hashTargetFromText(output, definition.kind);
if (fromAnchor) return _target(definition.kind, fromAnchor, definition.title);
if (definition.kind === 'task') {
const taskMatch = output.match(/\(([0-9a-f]{8}-[0-9a-f-]{27,})\)/i);
if (taskMatch) return _target('task', taskMatch[1], definition.title);
}
if (definition.kind === 'note') {
const noteMatch = output.match(/[-*]\s*\[([A-Za-z0-9_.:@-]{2,160})\]\s+\*\*/);
if (noteMatch) return _target('note', noteMatch[1], definition.title);
}
if (definition.kind === 'memory') {
const memoryMatch = output.match(/`([A-Za-z0-9_-]{4,160})`/);
if (memoryMatch) return _target('memory', memoryMatch[1], definition.title);
}
return { href: `#${definition.fallback}`, title: `Open ${definition.fallback}` };
}
if (lower.includes('read_email') || lower.endsWith('email')) {
return _emailTarget(args || {}, data) || _target('email', _hashTargetFromText(output, 'email'), 'Open email');
}
if (lower === 'generate_image' || lower.endsWith('image_gen')) {
return _target('image', data?.image_id || args?.image_id, 'Open image');
}
if (lower === 'manage_session' || lower.endsWith('manage_session')) {
return _target('session', data?.session_id || args?.session_id || _hashTargetFromText(output, 'session'), 'Open session');
}
return null;
}
export function renderToolIcon(tool, command, data = null) {
const icon = getToolIcon(tool);
if (!icon) return '';
const target = getToolActionTarget(tool, command, data);
if (!target) return `<span class="agent-thread-tool-icon">${icon}</span>`;
const href = target.external ? _safeHref(target.href) : uiModule.esc(target.href);
const externalAttrs = target.external ? ' target="_blank" rel="noopener noreferrer"' : '';
return `<a class="agent-thread-tool-icon agent-thread-tool-icon-link" href="${href}" title="${uiModule.esc(target.title)}" aria-label="${uiModule.esc(target.title)}"${externalAttrs}>${icon}</a>`;
}
function _browserPreviewHtml(command, screenshotSrc, fallbackUrl = '') {
const url = _privateBrowserUrlFromCommand(command) || String(fallbackUrl || '');
const urlAttr = url ? ` data-browser-url="${uiModule.esc(url)}" title="Open ${uiModule.esc(url)}"` : '';
const imgHtml = screenshotSrc
? `<img class="private-browser-preview-img" src="${uiModule.esc(screenshotSrc)}" alt="Private browser screenshot" />`
: '<div class="private-browser-preview-empty"><span class="private-browser-preview-spinner" aria-hidden="true"></span><span>Browsing…</span></div><img class="private-browser-preview-img" alt="Private browser screenshot" />';
return `<div class="private-browser-preview"${urlAttr}><div class="private-browser-preview-header"><span class="private-browser-preview-title">Private Browser</span><span class="private-browser-preview-status">${uiModule.esc(_privateBrowserActionLabel(command))}</span><button type="button" class="private-browser-preview-fold" title="Fold browser preview" aria-label="Fold browser preview">×</button></div><div class="private-browser-preview-frame">${imgHtml}</div></div>`;
}
function _renderAssistantBody(text, metadata) {
const suppressThinking = !!(metadata && metadata.character_name);
if (suppressThinking) {
const extracted = markdownModule.extractThinkingBlocks(text || '');
const visible = (extracted && typeof extracted.content === 'string') ? extracted.content : String(text || '');
return markdownModule.mdToHtml(markdownModule.squashOutsideCode(visible));
}
return markdownModule.processWithThinking(text);
}
function _toolDisplayInfo(tool, command, data = null) {
const rawTool = String(tool || '');
const lower = rawTool.toLowerCase();
let args = null;
try { args = JSON.parse(command || '{}'); } catch (_) {}
if (lower === 'web_search' || lower.endsWith('web_search') || lower.includes('__web_search')) {
const q = _webSearchQueryFromCommand(command);
return {
label: '',
commandHtml: q
? `<span class="agent-thread-summary">${uiModule.esc(q)}</span>`
: '',
};
}
if (lower === 'web_fetch' || lower.endsWith('web_fetch') || lower.includes('__web_fetch')) {
const url = _webFetchUrlFromCommand(command);
return {
label: '',
headerActionHtml: _toolHeaderLinkHtml(url, 'Open fetched page'),
commandHtml: url
? `<a class="agent-thread-summary agent-thread-summary-link" href="${uiModule.esc(url)}" target="_blank" rel="noopener noreferrer" title="Open ${uiModule.esc(url)}">${uiModule.esc(url)}</a>`
: '',
};
}
if (lower === 'manage_calendar' || lower.endsWith('manage_calendar')) {
const action = args && args.action ? String(args.action).toLowerCase() : '';
const summary = args && args.summary ? String(args.summary) : '';
const start = args && args.start ? String(args.start).slice(0, 10) : '';
const end = args && args.end ? String(args.end).slice(0, 10) : '';
const uid = _calendarEventUidFromToolData(command, data || {});
const actionLabel = action.includes('create') ? 'Create event'
: action.includes('update') ? 'Update event'
: action.includes('delete') ? 'Delete event'
: 'Check calendar';
const rangeSummary = start && end ? `${start} to ${end}` : '';
return {
label: actionLabel,
// The calendar glyph itself is the navigation target. Keeping the old
// trailing action link here produced two calendar icons in one row.
headerActionHtml: '',
commandHtml: summary
? `<div class="agent-thread-summary">${uiModule.esc(summary)}</div>`
: rangeSummary
? `<div class="agent-thread-summary">${uiModule.esc(rangeSummary)}</div>`
: '',
};
}
const emailTool = lower.includes('email');
if (emailTool && (lower.includes('read_email') || lower.endsWith('read_email'))) {
const uid = args && args.uid ? String(args.uid) : '';
const folder = args && args.folder ? String(args.folder) : 'INBOX';
const target = _emailTarget(args || {}, data);
return {
label: uid ? `Read email UID ${uid}` : 'Read email',
headerActionHtml: target
? _toolHeaderHashLinkHtml(target.href, 'Open this email')
: '',
commandHtml: uid
? `<a class="agent-thread-summary agent-thread-summary-link" href="${uiModule.esc(target ? target.href : `#email-${uid}`)}" title="Open this email">UID ${uiModule.esc(uid)} · ${uiModule.esc(folder)}</a>`
: '',
};
}
if (emailTool && (lower.includes('list_emails') || lower.endsWith('list_emails'))) {
const max = args && args.max_results ? String(args.max_results) : '';
const folder = args && args.folder ? String(args.folder) : 'INBOX';
const bits = [folder, max ? `${max} latest` : 'latest'].filter(Boolean).join(' · ');
return {
label: 'Check email',
commandHtml: `<div class="agent-thread-summary">${uiModule.esc(bits)}</div>`,
};
}
if (emailTool && (lower.includes('search_emails') || lower.endsWith('search_emails'))) {
const q = args && args.query ? String(args.query) : '';
return {
label: 'Search email',
commandHtml: q ? `<div class="agent-thread-summary">${uiModule.esc(q)}</div>` : '',
};
}
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 '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>';
if (s.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|webm)$/i.test(s))
return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>';
if (s === 'application/pdf' || /\.pdf$/i.test(s))
return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>';
// Default: generic document
return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
}
function _formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
// Build the `.attach-cards` element for a message's attachment list. Shared by
// addMessage and updateMessageAttachments so a live (optimistic) user bubble
// can be re-rendered with real upload ids once the upload resolves.
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 = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg><span class="attach-ocr-label">Caption</span>';
ocrBtn.addEventListener('click', (e) => {
e.stopPropagation();
_openVisionEditor(att, ocrBtn.closest('.msg'));
});
imgWrap.appendChild(ocrBtn);
}
}
if (att.vision_model) {
const visionLabel = document.createElement('div');
visionLabel.className = 'attach-vision-model';
visionLabel.textContent = 'Vision: ' + String(att.vision_model).split('/').pop();
imgWrap.appendChild(visionLabel);
}
if (att.name) {
const label = document.createElement('div');
label.className = 'attach-image-name';
label.textContent = att.name;
imgWrap.appendChild(label);
}
attachWrap.appendChild(imgWrap);
} else {
// Non-image file card
const card = document.createElement('div');
card.className = 'attach-card';
card.dataset.name = att.name;
if (att.id) {
card.dataset.fileId = att.id;
card.style.cursor = 'pointer';
card.addEventListener('click', () => {
// PDFs & text/code/markdown → open in the Documents viewer
// (others fall back to the raw file).
if (window.chatModule?.openAttachment) window.chatModule.openAttachment(att, false);
else window.open(`/api/upload/${att.id}`, '_blank');
});
}
const icon = _attachIcon(att.mime || att.name);
const nameSpan = document.createElement('span');
nameSpan.className = 'attach-card-name';
nameSpan.textContent = att.name;
card.innerHTML = icon;
card.appendChild(nameSpan);
if (att.size) {
const sizeSpan = document.createElement('span');
sizeSpan.className = 'attach-card-size';
sizeSpan.textContent = _formatSize(att.size);
card.appendChild(sizeSpan);
}
attachWrap.appendChild(card);
}
}
return attachWrap;
}
// Re-render the attachment cards of an already-rendered message. Used to swap
// in real upload ids (and image thumbnails) on the optimistic user bubble once
// uploadPending() resolves — otherwise image previews only appear after a
// refresh, because the bubble is rendered before the upload assigns ids.
export function updateMessageAttachments(msgWrap, attachments) {
if (!msgWrap || !attachments?.length) return;
const body = msgWrap.querySelector('.body') || msgWrap;
const existing = body.querySelector('.attach-cards');
const fresh = buildAttachCards(attachments);
if (existing) existing.replaceWith(fresh);
else body.appendChild(fresh);
}
// Quick full-size preview when the user taps a chat photo thumbnail. Just an
// overlay with the original image centered — no Gallery panel, no editor.
function _openImageLightbox(att) {
if (!att?.id) return;
const overlay = document.createElement('div');
overlay.className = 'attach-lightbox';
// Show the cached thumb immediately so the overlay doesn't sit blank
// while a 25MB original streams in. The full image swaps in once loaded;
// if the full load fails (404 / network), we keep the thumb + show an
// error label rather than a blank overlay forever.
const img = document.createElement('img');
img.alt = att.name || '';
img.src = `/api/upload/${att.id}?thumb=1`;
overlay.appendChild(img);
const full = new Image();
full.addEventListener('load', () => { img.src = full.src; });
full.addEventListener('error', () => {
const err = document.createElement('div');
err.className = 'attach-lightbox-err';
err.textContent = 'Failed to load full-resolution image.';
overlay.appendChild(err);
});
full.src = `/api/upload/${att.id}`;
const _onKey = (e) => { if (e.key === 'Escape') _close(); };
const _close = () => {
document.removeEventListener('keydown', _onKey);
if (_overlayObs) { try { _overlayObs.disconnect(); } catch {} }
overlay.remove();
};
// If the overlay is removed via any path other than our close handler
// (session switch, parent re-render, external cleanup), still drop the
// document-level keydown listener so it doesn't leak.
let _overlayObs = null;
try {
_overlayObs = new MutationObserver(() => {
if (!document.body.contains(overlay)) {
document.removeEventListener('keydown', _onKey);
_overlayObs.disconnect();
}
});
_overlayObs.observe(document.body, { childList: true, subtree: false });
} catch {}
overlay.addEventListener('click', _close);
document.addEventListener('keydown', _onKey);
document.body.appendChild(overlay);
}
// Vision/OCR editor modal — opened from the corner "Aa" button on a chat photo
// thumbnail. Lets the user view and correct the text the vision model fed to
// the LLM (e.g. when OCR misreads a word). Persists to the server's vision
// cache (PUT /api/upload/{id}/vision), so any subsequent message that
// references the same file picks up the corrected text.
let _visionEditorEl = null;
let _visionEditorEsc = null;
function _closeVisionEditor() {
if (_visionEditorEsc) { document.removeEventListener('keydown', _visionEditorEsc); _visionEditorEsc = null; }
if (_visionEditorEl) { _visionEditorEl.remove(); _visionEditorEl = null; }
}
function _openVisionEditor(att, userMsgEl) {
if (!att?.id) return;
_closeVisionEditor();
const overlay = document.createElement('div');
overlay.className = 'vision-editor-overlay';
overlay.addEventListener('click', (e) => { if (e.target === overlay) _closeVisionEditor(); });
const panel = document.createElement('div');
panel.className = 'vision-editor-panel';
const title = document.createElement('div');
title.className = 'vision-editor-title';
// Eye icon matches the one in Settings → Vision so users recognise where
// this text originates.
title.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;flex-shrink:0"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span>Vision text</span>';
panel.appendChild(title);
const desc = document.createElement('div');
desc.className = 'vision-editor-desc';
desc.textContent = 'Edit text and save, new chats will have the new context. Regenerate or continue from there.';
panel.appendChild(desc);
const ta = document.createElement('textarea');
ta.className = 'vision-editor-text';
ta.rows = 10;
ta.placeholder = 'Loading…';
ta.disabled = true;
panel.appendChild(ta);
const actions = document.createElement('div');
actions.className = 'vision-editor-actions';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'vision-editor-btn';
closeBtn.innerHTML = '<span class="vision-btn-label">Close</span>';
closeBtn.addEventListener('click', _closeVisionEditor);
const _saveVisionText = async () => {
const res = await fetch(`/api/upload/${att.id}/vision`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ text: ta.value }),
});
if (!res.ok) throw new Error('save failed');
};
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'vision-editor-btn vision-editor-btn-primary';
saveBtn.innerHTML = '<span class="vision-btn-label">Save</span>';
saveBtn.disabled = true;
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="vision-btn-label">Saving…</span>';
try {
await _saveVisionText();
if (uiModule?.showToast) uiModule.showToast('Saved');
_closeVisionEditor();
} catch (e) {
saveBtn.disabled = false;
saveBtn.innerHTML = '<span class="vision-btn-label">Save</span>';
if (uiModule?.showError) uiModule.showError('Failed to save OCR text');
}
});
// Regenerate-message: save the edited text, close, then trigger a resend of
// the user message so the new AI reply uses the edit immediately.
const regenBtn = document.createElement('button');
regenBtn.type = 'button';
regenBtn.className = 'vision-editor-btn vision-editor-btn-primary';
regenBtn.title = 'Save and regenerate the message';
regenBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.74 9.74 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg><span class="vision-btn-label">Regenerate message</span>';
regenBtn.disabled = true;
regenBtn.addEventListener('click', async () => {
regenBtn.disabled = true;
saveBtn.disabled = true;
try {
await _saveVisionText();
_closeVisionEditor();
if (userMsgEl && window.chatModule?.resendUserMessage) {
window.chatModule.resendUserMessage(userMsgEl, { 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: <minimax:tool_call>, <tool_call>, <function_call>, bare <invoke>
const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi;
const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi;
// DeepSeek "DSML" tool-call markup (fullwidth-pipe or ascii | delimited) that
// leaks into content when the model emits a text tool call instead of a native
// one. Strip the whole block; the second pattern catches stray/partial tags
// (e.g. mid-stream before the closing tag arrives).
const DSML_TOOL_RE = /<\s*[|]+\s*DSML\s*[|]+\s*tool_calls\s*>[\s\S]*?(?:<\s*\/\s*[|]+\s*DSML\s*[|]+\s*tool_calls\s*>|$)/gi;
const DSML_STRAY_RE = /<\s*\/?\s*[|]+\s*DSML\s*[|]+[^>]*>/gi;
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 = '<div style="font-weight:600;margin-bottom:6px;color:var(--fg);display:flex;align-items:center;gap:6px;">';
if (logoHtml) html += '<span class="role-provider-logo" style="opacity:0.7">' + logoHtml + '</span>';
html += uiModule.esc(short) + '</div>';
html += '<div><span class="ctx-label">Model</span> ' + uiModule.esc(modelName.split('/').pop()) + '</div>';
// 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 += '<div><span class="ctx-label">Provider</span> ' + uiModule.esc(_provLabel) + '</div>';
// Show static context initially, then fetch real from server
const _realCtx = window._realContextLengths && window._realContextLengths[modelName];
if (_realCtx) {
html += '<div><span class="ctx-label">Context</span> ' + _fmtCtx(_realCtx) + ' tokens';
if (info && info.ctx && info.ctx !== _realCtx) html += ' <span style="opacity:0.35">(spec: ' + _fmtCtx(info.ctx) + ')</span>';
html += '</div>';
} else if (info && info.ctx) {
html += '<div><span class="ctx-label">Context</span> <span id="_ctx-val">' + _fmtCtx(info.ctx) + ' tokens</span></div>';
}
// Fetch real context from server async
if (!_realCtx && window.sessionModule) {
const _sid = window.sessionModule.getCurrentSessionId();
if (_sid) {
fetch('/api/session/' + _sid + '/context_info').then(r => r.ok ? r.json() : null).then(d => {
if (d && d.context_length) {
if (!window._realContextLengths) window._realContextLengths = {};
window._realContextLengths[modelName] = d.context_length;
const el = document.getElementById('_ctx-val');
if (el) {
el.innerHTML = _fmtCtx(d.context_length) + ' tokens';
if (info && info.ctx && info.ctx !== d.context_length) {
el.innerHTML += ' <span style="opacity:0.35">(spec: ' + _fmtCtx(info.ctx) + ')</span>';
}
}
}
}).catch(() => {});
}
}
// Show configured max tokens if set
if (window.presetsModule) {
const _pid = window.presetsModule.getSelectedPreset();
const _preset = _pid ? window.presetsModule.getPreset(_pid) : null;
const _mt = _preset?.max_tokens;
if (_mt && _mt > 0 && _mt <= 8192) {
html += '<div><span class="ctx-label">Max tokens</span> ' + _mt.toLocaleString() + ' <span style="opacity:0.4">(configured)</span></div>';
}
}
if (isCostTrackedEndpoint(_epUrl)) {
if (info && info.input != null) html += '<div><span class="ctx-label">Input</span> $' + info.input.toFixed(2) + ' / 1M</div>';
if (info && info.output != null) html += '<div><span class="ctx-label">Output</span> $' + info.output.toFixed(2) + ' / 1M</div>';
if (!info) html += '<div style="opacity:0.4;font-size:0.85em;margin-top:4px;">No pricing data available</div>';
}
popup.innerHTML = html;
const rect = roleEl.getBoundingClientRect();
popup.style.top = (rect.bottom + 4) + 'px';
popup.style.left = rect.left + 'px';
document.body.appendChild(popup);
const pr = popup.getBoundingClientRect();
if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px';
if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px';
bindMenuDismiss(popup, () => popup.remove());
});
}
}
export function getModelCost(modelName, inputTokens, outputTokens) {
if (!modelName) return null;
const 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.64100.127.x), a
* `.local` name, or the app's own host, the model is local → free.
* Unknown / missing endpoint also counts as local (bias to not over-bill).
*/
export function isLocalEndpoint(url) {
if (!url) return true;
let host;
try { host = new URL(url).hostname; } catch (_e) { return true; }
if (!host) return true;
if (host === 'localhost' || host === '0.0.0.0' || host === 'host.docker.internal' || host.endsWith('.local')) return true;
if (typeof window !== 'undefined' && window.location && host === window.location.hostname) return true;
// A single-label hostname (no dot) is an internal/Docker service name
// (e.g. "nim-nano", "llamaswap", "nemotron-super-49b") or a LAN shortname —
// never a public API, which always needs an FQDN. Treat as local → free.
// (Without this, container-name endpoints get billed at cloud rates because
// the pricing table matches on a name substring, e.g. "nemotron".)
if (!host.includes('.')) return true;
if (/^127\./.test(host)) return true;
if (/^10\./.test(host)) return true;
if (/^192\.168\./.test(host)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true;
const cg = host.match(/^100\.(\d+)\./); // Tailscale CGNAT
if (cg && +cg[1] >= 64 && +cg[1] <= 127) return true;
return false;
}
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 <think> reasoning stripped. dataset.raw keeps
* the full model output (chat.js even embeds the elapsed time into the
* <think> 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 += '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer" class="source-link">'
+ '<span class="source-num">' + (i + 1) + '</span>'
+ '<span class="source-title">' + title + '</span>'
+ '<span class="source-domain">' + esc(domain) + '</span>'
+ '</a>';
}
var arrow = expanded ? 'down' : 'right';
var expandedClass = expanded ? ' expanded' : '';
return '<div class="sources-section">'
+ '<div class="sources-header" data-sources-id="' + id + '" onclick="window.toggleSources(\'' + id + '\')">'
+ '<div class="sources-header-left">' + SEARCH_ICON + '<span>' + count + ' ' + label + '</span></div>'
+ '<span class="sources-toggle" id="' + id + '-toggle" data-arrow="' + arrow + '"></span>'
+ '</div>'
+ '<div class="sources-content' + expandedClass + '" id="' + id + '">'
+ '<div class="sources-content-inner">' + lines + '</div>'
+ '</div></div>';
}
/**
* Build the RAG "Sources (N documents)" box — mirrors the live render in
* chat.js so persisted rag_sources survive a refresh. Items carry a
* filename, similarity %, and snippet (not URLs, unlike web sources).
* @param {Array<{filename, similarity, snippet}>} sources
*/
export function buildRagSourcesBox(sources) {
if (!sources || !sources.length) return '';
var esc = uiModule.esc;
var items = '';
for (var i = 0; i < sources.length; i++) {
var s = sources[i] || {};
var pct = (typeof s.similarity === 'number') ? (s.similarity * 100).toFixed(1) + '%' : '';
items += '<div class="rag-source-item"><strong>' + esc(s.filename || '') + '</strong>'
+ (pct ? ' <span class="rag-similarity">' + pct + '</span>' : '')
+ '<div class="rag-snippet">' + esc(s.snippet || '') + '</div></div>';
}
return '<details class="rag-sources"><summary>Sources (' + sources.length + ' documents)</summary>' + items + '</details>';
}
/**
* Build a collapsible "Raw collected findings" section, styled like the sources box.
* @param {Array<{url, title, summary}>} findings
* @param {boolean} [expanded=false]
*/
export function buildFindingsBox(findings, expanded) {
if (!findings || !findings.length) return '';
var esc = uiModule.esc;
var id = 'findings-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
var count = findings.length;
var lines = '';
for (var i = 0; i < count; i++) {
var f = findings[i];
var domain = '';
try { domain = new URL(f.url).hostname.replace('www.', ''); } catch(e) { domain = f.url; }
var title = esc(f.title || domain || '');
var summary = esc(f.summary || '');
var safeUrl = _safeHref(f.url);
lines += '<div class="finding-item">'
+ '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer" class="source-link">'
+ '<span class="source-num">' + (i + 1) + '</span>'
+ '<span class="source-title">' + title + '</span>'
+ '<span class="source-domain">' + esc(domain) + '</span>'
+ '</a>'
+ '<div class="finding-summary">' + summary + '</div>'
+ '</div>';
}
var FINDINGS_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>';
var arrow = expanded ? 'down' : 'right';
var expandedClass = expanded ? ' expanded' : '';
return '<div class="sources-section">'
+ '<div class="sources-header" data-sources-id="' + id + '" onclick="window.toggleSources(\'' + id + '\')">'
+ '<div class="sources-header-left">' + FINDINGS_ICON + '<span>' + count + ' Raw collected findings</span></div>'
+ '<span class="sources-toggle" id="' + id + '-toggle" data-arrow="' + arrow + '"></span>'
+ '</div>'
+ '<div class="sources-content' + expandedClass + '" id="' + id + '">'
+ '<div class="sources-content-inner">' + lines + '</div>'
+ '</div></div>';
}
/** Append report button + continue research prompt. */
export function appendReportButton(container, sessionId) {
_appendReportButton(container, sessionId);
_appendContinuePrompt(container);
}
function _appendContinuePrompt(container) {
var wrap = document.createElement('div');
wrap.className = 'continue-research-wrap';
wrap.innerHTML =
'<div class="continue-research-hint">'
+ '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>'
+ '<span>Dig deeper? Activate Research again and type a follow-up question to continue this research.</span>'
+ '</div>';
container.appendChild(wrap);
}
function _appendReportButton(container, sessionId) {
var apiBase = window.API_BASE || '';
// Wrapper holds report button + chat-about button
var wrap = document.createElement('div');
wrap.className = 'report-btn-wrap';
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'view-report-btn';
btn.innerHTML = REPORT_ICON + ' Open Visual Report';
var reportUrl = apiBase + '/api/research/report/' + sessionId;
btn.addEventListener('click', function() {
window.open(reportUrl, '_blank');
});
wrap.appendChild(btn);
var chatBtn = document.createElement('button');
chatBtn.type = 'button';
chatBtn.className = 'view-report-btn chat-about-btn';
chatBtn.innerHTML = CHAT_ABOUT_ICON + ' Discuss';
chatBtn.addEventListener('click', async function() {
if (chatBtn.disabled) return;
var origLabel = chatBtn.innerHTML;
chatBtn.disabled = true;
chatBtn.innerHTML = CHAT_ABOUT_ICON + ' Creating…';
try {
var res = await fetch(apiBase + '/api/research/spinoff/' + sessionId, { method: 'POST' });
if (!res.ok) {
var detail = '';
try { detail = (await res.json()).detail || ''; } catch {}
throw new Error(detail || ('HTTP ' + res.status));
}
var payload = await res.json();
if (window.sessionModule && payload.session_id) {
await window.sessionModule.loadSessions().catch(() => {});
await window.sessionModule.selectSession(payload.session_id);
}
} catch (e) {
chatBtn.disabled = false;
chatBtn.innerHTML = origLabel;
if (window.uiModule && uiModule.showError) {
uiModule.showError('Could not start follow-up chat: ' + e.message);
} else {
alert('Could not start follow-up chat: ' + e.message);
}
}
});
wrap.appendChild(chatBtn);
container.appendChild(wrap);
}
window.toggleSources = function(id) {
// Debounce to prevent double-fire from both inline onclick and delegation
var now = Date.now();
if (window._lastSourcesToggle && now - window._lastSourcesToggle < 100) return;
window._lastSourcesToggle = now;
var content = document.getElementById(id);
var toggle = document.getElementById(id + '-toggle');
if (content && toggle) {
var expanded = content.classList.contains('expanded');
content.classList.toggle('expanded', !expanded);
toggle.dataset.arrow = expanded ? 'right' : 'down';
}
};
// Event delegation for sources toggle (capture phase, handles SVG targets)
document.addEventListener('click', function(e) {
// Walk up from target manually to handle SVG elements that may not support closest()
var el = e.target;
while (el && el !== document) {
if (el.classList && el.classList.contains('sources-header') && el.dataset && el.dataset.sourcesId) {
e.stopPropagation();
window.toggleSources(el.dataset.sourcesId);
return;
}
el = el.parentElement || el.parentNode;
}
}, true);
function resolveDocumentPlaceholderLinks(text, metadata) {
if (!text || !metadata || !Array.isArray(metadata.tool_events)) return text;
const docEvents = metadata.tool_events.filter(ev => ev && ev.doc_id);
if (!docEvents.length) return text;
return String(text).replace(/#document-(\d+)\b/g, (match, num) => {
const idx = Number(num) - 1;
const ev = Number.isInteger(idx) && idx >= 0 ? docEvents[idx] : null;
return ev && ev.doc_id ? `#document-${ev.doc_id}` : match;
});
}
// Jump-to-entity anchors — the agent emits links like
// [New Chat](#session-89effa28)
// [Notes](#document-abc123)
// [Reminder](#note-42)
// and the chat-history click delegate turns them into navigation
// instead of default in-page anchor jumps. Each prefix routes to the
// matching module via a dynamic import (avoids circular deps —
// sessions.js itself imports chatRenderer.js).
function _activateEntityAnchor(e, forcedAnchor = null) {
// Walk past Text nodes — clicking link text yields a Text node target
// whose .closest is undefined, so preventDefault never fires and the
// browser performs a default hash-navigation that resets the session.
let _t = e.target;
while (_t && _t.nodeType === Node.TEXT_NODE) _t = _t.parentElement;
const a = forcedAnchor || (_t && _t.closest && _t.closest('a[href]'));
if (!a) return;
const rawHref = a.getAttribute('href') || '';
let href = rawHref;
try {
const parsed = new URL(rawHref, window.location.origin);
if (parsed.origin === window.location.origin && parsed.pathname === window.location.pathname) {
href = parsed.hash || rawHref;
}
} catch (_) {}
if (!href.startsWith('#')) return;
if (href === '#plan') {
e.preventDefault();
e.stopPropagation();
if (typeof window.__odysseusOpenPlanReview === 'function') window.__odysseusOpenPlanReview();
return;
}
const barePanel = href.match(/^#(calendar|memory|notes|tasks|documents|skills|research)$/);
if (barePanel) {
e.preventDefault();
e.stopPropagation();
const panel = barePanel[1];
if (panel === 'calendar') {
document.getElementById('tool-calendar-btn')?.click();
} else if (panel === 'memory') {
import('./memory.js').then(mod => {
const open = mod.openMemoryModal || (mod.default && mod.default.openMemoryModal);
if (open) open('browse');
}).catch(() => {});
} else if (panel === 'notes') {
document.getElementById('tool-notes-btn')?.click();
} else if (panel === 'tasks') {
document.getElementById('tool-tasks-btn')?.click();
} else if (panel === 'documents') {
document.getElementById('tool-library-btn')?.click();
} else if (panel === 'skills') {
document.getElementById('tool-skills-btn')?.click();
} else if (panel === 'research') {
import('./research/panel.js?v=20260911researchcardselect1').then(mod => {
const open = mod.openPanel || (mod.default && mod.default.openPanel);
if (open) open();
}).catch(() => {});
}
return;
}
let m = href.match(/^#(session|document|note|memory|image|email|event|task|skill|research|cookbook)-(.+)$/);
if (!m) {
const noteOpen = href.match(/^#open=notes&note=([^&]+)/);
if (noteOpen) m = ['note', 'note', decodeURIComponent(noteOpen[1])];
}
if (!m) {
const bareSession = href.match(/^#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
if (bareSession) m = ['session', 'session', bareSession[1]];
}
if (!m) return;
e.preventDefault();
e.stopPropagation();
const [, kind, rawId] = m;
let id = rawId;
try { id = decodeURIComponent(String(rawId || '').replace(/\+/g, ' ')); } catch (_) {}
if (kind === 'session') {
const fallback = () => {
try {
if (window.sessionModule && typeof window.sessionModule.selectSession === 'function') {
return window.sessionModule.selectSession(id, { showLoading: true, immediateLoading: true });
}
} catch (_) {}
try { window.location.hash = id; } catch (_) {}
};
try {
a.classList.add('is-loading');
a.setAttribute('aria-busy', 'true');
} catch {}
import('./sessions.js').then(mod => {
const fn = mod.selectSession || (mod.default && mod.default.selectSession);
if (fn) return fn(id, { showLoading: true, immediateLoading: true });
return fallback();
}).catch(() => {
return fallback();
}).finally(() => {
try {
a.classList.remove('is-loading');
a.removeAttribute('aria-busy');
} catch {}
});
} else if (kind === 'document') {
import('./document.js?v=20260911removealignrightshortcut1').then(mod => {
const open = mod.loadDocument
|| mod.openDocument
|| (mod.default && (mod.default.loadDocument || mod.default.openDocument));
if (open) open(id);
}).catch(() => {});
} else if (kind === 'note') {
import('./notes.js?v=20260910drawmerge1').then(mod => {
const open = mod.openNote || (mod.default && mod.default.openNote);
if (open) open(id);
try {
if (/^#(?:note-|open=notes&note=)/.test(window.location.hash || '')) {
history.replaceState(null, '', window.location.pathname + window.location.search);
}
} catch (_) {}
}).catch(() => {});
} else if (kind === 'memory') {
import('./memory.js').then(mod => {
const open = mod.openMemory || (mod.default && mod.default.openMemory);
if (open) open(id);
}).catch(() => {});
} else if (kind === 'image') {
import('./gallery.js?v=20260910promptcopy1').then(mod => {
const open = mod.openGalleryImage || (mod.default && mod.default.openGalleryImage);
if (open) open(id);
}).catch(() => {});
} else if (kind === 'email') {
import('./emailLibrary.js?v=20260910replyactions1').then(mod => {
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (!open) return;
const parts = String(id || '').split(':');
if (parts.length >= 2 && /^\d+$/.test(parts[1])) {
const opts = { folder: parts[0] || 'INBOX', uid: parts[1] };
const account = parts.slice(2).join(':');
if (account) opts.account_id = account;
open(opts);
} else {
open({ uid: id });
}
}).catch(() => {});
} else if (kind === 'event') {
import('./calendar.js?v=20260903weekscrollstable1').then(mod => {
const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo);
if (open) open(id);
}).catch(() => {});
} else if (kind === 'task') {
import('./tasks.js?v=20260901taskskilldensity1').then(mod => {
const open = mod.openTasks || (mod.default && mod.default.openTasks);
if (open) open(id);
else { const b = document.getElementById('tasks-btn'); if (b) b.click(); }
}).catch(() => { const b = document.getElementById('tasks-btn'); if (b) b.click(); });
} else if (kind === 'skill') {
import('./skills.js?v=20260909kebabconsistency1').then(mod => {
const open = mod.openSkill || (mod.default && mod.default.openSkill);
if (open) open(id);
}).catch(() => {});
} else if (kind === 'research') {
import('./research/panel.js?v=20260911researchcardselect1').then(mod => {
const open = mod.openPanel || (mod.default && mod.default.openPanel);
if (open) open(id);
}).catch(() => {});
} else if (kind === 'cookbook') {
import('./cookbook.js').then(mod => {
const open = mod.open || (mod.default && mod.default.open);
if (!open) return;
if (id.startsWith('session-')) open({ tab: 'Running', focusSession: id.slice(8) });
else if (id.startsWith('model-')) open({ tab: 'Serve', focusRepo: id.slice(6).replace('~', '/') });
else open();
}).catch(() => {});
}
}
// The live Markdown tail is replaced as tokens arrive. On touch devices that
// can detach an anchor between pointerdown and the browser's synthetic click,
// making links appear ready but inert until streaming ends. Preserve the
// pointerdown anchor and activate it on a stationary pointerup; suppress the
// duplicate click that browsers emit immediately afterward.
let _streamEntityPointer = null;
let _streamEntitySuppressClick = null;
const _isEntityHref = (href) => /^#(?:(?:calendar|memory|notes|tasks|documents|skills|research)$|(?:session|document|note|memory|image|email|event|task|skill|research|cookbook)-)/.test(String(href || ''));
document.addEventListener('pointerdown', function(e) {
const a = e.target?.closest?.('#chat-history .msg-ai.streaming a[href]');
const href = a?.getAttribute('href') || '';
_streamEntityPointer = a && _isEntityHref(href)
? { a, href, x: e.clientX, y: e.clientY, pointerId: e.pointerId }
: null;
}, true);
document.addEventListener('pointerup', function(e) {
const pending = _streamEntityPointer;
_streamEntityPointer = null;
if (!pending || pending.pointerId !== e.pointerId) return;
if (Math.hypot(e.clientX - pending.x, e.clientY - pending.y) > 10) return;
_streamEntitySuppressClick = { href: pending.href, until: performance.now() + 800 };
_activateEntityAnchor(e, pending.a);
}, true);
document.addEventListener('pointercancel', function() {
_streamEntityPointer = null;
}, true);
document.addEventListener('click', function(e) {
const a = e.target?.closest?.('a[href]');
const href = a?.getAttribute('href') || '';
if (_streamEntitySuppressClick
&& performance.now() <= _streamEntitySuppressClick.until
&& href === _streamEntitySuppressClick.href) {
_streamEntitySuppressClick = null;
e.preventDefault();
e.stopPropagation();
return;
}
_streamEntitySuppressClick = null;
_activateEntityAnchor(e);
}, true);
/**
* Build a generated-image bubble element.
*/
export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId) {
var esc = uiModule.esc;
const wrap = document.createElement('div');
wrap.className = 'msg msg-ai generated-image-wrap';
wrap.dataset.imageUrl = imageUrl || '';
wrap.dataset.imageKey = String(imageId || imageUrl || '');
if (imageId) wrap.dataset.imageId = imageId;
const role = document.createElement('div');
role.className = 'role';
role.textContent = (model || 'image').split('/').pop();
wrap.appendChild(role);
const body = document.createElement('div');
body.className = 'body';
const safeImageUrl = safeDisplayImageSrc(imageUrl);
if (!safeImageUrl) {
body.textContent = '[Image unavailable]';
wrap.appendChild(body);
return wrap;
}
const img = document.createElement('img');
img.className = 'generated-image';
img.alt = prompt || 'Generated image';
img.title = prompt || 'Generated image';
img.src = safeImageUrl;
img.addEventListener('click', () => { window.open(safeImageUrl, '_blank', 'noopener,noreferrer'); });
body.appendChild(img);
if (prompt) {
const caption = document.createElement('div');
caption.className = 'generated-image-caption';
caption.textContent = prompt;
body.appendChild(caption);
}
wrap.appendChild(body);
const footer = document.createElement('div');
footer.className = 'msg-footer';
const actions = document.createElement('span');
actions.className = 'msg-actions';
const copyBtn = document.createElement('button');
copyBtn.className = 'footer-copy-btn';
copyBtn.type = 'button';
copyBtn.title = 'Copy prompt';
copyBtn.innerHTML = COPY_ICON;
copyBtn.addEventListener('click', (e) => {
e.stopPropagation();
uiModule.copyToClipboard(prompt || '');
copyBtn.innerHTML = CHECK_ICON;
setTimeout(() => { copyBtn.innerHTML = COPY_ICON; }, 1500);
});
actions.appendChild(copyBtn);
const dlBtn = document.createElement('button');
dlBtn.className = 'footer-copy-btn';
dlBtn.type = 'button';
dlBtn.title = 'Download image';
dlBtn.textContent = '\u2913';
dlBtn.addEventListener('click', async (e) => {
e.stopPropagation();
try {
const resp = await fetch(imageUrl);
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (prompt || 'image').slice(0, 40).replace(/[^a-zA-Z0-9 ]/g, '') + '.png';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
dlBtn.textContent = '\u2713';
setTimeout(() => { dlBtn.textContent = '\u2913'; }, 1500);
} catch { dlBtn.textContent = '\u2717'; setTimeout(() => { dlBtn.textContent = '\u2913'; }, 1500); }
});
actions.appendChild(dlBtn);
const reuseBtn = document.createElement('button');
reuseBtn.className = 'footer-copy-btn';
reuseBtn.type = 'button';
reuseBtn.title = 'Attach image to new prompt';
reuseBtn.innerHTML = PAPERCLIP_ICON;
reuseBtn.addEventListener('click', async (e) => {
e.stopPropagation();
reuseBtn.disabled = true;
try {
const resp = await fetch(safeImageUrl, { credentials: 'same-origin' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
const ext = (blob.type || '').includes('jpeg') ? 'jpg'
: (blob.type || '').includes('webp') ? 'webp'
: (blob.type || '').includes('gif') ? 'gif'
: 'png';
const base = (prompt || 'generated-image').slice(0, 36).replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'generated-image';
const file = new File([blob], `${base}.${ext}`, { type: blob.type || 'image/png', lastModified: Date.now() });
const mod = await import('./fileHandler.js?v=20260909mobileattachmentedit1');
const addFiles = mod.addFiles || (mod.default && mod.default.addFiles);
if (!addFiles) throw new Error('attachment handler unavailable');
await addFiles([file], { skipCrop: true });
const input = document.getElementById('message');
if (input) input.focus();
reuseBtn.innerHTML = CHECK_ICON;
if (window.showToast) window.showToast('Image attached');
setTimeout(() => { reuseBtn.innerHTML = PAPERCLIP_ICON; reuseBtn.disabled = false; }, 1400);
} catch (err) {
console.warn('Attach generated image failed', err);
reuseBtn.textContent = '\u2717';
if (window.showToast) window.showToast('Could not attach image');
setTimeout(() => { reuseBtn.innerHTML = PAPERCLIP_ICON; reuseBtn.disabled = false; }, 1600);
}
});
actions.appendChild(reuseBtn);
const editBtn = document.createElement('button');
editBtn.className = 'footer-copy-btn';
editBtn.type = 'button';
editBtn.title = 'Edit in image editor';
editBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>';
editBtn.addEventListener('click', async (e) => {
e.stopPropagation();
try {
const [galleryMod, editorMod] = await Promise.all([
import('./gallery.js?v=20260910promptcopy1'),
loadPanel('editor'),
]);
// Ensure the Gallery modal is open so the editor has a container
// to render into; switch its tabs to the Edit tab.
galleryMod.default.openGallery();
const modal = document.getElementById('gallery-modal');
if (modal) {
modal.querySelectorAll('.gallery-tab').forEach(t => t.classList.remove('active'));
modal.querySelector('.gallery-tab[data-tab="editor"]')?.classList.add('active');
}
const imagesContainer = document.getElementById('gallery-images-container');
const albumsContainer = document.getElementById('gallery-albums-container');
if (imagesContainer) imagesContainer.style.display = 'none';
if (albumsContainer) albumsContainer.style.display = 'none';
const editorContainer = document.getElementById('gallery-editor-container');
if (editorContainer) editorContainer.style.display = 'flex';
const label = (prompt || '').trim().slice(0, 60) || 'Generated image';
editorMod.openEditor(imageUrl, null, null, label);
} catch (err) {
console.error('[chat] open in editor failed', err);
}
});
actions.appendChild(editBtn);
if (imageId) {
const galleryBtn = document.createElement('button');
galleryBtn.className = 'footer-copy-btn footer-open-gallery-btn';
galleryBtn.type = 'button';
galleryBtn.title = 'Open in gallery';
galleryBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg><span>Open in gallery</span>';
galleryBtn.addEventListener('click', async (e) => {
e.stopPropagation();
try {
const mod = await import('./gallery.js?v=20260910promptcopy1');
const open = mod.openGalleryImage || (mod.default && mod.default.openGalleryImage);
if (open) open(imageId);
} catch (err) {
console.error('[chat] open in gallery failed', err);
}
});
actions.appendChild(galleryBtn);
}
const delBtn = document.createElement('button');
delBtn.className = 'footer-copy-btn footer-delete-btn';
delBtn.type = 'button';
delBtn.title = 'Delete image';
delBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
delBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const ok = await uiModule.styledConfirm('Delete this image?', {
confirmText: 'Delete',
cancelText: 'Cancel',
danger: true,
});
if (!ok) return;
// If we have a gallery id, delete server-side; otherwise just remove
// the bubble from chat (e.g. external DALL-E url that wasn't saved).
if (imageId) {
try {
const res = await fetch(`/api/gallery/${encodeURIComponent(imageId)}`, {
method: 'DELETE', credentials: 'same-origin',
});
if (!res.ok && res.status !== 404) {
uiModule.showToast?.('Delete failed', 4000);
return;
}
window.dispatchEvent(new CustomEvent('gallery-refresh'));
} catch (_) {
uiModule.showToast?.('Delete failed', 4000);
return;
}
}
wrap.remove();
});
actions.appendChild(delBtn);
footer.appendChild(actions);
const metrics = document.createElement('span');
metrics.className = 'response-metrics';
const parts = [];
if (model) parts.push(model.split('/').pop());
if (size) parts.push(size);
if (quality) parts.push(quality);
const cost = getImageCost(model, quality, size);
if (cost !== null) parts.push('$' + (cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)));
metrics.textContent = parts.join(' \u00B7 ');
footer.appendChild(metrics);
wrap.appendChild(footer);
return wrap;
}
export function hideWelcomeScreen() {
const ws = document.getElementById('welcome-screen');
const cc = document.getElementById('chat-container');
if (ws) ws.classList.add('hidden');
if (cc) cc.classList.remove('welcome-active');
// Update send button — switches from muted arrow to + Chat
if (window._updateSendBtnIcon) setTimeout(window._updateSendBtnIcon, 50);
const ib = document.getElementById('incognito-btn');
if (ib) ib.style.display = ib.classList.contains('active') ? '' : 'none';
}
export function showWelcomeScreen() {
const ws = document.getElementById('welcome-screen');
const cc = document.getElementById('chat-container');
const alreadyVisible = !!(ws && !ws.classList.contains('hidden'));
if (ws) ws.classList.remove('hidden');
if (cc) cc.classList.add('welcome-active');
if (alreadyVisible) {
return;
}
// Entering the New Chat / welcome state: discard any stale draft left in the
// composer from the previous session so the input starts empty (issue #1343).
// Switching between existing sessions loads them directly and does NOT call
// this, so genuine drafts are not erased. Reset the autosized height and fire
// an `input` event so the send button + autosize listeners update.
const _msg = document.getElementById('message');
if (_msg) {
_msg.value = '';
_msg.style.height = '';
_msg.dispatchEvent(new Event('input', { bubbles: true }));
}
// Re-trigger the L→R clip-wipe reveal on the welcome name each time the
// welcome screen is shown (new session, deleted last session, etc.) — without
// this, the CSS animation only fires on initial DOM insertion.
const wn = document.querySelector('.welcome-name');
if (wn) {
wn.style.animation = 'none';
// force reflow so the next assignment registers as a new animation
void wn.offsetHeight;
wn.style.animation = '';
}
// Update send button — switches from + Chat to muted arrow on empty session
if (window._updateSendBtnIcon) setTimeout(window._updateSendBtnIcon, 50);
const ib = document.getElementById('incognito-btn');
const _researchChk = document.getElementById('research-toggle');
if (ib && !(_researchChk && _researchChk.checked)) ib.style.display = '';
if (window.innerWidth > 768) {
const msg = document.getElementById('message');
if (msg) msg.focus();
}
}
// ── Dynamic action buttons (show 3 most recent, rest under ···) ──
const _ACTION_RECENTS_KEY = 'odysseus-msg-actions-recent';
const _MAX_VISIBLE = 2;
function _getRecentActions() {
try { return JSON.parse(localStorage.getItem(_ACTION_RECENTS_KEY) || '[]'); } catch { return []; }
}
function _trackAction(id) {
let recent = _getRecentActions().filter(x => x !== id);
recent.unshift(id);
if (recent.length > 10) recent.length = 10;
localStorage.setItem(_ACTION_RECENTS_KEY, JSON.stringify(recent));
}
/**
* Create a footer row for an AI message with timestamp and action buttons.
*/
export function createMsgFooter(msgElement) {
const footer = document.createElement('div');
footer.className = 'msg-footer';
const actions = document.createElement('span');
actions.className = 'msg-actions';
// Define all available actions: { id, icon, title, className, handler }
const allActions = [
{ id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) {
e.stopPropagation();
const btn = e.currentTarget;
uiModule.copyToClipboard(copyMessageText(msgElement));
btn.innerHTML = CHECK_ICON;
setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500);
}},
{ id: 'edit', icon: '\u270E', title: 'Edit', cls: 'msg-action-btn', handler(e) {
e.stopPropagation();
if (window.chatModule?.editAIMessage) window.chatModule.editAIMessage(msgElement);
}},
{ id: 'regen', icon: RESEND_ICON, title: 'Regenerate from here', cls: 'msg-action-btn', html: true, handler(e) {
e.stopPropagation();
if (window.chatModule?.regenerateFrom) window.chatModule.regenerateFrom(msgElement);
}},
{ id: 'shorten', icon: '\u2702', title: 'Rewrite shorter', cls: 'msg-action-btn', handler(e) {
e.stopPropagation();
if (window.chatModule?.rewriteWith) window.chatModule.rewriteWith(msgElement, 'Rewrite your last response to be shorter and more concise. Keep the key information but cut the fluff.');
}},
{ id: 'explain', icon: '?', title: 'Explain simpler', cls: 'msg-action-btn', handler(e) {
e.stopPropagation();
if (window.chatModule?.rewriteWith) window.chatModule.rewriteWith(msgElement, 'Explain your last response in simpler terms. Use plain language and short sentences.');
}},
{ id: 'fork', icon: '\u2ADD', title: 'Fork conversation', cls: 'msg-action-btn', handler(e) {
e.stopPropagation();
if (window.chatModule?.forkFrom) window.chatModule.forkFrom(msgElement);
}},
{ id: 'delete', icon: TRASH_ICON, title: 'Delete message', cls: 'msg-action-btn msg-delete-btn', html: true, handler(e) {
e.stopPropagation();
if (window.chatModule?.deleteMessage) window.chatModule.deleteMessage(msgElement);
}},
];
// Filter out unavailable actions (e.g. TTS when not enabled)
const availableActions = allActions.filter(a => !a.available || a.available());
// Determine which 3 to show: use recent order, fallback to defaults
const recent = _getRecentActions();
const defaults = ['copy', 'delete', 'fork'];
const order = recent.length > 0 ? recent : defaults;
const sorted = [...availableActions].sort((a, b) => {
const ai = order.indexOf(a.id), bi = order.indexOf(b.id);
if (ai >= 0 && bi >= 0) return ai - bi;
if (ai >= 0) return -1;
if (bi >= 0) return 1;
return 0;
});
const visible = sorted.slice(0, _MAX_VISIBLE);
const overflow = sorted.slice(_MAX_VISIBLE);
// Render visible buttons
function _addBtn(action, container) {
const btn = _makeActionBtn(action.cls, action.title, action.html ? '' : action.icon, (e) => {
_trackAction(action.id);
action.handler(e);
});
if (action.html) btn.innerHTML = action.icon;
btn.dataset.action = action.id;
container.appendChild(btn);
}
visible.forEach(a => _addBtn(a, actions));
// Overflow "···" button
if (overflow.length > 0) {
const moreBtn = document.createElement('button');
moreBtn.className = 'msg-action-btn msg-more-btn';
moreBtn.type = 'button';
moreBtn.title = 'More actions';
moreBtn.textContent = '\u00B7\u00B7\u00B7';
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
// Toggle overflow menu — close any existing one first (through its own
// dismiss so the Escape registry entry goes with it).
const existing = document.querySelector('.msg-overflow-menu');
if (existing) {
if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
if (existing._trigger === moreBtn) return;
}
const menu = document.createElement('div');
menu.className = 'msg-overflow-menu';
let closeMenu = () => menu.remove();
overflow.forEach(a => {
const item = document.createElement('button');
item.className = 'msg-overflow-item';
item.type = 'button';
item.title = a.title;
item.innerHTML = `<span class="overflow-icon">${a.icon}</span> ${a.title}`;
item.addEventListener('click', (ev) => {
ev.stopPropagation();
_trackAction(a.id);
closeMenu();
a.handler(ev);
});
menu.appendChild(item);
});
menu._trigger = moreBtn;
document.body.appendChild(menu);
// Position fixed relative to the ··· button
const btnRect = moreBtn.getBoundingClientRect();
menu.style.top = (btnRect.top - menu.offsetHeight - 4) + 'px';
menu.style.left = btnRect.left + 'px';
// Flip down if above viewport
if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px';
// Keep within right edge
const mr = menu.getBoundingClientRect();
if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
// Close on outside click or Escape. The trigger button is treated as
// "inside" so its own click toggles rather than double-fires.
closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); });
actions.appendChild(moreBtn);
}
// Memory-used indicator pill
const mems = msgElement._memoriesUsed;
if (mems && mems.length > 0) {
const pill = document.createElement('button');
pill.className = 'memory-used-pill';
pill.type = 'button';
const pinnedCount = mems.filter(m => m.type === 'pinned').length;
const recalledCount = mems.filter(m => m.type === 'recalled').length;
const parts = [];
if (pinnedCount) parts.push(`${pinnedCount} pinned`);
if (recalledCount) parts.push(`${recalledCount} recalled`);
pill.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/></svg><span class="memory-used-pill-text">${parts.join(', ')}</span>`;
pill.title = mems.map(m => `[${m.type}] ${m.text}`).join('\n');
pill.addEventListener('click', (e) => {
e.stopPropagation();
let detail = pill._openDetail || document.querySelector('.memory-used-detail');
if (detail) {
if (typeof detail._dismiss === 'function') detail._dismiss();
else { detail.remove(); pill._openDetail = null; }
return;
}
detail = document.createElement('div');
detail.className = 'memory-used-detail';
let closeDetail = () => { detail.remove(); pill._openDetail = null; };
mems.forEach(m => {
const row = document.createElement('div');
row.className = 'memory-used-row';
row.style.cursor = 'pointer';
row.title = 'Click to open memory manager';
const badge = document.createElement('span');
badge.className = 'memory-used-badge ' + (m.type === 'pinned' ? 'pinned' : 'recalled');
badge.textContent = m.type === 'pinned' ? '\u25CF' : '\u21BB';
const text = document.createElement('span');
text.className = 'memory-used-text';
text.textContent = m.text;
row.appendChild(badge);
row.appendChild(text);
row.addEventListener('click', (ev) => {
ev.stopPropagation();
closeDetail();
import('./memory.js')
.then(mod => {
const open = mod.openMemoryModal || mod.default?.openMemoryModal;
if (open) open('browse');
else document.getElementById('memory-modal')?.classList.remove('hidden');
})
.catch(() => document.getElementById('memory-modal')?.classList.remove('hidden'));
});
detail.appendChild(row);
});
detail.style.visibility = 'hidden';
document.body.appendChild(detail);
const pillRect = pill.getBoundingClientRect();
const detailRect = detail.getBoundingClientRect();
const spaceAbove = pillRect.top;
const spaceBelow = window.innerHeight - pillRect.bottom;
if (spaceAbove >= detailRect.height + 8 || spaceAbove > spaceBelow) {
detail.style.top = (pillRect.top - detailRect.height - 8) + 'px';
} else {
detail.style.top = (pillRect.bottom + 8) + 'px';
}
detail.style.left = pillRect.left + 'px';
if (pillRect.left + detailRect.width > window.innerWidth - 8) {
detail.style.left = (window.innerWidth - detailRect.width - 8) + 'px';
}
if (parseFloat(detail.style.left) < 8) detail.style.left = '8px';
detail.style.visibility = '';
pill._openDetail = detail;
// Close on outside click or Escape (pill click toggles, so it's inside).
closeDetail = bindMenuDismiss(detail, () => { detail.remove(); pill._openDetail = null; }, (ev) => !detail.contains(ev.target) && ev.target !== pill); });
footer.appendChild(pill);
}
footer.appendChild(actions);
return footer;
}
/**
* Create a footer row for a user message with action buttons (same system as AI footer).
*/
const _USER_ACTION_RECENTS_KEY = 'odysseus-user-actions-recent';
function _getUserRecentActions() {
try { return JSON.parse(localStorage.getItem(_USER_ACTION_RECENTS_KEY) || '[]'); } catch { return []; }
}
function _trackUserAction(id) {
let recent = _getUserRecentActions().filter(x => x !== id);
recent.unshift(id);
if (recent.length > 10) recent.length = 10;
localStorage.setItem(_USER_ACTION_RECENTS_KEY, JSON.stringify(recent));
}
export function createUserMsgFooter(msgElement) {
const footer = document.createElement('div');
footer.className = 'msg-footer';
const actions = document.createElement('span');
actions.className = 'msg-actions';
const allActions = [
{ id: 'edit', icon: '\u270E', title: 'Edit message', cls: 'msg-action-btn', handler(e) {
e.stopPropagation();
if (window.chatModule?.editUserMessage) window.chatModule.editUserMessage(msgElement);
}},
{ id: 'delete', icon: TRASH_ICON, title: 'Delete message', cls: 'msg-action-btn msg-delete-btn', html: true, handler(e) {
e.stopPropagation();
if (window.chatModule?.deleteMessage) window.chatModule.deleteMessage(msgElement);
}},
{ id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) {
e.stopPropagation();
const btn = e.currentTarget;
uiModule.copyToClipboard(msgElement.querySelector('.body')?.textContent || '');
btn.innerHTML = CHECK_ICON;
setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500);
}},
{ id: 'resend', icon: RESEND_ICON, title: 'Resend message', cls: 'msg-action-btn', html: true, handler(e) {
e.stopPropagation();
if (window.chatModule?.resendUserMessage) window.chatModule.resendUserMessage(msgElement);
}},
];
const recent = _getUserRecentActions();
const defaults = ['edit', 'delete', 'copy'];
const order = recent.length > 0 ? recent : defaults;
const sorted = [...allActions].sort((a, b) => {
const ai = order.indexOf(a.id), bi = order.indexOf(b.id);
if (ai >= 0 && bi >= 0) return ai - bi;
if (ai >= 0) return -1;
if (bi >= 0) return 1;
return 0;
});
const visible = sorted.slice(0, _MAX_VISIBLE);
const overflow = sorted.slice(_MAX_VISIBLE);
visible.forEach(a => {
const btn = _makeActionBtn(a.cls, a.title, a.html ? '' : a.icon, (ev) => {
_trackUserAction(a.id);
a.handler(ev);
});
if (a.html) btn.innerHTML = a.icon;
btn.dataset.action = a.id;
actions.appendChild(btn);
});
if (overflow.length > 0) {
const moreBtn = document.createElement('button');
moreBtn.className = 'msg-action-btn msg-more-btn';
moreBtn.type = 'button';
moreBtn.title = 'More actions';
moreBtn.textContent = '\u00B7\u00B7\u00B7';
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
const existing = document.querySelector('.msg-overflow-menu');
if (existing) {
if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
if (existing._trigger === moreBtn) return;
}
const menu = document.createElement('div');
menu.className = 'msg-overflow-menu';
let closeMenu = () => menu.remove();
overflow.forEach(a => {
const item = document.createElement('button');
item.className = 'msg-overflow-item';
item.type = 'button';
item.title = a.title;
item.innerHTML = `<span class="overflow-icon">${a.icon}</span> ${a.title}`;
item.addEventListener('click', (ev) => {
ev.stopPropagation();
_trackUserAction(a.id);
closeMenu();
a.handler(ev);
});
menu.appendChild(item);
});
menu._trigger = moreBtn;
document.body.appendChild(menu);
const btnRect = moreBtn.getBoundingClientRect();
menu.style.top = (btnRect.top - menu.offsetHeight - 4) + 'px';
menu.style.left = btnRect.left + 'px';
if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px';
const mr = menu.getBoundingClientRect();
if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); });
actions.appendChild(moreBtn);
}
footer.appendChild(actions);
return footer;
}
/**
* Display performance metrics for a message.
*/
export function displayMetrics(messageElement, metrics) {
messageElement
.querySelectorAll('.response-metrics, .metrics-divider, .ctx-divider, .ctx-ring')
.forEach((el) => el.remove());
const metricsContainer = document.createElement('span');
metricsContainer.className = 'response-metrics';
const responseTime = metrics.response_time;
const inputTokens = metrics.input_tokens || 0;
const outputTokens = metrics.output_tokens || 0;
const tps = metrics.tokens_per_second;
const ttft = metrics.client_ttft ?? metrics.time_to_first_token;
const injectedTokens = metrics.injected_tokens;
const isReal = metrics.usage_source === 'real';
const ctxPct = metrics.context_percent;
const model = metrics.model || 'Unknown';
const cost = _metricsBillableCost(
metrics,
model,
inputTokens,
outputTokens,
);
// Nothing useful to show — bail out (only if ALL metrics are missing)
if (!responseTime && !inputTokens && !outputTokens && tps == null && !ctxPct) return;
// Rendering can occur when metrics arrive and again after [DONE]. The
// ledger mutation is idempotent for that shared payload.
recordSessionMetricsCost(metrics);
// Keep token counts in the Message Stats popup; the footer should stay slim.
const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
const hasTps = tps != null && tps !== 'undefined' && Number.isFinite(Number(tps));
const tpsText = hasTps ? `${Number(tps).toFixed(2)} tok/s` : '';
const ttftText = ttft != null && Number.isFinite(Number(ttft))
? `${Number(ttft).toFixed(3)}s TTFT`
: '';
const injectedText = injectedTokens != null && Number.isFinite(Number(injectedTokens))
? `${Number(injectedTokens).toLocaleString()} in`
: '';
const metricsLabel = hasTps
? [tpsText, ttftText, injectedText].filter(Boolean).join(' · ')
: costStr0
? costStr0
: responseTime != null
? `${Number(responseTime).toFixed(3)}s`
: '';
if (!metricsLabel) return;
metricsContainer.textContent = metricsLabel;
metricsContainer.style.cursor = 'pointer';
metricsContainer.title = 'Click for details';
const metricsDivider = document.createElement('span');
metricsDivider.className = 'metrics-divider';
metricsDivider.textContent = ' | ';
metricsDivider.style.color = 'var(--color-muted-alt)';
metricsDivider.style.pointerEvents = 'none';
metricsContainer.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : '';
const costRows = costStr ? `<div><span class="ctx-label">Cost</span> ${costStr}</div>` : '';
const speedStr = hasTps ? tpsText : 'n/a';
const speedLabel = metrics.tps_source === 'computed' ? 'Speed (wall)' : 'Speed';
const totalTok = inputTokens + outputTokens;
const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--color-muted-alt, #6b7280)';
const prepTime = metrics.agent_prep_time;
const modelWaitTime = metrics.agent_model_wait_time;
const visibleTtft = metrics.client_ttft ?? metrics.time_to_first_token;
const schemaCount = metrics.tool_schema_count;
const agentRounds = metrics.agent_rounds;
const toolCalls = metrics.tool_calls;
const prepBreakdown = metrics.agent_prep_breakdown || null;
const prepDetails = prepBreakdown
? Object.entries(prepBreakdown).map(([k, v]) => `${k}: ${v}s`).join('<br>')
: '';
// Session total cost
let sessionCostStr = '';
const sc = getSessionCost();
if (costStr && sc > 0) {
sessionCostStr = `<div><span class="ctx-label">Session</span> $${sc < 0.01 ? sc.toFixed(4) : sc.toFixed(3)}</div>`;
}
const popup = document.createElement('div');
popup.className = 'ctx-popup';
popup.innerHTML = `
<div style="font-weight:600;margin-bottom:6px;color:var(--fg);">Message Stats</div>
<div><span class="ctx-label">Model</span> ${model.split('/').pop()}</div>
<div><span class="ctx-label">Input (all rounds)</span> ${inputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</div>
${injectedTokens != null ? `<div><span class="ctx-label">Injected (first request)</span> ${Number(injectedTokens).toLocaleString()} tokens</div>` : ''}
<div><span class="ctx-label">Output</span> ${outputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</div>
<div><span class="ctx-label">Total</span> ${totalTok.toLocaleString()} tokens</div>
<div><span class="ctx-label">${speedLabel}</span> ${speedStr}</div>
<div><span class="ctx-label">Time</span> ${Number(responseTime).toFixed(3)}s</div>
${prepTime != null ? `<div><span class="ctx-label">Prep</span> ${prepTime}s</div>` : ''}
${modelWaitTime != null ? `<div><span class="ctx-label">Model wait</span> ${modelWaitTime}s</div>` : ''}
${visibleTtft != null ? `<div><span class="ctx-label">TTFT</span> ${Number(visibleTtft).toFixed(3)}s</div>` : ''}
${schemaCount != null ? `<div><span class="ctx-label">Tool schemas</span> ${Number(schemaCount).toLocaleString()}</div>` : ''}
${agentRounds != null ? `<div><span class="ctx-label">Agent rounds</span> ${Number(agentRounds).toLocaleString()}</div>` : ''}
${toolCalls != null ? `<div><span class="ctx-label">Tool calls</span> ${Number(toolCalls).toLocaleString()}</div>` : ''}
${costRows}
${sessionCostStr}
${prepDetails ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);font-size:0.85em;opacity:0.8;">
<div style="font-weight:600;margin-bottom:4px;color:var(--fg);">Agent prep</div>
${prepDetails}
</div>` : ''}
${ctxPct !== undefined && ctxPct > 0 ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);">
<span class="ctx-label">Context</span> <span style="color:${ctxColor};font-weight:600;">${ctxPct}%</span> used
</div>` : ''}
${isReal ? '' : '<div style="margin-top:4px;font-size:0.8em;opacity:0.4;">~ estimated token count</div>'}
`;
const rect = metricsContainer.getBoundingClientRect();
popup.style.left = rect.left + 'px';
popup.style.visibility = 'hidden';
document.body.appendChild(popup);
const pr = popup.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceAbove >= pr.height + 8 || spaceAbove > spaceBelow) {
popup.style.top = (rect.top - pr.height - 8) + 'px';
} else {
popup.style.top = (rect.bottom + 8) + 'px';
}
if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px';
if (parseFloat(popup.style.left) < 8) popup.style.left = '8px';
popup.style.visibility = '';
bindMenuDismiss(popup, () => popup.remove());
});
// Store real context length for model info popup
if (metrics.context_length && metrics.model) {
if (!window._realContextLengths) window._realContextLengths = {};
window._realContextLengths[metrics.model] = metrics.context_length;
}
// Context usage ring
let ctxRing = null;
const ctxLen = metrics.context_length || 0;
if (ctxPct !== undefined && ctxPct > 0) {
const r = 6, stroke = 1.5;
const circ = 2 * Math.PI * r;
const fill = circ * (ctxPct / 100);
const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--green, #98c379)';
ctxRing = document.createElement('span');
ctxRing.className = 'ctx-ring';
ctxRing.title = `${ctxPct}% context used — click for details`;
ctxRing.style.cursor = 'pointer';
ctxRing.style.setProperty('--ctx-color', ctxColor);
ctxRing.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14">
<circle cx="7" cy="7" r="${r}" fill="none" stroke="var(--border, #333)" stroke-width="${stroke}" opacity="0.3"/>
<circle cx="7" cy="7" r="${r}" fill="none" stroke="var(--ctx-stroke)" stroke-width="${stroke}"
stroke-dasharray="${fill} ${circ - fill}" stroke-dashoffset="${circ * 0.25}"
stroke-linecap="round" transform="rotate(-90 7 7)"/>
</svg><span class="ctx-ring-pct">${Math.round(ctxPct)}%</span>`;
ctxRing.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.ctx-detail-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
const usedTokens = inputTokens || 0;
const totalCtx = ctxLen || 0;
const modelShort = model.split('/').pop();
const fmtNum = n => n ? n.toLocaleString() : '?';
const popup = document.createElement('div');
popup.className = 'ctx-detail-popup';
popup.innerHTML = `
<div style="font-weight:600;margin-bottom:8px;color:var(--fg);">Context Window</div>
<div class="ctx-bar-wrap">
<div class="ctx-bar-fill" style="width:${Math.min(ctxPct, 100)}%;background:${ctxColor};"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:0.75rem;margin-top:4px;opacity:0.6;">
<span>${fmtNum(usedTokens)} used</span>
<span>${fmtNum(totalCtx)} total</span>
</div>
<div style="margin-top:8px;font-size:0.8rem;">
<div><span class="ctx-label">Model</span> ${modelShort}</div>
<div><span class="ctx-label">Usage</span> <span style="color:${ctxColor};font-weight:600;">${ctxPct}%</span></div>
<div><span class="ctx-label">Window</span> ${fmtNum(totalCtx)} tokens</div>
</div>
${ctxPct >= 70 ? `<button class="ctx-compact-btn" title="Summarize older messages to free up context">Compact context</button>` : ''}
`;
const compactBtn = popup.querySelector('.ctx-compact-btn');
if (compactBtn) {
compactBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (!sid) return;
popup.remove();
// Add a spinner bubble at the bottom of chat
const chatBox = document.getElementById('chat-history');
if (!chatBox) return;
const compactMsg = document.createElement('div');
compactMsg.className = 'msg msg-ai';
const compactRole = document.createElement('div');
compactRole.className = 'role';
compactRole.textContent = 'Odysseus';
const compactBody = document.createElement('div');
compactBody.className = 'body';
compactBody.innerHTML = 'Compacting context <span class="compact-wave">▁▂▃▅▂▁</span>';
compactMsg.appendChild(compactRole);
compactMsg.appendChild(compactBody);
chatBox.appendChild(compactMsg);
chatBox.scrollTop = chatBox.scrollHeight;
// Animate the wave
const waveFrames = ['▁▂▃▅▂▁', '▂▃▅▃▂▁', '▃▅▃▂▁▂', '▅▃▂▁▂▃', '▃▂▁▂▃▅', '▂▁▂▃▅▃'];
let frame = 0;
const waveEl = compactBody.querySelector('.compact-wave');
const waveInterval = setInterval(() => {
frame = (frame + 1) % waveFrames.length;
if (waveEl) waveEl.textContent = waveFrames[frame];
}, 150);
try {
const res = await fetch(window.location.origin + '/api/session/' + sid + '/compact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
clearInterval(waveInterval);
if (res.ok) {
const data = await res.json();
// Reload session — the compacted history will show
if (window.sessionModule) await window.sessionModule.selectSession(sid);
// Scroll to the compacted message (first msg with compacted metadata)
setTimeout(() => {
const msgs = document.querySelectorAll('#chat-history .msg');
for (const m of msgs) {
if (m.querySelector('.body')?.textContent.includes('Conversation compacted')) {
m.scrollIntoView({ behavior: 'smooth', block: 'center' });
break;
}
}
}, 200);
} else {
let detail = 'Compaction failed. Try again later.';
try {
const err = await res.json();
if (err.detail) detail = err.detail;
} catch {}
compactBody.textContent = detail;
compactBody.style.color = 'var(--red)';
}
} catch (err) {
clearInterval(waveInterval);
console.warn('compact failed:', err);
compactBody.innerHTML = '<span style="color:var(--red);">Compaction failed: ' + err.message + '</span>';
}
});
}
const rect = ctxRing.getBoundingClientRect();
popup.style.visibility = 'hidden';
document.body.appendChild(popup);
const pr = popup.getBoundingClientRect();
// Position above the ring, right-aligned
popup.style.left = Math.max(8, rect.right - pr.width) + 'px';
const spaceAbove = rect.top;
if (spaceAbove >= pr.height + 8) {
popup.style.top = (rect.top - pr.height - 8) + 'px';
} else {
popup.style.top = (rect.bottom + 8) + 'px';
}
popup.style.visibility = '';
bindMenuDismiss(popup, () => popup.remove(), (ev) => !popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target));
});
}
let footer = messageElement.querySelector('.msg-footer');
if (!footer) {
footer = createMsgFooter(messageElement);
if (messageElement.classList?.contains('agent-thread')) {
footer.classList.add('agent-thread-footer');
}
messageElement.appendChild(footer);
}
if (footer) {
const actions = footer.querySelector('.msg-actions');
if (actions) {
footer.insertBefore(metricsDivider, actions);
footer.insertBefore(metricsContainer, metricsDivider);
} else {
footer.appendChild(metricsContainer);
footer.appendChild(metricsDivider);
}
if (ctxRing) {
const ctxDiv = document.createElement('span');
ctxDiv.textContent = ' | ';
ctxDiv.style.color = 'var(--color-muted-alt)';
ctxDiv.style.pointerEvents = 'none';
ctxDiv.className = 'ctx-divider';
footer.appendChild(ctxDiv);
footer.appendChild(ctxRing);
}
} else {
messageElement.appendChild(metricsContainer);
if (ctxRing) messageElement.appendChild(ctxRing);
}
if (uiModule) uiModule.scrollHistory();
}
/** Remove any unanswered multiple-choice cards currently in the chat. */
export function removeAskUserCards(root) {
const scope = root || document.getElementById('chat-history') || document;
scope.querySelectorAll('.ask-user-card:not(.ask-user-answered)').forEach((node) => {
const previous = node.previousElementSibling;
if (previous?.classList?.contains('agent-thread')) {
previous.classList.remove('has-ask-user-bottom');
if (previous.dataset.askUserAttachedBottom === 'true') {
previous.classList.remove('has-bottom');
delete previous.dataset.askUserAttachedBottom;
}
}
node.remove();
});
}
function _markAskUserAnswered(card, text) {
if (!card || card.classList.contains('ask-user-answered')) return;
const answer = String(text || '').trim();
card.classList.add('ask-user-answered');
card.dataset.askUserAnswered = answer;
card.querySelectorAll('button, input').forEach((el) => { el.disabled = true; });
card.querySelector('.ask-user-options')?.remove();
card.querySelector('.ask-user-other')?.remove();
const row = document.createElement('div');
row.className = 'ask-user-answer';
const label = document.createElement('span');
label.className = 'ask-user-answer-label';
label.textContent = 'Answered';
const value = document.createElement('span');
value.className = 'ask-user-answer-value';
value.innerHTML = svgifyEmoji(uiModule.esc(answer || 'Sent'));
row.appendChild(label);
row.appendChild(value);
card.appendChild(row);
}
function _answerAskUserCards(root, text) {
const scope = root || document.getElementById('chat-history') || document;
scope.querySelectorAll('.ask-user-card:not(.ask-user-answered)').forEach((node) => {
_markAskUserAnswered(node, text);
});
}
// While a choice card is visible, let plain 13 activate the corresponding
// rendered option. Reuse the option's click path so the question keeps its
// existing submission semantics. Tool approval cards are excluded: that card
// exists to make consent deliberate after untrusted context influenced the
// run, and its first option is the widest grant, so a stray digit must not
// answer it.
function _handleAskUserShortcut(event) {
if (
event.defaultPrevented
|| event.repeat
|| event.isComposing
|| event.ctrlKey
|| event.altKey
|| event.metaKey
|| event.shiftKey
) return;
if (!/^[1-3]$/.test(event.key)) return;
const target = event.target;
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
const focusedCard = document.activeElement?.closest?.('.ask-user-card') || null;
const mainCard = document.querySelector('#chat-history .ask-user-card');
const compareCards = document.querySelectorAll('.compare-pane .ask-user-card');
const card = focusedCard || mainCard || (compareCards.length === 1 ? compareCards[0] : null);
if (!card) return;
if (card.dataset.askUserKind === 'tool_approval') return;
const option = card.querySelectorAll('.ask-user-option')[Number(event.key) - 1];
if (!option || option.disabled) return;
event.preventDefault();
option.click();
}
document.addEventListener('keydown', _handleAskUserShortcut);
/**
* Render an ask_user payload as a durable choice card.
*
* This lives in the history renderer rather than the streaming loop so the
* same UI can be used both for a live SSE event and for a persisted tool event
* after a session reload.
*/
export function renderAskUserCard(payload, options) {
const aq = payload || {};
if (aq.resolved) return null;
const opts = Array.isArray(aq.options) ? aq.options : [];
const renderOptions = options || {};
const chatBox = renderOptions.root || document.getElementById('chat-history');
const onSubmit = typeof renderOptions.onSubmit === 'function'
? renderOptions.onSubmit
: null;
if (!chatBox || !aq.question || opts.length < 2) return null;
removeAskUserCards(chatBox);
const card = document.createElement('div');
card.className = 'ask-user-card';
card.setAttribute('role', 'group');
card.tabIndex = -1;
const multi = !!aq.multi;
const isToolApproval = aq.kind === 'tool_approval' && !!aq.approval_id;
card.dataset.askUserKind = isToolApproval ? 'tool_approval' : 'question';
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
const question = document.createElement('div');
question.className = 'ask-user-question';
question.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
question.innerHTML = emojiText(aq.question);
card.appendChild(question);
card.setAttribute('aria-labelledby', question.id);
if (isToolApproval && aq.action) {
const action = document.createElement('div');
action.className = 'ask-user-option-desc';
const effects = Array.isArray(aq.action.effects) ? aq.action.effects.join(', ') : '';
action.textContent = [
aq.action.tool || 'tool',
aq.action.content || '',
effects ? `Effects: ${effects}` : '',
aq.action.workspace ? `Workspace: ${aq.action.workspace}` : '',
aq.action.document_id ? `Document: ${aq.action.document_id}` : '',
aq.action.document_version != null ? `Document version: ${aq.action.document_version}` : '',
aq.action.digest ? `Approval fingerprint: ${aq.action.digest}` : '',
].filter(Boolean).join('\n');
action.style.whiteSpace = 'pre-wrap';
card.appendChild(action);
}
const list = document.createElement('div');
list.className = 'ask-user-options';
card.appendChild(list);
const send = (text) => {
if (!text) return;
if (onSubmit) {
const accepted = onSubmit({
kind: 'answer',
text,
label: text,
payload: aq,
card,
});
if (accepted !== false) _markAskUserAnswered(card, text);
return;
}
_markAskUserAnswered(card, text);
const input = uiModule.el('message');
if (input) input.value = text;
const sendButton = document.querySelector('.send-btn');
if (sendButton) sendButton.click();
};
opts.forEach((opt) => {
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
if (!label) return;
const description = (opt && opt.description) ? String(opt.description) : '';
const row = document.createElement(multi ? 'label' : 'button');
row.className = 'ask-user-option';
if (multi) {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = label;
row.appendChild(checkbox);
}
const labelText = document.createElement('span');
labelText.className = 'ask-user-option-label';
labelText.innerHTML = emojiText(label);
row.appendChild(labelText);
if (description) {
const descriptionText = document.createElement('span');
descriptionText.className = 'ask-user-option-desc';
descriptionText.innerHTML = emojiText(description);
row.appendChild(descriptionText);
}
if (!multi) {
row.type = 'button';
row.addEventListener('click', () => {
if (isToolApproval) {
const detail = {
approval_id: aq.approval_id,
decision: String((opt && opt.value) || '').toLowerCase(),
label,
document_id: aq.action && aq.action.document_id
? String(aq.action.document_id)
: '',
};
if (onSubmit) {
const accepted = onSubmit({
kind: 'tool_approval',
...detail,
payload: aq,
card,
});
if (accepted !== false) _markAskUserAnswered(card, label);
} else {
_markAskUserAnswered(card, label);
document.dispatchEvent(new CustomEvent('odysseus:tool-approval', { detail }));
}
} else {
send(label);
}
});
}
list.appendChild(row);
});
const other = document.createElement('div');
other.className = 'ask-user-other';
const otherInput = document.createElement('input');
otherInput.type = 'text';
otherInput.className = 'styled-prompt-input ask-user-other-input';
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
const otherSend = document.createElement('button');
otherSend.type = 'button';
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
otherSend.setAttribute('aria-label', 'Send answer');
otherSend.title = multi ? 'Send selection' : 'Send answer';
otherSend.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
const submit = () => {
const freeText = otherInput.value.trim();
if (multi) {
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map((input) => input.value);
if (freeText) picked.push(freeText);
if (picked.length) send(picked.join(', '));
} else if (freeText) {
send(freeText);
}
};
otherSend.addEventListener('click', submit);
otherInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submit();
}
});
other.appendChild(otherInput);
other.appendChild(otherSend);
if (!isToolApproval) card.appendChild(other);
const previous = chatBox.lastElementChild;
if (previous?.classList?.contains('agent-thread')) {
card.classList.add('ask-user-card-attached');
const hadBottom = previous.classList.contains('has-bottom');
previous.classList.add('has-bottom', 'has-ask-user-bottom');
if (!hadBottom) previous.dataset.askUserAttachedBottom = 'true';
}
chatBox.appendChild(card);
if (renderOptions.scroll !== false) {
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
if (renderOptions.focus !== false) {
try { card.focus(); } catch (_) {}
}
return card;
}
/**
* Add a message to the chat history.
*/
export function addMessage(role, content, modelName, metadata) {
try {
hideWelcomeScreen();
const box = document.getElementById('chat-history');
if (!box) { console.error('Chat history element not found'); return; }
// Loading a later user message means any earlier ask_user card was answered.
// During history replay this is what keeps the ask_user block visible after
// refresh: assistant turn renders the question, next user turn stamps the
// answer onto that card instead of deleting it.
if (role === 'user') _answerAskUserCards(box, Array.isArray(content) ? markdownModule.renderContent(content) : content);
var esc = uiModule.esc;
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
// --- Agent multi-bubble reconstruction from saved metadata ---
if (
role === 'assistant'
&& metadata
&& (
(Array.isArray(metadata.tool_events) && metadata.tool_events.length > 0)
|| (Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1)
)
) {
let roundTexts = metadata.round_texts || [];
const toolEvents = metadata.tool_events || [];
const replacesTurn = metadata.replacement_scope === 'turn'
&& ['structured', 'streamed'].includes(metadata.render_owner);
if (replacesTurn) {
// Persisted content owns the answer. Round metadata still owns the
// reasoning/tool chronology, but its drafts must not become answers.
roundTexts = (Array.isArray(roundTexts) ? roundTexts : []).map(text =>
markdownModule.extractThinkingBlocks(markdownModule.normalizeThinkingMarkup(String(text || '')))
.thinkingBlocks.map(thinking => `<think>${thinking}</think>`).join('\n')
);
const finalIndex = Math.max(roundTexts.length, ...toolEvents.map(event => Number(event.round ?? 1) || 0));
roundTexts.length = finalIndex + 1;
roundTexts[finalIndex] = typeof textRaw === 'string' ? textRaw : '';
} else if (metadata?._fromHistory && typeof textRaw === 'string' && textRaw.trim()) {
roundTexts = Array.isArray(roundTexts) ? [...roundTexts] : [];
let lastTextIdx = -1;
for (let i = roundTexts.length - 1; i >= 0; i--) {
if (String(roundTexts[i] || '').trim()) {
lastTextIdx = i;
break;
}
}
if (lastTextIdx >= 0) {
roundTexts[lastTextIdx] = textRaw;
} else {
roundTexts.push(textRaw);
}
} else if (
roundTexts.length === 1
&& typeof textRaw === 'string'
&& textRaw.trim()
&& textRaw !== roundTexts[0]
) {
roundTexts = [textRaw];
}
const roundModels = metadata.round_models || [];
const roundEndpointIds = metadata.round_endpoint_ids || [];
const roundEndpointLabels = metadata.round_endpoint_labels || [];
let pendingAskUser = null;
let lastWrap = null;
let firstMsgAi = null;
let lastMsgAi = null;
const toolsByRound = {};
let lastPrivateBrowserUrl = '';
for (const ev of toolEvents) {
const r = ev.round ?? 1;
if (!toolsByRound[r]) toolsByRound[r] = [];
toolsByRound[r].push(ev);
}
const toolRounds = Object.keys(toolsByRound).map(Number);
const maxRound = Math.max(toolRounds.length ? Math.max(...toolRounds) : 0, roundTexts.length);
const firstRound = (toolsByRound[0] || []).length ? 0 : 1;
for (let roundNum = firstRound; roundNum <= maxRound; roundNum++) {
const r = roundNum - 1;
let txt = r >= 0
? resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata)
: '';
if (
role === 'assistant'
&& r === 0
&& metadata?.thinking
&& !metadata?.character_name
&& !roundTexts.some(t => /<\s*(?:think|thinking|thought)\b/i.test(String(t || '')))
) {
const thinkTime = metadata.thinking_time || null;
txt = '<think' + (thinkTime ? ` time="${thinkTime}"` : '') + '>'
+ String(metadata.thinking || '')
+ '</think>'
+ (txt ? '\n\n' + txt : '');
}
if (txt) {
const wrap = document.createElement('div');
wrap.className = 'msg msg-ai' + (r > 0 ? ' msg-continuation' : '');
const roleEl = document.createElement('div');
roleEl.className = 'role';
const pair = replyModelPair(modelName, metadata);
const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;
const contEndpointId = r < roundEndpointIds.length
? roundEndpointIds[r]
: pair.actualEndpointId;
const contEndpointLabel = r < roundEndpointLabels.length
? roundEndpointLabels[r]
: pair.actualEndpointLabel;
roleEl.textContent = modelRouteLabel(
pair.requestedModel,
contModel,
pair.requestedEndpointLabel,
contEndpointLabel,
pair.requestedEndpointId,
contEndpointId,
);
if (
pair.requestedModel
&& contModel
&& (
!sameModelName(pair.requestedModel, contModel)
|| (pair.requestedEndpointId && contEndpointId && pair.requestedEndpointId !== contEndpointId)
)
) {
roleEl.title = pair.requestedModel + ' -> ' + contModel
+ ' (' + pair.requestedEndpointLabel + ' -> ' + contEndpointLabel + ')';
}
applyModelColor(roleEl, contModel);
if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp));
wrap.appendChild(roleEl);
const body = document.createElement('div');
body.className = 'body';
// Check if this is the last text round — sources go on top of final response
var agentSourcesPrefix = '';
var isLastTextRound = true;
for (let rr = r + 1; rr < maxRound; rr++) {
if ((roundTexts[rr] || '').trim()) { isLastTextRound = false; break; }
}
var agentFindingsSuffix = '';
if (isLastTextRound && metadata?.web_sources?.length) {
agentSourcesPrefix = buildSourcesBox(metadata.web_sources, 'web');
} else if (isLastTextRound && metadata?.research_sources?.length) {
agentSourcesPrefix = buildSourcesBox(metadata.research_sources, 'research');
}
if (isLastTextRound && metadata?.research_findings?.length) {
agentFindingsSuffix = buildFindingsBox(metadata.research_findings);
}
// RAG document sources — restored on the final text round.
if (isLastTextRound && metadata?.rag_sources?.length) {
agentFindingsSuffix += buildRagSourcesBox(metadata.rag_sources);
}
body.innerHTML = agentSourcesPrefix + markdownModule.processWithThinking(markdownModule.squashOutsideCode(txt)) + agentFindingsSuffix;
wrap.appendChild(body);
wrap.dataset.raw = txt;
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
box.appendChild(wrap);
lastWrap = wrap;
if (!firstMsgAi) firstMsgAi = wrap;
lastMsgAi = wrap;
}
const roundTools = toolsByRound[roundNum] || [];
if (roundTools.length > 0) {
// Reuse previous thread if no text separated us (merge consecutive tool rounds)
let threadWrap = null;
if (!txt && lastWrap && lastWrap.classList.contains('agent-thread')) {
threadWrap = lastWrap;
} else {
threadWrap = document.createElement('div');
threadWrap.className = 'agent-thread';
// Extend line up if there's a chat bubble above
if (txt) threadWrap.classList.add('has-top');
box.appendChild(threadWrap);
}
for (const ev of roundTools) {
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
const ok = (ev.exit_code === 0 || ev.exit_code == null);
let outHtml = '';
if (!_suppressRawToolOutput(ev.tool, ok) && ev.output && ev.output.trim()) {
outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(ev.output)}</pre></details>`;
}
const screenshotSrc = safeToolScreenshotSrc(ev.screenshot);
if (_isPrivateBrowserTool(ev.tool)) {
const currentBrowserUrl = _privateBrowserUrlFromCommand(ev.command) || lastPrivateBrowserUrl;
if (currentBrowserUrl) lastPrivateBrowserUrl = currentBrowserUrl;
outHtml += _browserPreviewHtml(ev.command, screenshotSrc, currentBrowserUrl);
} else if (screenshotSrc) {
outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${uiModule.esc(screenshotSrc)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
}
// File-write/edit diff (persisted in the tool event) \u2014 re-render it
// so it survives reload, matching the live stream.
let evDiffHtml = '';
if (ev.diff && ev.diff.text) {
const d = ev.diff;
const stat = [
d.new_file ? '<span class="diff-stat-new">new</span>' : '',
d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '',
d.removed ? `<span class="diff-stat-del">\u2212${d.removed}</span>` : '',
].filter(Boolean).join(' ');
const rows = d.text.split('\n').map(line => {
let cls = 'diff-ctx', text = line;
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
else if (line.startsWith('@@')) cls = 'diff-hunk';
// Drop the leading diff marker (+/-/space) — colour encodes add/del.
else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); }
else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); }
else if (line.startsWith(' ')) { text = line.slice(1); }
return `<span class="${cls}">${esc(text) || '&nbsp;'}</span>`;
}).join(''); // spans are display:block \u2014 a literal \n would double-space
evDiffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`;
}
const node = document.createElement('div');
const browserTool = _isPrivateBrowserTool(ev.tool);
node.className = 'agent-thread-node' + (ok ? '' : ' error') + (browserTool ? ' open browser-preview-node' : '');
// Hide raw JSON for high-level app tools and diffs; show the same
// compact command summary that live streaming uses.
const evToolInfo = _toolDisplayInfo(ev.tool, ev.command, ev);
const evHeaderAction = evToolInfo.headerActionHtml || '';
const evToolIcon = renderToolIcon(ev.tool, ev.command, ev);
const evCmdHtml = browserTool ? '' : (evToolInfo.commandHtml || ((ev.command && !(ev.diff && ev.diff.text)) ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : ''));
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span>${evToolIcon}<span class="agent-thread-tool">${esc(evToolInfo.label || ev.tool)}</span>${evHeaderAction}<span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron" aria-hidden="true"></span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}${evDiffHtml}</div>`;
// Click handling is delegated globally \u2014 see chat.js init.
threadWrap.appendChild(node);
}
// Check if next round has text — extend line down to connect
const nextTxt = (roundTexts[r + 1] || '').trim();
if (nextTxt) threadWrap.classList.add('has-bottom');
lastWrap = threadWrap;
for (const ev of roundTools) {
if (ev.image_url) {
box.appendChild(buildImageBubble(ev.image_url, ev.image_prompt, ev.image_model, ev.image_size, ev.image_quality, ev.image_id));
}
}
}
}
const firstWrap = lastMsgAi || lastWrap;
if (firstWrap && firstWrap.classList.contains('msg-ai')) {
if (metadata?.memories_used?.length) firstWrap._memoriesUsed = metadata.memories_used;
firstWrap.appendChild(createMsgFooter(firstWrap));
if (metadata) displayMetrics(firstWrap, metadata);
}
if (window.hljs) {
box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
}
if (markdownModule.renderMermaid) markdownModule.renderMermaid(box);
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: <id>" 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: <sid>"
// (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 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>';
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
// <multi-line desc>) into a collapsible "image description" section. Done for
// ALL user messages (not just ones with attachment metadata) so it rebuilds
// from the stored text even after a browser restart drops the cached attachments.
const attachments = metadata?.attachments;
const _visionBlocks = [];
if (role === 'user') {
text = text.replace(
/\n*\[Image: ([^\]]+)\]\n([\s\S]*?)(?=\n*\[Image: |\n*\[Image attached: |\n*=== File: |\n*\[PDF content\]:|$)/g,
(_m, name, desc) => { const d = desc.trim(); if (d) _visionBlocks.push({ name: name, desc: d }); return ''; }
);
}
// With attachments present, also strip the embedded file/PDF/image-marker text.
if (role === 'user' && attachments?.length) {
// Strip === File: ... === blocks, [PDF content]: blocks, and [Image attached: ...] lines
text = text
.replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+```[\s\S]*?```/g, '')
.replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+[\s\S]*?(?=\n*=== File:|$)/g, '')
.replace(/\n*\[PDF content\]:[\s\S]*?(?=\n*\[PDF content\]|\n*=== File:|$)/g, '')
.replace(/\n*\[Image attached: [^\]]+\]/g, '')
.replace(/\n*\[Attached (?:document|non-text) file\]/g, '')
.trim();
}
wrap.dataset.raw = text;
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
// Prepend sources box if saved in metadata
var sourcesPrefix = '';
var findingsSuffix = '';
if (role === 'assistant' && metadata?.research_sources?.length) {
sourcesPrefix = buildSourcesBox(metadata.research_sources, 'research');
} else if (role === 'assistant' && metadata?.web_sources?.length) {
sourcesPrefix = buildSourcesBox(metadata.web_sources, 'web');
}
if (role === 'assistant' && metadata?.research_findings?.length) {
findingsSuffix = buildFindingsBox(metadata.research_findings);
}
// RAG document sources — restored from metadata so they survive refresh.
if (role === 'assistant' && metadata?.rag_sources?.length) {
findingsSuffix += buildRagSourcesBox(metadata.rag_sources);
}
// If thinking is stored in metadata (not in text), reconstruct the full display.
// 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(
'<think' + (thinkTime ? ` time="${thinkTime}"` : '') + '>' + metadata.thinking + '</think>\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*/,
'<span class="doc-edit-tag">Doc edit: $1</span> '
);
// Match raw format: "In the document, edit this specific text (line X):\n```\n...\n```\n\nInstruction: ..."
// After markdown processing this becomes a <p> + <pre><code> block + <p>Instruction: text</p>
const rawDocMatch = b.innerHTML.match(/In the document, edit this specific text \((lines? [\d\-]+)\)/);
if (rawDocMatch) {
const lineRef = rawDocMatch[1];
// Extract instruction text (after "Instruction: ")
const instrMatch = b.textContent.match(/Instruction:\s*([\s\S]*)$/);
const instrText = instrMatch ? instrMatch[1].trim() : '';
b.innerHTML = '<span class="doc-edit-tag">Doc edit: ' + lineRef + '</span> ' + markdownModule.processWithThinking(instrText);
}
// Render attachment cards
if (attachments?.length) {
b.appendChild(buildAttachCards(attachments));
}
}
wrap.appendChild(r);
wrap.appendChild(b);
// Add stopped indicator + continue button for messages that were stopped by user
if (role === 'assistant' && metadata?.stopped) {
const stoppedIndicator = document.createElement('div');
stoppedIndicator.className = 'stopped-indicator';
const stoppedLabel = document.createElement('span');
// Differentiate between "stopped mid-stream" (had content, can continue)
// and "cancelled before any content" — the latter has no Continue affordance.
stoppedLabel.textContent = metadata.cancelled
? '[Cancelled by user]'
: '[Message interrupted]';
stoppedIndicator.appendChild(stoppedLabel);
// Continue button only makes sense when there's partial content to
// resume from \u2014 skip it for fully-cancelled (empty) turns.
if (!metadata.cancelled) {
const continueBtn = document.createElement('button');
continueBtn.className = 'continue-btn resume-btn';
continueBtn.title = 'Resume response';
continueBtn.innerHTML = '<span class="resume-btn-label">Resume</span><svg class="resume-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m8 5 10 7-10 7z"></path></svg>';
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;