runonweb 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -106,6 +106,34 @@ cosineSimilarity(embeddings[0], embeddings[1])
106
106
 
107
107
  Base: all-MiniLM-L6-v2 (Apache-2.0). 384 dims, normalized.
108
108
 
109
+ ### Text classification: `runonweb/classify`
110
+
111
+ ```ts
112
+ import { Classifier } from 'runonweb/classify'
113
+
114
+ const classifier = new Classifier() // Kev-0.8B, ~750 MB, WebGPU or WASM
115
+ // new Classifier({ size: 'large' }) // Kev-4B, ~2.7 GB, about 4× slower, more accurate
116
+ const { answers } = await classifier.classify({
117
+ state: 'Shoes arrived two weeks late and in the wrong size.',
118
+ questions: {
119
+ department: { type: 'choice', instructions: 'Which team should handle this?',
120
+ criteria: { returns: null, shipping: null, billing: null } },
121
+ escalate: { type: 'noul', instructions: 'Does this need urgent human attention?' },
122
+ frustration: { type: 'score', instructions: 'How frustrated is the customer?',
123
+ criteria: ['Calm', 'Frustrated', 'Very angry'] },
124
+ },
125
+ })
126
+ answers.department // { type: 'choice', choice: 'returns', confidence, probabilities: { returns: 0.50, … } }
127
+ answers.escalate // { type: 'noul', noul: 0.51 } p(yes)
128
+ answers.frustration // { type: 'score', score: 1.21, confidence, legend, probabilities }
129
+
130
+ await classifier.classify(request, { dateFacts: true }) // adds day counts between dates in the state
131
+ ```
132
+
133
+ Base: [Kev-0.8B](https://huggingface.co/jaredpalmer/kev-0.8b) by Jared Palmer (Apache-2.0), a Jev-style decision model: a LoRA and a pointer head on Qwen3.5-0.8B-Base (Apache-2.0). Requests and answers are TypeSafe's System One shapes (`noul` / `choice` / `score`), so the same JSON works against a Kev or Jev server; `noul()`, `choice()` and `score()` build questions like the Python SDK does. Nothing is generated: the text is read once, each question runs as its own row on the text's cache and a pointer head turns it into one probability per option, calibrated with the checkpoint's temperature (`temperature: 1` for raw logits).
134
+
135
+ runonweb merged the LoRA, exported the backbone with the onnxruntime-genai builder (Gated DeltaNet as `LinearAttention` ops) and quantized it to int4 (Kev-0.8B keeps the DeltaNet layers in int8) (`training/kev-onnx`). WebGPU runs on `onnxruntime-web/webgpu` (the JSEP build only runs 2/4-bit `MatMulNBits`), WASM on `onnxruntime-web/wasm` (the WebGPU build has no CPU `GatherBlockQuantized`). Probabilities differ from the fp32 export by ~0.03 (small) and ~0.04 (large) on average. On sources Kev wasn't trained on, Kev-0.8B scores 0.70 and Kev-4B 0.84 (Jev 0.86); test on your own data.
136
+
109
137
  ### Translation: `runonweb/translate`
110
138
 
111
139
  ```ts
@@ -124,7 +152,7 @@ const multi = new Translator({ model: 'Xenova/m2m100_418M', from: 'fr', to: 'en'
124
152
 
125
153
  Base: Mozilla's [Firefox Translations](https://github.com/mozilla/translations) models (MPL-2.0), the same Marian NMT students Firefox ships. 17–44 MB per pair (int8), 106 direct pairs between English and 58 languages; other pairs pivot through English. They run in a Web Worker through the Bergamot WASM runtime (no COOP/COEP headers needed) and are cached in Cache Storage. `PAIRS` lists every pair with architecture, size and COMET score.
126
154
 
127
- Weights come from the runonweb mirror on the Hugging Face Hub by default. To self-host, run `node scripts/translate-models.mjs fetch en-es es-en` (or `--all`) and pass `modelPath` pointing at wherever you upload that folder; the script also copies the runtime so you can pass `runtimePath: '<modelPath>/runtime/'` instead of loading it from jsDelivr. The registry is regenerated from Mozilla's model list with `node scripts/translate-models.mjs registry`.
155
+ Weights come from the runonweb mirror on the Hugging Face Hub ([`midudev/firefox-translations`](https://huggingface.co/midudev/firefox-translations)) by default. To self-host, run `node scripts/translate-models.mjs fetch en-es es-en` (or `--all`) and pass `modelPath` pointing at wherever you upload that folder; the script also copies the runtime so you can pass `runtimePath: '<modelPath>/runtime/'` instead of loading it from jsDelivr. The registry is regenerated from Mozilla's model list with `node scripts/translate-models.mjs registry`.
128
156
 
129
157
  ### Text-to-speech: `runonweb/tts`
130
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runonweb",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Free browser ML modules: speech, vision, text. Runs locally with WebGPU or WASM.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,6 +45,16 @@
45
45
  "import": "./src/ocr/sizes.ts",
46
46
  "default": "./src/ocr/sizes.ts"
47
47
  },
48
+ "./classify": {
49
+ "types": "./src/classify/index.ts",
50
+ "import": "./src/classify/index.ts",
51
+ "default": "./src/classify/index.ts"
52
+ },
53
+ "./classify/sizes": {
54
+ "types": "./src/classify/sizes.ts",
55
+ "import": "./src/classify/sizes.ts",
56
+ "default": "./src/classify/sizes.ts"
57
+ },
48
58
  "./embed": {
49
59
  "types": "./src/embed/index.ts",
50
60
  "import": "./src/embed/index.ts",
@@ -114,6 +124,9 @@
114
124
  "background-removal",
115
125
  "ocr",
116
126
  "embeddings",
127
+ "classification",
128
+ "zero-shot",
129
+ "kev",
117
130
  "translation",
118
131
  "tts",
119
132
  "emoji",
@@ -0,0 +1,583 @@
1
+ import type { Device, ProgressCallback, ProgressInfo, ResolvedDevice } from '../core/index.ts'
2
+ import { resolveDevice } from '../core/device.ts'
3
+ import { CACHE_NAME } from '../core/cache.ts'
4
+ import {
5
+ toAnswers,
6
+ toRecord,
7
+ validateRequest,
8
+ withDateFacts,
9
+ type Answers,
10
+ type ClassifyRequest,
11
+ type DecisionRecord,
12
+ type Question,
13
+ } from './request.ts'
14
+
15
+ import { CLASSIFY_SIZES, DEFAULT_CLASSIFY_SIZE, type ClassifySize } from './sizes.ts'
16
+
17
+ export * from './request.ts'
18
+ export { CLASSIFY_SIZES, DEFAULT_CLASSIFY_SIZE, type ClassifySize } from './sizes.ts'
19
+
20
+ /**
21
+ * Default weights: Kev-0.8B (Jared Palmer, Apache-2.0), a Jev-style decision model on Qwen3.5-0.8B-Base,
22
+ * with its LoRA merged in and exported to ONNX by runonweb (`training/kev-onnx`). int4 weights with the
23
+ * Gated DeltaNet layers in int8, fp32 activations: ~735 MB, the same file on WebGPU and WASM.
24
+ * `size: 'large'` loads Kev-4B instead.
25
+ */
26
+ export const DEFAULT_MODEL = CLASSIFY_SIZES[DEFAULT_CLASSIFY_SIZE].model
27
+
28
+ const ORT_WASM = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.30.0/dist/'
29
+
30
+ export type ClassifyOptions = {
31
+ /** `small` = Kev-0.8B (default), `large` = Kev-4B: more accurate, several times the download and latency. */
32
+ size?: ClassifySize
33
+ /** Hugging Face repo id, or folder name under `modelPath`. Overrides `size`. */
34
+ model?: string
35
+ /**
36
+ * Base URL the weights are served from, e.g. `/models/`: files are read from `<modelPath>/<model>/`.
37
+ * Omit to download `model` from the Hugging Face Hub.
38
+ */
39
+ modelPath?: string
40
+ device?: Device
41
+ /**
42
+ * Append the day count between every pair of absolute dates found in the state
43
+ * ("June 26, 2026 is 8 days before July 4, 2026"). Kev can't subtract dates; it can use a stated count.
44
+ * Same as Kev's `KEV_DATE_FACTS=1`. Default false.
45
+ */
46
+ dateFacts?: boolean
47
+ /**
48
+ * Softmax temperature. Defaults to the one fitted for the checkpoint (~2.35 for Kev-0.8B), which
49
+ * calibrates probabilities without changing any answer. `1` gives the raw logits.
50
+ */
51
+ temperature?: number
52
+ onProgress?: ProgressCallback
53
+ }
54
+
55
+ /** Per-call overrides of the constructor options. */
56
+ export type ClassifyRunOptions = Pick<ClassifyOptions, 'dateFacts' | 'temperature'>
57
+
58
+ export type ClassifyResult<Q extends Record<string, Question> = Record<string, Question>> = {
59
+ model: string
60
+ answers: Answers<Q>
61
+ usage: {
62
+ /** State tokens (counted once) plus every question's tokens. */
63
+ inputTokens: number
64
+ }
65
+ latencyMs: number
66
+ }
67
+
68
+ /** Layout of `kev.json`, written by `training/kev-onnx/assemble.py`. */
69
+ type KevConfig = {
70
+ name: string
71
+ temperature: number
72
+ hidden_size: number
73
+ head_dim: number
74
+ head_file: string
75
+ /** `data` is one external-data file or several (big models are split under 2 GB per file). */
76
+ onnx: { model: string; data: string | string[] }
77
+ tokens: { state: number; question: number; option: number; option_end: number; decide: number }
78
+ max_state_tokens: number
79
+ max_row_tokens: number
80
+ cache: {
81
+ layer_types: ('linear_attention' | 'full_attention')[]
82
+ num_key_value_heads: number
83
+ attention_head_dim: number
84
+ conv_dim: number
85
+ conv_kernel: number
86
+ linear_heads: number
87
+ linear_key_dim: number
88
+ linear_value_dim: number
89
+ }
90
+ }
91
+
92
+ type Ort = typeof import('onnxruntime-web/webgpu')
93
+ type OrtTensor = import('onnxruntime-web').Tensor
94
+ type OrtSession = import('onnxruntime-web').InferenceSession
95
+ type Tokenizer = { encode: (text: string, opts?: { add_special_tokens?: boolean }) => number[] }
96
+
97
+ type Encoded = { state: number[]; rows: { ids: number[]; decide: number; options: number[] }[] }
98
+
99
+ type Runtime = {
100
+ ort: Ort
101
+ session: OrtSession
102
+ tokenizer: Tokenizer
103
+ config: KevConfig
104
+ head: { qW: Float32Array; qB: Float32Array; kW: Float32Array; kB: Float32Array }
105
+ /** Cache inputs for an empty prefix (zero conv/recurrent states, zero-length KV). */
106
+ empty: Record<string, OrtTensor>
107
+ /** present.* output name → past.* input name. */
108
+ pastFor: Map<string, string>
109
+ device: ResolvedDevice
110
+ }
111
+
112
+ /**
113
+ * Typed questions about a text, answered with calibrated probabilities: yes/no (`noul`),
114
+ * multiple choice (`choice`) and ratings (`score`), all in one request. No text is generated.
115
+ *
116
+ * Runs Kev (github.com/jaredpalmer/kev) in the browser. Requests and answers follow TypeSafe's
117
+ * System One API (`POST /v1/systemone`), so the same JSON works against a Kev or Jev server.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * import { Classifier } from 'runonweb/classify'
122
+ *
123
+ * const classifier = new Classifier()
124
+ * await classifier.load()
125
+ *
126
+ * const { answers } = await classifier.classify({
127
+ * state: 'Shoes arrived two weeks late and in the wrong size.',
128
+ * questions: {
129
+ * department: { type: 'choice', instructions: 'Which team should handle this?',
130
+ * criteria: { returns: null, shipping: null, billing: null } },
131
+ * escalate: { type: 'noul', instructions: 'Does this need urgent human attention?' },
132
+ * frustration: { type: 'score', instructions: 'How frustrated is the customer?',
133
+ * criteria: ['Calm', 'Frustrated', 'Very angry'] },
134
+ * },
135
+ * })
136
+ * answers.department.choice // 'returns'
137
+ * answers.escalate.noul // p(yes)
138
+ * ```
139
+ */
140
+ export class Classifier {
141
+ #size: ClassifySize
142
+ #model: string
143
+ #modelPath?: string
144
+ #device: Device
145
+ #dateFacts: boolean
146
+ #temperature?: number
147
+ #onProgress?: ProgressCallback
148
+ #rt: Runtime | null = null
149
+ #loading: Promise<void> | null = null
150
+ #queue: Promise<unknown> = Promise.resolve()
151
+
152
+ constructor(options: ClassifyOptions = {}) {
153
+ this.#size = options.size ?? DEFAULT_CLASSIFY_SIZE
154
+ this.#model = options.model ?? CLASSIFY_SIZES[this.#size].model
155
+ this.#modelPath = options.modelPath
156
+ this.#device = options.device ?? 'auto'
157
+ this.#dateFacts = options.dateFacts ?? false
158
+ this.#temperature = options.temperature
159
+ this.#onProgress = options.onProgress
160
+ }
161
+
162
+ get size(): ClassifySize {
163
+ return this.#size
164
+ }
165
+
166
+ get device(): ResolvedDevice | null {
167
+ return this.#rt?.device ?? null
168
+ }
169
+
170
+ /** Calibration temperature in use (the checkpoint's unless overridden). */
171
+ get temperature(): number | null {
172
+ return this.#temperature ?? this.#rt?.config.temperature ?? null
173
+ }
174
+
175
+ async load(): Promise<void> {
176
+ if (this.#rt) return
177
+ if (this.#loading) return this.#loading
178
+ this.#loading = (async () => {
179
+ this.#rt = await loadRuntime(this.#baseUrl(), await resolveDevice(this.#device), this.#onProgress)
180
+ })()
181
+ try {
182
+ await this.#loading
183
+ } finally {
184
+ this.#loading = null
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Answer every question about `state`. Each question only sees the state and itself;
190
+ * the state is read once and reused for every question.
191
+ */
192
+ async classify<const Q extends Record<string, Question>>(
193
+ request: ClassifyRequest<Q>,
194
+ options: ClassifyRunOptions = {}
195
+ ): Promise<ClassifyResult<Q>> {
196
+ validateRequest(request)
197
+ await this.load()
198
+ const run = this.#queue.then(() => this.#classify(request, options))
199
+ this.#queue = run.catch(() => undefined)
200
+ return run
201
+ }
202
+
203
+ async #classify<Q extends Record<string, Question>>(
204
+ request: ClassifyRequest<Q>,
205
+ options: ClassifyRunOptions
206
+ ): Promise<ClassifyResult<Q>> {
207
+ const rt = this.#rt
208
+ if (!rt) throw new Error('Classifier was disposed')
209
+ const t0 = performance.now()
210
+ this.#onProgress?.({ status: 'classifying' })
211
+
212
+ const dates = options.dateFacts ?? this.#dateFacts
213
+ const req = dates ? { ...request, state: withDateFacts(request.state) } : request
214
+ const { record, meta } = toRecord(req)
215
+ const enc = encode(rt, record)
216
+ const probs = await scoreRows(rt, enc, options.temperature ?? this.#temperature ?? rt.config.temperature)
217
+
218
+ this.#onProgress?.({ status: 'done' })
219
+ return {
220
+ model: rt.config.name,
221
+ answers: toAnswers(probs, meta) as Answers<Q>,
222
+ usage: { inputTokens: enc.state.length + enc.rows.reduce((n, r) => n + r.ids.length, 0) },
223
+ latencyMs: Math.round(performance.now() - t0),
224
+ }
225
+ }
226
+
227
+ dispose(): void {
228
+ const rt = this.#rt
229
+ this.#rt = null
230
+ void this.#queue.finally(() => rt?.session.release())
231
+ }
232
+
233
+ #baseUrl(): string {
234
+ if (this.#modelPath) {
235
+ const base = new URL(this.#modelPath.replace(/\/?$/, '/'), globalThis.location?.href).href
236
+ return `${base}${this.#model}/`
237
+ }
238
+ return `https://huggingface.co/${this.#model}/resolve/main/`
239
+ }
240
+ }
241
+
242
+ /** One-shot helper: load, classify, dispose. Keep a `Classifier` around for more than one call. */
243
+ export async function classify<const Q extends Record<string, Question>>(
244
+ request: ClassifyRequest<Q>,
245
+ options?: ClassifyOptions
246
+ ): Promise<ClassifyResult<Q>> {
247
+ const classifier = new Classifier(options)
248
+ try {
249
+ return await classifier.classify(request)
250
+ } finally {
251
+ classifier.dispose()
252
+ }
253
+ }
254
+
255
+ export type { ProgressInfo, Device }
256
+
257
+ // ---------------------------------------------------------------------------------------------------------------
258
+ // Encoding (kev/model.py `encode`, row form)
259
+
260
+ /** Caller text can never produce delimiter tokens: `<|name|>` is rewritten to `<¦name¦>` before tokenizing. */
261
+ function userTokens(tok: Tokenizer, text: string): number[] {
262
+ return tok.encode(text.replace(/<\|([A-Za-z0-9_]+)\|>/g, '<¦$1¦>'), { add_special_tokens: false })
263
+ }
264
+
265
+ /**
266
+ * `[<state> …state…]`, then per question one row that continues the state:
267
+ * `<q> instructions <opt> option </opt> … <decide>`. Delimiters are rarely used Qwen special tokens.
268
+ */
269
+ function encode(rt: Runtime, record: DecisionRecord): Encoded {
270
+ const { tokens: T, max_state_tokens, max_row_tokens } = rt.config
271
+ const state = [T.state, ...userTokens(rt.tokenizer, record.state)]
272
+ if (state.length > max_state_tokens) {
273
+ throw new Error(`State is ${state.length} tokens; the limit is ${max_state_tokens} (Kev was trained on up to 384)`)
274
+ }
275
+ const rows = record.questions.map((q) => {
276
+ const ids = [T.question, ...userTokens(rt.tokenizer, q.instr)]
277
+ const options: number[] = []
278
+ for (const o of q.options) {
279
+ ids.push(T.option, ...userTokens(rt.tokenizer, o), T.option_end)
280
+ options.push(ids.length - 1)
281
+ }
282
+ ids.push(T.decide)
283
+ if (state.length + ids.length > max_row_tokens) {
284
+ throw new Error(`State plus one question is ${state.length + ids.length} tokens; the limit is ${max_row_tokens}`)
285
+ }
286
+ return { ids, decide: ids.length - 1, options }
287
+ })
288
+ return { state, rows }
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------------------------------------------
292
+ // Inference
293
+
294
+ /**
295
+ * onnxruntime-web's WebGPU backend keeps a single global OrtRun slot; overlapping runs from any session
296
+ * fail with "Session already started". Every run in this module goes through one queue.
297
+ */
298
+ let ortTurn: Promise<void> = Promise.resolve()
299
+
300
+ function takeTurn<T>(fn: () => Promise<T>): Promise<T> {
301
+ const run = ortTurn.then(fn, fn)
302
+ ortTurn = run.then(
303
+ () => undefined,
304
+ () => undefined
305
+ )
306
+ return run
307
+ }
308
+
309
+ function int64(values: ArrayLike<number>, dims: number[], ort: Ort): OrtTensor {
310
+ const data = new BigInt64Array(values.length)
311
+ for (let i = 0; i < values.length; i++) data[i] = BigInt(values[i]!)
312
+ return new ort.Tensor('int64', data, dims)
313
+ }
314
+
315
+ /** input_ids, attention_mask and the 3-axis (M-RoPE) position ids for `ids` continuing `start` cached tokens. */
316
+ function tokenFeeds(ort: Ort, ids: number[], start: number): Record<string, OrtTensor> {
317
+ const L = ids.length
318
+ const pos = new Array<number>(3 * L)
319
+ for (let a = 0; a < 3; a++) for (let i = 0; i < L; i++) pos[a * L + i] = start + i
320
+ return {
321
+ input_ids: int64(ids, [1, L], ort),
322
+ attention_mask: int64(new Array<number>(start + L).fill(1), [1, start + L], ort),
323
+ position_ids: int64(pos, [3, 1, L], ort),
324
+ }
325
+ }
326
+
327
+ /** Pointer head: `<decide>` vs each `</opt>`, `(k · q) / sqrt(d) / T`, softmax. */
328
+ function readout(rt: Runtime, hidden: Float32Array, row: Encoded['rows'][number], temperature: number): Float64Array {
329
+ const H = rt.config.hidden_size
330
+ const D = rt.config.head_dim
331
+ const project = (W: Float32Array, b: Float32Array, at: number) => {
332
+ const out = new Float64Array(D)
333
+ const off = at * H
334
+ for (let i = 0; i < D; i++) {
335
+ let s = b[i]!
336
+ const w = i * H
337
+ for (let j = 0; j < H; j++) s += W[w + j]! * hidden[off + j]!
338
+ out[i] = s
339
+ }
340
+ return out
341
+ }
342
+ const q = project(rt.head.qW, rt.head.qB, row.decide)
343
+ const scale = 1 / Math.sqrt(D) / temperature
344
+ const z = row.options.map((at) => {
345
+ const k = project(rt.head.kW, rt.head.kB, at)
346
+ let dot = 0
347
+ for (let i = 0; i < D; i++) dot += k[i]! * q[i]!
348
+ return dot * scale
349
+ })
350
+ const max = Math.max(...z)
351
+ const e = z.map((v) => Math.exp(v - max))
352
+ const sum = e.reduce((a, b) => a + b, 0)
353
+ return Float64Array.from(e, (v) => v / sum)
354
+ }
355
+
356
+ /** State pass once, then each question's row on the state's cache (exact: rows are independent). */
357
+ async function scoreRows(rt: Runtime, enc: Encoded, temperature: number): Promise<Float64Array[]> {
358
+ const { ort, session } = rt
359
+ const Ls = enc.state.length
360
+ const owned: OrtTensor[] = []
361
+ const dropOutputs = (out: Record<string, OrtTensor>, keep?: Set<string>) => {
362
+ for (const [name, t] of Object.entries(out)) if (name !== 'hidden_states' && !keep?.has(name)) disposeTensor(t)
363
+ }
364
+ try {
365
+ const stateOut = await takeTurn(() => session.run({ ...tokenFeeds(ort, enc.state, 0), ...rt.empty }))
366
+ disposeTensor(stateOut.hidden_states)
367
+ const past: Record<string, OrtTensor> = {}
368
+ for (const [present, pastName] of rt.pastFor) {
369
+ const t = stateOut[present]
370
+ if (!t) throw new Error(`Model output ${present} is missing`)
371
+ past[pastName] = t
372
+ owned.push(t)
373
+ }
374
+
375
+ const probs: Float64Array[] = []
376
+ for (const row of enc.rows) {
377
+ const out = await takeTurn(() => session.run({ ...tokenFeeds(ort, row.ids, Ls), ...past }))
378
+ try {
379
+ const hidden = (await out.hidden_states!.getData()) as Float32Array
380
+ probs.push(readout(rt, hidden, row, temperature))
381
+ } finally {
382
+ disposeTensor(out.hidden_states)
383
+ dropOutputs(out)
384
+ }
385
+ }
386
+ return probs
387
+ } finally {
388
+ for (const t of owned) disposeTensor(t)
389
+ }
390
+ }
391
+
392
+ function disposeTensor(t: OrtTensor | undefined): void {
393
+ // GPU-resident outputs hold a GPUBuffer until disposed; CPU tensors ignore this.
394
+ if (t && t.location === 'gpu-buffer') t.dispose()
395
+ }
396
+
397
+ // ---------------------------------------------------------------------------------------------------------------
398
+ // Loading
399
+
400
+ async function loadRuntime(base: string, device: ResolvedDevice, onProgress?: ProgressCallback): Promise<Runtime> {
401
+ onProgress?.({ status: 'loading', progress: 0 })
402
+ const config = (await (await fetchCached(`${base}kev.json`)).json()) as KevConfig
403
+
404
+ const dataFiles = typeof config.onnx.data === 'string' ? [config.onnx.data] : config.onnx.data
405
+ const files = [config.onnx.model, config.head_file, 'tokenizer.json', 'tokenizer_config.json', ...dataFiles]
406
+ const progress = new ProgressTotals(onProgress)
407
+ const [model, head, tokenizerJson, tokenizerConfig, ...data] = await Promise.all(
408
+ files.map((file) => fetchBytes(`${base}${file}`, file, progress))
409
+ )
410
+
411
+ const { PreTrainedTokenizer } = await import('@huggingface/transformers')
412
+ const text = new TextDecoder()
413
+ const tokenizer = new PreTrainedTokenizer(
414
+ JSON.parse(text.decode(tokenizerJson)),
415
+ JSON.parse(text.decode(tokenizerConfig))
416
+ ) as unknown as Tokenizer
417
+
418
+ const D = config.head_dim
419
+ const H = config.hidden_size
420
+ const floats = new Float32Array(head!.buffer, head!.byteOffset, head!.byteLength / 4)
421
+ if (floats.length !== 2 * (D * H + D)) throw new Error('head.bin does not match kev.json')
422
+ const headW = {
423
+ qW: floats.subarray(0, D * H),
424
+ qB: floats.subarray(D * H, D * H + D),
425
+ kW: floats.subarray(D * H + D, 2 * D * H + D),
426
+ kB: floats.subarray(2 * D * H + D),
427
+ }
428
+
429
+ onProgress?.({ status: 'initializing' })
430
+ const { cache } = config
431
+ const pastFor = new Map<string, string>()
432
+ cache.layer_types.forEach((type, i) => {
433
+ if (type === 'full_attention') {
434
+ for (const kv of ['key', 'value']) pastFor.set(`present.${i}.${kv}`, `past_key_values.${i}.${kv}`)
435
+ } else {
436
+ pastFor.set(`present.${i}.conv`, `past.${i}.conv`)
437
+ pastFor.set(`present.${i}.recurrent`, `past.${i}.recurrent`)
438
+ }
439
+ })
440
+ // Zero conv/recurrent states and zero-length KV: the cache of an empty prefix.
441
+ const emptyCache = (ort: Ort) => {
442
+ const empty: Record<string, OrtTensor> = {}
443
+ const zeros = (dims: number[]) => new ort.Tensor('float32', new Float32Array(dims.reduce((a, b) => a * b)), dims)
444
+ cache.layer_types.forEach((type, i) => {
445
+ if (type === 'full_attention') {
446
+ for (const kv of ['key', 'value']) {
447
+ empty[`past_key_values.${i}.${kv}`] = zeros([1, cache.num_key_value_heads, 0, cache.attention_head_dim])
448
+ }
449
+ } else {
450
+ empty[`past.${i}.conv`] = zeros([1, cache.conv_dim, cache.conv_kernel])
451
+ empty[`past.${i}.recurrent`] = zeros([1, cache.linear_heads, cache.linear_key_dim, cache.linear_value_dim])
452
+ }
453
+ })
454
+ return empty
455
+ }
456
+
457
+ // External data is referenced by file name, relative to the .onnx file.
458
+ const externalData = dataFiles.map((file, i) => ({ path: file.split('/').pop()!, data: data[i]! }))
459
+ const create = async (ep: ResolvedDevice) => {
460
+ const ort = await loadOrt(ep)
461
+ const session = await ort.InferenceSession.create(model!, {
462
+ executionProviders: [ep],
463
+ externalData,
464
+ graphOptimizationLevel: 'all',
465
+ // Errors only: node-placement warnings ("Some nodes were not assigned…") are expected for shape ops.
466
+ logSeverityLevel: 3,
467
+ // Keep the state's cache on the GPU between the state pass and the question rows.
468
+ ...(ep === 'webgpu'
469
+ ? { preferredOutputLocation: Object.fromEntries([...pastFor.keys()].map((n) => [n, 'gpu-buffer' as const])) }
470
+ : {}),
471
+ })
472
+ return { ort, session, empty: emptyCache(ort) }
473
+ }
474
+
475
+ let runtime: Awaited<ReturnType<typeof create>>
476
+ let resolved = device
477
+ try {
478
+ runtime = await create(device)
479
+ } catch (err) {
480
+ if (device === 'wasm') throw err
481
+ console.warn('[runonweb/classify] WebGPU session failed, falling back to WASM:', err)
482
+ resolved = 'wasm'
483
+ runtime = await create('wasm')
484
+ }
485
+
486
+ onProgress?.({ status: 'ready', progress: 100 })
487
+ return { ...runtime, tokenizer, config, head: headW, pastFor, device: resolved }
488
+ }
489
+
490
+ const ortModules = new Map<ResolvedDevice, Promise<Ort>>()
491
+
492
+ /**
493
+ * One ONNX Runtime Web build per backend. `onnxruntime-web/webgpu` is the native WebGPU EP: it runs 8-bit
494
+ * MatMulNBits, which the JSEP build in the default entry rejects, but its CPU side lacks GatherBlockQuantized
495
+ * (the int4 embeddings). `onnxruntime-web/wasm` has every CPU kernel the model needs.
496
+ */
497
+ function loadOrt(ep: ResolvedDevice): Promise<Ort> {
498
+ let mod = ortModules.get(ep)
499
+ if (!mod) {
500
+ mod = (ep === 'webgpu' ? import('onnxruntime-web/webgpu') : import('onnxruntime-web/wasm')).then((ort) => {
501
+ if (typeof document !== 'undefined') ort.env.wasm.wasmPaths = ORT_WASM
502
+ return ort as Ort
503
+ })
504
+ ortModules.set(ep, mod)
505
+ }
506
+ return mod
507
+ }
508
+
509
+ class ProgressTotals {
510
+ #loaded = new Map<string, number>()
511
+ #total = new Map<string, number>()
512
+ #cb?: ProgressCallback
513
+
514
+ constructor(cb?: ProgressCallback) {
515
+ this.#cb = cb
516
+ }
517
+
518
+ update(file: string, loaded: number, total: number) {
519
+ this.#loaded.set(file, loaded)
520
+ this.#total.set(file, total)
521
+ if (!this.#cb) return
522
+ this.#cb({ status: 'progress', file, loaded, total, progress: total ? (loaded / total) * 100 : undefined })
523
+ let l = 0
524
+ let t = 0
525
+ for (const v of this.#loaded.values()) l += v
526
+ for (const v of this.#total.values()) t += v
527
+ if (t) this.#cb({ status: 'progress_total', loaded: l, total: t, progress: (l / t) * 100 })
528
+ }
529
+ }
530
+
531
+ async function openCache(): Promise<Cache | null> {
532
+ if (typeof caches === 'undefined') return null
533
+ return caches.open(CACHE_NAME).catch(() => null)
534
+ }
535
+
536
+ async function fetchCached(url: string): Promise<Response> {
537
+ const cache = await openCache()
538
+ const hit = await cache?.match(url)
539
+ if (hit) return hit
540
+ const res = await fetch(url)
541
+ if (!res.ok) throw new Error(`Could not load ${url} (${res.status})`)
542
+ await cache?.put(url, res.clone()).catch(() => undefined)
543
+ return res
544
+ }
545
+
546
+ /** Download into one buffer with progress, served from and stored in the shared model cache. */
547
+ async function fetchBytes(url: string, file: string, progress: ProgressTotals): Promise<Uint8Array> {
548
+ const cache = await openCache()
549
+ const hit = await cache?.match(url)
550
+ if (hit) {
551
+ const bytes = new Uint8Array(await hit.arrayBuffer())
552
+ progress.update(file, bytes.byteLength, bytes.byteLength)
553
+ return bytes
554
+ }
555
+
556
+ const res = await fetch(url)
557
+ if (!res.ok) throw new Error(`Could not load ${file} (${res.status})`)
558
+ const total = Number(res.headers.get('content-length') ?? 0)
559
+ let bytes: Uint8Array
560
+ if (!res.body) {
561
+ bytes = new Uint8Array(await res.arrayBuffer())
562
+ } else {
563
+ const reader = res.body.getReader()
564
+ let buf = new Uint8Array(total || 1 << 20)
565
+ let received = 0
566
+ for (;;) {
567
+ const { done, value } = await reader.read()
568
+ if (done) break
569
+ if (received + value.byteLength > buf.byteLength) {
570
+ const grown = new Uint8Array(Math.max(buf.byteLength * 2, received + value.byteLength))
571
+ grown.set(buf.subarray(0, received))
572
+ buf = grown
573
+ }
574
+ buf.set(value, received)
575
+ received += value.byteLength
576
+ progress.update(file, received, total || received)
577
+ }
578
+ bytes = received === buf.byteLength ? buf : buf.slice(0, received)
579
+ }
580
+ progress.update(file, bytes.byteLength, bytes.byteLength)
581
+ await cache?.put(url, new Response(bytes as BodyInit, { headers: { 'content-length': String(bytes.byteLength) } })).catch(() => undefined)
582
+ return bytes
583
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Request / answer shapes for `runonweb/classify`. They follow TypeSafe's System One contract
3
+ * (`POST /v1/systemone`), which is also what Kev serves, so a request written for either runs here unchanged.
4
+ *
5
+ * Ported from `kev/api.py` (github.com/jaredpalmer/kev, Apache-2.0): same rendering of objects to text,
6
+ * same option strings, same answer math. Changing any string here changes what the model reads.
7
+ */
8
+
9
+ export type JSONContent = string | number | boolean | null | undefined | JSONContent[] | { [key: string]: JSONContent }
10
+
11
+ /** Yes/no question. The answer is the probability of yes. */
12
+ export type NoulQuestion = {
13
+ type: 'noul'
14
+ instructions?: JSONContent
15
+ /** Optional descriptions of what counts as yes (`true`) and no (`false`). */
16
+ criteria?: { true?: JSONContent; false?: JSONContent }
17
+ }
18
+
19
+ /** Pick one option. Keys are the option names; values describe them (or `null`). */
20
+ export type ChoiceQuestion = {
21
+ type: 'choice'
22
+ instructions?: JSONContent
23
+ criteria: Record<string, JSONContent>
24
+ }
25
+
26
+ /** Rate on an ordered scale. Levels go from lowest to highest. */
27
+ export type ScoreQuestion = {
28
+ type: 'score'
29
+ instructions?: JSONContent
30
+ criteria: JSONContent[]
31
+ }
32
+
33
+ export type Question = NoulQuestion | ChoiceQuestion | ScoreQuestion
34
+
35
+ export type ClassifyRequest<Q extends Record<string, Question> = Record<string, Question>> = {
36
+ /** The text to evaluate. Objects and arrays are rendered as labeled text. */
37
+ state: JSONContent
38
+ /** You choose the ids; the model never sees them. Each question only sees the state and itself. */
39
+ questions: Q
40
+ }
41
+
42
+ export type NoulAnswer = { type: 'noul'; noul: number }
43
+
44
+ export type ChoiceAnswer<K extends string = string> = {
45
+ type: 'choice'
46
+ choice: K
47
+ /** `(p_max − 1/K) / (1 − 1/K)`. Not a measured accuracy rate. */
48
+ confidence: number
49
+ probabilities: Record<K, number>
50
+ }
51
+
52
+ export type ScoreAnswer = {
53
+ type: 'score'
54
+ /** Expected level index, starting at 0. */
55
+ score: number
56
+ /** How concentrated the distribution is around its most likely level. */
57
+ confidence: number
58
+ legend: Record<string, string>
59
+ probabilities: Record<string, number>
60
+ }
61
+
62
+ export type Answer = NoulAnswer | ChoiceAnswer | ScoreAnswer
63
+
64
+ export type Answers<Q extends Record<string, Question>> = {
65
+ [K in keyof Q]: Q[K] extends NoulQuestion
66
+ ? NoulAnswer
67
+ : Q[K] extends ChoiceQuestion
68
+ ? ChoiceAnswer<Extract<keyof Q[K]['criteria'], string>>
69
+ : ScoreAnswer
70
+ }
71
+
72
+ export const MAX_OPTIONS = 255
73
+
74
+ /** Build a yes/no question (same as TypeSafe's `Noul(...)`). */
75
+ export const noul = (instructions: JSONContent, criteria?: NoulQuestion['criteria']): NoulQuestion => ({
76
+ type: 'noul',
77
+ instructions,
78
+ ...(criteria ? { criteria } : {}),
79
+ })
80
+
81
+ /** Build a multiple-choice question (same as TypeSafe's `Choice(...)`). */
82
+ export const choice = <const C extends Record<string, JSONContent>>(instructions: JSONContent, criteria: C) => ({
83
+ type: 'choice' as const,
84
+ instructions,
85
+ criteria,
86
+ })
87
+
88
+ /** Build a rating question (same as TypeSafe's `Score(...)`). */
89
+ export const score = (instructions: JSONContent, criteria: JSONContent[]): ScoreQuestion => ({
90
+ type: 'score',
91
+ instructions,
92
+ criteria,
93
+ })
94
+
95
+ /** A record the encoder reads: state text plus, per question, instruction text and option strings. */
96
+ export type DecisionRecord = {
97
+ state: string
98
+ questions: { instr: string; options: string[] }[]
99
+ }
100
+
101
+ export type QuestionMeta = {
102
+ id: string
103
+ type: Question['type']
104
+ /** Keys probabilities are reported under, in option order. */
105
+ keys: string[]
106
+ legend?: Record<string, string>
107
+ }
108
+
109
+ const isScalar = (v: JSONContent): v is string | number | boolean =>
110
+ typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
111
+
112
+ /** Python's `str()` for the scalars JSON can carry (Kev renders booleans as `True` / `False`). */
113
+ function pyStr(v: string | number | boolean): string {
114
+ if (typeof v === 'boolean') return v ? 'True' : 'False'
115
+ return String(v)
116
+ }
117
+
118
+ /** Flatten str | object | array into the text the model sees. Field names are kept as labels. */
119
+ export function render(v: JSONContent, indent = 0): string {
120
+ const pad = ' '.repeat(indent)
121
+ if (v === null || v === undefined) return ''
122
+ if (isScalar(v)) return pyStr(v)
123
+ if (Array.isArray(v)) return v.map((x) => `${pad}- ${render(x, indent + 1).trimStart()}`).join('\n')
124
+ return Object.entries(v)
125
+ .map(([k, x]) =>
126
+ x !== null && typeof x === 'object' ? `${pad}${k}:\n${render(x, indent + 1)}` : `${pad}${k}: ${render(x)}`
127
+ )
128
+ .join('\n')
129
+ }
130
+
131
+ export function optionText(name: string, desc: JSONContent): string {
132
+ return desc === null || desc === undefined || desc === '' ? name : `${name}: ${render(desc)}`
133
+ }
134
+
135
+ const MONTHS = 'January|February|March|April|May|June|July|August|September|October|November|December'
136
+ const DATE_RE = new RegExp(`\\b(?:${MONTHS}) \\d{1,2}, \\d{4}\\b|\\b\\d{4}-\\d{2}-\\d{2}\\b`, 'g')
137
+
138
+ function parseDate(raw: string): number | null {
139
+ let y: number, m: number, d: number
140
+ if (raw.includes(',')) {
141
+ const [month, day, year] = raw.replace(',', '').split(' ')
142
+ m = MONTHS.split('|').indexOf(month!) + 1
143
+ d = Number(day)
144
+ y = Number(year)
145
+ } else {
146
+ ;[y, m, d] = raw.split('-').map(Number) as [number, number, number]
147
+ }
148
+ const t = Date.UTC(y, m - 1, d)
149
+ const back = new Date(t)
150
+ if (back.getUTCFullYear() !== y || back.getUTCMonth() !== m - 1 || back.getUTCDate() !== d) return null
151
+ return t / 86_400_000
152
+ }
153
+
154
+ /**
155
+ * Day counts between every pair of absolute dates in `text` ("August 3, 2026 is 12 days after July 22, 2026.").
156
+ * Kev can't subtract dates reliably but uses a stated day count. Empty when fewer than two dates are found.
157
+ */
158
+ export function dateFacts(text: string): string {
159
+ const found: [string, number][] = []
160
+ for (const m of text.matchAll(DATE_RE)) {
161
+ const raw = m[0]
162
+ const day = parseDate(raw)
163
+ if (day === null) continue
164
+ if (!found.some(([r]) => r === raw)) found.push([raw, day])
165
+ }
166
+ const facts: string[] = []
167
+ for (let i = 0; i < found.length; i++) {
168
+ for (let j = i + 1; j < found.length; j++) {
169
+ const n = found[j]![1] - found[i]![1]
170
+ const a = Math.abs(n)
171
+ facts.push(
172
+ n
173
+ ? `${found[j]![0]} is ${a} day${a !== 1 ? 's' : ''} ${n > 0 ? 'after' : 'before'} ${found[i]![0]}.`
174
+ : `${found[j]![0]} is the same day as ${found[i]![0]}.`
175
+ )
176
+ }
177
+ }
178
+ return facts.join(' ')
179
+ }
180
+
181
+ /** State with a `date_facts` field (objects), entry (arrays) or paragraph (text) when two or more dates appear. */
182
+ export function withDateFacts(state: JSONContent): JSONContent {
183
+ const facts = dateFacts(render(state))
184
+ if (!facts) return state
185
+ if (Array.isArray(state)) return [...state, { date_facts: facts }]
186
+ if (state !== null && typeof state === 'object') return { ...state, date_facts: facts }
187
+ return `${render(state)}\n\ndate_facts: ${facts}`
188
+ }
189
+
190
+ function questionKeys(q: Question): string[] {
191
+ if (q.type === 'choice') return Object.keys(q.criteria)
192
+ if (q.type === 'noul') return ['false', 'true']
193
+ return q.criteria.map((_, i) => String(i))
194
+ }
195
+
196
+ /** Validate a request the way the Kev server does (it answers 422); throws a readable error instead. */
197
+ export function validateRequest(req: ClassifyRequest): void {
198
+ if (!req || typeof req !== 'object') throw new TypeError('Request must be an object with `state` and `questions`')
199
+ const entries = Object.entries(req.questions ?? {})
200
+ if (entries.length === 0) throw new Error('`questions` needs at least one question')
201
+ for (const [id, q] of entries) {
202
+ if (!q || typeof q !== 'object') throw new Error(`Question "${id}" must be an object`)
203
+ if (q.type === 'choice') {
204
+ const n = Object.keys(q.criteria ?? {}).length
205
+ if (n < 1 || n > MAX_OPTIONS) throw new Error(`Choice "${id}" needs 1–${MAX_OPTIONS} options in \`criteria\``)
206
+ } else if (q.type === 'score') {
207
+ const n = Array.isArray(q.criteria) ? q.criteria.length : 0
208
+ if (n < 1 || n > MAX_OPTIONS) throw new Error(`Score "${id}" needs 1–${MAX_OPTIONS} levels in \`criteria\``)
209
+ } else if (q.type !== 'noul') {
210
+ throw new Error(`Question "${id}" has unknown type "${(q as { type?: string }).type}" (noul | choice | score)`)
211
+ }
212
+ }
213
+ }
214
+
215
+ /** Request → encoder record plus per-question metadata to map probabilities back. */
216
+ export function toRecord(req: ClassifyRequest): { record: DecisionRecord; meta: QuestionMeta[] } {
217
+ const questions: DecisionRecord['questions'] = []
218
+ const meta: QuestionMeta[] = []
219
+ for (const [id, q] of Object.entries(req.questions)) {
220
+ const m: QuestionMeta = { id, type: q.type, keys: questionKeys(q) }
221
+ let options: string[]
222
+ if (q.type === 'noul') {
223
+ const c = q.criteria ?? {}
224
+ options = [optionText('no', c.false), optionText('yes', c.true)]
225
+ } else if (q.type === 'choice') {
226
+ options = Object.entries(q.criteria).map(([k, v]) => optionText(k, v))
227
+ } else {
228
+ options = q.criteria.map((x) => render(x))
229
+ m.legend = Object.fromEntries(m.keys.map((k, i) => [k, options[i]!]))
230
+ }
231
+ questions.push({ instr: render(q.instructions), options })
232
+ meta.push(m)
233
+ }
234
+ return { record: { state: render(req.state), questions }, meta }
235
+ }
236
+
237
+ const round = (x: number) => Math.round(x * 10_000) / 10_000
238
+
239
+ export function choiceConfidence(p: ArrayLike<number>): number {
240
+ const K = p.length
241
+ return K === 1 ? 1 : (Math.max(...Array.from(p)) - 1 / K) / (1 - 1 / K)
242
+ }
243
+
244
+ export function scoreConfidence(p: ArrayLike<number>): number {
245
+ const L = p.length
246
+ if (L === 1) return 1
247
+ let mode = 0
248
+ for (let i = 1; i < L; i++) if (p[i]! > p[mode]!) mode = i
249
+ let spread = 0
250
+ for (let i = 0; i < L; i++) spread += p[i]! * Math.abs(i - mode)
251
+ return 1 - spread / (L - 1)
252
+ }
253
+
254
+ /** Probabilities per question → System One answers (4-decimal rounding, as served by Kev). */
255
+ export function toAnswers(probs: Float64Array[], meta: QuestionMeta[]): Record<string, Answer> {
256
+ const out: Record<string, Answer> = {}
257
+ probs.forEach((p, qi) => {
258
+ const m = meta[qi]!
259
+ if (m.type === 'noul') {
260
+ out[m.id] = { type: 'noul', noul: round(p[1]!) }
261
+ } else if (m.type === 'choice') {
262
+ let best = 0
263
+ for (let i = 1; i < p.length; i++) if (p[i]! > p[best]!) best = i
264
+ out[m.id] = {
265
+ type: 'choice',
266
+ choice: m.keys[best]!,
267
+ confidence: round(choiceConfidence(p)),
268
+ probabilities: Object.fromEntries(m.keys.map((k, i) => [k, round(p[i]!)])),
269
+ }
270
+ } else {
271
+ let s = 0
272
+ for (let i = 0; i < p.length; i++) s += i * p[i]!
273
+ out[m.id] = {
274
+ type: 'score',
275
+ score: round(s),
276
+ confidence: round(scoreConfidence(p)),
277
+ legend: m.legend ?? {},
278
+ probabilities: Object.fromEntries(m.keys.map((k, i) => [k, round(p[i]!)])),
279
+ }
280
+ }
281
+ })
282
+ return out
283
+ }
@@ -0,0 +1,26 @@
1
+ export type ClassifySize = 'small' | 'large'
2
+
3
+ /** Kev checkpoints packaged for the browser (`training/kev-onnx`). Accuracy is Kev's locked test on sources it never trained on. */
4
+ export const CLASSIFY_SIZES: Record<
5
+ ClassifySize,
6
+ { label: string; model: string; base: string; params: string; downloadMB: string; outOfDomain: string }
7
+ > = {
8
+ small: {
9
+ label: 'Small',
10
+ model: 'midudev/kev-0.8b-ONNX',
11
+ base: 'Kev-0.8B',
12
+ params: '0.8B',
13
+ downloadMB: '~750 MB',
14
+ outOfDomain: '0.70',
15
+ },
16
+ large: {
17
+ label: 'Large',
18
+ model: 'midudev/kev-4b-ONNX',
19
+ base: 'Kev-4B',
20
+ params: '4B',
21
+ downloadMB: '~2.7 GB',
22
+ outOfDomain: '0.84',
23
+ },
24
+ }
25
+
26
+ export const DEFAULT_CLASSIFY_SIZE: ClassifySize = 'small'
@@ -46,6 +46,11 @@ export async function loadPipeline(options: PipelineLoadOptions): Promise<Loaded
46
46
  options.onProgress?.({ status: 'loading', progress: 0 })
47
47
 
48
48
  const { pipeline, env } = await import('@huggingface/transformers')
49
+ // The threaded WASM build crashes with "memory access out of bounds" when the
50
+ // page is not cross-origin isolated (no SharedArrayBuffer). It happens at the
51
+ // end of the first download and a reload works. One thread avoids the spawn.
52
+ const wasm = env.backends?.onnx?.wasm
53
+ if (wasm && !globalThis.crossOriginIsolated) wasm.numThreads = 1
49
54
  if (options.modelPath) {
50
55
  env.allowLocalModels = true
51
56
  env.allowRemoteModels = false
@@ -2,6 +2,10 @@ export type ProgressInfo = {
2
2
  status: string
3
3
  progress?: number
4
4
  file?: string
5
+ /** Bytes received for this file, or for every file when `status` is `progress_total`. */
6
+ loaded?: number
7
+ /** Total bytes expected for the same scope as `loaded`. */
8
+ total?: number
5
9
  }
6
10
 
7
11
  export type ProgressCallback = (info: ProgressInfo) => void
@@ -16,5 +20,7 @@ export function toProgressInfo(data: Record<string, unknown>): ProgressInfo {
16
20
  ? (data.loaded / data.total) * 100
17
21
  : undefined
18
22
  const file = typeof data.file === 'string' ? data.file : undefined
19
- return { status, progress, file }
23
+ const loaded = typeof data.loaded === 'number' ? data.loaded : undefined
24
+ const total = typeof data.total === 'number' ? data.total : undefined
25
+ return { status, progress, file, loaded, total }
20
26
  }
@@ -4,16 +4,16 @@ import { loadPipeline } from '../core/pipeline.ts'
4
4
  /**
5
5
  * Default weights: `text2emoji-tiny`, a 2.4M-parameter T5 trained from scratch for runonweb
6
6
  * on the Text2Emoji dataset. 3.9 MB as q8 (encoder 1.7 MB + decoder 2.1 MB), MIT.
7
- * Trained with `training/text2emoji` in the runonweb repo.
7
+ * Trained with `training/text2emoji` in the runonweb repo. Published on the Hub as `midudev/text2emoji-tiny`.
8
8
  */
9
- export const DEFAULT_MODEL = 'text2emoji-tiny'
9
+ export const DEFAULT_MODEL = 'midudev/text2emoji-tiny'
10
10
 
11
11
  export type EmojiOptions = {
12
12
  /** Model folder name (under `modelPath`) or Hugging Face repo id. */
13
13
  model?: string
14
14
  /**
15
- * Base URL the weights are served from, e.g. `/models/`. The runonweb site self-hosts them
16
- * in `public/models/`. Omit to download `model` from the Hugging Face Hub.
15
+ * Base URL the weights are served from, e.g. `/models/`: files are read from `<modelPath>/<model>/`.
16
+ * The runonweb site self-hosts them in `public/models/text2emoji-tiny/`. Omit to download `model` from the Hub.
17
17
  */
18
18
  modelPath?: string
19
19
  device?: Device
@@ -36,7 +36,7 @@ export type EmojiResult = {
36
36
  * ```ts
37
37
  * import { Emojifier } from 'runonweb/emoji'
38
38
  *
39
- * const emojifier = new Emojifier({ modelPath: '/models/' })
39
+ * const emojifier = new Emojifier()
40
40
  * await emojifier.load()
41
41
  * const { text } = await emojifier.emojify('I love pizza and my dog')
42
42
  * // "🍕❤️🐶"
@@ -85,6 +85,8 @@ function mapLoadProgress(onProgress?: ProgressCallback): (status: Record<string,
85
85
  status: component ? `loading ${component}` : 'loading',
86
86
  progress: progress * 100,
87
87
  file: component,
88
+ loaded: loaded > 0 ? loaded : undefined,
89
+ total: total > 0 ? total : undefined,
88
90
  })
89
91
  }
90
92
  }
package/src/ocr/index.ts CHANGED
@@ -324,7 +324,7 @@ async function fetchCached(
324
324
  if (done) break
325
325
  chunks.push(value)
326
326
  received += value.byteLength
327
- onProgress?.({ status: 'progress', progress: (received / total) * 100, file })
327
+ onProgress?.({ status: 'progress', progress: (received / total) * 100, file, loaded: received, total })
328
328
  }
329
329
 
330
330
  const out = new Uint8Array(received)
package/src/stt/index.ts CHANGED
@@ -36,6 +36,14 @@ export type TranscribeOptions = {
36
36
  * `translate` turns speech into English. Never the default here.
37
37
  */
38
38
  task?: 'transcribe' | 'translate'
39
+ /**
40
+ * Ask Whisper for timestamps. `"segment"` returns each phrase with the model's
41
+ * start and end, in seconds. `"word"` (or `true`) splits those phrases into
42
+ * words and keeps them inside the phrase span. This ONNX build has no
43
+ * cross-attentions, so it cannot time each word on its own. Omit it and
44
+ * `chunks` is empty.
45
+ */
46
+ timestamps?: boolean | 'word' | 'segment'
39
47
  }
40
48
 
41
49
  export type STTAudioInput = Blob | File | string | Float32Array
@@ -117,8 +125,8 @@ export class SpeechToText {
117
125
  this.#onProgress?.({ status: 'transcribing' })
118
126
 
119
127
  const input = await prepareAudio(audio)
120
- const duration = input instanceof Float32Array ? input.length / SAMPLE_RATE : 0
121
128
  const onPartial = options?.onPartial
129
+ const timestampMode = resolveTimestamps(options?.timestamps)
122
130
  let streamed = ''
123
131
 
124
132
  const base: Record<string, unknown> = {
@@ -127,15 +135,14 @@ export class SpeechToText {
127
135
  // Always transcribe unless asked otherwise. Multilingual Whisper will
128
136
  // otherwise slip into "translate to English" when language is unknown.
129
137
  task: options?.task ?? 'transcribe',
130
- // Word-timestamp mode disables timestamp tokens. Without this, decode
131
- // throws "Whisper did not predict an ending timestamp" and the retry
132
- // can come back empty after the streamer already showed text.
138
+ // Without this, a clip that ends mid-word throws and the retry can come
139
+ // back empty after the streamer already showed text.
133
140
  force_full_sequences: false,
134
141
  }
135
142
  const language = options?.language ?? this.#language
136
143
  if (language) base.language = language
137
144
 
138
- const run = async (returnTimestamps: true | 'word', stream: boolean) => {
145
+ const run = async (stream: boolean) => {
139
146
  const streamer =
140
147
  stream && onPartial ? await createStreamer(this.#pipe!, (text) => {
141
148
  streamed = text
@@ -143,24 +150,26 @@ export class SpeechToText {
143
150
  }) : undefined
144
151
  return this.#pipe!(input, {
145
152
  ...base,
146
- return_timestamps: returnTimestamps,
153
+ return_timestamps: timestampMode ? true : false,
147
154
  ...(streamer ? { streamer } : {}),
148
155
  })
149
156
  }
150
157
 
151
158
  let raw: RawResult | RawResult[]
152
159
  try {
153
- // Segment timestamps + streamer is the reliable path. Word timestamps
154
- // need a different generate() return shape and often fail once a streamer
155
- // is attached, wiping the final text after a good partial stream.
156
- raw = await run(true, true)
160
+ raw = await run(true)
157
161
  } catch {
158
- raw = await run(true, false)
162
+ raw = await run(false)
159
163
  }
160
164
 
161
165
  const parsed = normalizeRaw(raw)
162
166
  const text = parsed.text || streamed
163
- const chunks = explodeWords(parsed.chunks.length ? parsed.chunks : wordsFromText(text, duration), duration)
167
+ const chunks =
168
+ timestampMode === 'word'
169
+ ? wordsInsideSegments(parsed.chunks)
170
+ : timestampMode === 'segment'
171
+ ? parsed.chunks
172
+ : []
164
173
 
165
174
  this.#onProgress?.({ status: 'done' })
166
175
  return { text, chunks }
@@ -190,6 +199,38 @@ export async function transcribe(
190
199
  }
191
200
  }
192
201
 
202
+ function resolveTimestamps(option: TranscribeOptions['timestamps']): 'word' | 'segment' | false {
203
+ if (option === true || option === 'word') return 'word'
204
+ if (option === 'segment') return 'segment'
205
+ return false
206
+ }
207
+
208
+ /** Place words inside the phrase span Whisper actually predicted. */
209
+ function wordsInsideSegments(segments: STTChunk[]): STTChunk[] {
210
+ const words: STTChunk[] = []
211
+ for (const segment of segments) {
212
+ const parts = segment.text.trim().split(/\s+/).filter(Boolean)
213
+ if (!parts.length) continue
214
+ const start = segment.start
215
+ const end = Math.max(segment.end, start)
216
+ if (parts.length === 1) {
217
+ words.push({ text: parts[0]!, start, end })
218
+ continue
219
+ }
220
+ const weights = parts.map((part) => Math.max(1, part.length))
221
+ const total = weights.reduce((sum, weight) => sum + weight, 0)
222
+ const span = end - start
223
+ let cursor = start
224
+ parts.forEach((text, i) => {
225
+ const dur = span * (weights[i]! / total)
226
+ const next = i === parts.length - 1 ? end : cursor + dur
227
+ words.push({ text, start: cursor, end: next })
228
+ cursor = next
229
+ })
230
+ }
231
+ return words
232
+ }
233
+
193
234
  async function createStreamer(pipe: ASRPipeline, onPartial: (text: string) => void) {
194
235
  const { WhisperTextStreamer } = await import('@huggingface/transformers')
195
236
  let acc = ''
@@ -218,8 +259,8 @@ function parseChunks(raw?: RawChunk[]): STTChunk[] {
218
259
  if (!raw?.length) return []
219
260
  const chunks: STTChunk[] = []
220
261
  for (const item of raw) {
221
- const text = (item.text ?? '').replace(/\s+/g, ' ')
222
- if (!text.trim()) continue
262
+ const text = (item.text ?? '').replace(/\s+/g, ' ').trim()
263
+ if (!text) continue
223
264
  const start = item.timestamp?.[0] ?? chunks.at(-1)?.end ?? 0
224
265
  const end = item.timestamp?.[1] ?? start
225
266
  chunks.push({ text, start, end: end < start ? start : end })
@@ -227,52 +268,6 @@ function parseChunks(raw?: RawChunk[]): STTChunk[] {
227
268
  return chunks
228
269
  }
229
270
 
230
- function wordsFromText(text: string, duration: number): STTChunk[] {
231
- const parts = text.trim().split(/\s+/).filter(Boolean)
232
- if (!parts.length) return []
233
- const span = Math.max(0.04, (duration || parts.length * 0.35) / parts.length)
234
- return parts.map((word, i) => ({
235
- text: word,
236
- start: i * span,
237
- end: (i + 1) * span,
238
- }))
239
- }
240
-
241
- /** Split segment chunks into words and align timestamps to the audio duration. */
242
- function explodeWords(raw: STTChunk[], duration: number): STTChunk[] {
243
- const words: STTChunk[] = []
244
- for (const chunk of raw) {
245
- const parts = chunk.text.trim().split(/\s+/).filter(Boolean)
246
- if (!parts.length) continue
247
- if (parts.length === 1) {
248
- words.push({ text: parts[0]!, start: chunk.start, end: Math.max(chunk.end, chunk.start) })
249
- continue
250
- }
251
- const start = chunk.start
252
- const end = Math.max(chunk.end, chunk.start)
253
- const span = Math.max(0.04, (end - start || parts.length * 0.35) / parts.length)
254
- parts.forEach((word, i) => {
255
- words.push({
256
- text: word,
257
- start: start + i * span,
258
- end: start + (i + 1) * span,
259
- })
260
- })
261
- }
262
- return alignToDuration(words, duration)
263
- }
264
-
265
- function alignToDuration(chunks: STTChunk[], duration: number): STTChunk[] {
266
- if (!chunks.length) return chunks
267
- const maxEnd = Math.max(...chunks.map((c) => Math.max(c.end, c.start)))
268
- if (maxEnd <= 0.05 && duration > 0) return wordsFromText(chunks.map((c) => c.text).join(' '), duration)
269
- if (duration > 0 && (maxEnd > duration * 1.2 || maxEnd < duration * 0.55)) {
270
- const scale = duration / maxEnd
271
- return chunks.map((c) => ({ ...c, start: c.start * scale, end: Math.max(c.end, c.start) * scale }))
272
- }
273
- return chunks
274
- }
275
-
276
271
  export type { ProgressInfo, Device }
277
272
 
278
273
  async function prepareAudio(audio: STTAudioInput): Promise<Float32Array | string> {
@@ -8,7 +8,7 @@ import { WORKER_SOURCE } from './worker.ts'
8
8
  * Hugging Face Hub (MPL-2.0). Layout: `<modelPath><from>-<to>/<file>?rev=<revision>`.
9
9
  * Self-host by pointing `modelPath` at a folder produced by `scripts/translate-models.mjs`.
10
10
  */
11
- export const DEFAULT_MODEL_PATH = 'https://huggingface.co/runonweb/firefox-translations/resolve/main/'
11
+ export const DEFAULT_MODEL_PATH = 'https://huggingface.co/midudev/firefox-translations/resolve/main/'
12
12
 
13
13
  export type BergamotOptions = {
14
14
  /** Base URL of the model files. Default: Hugging Face mirror. */
@@ -110,7 +110,13 @@ async function fetchFile(url: string, file: string, onProgress?: ProgressCallbac
110
110
  if (!res.ok) throw new Error(`Could not download ${file} (HTTP ${res.status}) from ${url}`)
111
111
 
112
112
  const buffer = await readWithProgress(res, (loaded, total) => {
113
- onProgress?.({ status: 'progress', file, progress: total > 0 ? (loaded / total) * 100 : undefined })
113
+ onProgress?.({
114
+ status: 'progress',
115
+ file,
116
+ progress: total > 0 ? (loaded / total) * 100 : undefined,
117
+ loaded,
118
+ total: total > 0 ? total : undefined,
119
+ })
114
120
  })
115
121
 
116
122
  if (cache) {
package/src/tts/kitten.ts CHANGED
@@ -175,7 +175,7 @@ async function fetchCached(url: string, file: string, onProgress?: ProgressCallb
175
175
  if (done) break
176
176
  chunks.push(value)
177
177
  received += value.byteLength
178
- onProgress?.({ status: 'progress', progress: (received / total) * 100, file })
178
+ onProgress?.({ status: 'progress', progress: (received / total) * 100, file, loaded: received, total })
179
179
  }
180
180
 
181
181
  const out = new Uint8Array(received)