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/switchboard-sdk.js
2026-03-14 22:51:50 +00:00

492 lines
18 KiB
JavaScript

// ==========================================
// 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 <script> block that duplicates logic.
function _hydrateUserMenu() {
if (typeof UserMenu === 'undefined') return;
if (UserMenu.primary) return; // already created by a surface
// Chat surface manages its own UserMenu via UI.toggleUserMenu()
const surface = window.__SURFACE__ || '';
if (surface === 'chat') return;
const btn = document.getElementById('userMenuBtn');
if (!btn) return;
const menu = UserMenu.create({ id: '' });
UserMenu.primary = menu;
const user = window.__USER__ || {};
menu.setUser(user);
menu.showAdmin(user.role === 'admin');
const BASE = window.__BASE__ || '';
menu.bind({
onSettings: () => { window.location.href = BASE + '/settings'; },
onAdmin: () => { window.location.href = BASE + '/admin'; },
onDebug: () => { sb.call('openDebugModal'); },
onSignout: () => { sb.call('handleLogout'); },
});
}
// ── Perform Init ─────────────────────────────────────
// Theme + appearance (safe on all surfaces)
if (typeof Theme !== 'undefined') Theme.init();
if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance();
// UserMenu hydration
_hydrateUserMenu();
// Emit ready event
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
this._instance = sw;
window.sw = sw; // convenience alias
console.log('[Switchboard] SDK initialized');
return sw;
},
};
// ── Registration ─────────────────────────────────────────────
sb.ns('Switchboard', Switchboard);