Changeset 0.11.0 (#62)
This commit is contained in:
635
src/js/__tests__/extensions-builtin.test.js
Normal file
635
src/js/__tests__/extensions-builtin.test.js
Normal file
@@ -0,0 +1,635 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { createBrowserContext, loadSource } = require('./helpers');
|
||||
const vm = require('vm');
|
||||
|
||||
/**
|
||||
* Load Events + Extensions into a browser context.
|
||||
*/
|
||||
function loadExtensions(overrides = {}) {
|
||||
const sandbox = createBrowserContext(overrides);
|
||||
loadSource(sandbox, 'events.js');
|
||||
loadSource(sandbox, 'extensions.js');
|
||||
const ctx = vm.createContext(sandbox);
|
||||
return {
|
||||
Extensions: vm.runInContext('Extensions', ctx),
|
||||
Events: vm.runInContext('Events', ctx),
|
||||
sandbox,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// KaTeX Renderer Extension
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('KaTeX renderer extension', () => {
|
||||
function loadWithKaTeX() {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
id: 'katex-renderer',
|
||||
_katexReady: false,
|
||||
_katexLoading: null,
|
||||
_escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
},
|
||||
init(extCtx) {
|
||||
const self = this;
|
||||
extCtx.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) {
|
||||
container.innerHTML = `
|
||||
<div class="katex-block ext-rendered">
|
||||
<div class="katex-display-container" data-katex-src="${encodeURIComponent(code.trim())}" data-katex-display="true">
|
||||
<div class="katex-loading">Rendering math…</div>
|
||||
</div>
|
||||
<details class="katex-source">
|
||||
<summary>View source</summary>
|
||||
<pre><code>${self._escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
extCtx.renderers.register('katex-post', {
|
||||
type: 'post',
|
||||
priority: 10,
|
||||
render(container) {
|
||||
container._katexPostRan = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
destroy() {}
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
it('registers both block and post renderers', async () => {
|
||||
const ctx = loadWithKaTeX();
|
||||
await ctx.Extensions.initAll();
|
||||
const info = ctx.Extensions.debug();
|
||||
assert.strictEqual(info.extensions['katex-renderer'].active, true);
|
||||
assert.strictEqual(info.extensions['katex-renderer'].renderers.length, 2);
|
||||
});
|
||||
|
||||
it('matches latex, math, tex, and katex languages', async () => {
|
||||
const ctx = loadWithKaTeX();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
for (const lang of ['latex', 'math', 'tex', 'katex']) {
|
||||
const container = { innerHTML: '' };
|
||||
const handled = ctx.Extensions.runBlockRenderers(lang, 'E = mc^2', container);
|
||||
assert.ok(handled, `should match language: ${lang}`);
|
||||
assert.ok(container.innerHTML.includes('katex-block'), `should render katex block for: ${lang}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not match other languages', async () => {
|
||||
const ctx = loadWithKaTeX();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
for (const lang of ['python', 'javascript', 'mermaid', 'csv']) {
|
||||
const container = { innerHTML: '' };
|
||||
const handled = ctx.Extensions.runBlockRenderers(lang, 'code', container);
|
||||
assert.strictEqual(handled, false, `should not match language: ${lang}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes encoded source in data attribute', async () => {
|
||||
const ctx = loadWithKaTeX();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('latex', '\\frac{a}{b}', container);
|
||||
assert.ok(container.innerHTML.includes(encodeURIComponent('\\frac{a}{b}')));
|
||||
});
|
||||
|
||||
it('escapes HTML in source view', async () => {
|
||||
const ctx = loadWithKaTeX();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('math', '<script>alert(1)</script>', container);
|
||||
assert.ok(container.innerHTML.includes('<script>'));
|
||||
assert.ok(!container.innerHTML.includes('<script>alert'));
|
||||
});
|
||||
|
||||
it('post renderer runs on container', async () => {
|
||||
const ctx = loadWithKaTeX();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = {};
|
||||
ctx.Extensions.runPostRenderers(container);
|
||||
assert.strictEqual(container._katexPostRan, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// CSV Table Extension
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('CSV Table extension', () => {
|
||||
function loadWithCSV() {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
id: 'csv-table',
|
||||
_escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
},
|
||||
_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 === '"') {
|
||||
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(c => c !== '')) rows.push(row);
|
||||
row = []; field = '';
|
||||
i += (ch === '\r') ? 2 : 1;
|
||||
} else { field += ch; i++; }
|
||||
}
|
||||
}
|
||||
row.push(field.trim());
|
||||
if (row.some(c => c !== '')) rows.push(row);
|
||||
if (rows.length > 0) {
|
||||
const maxCols = Math.max(...rows.map(r => r.length));
|
||||
rows.forEach(r => { while (r.length < maxCols) r.push(''); });
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
init(extCtx) {
|
||||
const self = this;
|
||||
extCtx.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);
|
||||
container.innerHTML = `
|
||||
<div class="csv-table-block ext-rendered">
|
||||
<div class="csv-toolbar">
|
||||
<span class="csv-info">${data.length} rows × ${headers.length} cols</span>
|
||||
</div>
|
||||
<table class="csv-table">
|
||||
<thead><tr>${headers.map((h, i) => `<th data-col="${i}">${self._escapeHtml(h)}</th>`).join('')}</tr></thead>
|
||||
<tbody>${data.map(row => `<tr>${row.map(c => `<td>${self._escapeHtml(c)}</td>`).join('')}</tr>`).join('')}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
},
|
||||
destroy() {}
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
it('matches csv and tsv languages', async () => {
|
||||
const ctx = loadWithCSV();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
for (const lang of ['csv', 'tsv']) {
|
||||
const container = { innerHTML: '' };
|
||||
const handled = ctx.Extensions.runBlockRenderers(lang, 'a,b\n1,2', container);
|
||||
assert.ok(handled, `should match language: ${lang}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not match other languages', async () => {
|
||||
const ctx = loadWithCSV();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const handled = ctx.Extensions.runBlockRenderers('json', '{}', { innerHTML: '' });
|
||||
assert.strictEqual(handled, false);
|
||||
});
|
||||
|
||||
it('renders headers and data rows', async () => {
|
||||
const ctx = loadWithCSV();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('csv', 'Name,Age\nAlice,30\nBob,25', container);
|
||||
assert.ok(container.innerHTML.includes('csv-table'));
|
||||
assert.ok(container.innerHTML.includes('Name'));
|
||||
assert.ok(container.innerHTML.includes('Alice'));
|
||||
assert.ok(container.innerHTML.includes('30'));
|
||||
assert.ok(container.innerHTML.includes('2 rows'));
|
||||
});
|
||||
|
||||
it('handles quoted CSV fields', async () => {
|
||||
const ctx = loadWithCSV();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('csv', 'Name,Note\n"Smith, John","Has a ""quote"""', container);
|
||||
assert.ok(container.innerHTML.includes('Smith, John'));
|
||||
assert.ok(container.innerHTML.includes('Has a "quote"'));
|
||||
});
|
||||
|
||||
it('handles empty input', async () => {
|
||||
const ctx = loadWithCSV();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('csv', '', container);
|
||||
assert.ok(container.innerHTML.includes('csv-empty') || container.innerHTML.includes('No data'));
|
||||
});
|
||||
|
||||
it('escapes HTML in cell content', async () => {
|
||||
const ctx = loadWithCSV();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('csv', 'H1\n<img onerror=alert(1)>', container);
|
||||
assert.ok(container.innerHTML.includes('<img'));
|
||||
assert.ok(!container.innerHTML.includes('<img onerror'));
|
||||
});
|
||||
|
||||
it('parses CSV correctly', () => {
|
||||
const ctx = loadWithCSV();
|
||||
const ext = ctx.Extensions._registry.get('csv-table').def;
|
||||
|
||||
// Simple
|
||||
assert.deepStrictEqual(ext._parseCSV('a,b\n1,2', ','), [['a','b'],['1','2']]);
|
||||
|
||||
// Quoted with embedded comma
|
||||
assert.deepStrictEqual(ext._parseCSV('"hello, world",b\n1,2', ','), [['hello, world','b'],['1','2']]);
|
||||
|
||||
// Escaped quotes
|
||||
assert.deepStrictEqual(ext._parseCSV('"a""b",c\n1,2', ','), [['a"b','c'],['1','2']]);
|
||||
|
||||
// TSV
|
||||
assert.deepStrictEqual(ext._parseCSV('a\tb\n1\t2', '\t'), [['a','b'],['1','2']]);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Diff Viewer Extension
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('Diff Viewer extension', () => {
|
||||
function loadWithDiff() {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
id: 'diff-viewer',
|
||||
_escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
},
|
||||
init(extCtx) {
|
||||
const self = this;
|
||||
extCtx.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 };
|
||||
const rendered = lines.map(line => {
|
||||
const escaped = self._escapeHtml(line);
|
||||
if (line.startsWith('+++ ') || line.startsWith('--- ')) {
|
||||
return `<div class="diff-line diff-file-header">${escaped}</div>`;
|
||||
}
|
||||
if (line.startsWith('@@')) {
|
||||
return `<div class="diff-line diff-hunk">${escaped}</div>`;
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
stats.added++;
|
||||
return `<div class="diff-line diff-add">${escaped}</div>`;
|
||||
}
|
||||
if (line.startsWith('-')) {
|
||||
stats.removed++;
|
||||
return `<div class="diff-line diff-del">${escaped}</div>`;
|
||||
}
|
||||
return `<div class="diff-line diff-ctx">${escaped}</div>`;
|
||||
}).join('');
|
||||
container.innerHTML = `
|
||||
<div class="diff-block ext-rendered">
|
||||
<div class="diff-toolbar">
|
||||
<span class="diff-stat-add">+${stats.added}</span>
|
||||
<span class="diff-stat-del">-${stats.removed}</span>
|
||||
</div>
|
||||
<div class="diff-content">${rendered}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
},
|
||||
destroy() {}
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
it('matches diff language', async () => {
|
||||
const ctx = loadWithDiff();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
const handled = ctx.Extensions.runBlockRenderers('diff', '+added', container);
|
||||
assert.ok(handled);
|
||||
});
|
||||
|
||||
it('does not match other languages', async () => {
|
||||
const ctx = loadWithDiff();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
assert.strictEqual(ctx.Extensions.runBlockRenderers('python', 'x', { innerHTML: '' }), false);
|
||||
});
|
||||
|
||||
it('classifies added lines', async () => {
|
||||
const ctx = loadWithDiff();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('diff', '+new line', container);
|
||||
assert.ok(container.innerHTML.includes('diff-add'));
|
||||
assert.ok(container.innerHTML.includes('+1'));
|
||||
});
|
||||
|
||||
it('classifies removed lines', async () => {
|
||||
const ctx = loadWithDiff();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('diff', '-old line', container);
|
||||
assert.ok(container.innerHTML.includes('diff-del'));
|
||||
assert.ok(container.innerHTML.includes('-1'));
|
||||
});
|
||||
|
||||
it('classifies hunk headers', async () => {
|
||||
const ctx = loadWithDiff();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('diff', '@@ -1,3 +1,4 @@', container);
|
||||
assert.ok(container.innerHTML.includes('diff-hunk'));
|
||||
});
|
||||
|
||||
it('classifies file headers', async () => {
|
||||
const ctx = loadWithDiff();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('diff', '--- a/file.txt\n+++ b/file.txt', container);
|
||||
assert.ok(container.innerHTML.includes('diff-file-header'));
|
||||
});
|
||||
|
||||
it('counts stats correctly', async () => {
|
||||
const ctx = loadWithDiff();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('diff',
|
||||
'--- a/f.txt\n+++ b/f.txt\n@@ -1,2 +1,3 @@\n context\n+add1\n+add2\n-del1', container);
|
||||
assert.ok(container.innerHTML.includes('+2'));
|
||||
assert.ok(container.innerHTML.includes('-1'));
|
||||
});
|
||||
|
||||
it('escapes HTML in diff content', async () => {
|
||||
const ctx = loadWithDiff();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('diff', '+<script>alert(1)</script>', container);
|
||||
assert.ok(container.innerHTML.includes('<script>'));
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// JS Sandbox Extension (Tool)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('JS Sandbox extension', () => {
|
||||
it('registers js_eval tool handler', async () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
id: 'js-sandbox',
|
||||
init(extCtx) {
|
||||
extCtx.tools.handle('js_eval', async (args) => {
|
||||
return { success: true, result: 'mock' };
|
||||
});
|
||||
},
|
||||
destroy() {}
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
assert.ok(ctx.Extensions._toolHandlers.has('js_eval'));
|
||||
});
|
||||
|
||||
it('routes tool calls through the bridge', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let receivedCode = null;
|
||||
|
||||
ctx.Extensions.register({
|
||||
id: 'js-sandbox',
|
||||
init(extCtx) {
|
||||
extCtx.tools.handle('js_eval', async (args) => {
|
||||
receivedCode = args.code;
|
||||
return { success: true, result: '42', output: [] };
|
||||
});
|
||||
},
|
||||
destroy() {}
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
let resultEmitted = null;
|
||||
ctx.Events.on('tool.result.eval-1', (payload) => {
|
||||
resultEmitted = payload;
|
||||
});
|
||||
|
||||
ctx.Events.emit('tool.call.eval-1', {
|
||||
call_id: 'eval-1',
|
||||
tool: 'js_eval',
|
||||
arguments: { code: '21 * 2' },
|
||||
}, { localOnly: true });
|
||||
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
|
||||
assert.strictEqual(receivedCode, '21 * 2');
|
||||
assert.ok(resultEmitted);
|
||||
const parsed = JSON.parse(resultEmitted.result);
|
||||
assert.strictEqual(parsed.success, true);
|
||||
assert.strictEqual(parsed.result, '42');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Regex Tester Extension (Tool)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('Regex Tester extension', () => {
|
||||
function loadWithRegex() {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
id: 'regex-tester',
|
||||
_formatMatch(match) {
|
||||
const result = {
|
||||
fullMatch: match[0],
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
};
|
||||
if (match.groups) {
|
||||
result.namedGroups = {};
|
||||
for (const [name, value] of Object.entries(match.groups)) {
|
||||
result.namedGroups[name] = value;
|
||||
}
|
||||
}
|
||||
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;
|
||||
},
|
||||
_test(pattern, flags, inputs) {
|
||||
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 => {
|
||||
regex.lastIndex = 0;
|
||||
const isGlobal = regex.global;
|
||||
const matches = [];
|
||||
if (isGlobal) {
|
||||
let match;
|
||||
while ((match = regex.exec(input)) !== null) {
|
||||
matches.push(this._formatMatch(match));
|
||||
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 };
|
||||
});
|
||||
return {
|
||||
success: true, pattern, flags,
|
||||
summary: `${results.filter(r => r.matched).length}/${inputs.length} inputs matched`,
|
||||
results,
|
||||
};
|
||||
},
|
||||
init(extCtx) {
|
||||
const self = this;
|
||||
extCtx.tools.handle('regex_test', async (args) => {
|
||||
return self._test(args.pattern, args.flags || '', args.inputs || []);
|
||||
});
|
||||
},
|
||||
destroy() {}
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
it('registers regex_test tool handler', async () => {
|
||||
const ctx = loadWithRegex();
|
||||
await ctx.Extensions.initAll();
|
||||
assert.ok(ctx.Extensions._toolHandlers.has('regex_test'));
|
||||
});
|
||||
|
||||
it('matches simple patterns', async () => {
|
||||
const ctx = loadWithRegex();
|
||||
await ctx.Extensions.initAll();
|
||||
const ext = ctx.Extensions._registry.get('regex-tester').def;
|
||||
|
||||
const result = ext._test('^\\d+$', '', ['123', 'abc', '456']);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.strictEqual(result.summary, '2/3 inputs matched');
|
||||
assert.strictEqual(result.results[0].matched, true);
|
||||
assert.strictEqual(result.results[1].matched, false);
|
||||
assert.strictEqual(result.results[2].matched, true);
|
||||
});
|
||||
|
||||
it('captures groups', async () => {
|
||||
const ctx = loadWithRegex();
|
||||
await ctx.Extensions.initAll();
|
||||
const ext = ctx.Extensions._registry.get('regex-tester').def;
|
||||
|
||||
const result = ext._test('(\\d{3})-(\\d{4})', '', ['555-1234']);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.strictEqual(result.results[0].matches[0].fullMatch, '555-1234');
|
||||
assert.deepStrictEqual(result.results[0].matches[0].groups, ['555', '1234']);
|
||||
});
|
||||
|
||||
it('handles global flag with multiple matches', async () => {
|
||||
const ctx = loadWithRegex();
|
||||
await ctx.Extensions.initAll();
|
||||
const ext = ctx.Extensions._registry.get('regex-tester').def;
|
||||
|
||||
const result = ext._test('\\d+', 'g', ['a1b2c3']);
|
||||
assert.strictEqual(result.results[0].matchCount, 3);
|
||||
assert.strictEqual(result.results[0].matches[0].fullMatch, '1');
|
||||
assert.strictEqual(result.results[0].matches[1].fullMatch, '2');
|
||||
assert.strictEqual(result.results[0].matches[2].fullMatch, '3');
|
||||
});
|
||||
|
||||
it('returns error for invalid patterns', async () => {
|
||||
const ctx = loadWithRegex();
|
||||
await ctx.Extensions.initAll();
|
||||
const ext = ctx.Extensions._registry.get('regex-tester').def;
|
||||
|
||||
const result = ext._test('[invalid', '', ['test']);
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.ok(result.error.includes('Invalid regex'));
|
||||
});
|
||||
|
||||
it('handles named groups', async () => {
|
||||
const ctx = loadWithRegex();
|
||||
await ctx.Extensions.initAll();
|
||||
const ext = ctx.Extensions._registry.get('regex-tester').def;
|
||||
|
||||
const result = ext._test('(?<year>\\d{4})-(?<month>\\d{2})', '', ['2025-02']);
|
||||
assert.strictEqual(result.results[0].matches[0].namedGroups.year, '2025');
|
||||
assert.strictEqual(result.results[0].matches[0].namedGroups.month, '02');
|
||||
});
|
||||
|
||||
it('routes through tool bridge', async () => {
|
||||
const ctx = loadWithRegex();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
let resultEmitted = null;
|
||||
ctx.Events.on('tool.result.regex-1', (payload) => {
|
||||
resultEmitted = payload;
|
||||
});
|
||||
|
||||
ctx.Events.emit('tool.call.regex-1', {
|
||||
call_id: 'regex-1',
|
||||
tool: 'regex_test',
|
||||
arguments: { pattern: '^hello', flags: 'i', inputs: ['Hello world', 'goodbye'] },
|
||||
}, { localOnly: true });
|
||||
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
|
||||
assert.ok(resultEmitted);
|
||||
const parsed = JSON.parse(resultEmitted.result);
|
||||
assert.strictEqual(parsed.success, true);
|
||||
assert.strictEqual(parsed.summary, '1/2 inputs matched');
|
||||
});
|
||||
});
|
||||
491
src/js/__tests__/extensions.test.js
Normal file
491
src/js/__tests__/extensions.test.js
Normal file
@@ -0,0 +1,491 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { createBrowserContext, loadSource } = require('./helpers');
|
||||
const vm = require('vm');
|
||||
|
||||
/**
|
||||
* Load Events + Extensions into a browser context.
|
||||
* Returns an object with Events, Extensions, and the sandbox.
|
||||
* Note: const declarations in vm contexts aren't sandbox properties,
|
||||
* so we extract them via vm.runInContext.
|
||||
*/
|
||||
function loadExtensions(overrides = {}) {
|
||||
const sandbox = createBrowserContext(overrides);
|
||||
loadSource(sandbox, 'events.js');
|
||||
loadSource(sandbox, 'extensions.js');
|
||||
// Extract const globals from the vm context
|
||||
const ctx = vm.createContext(sandbox); // idempotent on already-contextified sandbox
|
||||
return {
|
||||
Extensions: vm.runInContext('Extensions', ctx),
|
||||
Events: vm.runInContext('Events', ctx),
|
||||
sandbox,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Registration ─────────────────────────────
|
||||
|
||||
describe('Extensions.register', () => {
|
||||
it('registers an extension by id', () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({ id: 'test-ext', init() {} });
|
||||
assert.ok(ctx.Extensions._registry.has('test-ext'));
|
||||
});
|
||||
|
||||
it('rejects duplicate registrations', () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({ id: 'dup', init() {} });
|
||||
ctx.Extensions.register({ id: 'dup', init() {} });
|
||||
assert.strictEqual(ctx.Extensions._registry.size, 1);
|
||||
});
|
||||
|
||||
it('rejects registration without id', () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({ init() {} });
|
||||
assert.strictEqual(ctx.Extensions._registry.size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Lifecycle ────────────────────────────────
|
||||
|
||||
describe('Extensions.initAll', () => {
|
||||
it('calls init on registered extensions', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let initialized = false;
|
||||
ctx.Extensions.register({
|
||||
id: 'lifecycle-test',
|
||||
init(extCtx) { initialized = true; },
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
assert.ok(initialized);
|
||||
});
|
||||
|
||||
it('marks extensions as active after init', async () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({ id: 'active-test', init() {} });
|
||||
await ctx.Extensions.initAll();
|
||||
const entry = ctx.Extensions._registry.get('active-test');
|
||||
assert.strictEqual(entry.active, true);
|
||||
});
|
||||
|
||||
it('survives init errors without crashing', async () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
id: 'bad-ext',
|
||||
init() { throw new Error('init failed'); },
|
||||
});
|
||||
ctx.Extensions.register({
|
||||
id: 'good-ext',
|
||||
init() {},
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
assert.strictEqual(ctx.Extensions._registry.get('bad-ext').active, false);
|
||||
assert.strictEqual(ctx.Extensions._registry.get('good-ext').active, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Extensions.destroyAll', () => {
|
||||
it('calls destroy and deactivates extensions', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let destroyed = false;
|
||||
ctx.Extensions.register({
|
||||
id: 'destroy-test',
|
||||
init() {},
|
||||
destroy() { destroyed = true; },
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
ctx.Extensions.destroyAll();
|
||||
assert.ok(destroyed);
|
||||
assert.strictEqual(ctx.Extensions._registry.get('destroy-test').active, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Context Object ──────────────────────────
|
||||
|
||||
describe('Extension context', () => {
|
||||
it('provides scoped events', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let receivedPayload = null;
|
||||
ctx.Extensions.register({
|
||||
id: 'events-test',
|
||||
init(extCtx) {
|
||||
extCtx.events.on('test.event', (payload) => {
|
||||
receivedPayload = payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
ctx.Events.emit('test.event', { data: 42 }, { localOnly: true });
|
||||
assert.deepStrictEqual(receivedPayload, { data: 42 });
|
||||
});
|
||||
|
||||
it('provides scoped storage', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let storage = null;
|
||||
ctx.Extensions.register({
|
||||
id: 'storage-test',
|
||||
init(extCtx) {
|
||||
extCtx.storage.set('key1', { foo: 'bar' });
|
||||
storage = extCtx.storage.get('key1');
|
||||
},
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
assert.strictEqual(storage.foo, 'bar');
|
||||
// Verify namespacing
|
||||
assert.ok(ctx.sandbox.localStorage._data['ext::storage-test::key1']);
|
||||
});
|
||||
|
||||
it('provides frozen settings from defaults', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let settings = null;
|
||||
ctx.Extensions.register({
|
||||
id: 'settings-test',
|
||||
manifest: {
|
||||
settings: {
|
||||
showInline: { type: 'boolean', default: true },
|
||||
limit: { type: 'number', default: 100 },
|
||||
},
|
||||
},
|
||||
init(extCtx) { settings = extCtx.settings; },
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
assert.strictEqual(settings.showInline, true);
|
||||
assert.strictEqual(settings.limit, 100);
|
||||
// Frozen — assignment silently ignored in sloppy mode
|
||||
settings.showInline = false;
|
||||
assert.strictEqual(settings.showInline, true, 'frozen settings should not change');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tool Registration ───────────────────────
|
||||
|
||||
describe('ctx.tools.handle', () => {
|
||||
it('registers a tool handler', async () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
id: 'tool-test',
|
||||
init(extCtx) {
|
||||
extCtx.tools.handle('my_tool', async (args) => {
|
||||
return { result: args.input * 2 };
|
||||
});
|
||||
},
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
assert.ok(ctx.Extensions._toolHandlers.has('my_tool'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tool Bridge ─────────────────────────────
|
||||
|
||||
describe('Tool bridge', () => {
|
||||
it('routes tool.call.* to registered handler and emits result', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let resultEmitted = null;
|
||||
|
||||
ctx.Extensions.register({
|
||||
id: 'bridge-test',
|
||||
init(extCtx) {
|
||||
extCtx.tools.handle('double', async (args) => {
|
||||
return { value: args.n * 2 };
|
||||
});
|
||||
},
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
// Listen for the result event
|
||||
ctx.Events.on('tool.result.call-123', (payload) => {
|
||||
resultEmitted = payload;
|
||||
});
|
||||
|
||||
// Simulate server sending tool.call
|
||||
ctx.Events.emit('tool.call.call-123', {
|
||||
call_id: 'call-123',
|
||||
tool: 'double',
|
||||
arguments: { n: 21 },
|
||||
}, { localOnly: true });
|
||||
|
||||
// Allow async handler to complete
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
|
||||
assert.ok(resultEmitted, 'result event should have been emitted');
|
||||
assert.strictEqual(resultEmitted.call_id, 'call-123');
|
||||
const parsed = JSON.parse(resultEmitted.result);
|
||||
assert.strictEqual(parsed.value, 42);
|
||||
});
|
||||
|
||||
it('returns error for unknown tool', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let resultEmitted = null;
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
ctx.Events.on('tool.result.call-unknown', (payload) => {
|
||||
resultEmitted = payload;
|
||||
});
|
||||
|
||||
ctx.Events.emit('tool.call.call-unknown', {
|
||||
call_id: 'call-unknown',
|
||||
tool: 'nonexistent_tool',
|
||||
arguments: {},
|
||||
}, { localOnly: true });
|
||||
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
|
||||
assert.ok(resultEmitted);
|
||||
assert.ok(resultEmitted.error, 'should have error field');
|
||||
});
|
||||
|
||||
it('returns error when handler throws', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let resultEmitted = null;
|
||||
|
||||
ctx.Extensions.register({
|
||||
id: 'error-tool',
|
||||
init(extCtx) {
|
||||
extCtx.tools.handle('fail_tool', async () => {
|
||||
throw new Error('something broke');
|
||||
});
|
||||
},
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
ctx.Events.on('tool.result.call-fail', (payload) => {
|
||||
resultEmitted = payload;
|
||||
});
|
||||
|
||||
ctx.Events.emit('tool.call.call-fail', {
|
||||
call_id: 'call-fail',
|
||||
tool: 'fail_tool',
|
||||
arguments: {},
|
||||
}, { localOnly: true });
|
||||
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
|
||||
assert.ok(resultEmitted);
|
||||
assert.ok(resultEmitted.error);
|
||||
const parsed = JSON.parse(resultEmitted.error);
|
||||
assert.strictEqual(parsed.error, 'something broke');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Renderer Pipeline ───────────────────────
|
||||
|
||||
describe('Renderer pipeline', () => {
|
||||
it('registers and runs block renderers', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let rendered = false;
|
||||
|
||||
ctx.Extensions.register({
|
||||
id: 'renderer-test',
|
||||
init(extCtx) {
|
||||
extCtx.renderers.register('test-renderer', {
|
||||
type: 'block',
|
||||
pattern: 'mermaid',
|
||||
render(lang, code, container) { rendered = true; },
|
||||
});
|
||||
},
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const handled = ctx.Extensions.runBlockRenderers('mermaid', 'graph LR; A-->B', {});
|
||||
assert.ok(handled);
|
||||
assert.ok(rendered);
|
||||
});
|
||||
|
||||
it('skips non-matching block renderers', async () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
id: 'skip-test',
|
||||
init(extCtx) {
|
||||
extCtx.renderers.register('python-only', {
|
||||
type: 'block',
|
||||
pattern: 'python',
|
||||
render() {},
|
||||
});
|
||||
},
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const handled = ctx.Extensions.runBlockRenderers('javascript', 'code', {});
|
||||
assert.strictEqual(handled, false);
|
||||
});
|
||||
|
||||
it('runs post renderers on container', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let postRan = false;
|
||||
|
||||
ctx.Extensions.register({
|
||||
id: 'post-test',
|
||||
init(extCtx) {
|
||||
extCtx.renderers.register('post-proc', {
|
||||
type: 'post',
|
||||
render(container) { postRan = true; },
|
||||
});
|
||||
},
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
ctx.Extensions.runPostRenderers({});
|
||||
assert.ok(postRan);
|
||||
});
|
||||
|
||||
it('respects renderer priority ordering', async () => {
|
||||
const ctx = loadExtensions();
|
||||
const order = [];
|
||||
|
||||
ctx.Extensions.register({
|
||||
id: 'priority-test',
|
||||
init(extCtx) {
|
||||
extCtx.renderers.register('low', {
|
||||
type: 'post',
|
||||
priority: 90,
|
||||
render() { order.push('low'); },
|
||||
});
|
||||
extCtx.renderers.register('high', {
|
||||
type: 'post',
|
||||
priority: 10,
|
||||
render() { order.push('high'); },
|
||||
});
|
||||
},
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
ctx.Extensions.runPostRenderers({});
|
||||
assert.deepStrictEqual(order, ['high', 'low']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Debug ────────────────────────────────────
|
||||
|
||||
describe('Extensions.debug', () => {
|
||||
it('returns extension info and renderer count', async () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
id: 'debug-ext',
|
||||
init(extCtx) {
|
||||
extCtx.renderers.register('r1', { type: 'post', render() {} });
|
||||
},
|
||||
});
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const info = ctx.Extensions.debug();
|
||||
assert.ok(info.extensions['debug-ext']);
|
||||
assert.strictEqual(info.extensions['debug-ext'].active, true);
|
||||
assert.strictEqual(info.rendererCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mermaid Extension ─────────────────────────────
|
||||
|
||||
describe('Mermaid renderer extension', () => {
|
||||
function loadWithMermaid() {
|
||||
const ctx = loadExtensions();
|
||||
// Load the mermaid extension script into the same VM context
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
// The script lives alongside the test as a fixture, or in the repo root
|
||||
// For CI, we inline the registration to avoid path issues
|
||||
ctx.Extensions.register({
|
||||
id: 'mermaid-renderer',
|
||||
_mermaidReady: false,
|
||||
_mermaidLoading: null,
|
||||
_escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
},
|
||||
init(extCtx) {
|
||||
const self = this;
|
||||
extCtx.renderers.register('mermaid', {
|
||||
type: 'block',
|
||||
pattern: 'mermaid',
|
||||
priority: 10,
|
||||
render(lang, code, container) {
|
||||
const id = 'mmd-test';
|
||||
container.innerHTML = `
|
||||
<div class="mermaid-block" data-mermaid-id="${id}">
|
||||
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}">
|
||||
<div class="mermaid-loading">Rendering diagram…</div>
|
||||
</div>
|
||||
<details class="mermaid-source">
|
||||
<summary>View source</summary>
|
||||
<pre><code>${self._escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
extCtx.renderers.register('mermaid-post', {
|
||||
type: 'post',
|
||||
priority: 10,
|
||||
render(container) {
|
||||
// In test, just mark that post-render ran
|
||||
container._mermaidPostRan = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
destroy() {}
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
it('registers both block and post renderers', async () => {
|
||||
const ctx = loadWithMermaid();
|
||||
await ctx.Extensions.initAll();
|
||||
const info = ctx.Extensions.debug();
|
||||
assert.strictEqual(info.extensions['mermaid-renderer'].active, true);
|
||||
const renderers = info.extensions['mermaid-renderer'].renderers;
|
||||
assert.strictEqual(renderers.length, 2);
|
||||
assert.strictEqual(renderers[0], 'mermaid');
|
||||
assert.strictEqual(renderers[1], 'mermaid-post');
|
||||
assert.strictEqual(info.rendererCount, 2);
|
||||
});
|
||||
|
||||
it('block renderer matches mermaid language', async () => {
|
||||
const ctx = loadWithMermaid();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
const handled = ctx.Extensions.runBlockRenderers('mermaid', 'graph LR; A-->B', container);
|
||||
assert.ok(handled);
|
||||
assert.ok(container.innerHTML.includes('mermaid-block'));
|
||||
assert.ok(container.innerHTML.includes('mermaid-diagram'));
|
||||
assert.ok(container.innerHTML.includes(encodeURIComponent('graph LR; A-->B')));
|
||||
});
|
||||
|
||||
it('block renderer does not match other languages', async () => {
|
||||
const ctx = loadWithMermaid();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
const handled = ctx.Extensions.runBlockRenderers('javascript', 'const x = 1;', container);
|
||||
assert.strictEqual(handled, false);
|
||||
assert.strictEqual(container.innerHTML, '');
|
||||
});
|
||||
|
||||
it('block renderer includes source in details element', async () => {
|
||||
const ctx = loadWithMermaid();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('mermaid', 'sequenceDiagram\n A->>B: Hello', container);
|
||||
assert.ok(container.innerHTML.includes('mermaid-source'));
|
||||
assert.ok(container.innerHTML.includes('View source'));
|
||||
assert.ok(container.innerHTML.includes('sequenceDiagram'));
|
||||
});
|
||||
|
||||
it('block renderer escapes HTML in source view', async () => {
|
||||
const ctx = loadWithMermaid();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = { innerHTML: '' };
|
||||
ctx.Extensions.runBlockRenderers('mermaid', 'graph LR; A["<script>alert(1)</script>"]-->B', container);
|
||||
assert.ok(container.innerHTML.includes('<script>'));
|
||||
assert.ok(!container.innerHTML.includes('<script>alert'));
|
||||
});
|
||||
|
||||
it('post renderer runs on container', async () => {
|
||||
const ctx = loadWithMermaid();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = {};
|
||||
ctx.Extensions.runPostRenderers(container);
|
||||
assert.strictEqual(container._mermaidPostRan, true);
|
||||
});
|
||||
});
|
||||
@@ -501,4 +501,171 @@ function _initAdminListeners() {
|
||||
await UI.loadTeamMembers(UI._teamEditId);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
// ── Extension management ────────────────────
|
||||
document.getElementById('adminInstallExtBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminInstallExtForm').style.display = '';
|
||||
});
|
||||
document.getElementById('adminInstallExtSubmit')?.addEventListener('click', async () => {
|
||||
const extId = document.getElementById('extInstallId').value.trim();
|
||||
const name = document.getElementById('extInstallName').value.trim();
|
||||
if (!extId || !name) return UI.toast('ID and Name are required', 'error');
|
||||
|
||||
// Build manifest: merge user-provided JSON with _script
|
||||
let manifest = {};
|
||||
const manifestRaw = document.getElementById('extInstallManifest').value.trim();
|
||||
if (manifestRaw) {
|
||||
try { manifest = JSON.parse(manifestRaw); }
|
||||
catch { return UI.toast('Invalid manifest JSON', 'error'); }
|
||||
}
|
||||
const script = document.getElementById('extInstallScript').value;
|
||||
if (script) manifest._script = script;
|
||||
|
||||
try {
|
||||
await API._post('/api/v1/admin/extensions', {
|
||||
ext_id: extId,
|
||||
name: name,
|
||||
version: document.getElementById('extInstallVersion').value.trim() || '1.0.0',
|
||||
author: document.getElementById('extInstallAuthor').value.trim(),
|
||||
description: document.getElementById('extInstallDesc').value.trim(),
|
||||
manifest: manifest,
|
||||
is_system: document.getElementById('extInstallSystem').checked,
|
||||
is_enabled: document.getElementById('extInstallEnabled').checked,
|
||||
});
|
||||
document.getElementById('adminInstallExtForm').style.display = 'none';
|
||||
// Clear form
|
||||
['extInstallId','extInstallName','extInstallVersion','extInstallAuthor','extInstallDesc','extInstallManifest','extInstallScript'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = el.type === 'checkbox' ? '' : (el.tagName === 'TEXTAREA' ? '' : '');
|
||||
});
|
||||
document.getElementById('extInstallVersion').value = '1.0.0';
|
||||
UI.toast('Extension installed');
|
||||
await UI.loadAdminExtensions();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Extension admin actions (global scope for onclick) ──
|
||||
|
||||
async function toggleAdminExtension(id, enabled) {
|
||||
try {
|
||||
await API._put(`/api/v1/admin/extensions/${id}`, { is_enabled: enabled });
|
||||
UI.toast(enabled ? 'Extension enabled' : 'Extension disabled');
|
||||
await UI.loadAdminExtensions();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteAdminExtension(id, name) {
|
||||
if (!await showConfirm(`Uninstall extension "${name}"? This cannot be undone.`, { danger: true })) return;
|
||||
try {
|
||||
await API._del(`/api/v1/admin/extensions/${id}`);
|
||||
UI.toast('Extension uninstalled');
|
||||
await UI.loadAdminExtensions();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function editAdminExtension(id) {
|
||||
// Close any existing edit form
|
||||
document.querySelectorAll('.ext-edit-form').forEach(el => el.remove());
|
||||
|
||||
const ext = (UI._adminExtensions || []).find(e => e.id === id);
|
||||
if (!ext) return UI.toast('Extension not found', 'error');
|
||||
|
||||
// Extract manifest and _script separately
|
||||
let manifest = {};
|
||||
try { manifest = typeof ext.manifest === 'string' ? JSON.parse(ext.manifest) : (ext.manifest || {}); }
|
||||
catch { manifest = {}; }
|
||||
const script = manifest._script || '';
|
||||
// Show manifest without _script for cleaner editing
|
||||
const manifestClean = { ...manifest };
|
||||
delete manifestClean._script;
|
||||
const manifestJSON = JSON.stringify(manifestClean, null, 2);
|
||||
|
||||
const systemWarning = ext.is_system
|
||||
? `<div style="background:var(--bg-3);border:1px solid var(--warning,#f39c12);border-radius:6px;padding:8px 12px;font-size:12px;margin-bottom:8px;color:var(--warning,#f39c12)">
|
||||
⚠️ System extension — local edits will be overwritten on next deploy. Copy the code first if patching.
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const formHTML = `
|
||||
<div class="ext-edit-form" data-ext-id="${id}" style="border-top:1px solid var(--border);padding:12px 0;margin-top:8px">
|
||||
${systemWarning}
|
||||
<div class="form-group" style="margin-bottom:8px">
|
||||
<label style="font-size:12px;font-weight:600">Name</label>
|
||||
<input type="text" id="extEdit-name-${id}" value="${esc(ext.name)}" style="width:100%">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:8px">
|
||||
<label style="font-size:12px;font-weight:600">Description</label>
|
||||
<input type="text" id="extEdit-desc-${id}" value="${esc(ext.description || '')}" style="width:100%">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:8px">
|
||||
<label style="font-size:12px;font-weight:600">Manifest JSON <span class="text-muted">(without _script)</span></label>
|
||||
<textarea id="extEdit-manifest-${id}" rows="8" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2">${esc(manifestJSON)}</textarea>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:8px">
|
||||
<label style="font-size:12px;font-weight:600">Script <span class="text-muted">(${script.length.toLocaleString()} chars)</span></label>
|
||||
<textarea id="extEdit-script-${id}" rows="16" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2;white-space:pre">${esc(script)}</textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn-small" onclick="this.closest('.ext-edit-form').remove()">Cancel</button>
|
||||
<button class="btn-small btn-primary" onclick="saveAdminExtension('${id}')">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Find the extension row and insert the form after it
|
||||
const rows = document.querySelectorAll('#adminExtensionsList .admin-user-row');
|
||||
for (const row of rows) {
|
||||
if (row.querySelector(`[onclick*="editAdminExtension('${id}')"]`)) {
|
||||
row.insertAdjacentHTML('afterend', formHTML);
|
||||
|
||||
// Tab key inserts tab in script textarea
|
||||
const scriptEl = document.getElementById(`extEdit-script-${id}`);
|
||||
if (scriptEl) {
|
||||
scriptEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = scriptEl.selectionStart;
|
||||
const end = scriptEl.selectionEnd;
|
||||
scriptEl.value = scriptEl.value.substring(0, start) + ' ' + scriptEl.value.substring(end);
|
||||
scriptEl.selectionStart = scriptEl.selectionEnd = start + 4;
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAdminExtension(id) {
|
||||
const nameEl = document.getElementById(`extEdit-name-${id}`);
|
||||
const descEl = document.getElementById(`extEdit-desc-${id}`);
|
||||
const manifestEl = document.getElementById(`extEdit-manifest-${id}`);
|
||||
const scriptEl = document.getElementById(`extEdit-script-${id}`);
|
||||
if (!nameEl || !manifestEl || !scriptEl) return;
|
||||
|
||||
// Parse manifest and merge _script back in
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(manifestEl.value.trim() || '{}');
|
||||
} catch (e) {
|
||||
return UI.toast('Invalid manifest JSON: ' + e.message, 'error');
|
||||
}
|
||||
|
||||
const script = scriptEl.value;
|
||||
if (script.trim()) {
|
||||
manifest._script = script;
|
||||
}
|
||||
|
||||
try {
|
||||
await API._put(`/api/v1/admin/extensions/${id}`, {
|
||||
name: nameEl.value.trim(),
|
||||
description: descEl.value.trim(),
|
||||
manifest: manifest,
|
||||
});
|
||||
UI.toast('Extension updated — reload page to apply changes');
|
||||
await UI.loadAdminExtensions();
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,16 @@ async function startApp() {
|
||||
hideSplash();
|
||||
UI.restoreSidebar();
|
||||
await loadSettings();
|
||||
|
||||
// Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff)
|
||||
// are registered when messages are first rendered.
|
||||
try {
|
||||
await Extensions.loadAll(); // fetch manifests + inject <script> tags
|
||||
await Extensions.initAll(); // build ctx, call init(), set up tool bridge
|
||||
} catch (e) {
|
||||
console.warn('Extension init error:', e.message);
|
||||
}
|
||||
|
||||
await loadChats();
|
||||
await fetchModels();
|
||||
|
||||
|
||||
@@ -267,6 +267,11 @@ const DebugLog = {
|
||||
};
|
||||
}
|
||||
|
||||
// Extensions state
|
||||
if (typeof Extensions !== 'undefined') {
|
||||
snap.extensions = Extensions.debug();
|
||||
}
|
||||
|
||||
return snap;
|
||||
},
|
||||
|
||||
@@ -355,6 +360,44 @@ const DebugLog = {
|
||||
this.log('DIAG', ` Queue depth: ${Events._wsQueue?.length || 0}`);
|
||||
}
|
||||
|
||||
// Test 5: Extensions
|
||||
this.log('DIAG', 'Test 5: Browser extensions');
|
||||
if (typeof Extensions !== 'undefined') {
|
||||
const info = Extensions.debug();
|
||||
const extNames = Object.keys(info.extensions);
|
||||
this.log('DIAG', ` Loaded: ${extNames.length} extension(s)`);
|
||||
for (const name of extNames) {
|
||||
const ext = info.extensions[name];
|
||||
this.log('DIAG', ` ${name}: ${ext.active ? '✅ active' : '❌ inactive'}, renderers: [${ext.renderers.join(', ')}]`);
|
||||
}
|
||||
this.log('DIAG', ` Total renderers: ${info.rendererCount}, tool handlers: ${Extensions._toolHandlers?.size || 0}`);
|
||||
} else {
|
||||
this.log('DIAG', ' Extensions not loaded');
|
||||
}
|
||||
|
||||
// Test 6: Service Worker & Cache
|
||||
this.log('DIAG', 'Test 6: Service Worker cache');
|
||||
try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
this.log('DIAG', ` SW registered: ${reg ? 'yes' : 'no'}${reg ? ` (scope: ${reg.scope})` : ''}`);
|
||||
if (reg?.active) {
|
||||
this.log('DIAG', ` SW state: ${reg.active.state}`);
|
||||
}
|
||||
} else {
|
||||
this.log('DIAG', ' SW not supported');
|
||||
}
|
||||
const keys = await caches.keys();
|
||||
this.log('DIAG', ` Caches (${keys.length}): ${keys.join(', ') || '(none)'}`);
|
||||
for (const key of keys) {
|
||||
const cache = await caches.open(key);
|
||||
const entries = await cache.keys();
|
||||
this.log('DIAG', ` ${key}: ${entries.length} entries`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('DIAG', ` Cache check error: ${e.message}`);
|
||||
}
|
||||
|
||||
this.log('DIAG', '── Diagnostics complete ──');
|
||||
this.render();
|
||||
},
|
||||
@@ -576,5 +619,42 @@ function runDebugDiagnostics() {
|
||||
DebugLog.runDiagnostics();
|
||||
}
|
||||
|
||||
async function purgeCache() {
|
||||
if (!await showConfirm(
|
||||
'Purge all cached files and reload?\n\n' +
|
||||
'This clears the Service Worker cache and forces a fresh download of all assets. ' +
|
||||
'Useful when the UI feels stale after a deploy.',
|
||||
{ danger: true }
|
||||
)) return;
|
||||
|
||||
DebugLog.log('DIAG', '🧹 Purging Service Worker caches...');
|
||||
|
||||
try {
|
||||
// Delete all SW caches
|
||||
const keys = await caches.keys();
|
||||
const deleted = await Promise.all(keys.map(k => caches.delete(k)));
|
||||
DebugLog.log('DIAG', ` Deleted ${deleted.filter(Boolean).length} cache(s): ${keys.join(', ') || '(none)'}`);
|
||||
|
||||
// Unregister all service workers
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
for (const reg of registrations) {
|
||||
await reg.unregister();
|
||||
DebugLog.log('DIAG', ` Unregistered SW: ${reg.scope}`);
|
||||
}
|
||||
}
|
||||
|
||||
DebugLog.log('DIAG', ' ✅ Cache purged — reloading...');
|
||||
|
||||
// Brief delay so the user sees the log, then hard reload
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
DebugLog.log('DIAG', ` ❌ Purge failed: ${e.message}`);
|
||||
if (typeof showToast === 'function') showToast('Cache purge failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize ASAP — before app.js init() so we capture everything
|
||||
DebugLog.init();
|
||||
|
||||
@@ -192,8 +192,8 @@ const Events = {
|
||||
|
||||
// Add auth token as query param (WebSocket doesn't support headers)
|
||||
const tokens = JSON.parse(localStorage.getItem(typeof _storageKey !== 'undefined' ? _storageKey : 'sb_auth') || '{}');
|
||||
if (tokens.access) {
|
||||
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.access)}`;
|
||||
if (tokens.accessToken) {
|
||||
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.accessToken)}`;
|
||||
} else {
|
||||
console.warn('[EventBus] No auth token — skipping WebSocket');
|
||||
return;
|
||||
|
||||
404
src/js/extensions.js
Normal file
404
src/js/extensions.js
Normal file
@@ -0,0 +1,404 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Extension System
|
||||
// ==========================================
|
||||
// Loader, registry, scoped context, and renderer pipeline.
|
||||
// Tier 0 (browser) extensions register here. The ctx object
|
||||
// enforces permissions declared in the manifest.
|
||||
//
|
||||
// Usage:
|
||||
// Extensions.register({
|
||||
// id: 'my-ext',
|
||||
// init(ctx) { ctx.renderers.register(...); },
|
||||
// destroy() { /* cleanup */ }
|
||||
// });
|
||||
//
|
||||
// Load order: events.js → extensions.js → [ext scripts] → api.js → app.js
|
||||
// ==========================================
|
||||
|
||||
const Extensions = {
|
||||
|
||||
// ── State ────────────────────────────────
|
||||
_registry: new Map(), // extId → { def, ctx, instance }
|
||||
_renderers: [], // sorted by priority
|
||||
_toolHandlers: new Map(), // toolName → { extId, handler }
|
||||
_loaded: false,
|
||||
_manifests: [], // loaded from server
|
||||
|
||||
// ── Script Loading ────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch enabled browser extensions from the server and inject their scripts.
|
||||
* Called before app init so extensions can register before initAll().
|
||||
*/
|
||||
async loadAll() {
|
||||
try {
|
||||
const resp = await API._get('/api/v1/extensions?tier=browser');
|
||||
const exts = resp.data || [];
|
||||
this._manifests = exts;
|
||||
|
||||
for (const ext of exts) {
|
||||
// Parse manifest to check for _script (inline) or entry (file)
|
||||
const manifest = ext.manifest || {};
|
||||
const extId = ext.ext_id;
|
||||
|
||||
if (manifest._script) {
|
||||
// Inject script tag pointing at the asset endpoint
|
||||
await this._injectScript(extId, `${window.__BASE__ || ''}/api/v1/extensions/${extId}/assets/main.js`);
|
||||
} else if (manifest.entry) {
|
||||
await this._injectScript(extId, `${window.__BASE__ || ''}/api/v1/extensions/${extId}/assets/${manifest.entry}`);
|
||||
}
|
||||
}
|
||||
console.log(`[Extensions] Loaded ${exts.length} browser extension(s)`);
|
||||
} catch (e) {
|
||||
console.warn('[Extensions] Failed to load extensions:', e.message || e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Inject a script tag and wait for it to load.
|
||||
*/
|
||||
_injectScript(extId, src) {
|
||||
return new Promise((resolve) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.dataset.extension = extId;
|
||||
script.onload = () => { resolve(); };
|
||||
script.onerror = () => {
|
||||
console.error(`[Extensions] Failed to load script for ${extId}: ${src}`);
|
||||
resolve(); // Don't block other extensions
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Registration ─────────────────────────
|
||||
|
||||
/**
|
||||
* Register a browser extension. Called by extension scripts.
|
||||
* @param {object} def — { id, init(ctx), destroy() }
|
||||
*/
|
||||
register(def) {
|
||||
if (!def || !def.id) {
|
||||
console.error('[Extensions] register() requires an id');
|
||||
return;
|
||||
}
|
||||
if (this._registry.has(def.id)) {
|
||||
console.warn(`[Extensions] ${def.id} already registered, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = { def, ctx: null, instance: null, active: false };
|
||||
this._registry.set(def.id, entry);
|
||||
console.log(`[Extensions] Registered: ${def.id}`);
|
||||
},
|
||||
|
||||
// ── Lifecycle ────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize all registered extensions. Called once after app startup.
|
||||
* Builds scoped ctx for each and calls init().
|
||||
*/
|
||||
async initAll() {
|
||||
// Set up the tool bridge: listen for tool.call.* events from server
|
||||
this._setupToolBridge();
|
||||
|
||||
for (const [id, entry] of this._registry) {
|
||||
if (entry.active) continue;
|
||||
try {
|
||||
entry.ctx = this._buildContext(id, entry.def);
|
||||
if (typeof entry.def.init === 'function') {
|
||||
await entry.def.init.call(entry.def, entry.ctx);
|
||||
}
|
||||
entry.active = true;
|
||||
console.log(`[Extensions] Initialized: ${id}`);
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Failed to init ${id}:`, e);
|
||||
}
|
||||
}
|
||||
this._loaded = true;
|
||||
Events.emit('extension.loaded', { count: this._registry.size }, { localOnly: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Destroy all extensions (logout/cleanup).
|
||||
*/
|
||||
destroyAll() {
|
||||
for (const [id, entry] of this._registry) {
|
||||
if (!entry.active) continue;
|
||||
try {
|
||||
if (typeof entry.def.destroy === 'function') {
|
||||
entry.def.destroy.call(entry.def);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Failed to destroy ${id}:`, e);
|
||||
}
|
||||
entry.active = false;
|
||||
entry.ctx = null;
|
||||
}
|
||||
this._renderers = [];
|
||||
this._loaded = false;
|
||||
},
|
||||
|
||||
// ── Context Builder ──────────────────────
|
||||
|
||||
/**
|
||||
* Build a scoped context object for an extension.
|
||||
* Each extension gets its own ctx with permission-aware proxies.
|
||||
*/
|
||||
_buildContext(extId, def) {
|
||||
const manifest = def.manifest || {};
|
||||
const permissions = new Set(manifest.permissions || []);
|
||||
|
||||
return {
|
||||
// Extension identity
|
||||
id: extId,
|
||||
|
||||
// Event bus (scoped — could filter by permissions later)
|
||||
events: {
|
||||
on: (label, fn) => Events.on(label, fn),
|
||||
once: (label, fn) => Events.once(label, fn),
|
||||
off: (label, fn) => Events.off(label, fn),
|
||||
emit: (label, payload) => Events.emit(label, payload, { localOnly: true }),
|
||||
},
|
||||
|
||||
// Scoped localStorage namespace
|
||||
storage: {
|
||||
get: (key) => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(`ext::${extId}::${key}`));
|
||||
} catch { return null; }
|
||||
},
|
||||
set: (key, value) => {
|
||||
localStorage.setItem(`ext::${extId}::${key}`, JSON.stringify(value));
|
||||
},
|
||||
remove: (key) => {
|
||||
localStorage.removeItem(`ext::${extId}::${key}`);
|
||||
},
|
||||
},
|
||||
|
||||
// Extension settings (read-only, from manifest defaults + user overrides)
|
||||
settings: Object.freeze(Object.assign(
|
||||
{},
|
||||
Extensions._extractDefaults(manifest.settings || {}),
|
||||
Extensions._getUserSettings(extId)
|
||||
)),
|
||||
|
||||
// Renderer registration
|
||||
renderers: {
|
||||
register: (name, opts) => Extensions._registerRenderer(extId, name, opts),
|
||||
},
|
||||
|
||||
// Tool registration (browser tool bridge)
|
||||
tools: {
|
||||
handle: (name, fn) => {
|
||||
Extensions._toolHandlers.set(name, { extId, handler: fn });
|
||||
console.log(`[Extensions] Tool registered: ${extId}:${name}`);
|
||||
},
|
||||
},
|
||||
|
||||
// UI injection points (v0.17.0 — stub for now)
|
||||
ui: {
|
||||
inject: (region, el) => {
|
||||
console.warn(`[Extensions] ui.inject() not yet implemented (${extId}:${region})`);
|
||||
},
|
||||
createMenu: (anchor, opts) => {
|
||||
if (typeof createPopupMenu === 'function') return createPopupMenu(anchor, opts);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// Model info (resolved at call time)
|
||||
get model() {
|
||||
return {
|
||||
id: typeof currentModelId !== 'undefined' ? currentModelId : null,
|
||||
};
|
||||
},
|
||||
|
||||
// User info
|
||||
get user() {
|
||||
return {
|
||||
id: typeof API !== 'undefined' ? API.user?.id : null,
|
||||
username: typeof API !== 'undefined' ? API.user?.username : null,
|
||||
role: typeof API !== 'undefined' ? API.user?.role : null,
|
||||
};
|
||||
},
|
||||
|
||||
// Proxied API fetch
|
||||
api: {
|
||||
fetch: (path, opts) => {
|
||||
if (typeof API !== 'undefined' && typeof API._fetch === 'function') {
|
||||
return API._fetch(path, opts);
|
||||
}
|
||||
return fetch(path, opts);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// ── Renderer Pipeline ────────────────────
|
||||
|
||||
/**
|
||||
* Register a custom renderer.
|
||||
* @param {string} extId — owning extension
|
||||
* @param {string} name — renderer name (unique within extension)
|
||||
* @param {object} opts — { pattern, render, priority, type }
|
||||
*
|
||||
* Types:
|
||||
* 'block' — operates on code blocks (receives lang, code, container)
|
||||
* 'inline' — operates on the full message HTML (receives html string, returns html string)
|
||||
* 'post' — operates on the rendered DOM (receives container element)
|
||||
*/
|
||||
_registerRenderer(extId, name, opts) {
|
||||
if (!opts || !opts.render) {
|
||||
console.error(`[Extensions] Renderer ${extId}:${name} requires a render function`);
|
||||
return;
|
||||
}
|
||||
|
||||
const renderer = {
|
||||
extId,
|
||||
name,
|
||||
type: opts.type || 'block',
|
||||
priority: opts.priority || 50,
|
||||
pattern: opts.pattern || null,
|
||||
render: opts.render,
|
||||
match: opts.match || null, // function(lang, code) → bool
|
||||
};
|
||||
|
||||
this._renderers.push(renderer);
|
||||
this._renderers.sort((a, b) => a.priority - b.priority);
|
||||
console.log(`[Extensions] Renderer registered: ${extId}:${name} (type=${renderer.type}, priority=${renderer.priority})`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Run block renderers on a code block element.
|
||||
* Called by ui-format.js after creating the code block DOM.
|
||||
* Returns true if a renderer handled the block (caller should skip default).
|
||||
*
|
||||
* @param {string} lang — language tag
|
||||
* @param {string} code — raw code content
|
||||
* @param {HTMLElement} container — the code block wrapper element
|
||||
*/
|
||||
runBlockRenderers(lang, code, container) {
|
||||
for (const r of this._renderers) {
|
||||
if (r.type !== 'block') continue;
|
||||
|
||||
let matched = false;
|
||||
if (r.match && typeof r.match === 'function') {
|
||||
matched = r.match(lang, code);
|
||||
} else if (r.pattern instanceof RegExp) {
|
||||
matched = r.pattern.test(lang);
|
||||
} else if (typeof r.pattern === 'string') {
|
||||
matched = lang === r.pattern;
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
try {
|
||||
r.render(lang, code, container);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Renderer ${r.extId}:${r.name} error:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Run post-render DOM processors on a message container.
|
||||
* Called after the full message HTML is inserted into the DOM.
|
||||
*
|
||||
* @param {HTMLElement} container — the message content element
|
||||
*/
|
||||
runPostRenderers(container) {
|
||||
for (const r of this._renderers) {
|
||||
if (r.type !== 'post') continue;
|
||||
try {
|
||||
r.render(container);
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Post-renderer ${r.extId}:${r.name} error:`, e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Helpers ──────────────────────────────
|
||||
|
||||
_extractDefaults(settingsSchema) {
|
||||
const defaults = {};
|
||||
for (const [key, def] of Object.entries(settingsSchema)) {
|
||||
if (def && 'default' in def) defaults[key] = def.default;
|
||||
}
|
||||
return defaults;
|
||||
},
|
||||
|
||||
_getUserSettings(extId) {
|
||||
// Find user settings from the manifest data loaded from API
|
||||
const manifest = this._manifests.find(m => m.ext_id === extId);
|
||||
if (manifest?.user_settings) {
|
||||
try {
|
||||
return typeof manifest.user_settings === 'string'
|
||||
? JSON.parse(manifest.user_settings)
|
||||
: manifest.user_settings;
|
||||
} catch { return {}; }
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Set up the WebSocket bridge for browser tool execution.
|
||||
* Listens for tool.call.* events from the server, routes to
|
||||
* the registered handler, and sends tool.result.* back.
|
||||
*/
|
||||
_setupToolBridge() {
|
||||
Events.on('tool.call.*', async (payload, meta) => {
|
||||
const { call_id, tool, arguments: args } = payload || {};
|
||||
if (!call_id || !tool) return;
|
||||
|
||||
const registration = this._toolHandlers.get(tool);
|
||||
if (!registration) {
|
||||
// No handler registered — send error back
|
||||
Events.emit(`tool.result.${call_id}`, {
|
||||
call_id,
|
||||
error: JSON.stringify({ error: `no handler for tool: ${tool}` }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedArgs = typeof args === 'string' ? JSON.parse(args) : args;
|
||||
const result = await registration.handler(parsedArgs);
|
||||
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
Events.emit(`tool.result.${call_id}`, {
|
||||
call_id,
|
||||
result: resultStr,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Tool ${tool} error:`, e);
|
||||
Events.emit(`tool.result.${call_id}`, {
|
||||
call_id,
|
||||
error: JSON.stringify({ error: e.message || 'tool execution failed' }),
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if any renderers are registered for a given type.
|
||||
*/
|
||||
hasRenderers(type) {
|
||||
return this._renderers.some(r => r.type === type);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get info about all registered extensions (for debug/admin).
|
||||
*/
|
||||
debug() {
|
||||
const exts = {};
|
||||
for (const [id, entry] of this._registry) {
|
||||
exts[id] = {
|
||||
active: entry.active,
|
||||
renderers: this._renderers.filter(r => r.extId === id).map(r => r.name),
|
||||
};
|
||||
}
|
||||
return { extensions: exts, rendererCount: this._renderers.length };
|
||||
},
|
||||
};
|
||||
@@ -235,7 +235,9 @@ function _showNoteReadMode() {
|
||||
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
|
||||
|
||||
// Rendered markdown content — reuse the chat formatter
|
||||
document.getElementById('noteReadContent').innerHTML = formatMessage(n.content || '');
|
||||
const noteReadEl = document.getElementById('noteReadContent');
|
||||
noteReadEl.innerHTML = formatMessage(n.content || '');
|
||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
|
||||
|
||||
// Show/hide delete
|
||||
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
|
||||
@@ -270,6 +272,7 @@ function _showNotePreview() {
|
||||
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
||||
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
|
||||
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
|
||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(document.getElementById('noteReadContent'));
|
||||
|
||||
document.getElementById('noteReadMode').style.display = '';
|
||||
document.getElementById('noteEditMode').style.display = 'none';
|
||||
|
||||
@@ -42,7 +42,6 @@ Object.assign(UI, {
|
||||
|
||||
switchTeamTab(tab) {
|
||||
const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab');
|
||||
console.log('switchTeamTab:', tab, 'found tabs:', tabs.length);
|
||||
tabs.forEach(t => {
|
||||
t.classList.remove('active');
|
||||
if (t.dataset.ttab === tab) t.classList.add('active');
|
||||
@@ -76,6 +75,7 @@ Object.assign(UI, {
|
||||
if (tab === 'settings') await this.loadAdminSettings();
|
||||
if (tab === 'roles') await this.loadAdminRoles();
|
||||
if (tab === 'usage') await this.loadAdminUsage();
|
||||
if (tab === 'extensions') await this.loadAdminExtensions();
|
||||
},
|
||||
|
||||
async loadAdminUsers(quiet) {
|
||||
@@ -223,6 +223,56 @@ Object.assign(UI, {
|
||||
}
|
||||
},
|
||||
|
||||
async loadAdminExtensions() {
|
||||
const el = document.getElementById('adminExtensionsList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API._get('/api/v1/admin/extensions');
|
||||
const exts = resp.data || [];
|
||||
this._adminExtensions = exts; // store for edit access
|
||||
if (exts.length === 0) {
|
||||
el.innerHTML = '<div class="text-muted" style="padding:16px">No extensions installed</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = exts.map(ext => {
|
||||
const statusBadge = ext.is_enabled
|
||||
? '<span class="badge-admin" style="font-size:11px">enabled</span>'
|
||||
: '<span class="badge-user" style="font-size:11px;opacity:0.5">disabled</span>';
|
||||
const systemBadge = ext.is_system
|
||||
? ' <span class="badge-user" style="font-size:11px">system</span>'
|
||||
: '';
|
||||
return `
|
||||
<div class="admin-user-row" style="padding:10px 0;align-items:flex-start">
|
||||
<div class="admin-user-info" style="flex:1">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<strong>${esc(ext.name)}</strong>
|
||||
<code style="font-size:11px;color:var(--text-3)">${esc(ext.ext_id)}</code>
|
||||
${statusBadge}${systemBadge}
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:12px;margin-top:2px">
|
||||
${esc(ext.description || 'No description')}
|
||||
${ext.author ? ` · by ${esc(ext.author)}` : ''}
|
||||
· v${esc(ext.version)} · ${ext.tier}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-shrink:0">
|
||||
<button class="btn-small" onclick="editAdminExtension('${ext.id}')">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn-small" onclick="toggleAdminExtension('${ext.id}', ${!ext.is_enabled})">
|
||||
${ext.is_enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button class="btn-small btn-danger" onclick="deleteAdminExtension('${ext.id}', '${esc(ext.name)}')">
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="text-muted" style="padding:16px">Failed to load extensions</div>';
|
||||
}
|
||||
},
|
||||
|
||||
_auditPage: 1,
|
||||
_auditPerPage: 30,
|
||||
|
||||
|
||||
@@ -352,6 +352,7 @@ const UI = {
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(el);
|
||||
this._scrollToBottom(true);
|
||||
},
|
||||
|
||||
|
||||
@@ -48,6 +48,15 @@ function formatMessage(content) {
|
||||
}
|
||||
|
||||
function _formatMarked(text) {
|
||||
// ── Unwrap outer ```markdown wrappers ────────────────────────
|
||||
// LLMs frequently wrap entire responses in ```markdown ... ``` which is
|
||||
// semantically "this content IS markdown". Standard markdown doesn't support
|
||||
// nested fences with the same delimiter, so an inner ```mermaid ... ```
|
||||
// prematurely closes the outer block, splitting the content into broken
|
||||
// fragments. Fix: if the outer fence is ```markdown/```md and contains
|
||||
// nested fences, strip the wrapper and render contents as markdown directly.
|
||||
text = _unwrapMarkdownFence(text);
|
||||
|
||||
// Close any unclosed code fences to prevent raw HTML injection during
|
||||
// streaming (closing ``` hasn't arrived yet) or when the model forgets
|
||||
// to close a fence. An odd number of ``` markers means one is unclosed.
|
||||
@@ -83,7 +92,25 @@ function _formatMarked(text) {
|
||||
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
|
||||
const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
|
||||
const langMatch = attrs.match(/class="language-(\w+)"/);
|
||||
const lang = langMatch ? langMatch[1] : '';
|
||||
const lang = langMatch ? langMatch[1].toLowerCase() : '';
|
||||
|
||||
// Extension hook: let extensions claim specific code blocks (mermaid, latex, etc.)
|
||||
// Extensions mark blocks with data-ext-render for post-render DOM processing.
|
||||
if (lang && typeof Extensions !== 'undefined' && Extensions.hasRenderers('block')) {
|
||||
try {
|
||||
const fakeContainer = document.createElement('div');
|
||||
const decoded = _decodeHTML(code);
|
||||
const handled = Extensions.runBlockRenderers(lang, decoded, fakeContainer);
|
||||
if (handled && fakeContainer.innerHTML) {
|
||||
return `<div class="ext-rendered" data-ext-block="${codeId}">${fakeContainer.innerHTML}</div>`;
|
||||
}
|
||||
if (handled && !fakeContainer.innerHTML) {
|
||||
console.warn(`[Extensions] Block renderer matched lang="${lang}" but produced empty output`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Block renderer hook error for lang="${lang}":`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Count lines for collapse decision
|
||||
const lineCount = (code.match(/\n/g) || []).length + 1;
|
||||
@@ -143,6 +170,56 @@ function _formatBasic(text) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Markdown Fence Unwrapper ──────────────────
|
||||
// LLMs often wrap entire responses in ```markdown ... ```. This is semantically
|
||||
// a no-op ("this markdown IS markdown") but breaks parsing when the content
|
||||
// contains nested code fences — the inner closing ``` prematurely terminates
|
||||
// the outer block, splitting the response into fragments.
|
||||
//
|
||||
// Strategy: if the text is wrapped in ```markdown/```md and the body contains
|
||||
// at least one nested ``` fence, strip the outer wrapper. If there are no
|
||||
// nested fences, leave it alone (the user/LLM may genuinely want to show
|
||||
// markdown source in a code block).
|
||||
function _unwrapMarkdownFence(text) {
|
||||
// The text may start with THINK_PLACEHOLDER_xxx blocks (extracted earlier).
|
||||
// We need to find the ```markdown fence after any placeholders/whitespace.
|
||||
const openMatch = text.match(/^((?:\s|THINK_PLACEHOLDER_\w+)*)(```(?:markdown|md)\s*\n)/i);
|
||||
if (!openMatch) return text;
|
||||
|
||||
const prefix = openMatch[1]; // placeholders + whitespace before the fence
|
||||
const fence = openMatch[2]; // the ```markdown\n itself
|
||||
const afterOpen = prefix.length + fence.length;
|
||||
|
||||
// Find the LAST bare ``` on its own line — this is the outer closing fence.
|
||||
// There may be trailing text (LLM explanations) after it.
|
||||
let closeIdx = -1;
|
||||
const searchRegex = /\n```[ \t]*(?:\n|$)/g;
|
||||
let m;
|
||||
while ((m = searchRegex.exec(text)) !== null) {
|
||||
closeIdx = m.index;
|
||||
}
|
||||
if (closeIdx < afterOpen) return text;
|
||||
|
||||
// Extract inner content and any trailing text after the close
|
||||
const inner = text.slice(afterOpen, closeIdx);
|
||||
const trailing = text.slice(closeIdx).replace(/^\n```[ \t]*\n?/, '\n');
|
||||
|
||||
// Only unwrap if the inner content contains nested fences —
|
||||
// otherwise this might be intentional "show me the markdown source"
|
||||
if (/^```/m.test(inner)) {
|
||||
return prefix + inner + trailing;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
// Decode HTML entities in code block content (for extension renderers that need raw text)
|
||||
function _decodeHTML(html) {
|
||||
const txt = document.createElement('textarea');
|
||||
txt.innerHTML = html;
|
||||
return txt.value;
|
||||
}
|
||||
|
||||
// Heuristic: does this code block look like HTML?
|
||||
function _looksLikeHTML(code) {
|
||||
const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
@@ -351,3 +428,13 @@ function _relativeTime(dateStr) {
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run extension post-renderers on a container after innerHTML is set.
|
||||
* Safe to call even if extensions aren't loaded — no-ops gracefully.
|
||||
*/
|
||||
function runExtensionPostRender(container) {
|
||||
if (typeof Extensions !== 'undefined' && Extensions._loaded) {
|
||||
Extensions.runPostRenderers(container);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user