// 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 = ''; const REPORT_ICON = ''; const CHAT_ABOUT_ICON = ''; const COPY_ICON = ''; const CHECK_ICON = ''; const TRASH_ICON = ''; const PAPERCLIP_ICON = ''; const CALENDAR_ICON = ''; const EMAIL_ICON = ''; const TOOL_ICONS = { manage_calendar: CALENDAR_ICON, manage_memory: '', save_memory: '', search_memory: '', manage_skills: '', manage_notes: '', manage_tasks: '', trigger_research: '', manage_research: '', manage_documents: REPORT_ICON, create_document: REPORT_ICON, update_document: REPORT_ICON, edit_document: REPORT_ICON, read_file: '', write_file: '', edit_file: '', private_browser: '', manage_session: CHAT_ABOUT_ICON, manage_contact: '', manage_settings: '', bash: '', host_shell: '', python: '', generate_image: '', image_gen: '', ask_user: '', update_plan: '', list_models: '', youtube_tool: '', 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 = ''; const USER_MODE_AGENT_ICON = ''; const USER_MODE_CHAT_ICON = ''; /** Sanitize a URL for use in href — only allow http(s) and protocol-relative. */ function _safeHref(url) { if (!url) return '#'; try { var parsed = new URL(url, window.location.origin); if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return uiModule.esc(url); } catch(e) { /* invalid URL */ } return '#'; } 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 ``; } 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 `${iconHtml || ''}`; } 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 `${icon}`; const href = target.external ? _safeHref(target.href) : uiModule.esc(target.href); const externalAttrs = target.external ? ' target="_blank" rel="noopener noreferrer"' : ''; return `${icon}`; } 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 ? `Private browser screenshot` : '
Browsing…
Private browser screenshot'; return `
Private Browser${uiModule.esc(_privateBrowserActionLabel(command))}
${imgHtml}
`; } 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 ? `${uiModule.esc(q)}` : '', }; } 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 ? `${uiModule.esc(url)}` : '', }; } 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 ? `
${uiModule.esc(summary)}
` : rangeSummary ? `
${uiModule.esc(rangeSummary)}
` : '', }; } 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 ? `UID ${uiModule.esc(uid)} · ${uiModule.esc(folder)}` : '', }; } 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: `
${uiModule.esc(bits)}
`, }; } if (emailTool && (lower.includes('search_emails') || lower.endsWith('search_emails'))) { const q = args && args.query ? String(args.query) : ''; return { label: 'Search email', commandHtml: q ? `
${uiModule.esc(q)}
` : '', }; } return { label: '', commandHtml: '' }; } function _suppressRawToolOutput(tool, ok) { if (!ok) return false; const lower = String(tool || '').toLowerCase(); if (lower === 'manage_calendar' || lower.endsWith('manage_calendar')) return true; return ( lower.includes('email') && ( lower.includes('list_emails') || lower.includes('search_emails') || lower.includes('read_email') || lower.includes('download_attachment') || lower.includes('scan_spam') || lower.includes('scan_email_unsubscribes') ) ); } function _makeActionBtn(className, title, text, handler) { const btn = document.createElement('button'); btn.className = className; btn.type = 'button'; btn.title = title; btn.textContent = text; btn.addEventListener('click', handler); return btn; } // Attachment card helpers function _attachIcon(mimeOrName) { const s = (mimeOrName || '').toLowerCase(); if (s.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(s)) return ''; if (s.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|webm)$/i.test(s)) return ''; if (s === 'application/pdf' || /\.pdf$/i.test(s)) return ''; // Default: generic document return ''; } function _formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / 1048576).toFixed(1) + ' MB'; } // Build the `.attach-cards` element for a message's attachment list. Shared by // addMessage and updateMessageAttachments so a live (optimistic) user bubble // can be re-rendered with real upload ids once the upload resolves. export function buildAttachCards(attachments) { const attachWrap = document.createElement('div'); attachWrap.className = 'attach-cards'; for (const att of attachments) { const isImage = (att.mime || '').startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(att.name || ''); if (isImage) { // Image preview. Shown for both uploaded (att.id present) and still- // uploading attachments. A shimmering skeleton + whirlpool fills the // space until either the upload resolves (no id yet) or the thumbnail // image finishes loading, so the photo doesn't pop in abruptly. const imgWrap = document.createElement('div'); imgWrap.className = 'attach-image-preview'; imgWrap.style.cursor = att.id ? 'zoom-in' : 'default'; if (att.id) imgWrap.dataset.fileId = att.id; if (att.id) { imgWrap.addEventListener('click', (e) => { // Tapping the corner OCR button shouldn't also open the lightbox. if (e.target.closest('.attach-ocr-btn')) return; _openImageLightbox(att); }); } let skel = null; let sp = null; if (!att.previewUrl) { // Skeleton placeholder with a centered whirlpool. Self-stops when removed. skel = document.createElement('div'); skel.className = 'attach-image-skeleton'; // Match the photo's aspect ratio when the backend knew it at upload // time, so the skeleton doesn't sit at a 4:3 default and then snap to // a portrait shape when the image arrives. if (att.width && att.height) { skel.style.aspectRatio = att.width + ' / ' + att.height; skel.style.width = 'auto'; skel.style.height = 'auto'; skel.style.maxWidth = '300px'; skel.style.maxHeight = '200px'; skel.style.minWidth = '80px'; } sp = spinnerModule.createWhirlpool(20); skel.appendChild(sp.element); imgWrap.appendChild(skel); } if (att.id || att.previewUrl) { const img = document.createElement('img'); // Small cached thumbnail — the preview is tiny, no need to pull the // full-resolution photo. Click still opens the full image. img.alt = att.name || 'Image'; img.loading = 'lazy'; img.style.cssText = 'max-width:300px;max-height:200px;border-radius:6px;display:' + (att.previewUrl ? 'block' : 'none') + ';'; let _revealed = false; let _revealTimer = null; const _reveal = () => { if (_revealed) return; _revealed = true; if (_revealTimer) { clearTimeout(_revealTimer); _revealTimer = null; } img.style.display = 'block'; try { sp && sp.stop(); } catch {} if (skel) skel.remove(); }; img.addEventListener('load', _reveal); img.addEventListener('error', _reveal); img.src = att.previewUrl || `/api/upload/${att.id}?thumb=1`; // Cached images can be complete before the load listener attaches. if (img.complete && img.naturalWidth) _reveal(); // Failsafe: if neither load nor error fires within 8s, reveal anyway. // The timer is cleared on reveal AND when updateMessageAttachments // replaces the card (which scrubs the img / skel from the DOM), so // repeated re-renders don't accumulate stranded timers. if (!att.previewUrl) _revealTimer = setTimeout(_reveal, 8000); imgWrap.appendChild(img); if (att.id) { // Small corner button → opens the vision/OCR editor so the user can // correct what the vision model extracted. The edit is cached on the // server keyed by file id, so any later message referencing this same // image picks up the corrected text instead of re-running the model. const ocrBtn = document.createElement('button'); ocrBtn.type = 'button'; ocrBtn.className = 'attach-ocr-btn'; ocrBtn.title = 'View / edit OCR text'; ocrBtn.innerHTML = 'Caption'; ocrBtn.addEventListener('click', (e) => { e.stopPropagation(); _openVisionEditor(att, ocrBtn.closest('.msg')); }); imgWrap.appendChild(ocrBtn); } } if (att.vision_model) { const visionLabel = document.createElement('div'); visionLabel.className = 'attach-vision-model'; visionLabel.textContent = 'Vision: ' + String(att.vision_model).split('/').pop(); imgWrap.appendChild(visionLabel); } if (att.name) { const label = document.createElement('div'); label.className = 'attach-image-name'; label.textContent = att.name; imgWrap.appendChild(label); } attachWrap.appendChild(imgWrap); } else { // Non-image file card const card = document.createElement('div'); card.className = 'attach-card'; card.dataset.name = att.name; if (att.id) { card.dataset.fileId = att.id; card.style.cursor = 'pointer'; card.addEventListener('click', () => { // PDFs & text/code/markdown → open in the Documents viewer // (others fall back to the raw file). if (window.chatModule?.openAttachment) window.chatModule.openAttachment(att, false); else window.open(`/api/upload/${att.id}`, '_blank'); }); } const icon = _attachIcon(att.mime || att.name); const nameSpan = document.createElement('span'); nameSpan.className = 'attach-card-name'; nameSpan.textContent = att.name; card.innerHTML = icon; card.appendChild(nameSpan); if (att.size) { const sizeSpan = document.createElement('span'); sizeSpan.className = 'attach-card-size'; sizeSpan.textContent = _formatSize(att.size); card.appendChild(sizeSpan); } attachWrap.appendChild(card); } } return attachWrap; } // Re-render the attachment cards of an already-rendered message. Used to swap // in real upload ids (and image thumbnails) on the optimistic user bubble once // uploadPending() resolves — otherwise image previews only appear after a // refresh, because the bubble is rendered before the upload assigns ids. export function updateMessageAttachments(msgWrap, attachments) { if (!msgWrap || !attachments?.length) return; const body = msgWrap.querySelector('.body') || msgWrap; const existing = body.querySelector('.attach-cards'); const fresh = buildAttachCards(attachments); if (existing) existing.replaceWith(fresh); else body.appendChild(fresh); } // Quick full-size preview when the user taps a chat photo thumbnail. Just an // overlay with the original image centered — no Gallery panel, no editor. function _openImageLightbox(att) { if (!att?.id) return; const overlay = document.createElement('div'); overlay.className = 'attach-lightbox'; // Show the cached thumb immediately so the overlay doesn't sit blank // while a 25MB original streams in. The full image swaps in once loaded; // if the full load fails (404 / network), we keep the thumb + show an // error label rather than a blank overlay forever. const img = document.createElement('img'); img.alt = att.name || ''; img.src = `/api/upload/${att.id}?thumb=1`; overlay.appendChild(img); const full = new Image(); full.addEventListener('load', () => { img.src = full.src; }); full.addEventListener('error', () => { const err = document.createElement('div'); err.className = 'attach-lightbox-err'; err.textContent = 'Failed to load full-resolution image.'; overlay.appendChild(err); }); full.src = `/api/upload/${att.id}`; const _onKey = (e) => { if (e.key === 'Escape') _close(); }; const _close = () => { document.removeEventListener('keydown', _onKey); if (_overlayObs) { try { _overlayObs.disconnect(); } catch {} } overlay.remove(); }; // If the overlay is removed via any path other than our close handler // (session switch, parent re-render, external cleanup), still drop the // document-level keydown listener so it doesn't leak. let _overlayObs = null; try { _overlayObs = new MutationObserver(() => { if (!document.body.contains(overlay)) { document.removeEventListener('keydown', _onKey); _overlayObs.disconnect(); } }); _overlayObs.observe(document.body, { childList: true, subtree: false }); } catch {} overlay.addEventListener('click', _close); document.addEventListener('keydown', _onKey); document.body.appendChild(overlay); } // Vision/OCR editor modal — opened from the corner "Aa" button on a chat photo // thumbnail. Lets the user view and correct the text the vision model fed to // the LLM (e.g. when OCR misreads a word). Persists to the server's vision // cache (PUT /api/upload/{id}/vision), so any subsequent message that // references the same file picks up the corrected text. let _visionEditorEl = null; let _visionEditorEsc = null; function _closeVisionEditor() { if (_visionEditorEsc) { document.removeEventListener('keydown', _visionEditorEsc); _visionEditorEsc = null; } if (_visionEditorEl) { _visionEditorEl.remove(); _visionEditorEl = null; } } function _openVisionEditor(att, userMsgEl) { if (!att?.id) return; _closeVisionEditor(); const overlay = document.createElement('div'); overlay.className = 'vision-editor-overlay'; overlay.addEventListener('click', (e) => { if (e.target === overlay) _closeVisionEditor(); }); const panel = document.createElement('div'); panel.className = 'vision-editor-panel'; const title = document.createElement('div'); title.className = 'vision-editor-title'; // Eye icon matches the one in Settings → Vision so users recognise where // this text originates. title.innerHTML = 'Vision text'; panel.appendChild(title); const desc = document.createElement('div'); desc.className = 'vision-editor-desc'; desc.textContent = 'Edit text and save, new chats will have the new context. Regenerate or continue from there.'; panel.appendChild(desc); const ta = document.createElement('textarea'); ta.className = 'vision-editor-text'; ta.rows = 10; ta.placeholder = 'Loading…'; ta.disabled = true; panel.appendChild(ta); const actions = document.createElement('div'); actions.className = 'vision-editor-actions'; const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'vision-editor-btn'; closeBtn.innerHTML = 'Close'; closeBtn.addEventListener('click', _closeVisionEditor); const _saveVisionText = async () => { const res = await fetch(`/api/upload/${att.id}/vision`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ text: ta.value }), }); if (!res.ok) throw new Error('save failed'); }; const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'vision-editor-btn vision-editor-btn-primary'; saveBtn.innerHTML = 'Save'; saveBtn.disabled = true; saveBtn.addEventListener('click', async () => { saveBtn.disabled = true; saveBtn.innerHTML = 'Saving…'; try { await _saveVisionText(); if (uiModule?.showToast) uiModule.showToast('Saved'); _closeVisionEditor(); } catch (e) { saveBtn.disabled = false; saveBtn.innerHTML = 'Save'; if (uiModule?.showError) uiModule.showError('Failed to save OCR text'); } }); // Regenerate-message: save the edited text, close, then trigger a resend of // the user message so the new AI reply uses the edit immediately. const regenBtn = document.createElement('button'); regenBtn.type = 'button'; regenBtn.className = 'vision-editor-btn vision-editor-btn-primary'; regenBtn.title = 'Save and regenerate the message'; regenBtn.innerHTML = 'Regenerate message'; regenBtn.disabled = true; regenBtn.addEventListener('click', async () => { regenBtn.disabled = true; saveBtn.disabled = true; try { await _saveVisionText(); _closeVisionEditor(); if (userMsgEl && window.chatModule?.resendUserMessage) { window.chatModule.resendUserMessage(userMsgEl, { replaceFromHere: true }); } else if (uiModule?.showToast) { uiModule.showToast('Saved'); } } catch (e) { regenBtn.disabled = false; saveBtn.disabled = false; if (uiModule?.showError) uiModule.showError('Failed to save OCR text'); } }); actions.appendChild(closeBtn); actions.appendChild(saveBtn); actions.appendChild(regenBtn); panel.appendChild(actions); overlay.appendChild(panel); document.body.appendChild(overlay); _visionEditorEl = overlay; // ESC closes the popup. Registered on document so it works regardless of // focus (the textarea swallows the event otherwise). _visionEditorEsc = (e) => { if (e.key === 'Escape') _closeVisionEditor(); }; document.addEventListener('keydown', _visionEditorEsc); fetch(`/api/upload/${att.id}/vision`, { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : Promise.reject(r)) .then(data => { ta.value = data.text || ''; ta.placeholder = ''; ta.disabled = false; saveBtn.disabled = false; regenBtn.disabled = !userMsgEl; ta.focus(); }) .catch(() => { ta.value = ''; ta.placeholder = 'Could not load OCR text — type your correction and save.'; ta.disabled = false; saveBtn.disabled = false; regenBtn.disabled = !userMsgEl; }); } // Tool call syntax patterns to strip from displayed text const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi; // Strip fenced tool-call blocks that look like structured invocations, not // regular code examples. The tool tags are NOT hard-coded here — they are the // backend's authoritative TOOL_TAGS set, fetched once from GET /api/tools and // built into EXEC_FENCE_RE at load. TOOL_TAGS (src/agent_tools/__init__.py) is // thus the single source: the live-strip list can never drift from the backend // or miss a future tool (#3993). bash/python are carved out on purpose — they // are languages a user may legitimately have asked the model to show, not tool // invocations. // // Until the fetch resolves, EXEC_FENCE_RE stays null and exec fences aren't // stripped — normally a sub-second window before the first stream. If the fetch // fails it stays null for the rest of the session (logged below), so live exec // fences won't be stripped until reload. Either way the backend already strips // persisted history (src/tool_parsing.py builds the same regex from TOOL_TAGS), // so a reload always renders clean. let EXEC_FENCE_RE = null; const EXEC_FENCE_NON_TOOL = new Set(['bash', 'python']); function escapeRegex(source) { return String(source).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function stripExecutedFence(match, tag, inline, body) { const inlineArgs = (inline || '').trim(); if (!inlineArgs) return ''; const bodyText = (body || '').trim(); const content = bodyText ? `${inlineArgs}\n${bodyText}` : inlineArgs; try { JSON.parse(content); } catch { return match; } return ''; } async function loadExecFenceRegex() { try { // Shared with admin.js, and — more to the point — with the other copies of // this module: chatRenderer.js is imported under three different ?v= query // strings, so it is instantiated three times per load and used to issue // three identical /api/tools requests. appConfig.js is imported by one // specifier from all of them, so they now share a single fetch. const data = await getTools(); const tags = (data.tools || []) .map((t) => t.id) .filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id)); if (tags.length) { EXEC_FENCE_RE = new RegExp( '```(' + tags.map(escapeRegex).join('|') + ')(?![\\w-])' + '[ \\t]*([\\[{][^\\n]*?)?[ \\t]*(?=\\r?\\n|```)' + '\\r?\\n?([\\s\\S]*?)```', 'gi' ); } } catch (err) { // Surface the failure rather than swallowing it: EXEC_FENCE_RE stays null, // so this session won't strip live exec fences until reload (persisted path // stays clean regardless). console.warn('chatRenderer: /api/tools fetch failed; live exec-fence stripping disabled until reload', err); } } loadExecFenceRegex(); // XML-style tool calls: , , , bare const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi; const XML_INVOKE_RE = /[\s\S]*?<\/invoke>/gi; // DeepSeek "DSML" tool-call markup (fullwidth-pipe | or ascii | delimited) that // leaks into content when the model emits a text tool call instead of a native // one. Strip the whole block; the second pattern catches stray/partial tags // (e.g. mid-stream before the closing tag arrives). const DSML_TOOL_RE = /<\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>|$)/gi; const DSML_STRAY_RE = /<\s*\/?\s*[||]+\s*DSML\s*[||]+[^>]*>/gi; const DSML_INVOKE_RE = /<\s*[||]+\s*DSML\s*[||]+\s*invoke\b[^>]*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*invoke\s*>|$)/gi; const RAW_OPENAI_TOOL_JSON_RE = /(?:\[\s*)?\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"[^"]*"\s*,\s*"type"\s*:\s*"function"\s*\}\s*\]?/gi; const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi; // Keep in sync with _QWEN_BARE_MARKER_RE in src/tool_parsing.py. At least one // pipe is required around `end`: with both optional (`\|?end\|?`) this also ate // a bare `end` on its own line, breaking Ruby/Lua/shell snippets (#5547). const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|(?:^|[\r\n])[ \t]*assistan(?:t)?[ \t]*(?=[\r\n]|$)/gi; // Self-narration about tool results (model echoing stdout/exit_code) const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi; // Model pricing table — per million tokens // Model info: pricing (per 1M tokens) + context window length const MODEL_INFO = { // --- Anthropic --- 'claude-sonnet-4-5': { input: 3.00, output: 15.00, ctx: 200000 }, 'claude-sonnet-4-6': { input: 3.00, output: 15.00, ctx: 200000 }, 'claude-sonnet-4': { input: 3.00, output: 15.00, ctx: 200000 }, 'claude-opus-4': { input: 15.00, output: 75.00, ctx: 200000 }, 'claude-opus-4-6': { input: 15.00, output: 75.00, ctx: 200000 }, 'claude-haiku-4': { input: 0.80, output: 4.00, ctx: 200000 }, 'claude-haiku-3-5': { input: 0.80, output: 4.00, ctx: 200000 }, 'claude-3-5-sonnet': { input: 3.00, output: 15.00, ctx: 200000 }, 'claude-3-5-haiku': { input: 0.80, output: 4.00, ctx: 200000 }, 'claude-3-opus': { input: 15.00, output: 75.00, ctx: 200000 }, 'claude-3-sonnet': { input: 3.00, output: 15.00, ctx: 200000 }, 'claude-3-haiku': { input: 0.25, output: 1.25, ctx: 200000 }, // --- OpenAI --- 'gpt-5': { input: 2.00, output: 8.00, ctx: 400000 }, 'gpt-4.1': { input: 2.00, output: 8.00, ctx: 1047576 }, 'gpt-4.1-mini': { input: 0.40, output: 1.60, ctx: 1047576 }, 'gpt-4.1-nano': { input: 0.10, output: 0.40, ctx: 1047576 }, 'gpt-4o': { input: 2.50, output: 10.00, ctx: 128000 }, 'gpt-4o-mini': { input: 0.15, output: 0.60, ctx: 128000 }, 'gpt-4-turbo': { input: 10.00, output: 30.00, ctx: 128000 }, 'o1': { input: 15.00, output: 60.00, ctx: 200000 }, 'o1-mini': { input: 3.00, output: 12.00, ctx: 128000 }, 'o1-pro': { input: 150.0, output: 600.0, ctx: 200000 }, 'o3': { input: 2.00, output: 8.00, ctx: 200000 }, 'o3-mini': { input: 1.10, output: 4.40, ctx: 200000 }, 'o4-mini': { input: 1.10, output: 4.40, ctx: 200000 }, // --- DeepSeek --- 'deepseek-chat': { input: 0.27, output: 1.10, ctx: 64000 }, 'deepseek-coder': { input: 0.27, output: 1.10, ctx: 64000 }, 'deepseek-reasoner': { input: 0.55, output: 2.19, ctx: 64000 }, 'deepseek-r1': { input: 0.55, output: 2.19, ctx: 64000 }, 'deepseek-v3': { input: 0.27, output: 1.10, ctx: 64000 }, 'deepseek-v2': { input: 0.14, output: 0.28, ctx: 64000 }, // --- Google --- 'gemini-2.5-pro': { input: 1.25, output: 10.00, ctx: 1048576 }, 'gemini-2.5-flash': { input: 0.15, output: 0.60, ctx: 1048576 }, 'gemini-2.0-flash': { input: 0.10, output: 0.40, ctx: 1048576 }, 'gemini-1.5-pro': { input: 1.25, output: 5.00, ctx: 1048576 }, 'gemini-1.5-flash': { input: 0.075, output: 0.30, ctx: 1048576 }, 'gemma-3': { input: 0.10, output: 0.10, ctx: 128000 }, // --- Mistral --- 'mistral-large': { input: 2.00, output: 6.00, ctx: 128000 }, 'mistral-medium': { input: 2.00, output: 6.00, ctx: 32000 }, 'mistral-small': { input: 0.20, output: 0.60, ctx: 32000 }, 'mistral-nemo': { input: 0.15, output: 0.15, ctx: 128000 }, 'mixtral': { input: 0.24, output: 0.24, ctx: 32000 }, 'codestral': { input: 0.30, output: 0.90, ctx: 32000 }, 'pixtral': { input: 2.00, output: 6.00, ctx: 128000 }, // --- xAI --- 'grok-4': { input: 3.00, output: 15.00, ctx: 131072 }, 'grok-3': { input: 3.00, output: 15.00, ctx: 131072 }, 'grok-2': { input: 2.00, output: 10.00, ctx: 131072 }, // --- Meta --- 'llama-4': { input: 0.20, output: 0.20, ctx: 1048576 }, 'llama-3.3': { input: 0.20, output: 0.20, ctx: 131072 }, 'llama-3.2': { input: 0.20, output: 0.20, ctx: 131072 }, 'llama-3.1': { input: 0.20, output: 0.20, ctx: 131072 }, 'llama-3': { input: 0.20, output: 0.20, ctx: 131072 }, // --- Qwen --- 'qwen3': { input: 0.30, output: 1.20, ctx: 131072 }, 'qwen2.5': { input: 0.30, output: 1.20, ctx: 131072 }, 'qwq': { input: 0.30, output: 1.20, ctx: 32768 }, // --- Cohere --- 'command-a': { input: 2.50, output: 10.00, ctx: 256000 }, 'command-r-plus': { input: 2.50, output: 10.00, ctx: 128000 }, 'command-r': { input: 0.15, output: 0.60, ctx: 128000 }, // --- Perplexity --- 'sonar-pro': { input: 3.00, output: 15.00, ctx: 200000 }, 'sonar': { input: 1.00, output: 1.00, ctx: 128000 }, // --- MiniMax --- 'minimax': { input: 0.70, output: 0.70, ctx: 1000000 }, // --- Kimi / Moonshot --- 'moonshot': { input: 1.00, output: 1.00, ctx: 128000 }, 'kimi': { input: 1.00, output: 1.00, ctx: 128000 }, // --- Microsoft --- 'phi-4': { input: 0.07, output: 0.14, ctx: 16000 }, 'phi-3': { input: 0.07, output: 0.14, ctx: 128000 }, // --- Nvidia --- 'nemotron': { input: 0.30, output: 1.20, ctx: 131072 }, // --- Nous --- 'hermes': { input: 0.20, output: 0.20, ctx: 131072 }, }; // Compat alias const MODEL_PRICING = MODEL_INFO; // Image generation cost lookup (per-image, by model × quality × size) const IMAGE_PRICING = { 'gpt-image-1.5': { 'low': { '1024x1024': 0.009, '1024x1536': 0.013, '1536x1024': 0.013 }, 'medium': { '1024x1024': 0.034, '1024x1536': 0.05, '1536x1024': 0.05 }, 'high': { '1024x1024': 0.133, '1024x1536': 0.2, '1536x1024': 0.2 } }, 'gpt-image-1': { 'low': { '1024x1024': 0.011, '1024x1536': 0.016, '1536x1024': 0.016 }, 'medium': { '1024x1024': 0.042, '1024x1536': 0.063, '1536x1024': 0.063 }, 'high': { '1024x1024': 0.167, '1024x1536': 0.25, '1536x1024': 0.25 } }, 'gpt-image-1-mini': { 'low': { '1024x1024': 0.005, '1024x1536': 0.006, '1536x1024': 0.006 }, 'medium': { '1024x1024': 0.011, '1024x1536': 0.015, '1536x1024': 0.015 }, 'high': { '1024x1024': 0.036, '1024x1536': 0.052, '1536x1024': 0.052 } }, }; export function shortModel(name) { if (!name) return '...'; if (typeof name !== 'string') name = String(name); let short = name.split('/').pop(); // Strip .gguf extension short = short.replace(/\.gguf$/i, ''); // Strip quantization suffixes (Q4_K_M, Q8_0, etc.) and shard numbers short = short.replace(/-0000\d-of-\d+$/, ''); short = short.replace(/[-_](Q\d[_A-Z\d]*|F16|F32|BF16|fp16|fp32)$/i, ''); // Truncate if still too long (keep first meaningful part) if (short.length > 25) { // Try to find a natural break point (dash after model size like -35B or -7B) const sizeMatch = short.match(/^(.+?-\d+[BbMm])/); if (sizeMatch) short = sizeMatch[1]; else short = short.substring(0, 22) + '…'; } return short; } function modelValue(name) { if (name == null) return ''; return String(name).trim(); } export function sameModelName(left, right) { const a = modelValue(left); const b = modelValue(right); if (!a || !b) return false; return a.toLowerCase() === b.toLowerCase() || shortModel(a).toLowerCase() === shortModel(b).toLowerCase(); } function shortEndpointLabel(label) { const value = modelValue(label); if (!value) return ''; return value.length > 18 ? value.slice(0, 17) + '…' : value; } export function modelRouteLabel( requestedModel, actualModel, requestedEndpointLabel = '', actualEndpointLabel = '', requestedEndpointId = '', actualEndpointId = '', ) { const requested = modelValue(requestedModel); const actual = modelValue(actualModel) || requested; const requestedRoute = modelValue(requestedEndpointId || requestedEndpointLabel); const actualRoute = modelValue(actualEndpointId || actualEndpointLabel); const routeChanged = Boolean( actualRoute && requestedRoute && actualRoute !== requestedRoute ); if (!requested || sameModelName(requested, actual)) { const model = shortModel(actual || requested); if (!routeChanged) return model; const from = shortEndpointLabel(requestedEndpointLabel || 'Selected route'); const to = shortEndpointLabel(actualEndpointLabel || actualEndpointId); return model + ' (' + from + ' -> ' + to + ')'; } return shortModel(requested) + ' -> ' + shortModel(actual); } export function replyModelPair(modelName, metadata) { const meta = metadata || {}; const actualFromMeta = modelValue(meta.model || meta.actual_model); const requestedFromMeta = modelValue(meta.requested_model || meta.selected_model); if (actualFromMeta || requestedFromMeta) { const actual = actualFromMeta || requestedFromMeta || modelValue(modelName); const requested = requestedFromMeta || actual; return { requestedModel: requested, actualModel: actual, requestedEndpointId: meta.requested_endpoint_id || null, requestedEndpointLabel: meta.requested_endpoint_label || 'Selected route', actualEndpointId: meta.endpoint_id || null, actualEndpointLabel: meta.endpoint_label || meta.requested_endpoint_label || 'Selected route', }; } const fallback = modelValue(modelName); return { requestedModel: fallback, actualModel: fallback, requestedEndpointId: null, requestedEndpointLabel: 'Selected route', actualEndpointId: null, actualEndpointLabel: 'Selected route', }; } /** * Generate a consistent HSL color for a model name. * Returns an hsl() string. The hue is derived from a string hash, * saturation and lightness are fixed for readability on dark/light themes. */ export function modelColor(name) { if (!name) return null; const key = name.toLowerCase(); let hash = 0; for (let i = 0; i < key.length; i++) { hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0; } const hue = ((hash % 360) + 360) % 360; return `hsl(${hue}, 55%, 65%)`; } /** Look up model info (pricing + context) by substring match */ export function getModelInfo(modelName) { if (!modelName) return null; const key = matchModelKey(modelName, Object.keys(MODEL_INFO)); return key ? { key, ...MODEL_INFO[key] } : null; } function _fmtCtx(n) { if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; return Math.round(n / 1000) + 'K'; } /** * Apply model color to a role element (sets color + dot color). */ export function applyModelColor(roleEl, modelName) { if (!modelName) return; const color = modelColor(modelName); if (color) { roleEl.style.color = color; roleEl.style.setProperty('--model-dot', color); } // Replace generic dot with provider logo if available const logo = providerLogo(modelName); const existingLogo = roleEl.querySelector('.role-provider-logo'); if (!logo) { if (existingLogo) existingLogo.remove(); roleEl.classList.remove('has-logo'); } else if (!existingLogo) { const span = document.createElement('span'); span.className = 'role-provider-logo'; span.innerHTML = logo; roleEl.classList.add('has-logo'); roleEl.prepend(span); } // Click to show model info popup if (!roleEl._hasInfoClick) { roleEl._hasInfoClick = true; roleEl.style.cursor = 'pointer'; roleEl.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); }); const info = getModelInfo(modelName); const short = shortModel(modelName); const logoHtml = providerLogo(modelName); const popup = document.createElement('div'); popup.className = 'ctx-popup'; let html = '
'; if (logoHtml) html += ''; html += uiModule.esc(short) + '
'; html += '
Model ' + uiModule.esc(modelName.split('/').pop()) + '
'; // Provider = the serving endpoint, distinct from the model vendor/logo // (e.g. the same model via OpenRouter vs Copilot vs Anthropic direct). const _epUrl = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl) ? window.sessionModule.getCurrentEndpointUrl() : null; const _provLabel = providerLabel(_epUrl); if (_provLabel) html += '
Provider ' + uiModule.esc(_provLabel) + '
'; // Show static context initially, then fetch real from server const _realCtx = window._realContextLengths && window._realContextLengths[modelName]; if (_realCtx) { html += '
Context ' + _fmtCtx(_realCtx) + ' tokens'; if (info && info.ctx && info.ctx !== _realCtx) html += ' (spec: ' + _fmtCtx(info.ctx) + ')'; html += '
'; } else if (info && info.ctx) { html += '
Context ' + _fmtCtx(info.ctx) + ' tokens
'; } // Fetch real context from server async if (!_realCtx && window.sessionModule) { const _sid = window.sessionModule.getCurrentSessionId(); if (_sid) { fetch('/api/session/' + _sid + '/context_info').then(r => r.ok ? r.json() : null).then(d => { if (d && d.context_length) { if (!window._realContextLengths) window._realContextLengths = {}; window._realContextLengths[modelName] = d.context_length; const el = document.getElementById('_ctx-val'); if (el) { el.innerHTML = _fmtCtx(d.context_length) + ' tokens'; if (info && info.ctx && info.ctx !== d.context_length) { el.innerHTML += ' (spec: ' + _fmtCtx(info.ctx) + ')'; } } } }).catch(() => {}); } } // Show configured max tokens if set if (window.presetsModule) { const _pid = window.presetsModule.getSelectedPreset(); const _preset = _pid ? window.presetsModule.getPreset(_pid) : null; const _mt = _preset?.max_tokens; if (_mt && _mt > 0 && _mt <= 8192) { html += '
Max tokens ' + _mt.toLocaleString() + ' (configured)
'; } } if (isCostTrackedEndpoint(_epUrl)) { if (info && info.input != null) html += '
Input $' + info.input.toFixed(2) + ' / 1M
'; if (info && info.output != null) html += '
Output $' + info.output.toFixed(2) + ' / 1M
'; if (!info) html += '
No pricing data available
'; } popup.innerHTML = html; const rect = roleEl.getBoundingClientRect(); popup.style.top = (rect.bottom + 4) + 'px'; popup.style.left = rect.left + 'px'; document.body.appendChild(popup); const pr = popup.getBoundingClientRect(); if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px'; if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px'; bindMenuDismiss(popup, () => popup.remove()); }); } } export function getModelCost(modelName, inputTokens, outputTokens) { if (!modelName) return null; const key = matchModelKey(modelName, Object.keys(MODEL_PRICING)); if (!key) return null; const price = MODEL_PRICING[key]; return (inputTokens * price.input + outputTokens * price.output) / 1_000_000; } /** * Is this endpoint a local / self-hosted model server (vLLM, Ollama, …)? * Local models are free, so we must NOT bill them at cloud rates — the * pricing table matches on a name substring, so a local `qwen2.5-coder` * would otherwise be charged like cloud `qwen2.5`. When the serving host is * loopback, a private LAN range, Tailscale CGNAT (100.64–100.127.x), a * `.local` name, or the app's own host, the model is local → free. * Unknown / missing endpoint also counts as local (bias to not over-bill). */ export function isLocalEndpoint(url) { if (!url) return true; let host; try { host = new URL(url).hostname; } catch (_e) { return true; } if (!host) return true; if (host === 'localhost' || host === '0.0.0.0' || host === 'host.docker.internal' || host.endsWith('.local')) return true; if (typeof window !== 'undefined' && window.location && host === window.location.hostname) return true; // A single-label hostname (no dot) is an internal/Docker service name // (e.g. "nim-nano", "llamaswap", "nemotron-super-49b") or a LAN shortname — // never a public API, which always needs an FQDN. Treat as local → free. // (Without this, container-name endpoints get billed at cloud rates because // the pricing table matches on a name substring, e.g. "nemotron".) if (!host.includes('.')) return true; if (/^127\./.test(host)) return true; if (/^10\./.test(host)) return true; if (/^192\.168\./.test(host)) return true; if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true; const cg = host.match(/^100\.(\d+)\./); // Tailscale CGNAT if (cg && +cg[1] >= 64 && +cg[1] <= 127) return true; return false; } export function isSubscriptionEndpoint(url) { if (!url) return false; try { const parsed = new URL(url); const path = parsed.pathname.replace(/\/+$/, ''); return parsed.hostname === 'chatgpt.com' && (path === '/backend-api/codex' || path.startsWith('/backend-api/codex/')); } catch (_e) { return false; } } function _currentEndpointUrl() { return (window.sessionModule && window.sessionModule.getCurrentEndpointUrl) ? window.sessionModule.getCurrentEndpointUrl() : null; } export function isCostTrackedEndpoint(url) { return !isLocalEndpoint(url) && !isSubscriptionEndpoint(url); } /** Cost for the current turn, returning null for non-billable endpoints. */ function _billableCost(model, inputTokens, outputTokens, endpointCostTracked, selectedEndpointUrl) { // Foreground fallback can answer on a different endpoint than the session's // selected route. Prefer the backend's non-secret actual-route // classification; retain the selected-endpoint check for older history. if (endpointCostTracked === false) return null; const selectedUrl = selectedEndpointUrl === undefined ? _currentEndpointUrl() : selectedEndpointUrl; if (endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)) { return null; } return getModelCost(model, inputTokens, outputTokens); } /** Sum cost using the route/model that produced each Agent round. */ function _metricsBillableCost(metrics, model, inputTokens, outputTokens, selectedEndpointUrl) { const buckets = Array.isArray(metrics.usage_buckets) ? metrics.usage_buckets : []; if (!buckets.length) { return _billableCost( model, inputTokens, outputTokens, metrics.endpoint_cost_tracked, selectedEndpointUrl, ); } let total = 0; let hasPricedUsage = false; for (const bucket of buckets) { if (!bucket || typeof bucket !== 'object') continue; const bucketCost = _billableCost( bucket.model || model, Number(bucket.input_tokens) || 0, Number(bucket.output_tokens) || 0, bucket.endpoint_cost_tracked, selectedEndpointUrl, ); if (bucketCost === null) continue; total += bucketCost; hasPricedUsage = true; } return hasPricedUsage ? total : null; } export function getImageCost(model, quality, size) { if (!model) return null; const m = model.toLowerCase(); for (const [key, quals] of Object.entries(IMAGE_PRICING)) { if (m.includes(key)) { const q = quals[(quality || 'medium').toLowerCase()] || quals['medium']; return q ? (q[size] || q['1024x1024'] || null) : null; } } return null; } /* ── Session cost helpers ─────────────────────────────────────────── */ const _COST_KEY = 'ody-session-cost'; const _COST_RUNS_KEY = 'ody-session-cost-runs'; const _MAX_COST_RUNS_PER_SESSION = 256; const _COST_LEDGER_LOCK = 'odysseus-session-cost-ledger'; /** Return the accumulated cost for the current (or given) session. */ export function getSessionCost(sessionId) { const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId()); if (!sid) return 0; try { const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}'); const recordedRuns = runCosts[sid] && typeof runCosts[sid] === 'object' ? Object.values(runCosts[sid]) : []; return (costs[sid] || 0) + recordedRuns.reduce( (total, value) => total + (Number(value) || 0), 0, ); } catch (_e) { return 0; } } /** Reset session cost for the given session (defaults to current). */ export function resetSessionCost(sessionId) { const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId()); if (!sid) return; try { const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); delete costs[sid]; localStorage.setItem(_COST_KEY, JSON.stringify(costs)); const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}'); delete runCosts[sid]; localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts)); } catch (_e) { /* ignore */ } updateSessionCostUI(); } /** Update the persistent session-cost badge in the input bar. */ export function updateSessionCostUI() { const el = document.getElementById('session-cost-display'); if (!el) return; // The ledger records billable work already performed in this session. A // selected local endpoint does not erase cost from a paid fallback route. const cost = getSessionCost(); if (cost > 0) { el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2)); el.style.display = ''; } else { el.style.display = 'none'; } } /** Record one metrics payload in a session ledger at most once. */ export function recordSessionMetricsCost(metrics, sessionId, selectedEndpointUrl) { if (!metrics || typeof metrics !== 'object') return null; const cost = _metricsBillableCost( metrics, metrics.model || 'Unknown', metrics.input_tokens || 0, metrics.output_tokens || 0, selectedEndpointUrl, ); if (metrics._fromHistory) return cost; const sid = sessionId || ( window.sessionModule && window.sessionModule.getCurrentSessionId() ); if (!sid || cost === null) return cost; const runId = typeof metrics._costRecordId === 'string' ? metrics._costRecordId.trim() : ''; if ((metrics._costRecorded || metrics._costRecordPending) && !runId) return cost; // Recorded is only set once the write actually runs; pending covers the // window while the write waits on the cross-tab lock, so a replay in that // window cannot double-add and a tab closed mid-queue never claims recorded. metrics._costRecordPending = true; const writeCost = () => { if (runId) { try { const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}'); const sessionRuns = runCosts[sid] && typeof runCosts[sid] === 'object' ? runCosts[sid] : {}; // Assigning by detached-run identity is replay-idempotent even when a // refresh produces a fresh metrics object. The Web Lock around this // read/modify/write also keeps distinct runs from two tabs from // overwriting one another's stale snapshot. sessionRuns[runId] = cost; const entries = Object.entries(sessionRuns); if (entries.length > _MAX_COST_RUNS_PER_SESSION) { const overflow = entries.slice(0, entries.length - _MAX_COST_RUNS_PER_SESSION); const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); costs[sid] = (costs[sid] || 0) + overflow.reduce( (total, entry) => total + (Number(entry[1]) || 0), 0, ); overflow.forEach(([oldRunId]) => delete sessionRuns[oldRunId]); localStorage.setItem(_COST_KEY, JSON.stringify(costs)); } runCosts[sid] = sessionRuns; localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts)); } catch (_e) { /* ignore */ } } else { try { const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); costs[sid] = (costs[sid] || 0) + cost; localStorage.setItem(_COST_KEY, JSON.stringify(costs)); } catch (_e) { /* ignore */ } } metrics._costRecorded = true; metrics._costRecordPending = false; const currentSid = window.sessionModule && window.sessionModule.getCurrentSessionId(); if (currentSid === sid) updateSessionCostUI(); }; let writeStarted = false; const guardedWrite = () => { writeStarted = true; writeCost(); }; try { if ( typeof navigator !== 'undefined' && navigator.locks && typeof navigator.locks.request === 'function' ) { const pendingWrite = navigator.locks.request(_COST_LEDGER_LOCK, guardedWrite); if (pendingWrite && typeof pendingWrite.catch === 'function') { pendingWrite.catch(() => { if (!writeStarted) guardedWrite(); }); } } else { guardedWrite(); } } catch (_e) { if (!writeStarted) guardedWrite(); } return cost; } /** Create a timestamp span for role labels. * Pass an ISO string / Date / epoch-ms to render the message's own time * (used when replaying history). Falls back to "now" when no value is given. */ export function roleTimestamp(when) { const ts = document.createElement('span'); ts.className = 'role-timestamp'; let d; if (when instanceof Date) d = when; else if (typeof when === 'number') d = new Date(when); else if (typeof when === 'string' && when) d = new Date(when); else d = new Date(); if (isNaN(d.getTime())) d = new Date(); ts.textContent = d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); ts.title = d.toLocaleString(); return ts; } function userModePill(metadata) { const mode = String(metadata?.interaction_mode || metadata?.mode || '').toLowerCase(); if (mode !== 'agent' && mode !== 'chat' && mode !== 'research') return null; const pill = document.createElement('span'); pill.className = 'user-mode-pill user-mode-pill-' + mode; if (mode === 'agent' || mode === 'chat') { pill.innerHTML = mode === 'agent' ? USER_MODE_AGENT_ICON : USER_MODE_CHAT_ICON; pill.setAttribute('aria-label', mode === 'agent' ? 'Agent mode' : 'Chat mode'); } else { pill.textContent = 'research'; } pill.title = metadata?.auto_escalated ? 'Auto-promoted from chat for this tool request' : `Sent in ${mode} mode`; if (metadata?.auto_escalated) pill.dataset.auto = 'true'; return pill; } export function setUserModePill(messageEl, mode, autoEscalated) { if (!messageEl) return; const role = messageEl.querySelector?.('.role'); if (!role) return; role.querySelectorAll('.user-mode-pill').forEach(el => el.remove()); const pill = userModePill({ interaction_mode: mode, auto_escalated: !!autoEscalated, }); if (pill) role.appendChild(pill); } /** * Strip tool invocation blocks from text before rendering. */ export function stripToolBlocks(text) { let cleaned = text.replace(TOOL_CALL_RE, ''); if (EXEC_FENCE_RE) cleaned = cleaned.replace(EXEC_FENCE_RE, stripExecutedFence); cleaned = cleaned.replace(DSML_TOOL_RE, ''); cleaned = cleaned.replace(DSML_INVOKE_RE, ''); cleaned = cleaned.replace(DSML_STRAY_RE, ''); cleaned = cleaned.replace(XML_TOOL_CALL_RE, ''); cleaned = cleaned.replace(XML_INVOKE_RE, ''); cleaned = cleaned.replace(RAW_OPENAI_TOOL_JSON_RE, ''); cleaned = cleaned.replace(QWEN_ROLE_MARKER_RE, ''); cleaned = cleaned.replace(QWEN_BARE_MARKER_RE, ' '); cleaned = cleaned.replace(TOOL_NARRATION_RE, ''); cleaned = cleaned.replace(/\n{3,}/g, '\n\n'); return cleaned.trim(); } /** * Plain-text payload for the message copy buttons: the reply as the renderer * displays it — tool blocks and reasoning stripped. dataset.raw keeps * the full model output (chat.js even embeds the elapsed time into the * tag for reload persistence), so copying it verbatim leaks the * thinking block (#3722). Falls back to the raw text when stripping leaves * nothing (e.g. turns interrupted mid-thinking). */ export function copyMessageText(msgElement) { const raw = msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || ''; const { content } = markdownModule.extractThinkingBlocks(stripToolBlocks(raw)); return content || raw; } /** * Build a collapsible sources box (used by both research and web search). */ export function buildSourcesBox(sources, type, expanded) { var esc = uiModule.esc; var id = 'sources-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5); var count = sources.length; var label = type === 'research' ? 'Research sources' : 'Web sources'; var lines = ''; for (var i = 0; i < count; i++) { var s = sources[i]; var domain = ''; try { domain = new URL(s.url).hostname.replace('www.', ''); } catch(e) { domain = s.url; } var title = esc(s.title || domain || ''); var safeUrl = _safeHref(s.url); lines += '' + '' + (i + 1) + '' + '' + title + '' + '' + esc(domain) + '' + ''; } var arrow = expanded ? 'down' : 'right'; var expandedClass = expanded ? ' expanded' : ''; return '
' + '
' + '
' + SEARCH_ICON + '' + count + ' ' + label + '
' + '' + '
' + '
' + '
' + lines + '
' + '
'; } /** * Build the RAG "Sources (N documents)" box — mirrors the live render in * chat.js so persisted rag_sources survive a refresh. Items carry a * filename, similarity %, and snippet (not URLs, unlike web sources). * @param {Array<{filename, similarity, snippet}>} sources */ export function buildRagSourcesBox(sources) { if (!sources || !sources.length) return ''; var esc = uiModule.esc; var items = ''; for (var i = 0; i < sources.length; i++) { var s = sources[i] || {}; var pct = (typeof s.similarity === 'number') ? (s.similarity * 100).toFixed(1) + '%' : ''; items += '
' + esc(s.filename || '') + '' + (pct ? ' ' + pct + '' : '') + '
' + esc(s.snippet || '') + '
'; } return '
Sources (' + sources.length + ' documents)' + items + '
'; } /** * 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 += ''; } var FINDINGS_ICON = ''; var arrow = expanded ? 'down' : 'right'; var expandedClass = expanded ? ' expanded' : ''; return '
' + '
' + '
' + FINDINGS_ICON + '' + count + ' Raw collected findings
' + '' + '
' + '
' + '
' + lines + '
' + '
'; } /** 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 = '
' + '' + 'Dig deeper? Activate Research again and type a follow-up question to continue this research.' + '
'; 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¬e=([^&]+)/); 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¬e=)/.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 = ''; 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 = 'Open in gallery'; 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 = ''; 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 = `${a.icon} ${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 = `${parts.join(', ')}`; 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 = `${a.icon} ${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 ? `
Cost ${costStr}
` : ''; 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('
') : ''; // Session total cost let sessionCostStr = ''; const sc = getSessionCost(); if (costStr && sc > 0) { sessionCostStr = `
Session $${sc < 0.01 ? sc.toFixed(4) : sc.toFixed(3)}
`; } const popup = document.createElement('div'); popup.className = 'ctx-popup'; popup.innerHTML = `
Message Stats
Model ${model.split('/').pop()}
Input (all rounds) ${inputTokens.toLocaleString()} tokens${isReal ? '' : '~'}
${injectedTokens != null ? `
Injected (first request) ${Number(injectedTokens).toLocaleString()} tokens
` : ''}
Output ${outputTokens.toLocaleString()} tokens${isReal ? '' : '~'}
Total ${totalTok.toLocaleString()} tokens
${speedLabel} ${speedStr}
Time ${Number(responseTime).toFixed(3)}s
${prepTime != null ? `
Prep ${prepTime}s
` : ''} ${modelWaitTime != null ? `
Model wait ${modelWaitTime}s
` : ''} ${visibleTtft != null ? `
TTFT ${Number(visibleTtft).toFixed(3)}s
` : ''} ${schemaCount != null ? `
Tool schemas ${Number(schemaCount).toLocaleString()}
` : ''} ${agentRounds != null ? `
Agent rounds ${Number(agentRounds).toLocaleString()}
` : ''} ${toolCalls != null ? `
Tool calls ${Number(toolCalls).toLocaleString()}
` : ''} ${costRows} ${sessionCostStr} ${prepDetails ? `
Agent prep
${prepDetails}
` : ''} ${ctxPct !== undefined && ctxPct > 0 ? `
Context ${ctxPct}% used
` : ''} ${isReal ? '' : '
~ estimated token count
'} `; 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 = ` ${Math.round(ctxPct)}%`; 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 = `
Context Window
${fmtNum(usedTokens)} used ${fmtNum(totalCtx)} total
Model ${modelShort}
Usage ${ctxPct}%
Window ${fmtNum(totalCtx)} tokens
${ctxPct >= 70 ? `` : ''} `; 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 ▁▂▃▅▂▁'; 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 = 'Compaction failed: ' + err.message + ''; } }); } 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 1–3 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 = ''; 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 => `${thinking}`).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 = '' + String(metadata.thinking || '') + '
' + (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 = `
Output
${esc(ev.output)}
`; } 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 += `
Screenshot
`; } // 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 ? 'new' : '', d.added ? `+${d.added}` : '', d.removed ? `\u2212${d.removed}` : '', ].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 `${esc(text) || ' '}`; }).join(''); // spans are display:block \u2014 a literal \n would double-space evDiffHtml = `
${esc(d.file || 'diff')} ${stat}
${rows}
`; } 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)) ? `
${esc(ev.command)}
` : '')); node.innerHTML = `
${ok ? '\u2713' : '\u2717'}${evToolIcon}${esc(evToolInfo.label || ev.tool)}${evHeaderAction}${ok ? 'done' : 'failed'}
${evCmdHtml}${outHtml}${evDiffHtml}
`; // Click handling is delegated globally \u2014 see chat.js init. threadWrap.appendChild(node); } // Check if next round has text — extend line down to connect const nextTxt = (roundTexts[r + 1] || '').trim(); if (nextTxt) threadWrap.classList.add('has-bottom'); lastWrap = threadWrap; for (const ev of roundTools) { if (ev.image_url) { box.appendChild(buildImageBubble(ev.image_url, ev.image_prompt, ev.image_model, ev.image_size, ev.image_quality, ev.image_id)); } } } } const firstWrap = lastMsgAi || lastWrap; if (firstWrap && firstWrap.classList.contains('msg-ai')) { if (metadata?.memories_used?.length) firstWrap._memoriesUsed = metadata.memories_used; firstWrap.appendChild(createMsgFooter(firstWrap)); if (metadata) displayMetrics(firstWrap, metadata); } if (window.hljs) { box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b)); } if (markdownModule.renderMermaid) markdownModule.renderMermaid(box); if (pendingAskUser) { // Session history is rendered oldest-to-newest. A later user message // removes this card; if there is none, the pending choice survives a // refresh. Avoid stealing focus while the history is loading. renderAskUserCard(pendingAskUser, { focus: false, scroll: false }); } return lastWrap; } // --- Wake-task / supervisor system check-in --- // The self-wake mechanism injects "Did you finish?" as a user message // (or persisted history shows a "[Task] Self-check: " envelope) // so the agent loop re-enters and re-checks status. Render as a // normal user-style bubble — same chrome as a real user message, // just with role "Supervisor" and a short summary body — instead of // a slim system chip. Matches chat style and integrates cleanly // into the conversation flow. let _isWakeCheck = !!(metadata?.wake_check_in || metadata?.hidden_from_user_view); if (!_isWakeCheck && typeof textRaw === 'string') { // Also catch historical messages persisted as "[Task] Self-check: " // (older wake tasks that didn't set wake_check_in metadata). if (/^\s*\[Task\]\s+Self-check:/i.test(textRaw)) { _isWakeCheck = true; } } if (_isWakeCheck) { // Supervisor self-check messages are an internal control signal — // skip rendering entirely so they don't show up in the conversation. return null; } // Discuss sessions carry the full research report in a seeded system // message. Render it through the same thinking block used by model // reasoning, rather than introducing a second collapsible UI. if (role === 'system' && metadata?.research_spinoff_from) { const wrap = document.createElement('div'); wrap.className = 'msg msg-ai'; const roleEl = document.createElement('div'); roleEl.className = 'role'; roleEl.textContent = 'Research Context'; const body = document.createElement('div'); body.className = 'content'; body.innerHTML = markdownModule.createCollapsible(String(textRaw || ''), 'Research Context', true); const hint = document.createElement('div'); hint.className = 'research-context-continue-hint'; hint.innerHTML = 'Continue conversation below '; body.appendChild(hint); wrap.appendChild(roleEl); wrap.appendChild(body); box.appendChild(wrap); return wrap; } // --- Standard single-bubble message --- const wrap = document.createElement('div'); wrap.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai'); const r = document.createElement('div'); r.className = 'role'; const isSlash = metadata?.source === 'slash'; const isCompacted = metadata?.compacted; const replyModels = replyModelPair(modelName, metadata); const resolvedModel = replyModels.actualModel || replyModels.requestedModel; var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel( replyModels.requestedModel, resolvedModel, replyModels.requestedEndpointLabel, replyModels.actualEndpointLabel, replyModels.requestedEndpointId, replyModels.actualEndpointId, ); if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) { _roleText += ' (Research)'; } if (metadata?.group_model && role !== 'user') { _roleText = metadata.group_model; } else if (metadata?.character_name && getShowPersonaName() && role !== 'user' && !isSlash && !isCompacted) { _roleText = metadata.character_name; } r.textContent = _roleText; if (role === 'user') { const pill = userModePill(metadata); if (pill) r.appendChild(pill); } if (role !== 'user') { const endpointChanged = Boolean( replyModels.requestedEndpointId && replyModels.actualEndpointId && replyModels.requestedEndpointId !== replyModels.actualEndpointId ); if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && (!sameModelName(replyModels.requestedModel, resolvedModel) || endpointChanged)) { r.title = replyModels.requestedModel + ' -> ' + resolvedModel + ' (' + replyModels.requestedEndpointLabel + ' -> ' + replyModels.actualEndpointLabel + ')'; } if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel); r.appendChild(roleTimestamp(metadata?.timestamp)); } const b = document.createElement('div'); b.className = 'body'; let text = markdownModule.squashOutsideCode(stripToolBlocks(textRaw || '')); if (role === 'assistant') { text = resolveDocumentPlaceholderLinks(text, metadata); } // For user messages, pull out vision-model image descriptions ([Image: name]\n // ) into a collapsible "image description" section. Done for // ALL user messages (not just ones with attachment metadata) so it rebuilds // from the stored text even after a browser restart drops the cached attachments. const attachments = metadata?.attachments; const _visionBlocks = []; if (role === 'user') { text = text.replace( /\n*\[Image: ([^\]]+)\]\n([\s\S]*?)(?=\n*\[Image: |\n*\[Image attached: |\n*=== File: |\n*\[PDF content\]:|$)/g, (_m, name, desc) => { const d = desc.trim(); if (d) _visionBlocks.push({ name: name, desc: d }); return ''; } ); } // With attachments present, also strip the embedded file/PDF/image-marker text. if (role === 'user' && attachments?.length) { // Strip === File: ... === blocks, [PDF content]: blocks, and [Image attached: ...] lines text = text .replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+```[\s\S]*?```/g, '') .replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+[\s\S]*?(?=\n*=== File:|$)/g, '') .replace(/\n*\[PDF content\]:[\s\S]*?(?=\n*\[PDF content\]|\n*=== File:|$)/g, '') .replace(/\n*\[Image attached: [^\]]+\]/g, '') .replace(/\n*\[Attached (?:document|non-text) file\]/g, '') .trim(); } wrap.dataset.raw = text; if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id; // Prepend sources box if saved in metadata var sourcesPrefix = ''; var findingsSuffix = ''; if (role === 'assistant' && metadata?.research_sources?.length) { sourcesPrefix = buildSourcesBox(metadata.research_sources, 'research'); } else if (role === 'assistant' && metadata?.web_sources?.length) { sourcesPrefix = buildSourcesBox(metadata.web_sources, 'web'); } if (role === 'assistant' && metadata?.research_findings?.length) { findingsSuffix = buildFindingsBox(metadata.research_findings); } // RAG document sources — restored from metadata so they survive refresh. if (role === 'assistant' && metadata?.rag_sources?.length) { findingsSuffix += buildRagSourcesBox(metadata.rag_sources); } // If thinking is stored in metadata (not in text), reconstruct the full display. // Persona/character chats suppress the thinking UI so the persona answer stays // in-character; the metadata remains saved for debugging/export. if (role === 'assistant' && metadata?.thinking && !metadata?.character_name) { const thinkTime = metadata.thinking_time || null; const thinkHtml = markdownModule.processWithThinking( '' + metadata.thinking + '
\n\n' + text ); b.innerHTML = sourcesPrefix + thinkHtml + findingsSuffix; } else { b.innerHTML = sourcesPrefix + _renderAssistantBody(text, role === 'assistant' ? metadata : null) + findingsSuffix; } b.dataset.raw = text; // The vision/OCR caption is stripped from the displayed text above (so the // bubble doesn't show the raw model output) but no longer rendered as an // inline collapsible — the user can still view/edit it via the "Caption" // button on the photo thumbnail. _visionBlocks is intentionally left unused // so the parsing-and-strip side-effect on `text` still happens. void _visionBlocks; // Add "Open Visual Report" button for persisted research messages if (role === 'assistant' && metadata?.research) { var _sid = window.sessionModule?.getCurrentSessionId?.(); if (_sid) _appendReportButton(b, _sid); } // Style [Doc edit: ...] prefix in user messages if (role === 'user') { // Match compact format: [Doc edit: line X] instruction b.innerHTML = b.innerHTML.replace( /\[Doc edit: (lines? [\d–\-]+)\]\s*/, 'Doc edit: $1 ' ); // Match raw format: "In the document, edit this specific text (line X):\n```\n...\n```\n\nInstruction: ..." // After markdown processing this becomes a

+

 block + 

Instruction: text

const rawDocMatch = b.innerHTML.match(/In the document, edit this specific text \((lines? [\d–\-]+)\)/); if (rawDocMatch) { const lineRef = rawDocMatch[1]; // Extract instruction text (after "Instruction: ") const instrMatch = b.textContent.match(/Instruction:\s*([\s\S]*)$/); const instrText = instrMatch ? instrMatch[1].trim() : ''; b.innerHTML = 'Doc edit: ' + lineRef + ' ' + markdownModule.processWithThinking(instrText); } // Render attachment cards if (attachments?.length) { b.appendChild(buildAttachCards(attachments)); } } wrap.appendChild(r); wrap.appendChild(b); // Add stopped indicator + continue button for messages that were stopped by user if (role === 'assistant' && metadata?.stopped) { const stoppedIndicator = document.createElement('div'); stoppedIndicator.className = 'stopped-indicator'; const stoppedLabel = document.createElement('span'); // Differentiate between "stopped mid-stream" (had content, can continue) // and "cancelled before any content" — the latter has no Continue affordance. stoppedLabel.textContent = metadata.cancelled ? '[Cancelled by user]' : '[Message interrupted]'; stoppedIndicator.appendChild(stoppedLabel); // Continue button only makes sense when there's partial content to // resume from \u2014 skip it for fully-cancelled (empty) turns. if (!metadata.cancelled) { const continueBtn = document.createElement('button'); continueBtn.className = 'continue-btn resume-btn'; continueBtn.title = 'Resume response'; continueBtn.innerHTML = 'Resume'; continueBtn.addEventListener('click', () => { stoppedIndicator.remove(); if (window.chatModule) { window.chatModule.setHideUserBubble(); window.chatModule.setPendingContinue(wrap); const msgInput = document.getElementById('message'); if (msgInput) { msgInput.value = 'Continue from where you left off.'; const sb = document.querySelector('.send-btn'); if (sb) sb.click(); } } }); stoppedIndicator.appendChild(continueBtn); } b.appendChild(stoppedIndicator); } if (metadata?.edited) { const editedIndicator = document.createElement('div'); editedIndicator.className = 'edited-indicator'; editedIndicator.textContent = '[Message edited]'; b.appendChild(editedIndicator); } // Restore variant navigation from saved metadata if (role === 'assistant' && metadata?.variants && metadata.variants.length > 1) { wrap.dataset.variants = JSON.stringify(metadata.variants); const idx = metadata.variantIndex ?? metadata.variants.length - 1; wrap.dataset.variantIndex = String(idx); // Re-render from `raw` markdown rather than trusting cached `v.html`. // Variants ride through localStorage / chat export-import; cached HTML // would let an attacker-controlled session JSON inject markup. const _renderVariant = (v) => (v && v.raw) ? markdownModule.processWithThinking(markdownModule.squashOutsideCode(v.raw)) : (v && v.html) || ''; // Show the selected variant's content const v = metadata.variants[idx]; if (v) { b.innerHTML = _renderVariant(v); wrap.dataset.raw = v.raw; } // Render nav const nav = document.createElement('span'); nav.className = 'variant-nav'; nav.addEventListener('click', (e) => e.stopPropagation()); const divider = document.createElement('span'); divider.className = 'variant-divider'; divider.textContent = '|'; nav.appendChild(divider); const tagLabel = document.createElement('span'); const _icons = { regen: '\u21BB', shorter: '\u2702', simpler: '?', original: '\u25CB' }; const _tl0 = metadata.variants[idx]?.label; tagLabel.className = 'variant-tag' + (_tl0 === 'shorter' ? ' variant-tag-scissors' : ''); tagLabel.textContent = _icons[_tl0] || ''; nav.appendChild(tagLabel); const prevBtn = document.createElement('button'); prevBtn.className = 'variant-btn'; prevBtn.textContent = '<'; prevBtn.disabled = idx === 0; nav.appendChild(prevBtn); const numLeft = document.createElement('button'); numLeft.className = 'variant-num'; numLeft.textContent = String(idx + 1); numLeft.disabled = idx === 0; nav.appendChild(numLeft); const slash = document.createElement('span'); slash.className = 'variant-slash'; slash.textContent = '/'; nav.appendChild(slash); const numRight = document.createElement('button'); numRight.className = 'variant-num'; numRight.textContent = String(metadata.variants.length); numRight.disabled = idx === metadata.variants.length - 1; nav.appendChild(numRight); const nextBtn = document.createElement('button'); nextBtn.className = 'variant-btn'; nextBtn.textContent = '>'; nextBtn.disabled = idx === metadata.variants.length - 1; nav.appendChild(nextBtn); const switchFn = (newIdx) => { const vars = metadata.variants; if (newIdx < 0 || newIdx >= vars.length) return; const sv = vars[newIdx]; b.innerHTML = _renderVariant(sv); wrap.dataset.raw = sv.raw; wrap.dataset.variantIndex = String(newIdx); if (window.hljs) wrap.querySelectorAll('pre code').forEach(bl => window.hljs.highlightElement(bl)); tagLabel.textContent = _icons[sv.label] || ''; tagLabel.className = 'variant-tag' + (sv.label === 'shorter' ? ' variant-tag-scissors' : ''); numLeft.textContent = String(newIdx + 1); numLeft.disabled = newIdx === 0; numRight.disabled = newIdx === vars.length - 1; prevBtn.disabled = newIdx === 0; nextBtn.disabled = newIdx === vars.length - 1; }; prevBtn.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) - 1); }); numLeft.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) - 1); }); numRight.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) + 1); }); nextBtn.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) + 1); }); r.appendChild(nav); } if (role === 'assistant') { // The "N pinned" / "N recalled" pill in the footer reads from // wrap._memoriesUsed — propagate it from saved metadata so the pill // survives a page refresh (live-stream path sets it via SSE, but // history reloads need this assignment). if (metadata?.memories_used?.length) wrap._memoriesUsed = metadata.memories_used; wrap.appendChild(createMsgFooter(wrap)); if (metadata) displayMetrics(wrap, metadata); } else { // Add timestamp to user header (like AI messages) r.appendChild(roleTimestamp(metadata?.timestamp)); wrap.appendChild(createUserMsgFooter(wrap)); } box.appendChild(wrap); // TTS is now part of the msg-actions system if (role === 'assistant' && markdownModule.renderMermaid) { markdownModule.renderMermaid(wrap); } return wrap; } catch (error) { console.error('Error in addMessage:', error); if (uiModule) uiModule.showError('Failed to add message: ' + error.message); } } const chatRenderer = { shortModel, sameModelName, modelRouteLabel, replyModelPair, modelColor, applyModelColor, getModelCost, isCostTrackedEndpoint, isSubscriptionEndpoint, getImageCost, getSessionCost, resetSessionCost, updateSessionCostUI, recordSessionMetricsCost, roleTimestamp, stripToolBlocks, copyMessageText, safeToolScreenshotSrc, safeDisplayImageSrc, removeAskUserCards, renderAskUserCard, buildSourcesBox, buildFindingsBox, buildRagSourcesBox, appendReportButton, buildImageBubble, hideWelcomeScreen, showWelcomeScreen, createMsgFooter, displayMetrics, addMessage, buildAttachCards, updateMessageAttachments, }; export default chatRenderer;