diff --git a/src/dev.html b/src/dev.html index d9b1ffa..5665495 100644 --- a/src/dev.html +++ b/src/dev.html @@ -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` + + `; + } + function PrimitivesPage() { const [dialogOpen, setDialogOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); @@ -336,6 +482,7 @@
${page === 'primitives' && html`<${PrimitivesPage} />`} + ${page === 'sdk' && html`<${SDKPage} />`}
diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js new file mode 100644 index 0000000..a430880 --- /dev/null +++ b/src/js/sw/sdk/api-domains.js @@ -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 }), + }; +} diff --git a/src/js/sw/sdk/auth.js b/src/js/sw/sdk/auth.js new file mode 100644 index 0000000..978aecf --- /dev/null +++ b/src/js/sw/sdk/auth.js @@ -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; +} diff --git a/src/js/sw/sdk/can.js b/src/js/sw/sdk/can.js new file mode 100644 index 0000000..a04bebf --- /dev/null +++ b/src/js/sw/sdk/can.js @@ -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 }; +} diff --git a/src/js/sw/sdk/events.js b/src/js/sw/sdk/events.js new file mode 100644 index 0000000..9f4e0b7 --- /dev/null +++ b/src/js/sw/sdk/events.js @@ -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} 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; +} diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js new file mode 100644 index 0000000..b7623df --- /dev/null +++ b/src/js/sw/sdk/index.js @@ -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} 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; +} diff --git a/src/js/sw/sdk/pipe.js b/src/js/sw/sdk/pipe.js new file mode 100644 index 0000000..c29ea53 --- /dev/null +++ b/src/js/sw/sdk/pipe.js @@ -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); }, + }; +} diff --git a/src/js/sw/sdk/rest-client.js b/src/js/sw/sdk/rest-client.js new file mode 100644 index 0000000..76296d4 --- /dev/null +++ b/src/js/sw/sdk/rest-client.js @@ -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>/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} 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; +} diff --git a/src/js/sw/sdk/theme.js b/src/js/sw/sdk/theme.js new file mode 100644 index 0000000..6b04ebf --- /dev/null +++ b/src/js/sw/sdk/theme.js @@ -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()); + }, + }; +}