File size: 3,631 Bytes
7ee3316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/* 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 } });
    },
  };
}