// ==========================================
// Chat Switchboard – UI Formatting & Helpers
// ==========================================
// Pure utility layer: esc(), markdown rendering, code blocks,
// preview panel, time formatting. Loaded after panels.js.
// ── 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
\\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) {
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}`;
});
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()}`;
});
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') || '';
// 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) {
if (typeof Extensions !== 'undefined' && Extensions._loaded) {
Extensions.runPostRenderers(container);
}
}