File size: 20,772 Bytes
2d10ddc
1f9a158
 
2d10ddc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a752b1a
 
2d10ddc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a752b1a
 
2d10ddc
a752b1a
2d10ddc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a752b1a
2d10ddc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a752b1a
2d10ddc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
// SPDX-License-Identifier: MIT OR Apache-2.0
// browseML's page: one answerer here, bankML in this browser (browseml.js); the chooser names the others honestly (all
// on the full bankML Space: the free CPU, your own bankML, a Hugging Face provider) without calling them. The input → response pattern of the Savante Space, with every model word written with textContent, never innerHTML. Each answer keeps three things apart:
//   the answer   — only the text the model generated
//   a page note  — anything this page adds (a hidden <think> section, a length cut), labelled as the page's
//   the delivery — the engine's own token counts, time to the first token, tok/s, where it ran, the receipt's verdict
// No completion tokens counted is "No delivery from the model", not text; only a delivered answer joins the history.
import * as browseml from "./browseml.js";

const $ = (id) => document.getElementById(id);
const MB = (b) => (b / 1e6).toFixed(0);
const secs = (s) => (s === null || s === undefined || !isFinite(s) ? "?" : s < 60 ? `${s.toFixed(1)} s` : `${Math.floor(s / 60)} min ${Math.round(s % 60)} s`);
const WHO = "bankML in this browser";
const plural = (n, one, many = one + "s") => `${n} ${n === 1 ? one : many}`;
// cut a long string at a word boundary near `max`, and say it was cut
const clip = (t, max) => t.length <= max ? t : t.slice(0, max).replace(/[\s,;(]+\S*$/, "") + "…";
const HISTORY_TURNS = 3;  // the last three delivered turns go back to the model; the engine reuses what it has read
const history = [];       // delivered turns only
let busy = false;

// ── the engine log (a details drawer) ─────────────────────────────────────────────────────────────────────────
const logLines = [];
function log(kind, text) {
  logLines.push(`${new Date().toLocaleTimeString()} ${kind.padEnd(6)} ${text}`);
  if (logLines.length > 300) logLines.shift();
  $("englog").textContent = logLines.join("\n");
}
const engineLog = (level, text) => log(level === 0 ? "error" : level === 1 ? "warn" : "engine", text);

// ── the session counter: the engine's own token counts, delivered answers only; while one streams, its pieces ──
const tally = { in: 0, out: 0, turns: 0 };
const live = (t) => { $("c_live").textContent = t || ""; };
function counted(promptN, completionN, last) {
  tally.in += Number(promptN) || 0; tally.out += Number(completionN) || 0; tally.turns += 1;
  $("c_in").textContent = tally.in; $("c_out").textContent = tally.out; $("c_turns").textContent = tally.turns;
  live(""); $("c_last").textContent = last || "";
}

// ── the environment line, this side: what this device offers. Read here, never sent. ─────────────────────────
async function clientEnv() {
  const iso = !!(globalThis.crossOriginIsolated && typeof SharedArrayBuffer !== "undefined");
  const bits = [`${navigator.hardwareConcurrency || "?"} CPU threads`,
    navigator.deviceMemory ? `${navigator.deviceMemory >= 8 ? "≥ 8" : navigator.deviceMemory} GB RAM` : "RAM not reported by this browser"];
  let gpu = "";
  try {
    const gl = document.createElement("canvas").getContext("webgl");
    const ext = gl && gl.getExtension("WEBGL_debug_renderer_info");
    gpu = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : (gl ? gl.getParameter(gl.RENDERER) : "");
  } catch { /* no WebGL */ }
  bits.push(gpu ? `GPU ${clip(String(gpu).replace(/^ANGLE \((.*)\)$/, "$1"), 80)} (not used: the engine runs on the CPU)` : "GPU not reported");
  const n = browseml.threads();
  bits.push(iso ? `cross-origin isolated: bankML takes ${n} thread${n > 1 ? "s" : ""}` : "not cross-origin isolated: bankML takes one thread");
  $("env_client").textContent = bits.join(" · ");
}

// ── loading: the download (once), then bankML's verification, said as it happens ─────────────────────────────
const listeners = new Set();  // answer windows waiting on the load mirror its line
function say(text, cls = "is-working", frac) {
  $("status").textContent = text; $("status").className = "status " + cls;
  const bar = $("bar"); bar.hidden = frac === undefined; if (frac !== undefined) bar.value = frac;
  listeners.forEach((f) => f(text));
}
let verified = null;
async function load() {
  if (browseml.isReady() && verified) return verified;
  if (browseml.isLoading()) return browseml.whenLoaded();  // one download at a time: wait for the one under way
  $("load").disabled = true;
  const t0 = performance.now();
  let dl0 = null, clock = null;
  try {
    verified = await browseml.load((got, total, phase) => {
      if (phase === "download") {
        if (!dl0) dl0 = { t: performance.now(), got };
        const dt = (performance.now() - dl0.t) / 1000, rate = dt > 0.5 ? (got - dl0.got) / dt : 0;
        say(`downloading the model (${browseml.MODEL.title}): ${MB(got)} of ${MB(total)} MB` +
          (rate ? ` · ${(rate / 1e6).toFixed(1)} MB/s · about ${secs((total - got) / rate)} left` : "") + " · once; it stays in this browser's cache",
        "is-working", got / total);
      } else if (phase === "retry") {
        say(`the connection dropped at ${MB(got)} MB: asking for that piece again`, "is-working", got / total);
      } else if (phase === "cache") {
        say(`the model is in this browser's cache (${MB(got)} MB): no download`);
      } else if (phase === "verify" && !clock) {
        // the engine starts in a worker, checks the GGUF header, hashes all 248 MB against the pin, then lays out the
        // weights: seconds, not minutes; the clock runs until it says it is verified
        const v0 = performance.now();
        const tick = () => say(`bankML is verifying the model in this browser: the guard, then the sha256 of all ${MB(browseml.MODEL.bytes)} MB against its pin · ${secs((performance.now() - v0) / 1000)}`);
        tick(); clock = setInterval(tick, 500);
      }
    }, engineLog, { threads: browseml.threads(), ctx: 4096 });
    const v = verified;
    say(`✓ ${v.model} verified by bankML ${v.version} · sha256 ${v.sha256.slice(0, 12)}… · ${v.threads} thread${v.threads > 1 ? "s" : ""} · ${v.build || "browseml.wasm"} · ready in ${secs((performance.now() - t0) / 1000)}`, "is-ok");
    log("ok", `verified ${v.model} · sha256 ${v.sha256} · guard ${v.guard} · engine ${v.engine} · ${v.threads} thread(s) · ${v.build || "browseml.wasm"}`);
    $("load").hidden = true;
    return v;
  } catch (e) {
    const m = String(e && e.message || e);
    say("bankML could not load: " + m + hint(m), "is-bad");
    log("error", "load: " + m);
    throw e;
  } finally {
    if (clock) clearInterval(clock);
    $("load").disabled = false;
  }
}

// errors, said as what to do about them
function hint(m) {
  if (/download|HTTP \d|stopped at|longer than|failed to fetch|networkerror|load failed/i.test(m))
    return " — the model's download failed: check the connection and ask again. A partial download is not kept.";
  if (/FORK\.json/.test(m)) return " — the model's pin did not load with the page: reload the page.";
  if (/sha256|guard|pin/i.test(m)) return " — the model failed bankML's check, so it will not answer from it. Clear this site's data to download it again.";
  if (/memory|unreachable|RuntimeError|trap|-99/i.test(m)) return " — the engine stopped (most often out of memory). It loads again on the next question; closing other tabs helps.";
  if (/compile|WebAssembly|\.wasm/i.test(m)) return " — this browser could not start the engine's WebAssembly: a current Chrome, Edge or Firefox runs it.";
  return "";
}

// ── the receipt, checked here ────────────────────────────────────────────────────────────────────────────────
async function sha256hex(text) {
  const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
  return [...new Uint8Array(h)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
const verdictText = (rc, ok) => rc
  ? `${ok ? "✓" : "✗"} receipt — bankML ${rc.bankml} · model sha256 ${String(rc.model_sha256 || "").slice(0, 12)}… · answer sha256 ${ok ? "matches the text received" : "does NOT match"}`
  : "✗ no receipt came with this answer";
// a thinking model may put its reasoning in <think>…</think>: the answer is shown without it, and the page says so
const answerOnly = (t) => t.replace(/<think>[\s\S]*?(<\/think>|$)/g, "").replace(/^\s+/, "");

// ── the conversation ─────────────────────────────────────────────────────────────────────────────────────────
function bubble(role, label) {
  const div = document.createElement("div");
  div.className = `msg ${role}`;
  const who = document.createElement("div"); who.className = "who"; who.textContent = label;
  div.append(who);
  $("log").append(div);
  return div;
}
function userBubble(text) {
  const div = bubble("user", "you");
  const body = document.createElement("div"); body.className = "body"; body.textContent = text;
  div.append(body);
  div.scrollIntoView({ block: "nearest" });
}
function answerWindow() {
  const div = bubble("assistant", WHO);
  const card = document.createElement("div"); card.className = "card";
  const el = (cls) => { const e = document.createElement("div"); e.className = cls; card.append(e); return e; };
  const w = { div, card, answer: el("answer"), progress: el("progress"), note: null, delivery: null, actions: el("actions") };
  div.append(card);
  w.show = () => div.scrollIntoView({ block: "nearest" });
  w.fail = (cls, text) => { w.progress.textContent = ""; w.answer.textContent = ""; const e = el(cls); e.textContent = text; w.show(); };
  return w;
}
// the first question offers the one-time download in the answer window, rather than failing or starting it unasked
function offer(w) {
  w.progress.textContent = `bankML in this browser needs its model first: ${browseml.MODEL.title}, ${MB(browseml.MODEL.bytes)} MB from its Hugging Face repository at a pinned revision. Downloaded once, checked against its sha256 pin, and kept in this browser's cache.`;
  return new Promise((resolve) => {
    const yes = document.createElement("button"); yes.className = "go"; yes.textContent = `Download ${MB(browseml.MODEL.bytes)} MB and answer`;
    const no = document.createElement("button"); no.textContent = "Not now";
    yes.onclick = () => { w.actions.replaceChildren(); resolve(true); };
    no.onclick = () => { w.actions.replaceChildren(); resolve(false); };
    w.actions.replaceChildren(yes, no); w.show(); yes.focus();
  });
}

const SYSTEM = (v) => `You are bankML, answering in the visitor's own web browser: bankML's engine compiled to WebAssembly ` +
  `(browseML), running ${v.model} on the visitor's CPU with ${v.threads} thread${v.threads > 1 ? "s" : ""}. bankML ${v.version} ` +
  `verified the model against its sha256 pin before this conversation, and every answer you give carries a receipt that ` +
  `the page checks. No server is involved. Answer briefly and plainly; when you do not know, say so.`;

async function ask() {
  const field = $("message");
  const message = field.value.trim();
  if (!message || busy) return;
  busy = true; $("send").disabled = true;
  field.value = "";
  userBubble(message);
  const w = answerWindow();
  w.show();
  try {
    if (!browseml.isReady()) {
      if (!browseml.isLoading() && !(await browseml.isCached())) {
        if (!(await offer(w))) {
          log("mode", "the one-time download was not started");
          w.fail("nodelivery", `No delivery from the model: ${WHO} has no model yet (the ${MB(browseml.MODEL.bytes)} MB download was not started), so there is no answer to show. Nothing was added to this conversation.`);
          return;
        }
      }
      const mirror = (t) => { w.progress.textContent = t; };
      listeners.add(mirror);
      try { verified = await load(); } finally { listeners.delete(mirror); }
    }
    const v = verified || (verified = await load());
    const thr = `${v.threads} thread${v.threads > 1 ? "s" : ""}`;
    const msgs = [{ role: "system", content: SYSTEM(v) }, ...history.slice(-2 * HISTORY_TURNS), { role: "user", content: message }];
    const maxTokens = Number($("maxtok").value) || 128;
    const t0 = performance.now();
    let first = null, text = "", n = 0, lastShown = 0;
    const reading = setInterval(() => { if (first === null) w.progress.textContent = `reading the prompt in this browser · ${secs((performance.now() - t0) / 1000)} · ${thr}`; }, 500);
    w.progress.textContent = `reading the prompt in this browser · ${thr}`;
    let result;
    try {
      result = await browseml.chat({ messages: msgs, max_tokens: maxTokens }, (piece) => {
        n++;
        const now = performance.now();
        if (first === null) { first = now; log("answer", `first token after ${secs((first - t0) / 1000)}`); }
        text += piece;
        w.answer.textContent = answerOnly(text);
        live(`streaming · ${plural(n, "piece")}`);
        if (now - lastShown > 400) {
          lastShown = now;
          // the pieces as they arrive, not the engine's token count (that comes with the delivery line)
          const pps = n > 1 ? (n - 1) / ((now - first) / 1000) : 0;
          w.progress.textContent = `writing · ${plural(n, "piece")} received${pps ? ` · ${pps.toFixed(2)} pieces/s` : ""} · ${maxTokens} tokens at most`;
          w.show();
        }
      }, engineLog);
    } finally { clearInterval(reading); live(""); }
    w.progress.textContent = "";

    const receipt = result.bankml_receipt, tm = result.timings || {};
    const ok = !!receipt && (await sha256hex(text)) === receipt.response_sha256;
    // the whole prompt is usage.prompt_tokens; timings.prompt_n is only what the engine read anew (it reuses the rest)
    const promptN = result.usage?.prompt_tokens ?? tm.prompt_n, readNew = tm.prompt_n;
    const completion = result.usage?.completion_tokens ?? tm.predicted_n ?? 0;
    const total = performance.now() - t0, ttft = first === null ? null : first - t0;
    const shown = answerOnly(text).trim();
    if (!completion || !shown) {
      log("warn", "no delivery: the engine counted no completion tokens" + (completion ? " outside a <think> section" : ""));
      w.fail("nodelivery", `No delivery from the model: ${WHO} counted no completion tokens${completion ? " outside its <think> section" : ""}, so there is no answer to show. Nothing was added to this conversation.`);
      return;
    }
    w.answer.textContent = shown;
    // the page's own notes, apart from the answer and labelled as the page's
    const notes = [];
    if (shown.length < text.trim().length) notes.push("the model's <think> section is hidden here; the receipt covers the full text");
    if ((result.choices?.[0]?.finish_reason) === "length") notes.push(`the answer reached its length (${maxTokens} tokens) and was cut there`);
    if (notes.length) { w.note = document.createElement("div"); w.note.className = "pagenote"; w.note.textContent = "added by this page, not the model: " + notes.join(" · "); w.card.insertBefore(w.note, w.actions); }
    // the DELIVERY line
    w.delivery = document.createElement("div"); w.delivery.className = "delivery";
    const facts = `delivered by the model — ${WHO} (${String(v.model).replace(/\.gguf$/, "")}, browseML): ${promptN ?? "?"} prompt` +
      (readNew !== undefined && promptN !== undefined && readNew < promptN ? ` (${readNew} read anew, the rest reused)` : "") +
      ` → ${completion} completion tokens` +
      ` · first token ${secs(ttft === null ? null : ttft / 1000)}` + (tm.predicted_per_second ? ` · ${Number(tm.predicted_per_second).toFixed(2)} tok/s` : "") +
      ` · ${secs(total / 1000)} · in this browser on your CPU (WebAssembly, ${thr}${v.build ? ", " + v.build : ""}) · `;
    const verdict = document.createElement("span"); verdict.className = ok ? "ok" : "bad"; verdict.textContent = verdictText(receipt, ok);
    w.delivery.append(facts, verdict);
    w.card.insertBefore(w.delivery, w.actions);
    const turn = tally.turns + 1;  // this answer's number, fixed now: counted() below makes it the session's
    const pop = document.createElement("button"); pop.textContent = "pop out";
    pop.onclick = () => spawn(`${WHO} · ${turn} · ${message}`, shown, w.delivery);
    w.actions.replaceChildren(pop);
    log(receipt ? (ok ? "ok" : "error") : "warn", receipt ? `receipt ${ok ? "✓" : "✗"} · ${promptN} + ${completion} tokens · ${secs(total / 1000)}` : "no receipt came with this answer");
    counted(promptN, completion, `last: ${completion} tokens · ${WHO}`);
    history.push({ role: "user", content: message }, { role: "assistant", content: text });
    w.show();
  } catch (e) {
    const m = String(e && e.message || e);
    log("error", m);
    w.fail("failed", `${WHO} did not answer: ${m.slice(0, 300)}${hint(m)}`);
  } finally {
    busy = false; $("send").disabled = false;
  }
}

// ── pop-outs: an answer in its own window, glass over the page, moved by its title bar ────────────────────────
let spawned = 0;
function spawn(title, text, delivery) {
  const win = document.createElement("section"); win.className = "spawn"; win.setAttribute("role", "dialog"); win.setAttribute("aria-label", title);
  // each one a step down (and, where there is room, right) of the last; on a phone it fills the width between the gutters
  const off = (spawned++ % 6) * 28, phone = innerWidth <= 640, gutter = 16;
  win.style.left = `${phone ? gutter : Math.max(gutter, Math.min(innerWidth - 580, 80 + off))}px`;
  win.style.top = `${phone ? gutter + off : 80 + off}px`;
  const bar = document.createElement("header");
  const b = document.createElement("b"); b.textContent = title;
  const close = document.createElement("button"); close.textContent = "×"; close.setAttribute("aria-label", "close"); close.onclick = () => win.remove();
  bar.append(b, close);
  const body = document.createElement("div"); body.className = "answer"; body.textContent = text;
  const d = delivery.cloneNode(true);  // the delivery line as shown under the answer, its receipt verdict still coloured
  win.append(bar, body, d);
  bar.addEventListener("pointerdown", (e) => {
    if (e.target === close) return;
    const sx = e.clientX - win.offsetLeft, sy = e.clientY - win.offsetTop;
    bar.setPointerCapture(e.pointerId);
    const move = (ev) => { win.style.left = `${Math.max(0, Math.min(innerWidth - 80, ev.clientX - sx))}px`; win.style.top = `${Math.max(0, Math.min(innerHeight - 40, ev.clientY - sy))}px`; };
    bar.addEventListener("pointermove", move);
    bar.addEventListener("pointerup", () => bar.removeEventListener("pointermove", move), { once: true });
  });
  $("spawns").append(win);
  close.focus();
}
addEventListener("keydown", (e) => { if (e.key === "Escape") { const s = $("spawns").lastElementChild; if (s) s.remove(); } });

// ── the field: Enter sends, Shift+Enter a new line; the examples go into the field, still the visitor's to send ─
$("send").addEventListener("click", ask);
$("message").addEventListener("keydown", (e) => {
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); ask(); }
});
document.querySelectorAll("[data-example]").forEach((b) => b.addEventListener("click", () => {
  $("message").value = b.dataset.example; $("message").focus();
}));
$("load").addEventListener("click", () => load().catch(() => {}));
$("tmode").addEventListener("click", () => {
  const on = $("screen").classList.toggle("t");
  $("tmode").setAttribute("aria-pressed", String(on));
  try { localStorage.setItem("browseml-t", on ? "1" : ""); } catch {}
});
try { if (localStorage.getItem("browseml-t")) { $("screen").classList.add("t"); $("tmode").setAttribute("aria-pressed", "true"); } } catch {}
try { const m = localStorage.getItem("browseml-maxtok"); if (m) $("maxtok").value = m; } catch {}
$("maxtok").addEventListener("change", () => { try { localStorage.setItem("browseml-maxtok", $("maxtok").value); } catch {} });

clientEnv();
browseml.isCached().then((yes) => {
  if (yes) { $("load").textContent = "Load bankML (the model is in this browser's cache)"; say("The model is in this browser's cache: it loads with your first question, in seconds.", ""); }
});