tlcr-demo / worker.js
lsb's picture
Update worker.js
15ab6d3 verified
Raw
History Blame Contribute Delete
8.14 kB
// Runs SmolLM2-360M-Instruct (q4f16, ~273 MB) on the WASM backend, off the main thread.
import * as tf from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3";
const { pipeline, TextStreamer, InterruptableStoppingCriteria } = tf;
const MODEL_ID = "HuggingFaceTB/SmolLM2-135M-Instruct";
let generator = null;
self.onmessage = async (e) => {
const msg = e.data;
try {
if (msg.type === "load") {
const progress_callback = (data) => {
// Cache hits send non-cloneable payloads sometimes; keep it plain.
const { status, file, progress, loaded, total } = data;
self.postMessage({ type: "progress", data: { status, file, progress, loaded, total } });
};
// Prefer WebGPU when the adapter supports fp16 (q4f16 needs it);
// fall back to WASM otherwise or if WebGPU init fails.
let device = "wasm";
try {
const adapter = await self.navigator?.gpu?.requestAdapter?.();
if (adapter?.features?.has("shader-f16")) device = "webgpu";
} catch {}
try {
generator = await pipeline("text-generation", MODEL_ID, {
dtype: "q4f16",
device,
progress_callback,
});
} catch (err) {
if (device === "wasm") throw err;
device = "wasm";
generator = await pipeline("text-generation", MODEL_ID, {
dtype: "q4f16",
device,
progress_callback,
});
}
self.postMessage({ type: "ready", device });
} else if (msg.type === "condense") {
const line = await condensePair(msg.pair, msg.before, msg.after);
self.postMessage({ type: "result", id: msg.id, line });
} else if (msg.type === "expand") {
const lines = await expandLine(msg.line, msg.before, msg.after);
self.postMessage({ type: "result", id: msg.id, lines });
}
} catch (err) {
self.postMessage({ type: "error", id: msg.id, message: err.message ?? String(err) });
}
};
// Apply the chat template ourselves, then append `prefill` so the model's first
// generated token is already inside the JSON value β€” it cannot add a preamble.
// `stopPattern` interrupts generation as soon as the JSON value is closed.
async function generateConstrained(messages, prefill, stopPattern, maxNewTokens) {
const tokenizer = generator.tokenizer;
const prompt =
tokenizer.apply_chat_template(messages, { tokenize: false, add_generation_prompt: true }) +
prefill;
let acc = "";
const opts = {
max_new_tokens: maxNewTokens,
do_sample: true,
temperature: 0.7,
top_p: 0.9,
repetition_penalty: 1.15,
no_repeat_ngram_size: 3,
return_full_text: false,
};
if (typeof InterruptableStoppingCriteria === "function" && typeof TextStreamer === "function") {
const stopper = new InterruptableStoppingCriteria();
opts.stopping_criteria = stopper;
opts.streamer = new TextStreamer(tokenizer, {
skip_prompt: true,
skip_special_tokens: true,
callback_function: (text) => {
acc += text;
if (stopPattern.test(acc)) stopper.interrupt();
},
});
}
const out = await generator(prompt, opts);
return out[0].generated_text;
}
function unescapeJson(s) {
try {
return JSON.parse('"' + s + '"');
} catch {
return s;
}
}
function tidy(s) {
return s
.replace(/\s+/g, " ")
// JSON debris the parse fallback can drag along, e.g. a trailing '} or "]}.
.replace(/["'’\s]*[\]}]+["'’\s]*$/, "")
.trim();
}
// If the model looped and never closed the quote, the parse fallback swallows
// the whole ramble. Cut an overlong line back at the seam nearest the budget.
function clampLine(line, budget) {
if (line.length <= budget) return line;
const seams = [...line.matchAll(/[,;.—–]\s+|\s+(?:and|but|or|while|when|so)\s+/g)]
.filter((m) => m.index > 0 && m.index <= budget);
if (seams.length) return line.slice(0, seams.at(-1).index).trim();
const cut = line.lastIndexOf(" ", budget);
return line.slice(0, cut > 0 ? cut : budget).trim();
}
function contextNote(before, after) {
let note = "";
if (before?.length) note += `\nFor context, the lines just before them:\n${before.join("\n")}\n`;
if (after?.length) note += `\nFor context, the lines just after them:\n${after.join("\n")}\n`;
return note;
}
// A literal placeholder like {"line": "your new line here"} gets parroted
// verbatim by a 360M model, so the format is taught with a real few-shot
// exchange instead β€” the condense and expand examples are inverses.
const EXAMPLE_TWO = ["the rain fell all night on the tin roof", "and we listened without speaking"];
const EXAMPLE_ONE = "all night we listened to the rain speak on the tin roof";
async function condensePair([a, b], before, after) {
const messages = [
{
role: "system",
content:
"You are a poet who condenses poems. You reply with exactly one JSON object and nothing else.",
},
{
role: "user",
content: `Two consecutive lines of a poem:
Line A: ${EXAMPLE_TWO[0]}
Line B: ${EXAMPLE_TWO[1]}
Combine Line A and Line B into ONE short poetic line that keeps their imagery, meaning, and voice. Reply with JSON only.`,
},
{
role: "assistant",
content: `{"line": "${EXAMPLE_ONE}"}`,
},
{
role: "user",
content: `Two consecutive lines of a poem:
Line A: ${a}
Line B: ${b}
${contextNote(before, after)}
Combine Line A and Line B into ONE short poetic line that keeps their imagery, meaning, and voice. Reply with JSON only.`,
},
];
// Generation continues from `{"line": "` β€” capture up to the first unescaped quote.
const raw = await generateConstrained(messages, '{"line": "', /(?:^|[^\\])"/, 60);
const m = raw.match(/^((?:[^"\\]|\\.)*)"/s);
const line = clampLine(
tidy(unescapeJson(m ? m[1] : raw.split("\n")[0])),
Math.max(50, a.length + b.length),
);
// A condensation that vanished is worse than no condensation: keep the originals.
return line || tidy(`${a} ${b}`);
}
// Split one string into two lines at a natural seam (for fallback parsing).
function splitInTwo(s) {
const mid = s.length / 2;
const seams = [...s.matchAll(/[,;—–]\s+|\s+(?:and|but|or|while|when|so)\s+/g)];
if (seams.length) {
const best = seams.reduce((p, c) => (Math.abs(c.index - mid) < Math.abs(p.index - mid) ? c : p));
return [s.slice(0, best.index + (best[0].startsWith(" ") ? 0 : 1)).trim(), s.slice(best.index + best[0].length).trim()];
}
const words = s.split(" ");
const cut = Math.ceil(words.length / 2);
return [words.slice(0, cut).join(" "), words.slice(cut).join(" ")];
}
async function expandLine(line, before, after) {
const messages = [
{
role: "system",
content:
"You are a poet who elaborates poems. You reply with exactly one JSON object and nothing else.",
},
{
role: "user",
content: `One line of a poem:
Line: ${EXAMPLE_ONE}
Rewrite this single line as TWO short poetic lines that together carry the same meaning, imagery, and voice. Reply with JSON only.`,
},
{
role: "assistant",
content: `{"lines": ["${EXAMPLE_TWO[0]}", "${EXAMPLE_TWO[1]}"]}`,
},
{
role: "user",
content: `One line of a poem:
Line: ${line}
${contextNote(before, after)}
Rewrite this single line as TWO short poetic lines that together carry the same meaning, imagery, and voice. Reply with JSON only.`,
},
];
// Generation continues from `{"lines": ["` β€” stop once the array closes.
const raw = await generateConstrained(messages, '{"lines": ["', /"\s*\]/, 90);
const two = raw.match(/^((?:[^"\\]|\\.)*)"\s*,\s*"((?:[^"\\]|\\.)*)/s);
const budget = Math.max(60, line.length * 2);
if (two) {
const first = clampLine(tidy(unescapeJson(two[1])), budget);
const second = clampLine(tidy(unescapeJson(two[2])), budget);
if (first && second) return [first, second];
}
const one = raw.match(/^((?:[^"\\]|\\.)*)"/s);
const text = clampLine(tidy(unescapeJson(one ? one[1] : raw.split("\n")[0])), budget * 2);
if (!text) return [tidy(line)];
return splitInTwo(text);
}