bankml / browseml.js
Gregory-L's picture
bankML 0.4.3 pages: Savante's input→response pattern, bankML in this browser by default, one palette, phone to desktop
e14b0de verified
Raw History Blame Contribute Delete
6.83 kB
// SPDX-License-Identifier: MIT OR Apache-2.0
// browseML — bankML in this browser. The model is downloaded once from its Hugging Face repository at a pinned
// revision and kept in this browser's Cache Storage; bankML's own engine, compiled to WebAssembly, verifies it
// against its FORK.json (the guard, then the sha256 pin) before it answers, and puts a receipt on every answer —
// the same receipt `bankml serve` gives. The visitor's CPU does the work: no server, no account, no quota.
export const MODEL = {
name: "Bonsai-1.7B-Q1_0.gguf",
title: "Bonsai-1.7B · 1-bit (Q1_0)",
bytes: 248302272,
url: "https://huggingface.co/prism-ml/Bonsai-1.7B-gguf/resolve/210a9e99f79cb184909d49595906526eb2b3dd9a/Bonsai-1.7B-Q1_0.gguf",
fork: "browseML/Bonsai-1.7B-Q1_0.gguf.FORK.json",
};
const CACHE = "browseml-models";
let worker = null, ready = null, busy = null;
/** The engine's WebAssembly memory after its last open or answer, in bytes (0 when not loaded). */
export let memoryBytes = 0;
/** The most threads the engine takes, however many cores the browser reports. */
export const MAX_THREADS = 8;
/** Threads for the engine: every core this browser reports (up to MAX_THREADS), when the page is cross-origin
* isolated (the Space sends COOP and COEP); otherwise one. Also the most a visitor can choose. */
export const threads = () => (globalThis.crossOriginIsolated && typeof SharedArrayBuffer !== "undefined" ? Math.max(1, Math.min(MAX_THREADS, navigator.hardwareConcurrency || 1)) : 1);
/** The model in this browser's cache, or null. */
async function cached() {
try { const c = await caches.open(CACHE); const r = await c.match(MODEL.url); return r ? await r.arrayBuffer() : null; } catch { return null; }
}
/** Download the model (or take it from the cache), with progress as (bytes, total, phase). */
async function fetchModel(progress) {
const kept = await cached();
if (kept && kept.byteLength === MODEL.bytes) { progress(kept.byteLength, MODEL.bytes, "cache"); return kept; }
const r = await fetch(MODEL.url);
if (!r.ok) throw new Error(`the model could not be downloaded: HTTP ${r.status}`);
const total = +r.headers.get("Content-Length") || MODEL.bytes;
const buf = new Uint8Array(total);
const reader = r.body.getReader();
let got = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (got + value.length > buf.length) throw new Error("the download is longer than the model");
buf.set(value, got); got += value.length;
progress(got, total, "download");
}
if (got !== total) throw new Error(`the download stopped at ${got} of ${total} bytes`);
// kept for the next visit (put finishes reading the buffer before it is handed to the engine); a private window or
// a full disk just means downloading again
try { const c = await caches.open(CACHE); await c.put(MODEL.url, new Response(buf, { headers: { "Content-Type": "application/octet-stream" } })); } catch {}
return buf.buffer;
}
export const isReady = () => !!ready;
export const isBusy = () => !!busy;
/** Stop the engine and free its memory (the model stays in the browser's cache): new settings apply on the next load. */
export function unload() {
if (busy) throw new Error("browseML is writing an answer: wait for it to finish");
if (worker) worker.terminate();
worker = null; ready = null; memoryBytes = 0;
}
/** The model's bytes in this browser's cache, or 0. */
export async function cachedBytes() { try { const r = await (await caches.open(CACHE)).match(MODEL.url); return r ? +(r.headers.get("Content-Length") || MODEL.bytes) : 0; } catch { return 0; } }
export async function isCached() { try { return !!(await (await caches.open(CACHE)).match(MODEL.url)); } catch { return false; } }
export async function forget() { try { await caches.delete(CACHE); } catch {} }
/**
* Load bankML in this browser: the model (downloaded or cached), the engine, and bankML's verification.
* @param {(bytes:number, total:number, phase:string) => void} progress
* @param {(level:number, text:string) => void} log
* @returns {Promise<{model, sha256, guard, engine, arch, version, threads}>} what bankML verified, and its threads
*/
let loading = null;
/** A load in progress (a download, the verification), or null. */
export const isLoading = () => !!loading;
export const whenLoaded = () => loading;
export function load(progress, log, opts = {}) {
if (ready) return Promise.resolve(ready);
if (loading) return loading; // one download at a time: a second caller waits for the first
loading = loadOnce(progress, log, opts).finally(() => { loading = null; });
return loading;
}
async function loadOnce(progress, log, { threads: want, ctx = 4096 } = {}) {
const [bytes, fork] = await Promise.all([fetchModel(progress), fetch(MODEL.fork).then((r) => { if (!r.ok) throw new Error("the model's FORK.json is missing"); return r.text(); })]);
progress(MODEL.bytes, MODEL.bytes, "verify");
worker = new Worker(new URL("./browseml-worker.js", import.meta.url), { type: "module" });
const verified = await new Promise((resolve, reject) => {
worker.onmessage = (e) => {
const m = e.data;
if (m.type === "log") log(m.level, m.text);
else if (m.type === "opened") { memoryBytes = m.memory || 0; m.rc === 0 ? resolve({ ...JSON.parse(m.out), threads: m.threads, ctx, build: m.build }) : reject(new Error(m.out)); }
};
worker.onerror = (e) => reject(new Error(e.message || "the engine could not start"));
worker.postMessage({ type: "open", name: MODEL.name, bytes, fork, threads: Math.max(1, Math.min(threads(), want || threads())), ctx }, [bytes]);
});
ready = verified;
return verified;
}
/**
* One answer: an OpenAI-shaped request (messages, max_tokens, …), the pieces as they are written, and the
* `/v1/chat/completions` object at the end (usage, timings, bankml_receipt).
*/
export function chat(request, onPiece, log) {
if (!ready) return Promise.reject(new Error("browseML is not loaded"));
if (busy) return Promise.reject(new Error("browseML is still writing the last answer"));
busy = new Promise((resolve, reject) => {
worker.onmessage = (e) => {
const m = e.data;
if (m.type === "piece") onPiece(m.text);
else if (m.type === "log") log(m.level, m.text);
else if (m.type === "done") {
busy = null;
memoryBytes = m.memory || memoryBytes;
if (m.rc === -99) { ready = null; worker.terminate(); worker = null; } // the engine trapped: load it again
const j = (() => { try { return JSON.parse(m.out); } catch { return { error: { message: m.out } }; } })();
m.rc === 0 ? resolve(j) : reject(new Error(j.error ? j.error.message : m.out));
}
};
worker.postMessage({ type: "chat", request });
});
return busy;
}