Changeset 0.28.5 (#191)
This commit is contained in:
491
src/js/switchboard-sdk.js
Normal file
491
src/js/switchboard-sdk.js
Normal file
@@ -0,0 +1,491 @@
|
||||
// ==========================================
|
||||
// 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);
|
||||
Reference in New Issue
Block a user