// ========================================== // 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. */ 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);