This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/extensions.js
Jeffrey Smith 115004a3ab Changeset 0.29.2 (#197)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-17 22:31:34 +00:00

507 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// 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
// ==========================================
//
// Exports: window.Extensions
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.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 primitives — safe wrappers around primary UI components.
// Extensions use these instead of reaching into globals directly.
ui: {
/** Show a toast notification. type: 'success' | 'error' | 'warning' | 'info' */
toast(msg, type = 'success') {
if (typeof UI !== 'undefined' && UI.toast) UI.toast(msg, type);
},
/** Open the side-panel preview with arbitrary HTML content. */
openPreview(html) {
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame) {
frame.srcdoc = html;
frame.style.display = '';
}
if (empty) empty.style.display = 'none';
if (typeof PanelRegistry !== 'undefined') {
PanelRegistry.open('preview');
PanelRegistry.refreshActions();
}
},
/** Is the current theme dark? */
isDark() {
return document.body.classList.contains('dark-theme') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
},
/** Is the viewport at mobile width (≤ 768px)? */
isMobile() {
return window.innerWidth <= 768;
},
/** Is the side panel container currently open? */
isPanelOpen() {
if (typeof PanelRegistry !== 'undefined') return PanelRegistry.isContainerOpen();
return false;
},
/** Show a confirm dialog. Returns Promise<boolean>. */
confirm(msg, opts) {
if (typeof showConfirm === 'function') return showConfirm(msg, opts);
return Promise.resolve(window.confirm(msg));
},
/**
* Replace a surface region's content with a new element.
* Current children are preserved in memory (not destroyed)
* and can be restored later. Critical for CM6 state preservation.
*/
replace(regionId, element) {
console.warn(`[Extensions] ui.replace() requires surface system (removed in v0.22.6)`);
},
/**
* Restore a surface region's previously saved content.
*/
restore(regionId) {
console.warn(`[Extensions] ui.restore() requires surface system (removed in v0.22.6)`);
},
/** Inject an element into a named UI region (stub for future use). */
inject(region, el) {
console.warn(`[Extensions] ui.inject() not yet implemented (${extId}:${region})`);
},
/** Create a popup menu anchored to an element. */
createMenu(anchor, opts) {
if (typeof createPopupMenu === 'function') return createPopupMenu(anchor, opts);
return null;
},
},
// Surface registration (removed v0.22.6 — server-rendered surfaces)
surfaces: {
register: (id, opts) => {
console.warn(`[Extensions] surfaces.register() removed in v0.22.6`);
},
unregister: (id) => {
console.warn(`[Extensions] surfaces.unregister() removed in v0.22.6`);
},
activate: (id) => {
console.warn(`[Extensions] surfaces.activate() removed in v0.22.6`);
},
getCurrent: () => {
return window.__SURFACE__ || 'chat';
},
},
// 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);
// v0.28.5: Shim 'post' renderers into the SDK render pipe.
// This makes old ctx.renderers.register() calls visible in
// sw.pipe.list() and ordered alongside new sw.pipe.render() filters.
// Block renderers stay in the old path (they run during formatMessage,
// not after DOM insertion).
if (renderer.type === 'post' && typeof sw !== 'undefined' && sw.pipe) {
sw.pipe.render(renderer.priority, (renderCtx) => {
try {
renderer.render(renderCtx.element);
} catch (e) {
console.error(`[Extensions] Shimmed post-renderer ${extId}:${name} error:`, e);
}
return renderCtx;
}, { source: `${extId}:${name}` });
}
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.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 };
},
};
// ── Exports ─────────────────────────────────
sb.ns('Extensions', Extensions);