| |
| 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) => { |
| |
| const { status, file, progress, loaded, total } = data; |
| self.postMessage({ type: "progress", data: { status, file, progress, loaded, total } }); |
| }; |
| |
| |
| 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) }); |
| } |
| }; |
|
|
| |
| |
| |
| 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, " ") |
| |
| .replace(/["'β\s]*[\]}]+["'β\s]*$/, "") |
| .trim(); |
| } |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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.`, |
| }, |
| ]; |
|
|
| |
| 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), |
| ); |
| |
| return line || tidy(`${a} ${b}`); |
| } |
|
|
| |
| 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.`, |
| }, |
| ]; |
|
|
| |
| 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); |
| } |
|
|