File size: 22,269 Bytes
dbcfb9e 36a3c9b dbcfb9e 36a3c9b dbcfb9e 36a3c9b dbcfb9e 36a3c9b dbcfb9e 36a3c9b dbcfb9e 36a3c9b dbcfb9e e14b0de dbcfb9e e14b0de dbcfb9e e14b0de dbcfb9e e14b0de dbcfb9e e14b0de dbcfb9e e14b0de 36a3c9b dbcfb9e e14b0de dbcfb9e e14b0de daef2b2 dbcfb9e 36a3c9b dbcfb9e 36a3c9b dbcfb9e e14b0de dbcfb9e 36a3c9b dbcfb9e e14b0de dbcfb9e e14b0de dbcfb9e e14b0de dbcfb9e 36a3c9b dbcfb9e 36a3c9b dbcfb9e 36a3c9b dbcfb9e 36a3c9b daef2b2 36a3c9b dbcfb9e 36a3c9b dbcfb9e 36a3c9b dbcfb9e e14b0de dbcfb9e daef2b2 dbcfb9e | 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 | // SPDX-License-Identifier: MIT OR Apache-2.0
// The page behind the input field: this computer's controls and diagnostics. What the visitor's own machine gives
// bankML (threads, memory for the context, the length of an answer, a lean or full prompt), what it measured
// (each answer's prompt and generation speed, time to first token, the engine's memory, the browser's storage),
// and what to change, worked out from those measurements. The same report is T mode's `diag` (window.bankmlDiag).
// Reads window.bankmlStats and window.bankmlSettings (bankml-chat.js); everything stays in this browser.
// The disk and cache controls (browseml-fetch.js): what browseML keeps here and its size, persistent storage and the
// request to keep it, "remove the model after this session", and "clear browseML's cache" (confirmed in the page).
import * as browseml from "./browseml.js";
import * as fetcher from "./browseml-fetch.js";
const $ = (id) => document.getElementById(id);
const el = (tag, props = {}, ...kids) => { const e = Object.assign(document.createElement(tag), props); e.append(...kids.filter((k) => k !== null && k !== undefined)); return e; };
const cores = navigator.hardwareConcurrency || 1;
const isolated = !!(globalThis.crossOriginIsolated && typeof SharedArrayBuffer !== "undefined");
const MB = (b) => (b ? (b < 1e6 ? `${(b / 1e3).toFixed(1)} KB` : `${(b / 1e6).toFixed(0)} MB`) : "—");
const fmt = (x, d = 1, unit = "") => (x === null || x === undefined || Number.isNaN(x) ? "—" : `${Number(x).toFixed(d)}${unit}`);
// Bonsai-1.7B's KV cache in f16: 28 layers × (8 heads × 128) × K and V × 2 bytes, per token of context
const KV_BYTES_PER_TOKEN = 28 * 1024 * 2 * 2;
const CTX = [1024, 2048, 4096];
// the context advised where the device reports little memory: the same rule as the browseML card (caps.ctxCapFor)
const lowCap = () => { const m = navigator.deviceMemory; return typeof m !== "number" ? null : m <= 2 ? 1024 : m <= 4 ? 2048 : null; };
// a token is about 3.6 characters of English for this tokenizer: an estimate, said as one
const tokensOf = (chars) => Math.round(chars / 3.6);
let storage = null, kept = [], persistedNow = null, confirmClear = false;
async function refreshStorage() {
try { storage = navigator.storage && navigator.storage.estimate ? await navigator.storage.estimate() : null; } catch { storage = null; }
try { storage = { ...(storage || {}), model: await browseml.cachedBytes() }; } catch {}
try { kept = await fetcher.keptFiles(); } catch { kept = []; }
persistedNow = await fetcher.persisted();
}
// the prompt of whoever speaks now (bankML or Savante), lean and full, as bankml-chat.js built it: an estimate
const promptSize = () => { const p = window.bankmlStats && window.bankmlStats.prompt; return p ? { who: p.speaker, lean: tokensOf(p.lean), full: tokensOf(p.full) } : { who: "the persona", lean: 0, full: 0 }; };
// the threads the engine can take here: every core up to browseML's cap when isolated, else one
const maxThreads = () => browseml.threads();
/** What the measurements say to change: each line is a finding and what to do about it. */
function advice(S, st) {
const out = [], last = st.answers.filter((a) => a.mode === "browse").slice(-1)[0], persona = promptSize();
const mode = st.mode || "browse";
if (mode === "browse") {
if (!isolated) out.push(["warn", "One thread: this page is not cross-origin isolated (or this browser has no SharedArrayBuffer). Open the Space directly — pythai-bankml.static.hf.space — in Chrome, Edge or Firefox to use every core."]);
else if (st.browse && st.browse.threads < maxThreads()) out.push(["ok", `${st.browse.threads} of ${maxThreads()} threads in use${cores > maxThreads() ? ` (bankML in this browser takes at most ${browseml.MAX_THREADS} of the ${cores} cores)` : ""}. More threads answer faster; fewer leave the computer free for other work.`]);
if (!st.browse) out.push(["warn", `browseML is not loaded: Load downloads ${MB(browseml.MODEL.bytes)} once, then it starts in seconds from this browser's cache.`]);
if (last && last.prompt_tps) {
const next = (S.lean ? persona.lean : persona.full) + 40;
out.push(["ok", `Reading speed ${fmt(last.prompt_tps, 1)} tokens/s: a new conversation's first answer reads about ${next} tokens of ${persona.who}'s persona first — about ${fmt(next / last.prompt_tps, 0)} s. Later answers in the same window read only what is new (${last.cache_n ?? 0} tokens were reused last time).`]);
}
if (!S.lean && persona.full) out.push(["warn", `The full prompt adds ${persona.who}'s context summary: about ${persona.full - persona.lean} more tokens to read before every new conversation. “Lean” keeps only the persona and still adds the passages that match each question.`]);
const more = isolated && st.browse && st.browse.threads < maxThreads();
if (last && last.gen_tps && last.gen_tps < 1) out.push(["warn", `Writing at ${fmt(last.gen_tps, 2)} tokens/s: close other busy tabs, ${more ? "raise the threads, " : ""}or shorten answers (now ${S.maxTokens} tokens at most).`]);
const kv = S.ctx * KV_BYTES_PER_TOKEN, dev = navigator.deviceMemory ? navigator.deviceMemory * 1e9 : null;
if (dev && browseml.MODEL.bytes + kv > dev * 0.5) out.push(["warn", `Context ${S.ctx} reserves ${MB(kv)} for its cache, beside the ${MB(browseml.MODEL.bytes)} model, on a device that reports about ${navigator.deviceMemory} GB: a smaller context leaves more for the rest.`]);
if (storage && storage.quota && storage.usage / storage.quota > 0.8) out.push(["warn", `This site's storage is ${fmt(100 * storage.usage / storage.quota, 0)} % full: the browser may evict the cached model.`]);
}
if (mode === "local" && !(st.local && st.local.verified && st.local.verified.guard === "play")) out.push(["warn", "Your own bankML is not connected: start it with ./install.sh start --space, then connect (who answers: your own bankML)."]);
if (mode === "hf") out.push(["warn", "A provider answers: not bankML, no receipt, and free Hugging Face accounts have no included inference credit since 2026-10-07. browseML is free and verified."]);
if (mode === "space") out.push(["warn", "The free CPU answers: Bonsai on Hugging Face's free CPU, llama.cpp, not bankML's engine, no receipt; what you type goes to a public Space. browseML (bankML in this browser) gives verified answers with a receipt."]);
if (!out.length) out.push(["ok", "Nothing to change: the measurements look healthy."]);
return out;
}
function sparkline(values) {
const w = 220, h = 40, max = Math.max(1e-9, ...values);
const pts = values.map((v, i) => `${values.length === 1 ? w / 2 : (i * w) / (values.length - 1)},${h - 2 - (v / max) * (h - 6)}`).join(" ");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", `0 0 ${w} ${h}`); svg.setAttribute("class", "spark"); svg.setAttribute("role", "img");
svg.setAttribute("aria-label", `generation speed of the last ${values.length} answers, at most ${max.toFixed(2)} tokens per second`);
if (values.length) { const pl = document.createElementNS(svg.namespaceURI, "polyline"); pl.setAttribute("points", pts); svg.append(pl); }
return svg;
}
function card(title, ...body) { return el("div", { className: "dcard" }, el("h3", { textContent: title }), ...body); }
function kv(rows) {
const dl = el("dl", { className: "dkv" });
for (const [k, v, cls] of rows) dl.append(el("dt", { textContent: k }), el("dd", { textContent: v, className: cls || "" }));
return dl;
}
function render() {
const root = $("dash"); if (!root || !window.bankmlSettings) return;
// never under the visitor's hand: a slider being dragged or a menu open keeps its element
if (root.contains(document.activeElement) && /^(INPUT|SELECT)$/.test(document.activeElement.tagName)) return;
const S = window.bankmlSettings.get(), st = window.bankmlStats, set = window.bankmlSettings.set;
const b = st.browse, mode = st.mode || "browse";
const answers = st.answers, last = answers[answers.length - 1];
// the threads a reload would give: capped as browseml.js caps them, so above 8 cores "reload to apply" does not stay
const loaded = !!b, pendingThreads = Math.max(1, Math.min(maxThreads(), S.threads || cores)), stale = loaded && (b.threads !== pendingThreads || b.ctx !== S.ctx);
// who answers, and the engine's state
const engineRows = mode === "browse"
? (b ? [["engine", `bankML ${b.version} in this browser (WebAssembly)`], ["model", `${b.model} · verified`, "ok"], ["sha256", `${b.sha256.slice(0, 16)}…`],
["threads", `${b.threads} of ${cores} cores${isolated ? (cores > maxThreads() ? ` (at most ${browseml.MAX_THREADS} here)` : "") : " (not isolated: one)"}`],
["build", b.build === "browseml-mt-relaxed.wasm" ? "relaxed SIMD (fused multiply-add) — the fastest" : b.build === "browseml-mt.wasm" ? "SIMD, threads (no relaxed SIMD here)" : "SIMD, one thread"], ["context", `${b.ctx} tokens`], ["engine memory", MB(browseml.memoryBytes)], ["loaded in", fmt(b.load_ms / 1000, 0, " s")]]
: [["engine", "browseML not loaded"], ["model", `${browseml.MODEL.title}, ${MB(browseml.MODEL.bytes)}`], ["in this browser's cache", storage && storage.model ? "yes" : "no"]])
: mode === "local"
? (st.local ? [["engine", `your bankml serve at ${st.local.endpoint}`], ["model", st.local.verified?.name || "—", st.local.verified?.guard === "play" ? "ok" : "warn"], ["round trip", fmt(st.local.rtt_ms, 0, " ms")]] : [["engine", "your own bankML: not connected"]])
: mode === "space"
? [["engine", "Bonsai on Hugging Face's free CPU — llama.cpp, not bankML's engine"], ["where", "the public Space Gregory-L/mindXhfgradio"], ["receipt", "none", "warn"]]
: [["engine", "a Hugging Face provider — not bankML"], ["receipt", "none", "warn"]];
const engineBtns = el("div", { className: "drow" });
if (mode === "browse") {
engineBtns.append(
// never a download from here: a model not yet kept in this browser goes through browseML's consent card
el("button", { type: "button", textContent: loaded ? (stale ? "Apply: reload the engine" : "Reload the engine") : storage && storage.model ? "Start bankML in this browser" : "Open browseML's card (download and verify)", className: stale || !loaded ? "primary" : "",
onclick: async (e) => { e.target.disabled = true; try { if (loaded) await window.bankmlReloadBrowse(); else if (storage && storage.model) await window.bankmlLoadBrowse(); else window.bankmlBrowseCard && window.bankmlBrowseCard.show(); } finally { render(); } } }),
...(loaded ? [el("button", { type: "button", textContent: "Unload (free its memory)", onclick: () => { try { browseml.unload(); st.browse = null; st.emit(); } catch (err) { alertLine(err.message); } } })] : [])); // append() would write "null"
}
// the controls
const threadOut = el("output", { textContent: `${pendingThreads}` });
const threads = el("input", { type: "range", min: 1, max: maxThreads(), value: pendingThreads, disabled: !isolated,
oninput: (e) => { threadOut.textContent = e.target.value; }, onchange: (e) => set({ threads: +e.target.value }) });
const ctx = el("select", { onchange: (e) => set({ ctx: +e.target.value }) },
...CTX.map((c) => el("option", { value: c, selected: S.ctx === c, textContent: `${c} tokens · ${MB(c * KV_BYTES_PER_TOKEN)} of cache · about ${MB(browseml.MODEL.bytes + c * KV_BYTES_PER_TOKEN + 96e6)} in all (estimate)` })));
const maxOut = el("output", { textContent: `${S.maxTokens}` });
const maxT = el("input", { type: "range", min: 32, max: 512, step: 32, value: S.maxTokens,
oninput: (e) => { maxOut.textContent = e.target.value; }, onchange: (e) => set({ maxTokens: +e.target.value }) });
const persona = promptSize();
const prompt = el("div", { className: "drow", role: "radiogroup" },
el("label", {}, el("input", { type: "radio", name: "dprompt", checked: S.lean, onchange: () => set({ lean: true }) }), ` lean — ${persona.who}'s persona (~${persona.lean} tokens)`),
el("label", {}, el("input", { type: "radio", name: "dprompt", checked: !S.lean, onchange: () => set({ lean: false }) }), ` full — and its context summary (~${persona.full})`));
const controls = card("Controls",
el("label", { className: "dctl" }, el("span", { textContent: "threads" }), threads, threadOut,
el("small", { textContent: isolated ? `of ${cores} cores${cores > maxThreads() ? ` (at most ${browseml.MAX_THREADS} here)` : ""} · applies on reload` : "one: this page is not cross-origin isolated" })),
el("label", { className: "dctl" }, el("span", { textContent: "context" }), ctx, el("small", { textContent: "how much of a conversation it holds · applies on reload" })),
el("label", { className: "dctl" }, el("span", { textContent: "answer length" }), maxT, maxOut, el("small", { textContent: "tokens at most, from the next answer" })),
el("div", { className: "dctl" }, el("span", { textContent: "prompt" }), prompt),
stale ? el("p", { className: "dnote warn", textContent: `The engine runs ${b.threads} thread${b.threads > 1 ? "s" : ""} and context ${b.ctx}: reload it to apply.` }) : null);
// usage, measured
const gen = answers.filter((a) => a.gen_tps).map((a) => a.gen_tps).slice(-30);
const totals = answers.reduce((t, a) => ({ n: t.n + 1, tok: t.tok + (a.predicted_n || 0), ms: t.ms + (a.total_ms || 0) }), { n: 0, tok: 0, ms: 0 });
const usage = card("Usage, measured",
kv(last ? [["last answer", `${last.speaker} via ${{ space: "the free CPU (not bankML)", browse: "browseML", local: "your bankML", hf: "a provider" }[last.mode]} · ${last.ok ? "✓ receipt" : last.mode === "hf" || last.mode === "space" ? "no receipt" : "✗ receipt"}`, last.ok ? "ok" : "warn"],
["first token", fmt(last.ttft_ms / 1000, 1, " s")], ["reading", `${fmt(last.prompt_tps, 1, " tok/s")} · ${last.prompt_n ?? "—"} tokens (${last.cache_n ?? 0} reused)`],
["writing", `${fmt(last.gen_tps, 2, " tok/s")} · ${last.predicted_n ?? "—"} tokens`], ["whole answer", fmt(last.total_ms / 1000, 1, " s")]]
: [["last answer", "none yet — ask in the field below"]]),
el("div", { className: "dspark" }, sparkline(gen), el("small", { textContent: gen.length ? `writing speed, last ${gen.length} answers` : "writing speed appears after an answer" })),
kv([["this visit", `${totals.n} answer${totals.n === 1 ? "" : "s"} · ${totals.tok} tokens written · ${fmt(totals.ms / 1000, 0, " s")} computing`]]));
// memory and storage
const heap = performance.memory ? `${MB(performance.memory.usedJSHeapSize)} of ${MB(performance.memory.jsHeapSizeLimit)}` : "not reported by this browser";
const mem = card("Memory",
kv([["engine (WebAssembly)", loaded ? MB(browseml.memoryBytes) : "—"], ["page (JavaScript heap)", heap],
["this device", navigator.deviceMemory ? `about ${navigator.deviceMemory} GB (rounded by the browser)` : "not reported"],
["context cache", `${MB(S.ctx * KV_BYTES_PER_TOKEN)} at ${S.ctx} tokens (estimate)`],
["browseML needs", `about ${MB(browseml.MODEL.bytes + S.ctx * KV_BYTES_PER_TOKEN + 96e6)}: the model, the context cache and the engine's scratch (estimate)`]]),
lowCap() && S.ctx > lowCap() ? el("p", { className: "dnote warn", textContent: `This device reports about ${navigator.deviceMemory} GB: keep the context at ${lowCap()} or less (now ${S.ctx}), or the browser may close the tab under memory pressure. Or use the free CPU tab.` }) : null);
// disk and cache: what browseML keeps here, persistent storage, remove after this session, clear (confirmed here)
const cacheBytes = kept.filter((f) => f.where === "Cache Storage").reduce((n, f) => n + f.bytes, 0);
const files = kept.length
? el("ul", { className: "dadvice" }, ...kept.map((f) => el("li", { className: "ok", textContent: `${f.name} · ${MB(f.bytes)} · ${f.where}${f.where === "HTTP cache" ? " (the browser manages it)" : ""}` })))
: el("p", { className: "dnote", textContent: "browseML keeps nothing in this browser yet." });
const after = el("label", { className: "drow" }, el("input", { type: "checkbox", checked: !!S.removeAfterSession, onchange: (e) => set({ removeAfterSession: e.target.checked }) }),
" remove the model after this session — it goes when this site next opens in a new tab or window (a reload or a link within this tab keeps it)");
const clearRow = el("div", { className: "drow" });
if (!confirmClear) clearRow.append(el("button", { type: "button", textContent: `Clear browseML's cache${cacheBytes ? ` (frees ${MB(cacheBytes)})` : ""}`, disabled: !cacheBytes, onclick: () => { confirmClear = true; render(); } }));
else clearRow.append(el("span", { className: "dnote warn", textContent: `Remove ${MB(cacheBytes)} from this browser? The next browseML answer downloads the model again.` }),
el("button", { type: "button", className: "primary", textContent: "Yes, clear it", onclick: async () => { const freed = await fetcher.clearCache(); confirmClear = false; await refreshStorage(); alertLine(`cleared: ${MB(freed)} freed`); st.emit(); window.bankmlBrowseCard && window.bankmlBrowseCard.refresh(); } }),
el("button", { type: "button", textContent: "Keep it", onclick: () => { confirmClear = false; render(); } }));
const disk = card("Disk and cache",
kv([["this site's storage", storage && storage.quota ? `${MB(storage.usage)} used of ${(storage.quota / 1e9).toFixed(1)} GB allowed` : "not reported"],
["browseML's cache", cacheBytes ? MB(cacheBytes) : "empty"],
["kept under pressure", persistedNow === null ? "this browser does not say" : persistedNow ? "yes — persistent storage granted" : "no — the browser may evict it when space runs low", persistedNow ? "ok" : "warn"]]),
files,
el("div", { className: "drow" }, el("button", { type: "button", textContent: "Keep it (ask for persistent storage)", disabled: persistedNow !== false,
onclick: async () => { const p = await fetcher.askPersist(); await refreshStorage(); alertLine(p ? "persistent storage granted" : "the browser did not grant persistent storage (it decides; a bookmarked or installed site is more likely to get it)"); render(); } })),
after, clearRow);
const adv = card("What to change", el("ul", { className: "dadvice" }, ...advice(S, st).map(([lvl, t]) => el("li", { className: lvl, textContent: t }))));
const who = card("Who answers", kv(engineRows), engineBtns);
root.replaceChildren(el("h2", { textContent: "This computer — controls and diagnostics" }),
el("p", { className: "dlede", role: "status", textContent: notice || `Everything here is measured in this browser and stays in it. ${cores} logical cores · ${isolated ? "cross-origin isolated: threads on" : "not isolated: one thread"}.` }),
el("p", { className: "dnow", id: "dash-now", textContent: st.activity ? "now: " + st.activity : "now: idle" }),
el("div", { className: "dgrid" }, who, controls, usage, mem, disk, adv));
}
// a notice (what a button just did, such as the bytes a clear freed) is state, not a write into the DOM: render() rebuilds
// every card, so a line written straight into .dlede was replaced by the next render before anyone could read it
let alertTimer = null, notice = null;
function alertLine(t) { notice = t; clearTimeout(alertTimer); alertTimer = setTimeout(() => { notice = null; render(); }, 6000); render(); }
/** T mode's diag: the same measurements, as an accordion. */
window.bankmlDiag = async () => {
await refreshStorage();
const S = window.bankmlSettings.get(), st = window.bankmlStats, b = st.browse, last = st.answers.slice(-1)[0];
const sec = (title, level, lines) => ({ title, level, lines });
const adv = advice(S, st);
return {
title: `diag · this computer · ${new Date().toLocaleTimeString()} · ${adv.some(([l]) => l === "warn") ? "warn" : "ok"}`,
sections: [
sec("processor", isolated ? "ok" : "warn", [`${cores} logical cores`, isolated ? "cross-origin isolated: SharedArrayBuffer, threads available" : "not cross-origin isolated: one thread", `user agent: ${navigator.userAgent}`]),
sec("engine", b ? "ok" : "warn", b ? [`${b.model} verified · sha256 ${b.sha256}`, `bankML ${b.version} · ${b.threads} thread${b.threads > 1 ? "s" : ""} · context ${b.ctx}`, `engine memory ${MB(browseml.memoryBytes)}`] : ["browseML not loaded"]),
sec("memory", "ok", [`device: ${navigator.deviceMemory ? `about ${navigator.deviceMemory} GB` : "not reported"}`, `page heap: ${performance.memory ? MB(performance.memory.usedJSHeapSize) : "not reported"}`, `context cache: ${MB(S.ctx * KV_BYTES_PER_TOKEN)}`]),
sec("storage", storage && storage.quota && storage.usage / storage.quota > 0.8 ? "warn" : "ok", [storage && storage.quota ? `${MB(storage.usage)} of ${(storage.quota / 1e9).toFixed(1)} GB` : "not reported", `model cached: ${storage && storage.model ? MB(storage.model) : "no"}`, `persistent storage: ${persistedNow === null ? "not reported" : persistedNow ? "granted" : "not granted"}`, ...kept.map((f) => `${f.name}: ${MB(f.bytes)} (${f.where})`), `remove after this session: ${S.removeAfterSession ? "on" : "off"}`]),
sec("speed", last ? "ok" : "warn", last ? [`first token ${fmt(last.ttft_ms / 1000, 1, " s")}`, `reading ${fmt(last.prompt_tps, 1, " tok/s")} (${last.prompt_n ?? "—"} tokens, ${last.cache_n ?? 0} reused)`, `writing ${fmt(last.gen_tps, 2, " tok/s")} (${last.predicted_n ?? "—"} tokens)`] : ["no answer yet"]),
sec("what to change", adv.some(([l]) => l === "warn") ? "warn" : "ok", adv.map(([, t]) => t)),
],
};
};
(async () => {
await refreshStorage();
render();
window.addEventListener("bankml:stats", () => { refreshStorage().then(render); });
// the live line, without redrawing the cards: it changes every second while something runs
window.addEventListener("bankml:activity", () => { const n = $("dash-now"); if (n) n.textContent = window.bankmlStats.activity ? "now: " + window.bankmlStats.activity : "now: idle"; });
setInterval(() => { if (!document.hidden) render(); }, 5000); // the engine's memory and the page's heap move on their own
})();
|