Changeset 0.11.0 (#62)
This commit is contained in:
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 };
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user