// ========================================== // Chat Switchboard – UI Formatting & Helpers // ========================================== // Pure utility layer: esc(), markdown rendering, code blocks, // side panel, time formatting. Loaded before all other UI files. // ── HTML Escaping ─────────────────────────── function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // ── Message Formatting ────────────────────── function formatMessage(content) { if (!content) return ''; const thinkingBlocks = []; let text = content; text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => { const id = 'think-' + Math.random().toString(36).slice(2, 9); thinkingBlocks.push({ id, content: inner.trim() }); // Newlines ensure the placeholder is its own markdown block so adjacent // code fences (e.g. "```html") start on a fresh line for marked. return `\n\nTHINK_PLACEHOLDER_${id}\n\n`; }); let html; if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { html = _formatMarked(text); } else { html = _formatBasic(text); } for (const b of thinkingBlocks) { const inner = esc(b.content).replace(/\n/g, '
'); const openAttr = App.settings.showThinking ? ' open' : ''; const thinkHTML = `
💭 Thinking
${inner}
`; html = html.replace(new RegExp(`

\\s*THINK_PLACEHOLDER_${b.id}\\s*

`, 'g'), thinkHTML); html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML); } return html; } function _formatMarked(text) { // ── Unwrap outer ```markdown wrappers ──────────────────────── // LLMs frequently wrap entire responses in ```markdown ... ``` which is // semantically "this content IS markdown". Standard markdown doesn't support // nested fences with the same delimiter, so an inner ```mermaid ... ``` // prematurely closes the outer block, splitting the content into broken // fragments. Fix: if the outer fence is ```markdown/```md and contains // nested fences, strip the wrapper and render contents as markdown directly. text = _unwrapMarkdownFence(text); // Close any unclosed code fences to prevent raw HTML injection during // streaming (closing ``` hasn't arrived yet) or when the model forgets // to close a fence. An odd number of ``` markers means one is unclosed. const fences = text.match(/^```/gm); if (fences && fences.length % 2 !== 0) { text += '\n```'; } const rendered = marked.parse(text, { breaks: true, gfm: true }); let html = DOMPurify.sanitize(rendered, { // Strict allowlist: only elements that marked.js actually produces. // This prevents ANY raw HTML (canvas, style, script, iframe, form, etc.) // from rendering live even if a code fence fails to parse. ALLOWED_TAGS: [ // Block elements 'p', 'br', 'hr', 'pre', 'code', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', // Lists 'ul', 'ol', 'li', // Tables 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', // Inline formatting 'strong', 'em', 'b', 'i', 'u', 's', 'del', 'sub', 'sup', 'a', 'img', 'span', 'div', // Think blocks (injected post-sanitize, but placeholders may be in

) 'details', 'summary', ], ALLOWED_ATTR: ['id', 'class', 'href', 'target', 'rel', 'src', 'alt', 'title', 'colspan', 'rowspan', 'align'], }); // Process code blocks: add copy button, collapse toggle, HTML preview html = html.replace(/

]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
        const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
        const langMatch = attrs.match(/class="language-(\w+)"/);
        const lang = langMatch ? langMatch[1].toLowerCase() : '';

        // Extension hook: let extensions claim specific code blocks (mermaid, latex, etc.)
        // Extensions mark blocks with data-ext-render for post-render DOM processing.
        if (lang && typeof Extensions !== 'undefined' && Extensions.hasRenderers('block')) {
            try {
                const fakeContainer = document.createElement('div');
                const decoded = _decodeHTML(code);
                const handled = Extensions.runBlockRenderers(lang, decoded, fakeContainer);
                if (handled && fakeContainer.innerHTML) {
                    return `
${fakeContainer.innerHTML}
`; } if (handled && !fakeContainer.innerHTML) { console.warn(`[Extensions] Block renderer matched lang="${lang}" but produced empty output`); } } catch (e) { console.error(`[Extensions] Block renderer hook error for lang="${lang}":`, e); } } // Count lines for collapse decision const lineCount = (code.match(/\n/g) || []).length + 1; const COLLAPSE_THRESHOLD = 15; const shouldCollapse = lineCount > COLLAPSE_THRESHOLD; // Build toolbar buttons const collapseBtn = lineCount > 5 ? `` : ''; let toolbar = collapseBtn; toolbar += ``; toolbar += ``; // HTML preview button (only for HTML-like content) if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) { toolbar += ``; } const langLabel = lang ? `${lang}` : ''; const collapsedClass = shouldCollapse ? ' code-collapsed' : ''; return `
${langLabel}${code}
${toolbar}
`; }); return html; } function _formatBasic(text) { let html = esc(text); html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { const id = 'code-' + Math.random().toString(36).slice(2, 9); const lineCount = (code.match(/\n/g) || []).length + 1; const COLLAPSE_THRESHOLD = 15; const shouldCollapse = lineCount > COLLAPSE_THRESHOLD; const collapseBtn = lineCount > 5 ? `` : ''; let toolbar = collapseBtn; toolbar += ``; toolbar += ``; if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) { toolbar += ``; } const langLabel = lang ? `${lang}` : ''; const collapsedClass = shouldCollapse ? ' code-collapsed' : ''; return `
${langLabel}${code.trim()}
${toolbar}
`; }); html = html.replace(/`([^`]+)`/g, '$1'); html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); html = html.replace(/\*([^*]+)\*/g, '$1'); html = html.replace(/\n/g, '
'); return html; } // ── Markdown Fence Unwrapper ────────────────── // LLMs often wrap entire responses in ```markdown ... ```. This is semantically // a no-op ("this markdown IS markdown") but breaks parsing when the content // contains nested code fences — the inner closing ``` prematurely terminates // the outer block, splitting the response into fragments. // // Strategy: if the text is wrapped in ```markdown/```md and the body contains // at least one nested ``` fence, strip the outer wrapper. If there are no // nested fences, leave it alone (the user/LLM may genuinely want to show // markdown source in a code block). function _unwrapMarkdownFence(text) { // The text may start with THINK_PLACEHOLDER_xxx blocks (extracted earlier). // We need to find the ```markdown fence after any placeholders/whitespace. const openMatch = text.match(/^((?:\s|THINK_PLACEHOLDER_\w+)*)(```(?:markdown|md)\s*\n)/i); if (!openMatch) return text; const prefix = openMatch[1]; // placeholders + whitespace before the fence const fence = openMatch[2]; // the ```markdown\n itself const afterOpen = prefix.length + fence.length; // Find the LAST bare ``` on its own line — this is the outer closing fence. // There may be trailing text (LLM explanations) after it. let closeIdx = -1; const searchRegex = /\n```[ \t]*(?:\n|$)/g; let m; while ((m = searchRegex.exec(text)) !== null) { closeIdx = m.index; } if (closeIdx < afterOpen) return text; // Extract inner content and any trailing text after the close const inner = text.slice(afterOpen, closeIdx); const trailing = text.slice(closeIdx).replace(/^\n```[ \t]*\n?/, '\n'); // Only unwrap if the inner content contains nested fences — // otherwise this might be intentional "show me the markdown source" if (/^```/m.test(inner)) { return prefix + inner + trailing; } return text; } // Decode HTML entities in code block content (for extension renderers that need raw text) function _decodeHTML(html) { const txt = document.createElement('textarea'); txt.innerHTML = html; return txt.value; } // Heuristic: does this code block look like HTML? function _looksLikeHTML(code) { const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); return / { btn.classList.toggle('active', btn.dataset.tab === tab); }); // Show/hide pages document.getElementById('sidePanelPreview').style.display = tab === 'preview' ? '' : 'none'; document.getElementById('sidePanelNotes').style.display = tab === 'notes' ? '' : 'none'; } function toggleSidePanelFullscreen() { const panel = document.getElementById('sidePanel'); panel.classList.toggle('fullscreen'); const btn = document.getElementById('sidePanelFullscreenBtn'); if (btn) { const isFS = panel.classList.contains('fullscreen'); btn.title = isFS ? 'Exit fullscreen' : 'Toggle fullscreen'; btn.innerHTML = isFS ? '' : ''; } } // ── Side Panel Resize ─────────────────────── function _initSidePanelResize() { let startX, startW; const panel = document.getElementById('sidePanel'); const handle = document.getElementById('sidePanelResize'); if (!handle || !panel) return; handle.addEventListener('mousedown', (e) => { if (panel.classList.contains('fullscreen')) return; e.preventDefault(); startX = e.clientX; startW = panel.getBoundingClientRect().width; panel.style.transition = 'none'; // disable animation during drag document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; const onMove = (e) => { const delta = startX - e.clientX; // dragging left = wider const newW = Math.max(280, Math.min(window.innerWidth * 0.7, startW + delta)); panel.style.width = newW + 'px'; panel.style.minWidth = newW + 'px'; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; panel.style.transition = ''; // re-enable animation }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); } // ── Helpers ───────────────────────────────── // Render tool calls from stored message data (history view) function _renderToolCallsHTML(toolCalls) { if (!toolCalls || !Array.isArray(toolCalls) || toolCalls.length === 0) return ''; const items = toolCalls.map(tc => { const name = tc.function?.name || tc.name || 'unknown'; const args = tc.function?.arguments || tc.arguments || tc.input || ''; const result = tc.result || ''; const isError = tc.is_error || false; const tcId = tc.id || ''; const statusCls = isError ? 'tool-error' : 'tool-done'; const statusText = isError ? 'error' : 'done'; // Check for note tool — add view link let noteLink = ''; if (name.startsWith('note_') && result) { try { const parsed = typeof result === 'string' ? JSON.parse(result) : result; if (parsed.id) { noteLink = ``; } } catch (e) { /* not JSON */ } } // Build collapsible detail let detailHTML = ''; if (args || result) { const argsStr = typeof args === 'string' ? args : JSON.stringify(args, null, 2); const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2); detailHTML = `
`; if (argsStr) detailHTML += `
Input
${esc(argsStr)}
`; if (resultStr) detailHTML += `
Output
${esc(resultStr)}
`; detailHTML += `
`; } return `
🔧 ${esc(name)} ${statusText} ${noteLink} ${detailHTML}
`; }); return `
${items.join('')}
`; } function _relativeTime(dateStr) { if (!dateStr) return ''; const d = new Date(dateStr); const now = new Date(); const diff = (now - d) / 1000; if (diff < 60) return 'now'; if (diff < 3600) return `${Math.floor(diff / 60)}m`; if (diff < 86400) return `${Math.floor(diff / 3600)}h`; if (diff < 604800) return `${Math.floor(diff / 86400)}d`; return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); } /** * Run extension post-renderers on a container after innerHTML is set. * Safe to call even if extensions aren't loaded — no-ops gracefully. */ function runExtensionPostRender(container) { if (typeof Extensions !== 'undefined' && Extensions._loaded) { Extensions.runPostRenderers(container); } }