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.", ""); }
});
|