405 lines
15 KiB
JavaScript
405 lines
15 KiB
JavaScript
// ==========================================
|
||
// 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 };
|
||
},
|
||
};
|