Spaces:
Running
Running
Initial WebGPU demo
Browse files- .gitattributes +2 -0
- README.md +16 -5
- app.js +300 -0
- examples/cats.jpg +3 -0
- examples/voice.flac +3 -0
- index.html +83 -17
- prompt.js +163 -0
- style.css +125 -19
- vision.js +116 -0
- worker-3b.js +69 -0
- 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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
| 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
}
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
.
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
| 24 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
.
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
};
|