File size: 6,516 Bytes
559972f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import { tokenizer, storyPrompt } from "./tokenizer.js";

const RUNTIME = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.30.0/dist/";
// Pin the model commit so a later upload cannot silently change this demo.
const MODEL = "https://huggingface.co/GeorgeCodesSometimes/sprout-instruct/resolve/ecb41dc46edcfb540d13ad2fe3e72bd863aca25a/browser/";
let ort, session, tok, config;
let stopped = false;
let busy = false;
const send = (type, data = {}) => self.postMessage({ type, ...data });

async function response(name) {
  const result = await fetch(MODEL + name);
  if (!result.ok) throw new Error(`Model download failed (${result.status}). Check your connection and try again.`);
  return result;
}

async function load(onlyTokenizer = false) {
  if (session) return;
  if (!tok) {
    send("status", { text: "Downloading model settings…" });
    config = await (await response("config.json")).json();
    const source = await (await response("tokenizer.model")).text();
    const tokenHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source));
    const checksum = Array.from(new Uint8Array(tokenHash), b => b.toString(16).padStart(2, "0")).join("");
    if (checksum !== config.sha256["tokenizer.model"]) throw new Error("Tokenizer checksum failed. Reload and try again.");
    const parsed = tokenizer(source);
    if (parsed.vocab.length + 4 !== config.model.vocab ||
        JSON.stringify(parsed.special) !== JSON.stringify(config.special_tokens)) {
      throw new Error("Model and tokenizer do not match. Reload the page and try again.");
    }
    tok = parsed;
  }
  if (onlyTokenizer) return;
  let storage;
  try { storage = await caches.open("sprout-browser-v1"); } catch { /* Cache is optional. */ }
  let file = await storage?.match(MODEL + "model.onnx");
  if (!file) {
    file = await response("model.onnx");
    if (storage) storage.put(MODEL + "model.onnx", file.clone()).catch(() => {});
  }
  const bytes = new Uint8Array(config.browser_bytes);
  const reader = file.body.getReader();
  let offset = 0;
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    if (offset + value.length > bytes.length) throw new Error("Model download has an unexpected size. Reload and try again.");
    bytes.set(value, offset);
    offset += value.length;
    send("progress", { value: offset / bytes.length });
  }
  if (offset !== bytes.length) throw new Error("Model download was interrupted. Reload and try again.");
  send("status", { text: "Checking weights and starting the model…" });
  const hash = await crypto.subtle.digest("SHA-256", bytes);
  const checksum = Array.from(new Uint8Array(hash), b => b.toString(16).padStart(2, "0")).join("");
  if (checksum !== config.browser_sha256) {
    await storage?.delete(MODEL + "model.onnx");
    throw new Error("Model checksum failed. Reload to download a fresh copy.");
  }
  ort = await import(RUNTIME + "ort.wasm.min.mjs");
  // One worker thread also works on hosts without cross-origin isolation.
  ort.env.wasm.numThreads = 1;
  ort.env.wasm.wasmPaths = RUNTIME;
  session = await ort.InferenceSession.create(bytes, { executionProviders: ["wasm"] });
  send("loaded");
}

function sample(logits, temperature) {
  if (temperature === 0) {
    let best = 0;
    for (let i = 1; i < logits.length; i++) if (logits[i] > logits[best]) best = i;
    return best;
  }
  const sorted = Array.from(logits, (value, id) => ({ value, id })).sort((a, b) => b.value - a.value);
  const cutoff = sorted[49].value;
  const candidates = sorted.filter(item => item.value >= cutoff);
  const weights = candidates.map(item => Math.exp((item.value - sorted[0].value) / temperature));
  let remaining = Math.random() * weights.reduce((a, b) => a + b, 0);
  for (let i = 0; i < weights.length; i++) {
    remaining -= weights[i];
    if (remaining < 0) return candidates[i].id;
  }
  return candidates.at(-1).id;
}

async function generate({ fields, temperature, maxTokens }) {
  // Reject invalid instructions before starting the large download.
  const prefix = storyPrompt(fields);
  if (!Number.isFinite(temperature) || temperature < 0 || temperature > 2 ||
      !Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > 512) {
    throw new Error("Choose a valid temperature and story length.");
  }
  await load(true);
  let ids = tok.encode(prefix);
  const { n_layer: layers, n_head: heads, dim, ctx } = config.model;
  if (ids.length > ctx - 32) throw new Error("Your instructions leave too little room for a story. Shorten them and try again.");
  await load();
  if (stopped) { send("done", { reason: "Stopped", tokens: 0 }); return; }
  const limit = Math.min(maxTokens, ctx - ids.length + 1);
  let cache = new ort.Tensor("float32", new Float32Array(0), [layers, 2, 1, heads, 0, dim / heads]);
  const decoder = new TextDecoder();
  let text = "";
  let count = 0;
  let reason = "Length limit reached";
  const started = performance.now();
  send("status", { text: "Writing your story…" });
  try {
    for (let i = 0; i < limit; i++) {
      if (stopped) { reason = "Stopped"; break; }
      const tokens = new ort.Tensor("int64", BigInt64Array.from(ids, BigInt), [1, ids.length]);
      let output;
      try { output = await session.run({ tokens, cache }); }
      finally { tokens.dispose(); }
      cache.dispose();
      cache = output.present;
      const next = sample(output.logits.data, temperature);
      output.logits.dispose();
      count++;
      if (next >= tok.vocab.length) {
        reason = next === tok.special["<|end|>"] ? "Story finished" : "Model emitted a reserved token";
        break;
      }
      text += decoder.decode(tok.vocab[next], { stream: true });
      send("text", { text, tokens: count });
      ids = [next];
      // Yield between steps so stop messages are processed even during CPU inference.
      await new Promise(resolve => setTimeout(resolve, 0));
    }
    text += decoder.decode();
    send("text", { text, tokens: count });
    send("done", { reason, tokens: count, seconds: (performance.now() - started) / 1000 });
  } finally { cache.dispose(); }
}

self.onmessage = async ({ data }) => {
  if (data.type === "stop") { stopped = true; return; }
  if (data.type !== "generate" || busy) return;
  busy = true;
  stopped = false;
  try { await generate(data); }
  catch (error) {
    console.error(error);
    send("error", { text: error.message || "The model could not start. Reload and try again." });
  } finally { busy = false; }
};