492 lines
18 KiB
JavaScript
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);
|