// api.jsx — single client for canon_lm_server (auth, data, admin). // Lives on window.API so every babel-loaded JSX file can share it. (function () { const LS_TOKEN = 'gbr_token'; const LS_USER = 'gbr_user'; const LS_SERVER = 'gbr_server_url'; const LS_LLM = 'gbr_llm_url'; // Defaults are RELATIVE paths so the same UI works when served from any // hostname (localhost, Tailscale, Funnel). Python canon_lm_server mounts: // / → static webapp // /api/* → auth + threads + admin // /llm/* → reverse-proxied to M3 zigu_engine_rs const DEFAULT_SERVER = '/api'; const DEFAULT_LLM = '/llm'; function _read(key) { try { return localStorage.getItem(key); } catch (_) { return null; } } function _write(key, v) { try { localStorage.setItem(key, v); } catch (_) {} } function _del(key) { try { localStorage.removeItem(key); } catch (_) {} } // One-time migration: earlier builds stored absolute http://… URLs. We now // default to relative /api + /llm, so wipe any stale absolute values so they // don't override the new defaults. for (const k of [LS_SERVER, LS_LLM]) { const v = _read(k); if (v && /^https?:\/\//i.test(v)) _del(k); } const API = { // ----- storage ----- get serverUrl() { return (_read(LS_SERVER) || DEFAULT_SERVER).replace(/\/$/, ''); }, setServerUrl(v) { _write(LS_SERVER, (v || DEFAULT_SERVER).replace(/\/$/, '')); }, get llmUrl() { return (_read(LS_LLM) || DEFAULT_LLM).replace(/\/$/, ''); }, setLlmUrl(v) { _write(LS_LLM, (v || DEFAULT_LLM).replace(/\/$/, '')); }, get token() { return _read(LS_TOKEN); }, setToken(v) { if (v) _write(LS_TOKEN, v); else _del(LS_TOKEN); }, get user() { const s = _read(LS_USER); try { return s ? JSON.parse(s) : null; } catch (_) { return null; } }, setUser(v) { if (v) _write(LS_USER, JSON.stringify(v)); else _del(LS_USER); }, clear() { _del(LS_TOKEN); _del(LS_USER); }, // ----- low-level fetch ----- _url(path) { return this.serverUrl + path; }, _headers(extra) { const h = { 'Content-Type': 'application/json', ...(extra || {}) }; const t = this.token; if (t) h['Authorization'] = 'Bearer ' + t; return h; }, async fetch(path, options) { options = options || {}; const { json, headers, ...rest } = options; const init = { ...rest, headers: this._headers(headers) }; if (json !== undefined) init.body = JSON.stringify(json); const res = await fetch(this._url(path), init); if (!res.ok) { let detail = 'HTTP ' + res.status; try { const j = await res.clone().json(); if (j && j.detail) detail = j.detail; } catch (_) {} const e = new Error(detail); e.status = res.status; throw e; } return res; }, async json(path, options) { const r = await this.fetch(path, options); if (r.status === 204) return null; return r.json(); }, // ----- auth ----- async login(email, password) { const data = await this.json('/auth/login', { method: 'POST', json: { email, password } }); this.setToken(data.access_token); this.setUser(data.user); return data; }, async register(opts) { // Accepts { email, password, display_name, invite_code, accepted_tos }. // Backwards-compatible with the old (email, password, displayName) call // shape so any unmigrated caller still works. let body; if (typeof opts === 'string') { body = { email: opts, password: arguments[1], display_name: arguments[2] || null }; } else { body = { email: opts.email, password: opts.password, display_name: opts.display_name ?? opts.displayName ?? null, invite_code: opts.invite_code ?? null, accepted_tos: opts.accepted_tos ?? null, }; } const data = await this.json('/auth/register', { method: 'POST', json: body }); this.setToken(data.access_token); this.setUser(data.user); return data; }, async me() { const u = await this.json('/auth/me'); this.setUser(u); return u; }, // Self-service account management. async updateProfile(patch) { // Currently only display_name is mutable server-side. const u = await this.json('/auth/me', { method: 'PATCH', json: patch }); this.setUser(u); return u; }, async changePassword(currentPassword, newPassword) { return this.json('/auth/change-password', { method: 'POST', json: { current_password: currentPassword, new_password: newPassword }, }); }, async deleteSelf(password) { const result = await this.json('/auth/me', { method: 'DELETE', json: { password } }); this.clear(); return result; }, async health() { return this.json('/health'); }, // ----- threads & messages ----- listThreads(q) { return this.json('/threads' + (q ? '?q=' + encodeURIComponent(q) : '')); }, createThread(body) { return this.json('/threads', { method: 'POST', json: body || {} }); }, getThread(id) { return this.json('/threads/' + id); }, patchThread(id, patch) { return this.json('/threads/' + id, { method: 'PATCH', json: patch }); }, deleteThread(id) { return this.json('/threads/' + id, { method: 'DELETE' }); }, appendMessage(threadId, role, content) { return this.json('/threads/' + threadId + '/messages', { method: 'POST', json: { role, content }, }); }, // ----- settings ----- getSettings() { return this.json('/settings'); }, putSettings(patch) { return this.json('/settings', { method: 'PUT', json: patch }); }, // ----- admin (require is_admin) ----- adminSystem() { return this.json('/admin/system'); }, adminDb() { return this.json('/admin/db'); }, adminListUsers(q) { return this.json('/admin/users' + (q ? '?q=' + encodeURIComponent(q) : '')); }, adminToggleUserAdmin(id, isAdmin) { return this.json('/admin/users/' + id + '/admin', { method: 'PATCH', json: { is_admin: isAdmin } }); }, adminDeleteUser(id) { return this.json('/admin/users/' + id, { method: 'DELETE' }); }, adminCreateUser(body) { return this.json('/admin/users', { method: 'POST', json: body }); }, adminUpdateUser(id, patch) { return this.json('/admin/users/' + id, { method: 'PATCH', json: patch }); }, adminResetUserPassword(id) { return this.json('/admin/users/' + id + '/reset-password', { method: 'POST', json: {} }); }, adminListInvites() { return this.json('/admin/invites'); }, adminCreateInvite(body) { return this.json('/admin/invites', { method: 'POST', json: body || {} }); }, adminDeleteInvite(code) { return this.json('/admin/invites/' + encodeURIComponent(code), { method: 'DELETE' }); }, adminListThreads(q) { return this.json('/admin/threads' + (q ? '?q=' + encodeURIComponent(q) : '')); }, adminDeleteThread(id) { return this.json('/admin/threads/' + id, { method: 'DELETE' }); }, adminListDatasets() { return this.json('/admin/datasets'); }, adminUploadDataset(file, onProgress) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', this._url('/admin/datasets/upload?filename=' + encodeURIComponent(file.name))); if (this.token) xhr.setRequestHeader('Authorization', 'Bearer ' + this.token); xhr.setRequestHeader('Content-Type', 'application/octet-stream'); xhr.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText)); } catch (_) { resolve({}); } } else { let detail = 'HTTP ' + xhr.status; try { const j = JSON.parse(xhr.responseText); if (j && j.detail) detail = j.detail; } catch (_) {} reject(Object.assign(new Error(detail), { status: xhr.status })); } }; xhr.onerror = () => reject(new Error('Network error during upload')); xhr.send(file); }); }, adminDeleteDataset(name) { return this.json('/admin/datasets/' + encodeURIComponent(name), { method: 'DELETE' }); }, async adminDownloadDataset(name) { const r = await this.fetch('/admin/datasets/' + encodeURIComponent(name) + '/download'); const blob = await r.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }, adminListTrainingScripts() { return this.json('/admin/training/scripts'); }, adminListTrainingJobs(limit) { return this.json('/admin/training/jobs' + (limit ? '?limit=' + limit : '')); }, adminLaunchTraining(scriptName) { return this.json('/admin/training/jobs', { method: 'POST', json: { script_name: scriptName } }); }, adminStopTraining(id) { return this.json('/admin/training/jobs/' + id + '/stop', { method: 'POST' }); }, adminDeleteTrainingJob(id) { return this.json('/admin/training/jobs/' + id, { method: 'DELETE' }); }, adminTrainingLogChunk(id, offset, maxBytes) { const qs = new URLSearchParams({ offset: String(offset || 0) }); if (maxBytes) qs.set('max_bytes', String(maxBytes)); return this.json('/admin/training/jobs/' + id + '/log?' + qs.toString()); }, // ----- live model identity (zigu_engine_rs /v1/models) ----- // Returns the canonical name of the currently-loaded model (e.g. // "GBR-LM-V22"). The Rust engine asks its CPN sidecar's /health for the // value, so the UI auto-tracks whatever CPN_VERSION the M3 box was // started with — no UI redeploy needed when a new V## is trained. // Resolves to null if the engine is unreachable. async liveModel() { try { const res = await fetch(this.llmUrl + '/v1/models', { headers: { 'Content-Type': 'application/json' }, }); if (!res.ok) return null; const obj = await res.json(); const item = obj && obj.data && obj.data[0]; return (item && item.id) || null; } catch (_) { return null; } }, // ----- streaming chat ----- // Chat completions go to the LLM URL (default: M3 Rust zigu_engine_rs). The // Rust engine has no auth, so the bearer header is omitted there. // Body must include { model, messages, temperature, top_p, max_tokens }. // Persistence (saving user/assistant turns) is the caller's responsibility — // use API.appendMessage(thread_id, role, content) to write to the Python DB. streamChatCompletion(body, handlers) { const ctrl = new AbortController(); const { onChunk, onDone, onError } = handlers || {}; // NOTE 2026-05-12: zigu_engine_rs streaming path bypasses the CPN // sidecar and emits a hardcoded "day4-stub" (server.rs:280-282 + // STUB_STREAM_CHUNKS at line 456). Until that lands, request // non-streaming and synthesize a single onChunk from the JSON body // so the UI still updates the asst bubble. (async () => { try { const res = await fetch(this.llmUrl + '/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...body, stream: false }), signal: ctrl.signal, }); if (!res.ok) { let detail = 'HTTP ' + res.status; try { const j = await res.json(); if (j && j.detail) detail = j.detail; } catch (_) {} throw Object.assign(new Error(detail), { status: res.status }); } const obj = await res.json(); const choice = obj && obj.choices && obj.choices[0]; const acc = (choice && choice.message && choice.message.content) || ''; const model = obj.model || null; const fingerprint = obj.system_fingerprint || null; if (acc) onChunk && onChunk(acc, acc); onDone && onDone({ content: acc, model, system_fingerprint: fingerprint }); } catch (e) { if (e.name === 'AbortError') { onDone && onDone({ aborted: true, content: '' }); return; } onError && onError(e); } })(); return ctrl; }, }; window.API = API; // OS-aware modifier key + shortcut labels. JSX files read these instead of // hard-coding the Mac Cmd glyph so Windows/Linux users see "Ctrl". const _platform = (navigator.platform || navigator.userAgent || '').toLowerCase(); window.IS_MAC = /mac|iphone|ipad/i.test(_platform); window.MOD_KEY = window.IS_MAC ? '⌘' : 'Ctrl'; window.SHIFT_KEY = window.IS_MAC ? '⇧' : 'Shift'; window.ENTER_KEY = window.IS_MAC ? '↵' : 'Enter'; // ---------- theme apply helper ---------- // theme: 'dark' | 'light' | 'system'. We cache the last applied theme in // localStorage so the next page load can apply it before /settings round-trips. const LS_THEME = 'gbr_theme_cache'; const _systemDark = () => window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; function _resolveTheme(t) { if (t === 'system') return _systemDark() ? 'dark' : 'light'; return t === 'light' ? 'light' : 'dark'; } window.applyTheme = function(theme) { try { localStorage.setItem(LS_THEME, theme || 'dark'); } catch (_) {} const root = document.documentElement; const resolved = _resolveTheme(theme); if (resolved === 'light') { root.setAttribute('data-theme', 'light'); root.classList.add('light'); } else { root.removeAttribute('data-theme'); root.classList.remove('light'); } }; // Apply cached theme as early as possible (avoids dark-flash on light users). try { const cached = localStorage.getItem(LS_THEME) || 'dark'; window.applyTheme(cached); } catch (_) {} // If user is on 'system', track OS-level changes live. if (window.matchMedia) { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { try { const t = localStorage.getItem(LS_THEME); if (t === 'system') window.applyTheme('system'); } catch (_) {} }); } })();