// The engine lives here, not on the page's thread. // // candle in WASM is synchronous and CPU-bound: constructing the model, encoding // a sentence, and sampling a token all occupy their thread outright. Run that // where the UI lives and the tab stops responding, including the progress text // that was supposed to explain the wait. Everything heavy happens in this // worker and the page only ever handles messages. import init, { Engine, Loader } from "./blackwindow_wasm.js"; let engine = null; const post = (type, payload) => self.postMessage({ type, ...payload }); const DB = "blackwindow", STORE = "blobs", CHUNK = 32 * 1024 * 1024; // IndexedDB rather than the Cache API. Cache.put rejects the 380MB model with // an opaque internal error, and storing it as one 380MB value fails the same // way, so it goes in as 32MB chunks under a manifest. function idb() { return new Promise((res, rej) => { const r = indexedDB.open(DB, 1); r.onupgradeneeded = () => r.result.createObjectStore(STORE); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); } function tx(db, mode, fn) { return new Promise((res, rej) => { const t = db.transaction(STORE, mode); const out = fn(t.objectStore(STORE)); // "result" in out, not out.result !== undefined: a miss is a request whose // result is undefined, and the looser test returned the request itself, // which is truthy and made every miss look like a corrupt hit. t.oncomplete = () => res(out && typeof out === "object" && "result" in out ? out.result : out); t.onerror = () => rej(t.error); }); } async function cacheGet(db, key, label, sink) { const meta = await tx(db, "readonly", (s) => s.get(`${key}:meta`)); if (!meta) return null; const out = sink ? null : new Uint8Array(meta.size); let off = 0; for (let i = 0; i < meta.chunks; i++) { const part = await tx(db, "readonly", (s) => s.get(`${key}:${i}`)); if (!part) return null; const u = new Uint8Array(part); if (sink) sink(u); else out.set(u, off); off += u.length; // Reading 382MB back from IndexedDB is as expensive as downloading it, and // reporting nothing here once made a stalled counter look like a ceiling. post("progress", { label, got: off, total: meta.size }); } if (off !== meta.size) return null; return sink ? true : out; } async function cachePut(db, key, bytes) { const chunks = Math.ceil(bytes.length / CHUNK); for (let i = 0; i < chunks; i++) { const slice = bytes.slice(i * CHUNK, (i + 1) * CHUNK); await tx(db, "readwrite", (s) => s.put(slice.buffer, `${key}:${i}`)); } await tx(db, "readwrite", (s) => s.put({ size: bytes.length, chunks }, `${key}:meta`)); } // The cache key is the URL, so bumping a versioned filename strands the old // blob forever. Drop anything the current build no longer asks for. async function pruneCache(keep) { try { const db = await idb(); const keys = await tx(db, "readonly", (s) => s.getAllKeys()); const live = new Set(keep); const stale = keys.filter((k) => !live.has( String(k).replace(/:(meta|\d+)$/, "").replace(/:blob$/, ""))); if (!stale.length) return; await tx(db, "readwrite", (s) => { for (const k of stale) s.delete(k); }); post("log", { line: `cache: dropped ${stale.length} entries from an older build` }); } catch (e) { post("log", { line: `cache prune skipped (${e})` }); } } async function fetchBytes(url, label) { // Cache anything served from an absolute URL. Those are the big immutable // blobs on a CDN, and the URL is the cache key, so a versioned filename // invalidates correctly on rebuild. Relative paths are small local files. const cacheable = /^https?:/.test(url); let db = null; if (cacheable) { try { db = await idb(); const hit = await cacheGet(db, url); if (hit) { post("log", { line: `${label}: from cache (${(hit.length / 1e6).toFixed(0)} MB)` }); return hit; } } catch (e) { post("log", { line: `${label}: cache unavailable (${e})` }); } } const r = await fetch(url); if (!r.ok) throw new Error(`${label}: HTTP ${r.status} for ${url}`); const total = +r.headers.get("content-length") || 0; const reader = r.body.getReader(); // Preallocate when the length is known and fill in place. Accumulating // chunks and concatenating afterwards holds the file twice at once, which // is what puts a 382MB model over an iOS tab's memory limit. let out = total ? new Uint8Array(total) : null; const chunks = out ? null : []; let got = 0, lastPost = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; if (out) { if (got + value.length > total) { throw new Error(`${label}: server sent more than ${total} bytes`); } out.set(value, got); } else { chunks.push(value); } got += value.length; if (got - lastPost > 4e6) { lastPost = got; post("progress", { label, got, total }); } } post("progress", { label, got, total: total || got }); if (!out) { out = new Uint8Array(got); let o = 0; for (const c of chunks) { out.set(c, o); o += c.length; } } else if (got !== total) { throw new Error(`${label}: expected ${total} bytes, got ${got}`); } if (db) { try { await cachePut(db, url, out); post("log", { line: `${label}: cached for next time` }); } catch (e) { post("log", { line: `${label}: not cached (${e})` }); } } return out; } // Stream one blob straight into the loader, caching as it goes. Nothing here // ever holds more than one CHUNK, which is the entire point: the previous // version assembled the whole 382MB file in JS and then handed it to wasm, // which meant three copies existed at once and a phone refused the third. async function stageBlob(url, label, loader) { let db = null; if (/^https?:/.test(url)) { try { db = await idb(); } catch (e) { post("log", { line: `${label}: cache unavailable (${e})` }); } } if (db) { const meta = await tx(db, "readonly", (s) => s.get(`${url}:meta`)); if (meta) { loader.begin(meta.size); const ok = await cacheGet(db, url, label, (u) => loader.push(u)); if (ok) { post("log", { line: `${label}: from cache (${(meta.size / 1e6).toFixed(0)} MB)` }); return; } // A tab killed mid-write leaves chunks without a usable manifest. Drop // them, or they sit there forever and the refetch pays for them twice. post("log", { line: `${label}: cache entry incomplete, refetching` }); try { await tx(db, "readwrite", (s) => { for (let i = 0; i < (meta.chunks || 0); i++) s.delete(`${url}:${i}`); s.delete(`${url}:meta`); }); } catch (e) { /* best effort */ } loader.begin(0); } } const r = await fetch(url); if (!r.ok) throw new Error(`${label}: HTTP ${r.status} for ${url}`); const total = +r.headers.get("content-length") || 0; loader.begin(total); const reader = r.body.getReader(); const buf = new Uint8Array(CHUNK); let used = 0, chunkIdx = 0, got = 0, lastPost = 0; const flush = async () => { if (!used) return; loader.push(buf.subarray(0, used)); if (db) { try { const copy = buf.slice(0, used); await tx(db, "readwrite", (s) => s.put(copy.buffer, `${url}:${chunkIdx}`)); } catch (e) { db = null; post("log", { line: `${label}: not cached (${e})` }); } } chunkIdx++; used = 0; }; for (;;) { const { done, value } = await reader.read(); if (done) break; let off = 0; while (off < value.length) { const n = Math.min(CHUNK - used, value.length - off); buf.set(value.subarray(off, off + n), used); used += n; off += n; got += n; if (used === CHUNK) await flush(); } if (got - lastPost > 4e6) { lastPost = got; post("progress", { label, got, total }); } } await flush(); post("progress", { label, got, total: total || got }); if (db) { try { await tx(db, "readwrite", (s) => s.put({ size: got, chunks: chunkIdx }, `${url}:meta`)); post("log", { line: `${label}: cached for next time` }); } catch (e) { post("log", { line: `${label}: not cached (${e})` }); } } } // The model is kept as a Blob, never as bytes. // // Everything else is small enough to stage inside wasm, but the GGUF is not: // candle copies it into tensors, so a staged copy means the file exists twice, // and wasm memory never shrinks. Blobs are backed by the browser's disk store, // and FileReaderSync gives a worker synchronous random access to one, which is // exactly what a Rust Read + Seek needs. async function modelBlob(url, label) { let db = null; try { db = await idb(); } catch (e) { post("log", { line: `${label}: cache unavailable (${e})` }); } const key = `${url}:blob`; if (db) { const hit = await tx(db, "readonly", (s) => s.get(key)); if (hit && hit.size) { post("log", { line: `${label}: from cache (${(hit.size / 1e6).toFixed(0)} MB)` }); post("progress", { label, got: hit.size, total: hit.size }); return hit; } } // response.blob() lets the browser stream straight into its own store. Doing // this by hand means the chunks pass through the JS heap first, which is the // copy we are here to avoid. The cost is a progress bar that only moves at // the end. post("log", { line: `${label}: downloading` }); post("progress", { label, got: 0, total: 0 }); const r = await fetch(url); if (!r.ok) throw new Error(`${label}: HTTP ${r.status} for ${url}`); const blob = await r.blob(); post("progress", { label, got: blob.size, total: blob.size }); if (db) { try { await tx(db, "readwrite", (s) => s.put(blob, key)); post("log", { line: `${label}: cached for next time` }); } catch (e) { post("log", { line: `${label}: not cached (${e})` }); } } return blob; } async function load({ gguf, tokenizer, head, index, anchor, tapLayer, meanPool, stream }) { await init(); post("log", { line: "fetching model, tokenizer, head, gallery" }); await pruneCache([gguf, tokenizer, head, index, anchor].filter((u) => /^https?:/.test(u))); // Model first: it is the largest, so its peak must not overlap the gallery's. const loader = new Loader(); await stageBlob(tokenizer, "tokenizer", loader); loader.finish_tokenizer(); // Streaming is the default: it keeps the GGUF out of linear memory, which is // what lets an iPhone build the model at all. Staging is the fallback, // because a browser that cannot hand back a 382MB Blob should still work. let built = false; if (stream !== false) { try { const blob = await modelBlob(gguf, "model"); post("log", { line: `building model (streaming ${(blob.size / 1e6).toFixed(0)} MB)` }); const fr = new FileReaderSync(); loader.finish_model_streaming( (off, len) => new Uint8Array(fr.readAsArrayBuffer(blob.slice(off, off + len))), blob.size, ); built = true; } catch (e) { post("log", { line: `streaming load failed (${e.message || e}), staging instead` }); } } if (!built) { await stageBlob(gguf, "model", loader); post("log", { line: "building model" }); loader.finish_model(); } await stageBlob(head, "head", loader); loader.finish_head(); await stageBlob(index, "gallery", loader); loader.finish_index(); post("log", { line: "building engine" }); engine = loader.build(tapLayer, !!meanPool); const a = await fetchBytes(anchor, "anchor"); const bytes = engine.apply_anchor(a); post("log", { line: `runtime anchor applied: ${(bytes / 1024).toFixed(1)} KB` }); post("ready", { gallery: engine.gallery_size() }); } self.onmessage = async (ev) => { const m = ev.data; try { switch (m.type) { case "load": await load(m); break; case "verifyAnchor": { // Confirms the shipped constant actually describes this browser. const cos = engine.anchor_drift(m.probes); post("anchorDrift", { cos }); break; } case "chat": { const answer = engine.chat(m.text, m.maxTokens, m.temperature, m.k, m.seed, (piece, hits) => { if (hits) post("hits", { hits }); if (piece) post("token", { piece }); return true; }); post("answer", { answer }); break; } case "search": post("hits", { hits: engine.search(m.text, m.k), tag: m.tag }); break; case "buildAxis": { engine.build_axis(m.name, m.positive, m.negative); const control = engine.add_random_control(m.name, 7); post("axisReady", { name: m.name, control }); break; } case "steer": { const hits = engine.search_steered(m.text, m.axis, m.alpha, m.k); const retention = engine.retention(m.axis, m.alpha, [m.text]); post("steered", { hits, retention, label: m.label }); break; } case "recalibrateHere": { const bytes = engine.recalibrate(m.anchors); post("log", { line: `re-derived anchor here: ${(bytes / 1024).toFixed(1)} KB` }); post("ready", { gallery: engine.gallery_size() }); break; } } } catch (e) { post("error", { message: String(e && e.message ? e.message : e) }); } };