Changeset 0.10.3 (#59)
This commit is contained in:
353
src/js/ui-format.js
Normal file
353
src/js/ui-format.js
Normal file
@@ -0,0 +1,353 @@
|
||||
// ==========================================
|
||||
// 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. "</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);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function _formatMarked(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] : '';
|
||||
|
||||
// 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" onclick="toggleCodeCollapse('${codeId}')" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
|
||||
: '';
|
||||
|
||||
let toolbar = collapseBtn;
|
||||
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
||||
toolbar += `<button class="copy-code-btn" onclick="downloadCode('${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" onclick="toggleHTMLPreview('${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" onclick="toggleCodeCollapse('${id}')" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
|
||||
: '';
|
||||
|
||||
let toolbar = collapseBtn;
|
||||
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
||||
toolbar += `<button class="copy-code-btn" onclick="downloadCode('${id}','${lang}')">Download</button>`;
|
||||
|
||||
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
||||
toolbar += `<button class="copy-code-btn preview-code-btn" onclick="toggleHTMLPreview('${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;
|
||||
}
|
||||
|
||||
// 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';
|
||||
|
||||
// Show clear button
|
||||
const clearBtn = document.getElementById('sidePanelClearBtn');
|
||||
if (clearBtn) clearBtn.style.display = '';
|
||||
|
||||
openSidePanel('preview');
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
const clearBtn = document.getElementById('sidePanelClearBtn');
|
||||
if (frame) { frame.srcdoc = ''; frame.style.display = 'none'; }
|
||||
if (empty) empty.style.display = '';
|
||||
if (clearBtn) clearBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// ── Side Panel ──────────────────────────────
|
||||
|
||||
function openSidePanel(tab) {
|
||||
const panel = document.getElementById('sidePanel');
|
||||
panel.classList.add('open');
|
||||
if (tab) switchSidePanelTab(tab);
|
||||
}
|
||||
|
||||
function closeSidePanel() {
|
||||
const panel = document.getElementById('sidePanel');
|
||||
panel.classList.remove('open', 'fullscreen');
|
||||
panel.style.width = ''; panel.style.minWidth = '';
|
||||
}
|
||||
|
||||
function switchSidePanelTab(tab) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.side-panel-tab').forEach(btn => {
|
||||
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
|
||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'
|
||||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 = `<button class="tool-note-link" onclick="openNotes(); setTimeout(() => openNoteEditor('${esc(parsed.id)}'), 300)">📝 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' });
|
||||
}
|
||||
Reference in New Issue
Block a user