nochinator's picture
Add model picker: paper's §6.5 token-LM baseline, live-comparable against the thinker
0b29713 verified
Raw
History Blame Contribute Delete
6.54 kB
// 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");
});