// ========================================== // Chat Switchboard – UI Formatting & Helpers // ========================================== // Markdown rendering, code blocks, preview panel, time formatting. // esc() is in ui-primitives.js (loaded globally in base.html). // // Exports: formatMessage, clearPreview, runExtensionPostRender, // _livePreviewUpdate, _relativeTime, _renderToolCallsHTML, // _registerPreviewPanel, // toggleCodeCollapse, toggleHTMLPreview, downloadCode, // popOutExtBlock (onclick handlers) // ========================================== // ── 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); } // @mention highlighting (v0.23.0): style @Name references as pills html = _highlightMentions(html); return html; } /** * Highlight @mentions in rendered HTML as styled pills. * Matches @Name patterns against the channel model roster. * Only highlights inside text nodes (not inside code/pre/a tags). */ function _highlightMentions(html) { if (typeof ChannelModels === 'undefined') return html; const roster = ChannelModels.getRoster(); // Build patterns from roster handles and display names (AI mentions) const aiPatterns = []; const seen = new Set(); if (roster && roster.length >= 2) { for (const m of roster) { if (m.handle) { const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (!seen.has(escaped)) { aiPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); seen.add(escaped); } } if (m.display_name) { const hyphenated = m.display_name.replace(/\s+/g, '-'); const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (!seen.has(escaped.toLowerCase())) { const flexible = escaped.replace(/[-]+/g, '[\\s-]+'); aiPatterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); seen.add(escaped.toLowerCase()); } } } } // @all is special aiPatterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); // v0.23.2: Build user mention patterns const userPatterns = []; for (const u of (App.users || [])) { const escaped = u.username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (!seen.has(escaped.toLowerCase())) { userPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); seen.add(escaped.toLowerCase()); } } // Don't highlight inside ,
,  tags
    const parts = html.split(/(<[^>]+>)/);
    let inCode = false;
    for (let i = 0; i < parts.length; i++) {
        const part = parts[i];
        if (part.startsWith('<')) {
            const tag = part.toLowerCase();
            if (tag.startsWith('$1');
        }
        // User mentions (different color)
        for (const re of userPatterns) {
            text = text.replace(re, '$1');
        }
        parts[i] = text;
    }
    return parts.join('');
}

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) {
                    const popBtn = ``;
                    return `
${popBtn}${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 /', title: 'Clear preview', onClickName: 'clearPreview()', visible: _hasPreviewContent, }, ], saveState() { const frame = document.getElementById('previewFrame'); return { srcdoc: frame?.srcdoc || '', hasContent: frame?.style.display !== 'none', }; }, restoreState(state) { const frame = document.getElementById('previewFrame'); const empty = document.getElementById('previewEmpty'); if (frame && state.srcdoc) { frame.srcdoc = state.srcdoc; frame.style.display = state.hasContent ? '' : 'none'; if (empty) empty.style.display = state.hasContent ? 'none' : ''; } }, }); } function _hasPreviewContent() { const frame = document.getElementById('previewFrame'); return frame && frame.style.display !== 'none' && frame.srcdoc; } // ── Pop-out for Extension-Rendered Blocks ─── /** * Open an extension-rendered block (Mermaid, KaTeX, etc.) in the * preview panel at full size. */ function popOutExtBlock(blockId) { const block = document.querySelector(`[data-ext-block="${blockId}"]`); if (!block) return; // Clone rendered content (skip the pop-out button itself) const clone = block.cloneNode(true); const btn = clone.querySelector('.ext-popout-btn'); if (btn) btn.remove(); const lang = block.getAttribute('data-ext-lang') || ''; // Read current theme colors so the iframe respects light/dark mode const cs = getComputedStyle(document.documentElement); const bgColor = cs.getPropertyValue('--bg-surface').trim() || '#fff'; const textColor = cs.getPropertyValue('--text').trim() || '#1a1a2e'; // Wrap in a minimal HTML document for the iframe const html = ` ${clone.innerHTML}`; const frame = document.getElementById('previewFrame'); const empty = document.getElementById('previewEmpty'); if (frame) { frame.srcdoc = html; frame.style.display = ''; } if (empty) empty.style.display = 'none'; PanelRegistry.open('preview'); PanelRegistry.refreshActions(); } // ── Live Preview During Streaming ─────────── let _livePreviewTimer = null; /** * Called on each streaming delta with the accumulated raw markdown * content. If the preview panel is open, extracts the last completed * HTML code block and updates the iframe. Debounced at 500ms. */ function _livePreviewUpdate(rawContent) { // Only live-update if preview is the active panel if (!PanelRegistry.isOpen('preview')) return; clearTimeout(_livePreviewTimer); _livePreviewTimer = setTimeout(() => { const html = _extractLastHTMLBlock(rawContent); if (!html) return; const frame = document.getElementById('previewFrame'); const empty = document.getElementById('previewEmpty'); if (frame) { frame.srcdoc = html; frame.style.display = ''; } if (empty) empty.style.display = 'none'; PanelRegistry.refreshActions(); }, 500); } /** * Extract the last completed fenced HTML code block from raw markdown. * Returns the raw HTML content or null if none found. * * "Completed" means both opening ``` and closing ``` are present. */ function _extractLastHTMLBlock(text) { if (!text) return null; // Match all completed ```html ... ``` blocks (or ```htm, or untagged that look like HTML) const pattern = /```(?:html|htm)?\s*\n([\s\S]*?)```/gi; let lastMatch = null; let m; while ((m = pattern.exec(text)) !== null) { const blockContent = m[1]; const lang = m[0].match(/```(\w*)/)?.[1]?.toLowerCase() || ''; if (lang === 'html' || lang === 'htm' || _looksLikeHTML(blockContent)) { lastMatch = blockContent; } } return lastMatch; } // ── 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) { // v0.28.5: Render pipe subsumes extension post-renderers. // The pipe includes compat-shimmed renderers from ctx.renderers.register() // alongside new sw.pipe.render() filters. if (typeof sw !== 'undefined' && sw.pipe) { const renderCtx = { element: container, message: null, // batch render — no single message context channel: { id: typeof App !== 'undefined' ? App.activeId : null, type: typeof App !== 'undefined' ? (App.activeType || 'direct') : 'direct', }, }; sw.pipe._runRender(renderCtx); return; } // Fallback: SDK not loaded (shouldn't happen, but defensive) if (typeof Extensions !== 'undefined' && Extensions._loaded) { Extensions.runPostRenderers(container); } } // ── Wrappers for compound onclick expressions ── function _copyCodeBlock(codeId) { const el = document.getElementById(codeId); if (el) navigator.clipboard.writeText(el.textContent).then(() => UI.toast('Copied', 'success')); } function _openNoteFromTool(noteId) { // v0.37.9: old notes.js removed — navigate to notes surface instead if (window.sw?.notesPane) { console.log('[ui-format] Note tool link — navigate to /notes'); } } // ── Exports ───────────────────────────────── // Cross-file function calls sb.register('formatMessage', formatMessage); sb.register('clearPreview', clearPreview); sb.register('runExtensionPostRender', runExtensionPostRender); sb.register('_livePreviewUpdate', _livePreviewUpdate); sb.register('_relativeTime', _relativeTime); sb.register('_renderToolCallsHTML', _renderToolCallsHTML); sb.register('_registerPreviewPanel', _registerPreviewPanel); // data-action handlers sb.register('toggleCodeCollapse', toggleCodeCollapse); sb.register('toggleHTMLPreview', toggleHTMLPreview); sb.register('downloadCode', downloadCode); sb.register('popOutExtBlock', popOutExtBlock); sb.register('_copyCodeBlock', _copyCodeBlock); sb.register('_openNoteFromTool', _openNoteFromTool);