@degreesign/chat 1.6.0 → 1.7.1

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
@@ -1,13 +1,9 @@
1
1
  # @degreesign/chat
2
2
 
3
- One-shot on-device AI chat for browsers, workers and Node, using a wllama CPU
4
- engine and a LiteRT-LM WebGPU engine. It streams text, describes images,
5
- manages model downloads with automatic resume, and renders no UI.
6
-
7
- Voice notes are transcribed on device by `@degreesign/voice`, a companion
8
- package, then passed to `@degreesign/chat` as text.
9
-
10
- `@degreesign/chat` is a plug and play engine for any UI.
3
+ Headless on-device AI chat for browsers, workers and Node: one model in memory,
4
+ one streamed reply, no UI. A wllama CPU engine and a LiteRT-LM WebGPU engine
5
+ handle text and image turns, downloads resume after a reload, and every export
6
+ is typed.
11
7
 
12
8
  [![npm version](https://img.shields.io/npm/v/@degreesign/chat.svg)](https://www.npmjs.com/package/@degreesign/chat)
13
9
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](#licensing)
@@ -19,76 +15,52 @@ package, then passed to `@degreesign/chat` as text.
19
15
  ## Table of contents
20
16
 
21
17
  - [Why](#why)
22
- - [Install](#install)
23
- - [Quick start](#quick-start)
24
- - [Case study: DoListy](#case-study-dolisty)
25
- - [Enable WebGPU](#enable-webgpu)
26
- - [Public API](#public-api)
27
- - [Tools](#tools)
28
- - [Status](#status)
29
- - [Models](#models)
30
- - [Prompt assembly](#prompt-assembly)
31
- - [Engines and assets](#engines-and-assets)
32
- - [Node.js](#nodejs)
33
- - [React](#react)
34
- - [Types](#types)
35
- - [Testing](#testing)
36
- - [Architecture](#architecture)
37
- - [FAQ](#faq)
18
+ - [Install](#install)
19
+ - [Quick start](#quick-start)
20
+ - [Public API](#public-api)
21
+ - [Tools](#tools)
22
+ - [Status](#status)
23
+ - [Models](#models)
24
+ - [Prompt assembly](#prompt-assembly)
25
+ - [Enable WebGPU](#enable-webgpu)
26
+ - [Engines and assets](#engines-and-assets)
27
+ - [Node.js](#nodejs)
28
+ - [React](#react)
29
+ - [Types](#types)
30
+ - [Testing](#testing)
31
+ - [Architecture](#architecture)
32
+ - [FAQ](#faq)
38
33
  - [Licensing](#licensing)
39
34
 
40
35
  ## Why
41
36
 
42
- - Proven: [DoListy](https://dolisty.com/#chat), a no-signup PWA, answers
37
+ - Proven: [DoListy](https://dolisty.com/#chat), a no-signup PWA, describes
38
+ photos, turns voice notes into tasks with `@degreesign/voice`, and answers
43
39
  with the network off.
44
- - On device: model weights download once, then every reply is generated locally.
45
- - Headless: the package returns text through callbacks and renders no UI.
46
- - Stateless: the host passes the whole message list and keeps its own history.
47
- - Two engines: CPU through wllama and WebGPU through LiteRT-LM, behind one
48
- `startLLM` runtime contract.
49
- - Images: a user turn can carry image URLs, sent only to a vision model and
50
- capped by the prompt budgets.
51
- - Audio: a user turn can carry an audio URL, sent only to a model with audio
52
- support. No model in the catalog has an audio encoder.
53
- - Reusable: the loaded model stays in memory for the next call.
54
- - Typed: written in TypeScript with declarations for every export.
55
- - Portable: importing and calling works in browsers, workers, React and Node.
56
- Local inference stays browser only.
40
+ - On device: weights download once, then every reply is generated locally,
41
+ with no API key and no per-reply cost.
42
+ - Headless and stateless: results arrive through callbacks, no UI is rendered
43
+ and no history is kept; the host passes the whole message list.
44
+ - Two engines behind one runtime: CPU through wllama, WebGPU through LiteRT-LM.
45
+ - Typed and portable: declarations for every export, and importing it works in
46
+ browsers, workers, React and Node.
57
47
 
58
48
  ## Install
59
49
 
60
50
  ```bash
61
51
  npm install @degreesign/chat
62
- ```
63
-
64
- ```bash
65
52
  yarn add @degreesign/chat
66
- ```
67
-
68
- ```bash
69
53
  pnpm add @degreesign/chat
70
54
  ```
71
55
 
72
- `@wllama/wllama`, `@wllama/wllama-compat` and `@litert-lm/core` are build-time
73
- dependencies. The engines are bundled into the published build as dynamic
74
- chunks and their wasm assets ship beside them, so a consumer installs nothing
75
- extra and resolves no engine import. Only the model weights are downloaded at
76
- runtime.
77
-
78
- The build emits:
79
-
80
- - `dist/browser/chat.min.mjs`, the ESM browser entry.
81
- - `dist/browser/engine-wllama.min.mjs` and
82
- `dist/browser/engine-litert.min.mjs`, the bundled engines, fetched only when
83
- a matching model starts.
84
- - `dist/node/chat.node.min.js` and `chat.node.min.mjs`, the CommonJS and ESM
85
- Node entries, with the same engine chunks beside them.
86
- - `dist/assets/wllama/`, the wllama wasm binaries and compat worker.
87
- - `dist/assets/litert/`, the LiteRT-LM wasm glue and binary.
88
- - `dist/index.d.ts`, declarations for every export.
56
+ The engines and helpers are bundled into dynamic chunks, so a host resolves no
57
+ engine import and downloads only the chunk a model uses. Only the model weights
58
+ come from outside the package. The build emits `dist/browser/chat.min.mjs` with
59
+ its engine chunks, the CommonJS and ESM Node entries in `dist/node/`, the wasm
60
+ assets in `dist/assets/wllama/` and `dist/assets/litert/`, and `dist/index.d.ts`.
89
61
 
90
- Build from source with `yarn build`; typecheck with `yarn typecheck`; test the
91
- built Node entry with `yarn verify:node`.
62
+ Build with `yarn build`, typecheck with `yarn typecheck` and test the Node entry
63
+ with `yarn verify:node`.
92
64
 
93
65
  ## Quick start
94
66
 
@@ -110,10 +82,7 @@ await preloadModel();
110
82
  const cancel = createChat({
111
83
  messages,
112
84
  onResponse: (text) => renderReply(text),
113
- // onThinking: (text) => renderThinking(text),
114
- // onSpeed: (tokensPerSecond) => renderSpeed(tokensPerSecond),
115
- // onPromptSpeed: (tokensPerSecond) => renderPromptSpeed(tokensPerSecond),
116
- // onStatus: (snapshot) => renderStatus(snapshot),
85
+ // onThinking, onSpeed, onPromptSpeed and onStatus are optional.
117
86
  });
118
87
 
119
88
  // Later, stop the reply, cancel the load, or free the model.
@@ -123,194 +92,32 @@ await clearModelCache();
123
92
  ```
124
93
 
125
94
  Without `model` the package picks the GPU model when the browser provides a
126
- WebGPU adapter and the CPU model when it does not. `limits`, `reasoning`,
127
- `onThinking`, `onSpeed`, `onPromptSpeed` and `onStatus` are optional per call;
128
- see the API below.
129
-
130
- There is no promise and no controller object on `createChat`. Every result
131
- arrives through `onResponse`, and `onStatus` reports `requesting: false` when
132
- the reply ends. `onSpeed` reports the streamed generation speed in tokens per
133
- second, measured from the first token so prompt processing is excluded.
134
- `onPromptSpeed` streams the prompt processing speed while the prompt is
135
- evaluated, then reports the final value at the first generated token. It uses
136
- the engine prefill progress or stream timings, and falls back to a running
137
- estimate when the engine exposes neither.
138
- `preloadModel` is the one promise: it resolves once the model is loaded, or
139
- `false` when it cannot load.
140
-
141
- Attach images to a user turn with `images: [dataUrl]`. The package sends them
142
- only to a model with vision support, fits each image to 128 to 136 vision
143
- tokens, snaps both sides to multiples of 32 px, crops the remainder, and caps
144
- the number with `limits.maxImages`. `qwen3_vl_2b` is the vision model in the
145
- catalog, and it runs on the CPU engine. A turn sent to a model without vision
146
- drops its images when the turn also has text, and an image-only turn reports
147
- `Model does not accept images`.
148
-
149
- Attach a recording with `audio: [dataUrl]`. No model in the catalog has an
150
- audio encoder, so a text turn drops the clip and an audio-only turn reports
151
- `Model does not accept audio`. `limits.maxAudio` still caps the clips
152
- (default 1).
153
-
154
- The package never writes storage, so the host appends the user message before
155
- the call and appends the assistant message after the last `onResponse`.
156
-
157
- ## Case study: DoListy
158
-
159
- [DoListy](https://dolisty.com/#chat) is a DegreeSign project: a private
160
- to-do list PWA with no sign-up. It ships `@degreesign/chat` for replies
161
- and image descriptions, and `@degreesign/voice` for voice notes.
162
-
163
- I wanted an assistant that could read my daily tasks, but I did not want
164
- to give 100% of my data to big tech. I was also working on a new project
165
- that had to stay under wraps until its soft launch, so I could not send
166
- that work to a hosted model. The assistant had to run on the device, and
167
- it had to be easy for any developer to drop into an app. Most days I have
168
- that conversation on my phone: a voice note, a transcript, a few tasks,
169
- all local. DoListy is where that engine got its first real use.
170
-
171
- ![DoListy assistant answering a question on a phone][dolisty-shot]
172
-
173
- [dolisty-shot]: https://degreesign.github.io/ds_chat/case-study-dolisty.png
174
-
175
- *A real session on dolisty.com. The green dot is the local model, not a
176
- server.*
177
-
178
- ### What it does
179
-
180
- - Describes a photo and tags it without uploading it.
181
- - Turns a voice note into a task through `@degreesign/voice`.
182
- - Writes titles, tags and a running summary in 19 languages. The persona
183
- is the app's; the package adds no identity text.
184
- - Answers with the network off, from the model already on disk.
185
-
186
- ### What it costs
187
-
188
- - No per-reply cost, no API key, no rate limit. The only bill is the
189
- one-time download: about 1.5 GB for Qwen3-VL, 2.0 GB for Gemma 4.
190
- - An image waits about 70 seconds for its first token on an 8-thread
191
- desktop CPU. That is the cost of the CPU vision path, chosen so images
192
- work on phones without WebGPU; text takes the GPU path when the browser
193
- has one.
194
- - A 2 GB download resumes from the last committed part. A reload costs at
195
- most one part, never the whole file.
196
- - The gallery describes images in the background while you work.
197
-
198
- ### How it is wired
199
-
200
- - One `createChat` per send, over a prompt assembled from the app's
201
- encrypted stores.
202
- - The reply is committed when `onStatus` reports the end; a newer send
203
- drops the stale one.
204
- - `gpuAdapter()` picks Gemma on WebGPU and Qwen everywhere else.
205
- - One model stays resident, so a switch reloads the engine but never the
206
- file.
207
-
208
- **See it work:** open [dolisty.com](https://dolisty.com/#chat) on a phone,
209
- let the model download once, then turn off the network and ask for a
210
- summary. Speed numbers are in the
211
- [web-llm comparison](https://degreesign.github.io/ds_chat/vs_web_llm.html).
212
-
213
- ## Enable WebGPU
214
-
215
- A request without `model` picks `gemma4_e2b_limited` when the browser exposes a
216
- WebGPU adapter and `qwen3_vl_2b` when it does not. The Gemma build is the 2.0 GB
217
- text-only `gemma-4-E2B-it-gpu.litertlm`, and it is the only WebGPU model in the
218
- catalog, so it loads on a desktop and on a phone alike. Every status snapshot
219
- reports the build that runs in `model`. Hosts that need images pass
220
- `qwen3_vl_2b`. The GPU engine is far faster on a desktop, so check the browser
221
- before blaming the package:
222
-
223
- ```js
224
- // Run in the console of the page that loads the package
225
- const adapter = await navigator.gpu?.requestAdapter();
226
- adapter
227
- ? `${adapter.info.vendor} ${adapter.info.architecture}`
228
- : `no adapter`;
229
- ```
230
-
231
- `null` means the browser has the API but no usable adapter, and the package
232
- falls back to the CPU model. Chromium browsers report the whole state at
233
- `chrome://gpu`: `WebGPU` should read `Hardware accelerated` and `Vulkan` should
234
- read `Enabled`. Firefox lists the detected features at `about:support` under
235
- `Graphics`.
236
-
237
- ### Firefox
238
-
239
- WebGPU ships enabled on Windows and macOS. On Linux, and on any build that
240
- reports `navigator.gpu` as undefined:
241
-
242
- 1. Open `about:config`.
243
- 2. Set `dom.webgpu.enabled` to `true`.
244
- 3. Restart the browser and run the snippet again.
245
-
246
- Firefox runs WebGPU on its own wgpu stack, so an adapter can appear even with a
247
- software driver. Check the renderer in `about:support` when replies feel slow.
95
+ WebGPU adapter and the CPU model when it does not.
248
96
 
249
- ### Chrome, Edge and Brave
250
-
251
- WebGPU ships enabled on Windows, macOS and ChromeOS since version 113, so there
252
- is nothing to configure.
253
-
254
- On Linux the GPU process only exposes an adapter when ANGLE runs on Vulkan,
255
- which is not the default. Start the browser with both flags:
256
-
257
- ```bash
258
- google-chrome --enable-features=Vulkan --use-angle=vulkan
259
- microsoft-edge --enable-features=Vulkan --use-angle=vulkan
260
- brave-browser --enable-features=Vulkan --use-angle=vulkan
261
- ```
262
-
263
- Either flag alone leaves `requestAdapter()` returning `null`. To keep them, add
264
- them to the `Exec` line of the browser entry under
265
- `~/.local/share/applications`, or to the target of a desktop shortcut.
266
-
267
- `--enable-unsafe-webgpu` is a different switch: it only unlocks software
268
- adapters, so an adapter whose `info.vendor` reads `google` is SwiftShader on the
269
- CPU, not the GPU. Remove it when measuring speed. Headless Chromium hides
270
- `navigator.gpu` unless it starts with `--enable-unsafe-webgpu`,
271
- `--enable-features=Vulkan,WebGPU` and `--use-angle=vulkan`; check the adapter
272
- there too, because a missing adapter silently selects the CPU model.
273
-
274
- ### GPU memory
275
-
276
- The WebGPU engine keeps the model and its KV cache in GPU memory. The Gemma
277
- build carries no vision or audio encoder, so it never starts one and never
278
- resizes an image. A card that is already busy can lose the device
279
- (`VK_ERROR_DEVICE_LOST`) during a long generation. If the error appears, close
280
- other GPU tabs, unload the model, or switch to the CPU model. A lost device
281
- needs a page or model reload; later calls cannot reuse it.
282
-
283
- While a reply streams, the GPU engine rests after every token readback, so the
284
- card stays near 50% of wall time instead of pegging at 100% and starving the
285
- desktop compositor. `limits.gpuShare` overrides that share for one reply, from
286
- 0.05 to 1; `1` disables the rest and the default is 0.5. Each rest is capped
287
- at 500 ms, so a very slow token still makes progress. The rest applies after
288
- the prefill, so the first token is not delayed, but the prefill itself still
289
- runs unpaced. A freeze during loading or before the first token falls outside
290
- that rest; use a CPU model on those devices. Unloading releases the WebGPU
291
- device and resets the LiteRT wasm, because a deleted engine leaves both
292
- unusable; a later load rebuilds them from the stored model file without
293
- downloading it again.
97
+ `createChat` returns no promise and no controller. Every result arrives through
98
+ `onResponse`, and `onStatus` reports `requesting: false` when the reply ends.
99
+ `onSpeed` measures from the first token, so it excludes prompt processing. The
100
+ host appends the user and assistant messages itself; the package never writes
101
+ storage. A user turn may carry `images` or `audio`; see
102
+ [Prompt assembly](#prompt-assembly).
294
103
 
295
104
  ## Public API
296
105
 
297
106
  ```ts
298
107
  createChat(input: ChatInput): CancelChat
299
108
  preloadModel(input?: ChatPreloadInput): Promise<boolean>
300
- describeImages(input: DescribeImagesInput): Promise<ImageDescription[]>
301
- describeTranslated(input: DescribeTranslatedInput): Promise<string[]>
109
+ describeImage(input: DescribeImageInput): Promise<string>
110
+ descriptionEnhanced(input: DescriptionEnhancedInput): Promise<DescriptionEnhanced>
111
+ translateTags(input: TranslateTagsInput): Promise<ImageTag[]>
302
112
  describeModelId: ChatModelId
303
113
  imageReplyTokens: number
304
114
  gpuAdapter(): Promise<boolean>
305
115
  clearModelCache(): Promise<void>
306
116
  ```
307
117
 
308
- Call it once per response. A new call aborts the previous stream. When the
309
- requested model is already loaded, no download or load happens. One model stays
310
- resident on every device, so a new model replaces the old one, including a move
311
- between the CPU describer and the GPU translator. Files stay on disk, and
312
- `clearModelCache` releases the loaded model and deletes every cached model file
313
- from this device.
118
+ Call it once per response. A new call aborts the previous stream, and a model
119
+ already loaded skips the download and the load. One model stays resident, and
120
+ `clearModelCache` releases it and deletes every cached file.
314
121
 
315
122
  `ChatInput`:
316
123
 
@@ -327,137 +134,143 @@ from this device.
327
134
  | `onPromptSpeed?` | Receives live prompt processing speed in tokens per second. |
328
135
  | `onStatus?` | Receives a `ChatStatusSnapshot` on every state change. |
329
136
 
330
- Without `model` the package picks the GPU build when the browser provides a
331
- WebGPU adapter and the CPU build when it does not. `gpuAdapter` runs the same
332
- probe, so a host can check the device before it names a model. The budget
333
- defaults are 250 reply tokens, 128 CPU thinking tokens, 1200 GPU reasoning
334
- tokens (320 on the CPU), 2 images, 1 audio clip, 1000 characters per message,
335
- 10000 prompt characters, 32 CPU threads and a 0.5 GPU busy share. The CPU
336
- engine ignores `gpuShare`; the GPU engine applies it after prefill.
137
+ The budget defaults are 250 reply tokens, 128 CPU thinking tokens, 1200 GPU
138
+ reasoning tokens (320 on the CPU), 2 images, 1 audio clip, 1000 characters per
139
+ message, 10000 prompt characters, 32 CPU threads and a 0.5 GPU busy share. The
140
+ CPU engine ignores `gpuShare`; the GPU engine applies it after prefill.
337
141
 
338
- `CancelChat` is `() => void`. Its meaning depends on the current state:
142
+ `CancelChat` is `() => void`:
339
143
 
340
144
  | State | Effect |
341
145
  | ----- | ------ |
342
- | Downloading or loading | Stops waiting for the load. Bytes stay on disk, and a download another caller waits for keeps going. |
146
+ | Downloading or loading | Stops waiting; a shared download keeps going and its bytes stay. |
343
147
  | Ready | Aborts the reply and unloads the model that answered. |
344
148
  | An older call | Nothing, because a newer call owns the runtime. |
345
149
 
346
- The model stays loaded between calls until a cancel frees it, and a newer model
347
- replaces it. One model download serves every caller: a chat that joins a preload
348
- shares its progress and never starts a second download.
349
-
350
150
  ### Preload
351
151
 
152
+ Call `preloadModel` before the first `createChat` to load early. It resolves
153
+ `true` when the runtime is ready and `false` when the model is unknown,
154
+ unsupported or failed. A preload owns no cancel function, so a cancelled chat
155
+ leaves its download running.
156
+
157
+ `ChatPreloadInput` takes `model?`, `limits?`, `logging?` and `onStatus?`. The
158
+ final snapshot reports `ready: true`, `phase: LlmPhase.load` and `percent: 100`
159
+ on success, or an `error` with `availability: ChatStatus.offline` on failure.
160
+
161
+ ### Describe one image
162
+
352
163
  ```ts
353
- preloadModel(input?: ChatPreloadInput): Promise<boolean>
164
+ describeImage(input: DescribeImageInput): Promise<string>
354
165
  ```
355
166
 
356
- Call it before the first `createChat` to download and load the model early. It
357
- resolves `true` when the runtime is ready and `false` when the model is unknown,
358
- unsupported or failed. A later `createChat` for the same model reuses the loaded
359
- runtime, and either call joins a load already in flight. A preload owns no cancel
360
- function, so a chat that joins its download and is cancelled leaves the download
361
- running.
362
-
363
- A preload of another model replaces the resident one, because one model stays
364
- in memory at a time. A load of the same model still joins the one in flight.
167
+ Describe one image in English with `qwen3_vl_2b_instruct`, the Q4_K_M build of
168
+ Unsloth Qwen3-VL 2B Instruct. The describer replaces euphemisms with specific
169
+ terms, names visible organs and limbs for living things, and prioritizes
170
+ unusual details. It must never lie, use vague referrals, prefer a
171
+ general term over a precise one, or repeat the request.
172
+ `systemRulesOverride` replaces the default system message. The call uses the
173
+ `imageReplyTokens` budget of 128 tokens unless `limits.maxReplyTokens`
174
+ overrides it, requests 192 vision tokens for its resize, and samples at
175
+ temperature 0.1.
365
176
 
366
- `ChatPreloadInput` is the subset of `ChatInput` that a preload needs:
177
+ `DescribeImageInput` extends `CallOptions`:
367
178
 
368
179
  | Field | Description |
369
180
  | ----- | ----------- |
370
- | `model?` | `ChatModelId` to download and load. Defaults like `ChatInput.model`. |
371
- | `limits?` | Partial `LlmLimits`; `maxThreads` applies at load, `gpuShare` per reply. |
372
- | `logging?` | Whether package and engine console logs are written. Defaults to `false`. |
373
- | `onStatus?` | Receives a `ChatStatusSnapshot` on every progress and state change. |
181
+ | `image` | One image data URL or URL. |
182
+ | `systemRulesOverride?` | Replaces the default describer system message. |
183
+ | `onSpeed?` | Receives the generation speed, tokens per second. |
184
+ | `onText?` | Receives the accumulated streamed text. |
374
185
 
375
- The final snapshot reports `ready: true`, `phase: LlmPhase.load` and
376
- `percent: 100` on success, or an `error` with `availability: ChatStatus.offline`
377
- on failure. A preload owns no cancel function; a new load replaces it.
186
+ The promise resolves the trimmed English description, or an empty string when
187
+ the model returns no text. A finished load reports `percent: 100` and
188
+ `phase: LlmPhase.load` before the first text, so a host can clear its progress
189
+ row.
378
190
 
379
- ### Describe images
191
+ ### Enhance a description
380
192
 
381
193
  ```ts
382
- describeImages(input: DescribeImagesInput): Promise<ImageDescription[]>
194
+ descriptionEnhanced(
195
+ input: DescriptionEnhancedInput,
196
+ ): Promise<DescriptionEnhanced>
383
197
  ```
384
198
 
385
- Describe every image in English with `qwen3_vl_2b_instruct`, the Q4_K_M
386
- build of Unsloth Qwen3-VL 2B Instruct, one image at a time. When a WebGPU
387
- adapter exists and `targetLanguage` is not English, the English descriptions
388
- are then translated one by one with `gemma4_e2b_limited`. Without a GPU, when
389
- the translation model cannot load, or when the translator replies
390
- `CANNOT_TRANSLATE`, `description` stays equal to `descriptionEN`. Both the
391
- description and translation calls use the `imageReplyTokens` budget of 128
392
- tokens unless `limits.maxReplyTokens` overrides it. The describer requests
393
- 192 vision tokens for its image resize; the CPU engine keeps its instruct
394
- window at 16 to 128 tokens, and other models leave the engine image limits
395
- unset.
396
-
397
- The describer receives a system message that asks for every element of the
398
- image and adds rules against lying, verdicts and vague referrals.
399
- `DescribeImagesInput.additionalRules` appends host rules to that message. The
400
- translator translates its own previous reply, never the user's instruction,
401
- preserving meaning, names and numbers without adding or omitting anything.
402
-
403
- `DescribeImagesInput`:
199
+ Take one English description, translate it when the target is not English, then
200
+ build its gallery tags from the same text. Without a `model` override, the text
201
+ model is `gemma4_e2b_limited` when a WebGPU adapter exists and `qwen3_vl_2b`
202
+ otherwise. The tag pass asks for three English words, then fills the target
203
+ language key of each tag. A failed translation, or the reply
204
+ `CANNOT_TRANSLATE`, keeps the English description. A failed tag call leaves the
205
+ tags empty; a missing tag translation keeps the English tag.
404
206
 
405
- | Field | Description |
406
- | ----- | ----------- |
407
- | `targetLanguage` | Language of `description`; `en`, `English` or a tag such as `en-US` skips translation. |
408
- | `images` | Image data URLs or URLs, described in this order. |
409
- | `currentIndex?` | Receives `index`, then `index` and `descriptionEN`, then `index`, `descriptionEN`, `description` and the description time in milliseconds. |
410
- | `limits?` | Partial `LlmLimits` for both the description and translation calls. |
411
- | `logging?` | Whether package and engine console logs are written. Defaults to `false`. |
412
- | `onStatus?` | Receives a `ChatStatusSnapshot` while the describer and translator load and run. |
413
-
414
- The promise resolves one `ImageDescription` per image, in input order:
207
+ `DescriptionEnhancedInput` extends `CallOptions`:
415
208
 
416
209
  | Field | Description |
417
210
  | ----- | ----------- |
418
- | `descriptionEN` | The English description from the vision model. |
419
- | `description` | The translated description, or `descriptionEN` without a GPU. |
420
- | `durationMs` | Time the describer took for this image, in milliseconds. |
421
-
422
- `currentIndex` fires once per image at the start of its description, once when
423
- the English text is ready, and once when the final description is ready. The
424
- first call carries the index alone, the second adds `descriptionEN` and
425
- `durationMs`, and the third adds `description` while keeping `durationMs`. The
426
- function uses the shared runtime, so it loads the describer and then the
427
- translator, replacing whichever model was loaded before.
428
-
429
- `onStatus` follows the same snapshots as `createChat`. The describer reports
430
- first, with `supportsImages: true`. A finished load reports `percent: 100` and
431
- `phase: LlmPhase.load` before the first text, so a host can clear its progress
432
- row. When a translation pass follows, the snapshots switch to
433
- `gemma4_e2b_limited` while it loads and translates. The final snapshot has
434
- `requesting: false`; when the translator cannot load, it also carries that error
435
- and the descriptions stay English. An empty `images` list touches no model and
436
- reports nothing.
437
-
438
- ### Translate descriptions
211
+ | `descriptionEN` | The English description to translate and tag. |
212
+ | `targetLanguage` | Language name used in the prompts, such as `German`. |
213
+ | `targetCode` | Two-letter code used as the tag translation key, such as `de`. |
214
+ | `withTags?` | Whether tags are generated. Defaults to `true`. |
215
+ | `withDescription?` | Whether the description is translated. Defaults to `true`. |
216
+ | `model?` | Text model override; the package picks the GPU build when available. |
217
+ | `onStage?` | Receives `translate`, `tags` or `translateTags` as each pass starts. |
218
+ | `onSpeed?` | Receives the generation speed, tokens per second. |
219
+ | `onText?` | Receives the accumulated streamed text and its stage. |
220
+
221
+ `DescriptionEnhanced` holds `description` (the translation or English
222
+ fallback), `translated` (whether the target language is in use), `tags` (up to
223
+ three, with `en` and the target code, including `nsfw` when flagged) and
224
+ `nsfw?` boolean.
225
+
226
+ ### Translate stored tags
439
227
 
440
228
  ```ts
441
- describeTranslated(input: DescribeTranslatedInput): Promise<string[]>
229
+ translateTags(input: TranslateTagsInput): Promise<ImageTag[]>
442
230
  ```
443
231
 
444
- Translate English image descriptions that the host already has, without another
445
- vision pass. `describeImages` uses the same translation pass. When a WebGPU
446
- adapter exists, each description is translated one by one with
447
- `gemma4_e2b_limited`. Without a GPU, when the translator cannot load, or when it
448
- replies `CANNOT_TRANSLATE`, the result keeps the English text. The promise
449
- resolves one string per input description, in input order.
232
+ Translate tags a gallery already holds, without describing or tagging again.
233
+ Each tag keeps its `en` word and gains the `targetCode` key; a refusal or a
234
+ failed call returns the English tags unchanged. The text model follows the same
235
+ rule as `descriptionEnhanced`, and it is reused when it already sits in memory.
236
+
237
+ `TranslateTagsInput` extends `CallOptions`:
238
+
239
+ | Field | Description |
240
+ | ----- | ----------- |
241
+ | `tags` | The `ImageTag` list to translate. |
242
+ | `targetLanguage` | Language name used in the prompt, such as `German`. |
243
+ | `targetCode` | Two-letter code written as the tag translation key. |
244
+ | `model?` | Text model override; the package picks the GPU build when available. |
245
+ | `onText?` | Receives the accumulated streamed text. |
246
+
247
+ ### Call options
450
248
 
451
- `DescribeTranslatedInput`:
249
+ Every description and tag call takes the same `CallOptions`:
452
250
 
453
251
  | Field | Description |
454
252
  | ----- | ----------- |
455
- | `descriptions` | English descriptions, translated in this order. |
456
- | `targetLanguage` | Language of each result; `en`, `English` or a tag such as `en-US` skips translation. |
457
- | `currentIndex?` | Receives `index`, `descriptionEN` and `description` as each entry finishes. |
458
- | `limits?` | Partial `LlmLimits` for the translation call. |
253
+ | `signal?` | Stops the call and leaves finished work in place. |
254
+ | `limits?` | Partial `LlmLimits` for every model call of the request. |
459
255
  | `logging?` | Whether package and engine console logs are written. Defaults to `false`. |
460
- | `onStatus?` | Receives a `ChatStatusSnapshot` while the translator loads and runs. |
256
+ | `onStatus?` | Receives a `ChatStatusSnapshot` while the model loads and runs. |
257
+ | `onEstimate?` | Receives an `EstimateReport` before the model loads. |
258
+
259
+ ### Estimates
260
+
261
+ The three functions report stage durations through `onEstimate`, with `totalEst` and
262
+ a `breakdown` of `operation` and `est` seconds. `describeImage` reports
263
+ `analyze` and `describe`; `descriptionEnhanced` reports `translate`, `tags` and
264
+ `translateTags`; `translateTags` reports `translateTags` alone, leaving out a
265
+ stage that will not run. Measured stages are kept under the `localStorage` key
266
+ `degreesign-chat-estimates-v1`, with a built-in fallback until then.
267
+
268
+ ### Deprecated batch helpers
269
+
270
+ `describeImages` and `describeTranslated` remain as wrappers over the two
271
+ single-item functions. They are deprecated, will be removed after November 2027,
272
+ and skip translation when no WebGPU adapter exists. Use `describeImage` and
273
+ `descriptionEnhanced` for new code.
461
274
 
462
275
  ## Tools
463
276
 
@@ -481,13 +294,10 @@ const answer = await calculation.execute({ expression: `6*7` });
481
294
  | `WebSearchProvider` | `wikipedia` or `duckduckgo`. |
482
295
 
483
296
  Each tool has `name`, `description`, `parameters` (`ChatToolSchema`) and
484
- `execute(args)`, which returns a JSON-safe `ChatToolValue` or throws. `ChatTool`
485
- is that whole shape, and `ChatToolCall` / `ChatToolResult` describe a call and
486
- its outcome for a host that records them.
487
-
488
- `webSearchTool` takes `WebSearchConfig`: `provider`, a Wikipedia `language`
489
- edition, `limit` results, `snippetLength` and a `timeout` in milliseconds. The
490
- search and currency tools fetch over the network; `calculate` does not.
297
+ `execute(args)`, which returns a JSON-safe `ChatToolValue` or throws.
298
+ `webSearchTool` takes `provider`, a Wikipedia `language`, `limit`,
299
+ `snippetLength` and `timeout`. Search and currency fetch over the network;
300
+ `calculate` does not.
491
301
 
492
302
  ## Status
493
303
 
@@ -509,26 +319,14 @@ search and currency tools fetch over the network; `calculate` does not.
509
319
  | `error?` | The last failure, cleared when a reply starts. |
510
320
 
511
321
  The first snapshot fires synchronously inside `createChat`. A final snapshot
512
- with `requesting: false` marks the end of the call. A cancelled call also ends
513
- with that snapshot, once the model unloads or its wait on the load ends, so a
514
- host can clear its loading row. `describeImages` reports the same snapshot for
515
- each model it loads: `qwen3_vl_2b_instruct` while it describes, then
516
- `gemma4_e2b_limited` while it translates.
517
-
518
- A stream that never produces its first chunk ends with an error after 5
519
- minutes on a desktop or 50 minutes on a phone or tablet, and an image turn
520
- waits at least 14 minutes for a slow CPU vision prefill. Once a chunk has
521
- arrived, 30 seconds of silence ends the stream and keeps the text already
522
- delivered.
523
-
524
- ## Logging
525
-
526
- Console output stays off by default. Pass `logging: true` in a call to receive
527
- the package's censored engine logs, the wllama engine's own logs and the
528
- LiteRT-LM wasm diagnostics for that call. `describeImages` also logs every stage
529
- from the model load to the generated English description, including the image
530
- URL or data URL size and the raw reply. The flag is per call, so a later call
531
- without it goes quiet again.
322
+ with `requesting: false` marks the end, including a cancelled call.
323
+ `describeImage` and `descriptionEnhanced` report the same snapshots.
324
+
325
+ A stream that never produces its first chunk ends after 5 minutes on a desktop
326
+ or 50 minutes on a phone, and an image turn waits at least 14 minutes for a
327
+ slow CPU prefill. Once a chunk arrives, 30 seconds of silence ends the stream
328
+ and keeps the text already delivered. Console output stays off unless a call
329
+ passes `logging: true`.
532
330
 
533
331
  ## Models
534
332
 
@@ -539,47 +337,18 @@ without it goes quiet again.
539
337
  | `qwen3_vl_2b_instruct_q3` | Qwen3-VL 2B Instruct Q3 | No | Yes | No | Yes | No |
540
338
  | `gemma4_e2b_limited` | Gemma 4 E2B limited | Yes | No | No | No | Yes |
541
339
 
542
- `gemma4_e2b_limited` loads `gemma-4-E2B-it-gpu.litertlm` on every device. The
543
- build that runs is always the one in `ChatStatusSnapshot.model`. It has no
544
- vision or audio encoder, so `supportsImages` and `supportsAudio` report `false`
545
- there. Use `qwen3_vl_2b` for images.
546
-
547
- The first call downloads the model before its engine loads it. Later calls
548
- reuse the loaded runtime. One model stays resident, and a new model replaces it.
549
- An interrupted download keeps every part it committed and resumes from the last
550
- one on the next attempt, including after a reload.
551
-
552
- When the storage is full, the CPU engine reads the parts already on disk and
553
- streams only the missing tail over HTTP, so a nearly complete download still
554
- loads without writing a second copy. The WebGPU engine streams the whole file
555
- in that case.
556
-
557
- Qwen3-VL 2B downloads a vision projector of about 445 MB with the model, so its
558
- total download is about 1.5 GB. It runs on the CPU engine, with a 4096 token
559
- context on a phone and 8192 on a desktop. Its projector uses the same CPU thread
560
- budget as the text pass, so image turns wait behind the CPU queue.
561
-
562
- `qwen3_vl_2b_instruct` is the Unsloth Qwen3-VL 2B Instruct build that
563
- `describeImages` loads for the English pass, its Q4_K_M file. Both instruct
564
- builds use Qwen's Q8_0 projector, about 445 MB, so the describer totals about
565
- 1.5 GB on the CPU engine. It has no reasoning pass, so it answers an image
566
- prompt directly. `qwen3_vl_2b_instruct_q3` is the same model in Q3_K_M, about
567
- 160 MB smaller, for a host that prefers the smaller file and names it
568
- explicitly.
569
-
570
- Gemma 4 E2B downloads the 2.0 GB `gemma-4-E2B-it-gpu.litertlm`, the
571
- precompiled GPU_ARTISAN build Google ships for browsers. It runs on the WebGPU
572
- engine, carries no vision or audio encoder, and needs no projector download.
573
-
574
- The WebGPU engine asks for a 32768 token context on a desktop and 16384 on a
575
- phone, both multiples of 1024. Its int8 KV cache holds the full desktop window
576
- in about 300 MB, so the larger window stays a small part of the 2 GB model
577
- footprint.
578
-
579
- The WebGPU engine keeps one LiteRT conversation for consecutive turns. When the
580
- next prompt continues that history, only the new user turn is sent. A changed
581
- history opens a fresh conversation, and older images and audio become `[image]`
582
- and `[audio]` notes in its preface.
340
+ `qwen3_vl_2b` is the general and vision model: a 445 MB projector with the
341
+ model, about 1.5 GB in total, and a 4096 token context on a phone or 8192 on a
342
+ desktop. Its projector shares the text thread budget, so image turns wait.
343
+
344
+ `qwen3_vl_2b_instruct` is the Unsloth Q4_K_M build that `describeImage` loads,
345
+ with the same Q8_0 projector and no reasoning pass; the Q3_K_M variant is about
346
+ 160 MB smaller.
347
+
348
+ `gemma4_e2b_limited` loads the 2.0 GB `gemma-4-E2B-it-gpu.litertlm` with a
349
+ 32768 token context on a desktop and 16384 on a phone, without vision or audio
350
+ encoders. A first call downloads it and later calls reuse it; an interrupted
351
+ download resumes from the last committed part.
583
352
 
584
353
  ## Prompt assembly
585
354
 
@@ -593,135 +362,94 @@ assistant: {next turn}
593
362
  user: {next turn}
594
363
  ```
595
364
 
596
- - Leading assistant turns are dropped, because the transcript must start with a
597
- user turn.
598
- - Consecutive same-role turns are merged.
599
- - Messages with no text, images or audio are skipped.
365
+ - Leading assistant turns are dropped, consecutive same-role turns are merged,
366
+ and empty messages are skipped.
600
367
  - `maxContentLength` clips each message and `promptCharBudget` bounds the whole
601
- prompt.
368
+ prompt; image and audio payloads do not count against it.
602
369
  - System messages are folded into one leading system turn.
603
- - User turns may carry `images` or `audio`. They are sent only to a model with
604
- vision or audio support.
605
- - Images are collected from the newest turns first and capped by `maxImages`
606
- (default 2). Older image parts are dropped and their text stays.
607
- - Every image part keeps its full frame and targets 128 vision tokens, with at
608
- most 136. Both sides are snapped to multiples of 32 px and the remainder is
609
- cropped.
610
- - Audio is collected from the newest turns first and capped by `maxAudio`
611
- (default 1). Older audio parts are dropped and their text stays.
612
- - Image and audio payloads do not count against `promptCharBudget`.
613
- - An image-only turn with a text model reports `Model does not accept images`,
614
- and an audio-only turn reports `Model does not accept audio`.
370
+ - Images and audio come from the newest turns first, capped by `maxImages`
371
+ (default 2) and `maxAudio` (default 1). Older parts are dropped, text stays.
372
+ - Each image keeps its full frame and targets 128 vision tokens, at most 136,
373
+ snapped to multiples of 32 px with the remainder cropped.
374
+ - An image-only turn with a text model reports `Model does not accept images`;
375
+ an audio-only turn reports `Model does not accept audio`.
376
+
377
+ ## Enable WebGPU
378
+
379
+ A request without `model` picks `gemma4_e2b_limited` when the browser exposes a
380
+ WebGPU adapter and `qwen3_vl_2b` when it does not. `gpuAdapter()` runs the same
381
+ probe, and every snapshot reports the running build in `model`. Check before
382
+ blaming the package:
383
+
384
+ ```js
385
+ const adapter = await navigator.gpu?.requestAdapter();
386
+ adapter
387
+ ? `${adapter.info.vendor} ${adapter.info.architecture}`
388
+ : `no adapter`;
389
+ ```
390
+
391
+ `null` means the API exists but no adapter does. Chromium reports the state at
392
+ `chrome://gpu`: `WebGPU` should read `Hardware accelerated`. Firefox lists
393
+ features at `about:support`, and on Linux needs `dom.webgpu.enabled` set to
394
+ `true` in `about:config`.
395
+
396
+ Chrome, Edge and Brave ship WebGPU on Windows, macOS and ChromeOS. On Linux,
397
+ start the browser with both flags, since either one alone returns `null`:
398
+
399
+ ```bash
400
+ google-chrome --enable-features=Vulkan --use-angle=vulkan
401
+ ```
402
+
403
+ `--enable-unsafe-webgpu` only unlocks software adapters: a vendor that reads
404
+ `google` is SwiftShader on the CPU. Headless Chromium needs it plus
405
+ `--enable-features=Vulkan,WebGPU` and `--use-angle=vulkan`.
406
+
407
+ The GPU engine keeps the model and KV cache in GPU memory and rests after every
408
+ token readback, so the card stays near 50% of wall time. `limits.gpuShare`
409
+ overrides that for one reply, from 0.05 to 1; `1` disables the rest, and prefill
410
+ runs unpaced. A busy card can lose the device (`VK_ERROR_DEVICE_LOST`); close
411
+ other GPU tabs or use the CPU model.
615
412
 
616
413
  ## Engines and assets
617
414
 
618
- The engine is chosen per model:
619
-
620
- 1. WebGPU through LiteRT-LM when the model has a `litert` artifact and the
621
- browser exposes `navigator.gpu` and `WebAssembly`.
622
- 2. CPU through wllama when the model has a `cpu` artifact and the process
623
- exposes `WebAssembly`.
624
- 3. `LlmStatus.unsupported` otherwise, including Node.
625
-
626
- Both engines implement the internal `startLLM({ model, limits, signal,
627
- onProgress })` contract and return the runtime object with `model`, `backend`,
628
- `contextSize`, `chat()` and `unload()`. `createChat` starts the engine that
629
- matches the requested model.
630
-
631
- The CPU engine reads `navigator.hardwareConcurrency`, keeps a fifth of the
632
- logical cores free for the page (at least one) and gives the rest to the
633
- engine: 8 threads run with 7, 16 with 13, 4 with 3. `limits.maxThreads` only
634
- lowers the count, and phones follow the same rule; only the context window is
635
- smaller there. On an 8-thread desktop a 128-token image reaches its first token
636
- in 71.3 seconds with 7 threads, 82.3 seconds with 6 and 106.6 seconds with 4. A
637
- JavaScript probe was measured and rejected: it follows JS worker scaling, not
638
- the ggml pool, and its curve was too noisy to trust.
639
-
640
- The vision path runs two ggml pools, one for the projector and one for the
641
- model, so it needs twice the workers of a text model. wllama 3.8.1 pre-spawns
642
- only `n_threads` workers, which leaves the second pool without workers and
643
- stalls the encode at 3 or more threads. `scripts/patch-wllama.cjs` sizes the
644
- pool at twice the thread count on every build until the fix is released
645
- upstream; with it the image path runs at the heuristic count (7 threads on this
646
- 8-thread desktop) instead of 2. Every CPU model uses one count for the whole
647
- load.
648
-
649
- The build bundles wllama from the installed package, so `yarn build` runs
650
- `scripts/patch-wllama.cjs` first. The script is idempotent and fails loudly when
651
- the installed wllama no longer matches the patch. `patches/wllama-pool.patch`
652
- holds the same change as an upstream diff for wllama.
653
-
654
- The CPU engine needs cross-origin isolation for its threads. Serve the page with
655
- both headers:
415
+ The engine is chosen per model: WebGPU through LiteRT-LM when the model has a
416
+ `litert` artifact and the browser exposes `navigator.gpu` and `WebAssembly`;
417
+ CPU through wllama when the model has a `cpu` artifact; `LlmStatus.unsupported`
418
+ otherwise, including Node. Both implement `startLLM` and return `model`,
419
+ `backend`, `contextSize`, `chat()` and `unload()`.
420
+
421
+ The CPU engine keeps a fifth of the logical cores free (at least one) and gives
422
+ the rest to the engine, so 8 threads run with 7. `limits.maxThreads` only
423
+ lowers the count. The vision path runs two ggml pools, sized by
424
+ `scripts/patch-wllama.cjs` on every build.
425
+
426
+ The CPU engine needs cross-origin isolation for its threads:
656
427
 
657
428
  ```
658
429
  Cross-Origin-Opener-Policy: same-origin
659
430
  Cross-Origin-Embedder-Policy: require-corp
660
431
  ```
661
432
 
662
- Then assert `crossOriginIsolated === true` in the page. Without isolation the
663
- engine falls back to one thread and image encoding becomes much slower, since
664
- the projector runs on the CPU.
665
-
666
- Under `require-corp` every cross-origin subresource must be CORS enabled or
667
- carry `Cross-Origin-Resource-Policy`. Hugging Face downloads already qualify;
668
- third party images, fonts and embeds need `crossorigin="anonymous"` or must be
669
- self hosted. Chromium also accepts
670
- `Cross-Origin-Embedder-Policy: credentialless` when those assets cannot be
671
- changed. A page inside an iframe is isolated only when every ancestor sets the
672
- same headers.
673
-
674
- Each engine keeps its own cache. One model stays loaded, so a switch between
675
- the CPU and GPU models skips the download but still loads the new engine. The
676
- previous model's files always stay, so a switch back skips the download.
677
- `clearModelCache()` deletes them all and frees the storage. Both engines store
678
- model files in the origin private file system as numbered parts, under
679
- `wllama-store` for the CPU engine and `litert-store` for the GPU engine. The GPU
680
- engine also reads a Cache Storage entry under `litertlm-models` when an older
681
- version stored the file. A quota failure keeps the parts already stored, so
682
- freeing disk lets the next attempt resume, and the running load streams the
683
- model from the network so the assistant still starts.
684
-
685
- Both engines store the files before they load them, and one download serves
686
- every caller. A failed load, a cancelled call or a repeated `createChat` reads
687
- the stored parts instead of downloading them again. A download fetches bounded
688
- ranges of 32 MB, commits each part as it arrives and retries a stalled or cut
689
- connection from the last committed byte, so a reload loses at most one part.
690
- Each attempt checks the file size and entity tag, and a changed model starts
691
- over. A host that ignores the range replays the stored prefix and keeps the
692
- parts already stored. Hosts without a writable file system still stream from
693
- the network; the CPU engine falls back to the wllama cache manager and the GPU
694
- engine to Cache Storage, without resuming.
695
-
696
- A CPU vision model downloads its projector with the model and stores both files
697
- as parts. A GPU vision model keeps its encoder in the single `.litertlm` file.
698
-
699
- Both engines are bundled into `dist/browser` and `dist/node` as dynamic chunks.
700
- The package ships the wllama binaries under `dist/assets/wllama` and the LiteRT
701
- wasm under `dist/assets/litert`; model weights remain external.
433
+ Assert `crossOriginIsolated === true` in the page; without isolation the engine
434
+ falls back to one thread. Under `require-corp` every cross-origin subresource
435
+ must be CORS enabled or carry `Cross-Origin-Resource-Policy`.
702
436
 
703
- LiteRT-LM needs WebGPU and the JSPI and relaxed SIMD wasm features, currently
704
- available in Chromium. It loads a 22.7 MB wasm binary from `dist/assets/litert`
705
- and the 2.0 GB model file. The GPU engine reports progress while the file
706
- downloads and 100% of the load phase while the model compiles.
437
+ Both engines keep resumable parts in the origin private file system, under
438
+ `wllama-store` and `litert-store`. Downloads fetch 32 MB ranges and resume from
439
+ the last committed byte.
707
440
 
708
- wllama needs the compat worker on the same origin. When the app serves the
709
- package from `node_modules` in development, auto resolution works. In
710
- production, copy `dist/assets/wllama` next to the app bundle so the resolved
711
- asset URLs stay valid. The LiteRT wasm glue finds its binary in the same
712
- directory, so copy `dist/assets/litert` beside the bundle as well.
441
+ The package ships the wllama binaries under `dist/assets/wllama` and the LiteRT
442
+ wasm under `dist/assets/litert`. Copy both folders next to the app bundle in
443
+ production; wllama needs its compat worker on the same origin. LiteRT-LM needs
444
+ WebGPU with the JSPI and relaxed SIMD wasm features.
713
445
 
714
446
  ## Node.js
715
447
 
716
448
  The package imports and runs without a DOM, so it works in scripts, tests, SSR
717
- and server side orchestration. Everything except inference is available: prompt
718
- assembly, budgets, status state and cancellation.
719
-
720
- Both engines need a browser: wllama needs a worker and a wasm runtime, and
721
- LiteRT-LM needs WebGPU. In Node, `createChat` reports
722
- `LlmStatus.unsupported` and `ChatStatus.offline` through `onStatus`, and the
723
- reply callbacks stay silent. `preloadModel` resolves `false` and reports the
724
- same unsupported status.
449
+ and server side orchestration with prompt assembly, budgets, status and
450
+ cancellation. Both engines need a browser, so `createChat` there reports
451
+ `LlmStatus.unsupported` and `ChatStatus.offline`, the callbacks stay silent, and
452
+ `preloadModel` resolves `false`.
725
453
 
726
454
  ```js
727
455
  const { createChat, preloadModel, ChatModelId, LlmRole } = require("@degreesign/chat");
@@ -731,16 +459,14 @@ const { createChat, preloadModel, ChatModelId, LlmRole } = require("@degreesign/
731
459
  import { createChat, preloadModel, ChatModelId, LlmRole } from "@degreesign/chat";
732
460
  ```
733
461
 
734
- The Node entries are `dist/node/chat.node.min.js` for `require` and
735
- `dist/node/chat.node.min.mjs` for `import`. The package exports map selects them
736
- through the `node` condition and the browser bundle through the `browser`
737
- condition.
462
+ `require` resolves `dist/node/chat.node.min.js` and `import` resolves
463
+ `dist/node/chat.node.min.mjs`, both through the exports map.
738
464
 
739
465
  ## React
740
466
 
741
467
  Call `createChat` from the send handler, keep the cancel function in a ref, and
742
- unmount it with the panel. The package never touches the DOM while it is
743
- imported or called, so the same module renders during SSR.
468
+ unmount it with the panel. The package never touches the DOM, so the same
469
+ module renders during SSR.
744
470
 
745
471
  ```tsx
746
472
  import { useEffect, useRef, useState } from "react";
@@ -756,10 +482,7 @@ const useReply = () => {
756
482
  setReply("");
757
483
  cancelRef.current = createChat({
758
484
  model: ChatModelId.qwen3_vl_2b,
759
- messages: [
760
- ...history,
761
- { role: LlmRole.user, content: text },
762
- ],
485
+ messages: [...history, { role: LlmRole.user, content: text }],
763
486
  onResponse: setReply,
764
487
  });
765
488
  };
@@ -770,123 +493,65 @@ const useReply = () => {
770
493
 
771
494
  ## Types
772
495
 
773
- | Type | Description |
774
- | ---- | ----------- |
775
- | `ChatInput` | One request: model, messages, budgets, reasoning and callbacks. |
776
- | `ChatPreloadInput` | Model, limits, logging and status, the subset `preloadModel` needs. |
777
- | `DescribeImagesInput` | Target language, images, progress callback, limits and logging. |
778
- | `DescribeTranslatedInput` | English descriptions, target language, progress callback, limits and logging. |
779
- | `ImageDescription` | `descriptionEN`, the translated `description` and `durationMs`. |
780
- | `CancelChat` | The function returned by `createChat`. |
781
- | `ChatMessage` | One prompt turn, `role`, `content` and optional `images` and `audio`. |
782
- | `ChatStatusSnapshot` | Availability, engine status, backend, progress and request state. |
783
- | `LlmLimits` | Token, prompt, image, audio, thread and GPU share budgets, all overridable. |
784
- | `WllamaAssets` | Wasm, compat wasm and compat worker URLs. |
785
-
786
- The public enums are `ChatModelId`, `LlmRole`, `ChatStatus`, `LlmStatus`,
787
- `LlmBackend` and `LlmPhase`.
496
+ The inputs are `ChatInput`, `ChatPreloadInput`, `DescribeImageInput`,
497
+ `DescriptionEnhancedInput` and `TranslateTagsInput`, every image and tag input
498
+ built on `CallOptions`; the results are `DescriptionEnhanced`, `EstimateReport`
499
+ and `ImageTag`. `ChatMessage`, `ChatStatusSnapshot`, `LlmLimits`, `CancelChat`
500
+ and `WllamaAssets` describe the rest; the deprecated types are
501
+ `DescribeImagesInput`, `DescribeTranslatedInput` and `ImageDescription`. The
502
+ enums are `ChatModelId`, `LlmRole`, `ChatStatus`, `LlmStatus`, `LlmBackend`,
503
+ `LlmPhase`, `EstimateOperation`, `DescriptionEnhancedStage` and
504
+ `ChatPreloadField`.
788
505
 
789
506
  ## Testing
790
507
 
791
- The suites live in `test/` and run against the local checkout.
792
-
793
508
  `yarn test:instruct` starts a local llama.cpp server with the Qwen3-VL 2B
794
- Instruct Q4_K_M build:
795
-
796
- ```bash
797
- yarn test:instruct
798
- ```
799
-
800
- It runs `llama serve` on `http://127.0.0.1:8080` with a 160k context, 4
801
- threads and CPU inference. llama.cpp
802
- downloads the weights on first run and caches them outside the repository.
803
- llama.cpp runs about 10x faster than wllama, so prefer the live server
804
- whenever a test needs real inference. Keep the server in its own terminal
805
- while the live and image suites run.
806
-
807
- The image suite bundles the browser harness, serves it from `test/drive.cjs`
808
- and proxies `/llama/` to the server on port 8080, so start `test:instruct`
809
- first:
810
-
811
- ```bash
812
- yarn test:images
813
- ```
814
-
815
- The fake-engine harness needs no server and never reaches the network.
509
+ Instruct Q4_K_M build on `127.0.0.1:8080`, CPU only, caching the weights outside
510
+ the repository. `yarn test:images` runs the browser suite against it, and the
511
+ fake-engine harness needs no server. `yarn verify:node` checks the Node entry.
816
512
 
817
513
  ## Architecture
818
514
 
819
- An interactive map of the package lives at
820
- [degreesign.github.io/ds_chat/architecture.html](https://degreesign.github.io/ds_chat/architecture.html). It covers
821
- the two entries, every source module with line numbers, the helper edges and the
822
- send flow.
823
-
824
- A side-by-side comparison with `@mlc-ai/web-llm` for Gemma 4 E2B on WebGPU lives
825
- at [degreesign.github.io/ds_chat/vs_web_llm.html](https://degreesign.github.io/ds_chat/vs_web_llm.html).
515
+ The [interactive map](https://degreesign.github.io/ds_chat/architecture.html)
516
+ covers the two entries, every source module with line numbers, the helper edges
517
+ and the send flow. A
518
+ [comparison with `@mlc-ai/web-llm`](https://degreesign.github.io/ds_chat/vs_web_llm.html)
519
+ covers Gemma 4 E2B on WebGPU.
826
520
 
827
521
  ## FAQ
828
522
 
829
523
  ### What is @degreesign/chat?
830
524
 
831
525
  A headless on-device inference controller. It loads one model, streams one reply
832
- and reports status; the app owns the chat history, storage and UI.
833
-
834
- ### Does it store messages?
835
-
836
- No. The host passes the full prompt on every call and keeps the reply.
837
-
838
- ### Does it support images?
839
-
840
- Yes, on models with vision support. Put image URLs in a user turn's `images`
841
- array. Qwen3-VL 2B accepts images on the CPU engine and is the vision model in
842
- the catalog. Each image keeps its full frame and targets 128 vision tokens, at
843
- most 136, with both sides snapped to multiples of 32 px and the remainder
844
- cropped. The WebGPU build is text only, so a model without vision drops images
845
- when the turn also has text; an image-only turn reports
846
- `Model does not accept images`.
847
-
848
- ### Does it support audio?
849
-
850
- No model in the catalog has an audio encoder. A user turn can still carry an
851
- audio URL in `audio`, and `limits.maxAudio` caps the clips (default 1), but a
852
- model without audio support drops the clip when the turn also has text, and an
853
- audio-only turn reports `Model does not accept audio`.
854
-
855
- ### Does it need a server?
856
-
857
- No. Local chat, model download and inference work without a server. The package
858
- includes no server component.
526
+ and reports status; the app owns the chat history, storage and UI, passes the
527
+ full prompt on every call and keeps the reply.
859
528
 
860
- ### Does it have runtime dependencies?
529
+ ### Does it support images or audio?
861
530
 
862
- No runtime dependencies. `@wllama/wllama`, `@wllama/wllama-compat` and
863
- `@litert-lm/core` are build-time dependencies, bundled into the dynamic engine
864
- chunks and their wasm assets, so consumers resolve no engine import and
865
- download no engine code beyond the chunk they use.
531
+ Images work on models with vision support: put URLs in a user turn's `images`
532
+ array. A model without vision drops them when the turn also has text, and an
533
+ image-only turn reports `Model does not accept images`. No catalog model has an
534
+ audio encoder, so `audio` behaves the same way.
866
535
 
867
- ### Where are the models stored?
536
+ ### Does it need a server or runtime dependencies?
868
537
 
869
- Both engines store model files in the origin private file system as resumable
870
- parts, under `wllama-store` for the CPU engine and `litert-store` for the GPU
871
- engine. The GPU engine also reads a Cache Storage entry under `litertlm-models`
872
- when an older version stored the file. The package stores no model data itself.
538
+ No. Chat, download and inference run on the device, and the engines ship
539
+ bundled, so a host imports no engine and wires no peer dependency.
873
540
 
874
541
  ### Which model should I pick?
875
542
 
876
- Qwen3-VL 2B supports reasoning and images on the CPU, and it runs on any browser
877
- with WebAssembly. Gemma 4 E2B limited supports reasoning on WebGPU, with faster
878
- text generation but a larger download and a slower first compile. Use Qwen for
879
- broad browser support and for images. Use Gemma on a device with a WebGPU
880
- adapter when text speed matters.
543
+ Qwen3-VL 2B supports reasoning and images on the CPU, and runs on any browser
544
+ with WebAssembly. Gemma 4 E2B supports reasoning on WebGPU with faster text, but
545
+ a larger download and a slower first compile.
881
546
 
882
547
  ## Licensing
883
548
 
884
549
  The runtime code is MIT licensed. The inference engine and model weights are
885
- distributed by their authors under their own terms. Their items and terms are
886
- listed in `THIRD_PARTIES.md`. See `LICENSE`.
550
+ distributed by their authors under their own terms, listed in
551
+ `THIRD_PARTIES.md`. See `LICENSE`.
887
552
 
888
553
  ## Keywords
889
554
 
890
- `@degreesign/chat`, on-device AI chat, local LLM, wllama, litert, webgpu, gemma,
891
- wasm, chat controller, headless chat, TypeScript, browser, speech friendly
892
- replies.
555
+ `@degreesign/chat`, on-device AI chat, local LLM, wllama, litert, webgpu,
556
+ gemma, wasm, chat controller, headless chat, TypeScript, browser, speech
557
+ friendly replies.