Changeset 0.37.3 (#215)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-21 00:34:36 +00:00
committed by xcaliber
parent 4f1abc6321
commit fc43618501
9 changed files with 1722 additions and 0 deletions

View File

@@ -63,6 +63,152 @@
const { Dropdown } = await import('./js/sw/primitives/dropdown.js');
const { AppShell } = await import('./js/sw/shell/app-shell.js');
// ── SDK Boot ────────────────────────────
const { boot } = await import('./js/sw/sdk/index.js');
await boot().catch(e => console.warn('SDK boot (no backend):', e));
const sw = window.sw;
// ── SDK Demo Page ───────────────────────
function SDKPage() {
const [authState, setAuthState] = useState(null);
const [apiResult, setApiResult] = useState(null);
const [permInput, setPermInput] = useState('model.use');
const [canResult, setCanResult] = useState(null);
const [eventLog, setEventLog] = useState([]);
const [emitLabel, setEmitLabel] = useState('test.event');
const logRef = useRef([]);
// Subscribe to all events for live log
useEffect(() => {
const unsub = sw.on('*', (payload, meta) => {
const entry = { ts: new Date().toLocaleTimeString(), label: meta.event, local: meta.local };
logRef.current = [entry, ...logRef.current].slice(0, 20);
setEventLog([...logRef.current]);
});
return unsub;
}, []);
function refreshAuth() {
setAuthState({
isAuthenticated: sw.auth.isAuthenticated,
user: sw.auth.user,
permissions: [...sw.auth.permissions],
teams: sw.auth.teams,
policies: sw.auth.policies,
});
}
useEffect(() => { refreshAuth(); }, []);
async function testApi() {
try {
const res = await sw.api.channels.list();
setApiResult({ ok: true, data: res });
} catch (e) {
setApiResult({ ok: false, error: e.message });
}
}
function testCan() {
setCanResult(sw.can(permInput));
}
const monoStyle = 'font-family: var(--font-mono, monospace); font-size: 0.8rem; background: var(--bg-1); padding: 0.75rem; border-radius: 6px; overflow: auto; max-height: 200px; white-space: pre-wrap; word-break: break-all; color: var(--text-2);';
return html`
<div class="gallery">
<h1>SDK Demo</h1>
<p class="subtitle">Layer 1 — SDK modules (v0.37.3)</p>
<!-- Auth State -->
<div class="section">
<h2>sw.auth</h2>
<div class="row" style="margin-bottom:0.5rem">
<${Button} variant="secondary" size="sm" onClick=${refreshAuth}>Refresh<//>
</div>
<div style=${monoStyle}>${JSON.stringify(authState, null, 2)}</div>
</div>
<!-- API Test -->
<div class="section">
<h2>sw.api</h2>
<div class="row" style="margin-bottom:0.5rem">
<${Button} variant="secondary" size="sm" onClick=${testApi}>sw.api.channels.list()<//>
</div>
${apiResult && html`
<div style=${monoStyle}>
${apiResult.ok
? JSON.stringify(apiResult.data, null, 2)
: 'Error: ' + apiResult.error}
</div>
`}
</div>
<!-- RBAC Test -->
<div class="section">
<h2>sw.can()</h2>
<div class="row">
<input class="sw-input" style="width:180px" value=${permInput}
onInput=${e => setPermInput(e.target.value)} />
<${Button} variant="secondary" size="sm" onClick=${testCan}>Check<//>
${canResult !== null && html`
<span style="font-weight:600; color: ${canResult ? 'var(--success)' : 'var(--danger)'}">
${canResult ? 'ALLOWED' : 'DENIED'}
</span>
`}
</div>
<div class="row" style="margin-top:0.5rem">
<span style="color:var(--text-3); font-size:0.8rem">
sw.isAdmin: ${String(sw.isAdmin)}
</span>
</div>
</div>
<!-- Theme -->
<div class="section">
<h2>sw.theme</h2>
<div class="row">
<span style="color:var(--text-2); font-size:0.85rem">
mode: ${sw.theme.mode} · resolved: ${sw.theme.current}
</span>
</div>
<div class="row">
<${Button} variant="secondary" size="sm" onClick=${() => sw.theme.set('light')}>Light<//>
<${Button} variant="secondary" size="sm" onClick=${() => sw.theme.set('dark')}>Dark<//>
<${Button} variant="secondary" size="sm" onClick=${() => sw.theme.set('system')}>System<//>
</div>
</div>
<!-- Events -->
<div class="section">
<h2>sw.on / sw.emit</h2>
<div class="row" style="margin-bottom:0.5rem">
<input class="sw-input" style="width:180px" value=${emitLabel}
onInput=${e => setEmitLabel(e.target.value)} />
<${Button} variant="secondary" size="sm"
onClick=${() => sw.emit(emitLabel, { demo: true }, { localOnly: true })}>Emit<//>
</div>
<div style=${monoStyle + ' max-height:160px;'}>
${eventLog.length === 0 ? '(listening for events...)' :
eventLog.map(e => `${e.ts} ${e.local ? 'L' : 'R'} ${e.label}`).join('\n')}
</div>
</div>
<!-- Pipe -->
<div class="section">
<h2>sw.pipe</h2>
<div style=${monoStyle}>${JSON.stringify(sw.pipe.list(), null, 2)}</div>
</div>
<!-- Namespaces -->
<div class="section">
<h2>sw.api namespaces</h2>
<div style=${monoStyle}>${Object.keys(sw.api).filter(k => typeof sw.api[k] === 'object').join(', ')}</div>
</div>
</div>
`;
}
function PrimitivesPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -336,6 +482,7 @@
<nav class="dev-nav">
<button class=${page === 'primitives' ? 'active' : ''} onClick=${() => setPage('primitives')}>Primitives</button>
<button class=${page === 'shell' ? 'active' : ''} onClick=${() => setPage('shell')}>Shell</button>
<button class=${page === 'sdk' ? 'active' : ''} onClick=${() => setPage('sdk')}>SDK</button>
<div style="flex:1" />
<${ShellControls} ...${{
bannerOn, setBannerOn, bannerVariant, setBannerVariant, bannerText, setBannerText,
@@ -345,6 +492,7 @@
</nav>
<div class="dev-page">
${page === 'primitives' && html`<${PrimitivesPage} />`}
${page === 'sdk' && html`<${SDKPage} />`}
</div>
</div>
<//>

View File

@@ -0,0 +1,446 @@
// ==========================================
// Chat Switchboard — SDK: API Domain Namespaces
// ==========================================
// 18 namespaced domain clients wrapping restClient.
// Each domain is a plain object with typed methods.
//
// Factory: createDomains(restClient)
// ==========================================
/**
* Build query string from an options object. Skips null/undefined values.
* @param {object} [opts]
* @returns {string} — '' or '?key=val&...'
*/
function _qs(opts) {
if (!opts) return '';
const p = new URLSearchParams();
for (const [k, v] of Object.entries(opts)) {
if (v != null) p.set(k, String(v));
}
const s = p.toString();
return s ? '?' + s : '';
}
/**
* Standard CRUD factory.
* @param {object} rc — restClient
* @param {string} base — e.g. '/api/v1/channels'
* @returns {object} — { list, get, create, update, del }
*/
function crud(rc, base) {
return {
list: (opts) => rc.get(base + _qs(opts)),
get: (id) => rc.get(`${base}/${id}`),
create: (data) => rc.post(base, data),
update: (id, data) => rc.put(`${base}/${id}`, data),
del: (id) => rc.del(`${base}/${id}`),
};
}
/**
* Create all 18 domain namespaces.
*
* @param {object} restClient
* @returns {object} api — the full sw.api object
*/
export function createDomains(restClient) {
const rc = restClient;
return {
// ── Escape hatches (raw restClient) ─────
get: rc.get.bind(rc),
post: rc.post.bind(rc),
put: rc.put.bind(rc),
patch: rc.patch.bind(rc),
del: rc.del.bind(rc),
stream: rc.stream.bind(rc),
upload: rc.upload.bind(rc),
// ── 1. Auth ─────────────────────────────
auth: {
login: (login, password) => rc.post('/api/v1/auth/login', { login, password }),
register: (username, email, password) => rc.post('/api/v1/auth/register', { username, email, password }),
refresh: (refreshToken) => rc.post('/api/v1/auth/refresh', { refresh_token: refreshToken }),
logout: (refreshToken) => rc.post('/api/v1/auth/logout', { refresh_token: refreshToken }),
},
// ── 2. Channels ────────────────────────
channels: {
...crud(rc, '/api/v1/channels'),
messages: (id, opts) => rc.get(`/api/v1/channels/${id}/messages` + _qs(opts)),
send: (id, data) => rc.post(`/api/v1/channels/${id}/messages`, data),
complete: (data, signal) => rc.stream('/api/v1/chat/completions', data, signal),
regenerate: (id, msgId, data, signal) => rc.stream(`/api/v1/channels/${id}/messages/${msgId}/regenerate`, data, signal),
editMessage: (id, msgId, content) => rc.post(`/api/v1/channels/${id}/messages/${msgId}/edit`, { content }),
siblings: (id, msgId) => rc.get(`/api/v1/channels/${id}/messages/${msgId}/siblings`),
path: (id) => rc.get(`/api/v1/channels/${id}/path`),
cursor: (id, leafId) => rc.put(`/api/v1/channels/${id}/cursor`, { active_leaf_id: leafId }),
markRead: (id) => rc.post(`/api/v1/channels/${id}/mark-read`, {}),
generateTitle: (id) => rc.post(`/api/v1/channels/${id}/generate-title`, {}),
summarize: (id) => rc.post(`/api/v1/channels/${id}/summarize`, {}),
// Participants
participants: (id) => rc.get(`/api/v1/channels/${id}/participants`),
addParticipant: (id, data) => rc.post(`/api/v1/channels/${id}/participants`, data),
updateParticipant: (id, pid, data) => rc.patch(`/api/v1/channels/${id}/participants/${pid}`, data),
removeParticipant: (id, pid) => rc.del(`/api/v1/channels/${id}/participants/${pid}`),
// Channel models
models: (id) => rc.get(`/api/v1/channels/${id}/models`),
addModel: (id, data) => rc.post(`/api/v1/channels/${id}/models`, data),
updateModel: (id, modelId, data) => rc.patch(`/api/v1/channels/${id}/models/${modelId}`, data),
removeModel: (id, modelId) => rc.del(`/api/v1/channels/${id}/models/${modelId}`),
// Knowledge bases
kbs: (id) => rc.get(`/api/v1/channels/${id}/knowledge-bases`),
setKbs: (id, kbIds) => rc.put(`/api/v1/channels/${id}/knowledge-bases`, { kb_ids: kbIds }),
// Files
files: (id) => rc.get(`/api/v1/channels/${id}/files`),
uploadFile: (id, file) => rc.upload(`/api/v1/channels/${id}/files`, file),
},
// ── 3. Personas ────────────────────────
personas: {
...crud(rc, '/api/v1/personas'),
kbs: (id) => rc.get(`/api/v1/personas/${id}/knowledge-bases`),
setKbs: (id, data) => rc.put(`/api/v1/personas/${id}/knowledge-bases`, data),
},
// ── 4. Knowledge Bases ─────────────────
knowledge: {
...crud(rc, '/api/v1/knowledge-bases'),
discoverable: () => rc.get('/api/v1/knowledge-bases-discoverable'),
search: (id, query, limit) => rc.post(`/api/v1/knowledge-bases/${id}/search`, { query, limit }),
documents: (id) => rc.get(`/api/v1/knowledge-bases/${id}/documents`),
upload: (id, file) => rc.upload(`/api/v1/knowledge-bases/${id}/documents`, file),
docStatus: (id, docId) => rc.get(`/api/v1/knowledge-bases/${id}/documents/${docId}/status`),
delDoc: (id, docId) => rc.del(`/api/v1/knowledge-bases/${id}/documents/${docId}`),
},
// ── 5. Notes ───────────────────────────
notes: {
...crud(rc, '/api/v1/notes'),
search: (query, limit) => rc.get(`/api/v1/notes/search` + _qs({ q: query, limit })),
searchTitles: (query, limit) => rc.get(`/api/v1/notes/search-titles` + _qs({ q: query, limit })),
folders: () => rc.get('/api/v1/notes/folders'),
backlinks: (id) => rc.get(`/api/v1/notes/${id}/backlinks`),
graph: () => rc.get('/api/v1/notes/graph'),
bulkDelete: (ids) => rc.post('/api/v1/notes/bulk-delete', { ids }),
},
// ── 6. Projects ────────────────────────
projects: {
...crud(rc, '/api/v1/projects'),
channels: (id) => rc.get(`/api/v1/projects/${id}/channels`),
addChannel: (id, channelId, pos) => rc.post(`/api/v1/projects/${id}/channels`, { channel_id: channelId, position: pos || 0 }),
removeChannel: (id, channelId) => rc.del(`/api/v1/projects/${id}/channels/${channelId}`),
reorderChannels:(id, channelIds) => rc.put(`/api/v1/projects/${id}/channels/reorder`, { channel_ids: channelIds }),
kbs: (id) => rc.get(`/api/v1/projects/${id}/knowledge-bases`),
addKb: (id, kbId, autoSearch) => rc.post(`/api/v1/projects/${id}/knowledge-bases`, { kb_id: kbId, auto_search: autoSearch || false }),
removeKb: (id, kbId) => rc.del(`/api/v1/projects/${id}/knowledge-bases/${kbId}`),
notes: (id) => rc.get(`/api/v1/projects/${id}/notes`),
addNote: (id, noteId) => rc.post(`/api/v1/projects/${id}/notes`, { note_id: noteId }),
removeNote: (id, noteId) => rc.del(`/api/v1/projects/${id}/notes/${noteId}`),
files: (id) => rc.get(`/api/v1/projects/${id}/files`),
uploadFile: (id, file) => rc.upload(`/api/v1/projects/${id}/files`, file),
},
// ── 7. Workspaces ──────────────────────
workspaces: {
...crud(rc, '/api/v1/workspaces'),
files: (id, opts) => rc.get(`/api/v1/workspaces/${id}/files` + _qs(opts)),
readFile: (id, path) => rc.get(`/api/v1/workspaces/${id}/files/read` + _qs({ path })),
writeFile: (id, path, content) => rc.put(`/api/v1/workspaces/${id}/files/write` + _qs({ path }), content),
deleteFile: (id, path) => rc.del(`/api/v1/workspaces/${id}/files/delete` + _qs({ path })),
mkdir: (id, path) => rc.post(`/api/v1/workspaces/${id}/files/mkdir`, { path }),
gitStatus: (id) => rc.get(`/api/v1/workspaces/${id}/git/status`),
gitBranches: (id) => rc.get(`/api/v1/workspaces/${id}/git/branches`),
credentials: () => rc.get('/api/v1/git-credentials'),
},
// ── 8. Memory ──────────────────────────
memory: {
list: (opts) => rc.get('/api/v1/memories' + _qs(opts)),
get: (id) => rc.get(`/api/v1/memories/${id}`),
update: (id, data) => rc.put(`/api/v1/memories/${id}`, data),
del: (id) => rc.del(`/api/v1/memories/${id}`),
count: () => rc.get('/api/v1/memories/count'),
approve: (id) => rc.post(`/api/v1/memories/${id}/approve`, {}),
reject: (id) => rc.post(`/api/v1/memories/${id}/reject`, {}),
},
// ── 9. Models ──────────────────────────
models: {
enabled: () => rc.get('/api/v1/models/enabled'),
preferences: () => rc.get('/api/v1/models/preferences'),
setPref: (modelId, provId, hidden) => rc.put('/api/v1/models/preferences', { model_id: modelId, provider_config_id: provId, hidden }),
bulkSetPref: (entries, hidden) => rc.post('/api/v1/models/preferences/bulk', { entries, hidden }),
},
// ── 10. Providers (user BYOK) ──────────
providers: {
...crud(rc, '/api/v1/api-configs'),
models: (id) => rc.get(`/api/v1/api-configs/${id}/models`),
fetchModels: (id) => rc.post(`/api/v1/api-configs/${id}/models/fetch`),
},
// ── 11. Notifications ──────────────────
notifications: {
list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)),
markRead: (id) => rc.post(`/api/v1/notifications/${id}/read`, {}),
prefs: () => rc.get('/api/v1/notifications/preferences'),
setPref: (type, data) => rc.put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data),
delPref: (type) => rc.del(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`),
},
// ── 12. Extensions ─────────────────────
extensions: {
list: (opts) => rc.get('/api/v1/extensions' + _qs(opts)),
get: (id) => rc.get(`/api/v1/extensions/${id}`),
settings: (id) => rc.get(`/api/v1/extensions/${id}/settings`),
},
// ── 13. Profile ────────────────────────
profile: {
get: () => rc.get('/api/v1/profile'),
update: (data) => rc.put('/api/v1/profile', data),
avatar: (base64) => rc.post('/api/v1/profile/avatar', { image: base64 }),
deleteAvatar:() => rc.del('/api/v1/profile/avatar'),
password: (current, pw) => rc.post('/api/v1/profile/password', { current_password: current, new_password: pw }),
settings: () => rc.get('/api/v1/settings'),
updateSettings: (data) => rc.put('/api/v1/settings', data),
permissions: () => rc.get('/api/v1/profile/permissions'),
},
// ── 14. Teams ──────────────────────────
teams: {
mine: () => rc.get('/api/v1/teams/mine'),
get: (id) => rc.get(`/api/v1/teams/${id}`),
members: (id) => rc.get(`/api/v1/teams/${id}/members`),
addMember: (id, userId, role) => rc.post(`/api/v1/teams/${id}/members`, { user_id: userId, role }),
updateMember:(id, memberId, role) => rc.put(`/api/v1/teams/${id}/members/${memberId}`, { role }),
removeMember:(id, memberId) => rc.del(`/api/v1/teams/${id}/members/${memberId}`),
personas: (id) => rc.get(`/api/v1/teams/${id}/personas`),
createPersona:(id, data) => rc.post(`/api/v1/teams/${id}/personas`, data),
deletePersona:(id, pid) => rc.del(`/api/v1/teams/${id}/personas/${pid}`),
personaKbs: (id, pid) => rc.get(`/api/v1/teams/${id}/personas/${pid}/knowledge-bases`),
setPersonaKbs:(id, pid, data) => rc.put(`/api/v1/teams/${id}/personas/${pid}/knowledge-bases`, data),
models: (id) => rc.get(`/api/v1/teams/${id}/models`),
groups: (id) => rc.get(`/api/v1/teams/${id}/groups`),
providers: (id) => rc.get(`/api/v1/teams/${id}/providers`),
createProvider:(id, data) => rc.post(`/api/v1/teams/${id}/providers`, data),
updateProvider:(id, pid, data) => rc.put(`/api/v1/teams/${id}/providers/${pid}`, data),
deleteProvider:(id, pid) => rc.del(`/api/v1/teams/${id}/providers/${pid}`),
providerModels:(id, pid) => rc.get(`/api/v1/teams/${id}/providers/${pid}/models`),
roles: (id) => rc.get(`/api/v1/teams/${id}/roles`),
updateRole: (id, role, config) => rc.put(`/api/v1/teams/${id}/roles/${role}`, config),
deleteRole: (id, role) => rc.del(`/api/v1/teams/${id}/roles/${role}`),
audit: (id, opts) => rc.get(`/api/v1/teams/${id}/audit` + _qs(opts)),
auditActions:(id) => rc.get(`/api/v1/teams/${id}/audit/actions`),
usage: (id, opts) => rc.get(`/api/v1/teams/${id}/usage` + _qs(opts)),
},
// ── 15. Workflows ──────────────────────
workflows: {
...crud(rc, '/api/v1/workflows'),
instances: (id, opts) => rc.get(`/api/v1/workflows/${id}/instances` + _qs(opts)),
advance: (id, data) => rc.post(`/api/v1/workflows/${id}/advance`, data),
reject: (id, data) => rc.post(`/api/v1/workflows/${id}/reject`, data),
},
// ── 16. Tasks ──────────────────────────
tasks: {
...crud(rc, '/api/v1/tasks'),
runs: (id, opts) => rc.get(`/api/v1/tasks/${id}/runs` + _qs(opts)),
start: (id, data) => rc.post(`/api/v1/tasks/${id}/start`, data || {}),
stop: (id) => rc.post(`/api/v1/tasks/${id}/stop`, {}),
},
// ── 17. Surfaces ───────────────────────
surfaces: {
list: (opts) => rc.get('/api/v1/surfaces' + _qs(opts)),
get: (id) => rc.get(`/api/v1/surfaces/${id}`),
install: (id) => rc.post(`/api/v1/surfaces/${id}/install`, {}),
},
// ── 18. Admin ──────────────────────────
admin: {
stats: () => rc.get('/api/v1/admin/stats'),
dashboard: () => rc.get('/api/v1/admin/dashboard'),
users: {
list: (opts) => rc.get('/api/v1/admin/users' + _qs(opts)),
create: (data) => rc.post('/api/v1/admin/users', data),
del: (id) => rc.del(`/api/v1/admin/users/${id}`),
resetPassword: (id, pw) => rc.post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }),
updateRole: (id, role) => rc.put(`/api/v1/admin/users/${id}/role`, { role }),
toggleActive: (id, active, opts) => rc.put(`/api/v1/admin/users/${id}/active`, { is_active: active, ...opts }),
permissions: (id) => rc.get(`/api/v1/admin/users/${id}/permissions`),
},
settings: {
get: () => rc.get('/api/v1/admin/settings'),
update: (key, val) => rc.put(`/api/v1/admin/settings/${key}`, val),
public: () => rc.get('/api/v1/settings/public'),
},
configs: {
list: () => rc.get('/api/v1/admin/configs'),
create: (data) => rc.post('/api/v1/admin/configs', data),
update: (id, data) => rc.put(`/api/v1/admin/configs/${id}`, data),
del: (id) => rc.del(`/api/v1/admin/configs/${id}`),
},
models: {
list: () => rc.get('/api/v1/admin/models'),
fetch: () => rc.post('/api/v1/admin/models/fetch', {}),
update: (id, data) => rc.put(`/api/v1/admin/models/${id}`, data),
bulkUpdate: (visibility) => rc.put('/api/v1/admin/models/bulk', { visibility, is_enabled: visibility === 'enabled' }),
del: (id) => rc.del(`/api/v1/admin/models/${id}`),
capabilities: (modelId) => rc.get(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`),
setCapability: (modelId, d) => rc.put(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`, d),
delCapability: (modelId, oId) => rc.del(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities/${oId}`),
overrides: () => rc.get('/api/v1/admin/capability-overrides'),
},
personas: {
list: () => rc.get('/api/v1/admin/personas'),
create: (data) => rc.post('/api/v1/admin/personas', data),
update: (id, data) => rc.put(`/api/v1/admin/personas/${id}`, data),
del: (id) => rc.del(`/api/v1/admin/personas/${id}`),
avatar: (id, base64) => rc.post(`/api/v1/admin/personas/${id}/avatar`, { image: base64 }),
delAvatar: (id) => rc.del(`/api/v1/admin/personas/${id}/avatar`),
kbs: (id) => rc.get(`/api/v1/admin/personas/${id}/knowledge-bases`),
setKbs: (id, data) => rc.put(`/api/v1/admin/personas/${id}/knowledge-bases`, data),
},
teams: {
list: () => rc.get('/api/v1/admin/teams'),
get: (id) => rc.get(`/api/v1/admin/teams/${id}`),
create: (data) => rc.post('/api/v1/admin/teams', data),
update: (id, data) => rc.put(`/api/v1/admin/teams/${id}`, data),
del: (id) => rc.del(`/api/v1/admin/teams/${id}`),
members: (id) => rc.get(`/api/v1/admin/teams/${id}/members`),
addMember: (id, userId, role) => rc.post(`/api/v1/admin/teams/${id}/members`, { user_id: userId, role }),
updateMember: (id, mid, role) => rc.put(`/api/v1/admin/teams/${id}/members/${mid}`, { role }),
removeMember: (id, mid) => rc.del(`/api/v1/admin/teams/${id}/members/${mid}`),
},
groups: {
list: () => rc.get('/api/v1/admin/groups'),
get: (id) => rc.get(`/api/v1/admin/groups/${id}`),
create: (data) => rc.post('/api/v1/admin/groups', data),
update: (id, data) => rc.put(`/api/v1/admin/groups/${id}`, data),
del: (id) => rc.del(`/api/v1/admin/groups/${id}`),
members: (id) => rc.get(`/api/v1/admin/groups/${id}/members`),
addMember: (id, userId) => rc.post(`/api/v1/admin/groups/${id}/members`, { user_id: userId }),
removeMember: (id, userId) => rc.del(`/api/v1/admin/groups/${id}/members/${userId}`),
},
grants: {
get: (type, id) => rc.get(`/api/v1/admin/grants/${type}/${id}`),
set: (type, id, scope, groups) => rc.put(`/api/v1/admin/grants/${type}/${id}`, { grant_scope: scope, granted_groups: groups || [] }),
del: (type, id) => rc.del(`/api/v1/admin/grants/${type}/${id}`),
},
permissions: {
list: () => rc.get('/api/v1/admin/permissions'),
},
audit: {
list: (opts) => rc.get('/api/v1/admin/audit' + _qs(opts)),
actions: () => rc.get('/api/v1/admin/audit/actions'),
},
roles: {
list: () => rc.get('/api/v1/admin/roles'),
get: (role) => rc.get(`/api/v1/admin/roles/${role}`),
update: (role, config) => rc.put(`/api/v1/admin/roles/${role}`, config),
test: (role) => rc.post(`/api/v1/admin/roles/${role}/test`, {}),
},
usage: {
get: (opts) => rc.get('/api/v1/admin/usage' + _qs(opts)),
user: (id, opts) => rc.get(`/api/v1/admin/usage/users/${id}` + _qs(opts)),
team: (id, opts) => rc.get(`/api/v1/admin/usage/teams/${id}` + _qs(opts)),
},
pricing: {
list: () => rc.get('/api/v1/admin/pricing'),
upsert: (entry) => rc.put('/api/v1/admin/pricing', entry),
del: (provId, modelId) => rc.del(`/api/v1/admin/pricing/${provId}/${encodeURIComponent(modelId)}`),
},
providers: {
health: () => rc.get('/api/v1/admin/providers/health'),
healthOne: (id) => rc.get(`/api/v1/admin/providers/${id}/health`),
types: () => rc.get('/api/v1/admin/provider-types'),
},
routing: {
policies: () => rc.get('/api/v1/admin/routing/policies'),
getPolicy: (id) => rc.get(`/api/v1/admin/routing/policies/${id}`),
createPolicy:(data) => rc.post('/api/v1/admin/routing/policies', data),
updatePolicy:(id, data) => rc.put(`/api/v1/admin/routing/policies/${id}`, data),
deletePolicy:(id) => rc.del(`/api/v1/admin/routing/policies/${id}`),
test: (data) => rc.post('/api/v1/admin/routing/test', data),
},
storage: {
status: () => rc.get('/api/v1/admin/storage/status'),
orphans: () => rc.get('/api/v1/admin/storage/orphans'),
cleanup: () => rc.post('/api/v1/admin/storage/cleanup', {}),
extraction: () => rc.get('/api/v1/admin/storage/extraction'),
},
vault: {
status: () => rc.get('/api/v1/admin/vault/status'),
},
projects: {
list: (opts) => rc.get('/api/v1/admin/projects' + _qs(opts)),
del: (id) => rc.del(`/api/v1/admin/projects/${id}`),
},
memories: {
pending: () => rc.get('/api/v1/admin/memories/pending'),
bulkApprove: (ids) => rc.post('/api/v1/admin/memories/bulk-approve', { ids }),
},
notifications: {
testEmail: () => rc.post('/api/v1/admin/notifications/test-email', {}),
},
},
// ── Misc (not domain-specific) ─────────
folders: {
list: () => rc.get('/api/v1/folders'),
create: (name, sort) => rc.post('/api/v1/folders', { name, sort_order: sort || 0 }),
update: (id, data) => rc.put(`/api/v1/folders/${id}`, data),
del: (id) => rc.del(`/api/v1/folders/${id}`),
},
files: {
get: (id) => rc.get(`/api/v1/files/${id}`),
del: (id) => rc.del(`/api/v1/files/${id}`),
},
tools: {
list: () => rc.get('/api/v1/tools'),
},
presence: {
heartbeat: () => rc.post('/api/v1/presence/heartbeat', {}),
},
usage: {
mine: (opts) => rc.get('/api/v1/usage' + _qs(opts)),
},
groups: {
mine: () => rc.get('/api/v1/groups/mine'),
},
health: () => rc.get('/api/v1/health'),
export: (content, format, filename) => rc.post('/api/v1/export', { content, format, filename }),
};
}

245
src/js/sw/sdk/auth.js Normal file
View File

@@ -0,0 +1,245 @@
// ==========================================
// Chat Switchboard — SDK: Auth Module
// ==========================================
// Owns token storage, login/logout/refresh lifecycle,
// and the permissions/teams/policies cache.
//
// Factory: createAuth()
// Wire after creation: _setRestClient(client), _setEmit(fn)
// ==========================================
const BASE = window.__BASE__ || '';
const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
/**
* Create the auth module.
* Must call _setRestClient() and _setEmit() before use.
*
* @returns {object} auth
*/
export function createAuth() {
// ── Private state ───────────────────────────
let _accessToken = null;
let _refreshToken = null;
let _user = null;
let _permissions = new Set();
let _teams = [];
let _groups = [];
let _policies = {};
let _refreshTimer = null;
let _refreshPromise = null;
let _restClient = null;
let _emit = () => {};
// ── Token persistence ───────────────────────
function _saveTokens() {
localStorage.setItem(_storageKey, JSON.stringify({
accessToken: _accessToken,
refreshToken: _refreshToken,
user: _user,
}));
// Cookie sync for Go template page auth
if (_accessToken) {
document.cookie = `sb_token=${_accessToken}; path=/; max-age=900; SameSite=Strict`;
} else {
document.cookie = 'sb_token=; path=/; max-age=0';
}
}
function _loadTokens() {
try {
const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
if (saved) {
_accessToken = saved.accessToken || null;
_refreshToken = saved.refreshToken || null;
_user = saved.user || null;
}
} catch (_) { /* corrupt storage */ }
}
function _clearTokens() {
_accessToken = null;
_refreshToken = null;
_user = null;
_permissions = new Set();
_teams = [];
_groups = [];
_policies = {};
localStorage.removeItem(_storageKey);
document.cookie = 'sb_token=; path=/; max-age=0';
if (_refreshTimer) { clearTimeout(_refreshTimer); _refreshTimer = null; }
}
// ── Token lifecycle ─────────────────────────
function _setAuth(data) {
_accessToken = data.access_token;
_refreshToken = data.refresh_token;
_user = data.user;
_saveTokens();
_scheduleRefresh(data.expires_in || 900);
}
function _scheduleRefresh(expiresIn) {
if (_refreshTimer) clearTimeout(_refreshTimer);
// Refresh at 80% of expiry (e.g., 12min for 15min token)
const ms = Math.max((expiresIn * 0.8) * 1000, 30000);
_refreshTimer = setTimeout(async () => {
if (!_refreshToken) return;
console.debug('[sw.auth] Proactive token refresh');
try {
const data = await _restClient.post('/api/v1/auth/refresh', {
refresh_token: _refreshToken,
});
_setAuth(data);
_emit('auth.refresh', {}, { localOnly: true });
} catch (e) {
console.warn('[sw.auth] Proactive refresh failed, token still valid');
}
}, ms);
}
// ── Permissions fetch ───────────────────────
async function _fetchPermissions() {
try {
const data = await _restClient.get('/api/v1/profile/permissions');
_permissions = new Set(data.permissions || []);
_teams = Object.freeze(data.teams || []);
_groups = Object.freeze(data.groups || []);
_policies = Object.freeze(data.policies || {});
_emit('auth.permissions.changed', {}, { localOnly: true });
} catch (e) {
console.warn('[sw.auth] Failed to fetch permissions:', e.message);
throw e;
}
}
// ── Public API ──────────────────────────────
const auth = {
// ── Getters ─────────────────────────────
get isAuthenticated() { return !!_accessToken; },
get user() {
if (!_user) return null;
return Object.freeze({
id: _user.id,
username: _user.username,
display_name: _user.display_name || _user.username,
email: _user.email || null,
role: _user.role || 'user',
avatar: _user.avatar_url || _user.avatar || null,
});
},
get permissions() { return _permissions; },
get teams() { return _teams; },
get groups() { return _groups; },
get policies() { return _policies; },
// ── Lifecycle ───────────────────────────
/**
* Login with credentials. Stores tokens, fetches permissions, emits event.
*/
async login(login, password) {
const data = await _restClient.post('/api/v1/auth/login', { login, password });
_setAuth(data);
await _fetchPermissions();
_emit('auth.login', { user: auth.user }, { localOnly: true });
return auth.user;
},
/**
* Logout. Clears tokens, emits event. Server call is best-effort.
*/
async logout() {
try {
await _restClient.post('/api/v1/auth/logout', { refresh_token: _refreshToken });
} catch (_) { /* best effort */ }
_clearTokens();
_emit('auth.logout', {}, { localOnly: true });
},
/**
* Manual token refresh. Returns true on success, false on failure.
*/
async refresh() {
if (_refreshPromise) return _refreshPromise;
_refreshPromise = (async () => {
try {
const data = await _restClient.post('/api/v1/auth/refresh', {
refresh_token: _refreshToken,
});
_setAuth(data);
return true;
} catch (_) {
_clearTokens();
return false;
} finally {
_refreshPromise = null;
}
})();
return _refreshPromise;
},
/**
* Boot sequence. Called by index.js on startup.
* Loads tokens from localStorage, validates, fetches permissions.
*/
async boot() {
_loadTokens();
if (!_refreshToken) return;
// Unknown token age — schedule refresh soon
_scheduleRefresh(60);
// Fetch permissions (validates the access token)
try {
await _fetchPermissions();
} catch (e) {
if (e.status === 401) {
// Token expired — try refresh
const ok = await auth.refresh();
if (ok) {
try { await _fetchPermissions(); }
catch (_) { _clearTokens(); }
}
// If refresh failed, _clearTokens already called
} else {
// Network error or other — keep tokens, user may be offline
console.warn('[sw.auth] Boot: permissions fetch failed (keeping tokens):', e.message);
}
}
if (_accessToken) {
_emit('auth.boot', { user: auth.user }, { localOnly: true });
}
},
// ── Internal (called by rest-client / index.js) ──
_getToken() { return _accessToken; },
async _onRefresh() {
if (_refreshToken) return auth.refresh();
return false;
},
_on401Failure() {
_clearTokens();
const base = window.__BASE__ || '';
window.location.href = base + '/login';
},
_setRestClient(client) { _restClient = client; },
_setEmit(fn) { _emit = fn; },
};
return auth;
}

49
src/js/sw/sdk/can.js Normal file
View File

@@ -0,0 +1,49 @@
// ==========================================
// Chat Switchboard — SDK: RBAC Gate
// ==========================================
// Synchronous permission checking for render paths.
//
// Factory: createCan(authRef)
// ==========================================
/**
* Create the RBAC gate.
*
* @param {object} authRef — the auth module (reads .permissions, .user, .teams)
* @returns {{ can, isAdmin, isTeamAdmin }}
*/
export function createCan(authRef) {
/**
* Check if the current user has a permission.
* Synchronous — reads from cached permission set.
*
* @param {string} permission — e.g. 'model.use', 'kb.read', 'channel.create'
* @returns {boolean}
*/
function can(permission) {
return authRef.permissions.has(permission);
}
/**
* Is the current user a platform admin?
* @returns {boolean}
*/
function isAdmin() {
return authRef.user?.role === 'admin';
}
/**
* Is the current user an admin of the given team?
* @param {string} teamId
* @returns {boolean}
*/
function isTeamAdmin(teamId) {
const teams = authRef.teams;
if (!teams || !teams.length) return false;
const t = teams.find(t => t.id === teamId);
return t?.my_role === 'admin';
}
return { can, isAdmin, isTeamAdmin };
}

277
src/js/sw/sdk/events.js Normal file
View File

@@ -0,0 +1,277 @@
// ==========================================
// Chat Switchboard — SDK: Event Bus
// ==========================================
// Labeled event bus with WebSocket bridge.
// Port of events.js as ES module with factory pattern.
//
// Factory: createEvents(getWsAuth)
// ==========================================
/**
* Create the event bus.
*
* @param {() => Promise<string|null>} getWsAuth — returns WS auth query param
* @returns {object} events
*/
export function createEvents(getWsAuth) {
const _handlers = new Map(); // label → Set<{fn, once}>
let _ws = null;
let _wsUrl = null;
let _wsReconnectMs = 1000;
const _wsMaxReconnectMs = 30000;
let _wsReconnectTimer = null;
let _wsConnected = false;
let _wsRetries = 0;
const _wsMaxRetries = 5;
const _wsQueue = []; // buffered while disconnected
let _heartbeatInterval = null;
// Labels that should NOT cross the wire
const _localOnly = new Set([
'ui.modal.open', 'ui.modal.close',
'ui.sidebar.toggle', 'ui.sidebar.collapse',
'ui.toast',
'auth.login', 'auth.logout', 'auth.refresh',
'auth.boot', 'auth.permissions.changed',
'sdk.ready', 'theme.changed',
]);
// Labels the FE should never receive from BE
const _serverOnly = new Set([
'plugin.hook.pre_completion',
'plugin.hook.post_completion',
'internal.',
'db.',
]);
// ── Pattern matching ────────────────────────
function _match(label, pattern) {
if (pattern === '*') return true;
if (pattern === label) return true;
if (!pattern.endsWith('*')) return false;
const prefix = pattern.slice(0, -1);
return label.startsWith(prefix);
}
function _isLocalOnly(label) {
if (_localOnly.has(label)) return true;
for (const prefix of _localOnly) {
if (prefix.endsWith('.') && label.startsWith(prefix)) return true;
}
return false;
}
// ── Internal dispatch ───────────────────────
function _dispatch(label, payload, meta) {
for (const [pattern, entries] of _handlers) {
if (_match(label, pattern)) {
for (const entry of entries) {
try {
entry.fn(payload, meta);
} catch (e) {
console.error(`[sw.events] Handler error for ${pattern}:`, e);
}
if (entry.once) entries.delete(entry);
}
}
}
}
// ── WebSocket ───────────────────────────────
async function _doConnect() {
if (!_wsUrl) return;
let url = _wsUrl;
if (url.startsWith('/')) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
url = `${proto}//${location.host}${url}`;
}
const authParam = await getWsAuth();
if (!authParam) {
console.warn('[sw.events] No auth available — skipping WebSocket');
return;
}
url += (url.includes('?') ? '&' : '?') + authParam;
try {
_ws = new WebSocket(url);
} catch (e) {
console.warn('[sw.events] WebSocket creation failed:', e);
_scheduleReconnect();
return;
}
_ws.onopen = () => {
console.log('[sw.events] WebSocket connected');
_wsConnected = true;
_wsReconnectMs = 1000;
_wsRetries = 0;
// Flush queued events
while (_wsQueue.length > 0) {
_ws.send(JSON.stringify(_wsQueue.shift()));
}
_dispatch('ws.connected', {}, { event: 'ws.connected', ts: Date.now(), local: true });
};
_ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg.event === 'pong') return;
for (const prefix of _serverOnly) {
if (msg.event.startsWith(prefix)) return;
}
const meta = {
event: msg.event,
room: msg.room || null,
ts: msg.ts || Date.now(),
local: false,
};
_dispatch(msg.event, msg.payload, meta);
} catch (_) {
console.warn('[sw.events] Bad message:', evt.data);
}
};
_ws.onclose = (evt) => {
_wsConnected = false;
_ws = null;
if (evt.code !== 1000) {
console.log(`[sw.events] WebSocket closed (code=${evt.code}, reason=${evt.reason || 'none'})`);
}
_dispatch('ws.disconnected', { code: evt.code }, { event: 'ws.disconnected', ts: Date.now(), local: true });
_scheduleReconnect();
};
_ws.onerror = () => {};
_startHeartbeat();
}
function _scheduleReconnect() {
if (!_wsUrl) return;
if (_wsReconnectTimer) return;
_wsRetries++;
if (_wsRetries > _wsMaxRetries) {
console.warn(`[sw.events] WebSocket failed after ${_wsMaxRetries} attempts`);
_dispatch('ws.failed', { retries: _wsRetries }, { event: 'ws.failed', ts: Date.now(), local: true });
return;
}
console.log(`[sw.events] Reconnecting in ${_wsReconnectMs}ms (attempt ${_wsRetries}/${_wsMaxRetries})`);
_wsReconnectTimer = setTimeout(() => {
_wsReconnectTimer = null;
_doConnect();
}, _wsReconnectMs);
_wsReconnectMs = Math.min(_wsReconnectMs * 2, _wsMaxReconnectMs);
}
function _send(msg) {
if (_wsConnected && _ws?.readyState === WebSocket.OPEN) {
_ws.send(JSON.stringify(msg));
} else {
if (_wsQueue.length < 100) _wsQueue.push(msg);
}
}
function _startHeartbeat() {
if (_heartbeatInterval) clearInterval(_heartbeatInterval);
_heartbeatInterval = setInterval(() => {
if (_wsConnected) {
_send({ event: 'ping', payload: {}, ts: Date.now() });
}
}, 30000);
}
// ── Public API ──────────────────────────────
const events = {
on(label, fn) {
if (!_handlers.has(label)) _handlers.set(label, new Set());
const entry = { fn, once: false };
_handlers.get(label).add(entry);
return () => _handlers.get(label)?.delete(entry);
},
once(label, fn) {
if (!_handlers.has(label)) _handlers.set(label, new Set());
const entry = { fn, once: true };
_handlers.get(label).add(entry);
return () => _handlers.get(label)?.delete(entry);
},
off(label, fn) {
if (!fn) {
_handlers.delete(label);
} else {
const set = _handlers.get(label);
if (set) {
for (const entry of set) {
if (entry.fn === fn) { set.delete(entry); break; }
}
}
}
},
emit(label, payload, opts) {
const _opts = opts || {};
const meta = {
event: label,
room: _opts.room || null,
ts: Date.now(),
local: true,
};
_dispatch(label, payload, meta);
if (!_opts.localOnly && !_isLocalOnly(label)) {
_send({ event: label, room: meta.room, payload, ts: meta.ts });
}
},
connect(url) {
_wsUrl = url;
_wsReconnectMs = 1000;
_wsRetries = 0;
_doConnect();
},
disconnect() {
if (_wsReconnectTimer) clearTimeout(_wsReconnectTimer);
_wsReconnectTimer = null;
_wsRetries = 0;
if (_ws) {
_ws.onclose = null;
_ws.close();
_ws = null;
}
_wsConnected = false;
if (_heartbeatInterval) clearInterval(_heartbeatInterval);
_heartbeatInterval = null;
_dispatch('ws.disconnected', {}, { event: 'ws.disconnected', ts: Date.now(), local: true });
},
get connected() { return _wsConnected; },
clear() { _handlers.clear(); },
debug() {
const subs = {};
for (const [label, entries] of _handlers) {
subs[label] = entries.size;
}
return subs;
},
};
return events;
}

120
src/js/sw/sdk/index.js Normal file
View File

@@ -0,0 +1,120 @@
// ==========================================
// Chat Switchboard — SDK Entry Point
// ==========================================
// Assembles all SDK modules into the `sw` object
// and runs the boot sequence.
//
// Usage:
// import { boot } from './js/sw/sdk/index.js';
// const sw = await boot();
//
// Exports: boot() → window.sw
// ==========================================
import { createRestClient } from './rest-client.js';
import { createAuth } from './auth.js';
import { createCan } from './can.js';
import { createDomains } from './api-domains.js';
import { createEvents } from './events.js';
import { createPipe } from './pipe.js';
import { createTheme } from './theme.js';
/**
* Boot the SDK. Assembles modules, loads auth state, connects WebSocket.
* Idempotent — returns the same sw object on subsequent calls.
*
* @returns {Promise<object>} sw
*/
export async function boot() {
// Idempotent
if (window.sw?._sdk) return window.sw;
// 1. Auth — owns token state
const auth = createAuth();
// 2. REST client — uses auth for token injection + refresh
const restClient = createRestClient(
() => auth._getToken(),
() => auth._onRefresh(),
() => auth._on401Failure()
);
// 3. Wire rest client into auth (auth needs it for login/refresh/permissions calls)
auth._setRestClient(restClient);
// 4. Events — with WS auth callback
const events = createEvents(async () => {
// Prefer ticket exchange
try {
const data = await restClient.post('/api/v1/ws/ticket', {});
if (data?.ticket) return `ticket=${encodeURIComponent(data.ticket)}`;
} catch (_) { /* fall through to legacy */ }
// Legacy fallback: JWT in query param
const token = auth._getToken();
return token ? `token=${encodeURIComponent(token)}` : null;
});
// 5. Wire emit into auth
auth._setEmit(events.emit.bind(events));
// 6. Remaining modules
const { can, isAdmin, isTeamAdmin } = createCan(auth);
const api = createDomains(restClient);
const pipe = createPipe();
const theme = createTheme(events.emit.bind(events));
// 7. Assemble sw object
const sw = Object.create(null);
// Auth
sw.auth = auth;
// API domains
sw.api = api;
// RBAC
sw.can = can;
Object.defineProperty(sw, 'isAdmin', {
get() { return isAdmin(); },
enumerable: true,
});
sw.isTeamAdmin = isTeamAdmin;
// Events (expose as top-level convenience + events namespace for connect/disconnect)
sw.on = events.on.bind(events);
sw.once = events.once.bind(events);
sw.off = events.off.bind(events);
sw.emit = events.emit.bind(events);
sw.events = events;
// Pipe & Theme
sw.pipe = pipe;
sw.theme = theme;
// Marker for idempotency
sw._sdk = '0.37.3';
// 8. Expose globally
window.sw = sw;
// 9. Boot sequence
theme.init();
try {
await auth.boot();
} catch (e) {
console.warn('[sw] Auth boot failed (expected without backend):', e.message);
}
if (auth.isAuthenticated) {
const BASE = window.__BASE__ || '';
events.connect(BASE + '/ws');
}
// 10. Signal ready
events.emit('sdk.ready', {}, { localOnly: true });
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
console.log('[sw] SDK v0.37.3 ready');
return sw;
}

131
src/js/sw/sdk/pipe.js Normal file
View File

@@ -0,0 +1,131 @@
// ==========================================
// Chat Switchboard — SDK: Pipe/Filter Pipeline
// ==========================================
// Three-stage pipeline: pre-send, stream, render.
// Verbatim port of switchboard-sdk.js pipe code.
//
// Factory: createPipe()
// ==========================================
/**
* Create the pipe/filter pipeline.
* @returns {object} pipe
*/
export function createPipe() {
const _chains = {
pre: [], // { priority, fn, scope, source, stats }
stream: [],
render: [],
};
/**
* Register a filter into a pipe stage.
*/
function _registerFilter(stage, priority, fn, opts) {
if (typeof fn !== 'function') {
console.error(`[sw.pipe] ${stage}: filter must be a function`);
return;
}
const chain = _chains[stage];
if (!chain) {
console.error(`[sw.pipe] ${stage}: unknown stage`);
return;
}
const _opts = opts || {};
const entry = {
priority,
fn,
scope: _opts.scope || null,
source: _opts.source || _inferSource(),
stats: { calls: 0, totalMs: 0, errors: 0 },
};
const dup = chain.find(e => e.source === entry.source && e.priority === entry.priority);
if (dup) {
console.warn(`[sw.pipe] ${stage}: duplicate registration (source=${entry.source}, priority=${priority})`);
}
chain.push(entry);
chain.sort((a, b) => a.priority - b.priority);
}
/**
* Infer source label from call stack.
*/
function _inferSource() {
try {
const stack = new Error().stack || '';
const extMatch = stack.match(/extensions\/([^/]+)\//);
if (extMatch) return extMatch[1];
} catch (_) { /* best effort */ }
return 'anonymous';
}
/**
* Run a filter chain against a context.
* Sync-only — filters must not return Promises.
*/
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) {
if (entry.scope?.channelType) {
if (!channelType || !entry.scope.channelType.includes(channelType)) {
continue;
}
}
const t0 = performance.now();
try {
const result = entry.fn(ctx);
entry.stats.calls++;
entry.stats.totalMs += performance.now() - t0;
if (result === null || result === undefined) {
return null; // halt chain
}
ctx = result;
} catch (e) {
entry.stats.calls++;
entry.stats.errors++;
entry.stats.totalMs += performance.now() - t0;
console.error(`[sw.pipe] ${stage} filter '${entry.source}' (p=${entry.priority}) threw:`, e);
// Continue chain — error isolation
}
}
return ctx;
}
// ── Public API ──────────────────────────────
return {
pre(priority, fn, opts) { _registerFilter('pre', priority, fn, opts); },
stream(priority, fn, opts) { _registerFilter('stream', priority, fn, opts); },
render(priority, fn, opts) { _registerFilter('render', priority, fn, opts); },
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;
},
_runPre(ctx) { return _runChain('pre', ctx); },
_runStream(ctx) { return _runChain('stream', ctx); },
_runRender(ctx) { return _runChain('render', ctx); },
};
}

View File

@@ -0,0 +1,210 @@
// ==========================================
// Chat Switchboard — SDK: REST Client
// ==========================================
// Internal HTTP engine for the new SDK.
// All domain clients and auth module use this.
//
// Factory: createRestClient(getToken, onRefresh, on401Failure)
// ==========================================
const BASE = window.__BASE__ || '';
/**
* Build a normalized Error from a failed fetch response.
* Detects proxy interception (HTML instead of JSON).
*/
async function _buildError(resp, method, path) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
// Proxy interception — response is HTML, not JSON
if (ct.includes('text/html')) {
let title = 'unknown';
try {
const html = await resp.text();
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (m) title = m[1].trim();
} catch (_) { /* */ }
const err = new Error(`Proxy blocked ${method} ${path}: "${title}"`);
err.status = resp.status;
err.proxyBlocked = true;
err.proxyTitle = title;
return err;
}
// Standard JSON error
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `HTTP ${resp.status}`);
err.status = resp.status;
if (data.code) err.code = data.code;
if (data.details) err.details = data.details;
return err;
}
/**
* Create a REST client.
*
* @param {() => string|null} getToken — returns current access token
* @param {() => Promise<boolean>} onRefresh — attempt token refresh, return success
* @param {() => void} on401Failure — called when refresh fails (session dead)
* @returns {object} client
*/
export function createRestClient(getToken, onRefresh, on401Failure) {
async function _request(path, method, body, opts) {
const _opts = opts || {};
const headers = { 'Content-Type': 'application/json' };
const token = getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
const fetchOpts = { method, headers };
if (body !== undefined) fetchOpts.body = JSON.stringify(body);
if (_opts.signal) fetchOpts.signal = _opts.signal;
let resp = await fetch(BASE + path, fetchOpts);
// 401 — try refresh once
if (resp.status === 401) {
const refreshed = await onRefresh();
if (refreshed) {
// Retry with new token
const newToken = getToken();
if (newToken) headers['Authorization'] = `Bearer ${newToken}`;
const retryOpts = { method, headers };
if (body !== undefined) retryOpts.body = JSON.stringify(body);
if (_opts.signal) retryOpts.signal = _opts.signal;
resp = await fetch(BASE + path, retryOpts);
} else {
on401Failure();
throw await _buildError(resp, method, path);
}
}
if (!resp.ok) throw await _buildError(resp, method, path);
// 204 No Content
if (resp.status === 204) return null;
return _unwrap(resp);
}
/**
* Unwrap response envelope.
* List endpoints return { data: [...], page, per_page, total }.
* Single-object endpoints return the object directly.
*/
async function _unwrap(resp) {
const text = await resp.text();
if (!text) return null;
const json = JSON.parse(text);
// List envelope: { data: Array, ... }
if (json && Array.isArray(json.data) && ('page' in json || 'total' in json || 'per_page' in json)) {
return json.data;
}
return json;
}
// ── Public API ──────────────────────────────
const client = {
get(path, opts) {
return _request(path, 'GET', undefined, opts);
},
post(path, body, opts) {
return _request(path, 'POST', body, opts);
},
put(path, body, opts) {
return _request(path, 'PUT', body, opts);
},
patch(path, body, opts) {
return _request(path, 'PATCH', body, opts);
},
del(path, opts) {
return _request(path, 'DELETE', undefined, opts);
},
/**
* Streaming POST — returns raw Response for SSE consumption.
* No envelope unwrapping.
*/
async stream(path, body, signal) {
const headers = { 'Content-Type': 'application/json' };
const token = getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
let resp = await fetch(BASE + path, {
method: 'POST', headers,
body: JSON.stringify(body),
signal,
});
if (resp.status === 401) {
const refreshed = await onRefresh();
if (refreshed) {
const newToken = getToken();
if (newToken) headers['Authorization'] = `Bearer ${newToken}`;
resp = await fetch(BASE + path, {
method: 'POST', headers,
body: JSON.stringify(body),
signal,
});
} else {
on401Failure();
throw await _buildError(resp, 'POST', path);
}
}
if (!resp.ok) throw await _buildError(resp, 'POST', path);
return resp;
},
/**
* Multipart file upload.
*/
async upload(path, file, opts) {
const _opts = opts || {};
const headers = {};
const token = getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
// Don't set Content-Type — browser sets multipart boundary
const form = new FormData();
form.append('file', file);
if (_opts.fields) {
for (const [k, v] of Object.entries(_opts.fields)) {
form.append(k, v);
}
}
let resp = await fetch(BASE + path, {
method: 'POST', headers, body: form,
signal: _opts.signal,
});
if (resp.status === 401) {
const refreshed = await onRefresh();
if (refreshed) {
const newToken = getToken();
if (newToken) headers['Authorization'] = `Bearer ${newToken}`;
resp = await fetch(BASE + path, {
method: 'POST', headers, body: form,
signal: _opts.signal,
});
} else {
on401Failure();
throw await _buildError(resp, 'POST', path);
}
}
if (!resp.ok) throw await _buildError(resp, 'POST', path);
if (resp.status === 204) return null;
return _unwrap(resp);
},
};
return client;
}

96
src/js/sw/sdk/theme.js Normal file
View File

@@ -0,0 +1,96 @@
// ==========================================
// Chat Switchboard — SDK: Theme Control
// ==========================================
// Theme management: localStorage + data-theme + system pref.
// Port of Theme from ui-primitives.js as ES module.
//
// Factory: createTheme(emitFn)
// ==========================================
const _key = 'switchboard_theme';
/**
* Create the theme module.
*
* @param {Function} emitFn — events.emit (for 'theme.changed')
* @returns {object} theme
*/
export function createTheme(emitFn) {
let _mqListener = null;
const _listeners = new Set(); // local change subscribers
function _apply(mode) {
// Remove prior system-preference listener
if (_mqListener) {
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', _mqListener);
_mqListener = null;
}
if (mode === 'system') {
const resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', resolved);
_mqListener = (e) => {
if (_getMode() === 'system') {
const r = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', r);
_notify(r);
}
};
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', _mqListener);
} else {
document.documentElement.setAttribute('data-theme', mode);
}
}
function _getMode() {
return localStorage.getItem(_key) || 'system';
}
function _getResolved() {
const mode = _getMode();
if (mode !== 'system') return mode;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function _notify(resolved) {
for (const fn of _listeners) {
try { fn(resolved); } catch (e) { console.error('[sw.theme] Listener error:', e); }
}
emitFn('theme.changed', { theme: resolved }, { localOnly: true });
}
// ── Public API ──────────────────────────────
return {
get current() { return _getResolved(); },
get mode() { return _getMode(); },
set(mode) {
localStorage.setItem(_key, mode);
_apply(mode);
_notify(_getResolved());
},
/**
* Subscribe to theme changes.
* @param {string} event — 'change'
* @param {Function} fn — receives resolved theme string
* @returns {Function} unsubscribe
*/
on(event, fn) {
if (event !== 'change') {
console.warn(`[sw.theme] Unknown event '${event}'`);
return () => {};
}
_listeners.add(fn);
return () => _listeners.delete(fn);
},
/** Apply stored theme. Called during boot. */
init() {
_apply(_getMode());
},
};
}