/* Server backend — Swan running natively, reached through server.js's /api proxy. * * The proxy hop exists for one reason: the Anthropic API key. The natural * language step needs credentials, and a key shipped to the browser is a key * published. server.js forwards to tools/swan_sidecar.py on loopback, and the * key never leaves the machine running the sidecar. */ const BASE = '/api/swan'; async function call(path, { method = 'GET', body } = {}) { let res; try { res = await fetch(BASE + path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, }); } catch (e) { // fetch only rejects on a transport failure, which here almost always means // server.js is not running at all — worth saying rather than "Failed to fetch". throw new Error(`cannot reach the dev server (${String(e && e.message || e)})`); } const text = await res.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch { /* non-JSON body handled below */ } if (!res.ok) { /* Only a JSON body is quoted. A non-JSON one means something other than the * sidecar answered — most often a static host with no /api at all, whose * 404 is a full HTML page. Pasting that into the error puts a document in a * banner; the status code says the same thing in four characters. */ const err = new Error((payload && payload.error) || `no sidecar behind ${BASE} (HTTP ${res.status})`); err.status = res.status; // The NL path returns the rejected code and the translated failure alongside // the message; carrying them lets the UI show what was tried. if (payload && payload.code) err.code = payload.code; if (payload && payload.detail) err.detail = payload.detail; throw err; } if (payload === null) throw new Error('the sidecar returned a non-JSON response'); return payload; } export function createServerEngine() { return { name: 'server', async ready() { const health = await call('/health'); if (!health.ok) throw new Error('sidecar reported unhealthy'); return { swan: health.swan, duckdb: health.duckdb, model: health.model, nl: health.nl, anthropicModel: health.anthropicModel, // Rules the sidecar could not evaluate, and therefore skipped. A model // serving 112 of its 117 rule statements is a materially different thing // to answer questions against than one serving all of them, so this is // shown rather than logged — an answer that silently omits a derived // property is worse than a warning. ruleFailures: health.ruleFailures || [], // Surfaced in the HUD: with this backend, questions leave the browser. note: 'PyRel executes on the sidecar, not in the browser', }; }, schema() { return call('/schema'); }, pyrel(code) { return call('/pyrel', { method: 'POST', body: { code } }); }, ask(question) { return call('/nl', { method: 'POST', body: { question } }); }, /** * Like ask(), with the conversation so far. * * `history` is [{question, code}] and is supplied by the caller rather than * held on the sidecar. Keeping it client-side means two tabs cannot capture * each other's context, restarting the sidecar does not silently lose the * thread, and "ask again from here" is just a shorter list. */ chat(question, history = []) { return call('/chat', { method: 'POST', body: { question, history } }); }, }; }