sprout-instruct / docs /worker.js
GeorgeCodesSometimes's picture
Finalize public documentation and link the live browser story workshop
559972f verified
Raw History Blame Contribute Delete
6.52 kB
import { tokenizer, storyPrompt } from "./tokenizer.js";
const RUNTIME = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.30.0/dist/";
// Pin the model commit so a later upload cannot silently change this demo.
const MODEL = "https://huggingface.co/GeorgeCodesSometimes/sprout-instruct/resolve/ecb41dc46edcfb540d13ad2fe3e72bd863aca25a/browser/";
let ort, session, tok, config;
let stopped = false;
let busy = false;
const send = (type, data = {}) => self.postMessage({ type, ...data });
async function response(name) {
const result = await fetch(MODEL + name);
if (!result.ok) throw new Error(`Model download failed (${result.status}). Check your connection and try again.`);
return result;
}
async function load(onlyTokenizer = false) {
if (session) return;
if (!tok) {
send("status", { text: "Downloading model settings…" });
config = await (await response("config.json")).json();
const source = await (await response("tokenizer.model")).text();
const tokenHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source));
const checksum = Array.from(new Uint8Array(tokenHash), b => b.toString(16).padStart(2, "0")).join("");
if (checksum !== config.sha256["tokenizer.model"]) throw new Error("Tokenizer checksum failed. Reload and try again.");
const parsed = tokenizer(source);
if (parsed.vocab.length + 4 !== config.model.vocab ||
JSON.stringify(parsed.special) !== JSON.stringify(config.special_tokens)) {
throw new Error("Model and tokenizer do not match. Reload the page and try again.");
}
tok = parsed;
}
if (onlyTokenizer) return;
let storage;
try { storage = await caches.open("sprout-browser-v1"); } catch { /* Cache is optional. */ }
let file = await storage?.match(MODEL + "model.onnx");
if (!file) {
file = await response("model.onnx");
if (storage) storage.put(MODEL + "model.onnx", file.clone()).catch(() => {});
}
const bytes = new Uint8Array(config.browser_bytes);
const reader = file.body.getReader();
let offset = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (offset + value.length > bytes.length) throw new Error("Model download has an unexpected size. Reload and try again.");
bytes.set(value, offset);
offset += value.length;
send("progress", { value: offset / bytes.length });
}
if (offset !== bytes.length) throw new Error("Model download was interrupted. Reload and try again.");
send("status", { text: "Checking weights and starting the model…" });
const hash = await crypto.subtle.digest("SHA-256", bytes);
const checksum = Array.from(new Uint8Array(hash), b => b.toString(16).padStart(2, "0")).join("");
if (checksum !== config.browser_sha256) {
await storage?.delete(MODEL + "model.onnx");
throw new Error("Model checksum failed. Reload to download a fresh copy.");
}
ort = await import(RUNTIME + "ort.wasm.min.mjs");
// One worker thread also works on hosts without cross-origin isolation.
ort.env.wasm.numThreads = 1;
ort.env.wasm.wasmPaths = RUNTIME;
session = await ort.InferenceSession.create(bytes, { executionProviders: ["wasm"] });
send("loaded");
}
function sample(logits, temperature) {
if (temperature === 0) {
let best = 0;
for (let i = 1; i < logits.length; i++) if (logits[i] > logits[best]) best = i;
return best;
}
const sorted = Array.from(logits, (value, id) => ({ value, id })).sort((a, b) => b.value - a.value);
const cutoff = sorted[49].value;
const candidates = sorted.filter(item => item.value >= cutoff);
const weights = candidates.map(item => Math.exp((item.value - sorted[0].value) / temperature));
let remaining = Math.random() * weights.reduce((a, b) => a + b, 0);
for (let i = 0; i < weights.length; i++) {
remaining -= weights[i];
if (remaining < 0) return candidates[i].id;
}
return candidates.at(-1).id;
}
async function generate({ fields, temperature, maxTokens }) {
// Reject invalid instructions before starting the large download.
const prefix = storyPrompt(fields);
if (!Number.isFinite(temperature) || temperature < 0 || temperature > 2 ||
!Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > 512) {
throw new Error("Choose a valid temperature and story length.");
}
await load(true);
let ids = tok.encode(prefix);
const { n_layer: layers, n_head: heads, dim, ctx } = config.model;
if (ids.length > ctx - 32) throw new Error("Your instructions leave too little room for a story. Shorten them and try again.");
await load();
if (stopped) { send("done", { reason: "Stopped", tokens: 0 }); return; }
const limit = Math.min(maxTokens, ctx - ids.length + 1);
let cache = new ort.Tensor("float32", new Float32Array(0), [layers, 2, 1, heads, 0, dim / heads]);
const decoder = new TextDecoder();
let text = "";
let count = 0;
let reason = "Length limit reached";
const started = performance.now();
send("status", { text: "Writing your story…" });
try {
for (let i = 0; i < limit; i++) {
if (stopped) { reason = "Stopped"; break; }
const tokens = new ort.Tensor("int64", BigInt64Array.from(ids, BigInt), [1, ids.length]);
let output;
try { output = await session.run({ tokens, cache }); }
finally { tokens.dispose(); }
cache.dispose();
cache = output.present;
const next = sample(output.logits.data, temperature);
output.logits.dispose();
count++;
if (next >= tok.vocab.length) {
reason = next === tok.special["<|end|>"] ? "Story finished" : "Model emitted a reserved token";
break;
}
text += decoder.decode(tok.vocab[next], { stream: true });
send("text", { text, tokens: count });
ids = [next];
// Yield between steps so stop messages are processed even during CPU inference.
await new Promise(resolve => setTimeout(resolve, 0));
}
text += decoder.decode();
send("text", { text, tokens: count });
send("done", { reason, tokens: count, seconds: (performance.now() - started) / 1000 });
} finally { cache.dispose(); }
}
self.onmessage = async ({ data }) => {
if (data.type === "stop") { stopped = true; return; }
if (data.type !== "generate" || busy) return;
busy = true;
stopped = false;
try { await generate(data); }
catch (error) {
console.error(error);
send("error", { text: error.message || "The model could not start. Reload and try again." });
} finally { busy = false; }
};