Changeset 0.11.0 (#62)

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

404
src/js/extensions.js Normal file
View 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 };
},
};