Changeset 0.11.0 (#62)
This commit is contained in:
12
extensions/builtin/csv-table/manifest.json
Normal file
12
extensions/builtin/csv-table/manifest.json
Normal 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": {}
|
||||
}
|
||||
254
extensions/builtin/csv-table/script.js
Normal file
254
extensions/builtin/csv-table/script.js
Normal 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();
|
||||
}
|
||||
});
|
||||
12
extensions/builtin/diff-viewer/manifest.json
Normal file
12
extensions/builtin/diff-viewer/manifest.json
Normal 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": {}
|
||||
}
|
||||
166
extensions/builtin/diff-viewer/script.js
Normal file
166
extensions/builtin/diff-viewer/script.js
Normal 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();
|
||||
}
|
||||
});
|
||||
27
extensions/builtin/js-sandbox/manifest.json
Normal file
27
extensions/builtin/js-sandbox/manifest.json
Normal 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": {}
|
||||
}
|
||||
202
extensions/builtin/js-sandbox/script.js
Normal file
202
extensions/builtin/js-sandbox/script.js
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
12
extensions/builtin/katex-renderer/manifest.json
Normal file
12
extensions/builtin/katex-renderer/manifest.json
Normal 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": {}
|
||||
}
|
||||
297
extensions/builtin/katex-renderer/script.js
Normal file
297
extensions/builtin/katex-renderer/script.js
Normal 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();
|
||||
}
|
||||
});
|
||||
12
extensions/builtin/mermaid-renderer/manifest.json
Normal file
12
extensions/builtin/mermaid-renderer/manifest.json
Normal 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": {}
|
||||
}
|
||||
209
extensions/builtin/mermaid-renderer/script.js
Normal file
209
extensions/builtin/mermaid-renderer/script.js
Normal 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();
|
||||
}
|
||||
});
|
||||
36
extensions/builtin/regex-tester/manifest.json
Normal file
36
extensions/builtin/regex-tester/manifest.json
Normal 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": {}
|
||||
}
|
||||
102
extensions/builtin/regex-tester/script.js
Normal file
102
extensions/builtin/regex-tester/script.js
Normal 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() {}
|
||||
});
|
||||
Reference in New Issue
Block a user