// ========================================== // Chat Switchboard — SDK (switchboard-sdk.js) // ========================================== // v0.28.5: Composition layer over platform globals. Surface and // extension authors consume this instead of hunting through 15 JS files. // // Usage: // const sw = Switchboard.init(); // sw.api.get('/api/v1/channels').then(console.log); // sw.on('chat.message.*', (payload) => { ... }); // sw.pipe.render(50, (ctx) => { ... }); // // Load order: after ui-core.js + pane-container.js, before extensions.js. // // Exports: window.Switchboard, window.sw (convenience alias) // ========================================== 'use strict'; const Switchboard = { _instance: null, /** * Initialize the SDK. Idempotent — returns the same instance on * subsequent calls. Call early in the boot sequence (app.js init()). * * @param {object} [opts] — Reserved for future use ({ mount } etc.) * @returns {object} sw — The SDK instance */ init(opts) { if (this._instance) return this._instance; const _opts = opts || {}; // ── Build the sw instance ──────────────────────────── const sw = Object.create(null); // ── Identity ───────────────────────────────────────── Object.defineProperty(sw, 'user', { get() { if (typeof API === 'undefined') return null; const u = API.user; if (!u) return null; return { id: u.id, username: u.username, display_name: u.display_name || u.username, email: u.email || null, role: u.role || 'user', avatar: u.avatar || null, }; }, enumerable: true, }); Object.defineProperty(sw, 'isAdmin', { get() { return typeof API !== 'undefined' && API.isAdmin === true; }, enumerable: true, }); // ── REST Client ────────────────────────────────────── sw.api = { /** * GET request. Returns parsed JSON. * @param {string} path — API path (e.g. '/api/v1/channels') * @param {object} [opts] — { signal } */ async get(path, opts) { return API._get(path, opts?.signal); }, /** * POST request. Returns parsed JSON. * @param {string} path * @param {object} body * @param {object} [opts] — { signal } */ async post(path, body, opts) { return API._post(path, body, false, opts?.signal); }, /** * PUT request. Returns parsed JSON. * @param {string} path * @param {object} body * @param {object} [opts] — { signal } */ async put(path, body, opts) { return API._put(path, body, opts?.signal); }, /** * DELETE request. Returns parsed JSON (or empty on 204). * @param {string} path * @param {object} [opts] — { signal } */ async del(path, opts) { return API._delete(path, opts?.signal); }, /** * Streaming POST. Returns raw Response for SSE consumption. * Same contract as API.streamCompletion but generic. * @param {string} path * @param {object} body * @param {AbortSignal} [signal] */ async stream(path, body, signal) { const BASE = window.__BASE__ || ''; let resp = await fetch(BASE + path, { method: 'POST', headers: API._authHeaders(), body: JSON.stringify(body), signal, }); if (resp.status === 401) { if (await API._handle401()) { resp = await fetch(BASE + path, { method: 'POST', headers: API._authHeaders(), body: JSON.stringify(body), signal, }); } } if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } return resp; }, }; // ── Events ─────────────────────────────────────────── sw.on = function (label, fn) { return Events.on(label, fn); }; sw.once = function (label, fn) { return Events.once(label, fn); }; sw.off = function (label, fn) { Events.off(label, fn); }; sw.emit = function (label, payload, opts) { Events.emit(label, payload, opts); }; // ── Theme ──────────────────────────────────────────── sw.theme = { /** Resolved theme: 'dark' or 'light' (never 'system'). */ get current() { if (typeof Theme !== 'undefined') return Theme.resolved(); return 'dark'; }, /** User preference: 'dark', 'light', or 'system'. */ get mode() { if (typeof Theme !== 'undefined') return Theme.get(); return 'system'; }, /** Set theme mode. */ set(mode) { if (typeof Theme !== 'undefined') Theme.set(mode); }, /** * Subscribe to theme changes. * @param {string} event — 'change' * @param {Function} fn — receives resolved theme string * @returns {Function} unsubscribe */ on(event, fn) { if (event === 'change') { return Events.on('theme.changed', (payload) => { const resolved = typeof Theme !== 'undefined' ? Theme.resolved() : 'dark'; fn(resolved); }); } console.warn(`[Switchboard] theme.on: unknown event '${event}'`); return () => {}; }, }; // ── UI Primitives ──────────────────────────────────── sw.toast = function (message, type) { if (typeof UI !== 'undefined' && UI.toast) { UI.toast(message, type || 'success'); } }; sw.confirm = function (message, opts) { if (typeof showConfirm === 'function') return showConfirm(message, opts); return Promise.resolve(window.confirm(message)); }; sw.modal = { open(contentOrId) { if (typeof openModal === 'function') openModal(contentOrId); }, close(id) { if (typeof closeModal === 'function') closeModal(id); }, }; // ── Components ─────────────────────────────────────── /** * Create a ChatPane instance in a container element. * Wraps ChatPane.create() with SDK-aware defaults. * * @param {HTMLElement} container — mount target * @param {object} opts — { channelId, standalone } * @returns {object} ChatPane instance (renderMessages, destroy, etc.) */ sw.chat = function (container, opts) { if (typeof ChatPane === 'undefined') { console.error('[Switchboard] ChatPane not available'); return null; } const _opts = opts || {}; // Find or create required child elements let messagesEl = container.querySelector('.sw-chat-messages'); let inputEl = container.querySelector('.sw-chat-input'); if (!messagesEl) { messagesEl = document.createElement('div'); messagesEl.className = 'sw-chat-messages chat-messages'; container.appendChild(messagesEl); } if (!inputEl) { inputEl = document.createElement('div'); inputEl.className = 'sw-chat-input'; container.appendChild(inputEl); } return ChatPane.create({ messagesEl, inputEl, channelId: _opts.channelId || null, standalone: _opts.standalone !== false, }); }; /** * Initialize notes in a container element. * NOTE: v0.28.5 stub — notes lacks a clean create() factory. * Full component extraction is pre-1.0 tech debt. * * @param {HTMLElement} container * @param {object} opts — { projectId } */ sw.notes = function (container, opts) { console.warn('[Switchboard] sw.notes() not yet available — notes component needs refactor (pre-1.0)'); return null; }; // ── Pipe/Filter Pipeline ───────────────────────────── // CS1: registration + introspection. CS2: execution engine // wired into chat.js, ui-core.js, ui-format.js. const _chains = { pre: [], // { priority, fn, scope, source, stats } stream: [], render: [], }; /** * Register a filter into a pipe stage. * @param {string} stage — 'pre' | 'stream' | 'render' * @param {number} priority — lower runs first * @param {Function} fn — filter function * @param {object} [opts] — { scope: { channelType: [...] }, source: string } */ function _registerFilter(stage, priority, fn, opts) { if (typeof fn !== 'function') { console.error(`[Switchboard] pipe.${stage}: filter must be a function`); return; } const chain = _chains[stage]; if (!chain) { console.error(`[Switchboard] pipe.${stage}: unknown stage`); return; } const _opts = opts || {}; const entry = { priority: priority, fn: fn, scope: _opts.scope || null, source: _opts.source || _inferSource(), stats: { calls: 0, totalMs: 0, errors: 0 }, }; // Check for duplicate (same source + priority) — warn but allow const dup = chain.find(e => e.source === entry.source && e.priority === entry.priority); if (dup) { console.warn(`[Switchboard] pipe.${stage}: duplicate registration (source=${entry.source}, priority=${priority})`); } chain.push(entry); chain.sort((a, b) => a.priority - b.priority || 0); } /** * Infer the source label from the call stack. * Tries to extract extension ID from the script path. */ function _inferSource() { try { const stack = new Error().stack || ''; // Look for extensions/{id}/ or ext:: patterns const extMatch = stack.match(/extensions\/([^/]+)\//); if (extMatch) return extMatch[1]; } catch (_) { /* best effort */ } return 'anonymous'; } /** * Run a filter chain against a context. Returns modified context or null. * Sync-only in v0.28.5 — filters must not return Promises. * * @param {string} stage — chain name * @param {object} ctx — context object (mutated in place) * @returns {object|null} — modified context or null (halted) */ function _runChain(stage, ctx) { const chain = _chains[stage]; if (!chain || chain.length === 0) return ctx; const channelType = ctx.channel?.type || null; for (const entry of chain) { // Scope check: skip if filter's channelType list doesn't include this channel if (entry.scope?.channelType) { if (!channelType || !entry.scope.channelType.includes(channelType)) { continue; // scoped out — zero overhead, no call, no stats } } const t0 = performance.now(); try { const result = entry.fn(ctx); entry.stats.calls++; entry.stats.totalMs += performance.now() - t0; if (result === null || result === undefined) { // Halt the chain return null; } ctx = result; } catch (e) { entry.stats.calls++; entry.stats.errors++; entry.stats.totalMs += performance.now() - t0; console.error(`[Switchboard] pipe.${stage} filter '${entry.source}' (p=${entry.priority}) threw:`, e); // Continue chain — error isolation } } return ctx; } sw.pipe = { /** * Register a pre-send filter. * @param {number} priority * @param {Function} fn — (PreSendContext) => PreSendContext | null * @param {object} [opts] — { scope, source } */ pre(priority, fn, opts) { _registerFilter('pre', priority, fn, opts); }, /** * Register a post-receive stream filter. * @param {number} priority * @param {Function} fn — (StreamContext) => StreamContext | null * @param {object} [opts] — { scope, source } */ stream(priority, fn, opts) { _registerFilter('stream', priority, fn, opts); }, /** * Register a post-render filter. * @param {number} priority * @param {Function} fn — (RenderContext) => RenderContext | null * @param {object} [opts] — { scope, source } */ render(priority, fn, opts) { _registerFilter('render', priority, fn, opts); }, /** * List all registered filters with stats. * @returns {object} { pre: [...], stream: [...], render: [...] } */ list() { const result = {}; for (const [stage, chain] of Object.entries(_chains)) { result[stage] = chain.map(e => ({ priority: e.priority, source: e.source, scope: e.scope, calls: e.stats.calls, avgMs: e.stats.calls > 0 ? Math.round((e.stats.totalMs / e.stats.calls) * 100) / 100 : 0, errors: e.stats.errors, })); } return result; }, // ── Internal: called by patched chat.js / ui-core.js / ui-format.js ── /** Run pre-send chain. Returns modified context or null. */ _runPre(ctx) { return _runChain('pre', ctx); }, /** Run stream chain. Returns modified context or null. */ _runStream(ctx) { return _runChain('stream', ctx); }, /** Run render chain. Returns modified context or null. */ _runRender(ctx) { return _runChain('render', ctx); }, }; // ── UserMenu Hydration ─────────────────────────────── // Absorbs the cs15 band-aid from base.html. Every surface // gets UserMenu wired up through the SDK, not through an // inline