mrfakename commited on
Commit
2bce084
·
verified ·
1 Parent(s): 39f5f3f

Initial WebGPU demo

Browse files
Files changed (11) hide show
  1. .gitattributes +2 -0
  2. README.md +16 -5
  3. app.js +300 -0
  4. examples/cats.jpg +3 -0
  5. examples/voice.flac +3 -0
  6. index.html +83 -17
  7. prompt.js +163 -0
  8. style.css +125 -19
  9. vision.js +116 -0
  10. worker-3b.js +69 -0
  11. worker-omni.js +162 -0
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/cats.jpg filter=lfs diff=lfs merge=lfs -text
37
+ examples/voice.flac filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,10 +1,21 @@
1
  ---
2
- title: Open D1 Webgpu
3
- emoji: 🏢
4
- colorFrom: red
5
- colorTo: yellow
6
  sdk: static
7
  pinned: false
 
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: Open d1 WebGPU
3
+ emoji: ⚖️
4
+ colorFrom: yellow
5
+ colorTo: red
6
  sdk: static
7
  pinned: false
8
+ license: other
9
+ short_description: Liquid AI d1 decision models running in the browser
10
+ models:
11
+ - LiquidAI/d1-omni-600M
12
+ - LiquidAI/d1-3B
13
+ - onnx-community/d1-omni-600M-ONNX
14
+ - onnx-community/d1-3B-ONNX
15
+ custom_headers:
16
+ cross-origin-embedder-policy: require-corp
17
+ cross-origin-opener-policy: same-origin
18
+ cross-origin-resource-policy: cross-origin
19
  ---
20
 
21
+ Liquid AI's d1 decision models (d1-omni-600M and d1-3B) running locally in the browser on WebGPU, with a WASM fallback.
app.js ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const $ = (id) => document.getElementById(id);
2
+ const TYPES = ["choice", "noul", "score"];
3
+
4
+ const SUPPORT = [
5
+ ["refund", "noul", "Is the customer asking for a refund?", ""],
6
+ ["team", "choice", "Which team should handle this?", "billing: Charges, refunds, invoices | technical: App or site faults | fraud: Suspected unauthorised use"],
7
+ ["urgency", "score", "How urgent is this?", "Can wait | Today | Blocking the customer now"],
8
+ ];
9
+ const CATS = [
10
+ ["cats", "choice", "How many cats are there?", "one: One | two: Two | more: Three or more"],
11
+ ["remote", "noul", "Is there a remote control in the picture?", ""],
12
+ ["mood", "choice", "What are the cats doing?", "sleeping: Lying down, resting or asleep | playing: Playing or fighting | eating: Eating or drinking"],
13
+ ];
14
+ const VOICE = [
15
+ ["topic", "choice", "What is the speaker talking about?", "food: Food and meals | travel: Travel and transport | weather: The weather"],
16
+ ["question", "noul", "Is the speaker asking a question?", ""],
17
+ ];
18
+ const MODERATION = [
19
+ ["toxic", "noul", "Is this comment insulting or abusive?", ""],
20
+ ["action", "choice", "What should a moderator do?", "keep: Leave it up | warn: Leave it, warn the author | remove: Take it down"],
21
+ ];
22
+ const JUDGE = [
23
+ ["correct", "noul", "Does the answer correctly answer the question?", ""],
24
+ ["quality", "score", "Rate the answer's helpfulness.", "Useless | Poor | Okay | Good | Excellent"],
25
+ ];
26
+ const EXAMPLES = [
27
+ { label: "Support ticket", model: "omni", state: "I was charged twice this month, please refund one of them.", q: SUPPORT },
28
+ { label: "Two cats", model: "omni", state: "", image: "examples/cats.jpg", q: CATS },
29
+ { label: "Voice note", model: "omni", state: "Voice note from a user.", audio: "examples/voice.flac", q: VOICE },
30
+ { label: "Moderation", model: "omni", state: "You are honestly the dumbest person on this forum, log off.", q: MODERATION },
31
+ { label: "LLM judge", model: "3b", state: '{"question": "What is the boiling point of water at sea level in Fahrenheit?", "answer": "Water boils at 212 °F at sea level."}', q: JUDGE },
32
+ { label: "Support ticket (3B)", model: "3b", state: "I was charged twice this month, please refund one of them.", q: SUPPORT },
33
+ ];
34
+
35
+ // ------------------------------------------------------------------ state
36
+
37
+ let model = "omni";
38
+ let imageBlob = null, audioSamples = null, audioBlobUrl = null;
39
+ const workers = {}, ready = {}, devices = {};
40
+ let nextId = 1;
41
+ const pending = new Map();
42
+
43
+ // ------------------------------------------------------------------ questions editor
44
+
45
+ function addRow([name, type, text, opts] = ["", "choice", "", ""]) {
46
+ const row = document.createElement("div");
47
+ row.className = "qrow";
48
+ row.innerHTML = `
49
+ <input type="text" class="qname" placeholder="name" aria-label="name" />
50
+ <select class="qtype" aria-label="type">${TYPES.map((t) => `<option>${t}</option>`).join("")}</select>
51
+ <input type="text" class="qtext" placeholder="question" aria-label="question" />
52
+ <input type="text" class="qopts" placeholder="options" aria-label="options" />
53
+ <button class="del" type="button" aria-label="Remove question">×</button>`;
54
+ row.querySelector(".qname").value = name;
55
+ row.querySelector(".qtype").value = type;
56
+ row.querySelector(".qtext").value = text;
57
+ row.querySelector(".qopts").value = opts;
58
+ row.querySelector(".del").onclick = () => row.remove();
59
+ $("questions").appendChild(row);
60
+ }
61
+ function setRows(rows) { $("questions").innerHTML = ""; rows.forEach(addRow); }
62
+
63
+ function readQuestions() {
64
+ const out = [], seen = new Set();
65
+ [...$("questions").children].forEach((row, i) => {
66
+ let name = row.querySelector(".qname").value.trim();
67
+ const type = row.querySelector(".qtype").value;
68
+ const text = row.querySelector(".qtext").value.trim();
69
+ const parts = row.querySelector(".qopts").value.split("|").map((s) => s.trim()).filter(Boolean);
70
+ if (!name && !text) return;
71
+ name = name || `q${i + 1}`;
72
+ if (!text) throw new Error(`Question '${name}' has no question text.`);
73
+ if (seen.has(name)) throw new Error(`Two questions are named '${name}'.`);
74
+ seen.add(name);
75
+ const q = { name, type, instructions: text };
76
+ if (type === "choice") {
77
+ q.criteria = {};
78
+ for (const p of parts) {
79
+ const at = p.indexOf(":");
80
+ const key = (at < 0 ? p : p.slice(0, at)).trim();
81
+ const desc = at < 0 ? "" : p.slice(at + 1).trim();
82
+ q.criteria[key] = desc || key;
83
+ }
84
+ if (Object.keys(q.criteria).length < 2) throw new Error(`Question '${name}': a choice needs at least two options, e.g. a: ... | b: ...`);
85
+ } else if (type === "score") {
86
+ if (parts.length < 2 || parts.length > 10) throw new Error(`Question '${name}': a score needs 2 to 10 levels, lowest first, split by |.`);
87
+ q.criteria = parts;
88
+ } else if (parts.length === 2) {
89
+ q.criteria = { true: parts[0], false: parts[1] };
90
+ } else q.criteria = null;
91
+ out.push(q);
92
+ });
93
+ if (!out.length) throw new Error("Add at least one question.");
94
+ return out;
95
+ }
96
+
97
+ function parseState(text) {
98
+ text = text.trim();
99
+ if (!text) return null;
100
+ if (text[0] === "{" || text[0] === "[") { try { return JSON.parse(text); } catch {} }
101
+ return text;
102
+ }
103
+
104
+ // ------------------------------------------------------------------ media
105
+
106
+ function setImage(blob) {
107
+ imageBlob = blob;
108
+ const img = $("imagePreview");
109
+ if (blob) { img.src = URL.createObjectURL(blob); img.hidden = false; $("imageHint").hidden = true; $("imageClear").hidden = false; }
110
+ else { img.hidden = true; img.removeAttribute("src"); $("imageHint").hidden = false; $("imageClear").hidden = true; }
111
+ }
112
+
113
+ async function decodeAudio(blob) {
114
+ const ctx = new AudioContext({ sampleRate: 16000 });
115
+ try {
116
+ const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
117
+ const n = buf.length, mono = new Float32Array(n);
118
+ for (let c = 0; c < buf.numberOfChannels; c++) {
119
+ const ch = buf.getChannelData(c);
120
+ for (let i = 0; i < n; i++) mono[i] += ch[i] / buf.numberOfChannels;
121
+ }
122
+ return mono;
123
+ } finally { ctx.close(); }
124
+ }
125
+
126
+ async function setAudio(blob) {
127
+ if (audioBlobUrl) URL.revokeObjectURL(audioBlobUrl);
128
+ audioBlobUrl = null; audioSamples = null;
129
+ const el = $("audioPreview");
130
+ if (!blob) { el.hidden = true; el.removeAttribute("src"); $("audioHint").hidden = false; $("audioClear").hidden = true; return; }
131
+ audioSamples = await decodeAudio(blob);
132
+ audioBlobUrl = URL.createObjectURL(blob);
133
+ el.src = audioBlobUrl; el.hidden = false; $("audioHint").hidden = true; $("audioClear").hidden = false;
134
+ }
135
+
136
+ function wireDrop(drop, input, onFile) {
137
+ drop.addEventListener("click", (e) => { if (e.target === drop || e.target.classList.contains("hint")) input.click(); });
138
+ drop.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); input.click(); } });
139
+ input.addEventListener("change", () => input.files[0] && onFile(input.files[0]));
140
+ drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); });
141
+ drop.addEventListener("dragleave", () => drop.classList.remove("over"));
142
+ drop.addEventListener("drop", (e) => { e.preventDefault(); drop.classList.remove("over"); const f = e.dataTransfer.files[0]; if (f) onFile(f); });
143
+ }
144
+
145
+ let recorder = null;
146
+ async function toggleRecord() {
147
+ const btn = $("recordBtn");
148
+ if (recorder) { recorder.stop(); return; }
149
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
150
+ const chunks = [];
151
+ recorder = new MediaRecorder(stream);
152
+ recorder.ondataavailable = (e) => chunks.push(e.data);
153
+ recorder.onstop = async () => {
154
+ stream.getTracks().forEach((t) => t.stop());
155
+ recorder = null; btn.textContent = "Record"; btn.classList.remove("rec");
156
+ await setAudio(new Blob(chunks, { type: chunks[0]?.type || "audio/webm" }));
157
+ };
158
+ recorder.start();
159
+ btn.textContent = "Stop"; btn.classList.add("rec");
160
+ setTimeout(() => recorder && recorder.stop(), 30000);
161
+ }
162
+
163
+ // ------------------------------------------------------------------ workers
164
+
165
+ function status(text, err = false) { const s = $("status"); s.textContent = text; s.classList.toggle("err", err); }
166
+ function bar(frac) {
167
+ const b = $("bar");
168
+ if (frac === null) { b.hidden = true; return; }
169
+ b.hidden = false; b.firstElementChild.style.width = `${Math.round(frac * 100)}%`;
170
+ }
171
+ const gb = (n) => (n / 1e9).toFixed(2) + " GB";
172
+
173
+ function getWorker(which) {
174
+ if (workers[which]) return ready[which];
175
+ // One model on the GPU at a time.
176
+ for (const k of Object.keys(workers)) if (k !== which) { workers[k].terminate(); delete workers[k]; delete ready[k]; }
177
+ const w = new Worker(which === "3b" ? "worker-3b.js" : "worker-omni.js", { type: "module" });
178
+ workers[which] = w;
179
+ const fileProgress = {};
180
+ ready[which] = new Promise((resolve, reject) => {
181
+ w.onmessage = ({ data }) => {
182
+ if (data.type === "progress") {
183
+ let loaded = data.loaded, total = data.total;
184
+ if (!data.aggregate) {
185
+ fileProgress[data.file] = [data.loaded, data.total];
186
+ loaded = 0; total = 0;
187
+ for (const [l, t] of Object.values(fileProgress)) { loaded += l; total += t; }
188
+ }
189
+ status(`Downloading ${which === "3b" ? "d1-3B" : "d1-omni-600M"} (${data.label}) · ${gb(loaded)}${total ? " / " + gb(total) : ""}`);
190
+ bar(total ? loaded / total : 0);
191
+ } else if (data.type === "ready") {
192
+ devices[which] = data.device; bar(null); resolve(w);
193
+ } else if (data.type === "result" || data.type === "error") {
194
+ const p = pending.get(data.id);
195
+ if (!p) { if (data.type === "error") reject(new Error(data.message)); return; }
196
+ pending.delete(data.id);
197
+ data.type === "result" ? p.resolve(data) : p.reject(new Error(data.message));
198
+ }
199
+ };
200
+ w.onerror = (e) => reject(new Error(e.message || "worker failed to start"));
201
+ });
202
+ status(`Loading ${which === "3b" ? "d1-3B" : "d1-omni-600M"}…`);
203
+ w.postMessage({ type: "init" });
204
+ return ready[which];
205
+ }
206
+
207
+ async function run(which, payload) {
208
+ const w = await getWorker(which);
209
+ const id = nextId++;
210
+ return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); w.postMessage({ type: "decide", id, ...payload }); });
211
+ }
212
+
213
+ // ------------------------------------------------------------------ results
214
+
215
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
216
+
217
+ function renderResult(questions, res) {
218
+ const cards = questions.map((q) => {
219
+ const a = res.answers[q.name];
220
+ let head, sub, rows, top;
221
+ if (a.type === "noul") {
222
+ head = a.noul >= 0.5 ? "yes" : "no"; top = head;
223
+ sub = `${(100 * Math.max(a.noul, 1 - a.noul)).toFixed(1)}% confident`;
224
+ rows = [["yes", a.noul], ["no", 1 - a.noul]];
225
+ } else if (a.type === "choice") {
226
+ head = a.choice; top = head; sub = `${(100 * a.confidence).toFixed(1)}% confident`;
227
+ rows = Object.entries(a.probabilities);
228
+ } else {
229
+ head = a.score.toFixed(2); sub = "expected level";
230
+ const best = Object.keys(a.probabilities).reduce((x, y) => (a.probabilities[y] > a.probabilities[x] ? y : x));
231
+ rows = Object.entries(a.probabilities).map(([k, v]) => [`${k} · ${a.legend[k]}`, v]);
232
+ top = `${best} · ${a.legend[best]}`;
233
+ }
234
+ const bars = rows.map(([lab, v]) => `<div class="row${lab === top ? " top" : ""}"><span class="lab">${esc(lab)}</span><span class="track"><span class="fill" style="width:${(100 * v).toFixed(1)}%"></span></span><span class="pct">${(100 * v).toFixed(1)}%</span></div>`).join("");
235
+ return `<div class="card"><div class="head"><span class="name">${esc(q.name)}</span><span class="q">${esc(q.instructions)}</span></div><div class="ans">${esc(head)}<span class="sub">${sub}</span></div>${bars}</div>`;
236
+ }).join("");
237
+ $("result").className = "";
238
+ $("result").innerHTML = `<div class="cards">${cards}</div><div class="took">took ${(res.ms / 1000).toFixed(2)} s</div>`;
239
+ }
240
+
241
+ // ------------------------------------------------------------------ actions
242
+
243
+ function setModel(m) {
244
+ model = m;
245
+ document.querySelectorAll("#model button").forEach((b) => b.setAttribute("aria-checked", String(b.dataset.model === m)));
246
+ const omni = m === "omni";
247
+ $("audioDrop").hidden = !omni;
248
+ document.querySelector(".media").classList.toggle("single", !omni);
249
+ if (!omni) setAudio(null);
250
+ }
251
+
252
+ async function decide() {
253
+ const btn = $("run");
254
+ let questions;
255
+ try { questions = readQuestions(); } catch (e) { status(e.message, true); return; }
256
+ const state = parseState($("state").value);
257
+ const audio = model === "omni" ? audioSamples : null;
258
+ if (imageBlob && audio) { status("d1-omni takes an image or a voice clip in one request, not both. Remove one.", true); return; }
259
+ if (state === null && !imageBlob && !audio) { status("Give the model a state, an image or a voice clip.", true); return; }
260
+ btn.disabled = true;
261
+ try {
262
+ const res = await run(model, { state, questions, image: imageBlob, audio });
263
+ renderResult(questions, res);
264
+ status(`Ran on ${devices[model] === "webgpu" ? "WebGPU" : "WASM"}.`);
265
+ return res;
266
+ } catch (e) {
267
+ status(e.message, true); bar(null);
268
+ } finally { btn.disabled = false; }
269
+ }
270
+
271
+ async function loadExample(ex) {
272
+ setModel(ex.model);
273
+ $("state").value = ex.state;
274
+ setRows(ex.q);
275
+ setImage(ex.image ? await (await fetch(ex.image)).blob() : null);
276
+ await setAudio(ex.audio ? await (await fetch(ex.audio)).blob() : null);
277
+ }
278
+
279
+ // ------------------------------------------------------------------ boot
280
+
281
+ document.querySelectorAll("#model button").forEach((b) => (b.onclick = () => setModel(b.dataset.model)));
282
+ wireDrop($("imageDrop"), $("imageFile"), (f) => setImage(f));
283
+ wireDrop($("audioDrop"), $("audioFile"), (f) => setAudio(f).catch((e) => status(`Could not read that clip: ${e.message}`, true)));
284
+ $("imageClear").onclick = (e) => { e.stopPropagation(); setImage(null); };
285
+ $("audioClear").onclick = (e) => { e.stopPropagation(); setAudio(null); };
286
+ $("recordBtn").onclick = (e) => { e.stopPropagation(); toggleRecord().catch((err) => status(err.message, true)); };
287
+ $("addQ").onclick = () => addRow();
288
+ $("run").onclick = decide;
289
+ EXAMPLES.forEach((ex) => {
290
+ const b = document.createElement("button");
291
+ b.type = "button"; b.textContent = ex.label; b.onclick = () => loadExample(ex);
292
+ $("examples").appendChild(b);
293
+ });
294
+ $("state").value = EXAMPLES[0].state;
295
+ setRows(SUPPORT);
296
+ setModel("omni");
297
+ if (!navigator.gpu) status("WebGPU is not available here, so the models run on WASM (slower).");
298
+
299
+ // Test hook: load an example and run it, resolving with the raw answers.
300
+ window.d1 = { examples: EXAMPLES, loadExample, decide, setModel };
examples/cats.jpg ADDED

Git LFS Details

  • SHA256: dea9e7ef97386345f7cff32f9055da4982da5471c48d575146c796ab4563b04e
  • Pointer size: 131 Bytes
  • Size of remote file: 173 kB
examples/voice.flac ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:30885601173f96b0d8ddd020dc959b055c6c1582b85a33e3fcab8c4b08ed94c2
3
+ size 183318
index.html CHANGED
@@ -1,19 +1,85 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Open d1 WebGPU</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="style.css" />
11
+ </head>
12
+ <body>
13
+ <main>
14
+ <header>
15
+ <h1>Open d1 <span class="tag">in your browser</span></h1>
16
+ <p class="lede">Liquid AI's decision models: a state and some typed questions in, calibrated answers out of one forward pass. Runs locally on WebGPU, with a WASM fallback.</p>
17
+ <p class="links">
18
+ <a href="https://huggingface.co/LiquidAI/d1-omni-600M" target="_blank" rel="noopener">d1-omni-600M</a> (text + image or voice) ·
19
+ <a href="https://huggingface.co/LiquidAI/d1-3B" target="_blank" rel="noopener">d1-3B</a> (text + image) ·
20
+ ONNX by <a href="https://huggingface.co/onnx-community" target="_blank" rel="noopener">onnx-community</a>
21
+ </p>
22
+ </header>
23
+
24
+ <div class="grid">
25
+ <section class="panel" id="inputs">
26
+ <div class="field">
27
+ <span class="label">Model</span>
28
+ <div class="seg" role="radiogroup" id="model">
29
+ <button role="radio" data-model="omni" aria-checked="true">d1-omni-600M <small>0.4 GB</small></button>
30
+ <button role="radio" data-model="3b" aria-checked="false">d1-3B <small>2.4 GB</small></button>
31
+ </div>
32
+ </div>
33
+
34
+ <label class="field">
35
+ <span class="label">State</span>
36
+ <textarea id="state" rows="4" placeholder="Text or JSON the questions are about. Can be empty when an image or clip is the whole state."></textarea>
37
+ </label>
38
+
39
+ <div class="media">
40
+ <div class="drop" id="imageDrop" tabindex="0">
41
+ <input type="file" id="imageFile" accept="image/*" hidden />
42
+ <img id="imagePreview" alt="" hidden />
43
+ <span class="hint" id="imageHint">Image · drop or click</span>
44
+ <button class="clear" id="imageClear" hidden aria-label="Remove image">×</button>
45
+ </div>
46
+ <div class="drop" id="audioDrop" tabindex="0">
47
+ <input type="file" id="audioFile" accept="audio/*" hidden />
48
+ <span class="hint" id="audioHint">Voice clip · drop or click</span>
49
+ <audio id="audioPreview" controls hidden></audio>
50
+ <div class="audio-actions">
51
+ <button class="small" id="recordBtn" type="button">Record</button>
52
+ <button class="clear" id="audioClear" hidden aria-label="Remove clip">×</button>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+ <div class="field">
58
+ <span class="label">Questions</span>
59
+ <div id="questions"></div>
60
+ <div class="qfoot">
61
+ <button class="small" id="addQ" type="button">Add question</button>
62
+ <span class="help"><b>choice</b>: <code>name: description | name: description</code> · <b>score</b>: levels lowest first, split by <code>|</code> · <b>noul</b> (yes/no): optional <code>yes means | no means</code></span>
63
+ </div>
64
+ </div>
65
+
66
+ <button class="primary" id="run" type="button">Decide</button>
67
+ <div class="status" id="status" aria-live="polite"></div>
68
+ <div class="bar" id="bar" hidden><span></span></div>
69
+ </section>
70
+
71
+ <section class="panel" id="outputs">
72
+ <div id="result" class="placeholder">Answers appear here.</div>
73
+ </section>
74
+ </div>
75
+
76
+ <section class="examples">
77
+ <span class="label">Examples</span>
78
+ <div id="examples"></div>
79
+ </section>
80
+
81
+ <footer><a href="https://x.com/realmrfakename" target="_blank" rel="noopener">Twitter / X</a></footer>
82
+ </main>
83
+ <script type="module" src="app.js"></script>
84
+ </body>
85
  </html>
prompt.js ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Ports of the two model repos' prompt.py files: questions in, token ids out, probabilities back.
2
+
3
+ // Python's json.dumps(x, ensure_ascii=False) with its default ", " / ": " separators.
4
+ export function pyDumps(x) {
5
+ if (x === null || x === undefined) return "null";
6
+ if (typeof x === "string") return JSON.stringify(x);
7
+ if (typeof x === "number" || typeof x === "boolean") return JSON.stringify(x);
8
+ if (Array.isArray(x)) return "[" + x.map(pyDumps).join(", ") + "]";
9
+ return "{" + Object.entries(x).map(([k, v]) => JSON.stringify(k) + ": " + pyDumps(v)).join(", ") + "}";
10
+ }
11
+
12
+ // ------------------------------------------------------------------ d1-omni-600M (prompt.py)
13
+
14
+ export const QTYPES = { choice: 0, score: 1, noul: 2 };
15
+ const DELIM = { state: "<|reserved_7|>", q: "<|reserved_8|>", opt: "<|reserved_9|>", opt_end: "<|reserved_10|>", decide: "<|reserved_11|>" };
16
+ const MARKER = "<|mask|>";
17
+
18
+ export const escapeSpecial = (t) => t.replace(/<\|([A-Za-z0-9_]+)\|>/g, "<¦$1¦>");
19
+ const criterion = (v) => (typeof v === "string" ? v : pyDumps(v));
20
+ export const nOptions = (q) => (q.type === "noul" ? 2 : q.type === "choice" ? Object.keys(q.criteria).length : q.criteria.length);
21
+
22
+ function renderOptions(q, nounDefault, audio) {
23
+ if (q.type === "choice") {
24
+ const entries = Object.entries(q.criteria);
25
+ if (audio) return entries.map(([k, v], i) => `option_${String(i).padStart(3, "0")}: ${criterion(v == null || v === "" ? k : v)}`);
26
+ return entries.map(([k, v]) => (v == null || v === "" ? k : `${k}: ${criterion(v)}`));
27
+ }
28
+ if (q.type === "score") return q.criteria.map((c, i) => `level ${i}: ${criterion(c)}`);
29
+ if (audio) return ["false: no", "true: yes"];
30
+ const crit = q.criteria || nounDefault || {};
31
+ const f = crit.false ?? crit.no, t = crit.true ?? crit.yes;
32
+ return ["false: " + (f != null && f !== "" ? criterion(f) : "no, the statement does not hold"),
33
+ "true: " + (t != null && t !== "" ? criterion(t) : "yes, the statement holds")];
34
+ }
35
+
36
+ export function omniEncode(tok, state, q, maxLen, nounDefault = null, audio = false, perOption = 24) {
37
+ const id = (s) => {
38
+ const ids = tok.encode(s, { add_special_tokens: false });
39
+ if (ids.length !== 1) throw new Error(`not a single token: ${s}`);
40
+ return ids[0];
41
+ };
42
+ const enc = (s) => tok.encode(escapeSpecial(s), { add_special_tokens: false });
43
+ const opts = renderOptions(q, nounDefault, audio);
44
+ const budget = Math.max(96, Math.min(opts.length * perOption + 32, Math.floor(maxLen / 2)));
45
+ const per = Math.max(2, Math.floor((budget - 3 * opts.length) / opts.length));
46
+ let question = [id(DELIM.q), ...enc(q.instructions)].slice(0, Math.max(16, budget));
47
+ let markers = [];
48
+ for (const text of opts) {
49
+ markers.push(question.length + 1);
50
+ question = question.concat([id(DELIM.opt), id(MARKER)], enc(" " + text).slice(0, per), [id(DELIM.opt_end)]);
51
+ }
52
+ question.push(id(DELIM.decide));
53
+ const room = Math.max(0, maxLen - question.length - 2);
54
+ const stateIds = [id(DELIM.state), ...enc(typeof state === "string" ? state : pyDumps(state)).slice(0, room)];
55
+ const ids = [tok.bos_token_id ?? 1, ...stateIds, ...question].slice(0, maxLen);
56
+ markers = markers.map((m) => m + 1 + stateIds.length);
57
+ if (markers[markers.length - 1] >= maxLen) throw new Error("the options do not fit in the context");
58
+ return { ids, markers };
59
+ }
60
+
61
+ export function temperatureKey(q) {
62
+ const k = nOptions(q);
63
+ return `${q.type}:` + (k <= 2 ? "2" : k <= 5 ? "3-5" : k <= 10 ? "6-10" : "11+");
64
+ }
65
+
66
+ // ------------------------------------------------------------------ d1-3B (prompt.py)
67
+
68
+ const FALLBACK_POOL = (() => {
69
+ const out = [];
70
+ for (let c = 65; c <= 90; c++) out.push(String.fromCharCode(c));
71
+ for (let i = 0; i < 100; i++) out.push(String(i).padStart(2, "0"));
72
+ for (let c = 97; c <= 122; c++) out.push(String.fromCharCode(c));
73
+ for (let i = 0; i < 200; i++) out.push(`#${i}`);
74
+ for (let a = 65; a <= 90; a++) for (let b = 65; b <= 90; b++) out.push(String.fromCharCode(a) + String.fromCharCode(b));
75
+ return out;
76
+ })();
77
+
78
+ function optionCodes(labels) {
79
+ const labs = labels.map((x) => String(x).trim());
80
+ if (labs.length && labs.every((k) => k.length === 1 && /\p{L}/u.test(k))) return labs;
81
+ if (labs.length <= 26) return labs.map((_, i) => String.fromCharCode(65 + i));
82
+ return labs.map((_, i) => String(i).padStart(2, "0"));
83
+ }
84
+
85
+ function aliases(tok, labels) {
86
+ const used = new Set(), out = [];
87
+ const take = (raw) => {
88
+ const e = tok.encode(raw, { add_special_tokens: false });
89
+ if (e.length !== 1 || used.has(e[0])) return false;
90
+ out.push([raw, e[0]]);
91
+ used.add(e[0]);
92
+ return true;
93
+ };
94
+ for (const code of optionCodes(labels)) {
95
+ if (take(code)) continue;
96
+ if (!FALLBACK_POOL.some(take)) throw new Error(`no single-token alias left for ${labels.length} options`);
97
+ }
98
+ return out;
99
+ }
100
+
101
+ function singleIds(tok, texts) {
102
+ const out = [], seen = new Set();
103
+ for (const t of texts) {
104
+ const e = tok.encode(t, { add_special_tokens: false });
105
+ if (e.length === 1 && !seen.has(e[0])) { out.push(e[0]); seen.add(e[0]); }
106
+ }
107
+ return out;
108
+ }
109
+
110
+ export function readoutIds3b(tok, q) {
111
+ if (q.type === "noul") return [singleIds(tok, ["yes", "Yes", "YES"]), singleIds(tok, ["no", "No", "NO"])];
112
+ if (q.type === "score") return q.criteria.map((_, i) => singleIds(tok, [String(i)]));
113
+ return aliases(tok, Object.keys(q.criteria)).map(([code, tid]) => [tid, ...singleIds(tok, [` ${code}`]).filter((i) => i !== tid)]);
114
+ }
115
+
116
+ function questionBlock3b(tok, q) {
117
+ if (q.type === "choice") {
118
+ const labels = Object.keys(q.criteria);
119
+ const codes = aliases(tok, labels);
120
+ const lines = labels.map((lab, i) => `${codes[i][0]} ${q.criteria[lab] || lab.replace(/_/g, " ")}`).join("\n");
121
+ return `${q.instructions}\n\nOptions:\n${lines}\n\nReply with the option code only.`;
122
+ }
123
+ if (q.type === "noul") {
124
+ const extra = q.criteria ? `\nYes: ${q.criteria.true}\nNo: ${q.criteria.false}` : "";
125
+ return `${q.instructions}${extra}\n\nReply with yes or no only.`;
126
+ }
127
+ const legend = q.criteria.map((name, i) => `${i} ${name}`).join("\n");
128
+ return `${q.instructions}\n\n${legend}\n\nReply with a single digit 0-${q.criteria.length - 1} only.`;
129
+ }
130
+
131
+ export function render3b(tok, state, q, imageMarkup = "") {
132
+ const bos = "<|startoftext|>";
133
+ let body = "";
134
+ if (state !== null && state !== undefined) {
135
+ const block = typeof state === "string" ? `${state}\n\n` : JSON.stringify(state, null, 2) + "\n\n";
136
+ body = `${block}\nQUESTION:\n`;
137
+ }
138
+ return `${bos}<|im_start|>user\n${imageMarkup}${body}${questionBlock3b(tok, q)}<|im_end|>\n<|im_start|>assistant\n`;
139
+ }
140
+
141
+ // ------------------------------------------------------------------ answers (api.py / prompt.py `answer`)
142
+
143
+ export function softmax(z) {
144
+ const m = Math.max(...z);
145
+ const e = z.map((v) => Math.exp(v - m));
146
+ const s = e.reduce((a, b) => a + b, 0);
147
+ return e.map((v) => v / s);
148
+ }
149
+
150
+ export function answer(q, probs) {
151
+ if (q.type === "noul") return { type: "noul", noul: probs[0] };
152
+ let best = 0;
153
+ probs.forEach((p, i) => { if (p > probs[best]) best = i; });
154
+ if (q.type === "choice") {
155
+ const names = Object.keys(q.criteria);
156
+ return { type: "choice", choice: names[best], confidence: probs[best], probabilities: Object.fromEntries(names.map((n, i) => [n, probs[i]])) };
157
+ }
158
+ return {
159
+ type: "score", score: probs.reduce((a, p, i) => a + i * p, 0), confidence: probs[best],
160
+ probabilities: Object.fromEntries(probs.map((p, i) => [String(i), p])),
161
+ legend: Object.fromEntries(q.criteria.map((c, i) => [String(i), criterion(c)])),
162
+ };
163
+ }
style.css CHANGED
@@ -1,28 +1,134 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  }
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
 
 
 
9
  }
 
 
 
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
 
 
 
 
 
 
 
 
 
 
16
  }
 
 
 
 
 
 
 
 
17
 
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
 
24
  }
 
 
 
 
 
 
 
 
 
 
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
 
 
 
28
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #f4f1ea;
3
+ --panel: #fbf9f4;
4
+ --ink: #1d1b17;
5
+ --muted: #6d675c;
6
+ --line: #ddd6c8;
7
+ --track: #e8e2d5;
8
+ --accent: #b4532a;
9
+ --accent-ink: #ffffff;
10
+ --bar-dim: #9c968a;
11
+ --field: #ffffff;
12
+ --radius: 6px;
13
+ --h: 34px;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root:not([data-theme="light"]) {
17
+ --bg: #161512;
18
+ --panel: #1e1c18;
19
+ --ink: #ece7dc;
20
+ --muted: #9b9486;
21
+ --line: #34312a;
22
+ --track: #2c2924;
23
+ --accent: #d9774a;
24
+ --accent-ink: #161512;
25
+ --bar-dim: #6f695e;
26
+ --field: #191815;
27
+ }
28
+ }
29
+ :root[data-theme="dark"] {
30
+ --bg: #161512; --panel: #1e1c18; --ink: #ece7dc; --muted: #9b9486; --line: #34312a; --track: #2c2924;
31
+ --accent: #d9774a; --accent-ink: #161512; --bar-dim: #6f695e; --field: #191815;
32
  }
33
 
34
+ * { box-sizing: border-box; }
35
+ html, body { margin: 0; }
36
+ body {
37
+ background: var(--bg);
38
+ color: var(--ink);
39
+ font: 15px/1.45 "IBM Plex Sans", system-ui, sans-serif;
40
  }
41
+ main { max-width: 1180px; margin: 0 auto; padding: 24px 16px 32px; }
42
+ a { color: var(--accent); }
43
+ code, .mono { font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 12.5px; }
44
 
45
+ header h1 { font-size: 28px; font-weight: 600; margin: 0 0 6px; letter-spacing: -0.01em; }
46
+ .tag { font-size: 13px; font-weight: 500; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: 2px 6px; vertical-align: middle; }
47
+ .lede { margin: 0 0 4px; color: var(--ink); max-width: 760px; }
48
+ .links { margin: 0 0 18px; color: var(--muted); font-size: 14px; }
49
+
50
+ .grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 16px; align-items: start; }
51
+ @media (max-width: 860px) { .grid { grid-template-columns: 1fr; } }
52
+ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; }
53
+
54
+ .field { display: block; margin-bottom: 12px; }
55
+ .label { display: block; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 6px; }
56
+
57
+ textarea, input[type="text"], select {
58
+ width: 100%; font: inherit; color: var(--ink); background: var(--field);
59
+ border: 1px solid var(--line); border-radius: var(--radius); padding: 7px 9px;
60
  }
61
+ textarea { resize: vertical; }
62
+ textarea:focus, input:focus, select:focus, button:focus-visible, .drop:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; }
63
+
64
+ .seg { display: inline-flex; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
65
+ .seg button { height: var(--h); padding: 0 14px; border: 0; background: var(--field); color: var(--ink); font: inherit; cursor: pointer; }
66
+ .seg button + button { border-left: 1px solid var(--line); }
67
+ .seg button[aria-checked="true"] { background: var(--accent); color: var(--accent-ink); }
68
+ .seg small { opacity: 0.75; margin-left: 4px; }
69
 
70
+ .media { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; }
71
+ .media.single { grid-template-columns: 1fr; }
72
+ @media (max-width: 520px) { .media { grid-template-columns: 1fr; } }
73
+ .drop {
74
+ position: relative; min-height: 120px; border: 1px dashed var(--line); border-radius: var(--radius);
75
+ background: var(--field); display: flex; flex-direction: column; align-items: center; justify-content: center;
76
+ gap: 8px; padding: 8px; cursor: pointer; overflow: hidden;
77
  }
78
+ .drop.over { border-color: var(--accent); }
79
+ .drop img { max-width: 100%; max-height: 200px; border-radius: 4px; }
80
+ .drop audio { width: 100%; }
81
+ .hint { color: var(--muted); font-size: 13px; }
82
+ .audio-actions { display: flex; gap: 6px; align-items: center; }
83
+ .clear {
84
+ position: absolute; top: 6px; right: 6px; width: 26px; height: 26px; border-radius: 4px;
85
+ border: 1px solid var(--line); background: var(--panel); color: var(--ink); cursor: pointer; font-size: 16px; line-height: 1;
86
+ }
87
+ .audio-actions .clear { position: static; }
88
 
89
+ .qrow { display: grid; grid-template-columns: 90px 92px minmax(0, 1.2fr) minmax(0, 1.5fr) 30px; gap: 6px; margin-bottom: 6px; }
90
+ .qrow input, .qrow select { height: var(--h); padding: 0 8px; }
91
+ .qrow .del { height: var(--h); border: 1px solid var(--line); background: var(--field); color: var(--muted); border-radius: var(--radius); cursor: pointer; }
92
+ @media (max-width: 640px) {
93
+ .qrow { grid-template-columns: 1fr 1fr 30px; }
94
+ .qrow .qtext, .qrow .qopts { grid-column: 1 / -1; }
95
+ .qrow .del { grid-row: 1; grid-column: 3; }
96
  }
97
+ .qfoot { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 4px; }
98
+ .help { color: var(--muted); font-size: 12.5px; }
99
+
100
+ button.small { height: 28px; padding: 0 10px; border: 1px solid var(--line); background: var(--field); color: var(--ink); border-radius: var(--radius); font: inherit; font-size: 13px; cursor: pointer; }
101
+ button.small.rec { border-color: var(--accent); color: var(--accent); }
102
+ button.primary { width: 100%; height: 40px; border: 0; border-radius: var(--radius); background: var(--accent); color: var(--accent-ink); font: inherit; font-weight: 600; cursor: pointer; }
103
+ button.primary:disabled { opacity: 0.6; cursor: progress; }
104
+
105
+ .status { min-height: 20px; margin-top: 8px; font-size: 13px; color: var(--muted); }
106
+ .status.err { color: var(--accent); }
107
+ .bar { height: 4px; background: var(--track); border-radius: 2px; overflow: hidden; margin-top: 4px; }
108
+ .bar span { display: block; height: 100%; width: 0; background: var(--accent); transition: width 0.15s linear; }
109
+
110
+ .placeholder { color: var(--muted); font-size: 14px; }
111
+ .cards { display: flex; flex-direction: column; gap: 10px; }
112
+ .card { border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; background: var(--field); }
113
+ .card .head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 2px; }
114
+ .card .name { font-family: "IBM Plex Mono", monospace; font-size: 12px; color: var(--muted); }
115
+ .card .q { font-size: 14px; }
116
+ .card .ans { font-size: 22px; font-weight: 600; color: var(--accent); margin: 2px 0 8px; }
117
+ .card .sub { font-size: 13px; font-weight: 400; color: var(--muted); margin-left: 6px; }
118
+ .row { display: grid; grid-template-columns: minmax(70px, 38%) 1fr 56px; gap: 10px; align-items: center; font-size: 13px; color: var(--muted); padding: 2px 0; }
119
+ .row.top { color: var(--ink); font-weight: 600; }
120
+ .row .lab { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
121
+ .row .track { height: 8px; background: var(--track); border-radius: 4px; overflow: hidden; }
122
+ .row .fill { display: block; height: 100%; background: var(--bar-dim); }
123
+ .row.top .fill { background: var(--accent); }
124
+ .row .pct { text-align: right; font-variant-numeric: tabular-nums; }
125
+ .took { font-size: 12px; color: var(--muted); margin-top: 8px; }
126
+
127
+ .examples { margin-top: 16px; }
128
+ #examples { display: flex; flex-wrap: wrap; gap: 6px; }
129
+ #examples button { height: 30px; padding: 0 12px; border: 1px solid var(--line); background: var(--panel); color: var(--ink); border-radius: var(--radius); font: inherit; font-size: 13px; cursor: pointer; }
130
+ #examples button:hover { border-color: var(--accent); }
131
+
132
+ footer { margin-top: 24px; font-size: 13px; }
133
+
134
+ [hidden] { display: none !important; }
vision.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // d1-omni's vision.py `layout` and `preprocess`: LFM2-VL smart resize, optional 512 px tiles + thumbnail,
2
+ // RGB in [-1, 1], 16 px patches padded to 1024.
3
+ const TILE = 512, PATCH = 16, MAX_PATCHES = 1024;
4
+
5
+ export function layout(width, height) {
6
+ const factor = 32, maximum = 256 * 1024, minimum = 64 * 1024;
7
+ let h = Math.max(factor, Math.round(height / factor) * factor);
8
+ let w = Math.max(factor, Math.round(width / factor) * factor);
9
+ if (h * w > maximum) {
10
+ const beta = Math.sqrt((height * width) / maximum);
11
+ h = Math.max(factor, Math.floor(height / beta / factor) * factor);
12
+ w = Math.max(factor, Math.floor(width / beta / factor) * factor);
13
+ } else if (h * w < minimum) {
14
+ const beta = Math.sqrt(minimum / (height * width));
15
+ h = Math.ceil((height * beta) / factor) * factor;
16
+ w = Math.ceil((width * beta) / factor) * factor;
17
+ }
18
+ const large = Math.max(16, Math.round(height / factor) * factor) * Math.max(16, Math.round(width / factor) * factor) > maximum * 2;
19
+ let grid = [1, 1];
20
+ if (large) {
21
+ const set = new Map();
22
+ for (let n = 2; n <= 10; n++) for (let x = 1; x <= n; x++) for (let y = 1; y <= n; y++) if (x * y >= 2 && x * y <= 10) set.set(`${x},${y}`, [x, y]);
23
+ // Python sorts a set by area; ties keep the set's order, which only matters for exact ties in `diff`.
24
+ const ratios = [...set.values()].sort((a, b) => a[0] * a[1] - b[0] * b[1]);
25
+ let best = Infinity;
26
+ for (const r of ratios) {
27
+ const diff = Math.abs(width / height - r[0] / r[1]);
28
+ if (diff < best || (diff === best && width * height > 0.5 * TILE * TILE * r[0] * r[1])) { grid = r; best = diff; }
29
+ }
30
+ }
31
+ return { grid, thumbnail: [h, w], tiled: large };
32
+ }
33
+
34
+ // PIL / torchvision antialiased bilinear (triangle filter), uint8 in and out, horizontal pass then vertical.
35
+ function weights(inSize, outSize) {
36
+ const scale = inSize / outSize, support = Math.max(scale, 1);
37
+ const out = [];
38
+ for (let i = 0; i < outSize; i++) {
39
+ const center = (i + 0.5) * scale;
40
+ const lo = Math.max(0, Math.floor(center - support + 0.5));
41
+ const hi = Math.min(inSize, Math.floor(center + support + 0.5));
42
+ const ws = [];
43
+ let total = 0;
44
+ for (let j = lo; j < hi; j++) {
45
+ const x = (j - center + 0.5) / support;
46
+ const w = Math.max(0, 1 - Math.abs(x));
47
+ ws.push(w); total += w;
48
+ }
49
+ out.push({ lo, ws: ws.map((w) => (total ? w / total : 0)) });
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function resizeRGB(src, sw, sh, dw, dh) {
55
+ const wx = weights(sw, dw), wy = weights(sh, dh);
56
+ const mid = new Uint8ClampedArray(dw * sh * 3);
57
+ for (let y = 0; y < sh; y++) for (let x = 0; x < dw; x++) {
58
+ const { lo, ws } = wx[x];
59
+ for (let c = 0; c < 3; c++) {
60
+ let s = 0;
61
+ for (let k = 0; k < ws.length; k++) s += ws[k] * src[(y * sw + lo + k) * 3 + c];
62
+ mid[(y * dw + x) * 3 + c] = Math.round(s);
63
+ }
64
+ }
65
+ const out = new Uint8ClampedArray(dw * dh * 3);
66
+ for (let y = 0; y < dh; y++) {
67
+ const { lo, ws } = wy[y];
68
+ for (let x = 0; x < dw; x++) for (let c = 0; c < 3; c++) {
69
+ let s = 0;
70
+ for (let k = 0; k < ws.length; k++) s += ws[k] * mid[((lo + k) * dw + x) * 3 + c];
71
+ out[(y * dw + x) * 3 + c] = Math.round(s);
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // rgb: Uint8 RGB (no alpha), width, height -> {pixel_values Float32 [n,1024,768], mask, shapes, prefix}
78
+ export function preprocess(rgb, width, height) {
79
+ const plan = layout(width, height);
80
+ const crops = [];
81
+ if (plan.tiled) {
82
+ const [gw, gh] = plan.grid;
83
+ const big = resizeRGB(rgb, width, height, gw * TILE, gh * TILE);
84
+ for (let r = 0; r < gh; r++) for (let c = 0; c < gw; c++) {
85
+ const tile = new Uint8ClampedArray(TILE * TILE * 3);
86
+ for (let y = 0; y < TILE; y++) {
87
+ const off = ((r * TILE + y) * gw * TILE + c * TILE) * 3;
88
+ tile.set(big.subarray(off, off + TILE * 3), y * TILE * 3);
89
+ }
90
+ crops.push({ data: tile, h: TILE, w: TILE });
91
+ }
92
+ }
93
+ const [th, tw] = plan.thumbnail;
94
+ crops.push({ data: resizeRGB(rgb, width, height, tw, th), h: th, w: tw });
95
+ const n = crops.length, D = 3 * PATCH * PATCH;
96
+ const pixels = new Float32Array(n * MAX_PATCHES * D);
97
+ const mask = new BigInt64Array(n * MAX_PATCHES);
98
+ const shapes = new BigInt64Array(n * 2);
99
+ let prefix = 0;
100
+ crops.forEach((crop, i) => {
101
+ const ph = crop.h / PATCH, pw = crop.w / PATCH;
102
+ shapes[2 * i] = BigInt(ph); shapes[2 * i + 1] = BigInt(pw);
103
+ prefix += (ph * pw) / 4;
104
+ for (let py = 0; py < ph; py++) for (let px = 0; px < pw; px++) {
105
+ const p = py * pw + px;
106
+ mask[i * MAX_PATCHES + p] = 1n;
107
+ const base = (i * MAX_PATCHES + p) * D;
108
+ for (let y = 0; y < PATCH; y++) for (let x = 0; x < PATCH; x++) {
109
+ const s = ((py * PATCH + y) * crop.w + px * PATCH + x) * 3;
110
+ const d = base + (y * PATCH + x) * 3;
111
+ for (let c = 0; c < 3; c++) pixels[d + c] = (crop.data[s + c] - 127.5) / 127.5;
112
+ }
113
+ }
114
+ });
115
+ return { pixels, mask, shapes, n, prefix };
116
+ }
worker-3b.js ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // d1-3B in the browser: Transformers.js (WebGPU, WASM fallback) over onnx-community/d1-3B-ONNX.
2
+ import { AutoModelForImageTextToText, AutoProcessor, RawImage, env } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.1/dist/transformers.min.js";
3
+ import { answer, readoutIds3b, render3b, softmax } from "./prompt.js";
4
+
5
+ env.allowLocalModels = false;
6
+ const REPO = "onnx-community/d1-3B-ONNX";
7
+ const post = (m) => self.postMessage(m);
8
+ let processor = null, model = null, tok = null, device = "wasm";
9
+
10
+ async function init() {
11
+ if (navigator.gpu) {
12
+ try { if (await navigator.gpu.requestAdapter()) device = "webgpu"; } catch {}
13
+ }
14
+ const files = {};
15
+ const progress_callback = (p) => {
16
+ if (p.status === "progress" && p.file && p.total) {
17
+ files[p.file] = [p.loaded, p.total];
18
+ let loaded = 0, total = 0;
19
+ for (const [l, t] of Object.values(files)) { loaded += l; total += t; }
20
+ post({ type: "progress", label: "text", file: p.file, loaded, total, aggregate: true });
21
+ }
22
+ };
23
+ processor = await AutoProcessor.from_pretrained(REPO);
24
+ tok = processor.tokenizer;
25
+ model = await AutoModelForImageTextToText.from_pretrained(REPO, {
26
+ device,
27
+ dtype: { embed_tokens: "q4", vision_encoder: "q8", decoder_model_merged: "q4" },
28
+ progress_callback,
29
+ });
30
+ post({ type: "ready", device });
31
+ }
32
+
33
+ async function loadImage(blob) {
34
+ let img = await RawImage.fromBlob(blob);
35
+ img = img.rgb();
36
+ const max = 1024 * 1024; // runner.py `cap_pixels`
37
+ if (img.width * img.height > max) {
38
+ const s = Math.sqrt(max / (img.width * img.height));
39
+ img = await img.resize(Math.max(1, Math.floor(img.width * s)), Math.max(1, Math.floor(img.height * s)), { resample: 3 });
40
+ }
41
+ return img;
42
+ }
43
+
44
+ async function decide({ state, questions, image }) {
45
+ const t0 = performance.now();
46
+ const img = image ? await loadImage(image) : null;
47
+ const answers = {}, probs = {};
48
+ for (const q of questions) {
49
+ const prompt = render3b(tok, state, q, img ? "<image>" : "");
50
+ const inputs = img ? await processor(img, prompt, { add_special_tokens: false }) : tok(prompt, { add_special_tokens: false });
51
+ const { logits } = await model(inputs);
52
+ const [, T, V] = logits.dims;
53
+ const row = logits.data.subarray((T - 1) * V, T * V);
54
+ const scores = readoutIds3b(tok, q).map((g) => Math.max(...g.map((i) => row[i])));
55
+ const p = softmax(scores);
56
+ answers[q.name] = answer(q, p);
57
+ probs[q.name] = p;
58
+ }
59
+ return { answers, probs, ms: performance.now() - t0 };
60
+ }
61
+
62
+ self.onmessage = async ({ data }) => {
63
+ try {
64
+ if (data.type === "init") await init();
65
+ else if (data.type === "decide") post({ type: "result", id: data.id, ...(await decide(data)) });
66
+ } catch (e) {
67
+ post({ type: "error", id: data.id, message: String(e && e.message ? e.message : e) });
68
+ }
69
+ };
worker-omni.js ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // d1-omni-600M in the browser: onnxruntime-web (WebGPU, WASM fallback) over onnx-community/d1-omni-600M-ONNX.
2
+ import * as ort from "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.31.0-dev.20260914-8d85527a0/dist/ort.webgpu.bundle.min.mjs";
3
+ import { AutoTokenizer, env } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.1/dist/transformers.min.js";
4
+ import { QTYPES, answer, nOptions, omniEncode, softmax, temperatureKey } from "./prompt.js";
5
+ import { preprocess } from "./vision.js";
6
+
7
+ ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.31.0-dev.20260914-8d85527a0/dist/";
8
+ ort.env.logLevel = "error";
9
+ env.allowLocalModels = false;
10
+
11
+ const REPO = "onnx-community/d1-omni-600M-ONNX";
12
+ const BASE = `https://huggingface.co/${REPO}/resolve/main/`;
13
+ const CACHE = "open-d1-omni-v1";
14
+ const YES_NO = { false: "no", true: "yes" };
15
+ const post = (m) => self.postMessage(m);
16
+
17
+ let device = "wasm", tok = null, config = null;
18
+ const sessions = {};
19
+
20
+ async function fetchCached(path, label) {
21
+ const url = BASE + path;
22
+ let cache = null;
23
+ try { cache = await caches.open(CACHE); const hit = await cache.match(url); if (hit) return new Uint8Array(await hit.arrayBuffer()); } catch {}
24
+ const res = await fetch(url);
25
+ if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`);
26
+ const total = Number(res.headers.get("content-length")) || 0;
27
+ const reader = res.body.getReader();
28
+ const chunks = [];
29
+ let loaded = 0, last = 0;
30
+ for (;;) {
31
+ const { done, value } = await reader.read();
32
+ if (done) break;
33
+ chunks.push(value); loaded += value.length;
34
+ const now = performance.now();
35
+ if (now - last > 150) { last = now; post({ type: "progress", label, file: path, loaded, total }); }
36
+ }
37
+ post({ type: "progress", label, file: path, loaded, total: total || loaded });
38
+ const buf = new Uint8Array(loaded);
39
+ let off = 0;
40
+ for (const c of chunks) { buf.set(c, off); off += c.length; }
41
+ try { if (cache) await cache.put(url, new Response(buf, { headers: { "content-type": "application/octet-stream" } })); } catch {}
42
+ return buf;
43
+ }
44
+
45
+ async function session(name, label) {
46
+ if (sessions[name]) return sessions[name];
47
+ const graph = await fetchCached(`onnx/${name}.onnx`, label);
48
+ const data = await fetchCached(`onnx/${name}.onnx_data`, label);
49
+ const opts = { executionProviders: [device], graphOptimizationLevel: "all", externalData: [{ path: `${name}.onnx_data`, data }] };
50
+ sessions[name] = await ort.InferenceSession.create(graph, opts);
51
+ return sessions[name];
52
+ }
53
+
54
+ async function init() {
55
+ if (navigator.gpu) {
56
+ try { if (await navigator.gpu.requestAdapter()) device = "webgpu"; } catch {}
57
+ }
58
+ if (device === "wasm") ort.env.wasm.numThreads = self.crossOriginIsolated ? Math.min(8, navigator.hardwareConcurrency || 4) : 1;
59
+ config = await (await fetch(BASE + "config.json")).json();
60
+ tok = await AutoTokenizer.from_pretrained(REPO);
61
+ await session("embed_tokens_quantized", "text");
62
+ await session("decision_quantized", "text");
63
+ post({ type: "ready", device });
64
+ }
65
+
66
+ const i64 = (arr, dims) => new ort.Tensor("int64", BigInt64Array.from(arr, (v) => BigInt(v)), dims);
67
+
68
+ async function embed(sessionName, ids) {
69
+ const s = sessions[sessionName];
70
+ const out = await s.run({ input_ids: i64(ids, [1, ids.length]) });
71
+ return out.inputs_embeds.data; // Float32Array [L*1024]
72
+ }
73
+
74
+ async function imagePrefix(blob) {
75
+ const vis = await session("vision_encoder_fp16", "image");
76
+ const bmp = await createImageBitmap(blob, { colorSpaceConversion: "none", premultiplyAlpha: "none", imageOrientation: "none" });
77
+ const canvas = new OffscreenCanvas(bmp.width, bmp.height);
78
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
79
+ ctx.drawImage(bmp, 0, 0);
80
+ const rgba = ctx.getImageData(0, 0, bmp.width, bmp.height).data;
81
+ const rgb = new Uint8ClampedArray(bmp.width * bmp.height * 3);
82
+ for (let i = 0, j = 0; i < rgba.length; i += 4, j += 3) { rgb[j] = rgba[i]; rgb[j + 1] = rgba[i + 1]; rgb[j + 2] = rgba[i + 2]; }
83
+ const x = preprocess(rgb, bmp.width, bmp.height);
84
+ const out = await vis.run({
85
+ pixel_values: new ort.Tensor("float32", x.pixels, [x.n, 1024, 768]),
86
+ pixel_attention_mask: new ort.Tensor("int64", x.mask, [x.n, 1024]),
87
+ spatial_shapes: new ort.Tensor("int64", x.shapes, [x.n, 2]),
88
+ });
89
+ const f = out.image_features;
90
+ return { data: f.data, len: f.dims[0] };
91
+ }
92
+
93
+ async function audioPrefix(samples) {
94
+ const aud = await session("audio_encoder_quantized", "voice");
95
+ await session("embed_tokens_fp16", "voice");
96
+ // audio.py `waveform`: cut to 30 s, zero-pad to 0.5 s
97
+ let x = samples.subarray(0, 30 * 16000);
98
+ if (x.length < 8000) { const y = new Float32Array(8000); y.set(x); x = y; }
99
+ const out = await aud.run({ input_values: new ort.Tensor("float32", Float32Array.from(x), [1, x.length]) });
100
+ const a = out.audio_embeds;
101
+ return { data: a.data, len: a.dims[1] };
102
+ }
103
+
104
+ async function decide({ state, questions, image, audio }) {
105
+ const t0 = performance.now();
106
+ let prefix = null, maxLen = config.max_length, noul = null, calibrate = true, spoken = false, embedName = "embed_tokens_quantized";
107
+ if (image) {
108
+ prefix = await imagePrefix(image);
109
+ maxLen = Math.min(config.image_text_length, config.max_length - prefix.len);
110
+ noul = YES_NO; calibrate = false;
111
+ } else if (audio) {
112
+ prefix = await audioPrefix(audio);
113
+ maxLen = Math.min(config.audio_text_length, config.max_length - prefix.len);
114
+ noul = YES_NO; calibrate = false; spoken = true; embedName = "embed_tokens_fp16";
115
+ if (state === null) state = {};
116
+ }
117
+ if (maxLen < 64) throw new Error("the media take too many positions");
118
+ if (state === null) state = "";
119
+ const P = prefix ? prefix.len : 0, D = 1024;
120
+ const rows = [];
121
+ for (const q of questions) rows.push(omniEncode(tok, state, q, maxLen, noul, spoken));
122
+ const embeds = [];
123
+ for (const r of rows) embeds.push(await embed(embedName, r.ids));
124
+ const B = rows.length, L = P + Math.max(...rows.map((r) => r.ids.length)), K = Math.max(...rows.map((r) => r.markers.length));
125
+ const h = new Float32Array(B * L * D), mask = [], mpos = [], mmask = [], qtype = [];
126
+ rows.forEach((r, b) => {
127
+ if (prefix) h.set(prefix.data, b * L * D);
128
+ h.set(embeds[b], (b * L + P) * D);
129
+ for (let t = 0; t < L; t++) mask.push(t < P + r.ids.length ? 1 : 0);
130
+ for (let k = 0; k < K; k++) { mpos.push(k < r.markers.length ? r.markers[k] : 0); mmask.push(k < r.markers.length ? 1 : 0); }
131
+ qtype.push(QTYPES[questions[b].type]);
132
+ });
133
+ const out = await sessions.decision_quantized.run({
134
+ inputs_embeds: new ort.Tensor("float32", h, [B, L, D]),
135
+ attention_mask: i64(mask, [B, L]),
136
+ prefix_len: i64(new Array(B).fill(P), [B]),
137
+ marker_pos: i64(mpos, [B, K]),
138
+ marker_mask: i64(mmask, [B, K]),
139
+ qtype: i64(qtype, [B]),
140
+ });
141
+ const logits = out.logits.data, Kout = out.logits.dims[1];
142
+ const temps = config.temperatures || {};
143
+ const answers = {}, probs = {};
144
+ questions.forEach((q, b) => {
145
+ let z = Array.from(logits.subarray(b * Kout, b * Kout + nOptions(q)));
146
+ if (calibrate) { const t = temps[temperatureKey(q)] ?? temps[q.type] ?? 1; z = z.map((v) => v / t); }
147
+ let p = softmax(z);
148
+ if (q.type === "noul") p = p.reverse(); // the model reads a noul as [false, true]
149
+ answers[q.name] = answer(q, p);
150
+ probs[q.name] = p;
151
+ });
152
+ return { answers, probs, ms: performance.now() - t0, prefix: P };
153
+ }
154
+
155
+ self.onmessage = async ({ data }) => {
156
+ try {
157
+ if (data.type === "init") await init();
158
+ else if (data.type === "decide") post({ type: "result", id: data.id, ...(await decide(data)) });
159
+ } catch (e) {
160
+ post({ type: "error", id: data.id, message: String(e && e.message ? e.message : e) });
161
+ }
162
+ };