Changeset 0.11.0 (#62)

This commit is contained in:
2026-02-25 13:29:15 +00:00
parent d2ec55b16d
commit c9d8e9457e
56 changed files with 5664 additions and 91 deletions

View File

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

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,12 @@
{
"id": "diff-viewer",
"name": "Diff Viewer",
"version": "1.0.0",
"tier": "browser",
"author": "switchboard",
"description": "Renders ```diff code blocks with syntax-highlighted additions, deletions, and context lines",
"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,27 @@
{
"id": "js-sandbox",
"name": "JavaScript Sandbox",
"version": "1.0.0",
"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.",
"permissions": [],
"tools": [
{
"name": "js_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,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,12 @@
{
"id": "katex-renderer",
"name": "KaTeX Math",
"version": "1.0.0",
"tier": "browser",
"author": "switchboard",
"description": "Renders LaTeX math expressions: ```latex blocks and inline $...$ / $$...$$ syntax",
"permissions": [],
"tools": [],
"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,12 @@
{
"id": "mermaid-renderer",
"name": "Mermaid Diagrams",
"version": "1.0.0",
"tier": "browser",
"author": "switchboard",
"description": "Renders ```mermaid code blocks as SVG diagrams using mermaid.js",
"permissions": [],
"tools": [],
"surfaces": [],
"settings": {}
}

View File

@@ -0,0 +1,209 @@
// ==========================================
// Mermaid Diagram Renderer — Browser Extension
// ==========================================
// Renders ```mermaid code blocks as SVG diagrams.
// Loads mermaid.js dynamically on first use.
//
// Install: Admin → Extensions → paste manifest + this script.
// ==========================================
Extensions.register({
id: 'mermaid-renderer',
_mermaidReady: false,
_mermaidLoading: null,
_renderQueue: [],
async init(ctx) {
const self = this;
// ── Inject styles ──
this._injectStyles();
// ── Block renderer: match ```mermaid, output placeholder ──
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-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}">
<div class="mermaid-loading">
<span class="mermaid-spinner"></span> Rendering diagram…
</div>
</div>
<details class="mermaid-source">
<summary>📊 View source</summary>
<pre><code class="language-mermaid">${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
}
});
// ── Post renderer: find placeholders, render with mermaid.js ──
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 => {
// Skip already-rendered diagrams
if (el.dataset.rendered) return;
el.dataset.rendered = 'pending';
self._renderDiagram(el);
});
}
});
// Pre-load mermaid library
this._loadMermaid();
},
async _renderDiagram(el) {
const code = decodeURIComponent(el.dataset.mermaidSrc);
try {
await this._loadMermaid();
const id = 'mmd-svg-' + Math.random().toString(36).slice(2, 9);
const { svg } = await mermaid.render(id, code);
el.innerHTML = svg;
el.dataset.rendered = 'true';
// Styling handled by .mermaid-diagram svg in CSS
const svgEl = el.querySelector('svg');
if (svgEl) {
svgEl.removeAttribute('height');
}
} catch (e) {
el.innerHTML = `
<div class="mermaid-error">
<strong>Diagram error:</strong> ${this._escapeHtml(e.message || String(e))}
</div>
`;
el.dataset.rendered = 'error';
}
},
_loadMermaid() {
if (this._mermaidReady) return Promise.resolve();
if (this._mermaidLoading) return this._mermaidLoading;
this._mermaidLoading = new Promise((resolve, reject) => {
// Check if already loaded (e.g. by another script)
if (typeof mermaid !== 'undefined') {
this._initMermaid();
this._mermaidReady = true;
resolve();
return;
}
// Try local vendor first, fall back to CDN
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 = () => {
// Local failed — try CDN
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;
// Detect dark mode
const isDark = document.body.classList.contains('dark-theme') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
mermaid.initialize({
startOnLoad: false,
theme: isDark ? 'dark' : 'default',
securityLevel: 'strict',
fontFamily: 'inherit',
logLevel: 'error',
});
},
_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;
}
.mermaid-diagram {
padding: 16px; text-align: center; min-height: 60px;
max-height: 600px; overflow: auto;
}
.mermaid-diagram svg {
max-width: 100%; max-height: 560px;
height: auto; width: auto;
}
.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;
}
.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;
}
.mermaid-source summary:hover { color: var(--text-2); }
.mermaid-source pre {
margin: 0; border-radius: 0; border: none;
max-height: 200px; overflow: auto;
}`;
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();
}
});

View File

@@ -0,0 +1,36 @@
{
"id": "regex-tester",
"name": "Regex Tester",
"version": "1.0.0",
"tier": "browser",
"author": "switchboard",
"description": "Tests regular expressions against input strings. Allows the LLM to verify regex patterns and see matches, groups, and indices.",
"permissions": [],
"tools": [
{
"name": "regex_test",
"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": {}
}

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() {}
});