Feat v0.2.9 builtin extension retirement
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Failing after 2m26s
CI/CD / test-sqlite (pull_request) Successful in 2m41s
CI/CD / build-and-deploy (pull_request) Has been skipped

Stop auto-seeding 6 chat-centric extensions on startup. Move them to
standard packages with js/ layout and "requires": ["chat"] metadata.
Delete SeedBuiltinPackages, seed tests, Dockerfile COPY, and
extensions/builtin/ directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 16:45:45 +00:00
parent babfe66721
commit c65674965f
21 changed files with 42 additions and 439 deletions

View File

@@ -0,0 +1,254 @@
// ==========================================
// CSV Table Viewer — Browser Extension
// ==========================================
// Renders ```csv and ```tsv code blocks as sortable HTML tables.
// No external dependencies.
// ==========================================
Extensions.register({
id: 'csv-table',
async init(ctx) {
const self = this;
// ── Inject styles ──
this._injectStyles();
ctx.renderers.register('csv-block', {
type: 'block',
priority: 10,
match(lang) {
const l = (lang || '').toLowerCase();
return l === 'csv' || l === 'tsv';
},
render(lang, code, container) {
const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ',';
const rows = self._parseCSV(code.trim(), delimiter);
if (rows.length === 0) {
container.innerHTML = '<div class="csv-empty">No data</div>';
return;
}
const headers = rows[0];
const data = rows.slice(1);
const tableId = 'csv-' + Math.random().toString(36).slice(2, 9);
container.innerHTML = `
<div class="csv-table-block ext-rendered" data-csv-id="${tableId}">
<div class="csv-toolbar">
<span class="csv-info">${data.length} row${data.length !== 1 ? 's' : ''} × ${headers.length} col${headers.length !== 1 ? 's' : ''}</span>
<button class="csv-copy-btn" title="Copy as CSV">📋 Copy</button>
</div>
<div class="csv-table-scroll">
<table class="csv-table">
<thead>
<tr>${headers.map((h, i) => `<th data-col="${i}" title="Click to sort">${self._escapeHtml(h)}<span class="csv-sort-icon"></span></th>`).join('')}</tr>
</thead>
<tbody>
${data.map(row => `<tr>${row.map(cell => `<td>${self._escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}
</tbody>
</table>
</div>
<details class="csv-source">
<summary>📄 View raw</summary>
<pre><code>${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
// Attach sort handlers + copy
self._attachHandlers(container, tableId, code.trim());
}
});
},
_attachHandlers(container, tableId, rawCSV) {
const block = container.querySelector(`[data-csv-id="${tableId}"]`);
if (!block) return;
// ── Column sorting ──
const ths = block.querySelectorAll('th[data-col]');
let sortCol = -1;
let sortAsc = true;
ths.forEach(th => {
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
const col = parseInt(th.dataset.col, 10);
if (sortCol === col) {
sortAsc = !sortAsc;
} else {
sortCol = col;
sortAsc = true;
}
const tbody = block.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const cellA = (a.children[col]?.textContent || '').trim();
const cellB = (b.children[col]?.textContent || '').trim();
const numA = parseFloat(cellA);
const numB = parseFloat(cellB);
// Numeric sort if both are numbers
if (!isNaN(numA) && !isNaN(numB)) {
return sortAsc ? numA - numB : numB - numA;
}
// String sort
const cmp = cellA.localeCompare(cellB, undefined, { numeric: true, sensitivity: 'base' });
return sortAsc ? cmp : -cmp;
});
rows.forEach(row => tbody.appendChild(row));
// Update sort icons
ths.forEach(h => {
const icon = h.querySelector('.csv-sort-icon');
if (icon) {
const hCol = parseInt(h.dataset.col, 10);
icon.textContent = hCol === sortCol ? (sortAsc ? ' ▲' : ' ▼') : '';
}
});
});
});
// ── Copy button ──
const copyBtn = block.querySelector('.csv-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(rawCSV).then(() => {
copyBtn.textContent = '✓ Copied';
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
}).catch(() => {
copyBtn.textContent = '✗ Failed';
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
});
});
}
},
/**
* Simple CSV parser that handles quoted fields.
*/
_parseCSV(text, delimiter) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
let i = 0;
while (i < text.length) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
// Peek ahead: escaped quote or end of quoted field
if (i + 1 < text.length && text[i + 1] === '"') {
field += '"';
i += 2;
} else {
inQuotes = false;
i++;
}
} else {
field += ch;
i++;
}
} else {
if (ch === '"' && field === '') {
inQuotes = true;
i++;
} else if (ch === delimiter) {
row.push(field.trim());
field = '';
i++;
} else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) {
row.push(field.trim());
if (row.some(cell => cell !== '')) rows.push(row);
row = [];
field = '';
i += (ch === '\r') ? 2 : 1;
} else {
field += ch;
i++;
}
}
}
// Last field/row
row.push(field.trim());
if (row.some(cell => cell !== '')) rows.push(row);
// Normalize: pad short rows to header length
if (rows.length > 0) {
const maxCols = Math.max(...rows.map(r => r.length));
rows.forEach(r => {
while (r.length < maxCols) r.push('');
});
}
return rows;
},
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
_injectStyles() {
if (document.getElementById('ext-style-csv-table')) return;
const style = document.createElement('style');
style.id = 'ext-style-csv-table';
style.textContent = `
.csv-table-block {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; margin: 12px 0;
}
.csv-toolbar {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; border-bottom: 1px solid var(--border);
font-size: 12px; color: var(--text-3);
}
.csv-info { flex: 1; }
.csv-copy-btn {
background: none; border: 1px solid var(--border); border-radius: 4px;
padding: 2px 8px; font-size: 12px; cursor: pointer; color: var(--text-3);
}
.csv-copy-btn:hover { color: var(--text-2); border-color: var(--text-3); }
.csv-table-scroll { overflow-x: auto; }
.csv-table {
width: 100%; border-collapse: collapse; font-size: 13px;
font-family: var(--mono);
}
.csv-table th {
background: var(--bg-3); color: var(--text-2); font-weight: 600;
padding: 6px 12px; text-align: left; white-space: nowrap;
border-bottom: 2px solid var(--border); user-select: none;
}
.csv-table th:hover { color: var(--text-1); }
.csv-sort-icon { font-size: 11px; opacity: 0.7; }
.csv-table td {
padding: 4px 12px; border-bottom: 1px solid var(--border);
white-space: nowrap; max-width: 300px; overflow: hidden; text-overflow: ellipsis;
}
.csv-table tr:hover td { background: var(--bg-3); }
.csv-source, .csv-empty { border-top: 1px solid var(--border); font-size: 12px; }
.csv-source summary {
padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none;
}
.csv-source summary:hover { color: var(--text-2); }
.csv-source pre {
margin: 0; border-radius: 0; border: none;
max-height: 200px; overflow: auto;
}
.csv-empty { padding: 16px; text-align: center; color: var(--text-3); }`;
document.head.appendChild(style);
},
destroy() {
document.getElementById('ext-style-csv-table')?.remove();
}
});

View File

@@ -0,0 +1,14 @@
{
"id": "csv-table",
"name": "CSV Table Viewer",
"version": "1.0.0",
"type": "extension",
"tier": "browser",
"author": "switchboard",
"description": "Renders ```csv code blocks as sortable, interactive HTML tables",
"requires": ["chat"],
"permissions": [],
"tools": [],
"surfaces": [],
"settings": {}
}

View File

@@ -0,0 +1,166 @@
// ==========================================
// Diff Viewer — Browser Extension
// ==========================================
// Renders ```diff code blocks with syntax-highlighted
// additions, deletions, hunk headers, and context lines.
// No external dependencies.
// ==========================================
Extensions.register({
id: 'diff-viewer',
async init(ctx) {
const self = this;
// ── Inject styles ──
this._injectStyles();
ctx.renderers.register('diff-block', {
type: 'block',
priority: 10,
pattern: 'diff',
render(lang, code, container) {
const lines = code.split('\n');
let stats = { added: 0, removed: 0 };
let currentFile = '';
const rendered = lines.map(line => {
const escaped = self._escapeHtml(line);
// File headers
if (line.startsWith('--- ') || line.startsWith('+++ ')) {
if (line.startsWith('+++ ')) {
currentFile = line.slice(4).trim();
}
return `<div class="diff-line diff-file-header">${escaped}</div>`;
}
// Hunk headers
if (line.startsWith('@@')) {
const hunkMatch = line.match(/^@@\s*-(\d+)(?:,\d+)?\s*\+(\d+)(?:,\d+)?\s*@@\s*(.*)/);
const context = hunkMatch ? hunkMatch[3] : '';
return `<div class="diff-line diff-hunk">${escaped}</div>`;
}
// Additions
if (line.startsWith('+')) {
stats.added++;
return `<div class="diff-line diff-add"><span class="diff-indicator">+</span>${self._escapeHtml(line.slice(1))}</div>`;
}
// Deletions
if (line.startsWith('-')) {
stats.removed++;
return `<div class="diff-line diff-del"><span class="diff-indicator">-</span>${self._escapeHtml(line.slice(1))}</div>`;
}
// Context (lines starting with space or no prefix)
if (line.startsWith(' ')) {
return `<div class="diff-line diff-ctx"><span class="diff-indicator"> </span>${self._escapeHtml(line.slice(1))}</div>`;
}
// Other (commit info, etc)
return `<div class="diff-line diff-meta">${escaped}</div>`;
}).join('');
const fileLabel = currentFile ? `<span class="diff-filename" title="${self._escapeHtml(currentFile)}">${self._escapeHtml(currentFile)}</span>` : '';
container.innerHTML = `
<div class="diff-block ext-rendered">
<div class="diff-toolbar">
${fileLabel}
<span class="diff-stats">
<span class="diff-stat-add">+${stats.added}</span>
<span class="diff-stat-del">-${stats.removed}</span>
</span>
<button class="diff-copy-btn" title="Copy diff">📋 Copy</button>
</div>
<div class="diff-content">${rendered}</div>
<details class="diff-source-toggle">
<summary>📝 View raw</summary>
<pre><code>${self._escapeHtml(code)}</code></pre>
</details>
</div>
`;
// Copy handler
const copyBtn = container.querySelector('.diff-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(code).then(() => {
copyBtn.textContent = '✓ Copied';
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
}).catch(() => {});
});
}
}
});
},
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
_injectStyles() {
if (document.getElementById('ext-style-diff-viewer')) return;
const style = document.createElement('style');
style.id = 'ext-style-diff-viewer';
style.textContent = `
.diff-block {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; margin: 12px 0;
}
.diff-toolbar {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; border-bottom: 1px solid var(--border);
font-size: 12px; color: var(--text-3);
}
.diff-filename {
flex: 1; font-family: var(--mono); font-weight: 600;
color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.diff-stats { display: flex; gap: 8px; font-family: var(--mono); font-weight: 600; }
.diff-stat-add { color: var(--success, #27ae60); }
.diff-stat-del { color: var(--error, #e74c3c); }
.diff-copy-btn {
background: none; border: 1px solid var(--border); border-radius: 4px;
padding: 2px 8px; font-size: 12px; cursor: pointer; color: var(--text-3);
}
.diff-copy-btn:hover { color: var(--text-2); border-color: var(--text-3); }
.diff-content {
font-family: var(--mono); font-size: 13px; line-height: 1.5;
overflow-x: auto;
}
.diff-line { padding: 1px 12px; white-space: pre; min-height: 1.5em; }
.diff-indicator {
display: inline-block; width: 1.2em; text-align: center;
opacity: 0.7; user-select: none;
}
.diff-add { background: rgba(39,174,96,0.12); color: var(--success, #27ae60); }
.diff-del { background: rgba(231,76,60,0.12); color: var(--error, #e74c3c); }
.diff-hunk {
background: rgba(52,152,219,0.08); color: var(--info, #3498db);
font-style: italic; border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border); margin: 2px 0;
}
.diff-file-header { background: var(--bg-3); color: var(--text-2); font-weight: 600; }
.diff-ctx { color: var(--text-3); }
.diff-meta { color: var(--text-3); font-style: italic; }
.diff-source-toggle { border-top: 1px solid var(--border); font-size: 12px; }
.diff-source-toggle summary {
padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none;
}
.diff-source-toggle summary:hover { color: var(--text-2); }
.diff-source-toggle pre {
margin: 0; border-radius: 0; border: none;
max-height: 200px; overflow: auto;
}`;
document.head.appendChild(style);
},
destroy() {
document.getElementById('ext-style-diff-viewer')?.remove();
}
});

View File

@@ -0,0 +1,14 @@
{
"id": "diff-viewer",
"name": "Diff Viewer",
"version": "1.0.0",
"type": "extension",
"tier": "browser",
"author": "switchboard",
"description": "Renders ```diff code blocks with syntax-highlighted additions, deletions, and context lines",
"requires": ["chat"],
"permissions": [],
"tools": [],
"surfaces": [],
"settings": {}
}

View File

@@ -0,0 +1,202 @@
// ==========================================
// JavaScript Sandbox — Browser Extension
// ==========================================
// Executes JavaScript in a sandboxed iframe.
// Registers the js_eval tool so the LLM can run code,
// verify calculations, and transform data.
//
// Security: The iframe has sandbox="allow-scripts" only.
// No allow-same-origin, no network, no DOM access.
// ==========================================
Extensions.register({
id: 'js-sandbox',
_iframe: null,
_pendingCallbacks: new Map(), // messageId → { resolve, reject, timer }
_messageCounter: 0,
async init(ctx) {
const self = this;
// Create the sandboxed iframe on first use (lazy)
ctx.tools.handle('js_eval', async (args) => {
return self._execute(args.code || '');
});
},
async _execute(code) {
if (!code.trim()) {
return { error: 'No code provided' };
}
// Ensure iframe is ready
await this._ensureIframe();
const messageId = 'eval-' + (++this._messageCounter);
const TIMEOUT_MS = 10000;
return new Promise((resolve) => {
// Set up timeout
const timer = setTimeout(() => {
this._pendingCallbacks.delete(messageId);
resolve({
success: false,
error: 'Execution timed out (10s)',
output: [],
result: null,
});
}, TIMEOUT_MS);
this._pendingCallbacks.set(messageId, { resolve, timer });
// Send code to sandbox
this._iframe.contentWindow.postMessage({
type: 'eval',
id: messageId,
code: code,
}, '*');
});
},
_ensureIframe() {
if (this._iframe) return Promise.resolve();
return new Promise((resolve) => {
// Build sandbox HTML as a blob URL
const sandboxHTML = this._buildSandboxHTML();
const blob = new Blob([sandboxHTML], { type: 'text/html' });
const blobURL = URL.createObjectURL(blob);
const iframe = document.createElement('iframe');
iframe.sandbox = 'allow-scripts';
iframe.style.cssText = 'display:none;width:0;height:0;border:none;position:absolute;';
iframe.src = blobURL;
iframe.onload = () => {
this._iframe = iframe;
URL.revokeObjectURL(blobURL);
resolve();
};
// Listen for messages from sandbox
window.addEventListener('message', (event) => {
// Only accept messages from our sandbox iframe
if (!this._iframe || event.source !== this._iframe.contentWindow) return;
const data = event.data;
if (data?.type !== 'eval-result') return;
const pending = this._pendingCallbacks.get(data.id);
if (!pending) return;
clearTimeout(pending.timer);
this._pendingCallbacks.delete(data.id);
pending.resolve({
success: !data.error,
result: data.result !== undefined ? data.result : null,
output: data.output || [],
error: data.error || null,
});
});
document.body.appendChild(iframe);
});
},
_buildSandboxHTML() {
// This HTML runs INSIDE the sandboxed iframe.
// It has no access to the parent page, no network, no cookies.
return `<!DOCTYPE html>
<html><head><title>Sandbox</title></head>
<body><script>
(function() {
'use strict';
// Listen for eval messages from parent
window.addEventListener('message', function(event) {
var msg = event.data;
if (!msg || msg.type !== 'eval') return;
var output = [];
var result = undefined;
var error = null;
// Override console methods to capture output
var origLog = console.log;
var origWarn = console.warn;
var origError = console.error;
console.log = function() {
var args = Array.prototype.slice.call(arguments);
output.push({ level: 'log', text: args.map(stringify).join(' ') });
};
console.warn = function() {
var args = Array.prototype.slice.call(arguments);
output.push({ level: 'warn', text: args.map(stringify).join(' ') });
};
console.error = function() {
var args = Array.prototype.slice.call(arguments);
output.push({ level: 'error', text: args.map(stringify).join(' ') });
};
try {
// Use indirect eval for global scope
result = (0, eval)(msg.code);
} catch (e) {
error = (e && e.message) ? e.message : String(e);
}
// Restore console
console.log = origLog;
console.warn = origWarn;
console.error = origError;
// Stringify result for safe transfer
var resultStr;
try {
resultStr = stringify(result);
} catch (e2) {
resultStr = '[unserializable]';
}
// Post result back to parent
event.source.postMessage({
type: 'eval-result',
id: msg.id,
result: resultStr,
output: output,
error: error,
}, '*');
});
function stringify(val) {
if (val === undefined) return 'undefined';
if (val === null) return 'null';
if (typeof val === 'function') return val.toString();
if (typeof val === 'object') {
try { return JSON.stringify(val, null, 2); }
catch(e) { return String(val); }
}
return String(val);
}
})();
</` + `script></body></html>`;
},
destroy() {
// Clean up pending callbacks
for (const [id, pending] of this._pendingCallbacks) {
clearTimeout(pending.timer);
pending.resolve({ success: false, error: 'Extension destroyed', output: [], result: null });
}
this._pendingCallbacks.clear();
// Remove iframe
if (this._iframe) {
this._iframe.remove();
this._iframe = null;
}
}
});

View File

@@ -0,0 +1,30 @@
{
"id": "js-sandbox",
"name": "JavaScript Sandbox",
"version": "1.0.0",
"type": "extension",
"tier": "browser",
"author": "switchboard",
"description": "Executes JavaScript in a sandboxed iframe. Allows the LLM to run code, verify calculations, and transform data — no server compute required.",
"requires": ["chat"],
"permissions": [],
"tools": [
{
"name": "js_eval",
"display_name": "JavaScript Eval",
"description": "Execute JavaScript code in a secure browser sandbox. Use this to: verify calculations, run algorithms, transform data, test code snippets, parse/format strings, or any computation. Console output (log/warn/error) is captured. The last expression's value is returned as the result. Has no network access, no DOM access, no access to the parent page. Timeout: 10 seconds.",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "JavaScript code to execute. Use console.log() for output. The value of the last expression is returned automatically."
}
},
"required": ["code"]
}
}
],
"surfaces": [],
"settings": {}
}

View File

@@ -0,0 +1,297 @@
// ==========================================
// KaTeX Math Renderer — Browser Extension
// ==========================================
// Renders ```latex / ```math code blocks and inline $...$ / $$...$$ syntax.
// Loads KaTeX dynamically on first use.
// ==========================================
Extensions.register({
id: 'katex-renderer',
_katexReady: false,
_katexLoading: null,
async init(ctx) {
const self = this;
// ── Inject styles ──
this._injectStyles();
// ── Block renderer: match ```latex or ```math ──
ctx.renderers.register('katex-block', {
type: 'block',
priority: 10,
match(lang) {
const l = (lang || '').toLowerCase();
return l === 'latex' || l === 'math' || l === 'tex' || l === 'katex';
},
render(lang, code, container) {
const id = 'katex-' + Math.random().toString(36).slice(2, 9);
container.innerHTML = `
<div class="katex-block ext-rendered" data-katex-id="${id}">
<div class="katex-display-container" data-katex-src="${encodeURIComponent(code.trim())}" data-katex-display="true">
<div class="katex-loading">
<span class="katex-spinner"></span> Rendering math…
</div>
</div>
<details class="katex-source">
<summary>𝑓(𝑥) View source</summary>
<pre><code class="language-latex">${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
}
});
// ── Post renderer: render KaTeX blocks + find inline math ──
ctx.renderers.register('katex-post', {
type: 'post',
priority: 10,
render(container) {
// Render code blocks with data-katex-src
const blocks = container.querySelectorAll('.katex-display-container[data-katex-src]');
if (blocks.length > 0) {
blocks.forEach(el => {
if (el.dataset.rendered) return;
el.dataset.rendered = 'pending';
self._renderBlock(el);
});
}
// Process inline math in text content
self._processInlineMath(container);
}
});
// Pre-load KaTeX
this._loadKaTeX();
},
async _renderBlock(el) {
const code = decodeURIComponent(el.dataset.katexSrc);
const displayMode = el.dataset.katexDisplay === 'true';
try {
await this._loadKaTeX();
el.innerHTML = katex.renderToString(code, {
displayMode,
throwOnError: false,
trust: false,
strict: false,
});
el.dataset.rendered = 'true';
} catch (e) {
el.innerHTML = `
<div class="katex-error">
<strong>Math error:</strong> ${this._escapeHtml(e.message || String(e))}
</div>
`;
el.dataset.rendered = 'error';
}
},
async _processInlineMath(container) {
// Skip if no $ signs present at all
if (!container.textContent || !container.textContent.includes('$')) return;
try {
await this._loadKaTeX();
} catch {
return;
}
// Walk text nodes, skip code/pre/katex-already-rendered
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
// Skip code, pre, already-rendered katex, and script elements
const tag = parent.tagName;
if (tag === 'CODE' || tag === 'PRE' || tag === 'SCRIPT' ||
tag === 'TEXTAREA' || tag === 'STYLE') {
return NodeFilter.FILTER_REJECT;
}
if (parent.closest('.katex, .katex-block, .katex-display-container, code, pre')) {
return NodeFilter.FILTER_REJECT;
}
if (!node.textContent.includes('$')) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
}
);
const textNodes = [];
let node;
while (node = walker.nextNode()) textNodes.push(node);
for (const textNode of textNodes) {
this._replaceInlineMath(textNode);
}
},
_replaceInlineMath(textNode) {
const text = textNode.textContent;
// Match $$...$$ (display) and $...$ (inline), non-greedy
// Avoid matching escaped \$ or empty $$
const pattern = /\$\$([^$]+?)\$\$|\$([^$\n]+?)\$/g;
let match;
const parts = [];
let lastIndex = 0;
while ((match = pattern.exec(text)) !== null) {
// Text before the match
if (match.index > lastIndex) {
parts.push({ type: 'text', value: text.slice(lastIndex, match.index) });
}
const displayMode = !!match[1]; // $$ ... $$
const expr = match[1] || match[2];
try {
const html = katex.renderToString(expr.trim(), {
displayMode,
throwOnError: false,
trust: false,
strict: false,
});
parts.push({ type: 'katex', value: html, displayMode });
} catch {
// Leave as-is on error
parts.push({ type: 'text', value: match[0] });
}
lastIndex = match.index + match[0].length;
}
if (parts.length === 0) return; // No math found
// Remaining text after last match
if (lastIndex < text.length) {
parts.push({ type: 'text', value: text.slice(lastIndex) });
}
// Replace text node with a span containing the rendered parts
const span = document.createElement('span');
span.className = 'katex-inline-container';
for (const part of parts) {
if (part.type === 'text') {
span.appendChild(document.createTextNode(part.value));
} else {
const wrapper = document.createElement('span');
wrapper.className = part.displayMode ? 'katex-display' : 'katex-inline';
wrapper.innerHTML = part.value;
span.appendChild(wrapper);
}
}
textNode.parentNode.replaceChild(span, textNode);
},
_loadKaTeX() {
if (this._katexReady) return Promise.resolve();
if (this._katexLoading) return this._katexLoading;
this._katexLoading = new Promise((resolve, reject) => {
if (typeof katex !== 'undefined') {
this._katexReady = true;
resolve();
return;
}
const base = (window.__BASE__ || '');
const localCSS = `${base}/vendor/katex/katex.min.css`;
const localJS = `${base}/vendor/katex/katex.min.js`;
const cdnCSS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css';
const cdnJS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js';
// Load CSS first
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = localCSS;
link.onerror = () => {
link.href = cdnCSS;
};
document.head.appendChild(link);
// Load JS
const script = document.createElement('script');
script.src = localJS;
script.onload = () => {
this._katexReady = true;
resolve();
};
script.onerror = () => {
console.warn('[KaTeX] Local vendor not found, trying CDN');
const cdn = document.createElement('script');
cdn.src = cdnJS;
cdn.onload = () => {
this._katexReady = true;
resolve();
};
cdn.onerror = () => {
console.error('[KaTeX] Failed to load from both local and CDN');
reject(new Error('Failed to load KaTeX'));
};
document.head.appendChild(cdn);
};
document.head.appendChild(script);
});
return this._katexLoading;
},
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
_injectStyles() {
if (document.getElementById('ext-style-katex-renderer')) return;
const style = document.createElement('style');
style.id = 'ext-style-katex-renderer';
style.textContent = `
.katex-block {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; margin: 12px 0;
}
.katex-display-container {
padding: 16px; text-align: center; min-height: 40px;
}
.katex-loading {
color: var(--text-3); font-size: 13px; padding: 20px;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.katex-spinner {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: katex-spin 0.8s linear infinite;
}
@keyframes katex-spin { to { transform: rotate(360deg); } }
.katex-error {
color: var(--error, #e74c3c); background: var(--bg-3, rgba(231,76,60,0.1));
padding: 12px 16px; border-radius: 4px; font-size: 13px;
font-family: var(--mono); white-space: pre-wrap;
}
.katex-source { border-top: 1px solid var(--border); font-size: 12px; }
.katex-source summary {
padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none;
}
.katex-source summary:hover { color: var(--text-2); }
.katex-source pre {
margin: 0; border-radius: 0; border: none;
max-height: 200px; overflow: auto;
}
.katex-inline-container { display: inline; }
.katex-inline .katex { font-size: 1.05em; }
.katex-display { display: block; text-align: center; margin: 8px 0; }`;
document.head.appendChild(style);
},
destroy() {
document.getElementById('ext-style-katex-renderer')?.remove();
}
});

View File

@@ -0,0 +1,14 @@
{
"id": "katex-renderer",
"name": "KaTeX Math",
"version": "1.0.0",
"type": "extension",
"tier": "browser",
"author": "switchboard",
"description": "Renders LaTeX math expressions: ```latex blocks and inline $...$ / $$...$$ syntax",
"requires": ["chat"],
"permissions": [],
"tools": [],
"surfaces": [],
"settings": {}
}

View File

@@ -0,0 +1,779 @@
// ==========================================
// Mermaid Diagram Renderer — Browser Extension
// ==========================================
// Renders ```mermaid code blocks as interactive SVG diagrams.
// Features: viewBox-based zoom/pan, context-aware expand (fullscreen
// or side-panel pop-out), SVG/PNG export, source copy.
// Loads mermaid.js dynamically on first use.
//
// Uses ctx.ui primitives from the host app for toast notifications,
// side-panel preview, theme detection, and mobile awareness.
// ==========================================
Extensions.register({
id: 'mermaid-renderer',
_mermaidReady: false,
_mermaidLoading: null,
_ctx: null,
async init(ctx) {
const self = this;
this._ctx = ctx;
this._injectStyles();
// ── Block renderer: match ```mermaid ──
ctx.renderers.register('mermaid', {
type: 'block',
pattern: 'mermaid',
priority: 10,
render(lang, code, container) {
const id = 'mmd-' + Math.random().toString(36).slice(2, 9);
container.innerHTML = `
<div class="mermaid-block" data-mermaid-id="${id}">
<div class="mermaid-toolbar">
<span class="mermaid-title">\u{1f4ca} Diagram</span>
<span class="mermaid-zoom-label" data-zoom-label="${id}">100%</span>
<div class="mermaid-toolbar-btns">
<button class="mmd-btn" data-action="zoom-in" data-target="${id}" title="Zoom in">+</button>
<button class="mmd-btn" data-action="zoom-out" data-target="${id}" title="Zoom out">\u2212</button>
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">\u22a1</button>
<button class="mmd-btn" data-action="zoom-reset" data-target="${id}" title="Reset zoom">1:1</button>
<span class="mmd-sep"></span>
<button class="mmd-btn" data-action="expand" data-target="${id}" title="Expand">\u26f6</button>
<span class="mmd-sep"></span>
<button class="mmd-btn" data-action="export-svg" data-target="${id}" title="Download SVG">SVG</button>
<button class="mmd-btn" data-action="export-png" data-target="${id}" title="Download PNG">PNG</button>
</div>
</div>
<div class="mermaid-viewport" data-viewport="${id}">
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}" data-diagram="${id}">
<div class="mermaid-loading">
<span class="mermaid-spinner"></span> Rendering diagram\u2026
</div>
</div>
</div>
<details class="mermaid-source">
<summary>
<span>\u{1f4cb} View source</span>
<button class="mmd-btn mmd-copy-src" data-action="copy-src" data-target="${id}" title="Copy source" onclick="event.stopPropagation()">Copy</button>
</summary>
<pre><code class="language-mermaid" data-source="${id}">${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
}
});
// ── Post renderer: render diagrams + wire interactivity ──
ctx.renderers.register('mermaid-post', {
type: 'post',
priority: 10,
render(container) {
const diagrams = container.querySelectorAll('.mermaid-diagram[data-mermaid-src]');
if (diagrams.length === 0) return;
diagrams.forEach(el => {
if (el.dataset.rendered) return;
el.dataset.rendered = 'pending';
self._renderDiagram(el);
});
self._wireToolbar(container);
}
});
this._loadMermaid();
},
// ── ViewBox Zoom/Pan State ──────────────
_getState(id) {
const vp = document.querySelector(`[data-viewport="${id}"]`);
if (!vp) return null;
if (!vp._mmdState) {
vp._mmdState = {
natX: 0, natY: 0, natW: 0, natH: 0,
vbX: 0, vbY: 0, vbW: 0, vbH: 0,
dragging: false, startX: 0, startY: 0,
startVbX: 0, startVbY: 0,
ready: false,
};
}
return vp._mmdState;
},
_initState(id) {
const state = this._getState(id);
if (!state) return;
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
const vb = svg.getAttribute('viewBox');
if (!vb) {
const bbox = svg.getBBox();
state.natX = bbox.x;
state.natY = bbox.y;
state.natW = bbox.width;
state.natH = bbox.height;
} else {
const parts = vb.trim().split(/[\s,]+/).map(Number);
state.natX = parts[0] || 0;
state.natY = parts[1] || 0;
state.natW = parts[2] || 0;
state.natH = parts[3] || 0;
}
state.vbX = state.natX;
state.vbY = state.natY;
state.vbW = state.natW;
state.vbH = state.natH;
state.ready = true;
this._applyViewBox(id);
},
_applyViewBox(id) {
const state = this._getState(id);
if (!state || !state.ready) return;
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
svg.setAttribute('viewBox', `${state.vbX} ${state.vbY} ${state.vbW} ${state.vbH}`);
const zoomPct = Math.round((state.natW / state.vbW) * 100);
const label = document.querySelector(`[data-zoom-label="${id}"]`);
if (label) label.textContent = zoomPct + '%';
},
_zoom(id, factor) {
const state = this._getState(id);
if (!state || !state.ready) return;
const scale = 1 / (1 + factor);
const newW = state.vbW * scale;
const newH = state.vbH * scale;
const minW = state.natW / 20;
const maxW = state.natW * 2;
if (newW < minW || newW > maxW) return;
const cx = state.vbX + state.vbW / 2;
const cy = state.vbY + state.vbH / 2;
state.vbW = newW;
state.vbH = newH;
state.vbX = cx - newW / 2;
state.vbY = cy - newH / 2;
this._applyViewBox(id);
},
_zoomAt(id, factor, clientX, clientY) {
const state = this._getState(id);
if (!state || !state.ready) return;
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
const svgRect = svg.getBoundingClientRect();
if (svgRect.width === 0 || svgRect.height === 0) return;
const fx = (clientX - svgRect.left) / svgRect.width;
const fy = (clientY - svgRect.top) / svgRect.height;
const pointX = state.vbX + fx * state.vbW;
const pointY = state.vbY + fy * state.vbH;
const scale = 1 / (1 + factor);
const newW = state.vbW * scale;
const newH = state.vbH * scale;
const minW = state.natW / 20;
const maxW = state.natW * 2;
if (newW < minW || newW > maxW) return;
state.vbW = newW;
state.vbH = newH;
state.vbX = pointX - fx * newW;
state.vbY = pointY - fy * newH;
this._applyViewBox(id);
},
_zoomReset(id) {
const state = this._getState(id);
if (!state || !state.ready) return;
state.vbX = state.natX;
state.vbY = state.natY;
state.vbW = state.natW;
state.vbH = state.natH;
this._applyViewBox(id);
},
_zoomFit(id) {
const state = this._getState(id);
if (!state || !state.ready) return;
const vp = document.querySelector(`[data-viewport="${id}"]`);
if (!vp) return;
const vpRect = vp.getBoundingClientRect();
if (vpRect.width === 0 || vpRect.height === 0) return;
const vpAspect = vpRect.width / vpRect.height;
const natAspect = state.natW / state.natH;
if (vpAspect > natAspect) {
const newW = state.natH * vpAspect;
state.vbX = state.natX - (newW - state.natW) / 2;
state.vbY = state.natY;
state.vbW = newW;
state.vbH = state.natH;
} else {
const newH = state.natW / vpAspect;
state.vbX = state.natX;
state.vbY = state.natY - (newH - state.natH) / 2;
state.vbW = state.natW;
state.vbH = newH;
}
this._applyViewBox(id);
},
// ── Expand (context-aware) ──────────────
// Side panel open → pop out into it (replaces current content).
// Side panel closed → fullscreen overlay.
_expand(id) {
if (this._ctx.ui.isPanelOpen()) {
this._popOutToPanel(id);
} else {
this._toggleFullscreen(id);
}
},
// ── Fullscreen ──────────────────────────
_toggleFullscreen(id) {
const block = document.querySelector(`[data-mermaid-id="${id}"]`);
if (!block) return;
const isFS = block.classList.toggle('mermaid-fullscreen');
if (isFS) {
document.body.style.overflow = 'hidden';
// Close button overlay (always visible — critical for mobile)
const closeBtn = document.createElement('button');
closeBtn.className = 'mmd-fullscreen-close';
closeBtn.innerHTML = '\u2715';
closeBtn.title = 'Close fullscreen';
closeBtn.addEventListener('click', () => this._toggleFullscreen(id));
block.appendChild(closeBtn);
// Escape listener (desktop convenience, not sole exit)
block._mmdEscHandler = (e) => {
if (e.key === 'Escape') this._toggleFullscreen(id);
};
document.addEventListener('keydown', block._mmdEscHandler);
requestAnimationFrame(() => this._zoomFit(id));
} else {
document.body.style.overflow = '';
const closeBtn = block.querySelector('.mmd-fullscreen-close');
if (closeBtn) closeBtn.remove();
if (block._mmdEscHandler) {
document.removeEventListener('keydown', block._mmdEscHandler);
delete block._mmdEscHandler;
}
}
},
// ── Side Panel Pop-out ──────────────────
_popOutToPanel(id) {
const diagram = document.querySelector(`[data-diagram="${id}"]`);
const svg = diagram?.querySelector('svg');
if (!svg) return;
const state = this._getState(id);
const clone = svg.cloneNode(true);
if (state?.ready) {
clone.setAttribute('viewBox',
`${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
}
clone.setAttribute('width', '100%');
clone.removeAttribute('height');
clone.setAttribute('preserveAspectRatio', 'xMidYMid meet');
const isDark = this._ctx.ui.isDark();
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: ${isDark ? '#1a1a2e' : '#fff'};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
svg { max-width: 100%; height: auto; }
</style>
</head><body>${clone.outerHTML}</body></html>`;
this._ctx.ui.openPreview(html);
},
// ── Toolbar Wiring ──────────────────────
_wireToolbar(container) {
const self = this;
if (container._mmdWired) return;
container._mmdWired = true;
container.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.target;
if (!id) return;
switch (action) {
case 'zoom-in': self._zoom(id, 0.25); break;
case 'zoom-out': self._zoom(id, -0.2); break;
case 'zoom-reset': self._zoomReset(id); break;
case 'zoom-fit': self._zoomFit(id); break;
case 'expand': self._expand(id); break;
case 'export-svg': self._exportSVG(id); break;
case 'export-png': self._exportPNG(id); break;
case 'copy-src': self._copySource(id); break;
}
});
// Wire each viewport for mouse/touch interaction
container.querySelectorAll('.mermaid-viewport').forEach(vp => {
if (vp._mmdWired) return;
vp._mmdWired = true;
const id = vp.dataset.viewport;
vp.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
self._zoomAt(id, delta, e.clientX, e.clientY);
}, { passive: false });
vp.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
const state = self._getState(id);
if (!state || !state.ready) return;
state.dragging = true;
state.startX = e.clientX;
state.startY = e.clientY;
state.startVbX = state.vbX;
state.startVbY = state.vbY;
vp.classList.add('mmd-grabbing');
e.preventDefault();
});
vp.addEventListener('mousemove', (e) => {
const state = self._getState(id);
if (!state?.dragging) return;
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
const svgRect = svg.getBoundingClientRect();
if (svgRect.width === 0) return;
const pxToVb = state.vbW / svgRect.width;
const dx = (e.clientX - state.startX) * pxToVb;
const dy = (e.clientY - state.startY) * pxToVb;
state.vbX = state.startVbX - dx;
state.vbY = state.startVbY - dy;
self._applyViewBox(id);
});
const endDrag = () => {
const state = self._getState(id);
if (!state) return;
state.dragging = false;
vp.classList.remove('mmd-grabbing');
};
vp.addEventListener('mouseup', endDrag);
vp.addEventListener('mouseleave', endDrag);
let lastTouchDist = 0;
let lastTouchCenter = null;
vp.addEventListener('touchstart', (e) => {
const state = self._getState(id);
if (!state || !state.ready) return;
if (e.touches.length === 1) {
state.dragging = true;
state.startX = e.touches[0].clientX;
state.startY = e.touches[0].clientY;
state.startVbX = state.vbX;
state.startVbY = state.vbY;
} else if (e.touches.length === 2) {
lastTouchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
lastTouchCenter = {
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
};
}
}, { passive: true });
vp.addEventListener('touchmove', (e) => {
const state = self._getState(id);
if (!state || !state.ready) return;
if (e.touches.length === 1 && state.dragging) {
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
const svgRect = svg.getBoundingClientRect();
if (svgRect.width === 0) return;
const pxToVb = state.vbW / svgRect.width;
const dx = (e.touches[0].clientX - state.startX) * pxToVb;
const dy = (e.touches[0].clientY - state.startY) * pxToVb;
state.vbX = state.startVbX - dx;
state.vbY = state.startVbY - dy;
self._applyViewBox(id);
e.preventDefault();
} else if (e.touches.length === 2) {
const dist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
const center = {
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
};
if (lastTouchDist > 0) {
const factor = (dist - lastTouchDist) / lastTouchDist;
self._zoomAt(id, factor, center.x, center.y);
}
lastTouchDist = dist;
lastTouchCenter = center;
e.preventDefault();
}
}, { passive: false });
vp.addEventListener('touchend', () => {
const state = self._getState(id);
if (state) state.dragging = false;
lastTouchDist = 0;
lastTouchCenter = null;
}, { passive: true });
});
},
// ── Export ───────────────────────────────
_exportSVG(id) {
const diagram = document.querySelector(`[data-diagram="${id}"]`);
const svg = diagram?.querySelector('svg');
if (!svg) return;
const state = this._getState(id);
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (state?.ready) {
clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
}
const blob = new Blob([clone.outerHTML], { type: 'image/svg+xml' });
this._download(blob, `diagram-${id}.svg`);
},
_exportPNG(id) {
const diagram = document.querySelector(`[data-diagram="${id}"]`);
const svg = diagram?.querySelector('svg');
if (!svg) return;
const state = this._getState(id);
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (state?.ready) {
clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
clone.setAttribute('width', state.natW);
clone.setAttribute('height', state.natH);
}
const svgData = new XMLSerializer().serializeToString(clone);
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
const self = this;
const img = new Image();
img.onload = () => {
const scale = 2;
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth * scale;
canvas.height = img.naturalHeight * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => {
if (blob) self._download(blob, `diagram-${id}.png`);
}, 'image/png');
};
img.onerror = () => {
URL.revokeObjectURL(url);
console.error('[Mermaid] PNG export failed');
};
img.src = url;
},
_download(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
},
_copySource(id) {
const code = document.querySelector(`[data-source="${id}"]`);
if (!code) return;
navigator.clipboard.writeText(code.textContent).then(() => {
this._ctx.ui.toast('Source copied', 'success');
});
},
// ── Diagram Rendering ───────────────────
async _renderDiagram(el) {
const code = decodeURIComponent(el.dataset.mermaidSrc);
const id = el.dataset.diagram;
try {
await this._loadMermaid();
const svgId = 'mmd-svg-' + Math.random().toString(36).slice(2, 9);
const { svg } = await mermaid.render(svgId, code);
el.innerHTML = svg;
el.dataset.rendered = 'true';
const svgEl = el.querySelector('svg');
if (svgEl) {
svgEl.removeAttribute('height');
svgEl.setAttribute('width', '100%');
svgEl.style.maxWidth = 'none';
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
}
this._initState(id);
} catch (e) {
el.innerHTML = `
<div class="mermaid-error">
<strong>Diagram error:</strong> ${this._escapeHtml(e.message || String(e))}
</div>
`;
el.dataset.rendered = 'error';
}
},
// ── Mermaid Library Loading ──────────────
_loadMermaid() {
if (this._mermaidReady) return Promise.resolve();
if (this._mermaidLoading) return this._mermaidLoading;
this._mermaidLoading = new Promise((resolve, reject) => {
if (typeof mermaid !== 'undefined') {
this._initMermaid();
this._mermaidReady = true;
resolve();
return;
}
const base = (window.__BASE__ || '');
const localSrc = `${base}/vendor/mermaid/mermaid.min.js`;
const cdnSrc = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js';
const script = document.createElement('script');
script.src = localSrc;
script.onload = () => {
this._initMermaid();
this._mermaidReady = true;
resolve();
};
script.onerror = () => {
console.warn('[Mermaid] Local vendor not found, trying CDN');
const cdn = document.createElement('script');
cdn.src = cdnSrc;
cdn.onload = () => {
this._initMermaid();
this._mermaidReady = true;
resolve();
};
cdn.onerror = () => {
console.error('[Mermaid] Failed to load from both local and CDN');
reject(new Error('Failed to load mermaid.js'));
};
document.head.appendChild(cdn);
};
document.head.appendChild(script);
});
return this._mermaidLoading;
},
_initMermaid() {
if (typeof mermaid === 'undefined') return;
mermaid.initialize({
startOnLoad: false,
theme: this._ctx.ui.isDark() ? 'dark' : 'default',
securityLevel: 'strict',
fontFamily: 'inherit',
logLevel: 'error',
});
},
// ── Styles ──────────────────────────────
_injectStyles() {
if (document.getElementById('ext-style-mermaid-renderer')) return;
const style = document.createElement('style');
style.id = 'ext-style-mermaid-renderer';
style.textContent = `
.mermaid-block {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; margin: 12px 0;
}
/* ── Fullscreen mode ── */
.mermaid-block.mermaid-fullscreen {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
z-index: 10000; border-radius: 0; margin: 0;
display: flex; flex-direction: column;
background: var(--bg-1, var(--bg-2, #1a1a2e));
}
.mermaid-block.mermaid-fullscreen .mermaid-viewport {
background: var(--bg-2, #1a1a2e);
flex: 1; max-height: none;
}
.mermaid-block.mermaid-fullscreen .mermaid-toolbar {
border-bottom: 1px solid var(--border);
padding: 6px 14px;
}
/* Fullscreen close button — always visible, critical for mobile */
.mmd-fullscreen-close {
position: absolute; top: 12px; right: 12px; z-index: 10001;
width: 40px; height: 40px; border-radius: 50%;
background: var(--bg-raised, rgba(0,0,0,0.6));
border: 1px solid var(--border, rgba(255,255,255,0.2));
color: var(--text, #fff); font-size: 20px; line-height: 1;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s, transform 0.15s;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.mmd-fullscreen-close:hover {
background: var(--bg-hover, rgba(255,255,255,0.15));
transform: scale(1.1);
}
/* Toolbar */
.mermaid-toolbar {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px; border-bottom: 1px solid var(--border);
font-size: 12px; color: var(--text-3); flex-wrap: wrap;
}
.mermaid-title { font-weight: 600; color: var(--text-2); margin-right: auto; }
.mermaid-zoom-label {
font-family: var(--mono); font-size: 11px; min-width: 36px;
text-align: center; color: var(--text-3);
}
.mermaid-toolbar-btns { display: flex; gap: 2px; align-items: center; }
.mmd-btn {
background: none; border: 1px solid var(--border); border-radius: 4px;
padding: 1px 7px; font-size: 11px; cursor: pointer;
color: var(--text-3); font-family: var(--mono); line-height: 1.6;
}
.mmd-btn:hover { color: var(--text-1); border-color: var(--text-3); background: var(--bg-3); }
.mmd-sep { width: 1px; height: 16px; background: var(--border); margin: 0 4px; }
/* Viewport */
.mermaid-viewport {
overflow: hidden; position: relative;
min-height: 80px; max-height: 600px;
cursor: grab; user-select: none;
}
.mermaid-viewport.mmd-grabbing { cursor: grabbing; }
/* Diagram wrapper */
.mermaid-diagram {
width: 100%; height: 100%; min-height: 60px;
}
.mermaid-diagram svg {
display: block; width: 100%; height: 100%;
}
/* Loading / Error */
.mermaid-loading {
color: var(--text-3); font-size: 13px; padding: 20px;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.mermaid-spinner {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: mmd-spin 0.8s linear infinite;
}
@keyframes mmd-spin { to { transform: rotate(360deg); } }
.mermaid-error {
color: var(--error, #e74c3c); background: var(--bg-3, rgba(231,76,60,0.1));
padding: 12px 16px; border-radius: 4px; font-size: 13px;
font-family: var(--mono); white-space: pre-wrap;
}
/* Source panel */
.mermaid-source { border-top: 1px solid var(--border); font-size: 12px; }
.mermaid-source summary {
padding: 6px 12px; cursor: pointer; color: var(--text-3);
user-select: none; display: flex; align-items: center; gap: 8px;
}
.mermaid-source summary:hover { color: var(--text-2); }
.mermaid-source summary span { flex: 1; }
.mmd-copy-src { font-size: 11px; }
.mermaid-source pre {
margin: 0; border-radius: 0; border: none;
max-height: 200px; overflow: auto;
}
/* Hide source panel in fullscreen */
.mermaid-block.mermaid-fullscreen .mermaid-source { display: none; }
/* ── Mobile adjustments ── */
@media (max-width: 768px) {
.mermaid-toolbar { padding: 6px 10px; gap: 4px; }
.mmd-btn { padding: 4px 10px; font-size: 12px; }
.mmd-sep { margin: 0 2px; }
.mmd-fullscreen-close { width: 48px; height: 48px; font-size: 24px; top: 16px; right: 16px; }
}
`;
document.head.appendChild(style);
},
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
destroy() {
document.getElementById('ext-style-mermaid-renderer')?.remove();
document.querySelectorAll('.mermaid-fullscreen').forEach(el => {
el.classList.remove('mermaid-fullscreen');
const closeBtn = el.querySelector('.mmd-fullscreen-close');
if (closeBtn) closeBtn.remove();
});
document.body.style.overflow = '';
}
});

View File

@@ -0,0 +1,14 @@
{
"id": "mermaid-renderer",
"name": "Mermaid Diagrams",
"version": "2.0.0",
"type": "extension",
"tier": "browser",
"author": "switchboard",
"description": "Renders ```mermaid code blocks as interactive SVG diagrams with zoom/pan, SVG/PNG export, and source copy",
"requires": ["chat"],
"permissions": [],
"tools": [],
"surfaces": [],
"settings": {}
}

View File

@@ -0,0 +1,102 @@
// ==========================================
// Regex Tester — Browser Extension
// ==========================================
// Tests regular expressions against input strings.
// Registers the regex_test tool so the LLM can verify
// patterns instead of guessing.
// ==========================================
Extensions.register({
id: 'regex-tester',
async init(ctx) {
const self = this;
ctx.tools.handle('regex_test', async (args) => {
return self._test(args.pattern, args.flags || '', args.inputs || []);
});
},
_test(pattern, flags, inputs) {
// Validate pattern
let regex;
try {
regex = new RegExp(pattern, flags);
} catch (e) {
return {
success: false,
error: `Invalid regex: ${e.message}`,
pattern,
flags,
};
}
const results = inputs.map(input => {
// Reset lastIndex for global/sticky patterns
regex.lastIndex = 0;
const isGlobal = regex.global;
const matches = [];
if (isGlobal) {
// Collect all matches for global flag
let match;
while ((match = regex.exec(input)) !== null) {
matches.push(this._formatMatch(match));
// Prevent infinite loops on zero-length matches
if (match[0].length === 0) regex.lastIndex++;
}
} else {
const match = regex.exec(input);
if (match) {
matches.push(this._formatMatch(match));
}
}
return {
input,
matched: matches.length > 0,
matchCount: matches.length,
matches,
};
});
const totalMatched = results.filter(r => r.matched).length;
return {
success: true,
pattern,
flags,
summary: `${totalMatched}/${inputs.length} inputs matched`,
results,
};
},
_formatMatch(match) {
const result = {
fullMatch: match[0],
index: match.index,
length: match[0].length,
};
// Named groups (ES2018)
if (match.groups) {
result.namedGroups = {};
for (const [name, value] of Object.entries(match.groups)) {
result.namedGroups[name] = value;
}
}
// Numbered capture groups
if (match.length > 1) {
result.groups = [];
for (let i = 1; i < match.length; i++) {
result.groups.push(match[i] !== undefined ? match[i] : null);
}
}
return result;
},
destroy() {}
});

View File

@@ -0,0 +1,39 @@
{
"id": "regex-tester",
"name": "Regex Tester",
"version": "1.0.0",
"type": "extension",
"tier": "browser",
"author": "switchboard",
"description": "Tests regular expressions against input strings. Allows the LLM to verify regex patterns and see matches, groups, and indices.",
"requires": ["chat"],
"permissions": [],
"tools": [
{
"name": "regex_test",
"display_name": "Regex Tester",
"description": "Test a regular expression against one or more input strings. Returns match details including captured groups, indices, and whether each string matched. Use this to verify regex patterns instead of guessing.",
"parameters": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "The regular expression pattern (without delimiters), e.g. '^\\d{3}-\\d{4}$'"
},
"flags": {
"type": "string",
"description": "Regex flags, e.g. 'gi' for global+case-insensitive. Default: '' (no flags)"
},
"inputs": {
"type": "array",
"items": { "type": "string" },
"description": "Array of test strings to match against the pattern"
}
},
"required": ["pattern", "inputs"]
}
}
],
"surfaces": [],
"settings": {}
}