Changeset 0.28.5 (#191)

This commit is contained in:
2026-03-14 22:51:50 +00:00
parent 85d5e3cc13
commit 6f0ad1355c
17 changed files with 2389 additions and 110 deletions

View File

@@ -78,6 +78,9 @@ async function startApp() {
const surface = window.__SURFACE__ || 'chat';
// ── Common init (all surfaces) ──────────
// v0.28.5: Ensure SDK is initialized (idempotent — base.html also calls this).
if (typeof Switchboard !== 'undefined') Switchboard.init();
if (typeof loadSettings === 'function') await loadSettings();
// Guard: if token was invalidated during startup

View File

@@ -656,6 +656,21 @@ function startRenameChat(chatId) {
// ── Send Message ─────────────────────────────
// v0.28.5: Build context object for pre-send pipe filters.
function _buildPreSendContext(opts) {
const chat = opts.channel || {};
return {
message: opts.message || '',
channel: { id: chat.id || null, type: chat.type || 'direct', title: chat.title || '' },
attachments: opts.attachments || [],
model: opts.model || null,
persona: opts.personaId || null,
metadata: {},
regenerate: opts.regenerate || false,
regenerateMessageId: opts.regenerateMessageId || null,
};
}
async function sendMessage() {
const text = ChatInput.getValue().trim();
const hasFiles = hasStagedFiles();
@@ -698,6 +713,33 @@ async function sendMessage() {
const chat = App.getActiveChat();
if (!chat) return;
// ── v0.28.5: Pre-send pipe ──────────────────────────────
// Filters can transform message, inject metadata, or halt (return null).
if (typeof sw !== 'undefined' && sw.pipe) {
const preSendCtx = _buildPreSendContext({
message: text, channel: chat, model, personaId,
attachments: fileIds, regenerate: false,
});
const result = sw.pipe._runPre(preSendCtx);
if (result === null) {
// Filter halted — suppress message
UI.toast('Message blocked by extension', 'warning');
return;
}
// Apply any mutations from filters — model/persona may have changed
// but message text is the most common mutation (KB inject, rewrite).
// Note: we don't overwrite `text` (const) — the API call reads from ctx.
var _preSendText = result.message;
var _preSendModel = result.model || model;
var _preSendPersonaId = result.persona || personaId;
var _preSendMeta = result.metadata || {};
} else {
var _preSendText = text;
var _preSendModel = model;
var _preSendPersonaId = personaId;
var _preSendMeta = {};
}
// Optimistic: show user message immediately
const userContent = text || (hasFiles ? `[${fileIds.length} file${fileIds.length > 1 ? 's' : ''} attached]` : '');
chat.messages.push({ role: 'user', content: userContent, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
@@ -711,7 +753,7 @@ async function sendMessage() {
UI.setGenerating(true);
try {
const resp = await API.streamCompletion(App.activeId, text, model, App.abortController.signal, modelInfo?.configId, personaId, fileIds,
const resp = await API.streamCompletion(App.activeId, _preSendText, _preSendModel, App.abortController.signal, modelInfo?.configId, _preSendPersonaId, fileIds,
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []);
// v0.23.2: Non-streaming response (DM with mention_only, no @mention)
@@ -823,6 +865,26 @@ async function regenerateMessage(messageId) {
}
}
// ── v0.28.5: Pre-send pipe (regeneration path) ──────────
if (typeof sw !== 'undefined' && sw.pipe) {
const preSendCtx = _buildPreSendContext({
message: '', channel: chat, model, personaId,
regenerate: true, regenerateMessageId: messageId,
});
const result = sw.pipe._runPre(preSendCtx);
if (result === null) {
UI.toast('Regeneration blocked by extension', 'warning');
// Restore display since we truncated above
if (chat) UI.renderMessages(chat.messages);
return;
}
var _regenModel = result.model || model;
var _regenPersonaId = result.persona || personaId;
} else {
var _regenModel = model;
var _regenPersonaId = personaId;
}
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
@@ -830,7 +892,7 @@ async function regenerateMessage(messageId) {
try {
const resp = await API.streamRegenerate(
App.activeId, messageId, App.abortController.signal,
model, personaId, modelInfo?.configId,
_regenModel, _regenPersonaId, modelInfo?.configId,
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);

View File

@@ -348,6 +348,23 @@ const Extensions = {
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})`);
},

491
src/js/switchboard-sdk.js Normal file
View 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);

View File

@@ -1124,7 +1124,31 @@ const UI = {
// Normal content delta
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
content += delta;
// ── v0.28.5: Stream pipe ────────────────────
if (typeof sw !== 'undefined' && sw.pipe) {
const streamCtx = {
chunk: delta,
accumulated: content + delta,
channel: {
id: App.activeId || null,
type: App.activeType || 'direct',
},
model: App.settings?.model || '',
event: currentEvent || 'content',
tokens: { input: 0, output: content.length + delta.length },
};
const result = sw.pipe._runStream(streamCtx);
if (result === null) {
// Chunk dropped by filter
currentEvent = '';
continue;
}
// Use filter's accumulated (may differ from simple append)
content = result.accumulated;
} else {
content += delta;
}
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
if (activeContentEl) activeContentEl.innerHTML = formatMessage(full);
this._scrollToBottom();

View File

@@ -577,6 +577,22 @@ function _relativeTime(dateStr) {
* Safe to call even if extensions aren't loaded — no-ops gracefully.
*/
function runExtensionPostRender(container) {
// v0.28.5: Render pipe subsumes extension post-renderers.
// The pipe includes compat-shimmed renderers from ctx.renderers.register()
// alongside new sw.pipe.render() filters.
if (typeof sw !== 'undefined' && sw.pipe) {
const renderCtx = {
element: container,
message: null, // batch render — no single message context
channel: {
id: typeof App !== 'undefined' ? App.activeId : null,
type: typeof App !== 'undefined' ? (App.activeType || 'direct') : 'direct',
},
};
sw.pipe._runRender(renderCtx);
return;
}
// Fallback: SDK not loaded (shouldn't happen, but defensive)
if (typeof Extensions !== 'undefined' && Extensions._loaded) {
Extensions.runPostRenderers(container);
}