|
|
|
|
|
|
| import uiModule from './ui.js';
|
| import markdownModule from './markdown.js';
|
| import { addAITTSButton } from './tts-ai.js';
|
| import { providerLogo, providerLabel } from './providers.js';
|
| import settingsModule from './settings.js';
|
| import spinnerModule from './spinner.js';
|
| import { bindMenuDismiss } from './escMenuStack.js';
|
| import { matchModelKey } from './model/matchKey.js';
|
|
|
| const SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
| const REPORT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
|
| const CHAT_ABOUT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
| const COPY_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
| const CHECK_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
|
|
|
|
| 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) { }
|
| 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 _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;
|
| }
|
|
|
|
|
| function _attachIcon(mimeOrName) {
|
| const s = (mimeOrName || '').toLowerCase();
|
| if (s.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(s))
|
| return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>';
|
| if (s.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|webm)$/i.test(s))
|
| return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>';
|
| if (s === 'application/pdf' || /\.pdf$/i.test(s))
|
| return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>';
|
|
|
| return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
|
| }
|
| function _formatSize(bytes) {
|
| if (bytes < 1024) return bytes + ' B';
|
| if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
| return (bytes / 1048576).toFixed(1) + ' MB';
|
| }
|
|
|
|
|
|
|
|
|
| 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) {
|
|
|
|
|
|
|
|
|
| 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) => {
|
|
|
| if (e.target.closest('.attach-ocr-btn')) return;
|
| _openImageLightbox(att);
|
| });
|
| }
|
|
|
| let skel = null;
|
| let sp = null;
|
| if (!att.previewUrl) {
|
|
|
| skel = document.createElement('div');
|
| skel.className = 'attach-image-skeleton';
|
|
|
|
|
|
|
| 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');
|
|
|
|
|
| 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`;
|
|
|
| if (img.complete && img.naturalWidth) _reveal();
|
|
|
|
|
|
|
|
|
| if (!att.previewUrl) _revealTimer = setTimeout(_reveal, 8000);
|
| imgWrap.appendChild(img);
|
|
|
| if (att.id) {
|
|
|
|
|
|
|
|
|
| const ocrBtn = document.createElement('button');
|
| ocrBtn.type = 'button';
|
| ocrBtn.className = 'attach-ocr-btn';
|
| ocrBtn.title = 'View / edit OCR text';
|
| ocrBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg><span class="attach-ocr-label">Caption</span>';
|
| ocrBtn.addEventListener('click', (e) => {
|
| e.stopPropagation();
|
| _openVisionEditor(att, ocrBtn.closest('.msg'));
|
| });
|
| imgWrap.appendChild(ocrBtn);
|
| }
|
| }
|
|
|
| if (att.vision_model) {
|
| const visionLabel = document.createElement('div');
|
| visionLabel.className = 'attach-vision-model';
|
| visionLabel.textContent = 'Vision: ' + String(att.vision_model).split('/').pop();
|
| imgWrap.appendChild(visionLabel);
|
| }
|
| if (att.name) {
|
| const label = document.createElement('div');
|
| label.className = 'attach-image-name';
|
| label.textContent = att.name;
|
| imgWrap.appendChild(label);
|
| }
|
| attachWrap.appendChild(imgWrap);
|
| } else {
|
|
|
| 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', () => {
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
|
|
| function _openImageLightbox(att) {
|
| if (!att?.id) return;
|
| const overlay = document.createElement('div');
|
| overlay.className = 'attach-lightbox';
|
|
|
|
|
|
|
|
|
| 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();
|
| };
|
|
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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';
|
|
|
|
|
| title.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;flex-shrink:0"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span>Vision text</span>';
|
| panel.appendChild(title);
|
| const desc = document.createElement('div');
|
| desc.className = 'vision-editor-desc';
|
| desc.textContent = 'Edit text and save, new chats will have the new context. Regenerate or continue from there.';
|
| panel.appendChild(desc);
|
| const ta = document.createElement('textarea');
|
| ta.className = 'vision-editor-text';
|
| ta.rows = 10;
|
| ta.placeholder = 'Loading…';
|
| ta.disabled = true;
|
| panel.appendChild(ta);
|
| const actions = document.createElement('div');
|
| actions.className = 'vision-editor-actions';
|
| const closeBtn = document.createElement('button');
|
| closeBtn.type = 'button';
|
| closeBtn.className = 'vision-editor-btn';
|
| closeBtn.innerHTML = '<span class="vision-btn-label">Close</span>';
|
| closeBtn.addEventListener('click', _closeVisionEditor);
|
| const _saveVisionText = async () => {
|
| const res = await fetch(`/api/upload/${att.id}/vision`, {
|
| method: 'PUT',
|
| headers: { 'Content-Type': 'application/json' },
|
| credentials: 'same-origin',
|
| body: JSON.stringify({ text: ta.value }),
|
| });
|
| if (!res.ok) throw new Error('save failed');
|
| };
|
| const saveBtn = document.createElement('button');
|
| saveBtn.type = 'button';
|
| saveBtn.className = 'vision-editor-btn vision-editor-btn-primary';
|
| saveBtn.innerHTML = '<span class="vision-btn-label">Save</span>';
|
| saveBtn.disabled = true;
|
| saveBtn.addEventListener('click', async () => {
|
| saveBtn.disabled = true;
|
| saveBtn.innerHTML = '<span class="vision-btn-label">Saving…</span>';
|
| try {
|
| await _saveVisionText();
|
| if (uiModule?.showToast) uiModule.showToast('Saved');
|
| _closeVisionEditor();
|
| } catch (e) {
|
| saveBtn.disabled = false;
|
| saveBtn.innerHTML = '<span class="vision-btn-label">Save</span>';
|
| if (uiModule?.showError) uiModule.showError('Failed to save OCR text');
|
| }
|
| });
|
|
|
|
|
| const regenBtn = document.createElement('button');
|
| regenBtn.type = 'button';
|
| regenBtn.className = 'vision-editor-btn vision-editor-btn-primary';
|
| regenBtn.title = 'Save and regenerate the message';
|
| regenBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.74 9.74 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg><span class="vision-btn-label">Regenerate message</span>';
|
| regenBtn.disabled = true;
|
| regenBtn.addEventListener('click', async () => {
|
| regenBtn.disabled = true;
|
| saveBtn.disabled = true;
|
| try {
|
| await _saveVisionText();
|
| _closeVisionEditor();
|
| if (userMsgEl && window.chatModule?.resendUserMessage) {
|
| window.chatModule.resendUserMessage(userMsgEl);
|
| } else if (uiModule?.showToast) {
|
| uiModule.showToast('Saved');
|
| }
|
| } catch (e) {
|
| regenBtn.disabled = false;
|
| saveBtn.disabled = false;
|
| if (uiModule?.showError) uiModule.showError('Failed to save OCR text');
|
| }
|
| });
|
| actions.appendChild(closeBtn);
|
| actions.appendChild(saveBtn);
|
| actions.appendChild(regenBtn);
|
| panel.appendChild(actions);
|
| overlay.appendChild(panel);
|
| document.body.appendChild(overlay);
|
| _visionEditorEl = overlay;
|
|
|
|
|
|
|
| _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;
|
| });
|
| }
|
|
|
|
|
| const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi;
|
|
|
| const EXEC_FENCE_RE = /```(?:web_search|read_file|write_file|create_document|edit_document|update_document)\s*\n[\s\S]*?```/gi;
|
|
|
| const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi;
|
| const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi;
|
|
|
|
|
|
|
|
|
| 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 TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
|
|
|
|
|
|
|
|
|
| const MODEL_INFO = {
|
|
|
| '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 },
|
|
|
| '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-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 },
|
|
|
| '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-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 },
|
|
|
| '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 },
|
|
|
| '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 },
|
|
|
| '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 },
|
|
|
| '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 },
|
|
|
| 'sonar-pro': { input: 3.00, output: 15.00, ctx: 200000 },
|
| 'sonar': { input: 1.00, output: 1.00, ctx: 128000 },
|
|
|
| 'minimax': { input: 0.70, output: 0.70, ctx: 1000000 },
|
|
|
| 'moonshot': { input: 1.00, output: 1.00, ctx: 128000 },
|
| 'kimi': { input: 1.00, output: 1.00, ctx: 128000 },
|
|
|
| 'phi-4': { input: 0.07, output: 0.14, ctx: 16000 },
|
| 'phi-3': { input: 0.07, output: 0.14, ctx: 128000 },
|
|
|
| 'nemotron': { input: 0.30, output: 1.20, ctx: 131072 },
|
|
|
| 'hermes': { input: 0.20, output: 0.20, ctx: 131072 },
|
| };
|
|
|
|
|
| const MODEL_PRICING = MODEL_INFO;
|
|
|
|
|
| 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();
|
|
|
| short = short.replace(/\.gguf$/i, '');
|
|
|
| short = short.replace(/-0000\d-of-\d+$/, '');
|
| short = short.replace(/[-_](Q\d[_A-Z\d]*|F16|F32|BF16|fp16|fp32)$/i, '');
|
|
|
| if (short.length > 25) {
|
|
|
| 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();
|
| }
|
|
|
| export function modelRouteLabel(requestedModel, actualModel) {
|
| const requested = modelValue(requestedModel);
|
| const actual = modelValue(actualModel) || requested;
|
| if (!requested || sameModelName(requested, actual)) return shortModel(actual || requested);
|
| 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 };
|
| }
|
| const fallback = modelValue(modelName);
|
| return { requestedModel: fallback, actualModel: fallback };
|
| }
|
|
|
| |
| |
| |
| |
|
|
| 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%)`;
|
| }
|
|
|
|
|
| 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';
|
| }
|
|
|
| |
| |
|
|
| export function applyModelColor(roleEl, modelName) {
|
| if (!modelName) return;
|
| const color = modelColor(modelName);
|
| if (color) {
|
| roleEl.style.color = color;
|
| roleEl.style.setProperty('--model-dot', color);
|
| }
|
|
|
| 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);
|
| }
|
|
|
| if (!roleEl._hasInfoClick) {
|
| roleEl._hasInfoClick = true;
|
| roleEl.style.cursor = 'pointer';
|
| roleEl.addEventListener('click', (e) => {
|
| e.stopPropagation();
|
| document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
|
| const info = getModelInfo(modelName);
|
| const short = shortModel(modelName);
|
| const logoHtml = providerLogo(modelName);
|
| const popup = document.createElement('div');
|
| popup.className = 'ctx-popup';
|
| let html = '<div style="font-weight:600;margin-bottom:6px;color:var(--fg);display:flex;align-items:center;gap:6px;">';
|
| if (logoHtml) html += '<span class="role-provider-logo" style="opacity:0.7">' + logoHtml + '</span>';
|
| html += short + '</div>';
|
| html += '<div><span class="ctx-label">Model</span> ' + modelName.split('/').pop() + '</div>';
|
|
|
|
|
| const _epUrl = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
|
| ? window.sessionModule.getCurrentEndpointUrl() : null;
|
| const _provLabel = providerLabel(_epUrl);
|
| if (_provLabel) html += '<div><span class="ctx-label">Provider</span> ' + uiModule.esc(_provLabel) + '</div>';
|
|
|
| const _realCtx = window._realContextLengths && window._realContextLengths[modelName];
|
| if (_realCtx) {
|
| html += '<div><span class="ctx-label">Context</span> ' + _fmtCtx(_realCtx) + ' tokens';
|
| if (info && info.ctx && info.ctx !== _realCtx) html += ' <span style="opacity:0.35">(spec: ' + _fmtCtx(info.ctx) + ')</span>';
|
| html += '</div>';
|
| } else if (info && info.ctx) {
|
| html += '<div><span class="ctx-label">Context</span> <span id="_ctx-val">' + _fmtCtx(info.ctx) + ' tokens</span></div>';
|
| }
|
|
|
| if (!_realCtx && window.sessionModule) {
|
| const _sid = window.sessionModule.getCurrentSessionId();
|
| if (_sid) {
|
| fetch('/api/session/' + _sid + '/context_info').then(r => r.ok ? r.json() : null).then(d => {
|
| if (d && d.context_length) {
|
| if (!window._realContextLengths) window._realContextLengths = {};
|
| window._realContextLengths[modelName] = d.context_length;
|
| const el = document.getElementById('_ctx-val');
|
| if (el) {
|
| el.innerHTML = _fmtCtx(d.context_length) + ' tokens';
|
| if (info && info.ctx && info.ctx !== d.context_length) {
|
| el.innerHTML += ' <span style="opacity:0.35">(spec: ' + _fmtCtx(info.ctx) + ')</span>';
|
| }
|
| }
|
| }
|
| }).catch(() => {});
|
| }
|
| }
|
|
|
| if (window.presetsModule) {
|
| const _pid = window.presetsModule.getSelectedPreset();
|
| const _preset = _pid ? window.presetsModule.getPreset(_pid) : null;
|
| const _mt = _preset?.max_tokens;
|
| if (_mt && _mt > 0 && _mt <= 8192) {
|
| html += '<div><span class="ctx-label">Max tokens</span> ' + _mt.toLocaleString() + ' <span style="opacity:0.4">(configured)</span></div>';
|
| }
|
| }
|
| if (isCostTrackedEndpoint(_epUrl)) {
|
| if (info && info.input != null) html += '<div><span class="ctx-label">Input</span> $' + info.input.toFixed(2) + ' / 1M</div>';
|
| if (info && info.output != null) html += '<div><span class="ctx-label">Output</span> $' + info.output.toFixed(2) + ' / 1M</div>';
|
| if (!info) html += '<div style="opacity:0.4;font-size:0.85em;margin-top:4px;">No pricing data available</div>';
|
| }
|
| popup.innerHTML = html;
|
| const rect = roleEl.getBoundingClientRect();
|
| popup.style.top = (rect.bottom + 4) + 'px';
|
| popup.style.left = rect.left + 'px';
|
| document.body.appendChild(popup);
|
| const pr = popup.getBoundingClientRect();
|
| if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px';
|
| if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px';
|
| bindMenuDismiss(popup, () => popup.remove());
|
| });
|
| }
|
| }
|
|
|
| export function getModelCost(modelName, inputTokens, outputTokens) {
|
| if (!modelName) return null;
|
| const key = matchModelKey(modelName, Object.keys(MODEL_PRICING));
|
| if (!key) return null;
|
| const price = MODEL_PRICING[key];
|
| return (inputTokens * price.input + outputTokens * price.output) / 1_000_000;
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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;
|
|
|
|
|
|
|
|
|
|
|
| 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+)\./);
|
| 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);
|
| }
|
|
|
|
|
| function _billableCost(model, inputTokens, outputTokens) {
|
| const url = _currentEndpointUrl();
|
| if (!isCostTrackedEndpoint(url)) return null;
|
| return getModelCost(model, inputTokens, outputTokens);
|
| }
|
|
|
| export function getImageCost(model, quality, size) {
|
| if (!model) return null;
|
| const m = model.toLowerCase();
|
| for (const [key, quals] of Object.entries(IMAGE_PRICING)) {
|
| if (m.includes(key)) {
|
| const q = quals[(quality || 'medium').toLowerCase()] || quals['medium'];
|
| return q ? (q[size] || q['1024x1024'] || null) : null;
|
| }
|
| }
|
| return null;
|
| }
|
|
|
|
|
| const _COST_KEY = 'ody-session-cost';
|
|
|
|
|
| export function getSessionCost(sessionId) {
|
| const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId());
|
| if (!sid) return 0;
|
| try {
|
| const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
| return costs[sid] || 0;
|
| } catch (_e) { return 0; }
|
| }
|
|
|
|
|
| export function resetSessionCost(sessionId) {
|
| const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId());
|
| if (!sid) return;
|
| try {
|
| const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
| delete costs[sid];
|
| localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
| } catch (_e) { }
|
| updateSessionCostUI();
|
| }
|
|
|
|
|
| export function updateSessionCostUI() {
|
| const el = document.getElementById('session-cost-display');
|
| if (!el) return;
|
|
|
|
|
| const _url = _currentEndpointUrl();
|
| if (!isCostTrackedEndpoint(_url)) {
|
| const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
| if (sid && getSessionCost(sid) > 0) {
|
| try {
|
| const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
| delete costs[sid];
|
| localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
| } catch (_e) { }
|
| }
|
| el.style.display = 'none';
|
| return;
|
| }
|
| const cost = getSessionCost();
|
| if (cost > 0) {
|
| el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2));
|
| el.style.display = '';
|
| } else {
|
| el.style.display = 'none';
|
| }
|
| }
|
|
|
| |
| |
|
|
| 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;
|
| }
|
|
|
| |
| |
|
|
| export function stripToolBlocks(text) {
|
| let cleaned = text.replace(TOOL_CALL_RE, '');
|
| cleaned = cleaned.replace(EXEC_FENCE_RE, '');
|
| cleaned = cleaned.replace(DSML_TOOL_RE, '');
|
| cleaned = cleaned.replace(DSML_STRAY_RE, '');
|
| cleaned = cleaned.replace(XML_TOOL_CALL_RE, '');
|
| cleaned = cleaned.replace(XML_INVOKE_RE, '');
|
| cleaned = cleaned.replace(TOOL_NARRATION_RE, '');
|
| cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
|
| return cleaned.trim();
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| export function copyMessageText(msgElement) {
|
| const raw = msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || '';
|
| const { content } = markdownModule.extractThinkingBlocks(stripToolBlocks(raw));
|
| return content || raw;
|
| }
|
|
|
| |
| |
|
|
| export function buildSourcesBox(sources, type, expanded) {
|
| var esc = uiModule.esc;
|
| var id = 'sources-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
|
| var count = sources.length;
|
| var label = type === 'research' ? 'Research sources' : 'Web sources';
|
| var lines = '';
|
| for (var i = 0; i < count; i++) {
|
| var s = sources[i];
|
| var domain = '';
|
| try { domain = new URL(s.url).hostname.replace('www.', ''); } catch(e) { domain = s.url; }
|
| var title = esc(s.title || domain || '');
|
| var safeUrl = _safeHref(s.url);
|
| lines += '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer" class="source-link">'
|
| + '<span class="source-num">' + (i + 1) + '</span>'
|
| + '<span class="source-title">' + title + '</span>'
|
| + '<span class="source-domain">' + esc(domain) + '</span>'
|
| + '</a>';
|
| }
|
| var arrow = expanded ? 'down' : 'right';
|
| var expandedClass = expanded ? ' expanded' : '';
|
| return '<div class="sources-section">'
|
| + '<div class="sources-header" data-sources-id="' + id + '" onclick="window.toggleSources(\'' + id + '\')">'
|
| + '<div class="sources-header-left">' + SEARCH_ICON + '<span>' + count + ' ' + label + '</span></div>'
|
| + '<span class="sources-toggle" id="' + id + '-toggle" data-arrow="' + arrow + '"></span>'
|
| + '</div>'
|
| + '<div class="sources-content' + expandedClass + '" id="' + id + '">'
|
| + '<div class="sources-content-inner">' + lines + '</div>'
|
| + '</div></div>';
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| export function buildRagSourcesBox(sources) {
|
| if (!sources || !sources.length) return '';
|
| var esc = uiModule.esc;
|
| var items = '';
|
| for (var i = 0; i < sources.length; i++) {
|
| var s = sources[i] || {};
|
| var pct = (typeof s.similarity === 'number') ? (s.similarity * 100).toFixed(1) + '%' : '';
|
| items += '<div class="rag-source-item"><strong>' + esc(s.filename || '') + '</strong>'
|
| + (pct ? ' <span class="rag-similarity">' + pct + '</span>' : '')
|
| + '<div class="rag-snippet">' + esc(s.snippet || '') + '</div></div>';
|
| }
|
| return '<details class="rag-sources"><summary>Sources (' + sources.length + ' documents)</summary>' + items + '</details>';
|
| }
|
|
|
| |
| |
| |
| |
|
|
| export function buildFindingsBox(findings, expanded) {
|
| if (!findings || !findings.length) return '';
|
| var esc = uiModule.esc;
|
| var id = 'findings-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
|
| var count = findings.length;
|
| var lines = '';
|
| for (var i = 0; i < count; i++) {
|
| var f = findings[i];
|
| var domain = '';
|
| try { domain = new URL(f.url).hostname.replace('www.', ''); } catch(e) { domain = f.url; }
|
| var title = esc(f.title || domain || '');
|
| var summary = esc(f.summary || '');
|
| var safeUrl = _safeHref(f.url);
|
| lines += '<div class="finding-item">'
|
| + '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer" class="source-link">'
|
| + '<span class="source-num">' + (i + 1) + '</span>'
|
| + '<span class="source-title">' + title + '</span>'
|
| + '<span class="source-domain">' + esc(domain) + '</span>'
|
| + '</a>'
|
| + '<div class="finding-summary">' + summary + '</div>'
|
| + '</div>';
|
| }
|
| var FINDINGS_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>';
|
| var arrow = expanded ? 'down' : 'right';
|
| var expandedClass = expanded ? ' expanded' : '';
|
| return '<div class="sources-section">'
|
| + '<div class="sources-header" data-sources-id="' + id + '" onclick="window.toggleSources(\'' + id + '\')">'
|
| + '<div class="sources-header-left">' + FINDINGS_ICON + '<span>' + count + ' Raw collected findings</span></div>'
|
| + '<span class="sources-toggle" id="' + id + '-toggle" data-arrow="' + arrow + '"></span>'
|
| + '</div>'
|
| + '<div class="sources-content' + expandedClass + '" id="' + id + '">'
|
| + '<div class="sources-content-inner">' + lines + '</div>'
|
| + '</div></div>';
|
| }
|
|
|
|
|
| export function appendReportButton(container, sessionId) {
|
| _appendReportButton(container, sessionId);
|
| _appendContinuePrompt(container);
|
| }
|
|
|
| function _appendContinuePrompt(container) {
|
| var wrap = document.createElement('div');
|
| wrap.className = 'continue-research-wrap';
|
| wrap.innerHTML =
|
| '<div class="continue-research-hint">'
|
| + '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>'
|
| + '<span>Dig deeper? Activate Research again and type a follow-up question to continue this research.</span>'
|
| + '</div>';
|
| container.appendChild(wrap);
|
| }
|
| function _appendReportButton(container, sessionId) {
|
| var apiBase = window.API_BASE || '';
|
|
|
|
|
| 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) {
|
|
|
| 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';
|
| }
|
| };
|
|
|
|
|
| document.addEventListener('click', function(e) {
|
|
|
| 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);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| document.addEventListener('click', function(e) {
|
|
|
|
|
|
|
| let _t = e.target;
|
| while (_t && _t.nodeType === Node.TEXT_NODE) _t = _t.parentElement;
|
| const a = _t && _t.closest && _t.closest('a[href]');
|
| if (!a) return;
|
| const href = a.getAttribute('href') || '';
|
| if (!href.startsWith('#')) return;
|
| const m = href.match(/^#(session|document|note|image|email|event|task|skill|research)-(.+)$/);
|
| if (!m) return;
|
| e.preventDefault();
|
| e.stopPropagation();
|
| const [, kind, id] = m;
|
| if (kind === 'session') {
|
| import('./sessions.js').then(mod => {
|
| const fn = mod.selectSession || (mod.default && mod.default.selectSession);
|
| if (fn) fn(id);
|
| });
|
| } else if (kind === 'document') {
|
| import('./document.js').then(mod => {
|
| const open = mod.loadDocument
|
| || mod.openDocument
|
| || (mod.default && (mod.default.loadDocument || mod.default.openDocument));
|
| if (open) open(id);
|
| }).catch(() => {});
|
| } else if (kind === 'note') {
|
| import('./notes.js').then(mod => {
|
| const open = mod.openNote || (mod.default && mod.default.openNote);
|
| if (open) open(id);
|
| }).catch(() => {});
|
| } else if (kind === 'image') {
|
| import('./gallery.js').then(mod => {
|
| const open = mod.openGalleryImage || (mod.default && mod.default.openGalleryImage);
|
| if (open) open(id);
|
| }).catch(() => {});
|
| } else if (kind === 'email') {
|
| import('./emailLibrary.js').then(mod => {
|
| const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
| if (open) open({ uid: id });
|
| }).catch(() => {});
|
| } else if (kind === 'event') {
|
| import('./calendar.js').then(mod => {
|
| const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo);
|
| if (open) open(id);
|
| }).catch(() => {});
|
| } else if (kind === 'task') {
|
| import('./tasks.js').then(mod => {
|
| const open = mod.openTasks || (mod.default && mod.default.openTasks);
|
| if (open) open(id);
|
| else { const b = document.getElementById('tasks-btn'); if (b) b.click(); }
|
| }).catch(() => { const b = document.getElementById('tasks-btn'); if (b) b.click(); });
|
| } else if (kind === 'skill') {
|
| import('./skills.js').then(mod => {
|
| const open = mod.openSkill || (mod.default && mod.default.openSkill);
|
| if (open) open(id);
|
| }).catch(() => {});
|
| } else if (kind === 'research') {
|
| import('./research/panel.js').then(mod => {
|
| const open = mod.openPanel || (mod.default && mod.default.openPanel);
|
| if (open) open(id);
|
| }).catch(() => {});
|
| }
|
| });
|
|
|
| |
| |
|
|
| export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId) {
|
| var esc = uiModule.esc;
|
| const wrap = document.createElement('div');
|
| wrap.className = 'msg msg-ai generated-image-wrap';
|
|
|
| const role = document.createElement('div');
|
| role.className = 'role';
|
| role.textContent = (model || 'image').split('/').pop();
|
| wrap.appendChild(role);
|
|
|
| const body = document.createElement('div');
|
| body.className = 'body';
|
|
|
| const 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 editBtn = document.createElement('button');
|
| editBtn.className = 'footer-copy-btn';
|
| editBtn.type = 'button';
|
| editBtn.title = 'Edit in image editor';
|
| editBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>';
|
| editBtn.addEventListener('click', async (e) => {
|
| e.stopPropagation();
|
| try {
|
| const [galleryMod, editorMod] = await Promise.all([
|
| import('./gallery.js'),
|
| import('./galleryEditor.js'),
|
| ]);
|
|
|
|
|
| galleryMod.default.openGallery();
|
| const modal = document.getElementById('gallery-modal');
|
| if (modal) {
|
| modal.querySelectorAll('.gallery-tab').forEach(t => t.classList.remove('active'));
|
| modal.querySelector('.gallery-tab[data-tab="editor"]')?.classList.add('active');
|
| }
|
| const imagesContainer = document.getElementById('gallery-images-container');
|
| const albumsContainer = document.getElementById('gallery-albums-container');
|
| if (imagesContainer) imagesContainer.style.display = 'none';
|
| if (albumsContainer) albumsContainer.style.display = 'none';
|
| const editorContainer = document.getElementById('gallery-editor-container');
|
| if (editorContainer) editorContainer.style.display = 'flex';
|
| const label = (prompt || '').trim().slice(0, 60) || 'Generated image';
|
| editorMod.openEditor(imageUrl, null, null, label);
|
| } catch (err) {
|
| console.error('[chat] open in editor failed', err);
|
| }
|
| });
|
| actions.appendChild(editBtn);
|
|
|
| const delBtn = document.createElement('button');
|
| delBtn.className = 'footer-copy-btn footer-delete-btn';
|
| delBtn.type = 'button';
|
| delBtn.title = 'Delete image';
|
| delBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
|
| delBtn.addEventListener('click', async (e) => {
|
| e.stopPropagation();
|
| const ok = await uiModule.styledConfirm('Delete this image?', {
|
| confirmText: 'Delete',
|
| cancelText: 'Cancel',
|
| danger: true,
|
| });
|
| if (!ok) return;
|
|
|
|
|
| if (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');
|
|
|
| if (window._updateSendBtnIcon) setTimeout(window._updateSendBtnIcon, 50);
|
| const ib = document.getElementById('incognito-btn');
|
| if (ib) ib.style.display = ib.classList.contains('active') ? '' : 'none';
|
| }
|
|
|
| export function showWelcomeScreen() {
|
| const ws = document.getElementById('welcome-screen');
|
| const cc = document.getElementById('chat-container');
|
| if (ws) ws.classList.remove('hidden');
|
| if (cc) cc.classList.add('welcome-active');
|
|
|
|
|
|
|
|
|
|
|
| const _msg = document.getElementById('message');
|
| if (_msg) {
|
| _msg.value = '';
|
| _msg.style.height = '';
|
| _msg.dispatchEvent(new Event('input', { bubbles: true }));
|
| }
|
|
|
|
|
|
|
| const wn = document.querySelector('.welcome-name');
|
| if (wn) {
|
| wn.style.animation = 'none';
|
|
|
| void wn.offsetHeight;
|
| wn.style.animation = '';
|
| }
|
|
|
| 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();
|
| }
|
| }
|
|
|
|
|
| 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));
|
| }
|
|
|
| |
| |
|
|
| export function createMsgFooter(msgElement) {
|
| const footer = document.createElement('div');
|
| footer.className = 'msg-footer';
|
|
|
| const actions = document.createElement('span');
|
| actions.className = 'msg-actions';
|
|
|
|
|
| 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: '\u21BB', title: 'Regenerate from here', cls: 'msg-action-btn', handler(e) {
|
| e.stopPropagation();
|
| if (window.chatModule?.regenerateFrom) window.chatModule.regenerateFrom(msgElement);
|
| }},
|
| { id: 'shorten', icon: '\u2702', title: 'Rewrite shorter', cls: 'msg-action-btn', handler(e) {
|
| e.stopPropagation();
|
| if (window.chatModule?.rewriteWith) window.chatModule.rewriteWith(msgElement, 'Rewrite your last response to be shorter and more concise. Keep the key information but cut the fluff.');
|
| }},
|
| { id: 'explain', icon: '?', title: 'Explain simpler', cls: 'msg-action-btn', handler(e) {
|
| e.stopPropagation();
|
| if (window.chatModule?.rewriteWith) window.chatModule.rewriteWith(msgElement, 'Explain your last response in simpler terms. Use plain language and short sentences.');
|
| }},
|
| { id: 'fork', icon: '\u2ADD', title: 'Fork conversation', cls: 'msg-action-btn', handler(e) {
|
| e.stopPropagation();
|
| if (window.chatModule?.forkFrom) window.chatModule.forkFrom(msgElement);
|
| }},
|
| { id: 'delete', icon: '\u2715', title: 'Delete message', cls: 'msg-action-btn msg-delete-btn', handler(e) {
|
| e.stopPropagation();
|
| if (window.chatModule?.deleteMessage) window.chatModule.deleteMessage(msgElement);
|
| }},
|
| ];
|
|
|
|
|
| const availableActions = allActions.filter(a => !a.available || a.available());
|
|
|
|
|
| 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);
|
|
|
|
|
| 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));
|
|
|
|
|
| if (overflow.length > 0) {
|
| const moreBtn = document.createElement('button');
|
| moreBtn.className = 'msg-action-btn msg-more-btn';
|
| moreBtn.type = 'button';
|
| moreBtn.title = 'More actions';
|
| moreBtn.textContent = '\u00B7\u00B7\u00B7';
|
| moreBtn.addEventListener('click', (e) => {
|
| e.stopPropagation();
|
|
|
|
|
| const existing = document.querySelector('.msg-overflow-menu');
|
| if (existing) {
|
| if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
|
| if (existing._trigger === moreBtn) return;
|
| }
|
|
|
| const menu = document.createElement('div');
|
| menu.className = 'msg-overflow-menu';
|
| let closeMenu = () => menu.remove();
|
| overflow.forEach(a => {
|
| const item = document.createElement('button');
|
| item.className = 'msg-overflow-item';
|
| item.type = 'button';
|
| item.title = a.title;
|
| item.innerHTML = `<span class="overflow-icon">${a.icon}</span> ${a.title}`;
|
| item.addEventListener('click', (ev) => {
|
| ev.stopPropagation();
|
| _trackAction(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);
|
| }
|
|
|
|
|
| const mems = msgElement._memoriesUsed;
|
| if (mems && mems.length > 0) {
|
| const pill = document.createElement('button');
|
| pill.className = 'memory-used-pill';
|
| pill.type = 'button';
|
| const pinnedCount = mems.filter(m => m.type === 'pinned').length;
|
| const recalledCount = mems.filter(m => m.type === 'recalled').length;
|
| const parts = [];
|
| if (pinnedCount) parts.push(`${pinnedCount} pinned`);
|
| if (recalledCount) parts.push(`${recalledCount} recalled`);
|
| pill.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.8-3.5 6-.3.2-.5.5-.5.9V18h-6v-2.1c0-.4-.2-.7-.5-.9C6.3 13.8 5 11.5 5 9a7 7 0 0 1 7-7z"/><path d="M9 18h6v1a3 3 0 0 1-6 0v-1z"/><path d="M12 2v7"/><path d="M8.5 6.5L12 9l3.5-2.5"/></svg><span class="memory-used-pill-text">${parts.join(', ')}</span>`;
|
| pill.title = mems.map(m => `[${m.type}] ${m.text}`).join('\n');
|
|
|
| pill.addEventListener('click', (e) => {
|
| e.stopPropagation();
|
| let detail = pill._openDetail || document.querySelector('.memory-used-detail');
|
| if (detail) {
|
| if (typeof detail._dismiss === 'function') detail._dismiss();
|
| else { detail.remove(); pill._openDetail = null; }
|
| return;
|
| }
|
| detail = document.createElement('div');
|
| detail.className = 'memory-used-detail';
|
| let closeDetail = () => { detail.remove(); pill._openDetail = null; };
|
| mems.forEach(m => {
|
| const row = document.createElement('div');
|
| row.className = 'memory-used-row';
|
| row.style.cursor = 'pointer';
|
| row.title = 'Click to open memory manager';
|
| const badge = document.createElement('span');
|
| badge.className = 'memory-used-badge ' + (m.type === 'pinned' ? 'pinned' : 'recalled');
|
| badge.textContent = m.type === 'pinned' ? '\u25CF' : '\u21BB';
|
| const text = document.createElement('span');
|
| text.className = 'memory-used-text';
|
| text.textContent = m.text;
|
| row.appendChild(badge);
|
| row.appendChild(text);
|
| row.addEventListener('click', (ev) => {
|
| ev.stopPropagation();
|
| closeDetail();
|
| const memModal = document.getElementById('memory-modal');
|
| if (memModal) memModal.classList.remove('hidden');
|
| });
|
| detail.appendChild(row);
|
| });
|
| detail.style.visibility = 'hidden';
|
| document.body.appendChild(detail);
|
| const pillRect = pill.getBoundingClientRect();
|
| const detailRect = detail.getBoundingClientRect();
|
| const spaceAbove = pillRect.top;
|
| const spaceBelow = window.innerHeight - pillRect.bottom;
|
| if (spaceAbove >= detailRect.height + 8 || spaceAbove > spaceBelow) {
|
| detail.style.top = (pillRect.top - detailRect.height - 8) + 'px';
|
| } else {
|
| detail.style.top = (pillRect.bottom + 8) + 'px';
|
| }
|
| detail.style.left = pillRect.left + 'px';
|
| if (pillRect.left + detailRect.width > window.innerWidth - 8) {
|
| detail.style.left = (window.innerWidth - detailRect.width - 8) + 'px';
|
| }
|
| if (parseFloat(detail.style.left) < 8) detail.style.left = '8px';
|
| detail.style.visibility = '';
|
| pill._openDetail = detail;
|
|
|
| closeDetail = bindMenuDismiss(detail, () => { detail.remove(); pill._openDetail = null; }, (ev) => !detail.contains(ev.target) && ev.target !== pill); });
|
|
|
| footer.appendChild(pill);
|
| }
|
|
|
| footer.appendChild(actions);
|
| return footer;
|
| }
|
|
|
| |
| |
|
|
| const _USER_ACTION_RECENTS_KEY = 'odysseus-user-actions-recent';
|
|
|
| function _getUserRecentActions() {
|
| try { return JSON.parse(localStorage.getItem(_USER_ACTION_RECENTS_KEY) || '[]'); } catch { return []; }
|
| }
|
| function _trackUserAction(id) {
|
| let recent = _getUserRecentActions().filter(x => x !== id);
|
| recent.unshift(id);
|
| if (recent.length > 10) recent.length = 10;
|
| localStorage.setItem(_USER_ACTION_RECENTS_KEY, JSON.stringify(recent));
|
| }
|
|
|
| export function createUserMsgFooter(msgElement) {
|
| const footer = document.createElement('div');
|
| footer.className = 'msg-footer';
|
|
|
| const actions = document.createElement('span');
|
| actions.className = 'msg-actions';
|
|
|
| const allActions = [
|
| { id: 'edit', icon: '\u270E', title: 'Edit message', cls: 'msg-action-btn', handler(e) {
|
| e.stopPropagation();
|
| if (window.chatModule?.editUserMessage) window.chatModule.editUserMessage(msgElement);
|
| }},
|
| { id: 'delete', icon: '\u2715', title: 'Delete message', cls: 'msg-action-btn msg-delete-btn', handler(e) {
|
| e.stopPropagation();
|
| if (window.chatModule?.deleteMessage) window.chatModule.deleteMessage(msgElement);
|
| }},
|
| { id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) {
|
| e.stopPropagation();
|
| const btn = e.currentTarget;
|
| uiModule.copyToClipboard(msgElement.querySelector('.body')?.textContent || '');
|
| btn.innerHTML = CHECK_ICON;
|
| setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500);
|
| }},
|
| { id: 'resend', icon: '\u21BB', title: 'Resend message', cls: 'msg-action-btn', handler(e) {
|
| e.stopPropagation();
|
| if (window.chatModule?.resendUserMessage) window.chatModule.resendUserMessage(msgElement);
|
| }},
|
| ];
|
|
|
| const recent = _getUserRecentActions();
|
| const defaults = ['edit', 'delete', 'copy'];
|
| const order = recent.length > 0 ? recent : defaults;
|
| const sorted = [...allActions].sort((a, b) => {
|
| const ai = order.indexOf(a.id), bi = order.indexOf(b.id);
|
| if (ai >= 0 && bi >= 0) return ai - bi;
|
| if (ai >= 0) return -1;
|
| if (bi >= 0) return 1;
|
| return 0;
|
| });
|
| const visible = sorted.slice(0, _MAX_VISIBLE);
|
| const overflow = sorted.slice(_MAX_VISIBLE);
|
|
|
| visible.forEach(a => {
|
| const btn = _makeActionBtn(a.cls, a.title, a.html ? '' : a.icon, (ev) => {
|
| _trackUserAction(a.id);
|
| a.handler(ev);
|
| });
|
| if (a.html) btn.innerHTML = a.icon;
|
| btn.dataset.action = a.id;
|
| actions.appendChild(btn);
|
| });
|
|
|
| if (overflow.length > 0) {
|
| const moreBtn = document.createElement('button');
|
| moreBtn.className = 'msg-action-btn msg-more-btn';
|
| moreBtn.type = 'button';
|
| moreBtn.title = 'More actions';
|
| moreBtn.textContent = '\u00B7\u00B7\u00B7';
|
| moreBtn.addEventListener('click', (e) => {
|
| e.stopPropagation();
|
| const existing = document.querySelector('.msg-overflow-menu');
|
| if (existing) {
|
| if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
|
| if (existing._trigger === moreBtn) return;
|
| }
|
|
|
| const menu = document.createElement('div');
|
| menu.className = 'msg-overflow-menu';
|
| let closeMenu = () => menu.remove();
|
| overflow.forEach(a => {
|
| const item = document.createElement('button');
|
| item.className = 'msg-overflow-item';
|
| item.type = 'button';
|
| item.title = a.title;
|
| item.innerHTML = `<span class="overflow-icon">${a.icon}</span> ${a.title}`;
|
| item.addEventListener('click', (ev) => {
|
| ev.stopPropagation();
|
| _trackUserAction(a.id);
|
| closeMenu();
|
| a.handler(ev);
|
| });
|
| menu.appendChild(item);
|
| });
|
| menu._trigger = moreBtn;
|
| document.body.appendChild(menu);
|
| const btnRect = moreBtn.getBoundingClientRect();
|
| menu.style.top = (btnRect.top - menu.offsetHeight - 4) + 'px';
|
| menu.style.left = btnRect.left + 'px';
|
| if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px';
|
| const mr = menu.getBoundingClientRect();
|
| if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
|
| closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); });
|
| actions.appendChild(moreBtn);
|
| }
|
|
|
| footer.appendChild(actions);
|
| return footer;
|
| }
|
|
|
| |
| |
|
|
| export function displayMetrics(messageElement, metrics) {
|
| const existingMetrics = messageElement.querySelector('.response-metrics');
|
| if (existingMetrics) existingMetrics.remove();
|
|
|
| const metricsContainer = document.createElement('span');
|
| metricsContainer.className = 'response-metrics';
|
|
|
| const responseTime = metrics.response_time;
|
| const inputTokens = metrics.input_tokens || 0;
|
| const outputTokens = metrics.output_tokens || 0;
|
| const tps = metrics.tokens_per_second;
|
| const isReal = metrics.usage_source === 'real';
|
| const ctxPct = metrics.context_percent;
|
| const model = metrics.model || 'Unknown';
|
| const cost = _billableCost(model, inputTokens, outputTokens);
|
|
|
|
|
| if (!responseTime && !outputTokens && tps == null && !ctxPct) return;
|
|
|
|
|
| if (!metrics._fromHistory) {
|
| const _sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
| if (_sid && cost !== null) {
|
| try {
|
| const _costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
| _costs[_sid] = (_costs[_sid] || 0) + cost;
|
| localStorage.setItem(_COST_KEY, JSON.stringify(_costs));
|
| } catch (_e) { }
|
| updateSessionCostUI();
|
| }
|
| }
|
|
|
|
|
| const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
|
| const metricsLabel = tps != null && tps !== 'undefined'
|
| ? `${tps} tok/s`
|
| : costStr0
|
| ? `${outputTokens} tok · ${costStr0}`
|
| : outputTokens
|
| ? `${outputTokens} tok · ${responseTime != null ? responseTime + 's' : ''}`
|
| : responseTime != null
|
| ? `${responseTime}s`
|
| : '';
|
| if (!metricsLabel) return;
|
| metricsContainer.textContent = metricsLabel;
|
| metricsContainer.style.cursor = 'pointer';
|
| metricsContainer.title = 'Click for details';
|
| const metricsDivider = document.createElement('span');
|
| metricsDivider.textContent = ' | ';
|
| metricsDivider.style.color = 'var(--color-muted-alt)';
|
| metricsDivider.style.pointerEvents = 'none';
|
| metricsContainer.addEventListener('click', (e) => {
|
| e.stopPropagation();
|
| document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
|
|
|
| const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : '';
|
| const costRows = costStr ? `<div><span class="ctx-label">Cost</span> ${costStr}</div>` : '';
|
| const speedStr = tps != null && tps !== 'undefined' ? `${tps} tok/s` : 'n/a';
|
| const totalTok = inputTokens + outputTokens;
|
| const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--color-muted-alt, #6b7280)';
|
| const prepTime = metrics.agent_prep_time;
|
| const modelWaitTime = metrics.agent_model_wait_time;
|
| const prepBreakdown = metrics.agent_prep_breakdown || null;
|
| const prepDetails = prepBreakdown
|
| ? Object.entries(prepBreakdown).map(([k, v]) => `${k}: ${v}s`).join('<br>')
|
| : '';
|
|
|
|
|
| let sessionCostStr = '';
|
| const sc = getSessionCost();
|
| if (costStr && sc > 0) {
|
| sessionCostStr = `<div><span class="ctx-label">Session</span> $${sc < 0.01 ? sc.toFixed(4) : sc.toFixed(3)}</div>`;
|
| }
|
|
|
| const popup = document.createElement('div');
|
| popup.className = 'ctx-popup';
|
| popup.innerHTML = `
|
| <div style="font-weight:600;margin-bottom:6px;color:var(--fg);">Message Stats</div>
|
| <div><span class="ctx-label">Model</span> ${model.split('/').pop()}</div>
|
| <div><span class="ctx-label">Input</span> ${inputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</div>
|
| <div><span class="ctx-label">Output</span> ${outputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</div>
|
| <div><span class="ctx-label">Total</span> ${totalTok.toLocaleString()} tokens</div>
|
| <div><span class="ctx-label">Speed</span> ${speedStr}</div>
|
| <div><span class="ctx-label">Time</span> ${responseTime}s</div>
|
| ${prepTime != null ? `<div><span class="ctx-label">Prep</span> ${prepTime}s</div>` : ''}
|
| ${modelWaitTime != null ? `<div><span class="ctx-label">Model wait</span> ${modelWaitTime}s</div>` : ''}
|
| ${costRows}
|
| ${sessionCostStr}
|
| ${prepDetails ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);font-size:0.85em;opacity:0.8;">
|
| <div style="font-weight:600;margin-bottom:4px;color:var(--fg);">Agent prep</div>
|
| ${prepDetails}
|
| </div>` : ''}
|
| ${ctxPct !== undefined && ctxPct > 0 ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);">
|
| <span class="ctx-label">Context</span> <span style="color:${ctxColor};font-weight:600;">${ctxPct}%</span> used
|
| </div>` : ''}
|
| ${isReal ? '' : '<div style="margin-top:4px;font-size:0.8em;opacity:0.4;">~ estimated token count</div>'}
|
| `;
|
|
|
| const rect = metricsContainer.getBoundingClientRect();
|
| popup.style.left = rect.left + 'px';
|
| popup.style.visibility = 'hidden';
|
| document.body.appendChild(popup);
|
| const pr = popup.getBoundingClientRect();
|
| const spaceAbove = rect.top;
|
| const spaceBelow = window.innerHeight - rect.bottom;
|
| if (spaceAbove >= pr.height + 8 || spaceAbove > spaceBelow) {
|
| popup.style.top = (rect.top - pr.height - 8) + 'px';
|
| } else {
|
| popup.style.top = (rect.bottom + 8) + 'px';
|
| }
|
| if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px';
|
| if (parseFloat(popup.style.left) < 8) popup.style.left = '8px';
|
| popup.style.visibility = '';
|
|
|
| bindMenuDismiss(popup, () => popup.remove());
|
| });
|
|
|
|
|
| if (metrics.context_length && metrics.model) {
|
| if (!window._realContextLengths) window._realContextLengths = {};
|
| window._realContextLengths[metrics.model] = metrics.context_length;
|
| }
|
|
|
|
|
| let ctxRing = null;
|
| const ctxLen = metrics.context_length || 0;
|
| if (ctxPct !== undefined && ctxPct > 0) {
|
| const r = 6, stroke = 1.5;
|
| const circ = 2 * Math.PI * r;
|
| const fill = circ * (ctxPct / 100);
|
| const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--green, #98c379)';
|
| ctxRing = document.createElement('span');
|
| ctxRing.className = 'ctx-ring';
|
| ctxRing.title = `${ctxPct}% context used — click for details`;
|
| ctxRing.style.cursor = 'pointer';
|
| ctxRing.style.setProperty('--ctx-color', ctxColor);
|
| ctxRing.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14">
|
| <circle cx="7" cy="7" r="${r}" fill="none" stroke="var(--border, #333)" stroke-width="${stroke}" opacity="0.3"/>
|
| <circle cx="7" cy="7" r="${r}" fill="none" stroke="var(--ctx-stroke)" stroke-width="${stroke}"
|
| stroke-dasharray="${fill} ${circ - fill}" stroke-dashoffset="${circ * 0.25}"
|
| stroke-linecap="round" transform="rotate(-90 7 7)"/>
|
| </svg><span class="ctx-ring-pct">${Math.round(ctxPct)}%</span>`;
|
|
|
| ctxRing.addEventListener('click', (e) => {
|
| e.stopPropagation();
|
| document.querySelectorAll('.ctx-detail-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
|
|
|
| const usedTokens = inputTokens || 0;
|
| const totalCtx = ctxLen || 0;
|
| const modelShort = model.split('/').pop();
|
| const fmtNum = n => n ? n.toLocaleString() : '?';
|
|
|
| const popup = document.createElement('div');
|
| popup.className = 'ctx-detail-popup';
|
| popup.innerHTML = `
|
| <div style="font-weight:600;margin-bottom:8px;color:var(--fg);">Context Window</div>
|
| <div class="ctx-bar-wrap">
|
| <div class="ctx-bar-fill" style="width:${Math.min(ctxPct, 100)}%;background:${ctxColor};"></div>
|
| </div>
|
| <div style="display:flex;justify-content:space-between;font-size:0.75rem;margin-top:4px;opacity:0.6;">
|
| <span>${fmtNum(usedTokens)} used</span>
|
| <span>${fmtNum(totalCtx)} total</span>
|
| </div>
|
| <div style="margin-top:8px;font-size:0.8rem;">
|
| <div><span class="ctx-label">Model</span> ${modelShort}</div>
|
| <div><span class="ctx-label">Usage</span> <span style="color:${ctxColor};font-weight:600;">${ctxPct}%</span></div>
|
| <div><span class="ctx-label">Window</span> ${fmtNum(totalCtx)} tokens</div>
|
| </div>
|
| ${ctxPct >= 70 ? `<button class="ctx-compact-btn" title="Summarize older messages to free up context">Compact context</button>` : ''}
|
| `;
|
|
|
| const compactBtn = popup.querySelector('.ctx-compact-btn');
|
| if (compactBtn) {
|
| compactBtn.addEventListener('click', async (e) => {
|
| e.stopPropagation();
|
| const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
| if (!sid) return;
|
| popup.remove();
|
|
|
|
|
| const chatBox = document.getElementById('chat-history');
|
| if (!chatBox) return;
|
| const compactMsg = document.createElement('div');
|
| compactMsg.className = 'msg msg-ai';
|
| const compactRole = document.createElement('div');
|
| compactRole.className = 'role';
|
| compactRole.textContent = 'Odysseus';
|
| const compactBody = document.createElement('div');
|
| compactBody.className = 'body';
|
| compactBody.innerHTML = 'Compacting context <span class="compact-wave">▁▂▃▅▂▁</span>';
|
| compactMsg.appendChild(compactRole);
|
| compactMsg.appendChild(compactBody);
|
| chatBox.appendChild(compactMsg);
|
| chatBox.scrollTop = chatBox.scrollHeight;
|
|
|
|
|
| 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();
|
|
|
| if (window.sessionModule) await window.sessionModule.selectSession(sid);
|
|
|
| setTimeout(() => {
|
| const msgs = document.querySelectorAll('#chat-history .msg');
|
| for (const m of msgs) {
|
| if (m.querySelector('.body')?.textContent.includes('Conversation compacted')) {
|
| m.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
| break;
|
| }
|
| }
|
| }, 200);
|
| } else {
|
| let detail = 'Compaction failed. Try again later.';
|
| try {
|
| const err = await res.json();
|
| if (err.detail) detail = err.detail;
|
| } catch {}
|
| compactBody.textContent = detail;
|
| compactBody.style.color = 'var(--red)';
|
| }
|
| } catch (err) {
|
| clearInterval(waveInterval);
|
| console.warn('compact failed:', err);
|
| compactBody.innerHTML = '<span style="color:var(--red);">Compaction failed: ' + err.message + '</span>';
|
| }
|
| });
|
| }
|
|
|
| const rect = ctxRing.getBoundingClientRect();
|
| popup.style.visibility = 'hidden';
|
| document.body.appendChild(popup);
|
| const pr = popup.getBoundingClientRect();
|
|
|
| popup.style.left = Math.max(8, rect.right - pr.width) + 'px';
|
| const spaceAbove = rect.top;
|
| if (spaceAbove >= pr.height + 8) {
|
| popup.style.top = (rect.top - pr.height - 8) + 'px';
|
| } else {
|
| popup.style.top = (rect.bottom + 8) + 'px';
|
| }
|
| popup.style.visibility = '';
|
|
|
| bindMenuDismiss(popup, () => popup.remove(), (ev) => !popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target));
|
| });
|
| }
|
|
|
| let footer = messageElement.querySelector('.msg-footer');
|
| if (footer) {
|
| const actions = footer.querySelector('.msg-actions');
|
| if (actions) {
|
| footer.insertBefore(metricsDivider, actions);
|
| footer.insertBefore(metricsContainer, metricsDivider);
|
| } else {
|
| footer.appendChild(metricsContainer);
|
| footer.appendChild(metricsDivider);
|
| }
|
| if (ctxRing) {
|
| const ctxDiv = document.createElement('span');
|
| ctxDiv.textContent = ' | ';
|
| ctxDiv.style.color = 'var(--color-muted-alt)';
|
| ctxDiv.style.pointerEvents = 'none';
|
| ctxDiv.className = 'ctx-divider';
|
| footer.appendChild(ctxDiv);
|
| footer.appendChild(ctxRing);
|
| }
|
| } else {
|
| messageElement.appendChild(metricsContainer);
|
| if (ctxRing) messageElement.appendChild(ctxRing);
|
| }
|
|
|
| if (uiModule) uiModule.scrollHistory();
|
| }
|
|
|
| |
| |
|
|
| export function addMessage(role, content, modelName, metadata) {
|
| try {
|
| hideWelcomeScreen();
|
| const box = document.getElementById('chat-history');
|
| if (!box) { console.error('Chat history element not found'); return; }
|
|
|
| var esc = uiModule.esc;
|
| const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
|
|
|
|
|
| if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
|
| const roundTexts = metadata.round_texts || [];
|
| const toolEvents = metadata.tool_events;
|
| let lastWrap = null;
|
| let firstMsgAi = null;
|
| let lastMsgAi = null;
|
|
|
| const toolsByRound = {};
|
| for (const ev of toolEvents) {
|
| const r = ev.round || 1;
|
| if (!toolsByRound[r]) toolsByRound[r] = [];
|
| toolsByRound[r].push(ev);
|
| }
|
|
|
| const maxRound = Math.max(...Object.keys(toolsByRound).map(Number), roundTexts.length);
|
|
|
| for (let r = 0; r < maxRound; r++) {
|
| const roundNum = r + 1;
|
| const txt = (roundTexts[r] || '').trim();
|
|
|
| if (txt) {
|
| const wrap = document.createElement('div');
|
| wrap.className = 'msg msg-ai' + (r > 0 ? ' msg-continuation' : '');
|
| const roleEl = document.createElement('div');
|
| roleEl.className = 'role';
|
| const pair = replyModelPair(modelName, metadata);
|
| const contModel = pair.actualModel || pair.requestedModel;
|
| roleEl.textContent = modelRouteLabel(pair.requestedModel, contModel);
|
| if (pair.requestedModel && contModel && !sameModelName(pair.requestedModel, contModel)) {
|
| roleEl.title = pair.requestedModel + ' -> ' + contModel;
|
| }
|
| applyModelColor(roleEl, contModel);
|
| if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp));
|
| wrap.appendChild(roleEl);
|
| const body = document.createElement('div');
|
| body.className = 'body';
|
|
|
| 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);
|
| }
|
|
|
| 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) {
|
|
|
| let threadWrap = null;
|
| if (!txt && lastWrap && lastWrap.classList.contains('agent-thread')) {
|
| threadWrap = lastWrap;
|
| } else {
|
| threadWrap = document.createElement('div');
|
| threadWrap.className = 'agent-thread';
|
|
|
| if (txt) threadWrap.classList.add('has-top');
|
| box.appendChild(threadWrap);
|
| }
|
| for (const ev of roundTools) {
|
| const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
| let outHtml = '';
|
| if (ev.output && ev.output.trim()) {
|
| outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(ev.output)}</pre></details>`;
|
| }
|
| const screenshotSrc = safeToolScreenshotSrc(ev.screenshot);
|
| if (screenshotSrc) {
|
| outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(screenshotSrc)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
|
| }
|
|
|
|
|
| let evDiffHtml = '';
|
| if (ev.diff && ev.diff.text) {
|
| const d = ev.diff;
|
| const stat = [
|
| d.new_file ? '<span class="diff-stat-new">new</span>' : '',
|
| d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '',
|
| d.removed ? `<span class="diff-stat-del">\u2212${d.removed}</span>` : '',
|
| ].filter(Boolean).join(' ');
|
| const rows = d.text.split('\n').map(line => {
|
| let cls = 'diff-ctx', text = line;
|
| if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
|
| else if (line.startsWith('@@')) cls = 'diff-hunk';
|
|
|
| else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); }
|
| else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); }
|
| else if (line.startsWith(' ')) { text = line.slice(1); }
|
| return `<span class="${cls}">${esc(text) || ' '}</span>`;
|
| }).join('');
|
| evDiffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`;
|
| }
|
| const node = document.createElement('div');
|
| node.className = 'agent-thread-node' + (ok ? '' : ' error');
|
|
|
| const evCmdHtml = (ev.command && !(ev.diff && ev.diff.text)) ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : '';
|
| node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(ev.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}${evDiffHtml}</div>`;
|
|
|
| threadWrap.appendChild(node);
|
| }
|
|
|
| const nextTxt = (roundTexts[r + 1] || '').trim();
|
| if (nextTxt) threadWrap.classList.add('has-bottom');
|
| lastWrap = threadWrap;
|
|
|
| for (const ev of roundTools) {
|
| if (ev.image_url) {
|
| box.appendChild(buildImageBubble(ev.image_url, ev.image_prompt, ev.image_model, ev.image_size, ev.image_quality, ev.image_id));
|
| }
|
| }
|
| }
|
| }
|
|
|
| const firstWrap = lastMsgAi || lastWrap;
|
| if (firstWrap && firstWrap.classList.contains('msg-ai')) {
|
| if (metadata?.memories_used?.length) firstWrap._memoriesUsed = metadata.memories_used;
|
| firstWrap.appendChild(createMsgFooter(firstWrap));
|
| if (metadata) displayMetrics(firstWrap, metadata);
|
| }
|
|
|
| if (window.hljs) {
|
| box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
| }
|
| if (markdownModule.renderMermaid) markdownModule.renderMermaid(box);
|
| return lastWrap;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| let _isWakeCheck = !!(metadata?.wake_check_in || metadata?.hidden_from_user_view);
|
| if (!_isWakeCheck && typeof textRaw === 'string') {
|
|
|
|
|
| if (/^\s*\[Task\]\s+Self-check:/i.test(textRaw)) {
|
| _isWakeCheck = true;
|
| }
|
| }
|
| if (_isWakeCheck) {
|
|
|
|
|
| return null;
|
| }
|
|
|
|
|
| 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);
|
| if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) {
|
| _roleText += ' (Research)';
|
| }
|
| if (metadata?.group_model && role !== 'user') {
|
| _roleText = metadata.group_model;
|
| } else if (metadata?.character_name && role !== 'user' && !isSlash && !isCompacted) {
|
| _roleText = metadata.character_name;
|
| }
|
| r.textContent = _roleText;
|
| if (role !== 'user') {
|
| if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && !sameModelName(replyModels.requestedModel, resolvedModel)) {
|
| r.title = replyModels.requestedModel + ' -> ' + resolvedModel;
|
| }
|
| 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 || ''));
|
|
|
|
|
|
|
|
|
|
|
| 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 ''; }
|
| );
|
| }
|
|
|
| if (role === 'user' && attachments?.length) {
|
|
|
| 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;
|
|
|
| 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);
|
| }
|
|
|
| if (role === 'assistant' && metadata?.rag_sources?.length) {
|
| findingsSuffix += buildRagSourcesBox(metadata.rag_sources);
|
| }
|
|
|
| if (role === 'assistant' && metadata?.thinking) {
|
| const thinkTime = metadata.thinking_time || null;
|
| const thinkHtml = markdownModule.processWithThinking(
|
| '<think' + (thinkTime ? ` time="${thinkTime}"` : '') + '>' + metadata.thinking + '</think>\n\n' + text
|
| );
|
| b.innerHTML = sourcesPrefix + thinkHtml + findingsSuffix;
|
| } else {
|
| b.innerHTML = sourcesPrefix + markdownModule.processWithThinking(text) + findingsSuffix;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| void _visionBlocks;
|
|
|
|
|
| if (role === 'assistant' && metadata?.research) {
|
| var _sid = window.sessionModule?.getCurrentSessionId?.();
|
| if (_sid) _appendReportButton(b, _sid);
|
| }
|
|
|
|
|
| if (role === 'user') {
|
|
|
| b.innerHTML = b.innerHTML.replace(
|
| /\[Doc edit: (lines? [\d–\-]+)\]\s*/,
|
| '<span class="doc-edit-tag">Doc edit: $1</span> '
|
| );
|
|
|
|
|
| const rawDocMatch = b.innerHTML.match(/In the document, edit this specific text \((lines? [\d–\-]+)\)/);
|
| if (rawDocMatch) {
|
| const lineRef = rawDocMatch[1];
|
|
|
| const instrMatch = b.textContent.match(/Instruction:\s*([\s\S]*)$/);
|
| const instrText = instrMatch ? instrMatch[1].trim() : '';
|
| b.innerHTML = '<span class="doc-edit-tag">Doc edit: ' + lineRef + '</span> ' + markdownModule.processWithThinking(instrText);
|
| }
|
|
|
|
|
| if (attachments?.length) {
|
| b.appendChild(buildAttachCards(attachments));
|
| }
|
| }
|
|
|
| wrap.appendChild(r);
|
| wrap.appendChild(b);
|
|
|
|
|
| if (role === 'assistant' && metadata?.stopped) {
|
| const stoppedIndicator = document.createElement('div');
|
| stoppedIndicator.className = 'stopped-indicator';
|
| const stoppedLabel = document.createElement('span');
|
|
|
|
|
| stoppedLabel.textContent = metadata.cancelled
|
| ? '[Cancelled by user]'
|
| : '[Message interrupted]';
|
| stoppedIndicator.appendChild(stoppedLabel);
|
|
|
|
|
| if (!metadata.cancelled) {
|
| const continueBtn = document.createElement('button');
|
| continueBtn.className = 'continue-btn';
|
| continueBtn.title = 'Continue';
|
| continueBtn.textContent = '\u25B8';
|
| continueBtn.addEventListener('click', () => {
|
| stoppedIndicator.remove();
|
| if (window.chatModule) {
|
| window.chatModule.setHideUserBubble();
|
| window.chatModule.setPendingContinue(wrap);
|
| const rawText = wrap.dataset.raw || wrap.querySelector('.body')?.textContent || '';
|
| const cutoff = rawText;
|
| const msgInput = document.getElementById('message');
|
| if (msgInput) {
|
| msgInput.value = 'Your previous response was interrupted. It ended with:\n\n' + cutoff.slice(-500) + '\n\nDo NOT repeat what you already said. Continue exactly from where you were cut off.';
|
| const sb = document.querySelector('.send-btn');
|
| if (sb) sb.click();
|
| }
|
| }
|
| });
|
| stoppedIndicator.appendChild(continueBtn);
|
| }
|
| b.appendChild(stoppedIndicator);
|
| }
|
|
|
| if (metadata?.edited) {
|
| const editedIndicator = document.createElement('div');
|
| editedIndicator.className = 'edited-indicator';
|
| editedIndicator.textContent = '[Message edited]';
|
| b.appendChild(editedIndicator);
|
| }
|
|
|
|
|
| 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);
|
|
|
|
|
|
|
|
|
| const _renderVariant = (v) => (v && v.raw)
|
| ? markdownModule.processWithThinking(markdownModule.squashOutsideCode(v.raw))
|
| : (v && v.html) || '';
|
|
|
|
|
| const v = metadata.variants[idx];
|
| if (v) {
|
| b.innerHTML = _renderVariant(v);
|
| wrap.dataset.raw = v.raw;
|
| }
|
|
|
|
|
| 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') {
|
|
|
|
|
|
|
|
|
| if (metadata?.memories_used?.length) wrap._memoriesUsed = metadata.memories_used;
|
| wrap.appendChild(createMsgFooter(wrap));
|
| if (metadata) displayMetrics(wrap, metadata);
|
| } else {
|
|
|
| r.appendChild(roleTimestamp(metadata?.timestamp));
|
|
|
| wrap.appendChild(createUserMsgFooter(wrap));
|
| }
|
|
|
| box.appendChild(wrap);
|
|
|
|
|
| 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,
|
| roleTimestamp,
|
| stripToolBlocks,
|
| copyMessageText,
|
| safeToolScreenshotSrc,
|
| safeDisplayImageSrc,
|
| buildSourcesBox,
|
| buildFindingsBox,
|
| appendReportButton,
|
| buildImageBubble,
|
| hideWelcomeScreen,
|
| showWelcomeScreen,
|
| createMsgFooter,
|
| displayMetrics,
|
| addMessage,
|
| updateMessageAttachments,
|
| };
|
|
|
| export default chatRenderer;
|
|
|