Decision-AI-Web / worker.js
SeaWolf-AI's picture
Load the decision fine-tune (ONNX served from the Space), trained prompt format and scale, Egg check tab
d562f50 verified
Raw History Blame Contribute Delete
9.07 kB
// SPDX-License-Identifier: Apache-2.0
// Embedding worker: loads a decision-tuned EmbeddingGemma 2 (ONNX, same graph layout as
// onnx-community/embeddinggemma-2-ONNX) with transformers.js and returns L2-normalized
// 768-d embeddings (mean pooling over the attention mask, then L2 normalization).
// The model files are served next to this page (./models/...), so nothing is fetched from elsewhere.
import {
AutoConfig,
AutoModel,
AutoProcessor,
RawImage,
RawVideo,
RawVideoFrame,
env,
} from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.1/dist/transformers.min.js";
const MODEL_ID = "egemma2-decide";
env.allowLocalModels = true;
env.allowRemoteModels = false;
env.localModelPath = new URL("./models/", self.location.href).pathname; // same-origin path, e.g. "/models/"
if (env.backends?.onnx?.wasm) env.backends.onnx.wasm.numThreads = 1; // static hosting is not cross-origin isolated
let processor = null;
let model = null;
let loaded = null; // { device, dtype, audio }
let decision = null; // { scale } from decision.json
let queue = Promise.resolve();
const post = (msg, transfer) => self.postMessage(msg, transfer || []);
const state = (phase, label, extra = {}) => post({ type: "state", phase, label, ...extra });
let gpuF16 = false;
async function hasWebGPU() {
try {
if (!("gpu" in navigator)) return false;
const adapter = await navigator.gpu.requestAdapter();
gpuF16 = !!adapter?.features?.has("shader-f16");
return !!adapter;
} catch {
return false;
}
}
async function loadModel({ device = "auto", dtype = "q4", audio = false }) {
if (loaded && (device === "auto" || device === loaded.device) && dtype === loaded.requested && (!audio || loaded.audio)) {
return loaded;
}
if (model) {
try { await model.dispose(); } catch {}
model = null;
loaded = null;
}
let candidates;
if (device === "auto") candidates = (await hasWebGPU()) ? ["webgpu", "wasm"] : ["wasm"];
else candidates = [device];
if (!decision) {
try { decision = await (await fetch(`${env.localModelPath}${MODEL_ID}/decision.json`)).json(); } catch { decision = {}; }
}
if (!processor) {
state("loading", "Loading processor");
processor = await AutoProcessor.from_pretrained(MODEL_ID);
}
let lastErr = null;
const requested = dtype;
if (candidates.includes("webgpu") && device !== "auto") await hasWebGPU();
for (const dev of candidates) {
// Files shipped with this page (Space storage is limited to 1 GB):
// q4: 4-bit weights with plain 8-bit gathers for the embedding and position tables, so the same files
// run on WebGPU and on the WASM (CPU) runtime, which lacks GatherBlockQuantized;
// q4f16: 4-bit weights with fp16 math (WebGPU with shader-f16 only);
// audio: one 4-bit audio encoder used with both.
const d = dev === "wasm" ? "q4" : requested === "auto" ? (gpuF16 ? "q4f16" : "q4") : requested;
dtype = audio ? { model: d, vision_encoder: d, audio_encoder: "q4" } : d;
try {
const config = await AutoConfig.from_pretrained(MODEL_ID);
if (!audio) config.audio_config = null; // text + vision only (video uses the vision encoder)
state("loading", `Downloading model (${dev}${audio ? ", with audio encoder" : ""})`, { progress: 0, device: dev });
const t0 = performance.now();
model = await AutoModel.from_pretrained(MODEL_ID, {
config,
device: dev,
dtype,
progress_callback: (p) => {
if (p.status === "progress_total") state("loading", `Downloading model (${dev})`, { progress: p.progress, device: dev, loadedBytes: p.loaded, totalBytes: p.total });
},
});
const sessionMs = performance.now() - t0;
state("loading", "Warming up", { progress: 100, device: dev });
const tw = performance.now();
const warm = await processor(["task: classification | query: warm up"]);
const out = await model(warm);
out.sentence_embedding.dispose?.();
const warmMs = performance.now() - tw;
const label = typeof dtype === "string" ? dtype : Object.entries(dtype).map(([k, v]) => `${k.replace("_encoder", "")} ${v}`).join(", ");
loaded = { device: dev, dtype: label, requested, audio, loadMs: performance.now() - t0, sessionMs, warmMs, scale: decision?.scale || null };
state("ready", `Ready on ${dev.toUpperCase()} (${label})`, { device: dev, audio });
return loaded;
} catch (e) {
lastErr = e;
console.warn(`Loading on ${dev} failed`, e);
if (model) { try { await model.dispose(); } catch {} model = null; }
state("loading", `Loading on ${dev} failed, trying next backend`, { device: dev });
}
}
state("error", `Could not load the model: ${lastErr?.message || lastErr}`);
throw lastErr;
}
function rows(tensor) {
const [n, d] = tensor.dims;
const data = tensor.data;
const out = [];
for (let i = 0; i < n; i++) {
const v = new Float32Array(d);
let s = 0;
for (let j = 0; j < d; j++) { v[j] = data[i * d + j]; s += v[j] * v[j]; }
s = Math.sqrt(s) || 1;
for (let j = 0; j < d; j++) v[j] /= s;
out.push(v);
}
return out;
}
let lastModelMs = 0;
async function run(inputs) {
const tm = performance.now();
const out = await model(inputs);
lastModelMs = performance.now() - tm;
const emb = rows(out.sentence_embedding);
try { out.sentence_embedding.dispose?.(); } catch {}
return emb;
}
// texts: array of strings, embedded in small batches (right padding; padded tokens are masked out)
async function embedTexts(texts) {
const res = [];
const B = 8;
for (let i = 0; i < texts.length; i += B) {
const batch = texts.slice(i, i + B);
res.push(...(await run(await processor(batch))));
}
return res;
}
// One input that may interleave text with an image, audio clip or video.
// If the text already holds a placeholder (<|image|>, <|audio|>, <|video|>) it is used as is;
// otherwise the placeholder is put first, as in the trained prompt format. Empty text embeds the media alone.
async function embedInput(input) {
const { kind } = input;
const text = (input.text || "").trim();
const withPh = (ph) => (text.includes(ph) ? text : `${ph}${text}`);
if (kind === "text" || !kind) {
return (await run(await processor([text])))[0];
}
if (kind === "image" || kind === "rgba") {
if (processor.image_processor) processor.image_processor.max_soft_tokens = input.budget || 280;
const img = kind === "rgba"
? new RawImage(new Uint8ClampedArray(input.data), input.width, input.height, 4)
: await RawImage.fromBlob(input.blob);
if (!text) return (await run(await processor(null, img)))[0];
return (await run(await processor([withPh("<|image|>")], [[img]])))[0];
}
if (kind === "audio") {
if (!loaded?.audio) throw new Error("The audio encoder is not loaded.");
const audio = input.audio instanceof Float32Array ? input.audio : new Float32Array(input.audio);
if (!text) return (await run(await processor(null, null, audio)))[0];
return (await run(await processor([withPh("<|audio|>")], null, [audio])))[0];
}
if (kind === "video") {
if (processor.image_processor) processor.image_processor.max_soft_tokens = input.budget || 280;
const frames = input.frames.map((f) => new RawVideoFrame(new RawImage(new Uint8ClampedArray(f.data), f.width, f.height, 4), f.timestamp));
const video = new RawVideo(frames, input.duration);
if (!text) return (await run(await processor(null, null, null, video)))[0];
return (await run(await processor([withPh("<|video|>")], null, null, [video])))[0];
}
throw new Error(`Unknown input kind: ${kind}`);
}
async function handle(msg) {
const { id, op } = msg;
try {
if (op === "load") {
const info = await loadModel(msg.options || {});
post({ type: "result", id, result: info });
return;
}
if (!model) throw new Error("Model is not loaded yet.");
const t0 = performance.now();
if (op === "embedTexts") {
const embs = await embedTexts(msg.texts);
post({ type: "result", id, result: { embeddings: embs, ms: performance.now() - t0 } }, embs.map((e) => e.buffer));
} else if (op === "embedInput") {
const emb = await embedInput(msg.input);
post({ type: "result", id, result: { embedding: emb, ms: performance.now() - t0, modelMs: lastModelMs } }, [emb.buffer]);
} else if (op === "embedInputs") {
// several prompts over the same media (one pass each); used by live cards in the trained format
const embs = [];
for (const inp of msg.inputs) embs.push(await embedInput(inp));
post({ type: "result", id, result: { embeddings: embs, ms: performance.now() - t0 } }, embs.map((e) => e.buffer));
} else {
throw new Error(`Unknown op ${op}`);
}
} catch (e) {
post({ type: "error", id, message: e?.message || String(e) });
}
}
self.addEventListener("message", ({ data }) => {
// serialize all requests: one model, one queue
queue = queue.then(() => handle(data));
});