Changeset 0.37.3 (#215)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
148
src/dev.html
148
src/dev.html
@@ -63,6 +63,152 @@
|
|||||||
const { Dropdown } = await import('./js/sw/primitives/dropdown.js');
|
const { Dropdown } = await import('./js/sw/primitives/dropdown.js');
|
||||||
const { AppShell } = await import('./js/sw/shell/app-shell.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() {
|
function PrimitivesPage() {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
@@ -336,6 +482,7 @@
|
|||||||
<nav class="dev-nav">
|
<nav class="dev-nav">
|
||||||
<button class=${page === 'primitives' ? 'active' : ''} onClick=${() => setPage('primitives')}>Primitives</button>
|
<button class=${page === 'primitives' ? 'active' : ''} onClick=${() => setPage('primitives')}>Primitives</button>
|
||||||
<button class=${page === 'shell' ? 'active' : ''} onClick=${() => setPage('shell')}>Shell</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" />
|
<div style="flex:1" />
|
||||||
<${ShellControls} ...${{
|
<${ShellControls} ...${{
|
||||||
bannerOn, setBannerOn, bannerVariant, setBannerVariant, bannerText, setBannerText,
|
bannerOn, setBannerOn, bannerVariant, setBannerVariant, bannerText, setBannerText,
|
||||||
@@ -345,6 +492,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="dev-page">
|
<div class="dev-page">
|
||||||
${page === 'primitives' && html`<${PrimitivesPage} />`}
|
${page === 'primitives' && html`<${PrimitivesPage} />`}
|
||||||
|
${page === 'sdk' && html`<${SDKPage} />`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<//>
|
<//>
|
||||||
|
|||||||
446
src/js/sw/sdk/api-domains.js
Normal file
446
src/js/sw/sdk/api-domains.js
Normal 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
245
src/js/sw/sdk/auth.js
Normal 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
49
src/js/sw/sdk/can.js
Normal 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
277
src/js/sw/sdk/events.js
Normal 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
120
src/js/sw/sdk/index.js
Normal 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
131
src/js/sw/sdk/pipe.js
Normal 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); },
|
||||||
|
};
|
||||||
|
}
|
||||||
210
src/js/sw/sdk/rest-client.js
Normal file
210
src/js/sw/sdk/rest-client.js
Normal 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
96
src/js/sw/sdk/theme.js
Normal 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());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user