629 lines
26 KiB
JavaScript
629 lines
26 KiB
JavaScript
// ==========================================
|
||
// 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. "</think>```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, '<br>');
|
||
const openAttr = App.settings.showThinking ? ' open' : '';
|
||
const thinkHTML = `<details class="thinking-block"${openAttr}><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`;
|
||
html = html.replace(new RegExp(`<p>\\s*THINK_PLACEHOLDER_${b.id}\\s*</p>`, '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 <code>, <pre>, <a> 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('<code') || tag.startsWith('<pre') || tag.startsWith('<a ')) inCode = true;
|
||
if (tag.startsWith('</code') || tag.startsWith('</pre') || tag.startsWith('</a')) inCode = false;
|
||
continue;
|
||
}
|
||
if (inCode) continue;
|
||
let text = part;
|
||
// AI mentions first (persona/model)
|
||
for (const re of aiPatterns) {
|
||
text = text.replace(re, '<span class="mention-pill">$1</span>');
|
||
}
|
||
// User mentions (different color)
|
||
for (const re of userPatterns) {
|
||
text = text.replace(re, '<span class="mention-pill mention-user">$1</span>');
|
||
}
|
||
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 <p>)
|
||
'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(/<pre><code([^>]*)>([\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 = `<button class="ext-popout-btn" data-action="popOutExtBlock" data-args='${JSON.stringify([codeId])}'" title="Open in side panel">⧉</button>`;
|
||
return `<div class="ext-rendered" data-ext-block="${codeId}" data-ext-lang="${lang}">${popBtn}${fakeContainer.innerHTML}</div>`;
|
||
}
|
||
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
|
||
? `<button class="code-collapse-btn" data-action="toggleCodeCollapse" data-args='${JSON.stringify([codeId])}'" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
|
||
: '';
|
||
|
||
let toolbar = collapseBtn;
|
||
toolbar += `<button class="copy-code-btn" data-action="_copyCodeBlock" data-args='${JSON.stringify([codeId])}'">Copy</button>`;
|
||
toolbar += `<button class="copy-code-btn" data-action="downloadCode" data-args='${JSON.stringify([codeId, lang])}'">Download</button>`;
|
||
|
||
// HTML preview button (only for HTML-like content)
|
||
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
||
toolbar += `<button class="copy-code-btn preview-code-btn" data-action="toggleHTMLPreview" data-args='${JSON.stringify([codeId])}'">Preview</button>`;
|
||
}
|
||
|
||
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
|
||
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
|
||
return `<pre class="code-block${collapsedClass}" id="block-${codeId}">${langLabel}<code id="${codeId}"${attrs}>${code}</code><div class="code-toolbar">${toolbar}</div></pre>`;
|
||
});
|
||
|
||
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
|
||
? `<button class="code-collapse-btn" data-action="toggleCodeCollapse" data-args='${JSON.stringify([id])}'" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
|
||
: '';
|
||
|
||
let toolbar = collapseBtn;
|
||
toolbar += `<button class="copy-code-btn" data-action="_copyCodeBlock" data-args='${JSON.stringify([id])}'">Copy</button>`;
|
||
toolbar += `<button class="copy-code-btn" data-action="downloadCode" data-args='${JSON.stringify([id, lang])}'">Download</button>`;
|
||
|
||
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
||
toolbar += `<button class="copy-code-btn preview-code-btn" data-action="toggleHTMLPreview" data-args='${JSON.stringify([id])}'">Preview</button>`;
|
||
}
|
||
|
||
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
|
||
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
|
||
return `<pre class="code-block${collapsedClass}" id="block-${id}">${langLabel}<code id="${id}" class="language-${lang}">${code.trim()}</code><div class="code-toolbar">${toolbar}</div></pre>`;
|
||
});
|
||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||
html = html.replace(/\n/g, '<br>');
|
||
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 /<!DOCTYPE|<html|<head|<body|<div|<span|<style|<script/i.test(decoded);
|
||
}
|
||
|
||
// ── Code Block Actions ──────────────────────
|
||
|
||
function toggleCodeCollapse(codeId) {
|
||
const pre = document.getElementById('block-' + codeId);
|
||
if (!pre) return;
|
||
const isCollapsed = pre.classList.toggle('code-collapsed');
|
||
const btn = pre.querySelector('.code-collapse-btn');
|
||
if (btn) {
|
||
const lines = btn.textContent.replace(/[▸▾]\s*/, '');
|
||
btn.textContent = (isCollapsed ? '▸ ' : '▾ ') + lines;
|
||
btn.title = isCollapsed ? 'Expand' : 'Collapse';
|
||
}
|
||
}
|
||
|
||
function toggleHTMLPreview(codeId) {
|
||
const codeEl = document.getElementById(codeId);
|
||
if (!codeEl) return;
|
||
|
||
const rawHTML = codeEl.textContent;
|
||
const frame = document.getElementById('previewFrame');
|
||
const empty = document.getElementById('previewEmpty');
|
||
|
||
frame.srcdoc = rawHTML;
|
||
frame.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
|
||
PanelRegistry.open('preview');
|
||
PanelRegistry.refreshActions();
|
||
}
|
||
|
||
function clearPreview() {
|
||
const frame = document.getElementById('previewFrame');
|
||
const empty = document.getElementById('previewEmpty');
|
||
if (frame) { frame.srcdoc = ''; frame.style.display = 'none'; }
|
||
if (empty) empty.style.display = '';
|
||
PanelRegistry.refreshActions();
|
||
}
|
||
|
||
function downloadCode(codeId, lang) {
|
||
const el = document.getElementById(codeId);
|
||
if (!el) return;
|
||
const extMap = {
|
||
javascript: 'js', typescript: 'ts', python: 'py', ruby: 'rb',
|
||
golang: 'go', go: 'go', rust: 'rs', java: 'java', cpp: 'cpp',
|
||
c: 'c', csharp: 'cs', css: 'css', html: 'html', htm: 'html',
|
||
json: 'json', yaml: 'yaml', yml: 'yml', xml: 'xml', sql: 'sql',
|
||
bash: 'sh', shell: 'sh', sh: 'sh', markdown: 'md', md: 'md',
|
||
toml: 'toml', dockerfile: 'Dockerfile', makefile: 'Makefile',
|
||
perl: 'pl', lua: 'lua', swift: 'swift', kotlin: 'kt', php: 'php',
|
||
};
|
||
const ext = extMap[lang?.toLowerCase()] || lang || 'txt';
|
||
const filename = `code.${ext}`;
|
||
const blob = new Blob([el.textContent], { type: 'text/plain' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = filename;
|
||
document.body.appendChild(a); a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
UI.toast('Downloaded ' + filename, 'success');
|
||
}
|
||
|
||
// ── Preview Panel Registration ──────────────
|
||
|
||
function _registerPreviewPanel() {
|
||
const el = document.getElementById('sidePanelPreview');
|
||
if (!el) return;
|
||
|
||
PanelRegistry.register('preview', {
|
||
element: el,
|
||
label: 'Preview',
|
||
actions: [
|
||
{
|
||
id: 'sidePanelClearBtn',
|
||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>',
|
||
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 = `<!DOCTYPE html>
|
||
<html><head>
|
||
<meta charset="utf-8">
|
||
<style>
|
||
body { margin: 0; padding: 16px; display: flex; justify-content: center;
|
||
align-items: flex-start; min-height: 100vh; background: ${bgColor}; color: ${textColor};
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||
body > * { max-width: 100%; }
|
||
svg { max-width: 100%; height: auto; }
|
||
</style>
|
||
</head><body>${clone.innerHTML}</body></html>`;
|
||
|
||
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 = `<button class="tool-note-link" data-action="_openNoteFromTool" data-args='${JSON.stringify([esc(parsed.id)])}'">📝 View</button>`;
|
||
}
|
||
} 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 = `<div class="tool-detail">`;
|
||
if (argsStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Input</span><pre class="tool-detail-pre">${esc(argsStr)}</pre></div>`;
|
||
if (resultStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Output</span><pre class="tool-detail-pre">${esc(resultStr)}</pre></div>`;
|
||
detailHTML += `</div>`;
|
||
}
|
||
|
||
return `<details class="tool-call-block">
|
||
<summary class="tool-call-summary">
|
||
<span class="tool-icon">🔧</span>
|
||
<span class="tool-name">${esc(name)}</span>
|
||
<span class="tool-status ${statusCls}">${statusText}</span>
|
||
${noteLink}
|
||
</summary>
|
||
${detailHTML}
|
||
</details>`;
|
||
});
|
||
|
||
return `<div class="msg-tools">${items.join('')}</div>`;
|
||
}
|
||
|
||
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);
|