// ==========================================
// 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
\\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}`;
});
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') || '';
// 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);