Changeset 0.28.5 (#191)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
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);
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user