File size: 12,671 Bytes
28c70af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
// SPDX-License-Identifier: MIT OR Apache-2.0
// bankML console: four tabs over sAGI/console.py. Every value is rendered with textContent; the charts draw only what
// bankml measured (a null is drawn as a gap and labelled "not measured", never filled in).
"use strict";
const $ = (id) => document.getElementById(id);
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
const fmt = (v, d = 1, unit = "") => (v === null || v === undefined || Number.isNaN(v)) ? "not measured" : `${(+v).toFixed(d)}${unit}`;
const GB = 1e9, MB = 1e6;
let history = [];
const series = { t: [], cpu: [], rss: [], avail: [], gbusy: [], galloc: [], watts: [] };
const KEEP = 150;

// ── tabs ────────────────────────────────────────────────────────────────────────────────────────────────────────
document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => {
  document.querySelectorAll("nav button").forEach((x) => x.setAttribute("aria-selected", x === b ? "true" : "false"));
  document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.id === b.dataset.tab));
  if (b.dataset.tab === "logging") loadLog();
  if (b.dataset.tab === "infotags") loadInfo();
}));

// ── interaction ─────────────────────────────────────────────────────────────────────────────────────────────────
$("ask").addEventListener("submit", async (ev) => {
  ev.preventDefault();
  const q = $("q").value.trim();
  if (!q) return;
  $("q").value = ""; $("send").disabled = true;
  $("chat").append(el("div", "msg user", q));
  const bot = el("div", "msg bot", ""); const body = el("div"); const rc = el("div", "receipt", "…");
  bot.append(body, rc); $("chat").append(bot); bot.scrollIntoView({ block: "end" });
  try {
    const r = await fetch("/api/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: q, history }) });
    const reader = r.body.getReader(); const dec = new TextDecoder(); let buf = "", text = "";
    for (;;) {
      const { done, value } = await reader.read(); if (done) break;
      buf += dec.decode(value, { stream: true });
      let i;
      while ((i = buf.indexOf("\n")) >= 0) {
        const line = buf.slice(0, i); buf = buf.slice(i + 1);
        if (!line.trim()) continue;
        const d = JSON.parse(line);
        if (d.piece) { text += d.piece; body.textContent = text; bot.scrollIntoView({ block: "end" }); }
        if (d.done) {
          rc.textContent = "";
          if (d.error) { rc.append(el("span", "bad", "refused: " + d.error)); }
          else if (d.receipt) {
            const m = d.metrics || {};
            rc.append(el("span", d.answer_sha256_ok ? "ok" : "bad", d.answer_sha256_ok ? "βœ“ answer = receipt" : "β‰  received!"),
              document.createTextNode(`  model ${String(d.receipt.model_sha256).slice(0, 12)}…  ` +
                `${d.receipt.prompt_tokens}+${d.receipt.completion_tokens} tok Β· TTFT ${fmt(m.ttft_ms, 0, " ms")} Β· tg ${fmt(m.eval_tps, 1, " tok/s")} Β· ${fmt(m.joules_per_token, 3, " J/tok")}`));
          }
          history.push({ role: "user", content: q }, { role: "assistant", content: text });
          history = history.slice(-12);
        }
      }
    }
  } catch (e) { rc.textContent = "error: " + e; }
  $("send").disabled = false; $("q").focus();
});
$("q").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("ask").requestSubmit(); } });

// ── admin: sliders ──────────────────────────────────────────────────────────────────────────────────────────────
const sync = () => { $("threads-v").textContent = $("threads").value; $("ram-v").textContent = (+$("ram").value).toFixed(1) + " GB";
  $("gpu-v").textContent = +$("gpu").value === 0 ? "off" : $("gpu").value + " %"; };
["threads", "ram", "gpu"].forEach((id) => $(id).addEventListener("input", sync));
let slidersSet = false;
$("apply").addEventListener("click", async () => {
  $("apply").disabled = true;
  const r = await fetch("/api/resources", { method: "POST", headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ threads: +$("threads").value, ram_gb: +$("ram").value, gpu_limit: +$("gpu").value / 100 }) });
  const j = await r.json(); $("job").textContent = j.ok ? "restarting…" : "refused: " + j.error; $("apply").disabled = false;
});

// ── admin: charts (D3) ──────────────────────────────────────────────────────────────────────────────────────────
function chart(id, lines, opts = {}) {
  const svg = d3.select("#" + id); const node = svg.node(); const W = node.clientWidth || 340, H = 150, m = { t: 8, r: 10, b: 20, l: 38 };
  svg.attr("viewBox", `0 0 ${W} ${H}`); svg.selectAll("*").remove();
  const all = lines.flatMap((l) => l.data.filter((p) => p.y !== null && p.y !== undefined));
  if (!all.length) { svg.append("text").attr("class", "none").attr("x", W / 2).attr("y", H / 2).attr("text-anchor", "middle").text(opts.empty || "not measured yet"); return; }
  const xs = lines.flatMap((l) => l.data.map((p) => p.x));
  const x = d3.scaleLinear().domain(d3.extent(xs)).range([m.l, W - m.r]);
  const ymax = d3.max([d3.max(all, (p) => p.y), opts.limit || 0]) || 1;
  const y = d3.scaleLinear().domain([0, ymax * 1.1]).nice().range([H - m.b, m.t]);
  svg.append("g").attr("class", "axis").attr("transform", `translate(0,${H - m.b})`).call(d3.axisBottom(x).ticks(4).tickFormat(opts.xfmt || d3.format("d")));
  svg.append("g").attr("class", "axis").attr("transform", `translate(${m.l},0)`).call(d3.axisLeft(y).ticks(4));
  if (opts.limit) svg.append("line").attr("class", "limit").attr("x1", m.l).attr("x2", W - m.r).attr("y1", y(opts.limit)).attr("y2", y(opts.limit));
  const ln = d3.line().defined((p) => p.y !== null && p.y !== undefined).x((p) => x(p.x)).y((p) => y(p.y));
  lines.forEach((l) => svg.append("path").attr("class", l.cls).attr("d", ln(l.data)));
}
const tfmt = (s) => { const d = new Date(s * 1000); return d.toTimeString().slice(0, 8); };

function nowRow(dl, k, v) { dl.append(el("dt", null, k), el("dd", null, v)); }

async function poll() {
  let s;
  try { s = await (await fetch("/api/state")).json(); } catch (e) { $("status").textContent = "console: " + e; return; }
  const v = (s.serve || {}).verified || {}; const u = s.usage || {}; const m = s.metrics || {}; const lim = u.gpu_limiter || null;
  $("mantra").textContent = s.persona.mantra;
  $("status").textContent = "";
  $("status").append(v.verdict === "play" ? el("span", "ok", "βœ“ verified ") : el("span", "bad", "no verified engine "),
    document.createTextNode(`${v.name || ""} Β· bankML ${v.bankml || "?"} Β· ${String(v.model_sha256 || "").slice(0, 12)}…`));
  if (!slidersSet) {
    const r = s.resources || {}; const cores = u.cores || 4;
    $("threads").max = cores; $("threads").value = r.threads || 1;
    $("ram").max = Math.max(1, ((u.mem_total_bytes || 6 * GB) / GB)).toFixed(1); $("ram").value = r.ram_gb || 2;
    $("gpu").value = Math.round(100 * (r.gpu_limit ?? 0.8)); sync(); slidersSet = true;
    if (s.public) {  // a public console is read-only: the controls show the settings, they do not change them
      ["threads", "ram", "gpu", "apply"].forEach((id) => { $(id).disabled = true; });
      $("apply").textContent = "read-only: this is a public console";
    }
  }
  $("job").textContent = s.job.busy ? s.job.what : (s.job.error ? "failed: " + s.job.error : (s.job.done ? "done: the engine restarted, verified" : ""));
  const t = Date.now() / 1000; const g = (u.gpus || [])[0] || {};
  const push = (k, val) => { series[k].push(val); if (series[k].length > KEEP) series[k].shift(); };
  push("t", t); push("cpu", u.cpu_percent ?? null); push("rss", u.rss_bytes != null ? u.rss_bytes / GB : null);
  push("avail", u.mem_available_bytes != null ? u.mem_available_bytes / GB : null); push("gbusy", g.busy_percent ?? null);
  push("galloc", lim ? lim.allocated_bytes / MB : null); push("watts", u.package_watts ?? null);
  const pts = (k) => series.t.map((x, i) => ({ x, y: series[k][i] }));
  const recs = (m.records || []).slice(-60);
  chart("c-tps", [{ cls: "l1", data: recs.map((r) => ({ x: r.at, y: r.eval_tps })) }, { cls: "l2", data: recs.map((r) => ({ x: r.at, y: r.prompt_tps })) }], { xfmt: tfmt, empty: "no answers yet" });
  chart("c-ttft", [{ cls: "l1", data: recs.map((r) => ({ x: r.at, y: r.ttft_ms })) }], { xfmt: tfmt, empty: "no answers yet" });
  chart("c-cpu", [{ cls: "l1", data: pts("cpu") }], { xfmt: tfmt });
  chart("c-mem", [{ cls: "l1", data: pts("rss") }, { cls: "l2", data: pts("avail") }], { xfmt: tfmt });
  chart("c-gpu", [{ cls: "l1", data: pts("gbusy") }], { xfmt: tfmt, limit: lim ? 100 * lim.limit : null, empty: "no GPU reading" });
  chart("c-pow", [{ cls: "l1", data: pts("watts") }], { xfmt: tfmt, empty: "power not measured (./install.sh power)" });
  $("pow-note").textContent = `energy per token over the kept answers: ${fmt(m.joules_per_token, 3, " J")}`;
  const now = $("now"); now.textContent = "";
  nowRow(now, "tokens in / out", `${m.prompt_tokens ?? 0} / ${m.completion_tokens ?? 0}`);
  const last = (m.records || []).slice(-1)[0] || {};
  nowRow(now, "last TTFT", fmt(last.ttft_ms, 0, " ms")); nowRow(now, "last pp Β· tg", `${fmt(last.prompt_tps, 1)} Β· ${fmt(last.eval_tps, 1)} tok/s`);
  nowRow(now, "CPU", fmt(u.cpu_percent, 0, " %")); nowRow(now, "RSS", fmt(u.rss_bytes / GB, 2, " GB"));
  nowRow(now, "RAM available", fmt(u.mem_available_bytes / GB, 2, " GB"));
  nowRow(now, "GPU busy", fmt(g.busy_percent, 0, " %"));
  nowRow(now, "GPU limiter", lim ? `${(100 * lim.limit).toFixed(0)} % Β· ${(lim.allocated_bytes / MB).toFixed(0)} MB of ${(lim.heap_bytes / GB).toFixed(1)} GB Β· busy ${(100 * lim.busy).toFixed(0)} %` : "no card in use");
  nowRow(now, "power", fmt(u.package_watts, 1, " W"));
}

// ── logging ─────────────────────────────────────────────────────────────────────────────────────────────────────
async function loadLog() {
  const j = await (await fetch("/api/log")).json(); const tb = document.querySelector("#log tbody"); tb.textContent = "";
  j.exchanges.slice().reverse().forEach((x) => {
    const tr = el("tr"); const m = x.metrics || {}; const r = x.receipt || {};
    [tfmt(x.at), x.question.slice(0, 80), r.prompt_tokens != null ? `${r.prompt_tokens}+${r.completion_tokens}` : "β€”",
      fmt(m.ttft_ms, 0), fmt(m.eval_tps, 1), x.error ? "refused: " + x.error.slice(0, 60) : String(r.response_sha256 || "").slice(0, 16)]
      .forEach((c) => tr.append(el("td", null, c)));
    tb.append(tr);
  });
  $("englog").textContent = j.engine_log.join("\n");
}

// ── infotags ────────────────────────────────────────────────────────────────────────────────────────────────────
let info = null;
async function loadInfo() {
  info = await (await fetch("/api/infotags")).json();
  const tb = document.querySelector("#attrs tbody"); tb.textContent = "";
  info.attributes.forEach((a) => { const tr = el("tr"); tr.append(el("td", null, a.trait_type), el("td", null, String(a.value))); tb.append(tr); });
  $("infojson").textContent = JSON.stringify(info, null, 1);
}
$("download").addEventListener("click", () => {
  if (!info) return;
  const a = el("a"); a.href = URL.createObjectURL(new Blob([JSON.stringify(info, null, 1)], { type: "application/json" }));
  a.download = "bankml-infotags.json"; a.click(); URL.revokeObjectURL(a.href);
});

poll(); setInterval(poll, 2000);