Spaces:
Running
Running
Add model picker: paper's §6.5 token-LM baseline, live-comparable against the thinker
0b29713 verified | // Browser bootstrap: download models with progress, build sessions, wire UI. | |
| // The chat loops themselves are chat.js, parity-tested against torch. | |
| import * as ort from "./lib/ort.wasm.min.mjs"; | |
| import { SentencePieceProcessor } from "./lib/sentencepiece.js"; | |
| import { OnnxChat, OnnxLmChat, PAD_ID, BOS_ID, EOS_ID } from "./chat.js"; | |
| const TV_FILES = { | |
| "models/encoder.fp16.onnx": 36599048, | |
| "models/thinker.onnx": 591535, | |
| "models/thinker.onnx.data": 57344000, | |
| "models/decoder.fp16.onnx": 40770427, | |
| }; | |
| const LM_FILES = { "models/lm.fp16.onnx": 100772166 }; | |
| const TOK_FILE = { "models/spm16k_bpe.model": 503814 }; | |
| const LM_MAX_LEN = 384; | |
| const LM_MAX_NEW = 64; | |
| const THINKER = "thinker"; | |
| const LM = "lm"; | |
| const LABEL = { [THINKER]: "thinker", [LM]: "token-LM baseline" }; | |
| const $ = (id) => document.getElementById(id); | |
| const messagesEl = $("messages"); | |
| const statusEl = $("status"); | |
| const barEl = $("bar"); | |
| const formEl = $("form"); | |
| const inputEl = $("input"); | |
| const sendEl = $("send"); | |
| const resetEl = $("reset"); | |
| const modelRadios = document.querySelectorAll('input[name="model"]'); | |
| function addBubble(role, text, tag) { | |
| const div = document.createElement("div"); | |
| div.className = `msg ${role}`; | |
| if (tag) { | |
| const t = document.createElement("span"); | |
| t.className = "tag"; | |
| t.textContent = tag; | |
| div.appendChild(t); | |
| } | |
| const body = document.createElement("span"); | |
| body.textContent = text; | |
| div.appendChild(body); | |
| messagesEl.appendChild(div); | |
| messagesEl.scrollTop = messagesEl.scrollHeight; | |
| return body; | |
| } | |
| async function fetchProgress(path, onBytes) { | |
| const res = await fetch(path); | |
| if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`); | |
| const reader = res.body.getReader(); | |
| const chunks = []; | |
| let size = 0; | |
| for (;;) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| chunks.push(value); | |
| size += value.length; | |
| onBytes(value.length); | |
| } | |
| const buf = new Uint8Array(size); | |
| let off = 0; | |
| for (const c of chunks) { buf.set(c, off); off += c.length; } | |
| return buf; | |
| } | |
| async function downloadWithBar(files, label) { | |
| const total = Object.values(files).reduce((a, b) => a + b, 0); | |
| let got = 0; | |
| barEl.parentElement.style.display = ""; | |
| const onBytes = (n) => { | |
| got += n; | |
| const pct = Math.min(100, (100 * got) / total); | |
| barEl.style.width = `${pct}%`; | |
| statusEl.textContent = | |
| `downloading ${label} — ${(got / 1e6).toFixed(0)} / ${(total / 1e6).toFixed(0)} MB`; | |
| }; | |
| const names = Object.keys(files); | |
| const bufs = Object.fromEntries( | |
| (await Promise.all(names.map((p) => fetchProgress(p, onBytes)))) | |
| .map((b, i) => [names[i], b])); | |
| barEl.parentElement.style.display = "none"; | |
| return bufs; | |
| } | |
| function makeTok(sppModelBytes) { | |
| const spp = new SentencePieceProcessor(); | |
| let b64 = ""; | |
| for (let i = 0; i < sppModelBytes.length; i += 0x8000) { | |
| b64 += String.fromCharCode(...sppModelBytes.subarray(i, i + 0x8000)); | |
| } | |
| const ready = spp.loadFromB64StringModel(btoa(b64)); | |
| return { | |
| ready, | |
| encode: (t) => Array.from(spp.encodeIds(t)), | |
| decode: (ids) => spp.decodeIds(new Int32Array( | |
| ids.filter((i) => i !== PAD_ID && i !== BOS_ID && i !== EOS_ID))), | |
| }; | |
| } | |
| ort.env.wasm.wasmPaths = new URL("lib/", document.baseURI).href; | |
| ort.env.wasm.numThreads = self.crossOriginIsolated | |
| ? Math.min(4, navigator.hardwareConcurrency || 1) : 1; | |
| const chats = {}; // { thinker: OnnxChat, lm: OnnxLmChat }, built lazily | |
| let tok = null; | |
| async function ensureTokenizer() { | |
| if (tok) return tok; | |
| const bufs = await downloadWithBar(TOK_FILE, "tokenizer"); | |
| tok = makeTok(bufs["models/spm16k_bpe.model"]); | |
| await tok.ready; | |
| return tok; | |
| } | |
| async function ensureModel(name) { | |
| if (chats[name]) return chats[name]; | |
| await ensureTokenizer(); | |
| statusEl.textContent = `compiling ${LABEL[name]}…`; | |
| if (name === THINKER) { | |
| const bufs = await downloadWithBar(TV_FILES, "thinker model (~135 MB)"); | |
| const sessions = { | |
| encoder: await ort.InferenceSession.create(bufs["models/encoder.fp16.onnx"]), | |
| thinker: await ort.InferenceSession.create(bufs["models/thinker.onnx"], { | |
| externalData: [{ path: "thinker.onnx.data", | |
| data: bufs["models/thinker.onnx.data"] }], | |
| }), | |
| decoder: await ort.InferenceSession.create(bufs["models/decoder.fp16.onnx"]), | |
| }; | |
| chats[THINKER] = new OnnxChat(ort, sessions, tok); | |
| } else { | |
| const bufs = await downloadWithBar(LM_FILES, "token-LM baseline (~100 MB)"); | |
| const session = { lm: await ort.InferenceSession.create(bufs["models/lm.fp16.onnx"]) }; | |
| chats[LM] = new OnnxLmChat(ort, session, tok, LM_MAX_LEN, LM_MAX_NEW); | |
| } | |
| return chats[name]; | |
| } | |
| // The canonical conversation, shared across backends so switching models | |
| // mid-conversation compares them on the same transcript (each backend's | |
| // internal history is just reset to this before every reply). | |
| let transcript = []; | |
| function selectedModel() { | |
| for (const r of modelRadios) if (r.checked) return r.value; | |
| return THINKER; | |
| } | |
| let busy = false; | |
| async function boot() { | |
| await ensureModel(THINKER); | |
| statusEl.textContent = "ready — everything runs in your browser"; | |
| inputEl.disabled = false; | |
| sendEl.disabled = false; | |
| inputEl.focus(); | |
| } | |
| const bootDone = boot().catch((e) => { | |
| statusEl.textContent = `failed to load: ${e.message}`; | |
| throw e; | |
| }); | |
| formEl.addEventListener("submit", async (ev) => { | |
| ev.preventDefault(); | |
| const text = inputEl.value.trim(); | |
| if (!text || busy) return; | |
| await bootDone; | |
| busy = true; | |
| inputEl.value = ""; | |
| sendEl.disabled = true; | |
| addBubble("user", text); | |
| const modelName = selectedModel(); | |
| let chat; | |
| try { | |
| chat = await ensureModel(modelName); | |
| } catch (e) { | |
| statusEl.textContent = `failed to load ${LABEL[modelName]}: ${e.message}`; | |
| busy = false; | |
| sendEl.disabled = false; | |
| return; | |
| } | |
| statusEl.textContent = "ready — everything runs in your browser"; | |
| const bubble = addBubble("bot", "…", LABEL[modelName]); | |
| try { | |
| chat.history = transcript.slice(); | |
| const reply = await chat.reply(text, (partial) => { | |
| bubble.textContent = partial || "…"; | |
| messagesEl.scrollTop = messagesEl.scrollHeight; | |
| }); | |
| transcript.push(text, reply); | |
| } catch (e) { | |
| bubble.textContent = `error: ${e.message}`; | |
| } | |
| busy = false; | |
| sendEl.disabled = false; | |
| inputEl.focus(); | |
| }); | |
| resetEl.addEventListener("click", () => { | |
| if (busy) return; | |
| transcript = []; | |
| messagesEl.textContent = ""; | |
| addBubble("note", "conversation cleared"); | |
| }); | |