lemonteaa commited on
Commit
f375a7a
Β·
1 Parent(s): d27b1ab

update readme, add custom header to allow multithread

Browse files
Files changed (1) hide show
  1. README.md +794 -14
README.md CHANGED
@@ -5,21 +5,801 @@ colorFrom: green
5
  colorTo: red
6
  sdk: static
7
  pinned: false
 
 
 
 
8
  ---
9
 
10
- llm.js lab - static bundle
11
- build commit: 1ab62ba
12
- llama.cpp: f7591a6a01f9273de03331732bdc0b39721168be
13
- built: 2026-11-08
14
 
15
- Serve this directory with any static server and open index.html:
16
- python3 -m http.server 8000 --directory .
17
- Models are not bundled: add a GGUF by URL (e.g. Hugging Face) or pick one
18
- from your disk. Everything else runs locally in the browser (wasm32, CPU).
19
- No COOP/COEP headers are needed (single-threaded build).
20
 
21
- This build adds, over the 0addf13 bundle:
22
- - tools: paste an OpenAI tools array, replies are split by the model's own
23
- PEG parser and calls shown as JSON with "Add tool response"
24
- - chat template params (chat_template_kwargs, e.g. enable_thinking)
25
- - conversation export as OpenAI JSON or Markdown
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  colorTo: red
6
  sdk: static
7
  pinned: false
8
+ custom_headers:
9
+ cross-origin-embedder-policy: require-corp
10
+ cross-origin-opener-policy: same-origin
11
+ cross-origin-resource-policy: cross-origin
12
  ---
13
 
14
+ # llm.js β†’ current llama.cpp (WASM, CPU only)
 
 
 
15
 
16
+ An experiment: rebuild [rahuldshetty/llm.js](https://github.com/rahuldshetty/llm.js) β€”
17
+ "run an LLM in the browser with llama.cpp + WebAssembly", last touched in 2023 β€”
18
+ against **today's llama.cpp**, keeping the same `LLM` JavaScript API.
 
 
19
 
20
+ Everything here was built and run: `llama.cpp` `f7591a6` (2026-11-07) β†’ 7.1 MB
21
+ `wasm32` CPU-only module (chat templates, tools, the PEG output parser and the
22
+ `mtmd` multimodal stack linked in), running
23
+ [LiquidAI/LFM2.5-VL-450M](https://huggingface.co/LiquidAI/LFM2.5-VL-450M-GGUF)
24
+ (`Q4_K_M`, 219 MiB) at ~5.6 tok/s single-threaded in Node, with chat templating
25
+ verified against the model's own Jinja template.
26
+
27
+ No WebGPU, no threads, no `SharedArrayBuffer`: the baseline that works in
28
+ browsers that only have plain WASM.
29
+
30
+ ---
31
+
32
+ ## TL;DR
33
+
34
+ | | llm.js (2023) | this |
35
+ |---|---|---|
36
+ | llama.cpp | `dd047b4` | `f7591a6a01f9` (2026-11-07) |
37
+ | entry point | patch adding `examples/main`, `callMain(argv)` | own C API, `src/llm_wasm.cpp` |
38
+ | output | scrape `printf` from stdout + regex filter | `EM_JS` token callback, exact streaming |
39
+ | model | GGUF into MEMFS, `-m /models/model.bin` | same (no buffer-load API exists anymore) |
40
+ | wasm | `wasm32`, 1 thread, SIMD | same, but now `LLAMA_WASM_MEM64=OFF` explicitly |
41
+ | prompt | raw completion only | raw **or** the GGUF's own Jinja template (engine linked in), preset fallback |
42
+ | run modes | one text prompt | tabbed demo: chat playground over an editable message list + raw completion |
43
+ | sampling | CLI flags | temperature, top_p, top_k, repetition penalty/window, seed, max new tokens, stop sequences |
44
+ | tools / reasoning | not supported | OpenAI-style `tools` handed to the template; the reply is split by the model's own PEG parser (tool calls as JSON, reasoning left inline in `think` tags) |
45
+ | template params | not supported | `chat_template_kwargs` (e.g. `enable_thinking`) for the Jinja render |
46
+ | export | not supported | the playground conversation as OpenAI JSON or Markdown, downloaded |
47
+ | images | not supported | an `mmproj` GGUF loaded next to the model; images attach to a message, are tokenized by `mtmd`, and export as OpenAI multi-part content |
48
+ | context / KV | `-c` flag, prompt re-evaluated every run | context length, KV type per K/V, unified cache, flash attention as launch params; prompt prefix reused across turns |
49
+ | grammar | `--grammar` argv | `llama_sampler_init_grammar` |
50
+ | threads | 1 (ggml clamps wasm without pthreads) | optional pthread build: N wasm workers, 8.2 β†’ 26.4 tok/s at 4 threads in Node; single-thread module is the default |
51
+
52
+ **The concept still works.** What broke are the three things llm.js depended on:
53
+ `examples/main` was deleted, CLI-driving (`callMain`) is gone, and the C API was
54
+ renamed/reworked (model/context/vocab/sampler/chat). Everything else is small.
55
+
56
+ ---
57
+
58
+ ## Measured results
59
+
60
+ Run on 4 vCPU, Node 27, single-threaded wasm, `n_ctx = 512`:
61
+
62
+ ```
63
+ llama.cpp wasm build: 0.9.0-dev (f7591a6)
64
+ model: LFM2.5-VL-450M-Q4_K_M.gguf (218.7 MiB, arch lfm2, 16 blocks, 65536 vocab)
65
+ loaded in 0.4s, n_ctx=512, chat_template=yes
66
+ prompt: "Explain in two sentences why WebAssembly is useful for running LLMs in a browser."
67
+ tokens: 48
68
+ generation: 8.58s (5.59 tok/s)
69
+ wasm binary: 7.1 MB (Jinja engine, common/chat, PEG parser, parsers/*, mtmd)
70
+ CPU compute buffer reserved for n_batch=512: 138.52 MiB
71
+ ```
72
+
73
+ Output (greedy-ish, temp 0.7):
74
+
75
+ > WebAssembly provides a high-performance, low-level runtime environment that enables efficient and scalable execution of large-scale machine learning models, suc…
76
+
77
+ Multi-turn chat (`chat: true`) produces the same quality as the native build for
78
+ simple user messages, because the prompt is verified token-identical (below).
79
+
80
+ ## Layout
81
+
82
+ ```
83
+ llama.cpp/ shallow clone of ggml-org/llama.cpp (gitignored)
84
+ llama.cpp.lock revision the results below were produced with
85
+ src/llm_wasm.cpp the C API that replaces llm.js' patched CLI
86
+ scripts/fetch-llama.sh clone llama.cpp at the pinned revision (shallow)
87
+ scripts/build-wasm.sh llama.cpp (wasm32, static) + wrapper -> web/wasm/
88
+ scripts/fetch-model.sh download the test GGUF into models/
89
+ web/index.html tabbed demo: chat playground, raw completion, template editor, library
90
+ web/llm.js LLM class, same API as llm.js
91
+ web/worker.js worker: download -> MEMFS -> wasm -> stream tokens
92
+ web/download.js resumable chunked download (Range + restart fallback)
93
+ web/storage.js IndexedDB model library (uuid records + 4 MiB chunks)
94
+ web/sha256.js streaming SHA-256, for model fingerprints
95
+ web/actions.js worker message ids
96
+ web/wasm/ build output + llm-wasm.build.txt (commit stamp)
97
+ test/node-test.mjs headless inference test
98
+ test/download-test.mjs pause/resume/Range tests for the downloader
99
+ test/storage-test.mjs model library CRUD, chunk iteration, v1 -> v2 migration
100
+ test/sha256-test.mjs streaming SHA-256 vs node's crypto
101
+ test/chat-template-test.mjs Jinja template: preset equivalence, override, error fallback
102
+ test/chat-generate-test.mjs stepwise generation: streaming, stop sequences, interrupt, limits
103
+ test/tool-call-test.mjs tools in the template, enable_thinking, PEG tool-call parsing, tool turns
104
+ test/kv-cache-test.mjs ctx/KV launch params, quantized caches, prefix reuse across turns
105
+ test/multimodal-test.mjs mmproj load, an image prompt through mtmd, marker/image mismatch
106
+ test/browser-test.py drives the demo page in headless Chromium
107
+ test/dump-chat-template.mjs, verify-chat-template.{mjs,py}, verify-tokens.mjs
108
+ ```
109
+
110
+ ## Build
111
+
112
+ Requires Emscripten. `emsdk` is expected at `../tools/emsdk` (override `EMSDK=`).
113
+
114
+ ```bash
115
+ scripts/fetch-llama.sh # llama.cpp at the pinned revision (~100 MB, shallow)
116
+ scripts/build-wasm.sh # clean build ~3-4 min on 4 cores
117
+ scripts/fetch-model.sh # 219 MiB
118
+ ```
119
+
120
+ The checkout is not committed (it is a nested git repo, ~1 GB of history).
121
+ `llama.cpp.lock` records the revision used for the results above;
122
+ `build-wasm.sh` fetches it when `llama.cpp/` is missing and warns when your
123
+ checkout has moved past it. Updating is expected β€” llama.cpp changes weekly:
124
+
125
+ ```bash
126
+ rm -rf llama.cpp && LLAMA_REF=master scripts/fetch-llama.sh
127
+ scripts/build-wasm.sh
128
+ node test/node-test.mjs models/LFM2.5-VL-450M-Q4_K_M.gguf "Hi" 32
129
+ ```
130
+
131
+ If a future llama.cpp breaks the build or the API, the compile error points at
132
+ `src/llm_wasm.cpp`; the porting notes below list what tends to move.
133
+
134
+ What the script does, and why each flag is there:
135
+
136
+ ```bash
137
+ emcmake cmake -S llama.cpp -B build/llama-wasm \
138
+ -DBUILD_SHARED_LIBS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_TOOLS=OFF \
139
+ -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_COMMON=OFF -DLLAMA_CURL=OFF \
140
+ -DLLAMA_WASM_MEM64=OFF # upstream defaults to wasm64 for EMSCRIPTEN
141
+ -DLLAMA_BUILD_HTML=OFF # otherwise the target suffix becomes .html
142
+ -DGGML_NATIVE=OFF -DGGML_OPENMP=OFF -DGGML_BACKEND_DL=OFF -DGGML_CPU=ON
143
+ -DCMAKE_C_FLAGS="-O3 -msimd128" -DCMAKE_CXX_FLAGS="-O3 -msimd128"
144
+ cmake --build build/llama-wasm --target llama -j$(nproc)
145
+
146
+ em++ src/llm_wasm.cpp llama.cpp/common/jinja/*.cpp \
147
+ llama.cpp/common/json.cpp llama.cpp/common/unicode.cpp \
148
+ -O3 -msimd128 -fexceptions \
149
+ -I llama.cpp/include -I llama.cpp/ggml/include -I llama.cpp/common -I llama.cpp/vendor \
150
+ -Wl,--start-group $(find build/llama-wasm -name '*.a') -Wl,--end-group \
151
+ -o web/wasm/llm-wasm.js \
152
+ -sDISABLE_EXCEPTION_CATCHING=0 \
153
+ -sMODULARIZE=1 -sEXPORT_ES6=1 -sEXPORT_NAME=createLLMWasmModule \
154
+ -sENVIRONMENT=web,worker,node -sFORCE_FILESYSTEM=1 -sNO_EXIT_RUNTIME=1 \
155
+ -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=4GB -sINITIAL_MEMORY=64MB -sSTACK_SIZE=8MB \
156
+ -sEXPORTED_FUNCTIONS=... -sEXPORTED_RUNTIME_METHODS='["FS","ccall","cwrap","UTF8ToString"]'
157
+ ```
158
+
159
+ Notes that cost time to discover:
160
+
161
+ * **`em++`, not `emcc`, for the final link.** `emcc` treats `.cpp` as C++ for
162
+ compiling but links without libc++, so every `std::string`/`operator new`
163
+ symbol is undefined.
164
+ * **`-DLLAMA_WASM_MEM64=OFF`** β€” since wasm memory64 landed, llama.cpp's
165
+ `CMakeLists.txt` adds `-sMEMORY64=1` for `EMSCRIPTEN` by default. 64-bit wasm
166
+ memory is not available in every browser/toolchain, and 4 GiB is enough for a
167
+ 450M Q4 model, so this build stays on wasm32.
168
+ * **`libcommon` is not built** (`LLAMA_BUILD_COMMON=OFF`). `tools/` and
169
+ `examples/` are skipped entirely under EMSCRIPTEN, so the CLI/server path is
170
+ not available at all β€” the API has to be ours.
171
+ * `-msimd128` is what enables `ggml/src/ggml-cpu/arch/wasm/quants.c`; upstream
172
+ also sets it per-target in `ggml/src/ggml-cpu/CMakeLists.txt`.
173
+ * **`mtmd` is compiled in** (`tools/mtmd/*.cpp` + `tools/mtmd/models/*.cpp`,
174
+ minus the CLI): upstream's CMake target links `vendor::stb`, miniaudio and
175
+ `vendor::hash`, so the build adds `vendor/hash/hash.cpp` and `sha256.c`. That
176
+ last file is **C**: `em++` would compile it as C++ and mangle `sha256_hash`
177
+ (`wasm-ld: undefined symbol`), so the script builds it with `emcc` into an
178
+ object and links that. `MTMD_VIDEO` stays off (it needs ffmpeg); audio sources
179
+ compile but were not run.
180
+ * The build writes `web/wasm/llm-wasm.build.txt` with the exact llama.cpp commit,
181
+ so results stay attributable while upstream moves weekly.
182
+
183
+ ## Threads (optional, cross-origin isolation)
184
+
185
+ There are two builds of the same sources:
186
+
187
+ | build | flag | works on | threads |
188
+ |---|---|---|---|
189
+ | `web/wasm/llm-wasm.*` | `scripts/build-wasm.sh` | any page | 1 (ggml clamps it) |
190
+ | `web/wasm/llm-wasm-mt.*` | `MT=1 scripts/build-wasm.sh` | pages with COOP/COEP | 1..N (`llmjs_set_threads`) |
191
+
192
+ The pthread build needs `SharedArrayBuffer`, which browsers expose only to a
193
+ **cross-origin isolated** page. `test/serve.py` is `http.server` plus the two
194
+ headers (`Cross-Origin-Opener-Policy: same-origin`,
195
+ `Cross-Origin-Embedder-Policy: require-corp`); GitHub Pages and most static
196
+ hosts cannot set them, which is why the single-thread module stays the default
197
+ and the worker picks the module at runtime:
198
+
199
+ ```js
200
+ const THREADED = typeof SharedArrayBuffer === "function" && self.crossOriginIsolated === true;
201
+ factory = THREADED ? await import("./wasm/llm-wasm-mt.js") : await import("./wasm/llm-wasm.js");
202
+ ```
203
+
204
+ The thread count is a launch parameter like context length or KV type: it goes
205
+ into the context signature, so changing it rebuilds `llama_context` (Reload
206
+ engine), and it also drives mtmd's image encode/decode (`p.n_threads`).
207
+
208
+ **Measured in Node** (`test/threads-test.mjs`, LFM2.5-VL-450M Q4_K_M, greedy,
209
+ same prompt and output):
210
+
211
+ ```
212
+ 1 thread: 8.22 tok/s (2921 ms for 24 tokens)
213
+ 4 threads: 26.36 tok/s ( 911 ms for 24 tokens) β†’ 3.21Γ— wall time
214
+ prompt eval: 1605 ms β†’ 1032 ms
215
+ ```
216
+
217
+ **Threads are pre-spawned, and that is not optional.** `pthread_create()` called
218
+ from inside a wasm call deadlocks in a browser: the calling thread blocks in
219
+ `Atomics.wait` while the worker it just asked for has to fetch and boot the same
220
+ ES module, and it never gets to report ready. Node is fine because
221
+ `worker_threads` start differently. So the module is built with
222
+ `-sPTHREAD_POOL_SIZE` set to a JS expression that runs at module init:
223
+
224
+ ```
225
+ "-sPTHREAD_POOL_SIZE=${PTHREAD_POOL_SIZE:-Math.max(1, Math.min((typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 4, 8))}"
226
+ ```
227
+
228
+ The pool therefore adapts to the machine that loads the page (capped at 8), and
229
+ the thread field is capped to the same number. Choosing fewer threads than the
230
+ pool costs nothing at run time β€” the extra workers sit idle on shared memory and
231
+ model load stays ~10 s. Asking for more than the pool size reproduces the old
232
+ silent hang, which is why the field can't go above it.
233
+
234
+ Measured in headless Chromium, 450M Q4_K_M, 8 new tokens, same prompt:
235
+
236
+ | threads | decode | vs 1 thread |
237
+ |---|---|---|
238
+ | 1 | 0.98 s, 8.18 tok/s | 1.0x |
239
+ | 2 | 0.58 s, 13.74 tok/s | 1.68x |
240
+ | 4 | 0.43 s, 18.56 tok/s | 2.27x |
241
+
242
+ The page now defaults to `min(cores, 4)` threads when the page is cross-origin
243
+ isolated, and `?threads=N` still forces a specific count (`?threads=1` for the
244
+ old single-thread behaviour). Without COOP/COEP it falls back to the
245
+ single-thread module.
246
+
247
+ Useful facts found while building it:
248
+
249
+ * `-pthread` on the CMake flags defines `__EMSCRIPTEN_PTHREADS__`, which is what
250
+ stops `ggml_graph_plan()` from forcing `n_threads = 1`.
251
+ * the MT build caps memory at 2 GB (safe `SharedArrayBuffer` size across
252
+ browsers) instead of the single-thread build's 4 GB.
253
+ * Emscripten warns that `-pthread` + `ALLOW_MEMORY_GROWTH` makes the JS side of
254
+ growth slow; the model load grows the heap once, before generation.
255
+ * `vendor/hash/sha256.c` still has to be compiled as C (`emcc`) in both builds.
256
+
257
+ ## Run
258
+
259
+ Headless (this is what was verified here):
260
+
261
+ ```bash
262
+ node test/node-test.mjs models/LFM2.5-VL-450M-Q4_K_M.gguf "Hi" 48
263
+ NO_CHAT=1 node test/node-test.mjs ... # raw completion, no template
264
+ CHAT_TEMPLATE=chatml node test/node-test.mjs ... # force a built-in template
265
+ ```
266
+
267
+ ### Browser demo
268
+
269
+ ```bash
270
+ python3 -m http.server 8000 --directory web
271
+ ```
272
+
273
+ Then open <http://localhost:8000> (serve it β€” `file://` cannot load ES modules
274
+ or `fetch` the wasm; any static server works, no CORS/COOP headers needed for
275
+ the single-threaded build).
276
+
277
+ The page gives you a small **model library**: any number of models, each with a
278
+ uuid record, an editable display name, its size, its SHA-256 and a state
279
+ (`complete`, `partial`, `needs file`).
280
+
281
+ * **from a URL** β€” 4 MiB chunks into IndexedDB, progress bar, `Pause` (aborts the
282
+ fetch, keeps stored chunks), `Resume` (continues with an HTTP `Range`
283
+ request), `delete` (drops the record + every chunk). If the server ignores
284
+ `Range` and returns `200`, the download restarts cleanly instead of corrupting
285
+ the file. Adding the same URL twice reuses the record instead of storing two
286
+ 200 MB copies.
287
+ * **from this computer** β€” a GGUF you already have. By default **nothing is
288
+ copied**: the `File` is handed to the worker and read in 4 MiB slices straight
289
+ into the wasm FS (and the SHA-256), so the browser holds exactly one copy β€” the
290
+ one already on your disk. After a reload the record reads `needs file` and asks
291
+ you to pick it again; nothing is re-downloaded. Tick *keep a copy in browser
292
+ storage* if you would rather have it live in IndexedDB (still one copy, and the
293
+ original file is not needed afterwards).
294
+ * **sha256** β€” computed once per model, streamed from wherever its bytes live
295
+ (`crypto.subtle.digest()` is one-shot, so `web/sha256.js` is a small incremental
296
+ SHA-256), shown in the row (full value on hover) with a `copy hash` button so it
297
+ can be compared with the publisher's. Paste an expected hash when adding and
298
+ the row marks it βœ“ or β‰  expected.
299
+ * **pick the model from a dropdown** β€” the list of library models; `Load` runs the
300
+ selected one (a local file asks for its file again if it was not copied), and
301
+ `Reload engine` frees the model and the wasm backend and loads it once more, so
302
+ switching models or recovering from a bad state does not need a page reload.
303
+ * **tabs** β€” *Chat playground*, *Raw completion*, *Chat template*, *Models*. The
304
+ sampling/limits rail sits next to the two run tabs, so both modes share it.
305
+ * **chat playground** β€” the message list *is* the conversation: add, remove and
306
+ reorder `system` / `user` / `assistant` turns and write them yourself, then
307
+ `Generate`. The assistant turn is appended before the first token and streams
308
+ into place, so a human-written transcript can be continued turn by turn.
309
+ `Stop` in the top bar interrupts and keeps whatever already arrived; the turn
310
+ stays editable afterwards. (Stepwise generation, below, is what makes that
311
+ possible without threads.)
312
+ * **tools** β€” paste an OpenAI `tools` array in the chat tab's `tools` box and it is
313
+ handed to the template for rendering. The reply is then run through the PEG
314
+ parser built for that exact prompt, so a model that answers with
315
+ `<function name="get_weather"><param name="city">Paris</param></function>`
316
+ (MiniCPM5's format) produces a tool-call card: name, id, and the arguments as
317
+ a JSON tree, plus **Add tool response** which appends a `tool` message already
318
+ carrying that `tool_call_id`. The call markup is removed from the transcript
319
+ text, which keeps what the model said before calling. Reasoning is deliberately
320
+ **not** extracted: it stays in the assistant text inside its `think` block,
321
+ exactly as the model emitted it. The JSON tree is a small local renderer, not
322
+ a CDN library, so the lab still works offline.
323
+ * **export** β€” `Export JSON` downloads `[{ role, content }, …]` in OpenAI shape
324
+ (assistant turns keep `tool_calls`, tool turns keep `tool_call_id`, and a
325
+ message with images gets the multi-part content list with
326
+ `{"type":"image_url","image_url":{"url":"data:image/png;base64,…"}}` parts);
327
+ `Export Markdown` downloads the same transcript with `### SYSTEM` / `### USER` /
328
+ `### ASSISTANT` / `### TOOL (call_id)` headers, fenced JSON for calls and a
329
+ literal `<< image >>` where an image was. No conversation library, no cloud:
330
+ it is a lab, and the file is the storage.
331
+ * **images** β€” an uploaded GGUF can be flagged **mmproj** (checkbox in either
332
+ ingest form; it is then left out of the text-model dropdown). In the engine
333
+ rail, tick *load multimodal projector* and pick the projector that is loaded
334
+ with the next model load / `Reload engine`. In the playground, the πŸ–Ό button on
335
+ a message (or a drag & drop onto it) attaches PNG/JPEG/WebP images as
336
+ mini-avatar thumbnails, each with its own βœ• to remove it. The message goes
337
+ through the template with one media marker per image and `mtmd` replaces each
338
+ marker with that image's token sequence; the template preview deliberately
339
+ shows the marker itself, since image tokens sit below the rendered text.
340
+ * **raw completion** β€” no template at all: the text is the prompt, which is what
341
+ a base/completion model expects (and a chat model then answers as plain text).
342
+ * **engine** β€” the launch parameters the rail fixes when a context is created:
343
+ context length, KV cache type for K and V (`f16`, `q8_0`, `q4_0`), flash
344
+ attention (`auto`/`off`/`on`), the unified KV cache, and the optional mmproj. `Reload engine` applies
345
+ a change (a `llama_context` cannot be resized in place); loading a model also
346
+ applies the current values. A quantized V cache switches flash attention on,
347
+ because llama.cpp refuses that combination otherwise. Parallel sequences stay
348
+ at one: extra slots would only duplicate a cache a single user cannot fill.
349
+ This build has no server-style prompt-cache RAM pool (that 8 GiB default is
350
+ `common_params::cache_ram_mib`, used by `llama-server`) β€” memory is the model
351
+ plus the KV cache, which grows with context length and shrinks with the
352
+ quantized types.
353
+ * **sampling / limits** β€” temperature, top_p, top_k, repetition penalty and its
354
+ window, seed (`-1` = random), max new tokens, and stop sequences (one per line,
355
+ `\n`/`\t` expanded). Stops are matched in C++ against the decoded text, so they
356
+ never appear in the output; the last `len-1` bytes are held back until a match
357
+ is impossible, and the finished text comes from `llmjs_last_output()` instead
358
+ of being reassembled from the stream.
359
+ * **chat template** tab β€” the textarea is prefilled from the GGUF; unchecking
360
+ "use the template shipped in the GGUF" makes it editable and the override wins
361
+ for the next run. A `parameters JSON` box adds template kwargs (e.g.
362
+ `{"enable_thinking": true}`), and the *enable_thinking* checkbox in the rail
363
+ writes that key for you; both the preview and the next run use them. `Preview with the playground messages` renders the current
364
+ transcript without generating β€” the fastest way to see why a model answers
365
+ badly. The stats line reports which path ran (`template: jinja`,
366
+ `jinja:override`, `builtin:<name>`, `raw`).
367
+ * **llama.cpp log** β€” collapsible, shows the wasm build version and load messages.
368
+
369
+ The two ingest paths are deliberately separate in the UI (pasting a URL and
370
+ picking a file are different gestures, and only the file path has a "copy or
371
+ read in place" decision) but converge immediately: one record type, one load
372
+ path, one hash, one delete.
373
+
374
+ Everything stays local: no server-side component, no model upload, and the
375
+ cached model is visible/removable from the page (browser quota shown at the top).
376
+
377
+ `web/llm.js` keeps the original API:
378
+
379
+ ```js
380
+ const app = new LLM('GGUF_CPU', modelUrl,
381
+ () => {}, // init
382
+ (text) => {}, // streaming tokens
383
+ (stats) => {}, // complete: { tokens, ms, tokens_per_second }
384
+ (err) => {}, // NEW optional: error
385
+ (loaded, total) => {}, // NEW optional: download progress
386
+ (line) => {}); // NEW optional: llama.cpp logs
387
+ app.load_worker({ context_size: 1024,
388
+ kv: { type_k: 'q8_0', type_v: 'q8_0', flash_attn: -1,
389
+ kv_unified: true, n_parallel: 1 },
390
+ mmproj_id: 'uuid-of-a-flagged-mmproj' }); // optional projector
391
+ app.run({ prompt: 'Hi', chat: true, max_token_len: 48, temp: 0.7 }); // old shape, still works
392
+
393
+ // chat completion over a whole transcript (what the playground sends)
394
+ app.run({
395
+ mode: 'chat',
396
+ messages: [{ role: 'system', content: 'You are terse.' },
397
+ { role: 'user', content: 'Hi' }],
398
+ temp: 0, top_p: 0.9, top_k: 40, repeat_penalty: 1.1, repeat_last_n: 32,
399
+ seed: 42, max_token_len: 64, stop: ['\n\n'], chat_template_source: '',
400
+ tools: JSON.stringify(tools), // OpenAI tools array, handed to the template
401
+ template_kwargs: '{"enable_thinking": true}',
402
+ images: ['data:image/png;base64,…'], // one per <__media__> marker, in order
403
+ });
404
+ // stats.parsed = { content, reasoning_content, tool_calls: [{ id, name, arguments }] }
405
+ // null when the template's parser did not recognise the reply
406
+ // stats.run_stats = { prompt_tokens, reused_tokens, prompt_ms, n_ctx }
407
+ app.run({ mode: 'completion', prompt: 'The capital of France is' });
408
+ app.stop(); // keeps the tokens already streamed, completes with interrupted: true
409
+ ```
410
+
411
+ `run()` is stepped inside the worker (`llmjs_gen_begin_* β†’ llmjs_gen_step β†’
412
+ llmjs_gen_end`): one token per turn of the event loop. That is what allows a
413
+ `stop` message to be received at all in a single-threaded build, and it keeps
414
+ tokens flowing to the page as they are produced.
415
+
416
+ Model library β€” a uuid record per model; url and local-file ingest produce the
417
+ same record and load through the same code path:
418
+
419
+ ```js
420
+ app.add_url_model({ url, name: 'LFM 450M', expected_sha256: '' });
421
+ app.add_file_model({ file, name: '', keep_copy: false }); // nothing copied by default
422
+ app.load(id);
423
+ app.hash_model(id);
424
+ app.delete_model(id);
425
+ ```
426
+
427
+ Deviations from llm.js: the GGUF is downloaded **inside the worker** (posting a
428
+ 219 MB `ArrayBuffer` across the thread boundary would double memory), it is
429
+ cached in IndexedDB rather than the Cache API so pauses survive reloads, and the
430
+ new callbacks/progress exist because there is no stdout to scrape anymore.
431
+ Extra methods: `pause()`, `resume()`, `load(id)`, `add_url_model()`,
432
+ `add_file_model()`, `hash_model(id)`, `delete_model(id)`, `list_models()`, plus
433
+ an `on_event` hook for worker messages (`MODEL_ADDED`, `MODEL_UPDATED`, `PAUSED`,
434
+ `DELETED`, …).
435
+
436
+ ### Embedding the playground (minimal postMessage API)
437
+
438
+ The page can be used as an iframe by a host page β€” the host keeps its own UI and
439
+ borrows this one's model library and wasm engine:
440
+
441
+ ```html
442
+ <iframe src="http://localhost:8010/llmjs-latest/web/" width="900" height="700"></iframe>
443
+ <script>
444
+ const frame = document.querySelector("iframe").contentWindow;
445
+ addEventListener("message", (e) => {
446
+ if (e.data.type === "llmjs:ready") backend = e.data; // model loaded
447
+ if (e.data.type === "llmjs:token") append(e.data.text); // streaming
448
+ if (e.data.type === "llmjs:done") finish(e.data.text, e.data.stats);
449
+ if (e.data.type === "llmjs:error") fail(e.data.error);
450
+ });
451
+ // after a model is loaded in the frame:
452
+ frame.postMessage({ type: "llmjs:run", id: 1,
453
+ messages: [{ role: "user", content: "Say hi in five words." }],
454
+ max_tokens: 24 }, "*");
455
+ </script>
456
+ ```
457
+
458
+ Protocol (this iteration: chat completion only):
459
+
460
+ | direction | message |
461
+ |---|---|
462
+ | parent β†’ frame | `llmjs:run` `{ id, messages \| prompt, images?, max_tokens?, temperature?, top_p?, top_k?, repeat_penalty?, repeat_last_n?, seed?, stop?, tools?, template_kwargs? }` |
463
+ | parent β†’ frame | `llmjs:stop` (keeps what streamed, like the Stop button) |
464
+ | frame β†’ parent | `llmjs:api` `{ version }` once the frame is ready |
465
+ | frame β†’ parent | `llmjs:ready` `{ model, n_ctx, threads }` when a model finishes loading |
466
+ | frame β†’ parent | `llmjs:token` `{ id, text }` per token, `llmjs:done` `{ id, text, stats }`, `llmjs:error` `{ id, error }` |
467
+
468
+ A model still has to be loaded inside the frame first (that is where the library
469
+ and the wasm module live), one run at a time, and the frame accepts messages
470
+ from `window.parent` regardless of origin β€” fine for a local lab, but a public
471
+ deployment should check `event.origin` before answering. The host page needs the
472
+ frame's URL to be same-origin or CORS/COOP-friendly; the threaded build's
473
+ COOP/COEP headers do not leak to the parent page.
474
+
475
+ ---
476
+
477
+ ## What actually broke between llm.js and today's llama.cpp
478
+
479
+ `llm.js` patched the root `CMakeLists.txt` to add `examples/main` and called
480
+ `Module.callMain(["-m", ..., "-p", ...])`, filtering `main:` / `*_model_load:`
481
+ lines out of stdout with regexes. Current status of each assumption:
482
+
483
+ 1. **`examples/main` no longer exists.** The patch does not apply; there is no
484
+ CLI to embed. (The current CLI lives in `tools/cli`, is excluded from
485
+ Emscripten builds, and pulls in `common/` + `arg.cpp`.)
486
+ 2. **`callMain` is the wrong shape anyway.** Token streaming now comes from
487
+ `llama_sampler_sample` β†’ `llama_token_to_piece` β†’ an `EM_JS` callback, so
488
+ output is exact and no regex filtering is needed.
489
+ 3. **No buffer loading.** `llama_model_load_from_buffer` is gone; only
490
+ `llama_model_load_from_file`, `..._file_ptr`, `..._splits`. So the GGUF still
491
+ goes into MEMFS and is loaded from `/models/model.gguf`.
492
+ 4. **Renames** the old code hits immediately:
493
+ `llama_new_context_with_model` β†’ `llama_init_from_model`;
494
+ `llama_n_vocab` β†’ `llama_vocab_n_tokens` (vocab is an opaque object);
495
+ `llama_kv_cache_clear` β†’ `llama_memory_clear(llama_get_memory(ctx), true)`;
496
+ `llama_sample_*` β†’ `llama_sampler_*` chain
497
+ (`llama_sampler_chain_init` + `top_k`/`top_p`/`temp`/`penalties`/`dist`);
498
+ `llama_token_to_piece(vocab, tok, buf, len)` gained `lstrip`/`special`;
499
+ `llama_model_params` lost `use_mmap`/`use_mlock` (now `enum llama_load_mode`).
500
+ 5. **Sampling is explicit.** Nothing is sampled for you: build a chain per run,
501
+ `llama_sampler_sample`, then `llama_sampler_accept` and decode the token.
502
+ 6. **Threads.** ggml forces `n_threads = 1` for `__EMSCRIPTEN__` without
503
+ pthreads (`ggml/src/ggml-cpu/ggml-cpu.c`), and the wrapper sets
504
+ `n_threads`/`n_threads_batch = 1` so the thread-pool path is never entered.
505
+ 7. **Chat templating is a different subsystem now** β€” see the next section.
506
+ 8. **`--json-schema` is not available** to this build: JSON-schema β†’ GBNF lives
507
+ in `common/json-schema-to-grammar.cpp`, which we do not compile. `grammar`
508
+ (GBNF) works via `llama_sampler_init_grammar`.
509
+
510
+ ---
511
+
512
+ ## The chat-template rabbit hole (verified, not assumed)
513
+
514
+ This model ships a **Jinja** `tokenizer.chat_template` (ChatML-style:
515
+ `<|im_start|>user … <|im_end|>`, `think` blocks, tool calls, image content).
516
+ That matters because llama.cpp has *two* templating stacks:
517
+
518
+ * **`libllama`'s `llama_chat_apply_template()`** β€” a fixed list of ~60 built-in
519
+ presets (`chatml`, `llama3`, `mistral-v1`, `gemma`, `phi3`, …). It does **not**
520
+ run Jinja. This is all you get if you link only `libllama`.
521
+ * **`common/chat.cpp` + `common/jinja` (minja) + the PEG auto-parser**
522
+ (`docs/autoparser.md`, `common/chat-auto-parser.*`, `common/chat-peg-parser.*`)
523
+ β€” the real Jinja engine and output parser. This is what `llama-cli` /
524
+ `llama-server` use, and it drags in `common/sampling`, `common/json`,
525
+ `json-schema-to-grammar`, `peg-parser`, … i.e. the whole `common` library.
526
+
527
+ Relevant flags, read from `common/arg.cpp` (not from memory):
528
+
529
+ | flag | behaviour |
530
+ |---|---|
531
+ | `--jinja` / `--no-jinja` | enable the Jinja engine (default off) |
532
+ | `--chat-template JINJA_TEMPLATE` | custom template; **without `--jinja` only commonly used (built-in) templates are accepted** |
533
+ | `--chat-template-file FILE` | same, from a file |
534
+ | `--chat-template-kwargs` | template kwargs (e.g. thinking/preserve reasoning) |
535
+ | `--skip-chat-parsing` | force pure content parsing even with a Jinja template |
536
+ | `--reasoning-format` | how `think` blocks are returned |
537
+
538
+ **The trap:** passing the model's own Jinja string to
539
+ `llama_chat_apply_template()` *aborts the wasm module* with a bare
540
+ `RuntimeError: Aborted(undefined)`. Reason, from the source:
541
+ `llm_chat_detect_template()` (`src/llama-chat.cpp:89`) does
542
+ `try { llm_chat_template_from_str(tmpl) } catch (const std::out_of_range &)`,
543
+ i.e. it uses a C++ **exception for control flow**. Emscripten builds disable
544
+ exception handling by default, so the `throw` never reaches the `catch` β€” it
545
+ calls `__cxa_throw` β†’ `__Unwind_RaiseException` β†’ `abort()`. Passing the short
546
+ name `"chatml"` hits the map directly and never throws. (llm.js never hit this:
547
+ it had no template support at all.)
548
+
549
+ **The fix: link the engine, not the presets.** `common/jinja` is a Jinja engine of
550
+ its own (lexer/parser/runtime), and it needs only `common/json.cpp` +
551
+ `common/unicode.cpp` next to it β€” not the whole `common` library (no sampling,
552
+ no PEG parser, no autoparser). This build compiles those eight files with
553
+ `-fexceptions` and links with `-sDISABLE_EXCEPTION_CATCHING=0`, which is exactly
554
+ what makes the `try { … } catch (const std::out_of_range &)` above work instead
555
+ of calling `abort()`. So the model's own template is the primary path, rendered
556
+ by the same engine `llama-server --jinja` uses:
557
+
558
+ 1. read `tokenizer.chat_template` from the GGUF (`llama_model_chat_template`),
559
+ 2. render it with `messages`, `bos_token`, `eos_token` and
560
+ `add_generation_prompt` in scope (`jinja::global_from_json` + `jinja::runtime`),
561
+ 3. strip a leading BOS / trailing EOS when the tokenizer adds them itself β€”
562
+ the same rule `common_chat_template_direct_apply_impl()` applies, which is why
563
+ the rendered text does not end up with two `<|startoftext|>` tokens,
564
+ 4. let the caller override the template source entirely (the demo has an
565
+ "advanced Β· chat template" box for this, handy for a model with a broken or
566
+ missing template),
567
+ 5. fall back to the closest built-in preset
568
+ (`<|im_start|>` β†’ `chatml`, `<|start_header_id|>` β†’ `llama3`,
569
+ `<start_of_turn>` β†’ `gemma`, `[INST]` β†’ `mistral-v1`, `<|im_sep|>` β†’ `phi3`)
570
+ if the template is missing or throws, and to raw completion if nothing fits.
571
+
572
+ The chain is reported back to the page: `template: jinja`, `jinja:override`,
573
+ `builtin:<name>` or `raw`.
574
+
575
+ ### Verification (byte-level and token-level)
576
+
577
+ `test/chat-template-test.mjs` renders the model's template **inside wasm** and
578
+ compares it with the built-in preset, then tokenizes both:
579
+
580
+ ```
581
+ 1. GGUF template: 3836 bytes, detected preset 'chatml'
582
+ 2. Jinja render is byte- and token-identical to builtin chatml
583
+ "<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n"
584
+ 3. caller-supplied template renders verbatim
585
+ 4. 4-message conversation renders (168 bytes)
586
+ 5. broken template reported, module still alive: "Unexpected token: %} of type 9"
587
+ 6. generated 2 tokens via the Jinja path; broken override fell back to builtin:chatml
588
+ ```
589
+
590
+ The older pair below checks the preset fallback against Python's `jinja2`:
591
+ `test/verify-chat-template.mjs` renders through the wasm module;
592
+ `test/verify-chat-template.py` parses the GGUF metadata directly (small GGUF
593
+ reader), renders the *actual* Jinja template with Python `jinja2`, and diffs the
594
+ two; `test/verify-tokens.mjs` then proves the token streams are equal.
595
+
596
+ ```
597
+ wasm (built-in chatml): "<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n"
598
+ jinja2 (model template): "<|startoftext|><|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n"
599
+ ^ only difference: BOS
600
+ TOKENS MATCH: identical token ids # tokenize(ref, add_special=false) == tokenize(ours, add_special=true)
601
+ ```
602
+
603
+ The BOS difference is expected and harmless: the Jinja template emits
604
+ `{{- bos_token -}}` itself, while our built-in render relies on the tokenizer,
605
+ which adds BOS because `tokenizer.ggml.add_bos_token = true`. Verified equal at
606
+ the token level, not argued.
607
+
608
+ ### Tools and the output parser
609
+
610
+ The same `common/chat.cpp` stack that renders the prompt also **parses** the
611
+ reply: for each template it builds a PEG grammar in `common/parsers/*.cpp`
612
+ (MiniCPM5's is `minicpm5.cpp`) and `common_chat_peg_parse()` splits the text into
613
+ `content`, `reasoning_content` and `tool_calls`. This build compiles that half
614
+ too (`chat*.cpp`, `peg-parser.cpp`, `parsers/*.cpp`, `json-schema-to-grammar`,
615
+ `reasoning-budget`), which is why the wasm grew from 3.9 MB to 5.1 MB.
616
+
617
+ Two details that are easy to get wrong, both verified here rather than assumed:
618
+
619
+ * `common_chat_parser_params` copies the format and generation prompt but **not**
620
+ the PEG arena. `common_chat_peg_parse(arena, input, is_partial, params)` takes
621
+ the arena explicitly; pass `g_chat_params->parser` or everything parses as
622
+ content-only.
623
+ * the parser expects the generation prompt in front of the generated text
624
+ (`effective_input.prepend(params.generation_prompt)`), exactly as
625
+ `llama-server` feeds it. Whatever the template appended past the parser's
626
+ literal β€” an opened `think` block, for instance β€” lands in the content, so the
627
+ wrapper strips the generation-prompt tail from the start of the content.
628
+
629
+ Reasoning is not extracted for display: `run()` returns it inside `content`,
630
+ `think` tags included, which is what the playground shows. Tool calls are
631
+ extracted because they are structured data the page renders as JSON.
632
+
633
+ Special tokens matter here: the token pieces are decoded with
634
+ `llama_token_to_piece(..., special = true)`, because `<function` / `<param` are
635
+ special tokens in MiniCPM5's vocab and a `special = false` decode silently drops
636
+ them, turning a perfectly good tool call into ` name="get_weather">`.
637
+
638
+ Multimodal content goes through `mtmd` instead: `llmjs_set_images()` takes the
639
+ data URLs, the same `llmjs_gen_begin_chat()` sees the markers in the message
640
+ text, and `mtmd_tokenize()` turns each marker into that image's token sequence
641
+ before the prompt is evaluated. The projector itself is loaded by
642
+ `llmjs_mmproj_load()` (llama-server's `--mmproj`): a second GGUF from the same
643
+ library that is flagged `multimodal` by the user, never auto-detected.
644
+
645
+ ---
646
+
647
+ ## Debugging notes (WASM-specific)
648
+
649
+ * **Exceptions abort silently.** Any `throw` in llama.cpp kills the module with
650
+ `Aborted(undefined)`. Bisect by logging before/after each call from C++ (an
651
+ `EM_JS` log helper works); that is how the chat-template crash above was
652
+ localised to the first `generate` call.
653
+ * **`EM_JS` bodies are macro arguments** β€” a top-level comma breaks the macro.
654
+ * **MEMFS + `canOwn`**: `FS.createDataFile("/models", "model.gguf", bytes, true,
655
+ false, true)` takes ownership, so the 219 MB buffer is not copied again.
656
+ Weights still get read into the wasm heap, so the heap grows to a few hundred
657
+ MB on load.
658
+ * **Wasm32 vs wasm64**: `-sMAXIMUM_MEMORY=4GB` is the wasm32 ceiling; without
659
+ `ALLOW_MEMORY_GROWTH` the 64 MB initial heap would fail on model load.
660
+ * **`HEAPU8`/`UTF8ToString` are not exported by default** β€” add them to
661
+ `EXPORTED_RUNTIME_METHODS` if JS needs them.
662
+ * **Single-thread `n_batch=512` reserve** costs ~139 MiB of compute buffer. Lower
663
+ `n_batch` if the browser is memory-constrained.
664
+
665
+ ---
666
+
667
+ ## Tests / what is verified
668
+
669
+ | command | needs | checks |
670
+ |---|---|---|
671
+ | `node test/node-test.mjs [model] [prompt] [n]` | model | full wasm load + generation, tok/s, output |
672
+ | `node test/download-test.mjs` | nothing | chunking, pause at chunk boundary, `Range` resume, restart when the server ignores `Range` |
673
+ | `node test/sha256-test.mjs` | nothing | streaming SHA-256 vs node's `crypto`: every padding boundary, uneven slices, known vectors |
674
+ | `npm i --no-save fake-indexeddb && node test/storage-test.mjs` | fake-indexeddb | model library CRUD, chunk iteration, v1 β†’ v2 migration |
675
+ | `node test/chat-template-test.mjs [model]` | model | GGUF Jinja template vs the verified preset (byte + token identical), custom override, broken template falls back instead of aborting |
676
+ | `node test/chat-generate-test.mjs [model]` | model | stepwise chat generation over a multi-turn list, streamed text == final output, greedy reproducibility, stop-sequence truncation, interrupt keeps the partial text, raw mode, max-new-tokens |
677
+ | `node test/tool-call-test.mjs [model]` | MiniCPM5-1B | tools rendered into the prompt, `enable_thinking` changes it, a canned and a **live** reply parse into name + JSON arguments, text before the call is kept, assistant `tool_calls` + `tool` result render back |
678
+ | `node test/kv-cache-test.mjs [model]` | model | ctx/KV options reported by `llmjs_ctx_config()`, repeated-prompt reuse (6.5 s β†’ 0.3 s), reply reuse on the next turn, identical output with a cold cache, quantized K/V + flash attention rule, `n_ctx` really applied; adaptive for hybrid/RNN memories |
679
+ | `node test/multimodal-test.mjs [model] [mmproj]` | LFM2.5-VL + mmproj | projector reports vision support and the media marker, an image prompt evaluates its image tokens (121 prompt tokens vs 19 for the same text) and answers about the picture, one image per marker is enforced, image runs are refused without a projector while text still works |
680
+ | `node test/threads-test.mjs [model] [threads]` | mt build | the pthread module really runs N threads: identical greedy output, measured tok/s at 1 vs N (3.2Γ— at 4 threads on 4 vCPU) |
681
+ | `python test/serve.py` | nothing | static server with COOP/COEP, needed for `SharedArrayBuffer` / `--threads` |
682
+ | `node test/verify-chat-template.mjs … && python3 test/verify-chat-template.py …` | jinja2 | built-in `chatml` render vs the model's real Jinja template |
683
+ | `node test/verify-tokens.mjs …` | model | the two prompts tokenize to identical ids |
684
+ | `python test/browser-test.py --model-url … \| --file … [--mmproj …]` | playwright | the real page in headless Chromium: both ingest paths β†’ sha256 β†’ load β†’ generate; `--pause-mib N` pauses/resumes, `--file` asserts nothing was copied, plus a `tool` row, both exports (downloads are parsed back) and a template kwarg reaching the preview; `--mmproj` adds a flagged projector, reloads with it, attaches an image (thumbnail add/remove), generates about it, checks the preview marker and the multi-part JSON / `<< image >>` Markdown exports |
685
+
686
+ Verified here: clean wasm build, 48-token generation, raw + chat paths, prompt
687
+ render byte diff, token-id equivalence, download pause/resume, the model library
688
+ (uuid records, rename, multi-model delete, v1 β†’ v2 migration, chunk iteration),
689
+ streaming SHA-256 against node's `crypto`, HTTP serving of every demo asset, and
690
+ the demo page driven end to end in headless Chromium over **both** ingest paths
691
+ (download: 218.7 MiB into 55 chunks, `GGUF` magic intact, paused and resumed via
692
+ `Range`; local file: hashed and loaded with zero chunks written to IndexedDB).
693
+
694
+ KV reuse and the launch parameters were verified with `test/kv-cache-test.mjs`
695
+ on MiniCPM5-1B (transformer) and LFM2.5-VL-450M (hybrid): a repeated 27-token
696
+ prompt went from 6.5 s to 0.3 s of prompt eval with 26/27 tokens reused, a
697
+ cleared cache gave byte-identical greedy output, `q8_0` K/V ran with flash
698
+ attention forced on, `q8_0` K with `f16` V kept it off, and `n_ctx=256` was
699
+ really applied and invalidated the old cache. On LFM2.5 the hybrid memory reused
700
+ 32/46 tokens on the next turn (append-only, as expected).
701
+
702
+ The tabbed page was verified the same way: the chat playground appends an
703
+ assistant turn and streams into it (`stats: 24 tokens … Β· template: jinja`),
704
+ `Stop` reported `Β· stopped` with the partial text kept, the template tab previews
705
+ the GGUF template (`<|im_start|>user … <|im_end|>`), an override was used
706
+ (`template: jinja:override`), raw completion reported `template: raw`, andthe engine reloaded from the top bar. A `tool` message row survived with its
707
+ `tool_call_id`, the JSON export came back as OpenAI messages ending in that tool
708
+ turn, the Markdown export contained `### TOOL (call_test_1)` and `### USER`, and
709
+ the template preview accepted a `parameters JSON` kwarg.
710
+
711
+ Multimodal was verified with LFM2.5-VL-450M plus its
712
+ `mmproj-LFM2.5-VL-450m-Q8_0.gguf` (same Hugging Face repo, `mmproj` file): in
713
+ Node the projector loaded (`vision=true`, marker `<__media__>`), a 64Γ—64 PNG
714
+ prompted with one marker evaluated 121 prompt tokens against 19 for the same text
715
+ and the model answered *"The left half of this image is red"*; a marker/image
716
+ count mismatch was refused (`-2`) instead of mis-rendering, and an image run
717
+ without a projector was refused (`-3`) while text runs kept working. In the
718
+ browser the same flow used the real page: flag the file as mmproj, reload with
719
+ *load multimodal projector* (status line gains `mmproj … (vision)`), attach the
720
+ image through the πŸ–Ό button, remove and re-attach the thumbnail, generate
721
+ (*"The left half of this image is a red color."*, 121 prompt tokens in 24 s β€”
722
+ image encoding dominates), see `<__media__>` in the template preview, and export
723
+ the conversation: one multi-part message with
724
+ `data:image/png;base64,…` in JSON and `<< image >>` in Markdown.
725
+
726
+ Tools and the parser were verified with MiniCPM5-1B (`MiniCPM5-1B-Q4_K_M.gguf`)
727
+ in `test/tool-call-test.mjs`: the tools array reaches the template (943 vs 90
728
+ bytes), `enable_thinking` toggles the open `think` block, and a real 15-token
729
+ generation produced `<function name="get_weather"><param name="city">Paris</param></function>`,
730
+ which the model's own parser turned into one tool call with
731
+ `{"city": "Paris"}`.
732
+
733
+ ## Docs consulted (both projects)
734
+
735
+ **llm.js** β€” `docs/BUILD.md` (build flow), `docs/api_guide.md` (the API preserved
736
+ here), `scripts/build-llama-cpp-wasm.sh` + `patches/00-llama-cpp-enable-main.patch`
737
+ (exactly what broke), `src/web-workers/llamacpp-cpu-worker.js` (stdout scraping).
738
+
739
+ **llama.cpp** β€” the docs are scattered; these were the useful ones:
740
+ `docs/build.md` (WASM/WebGPU + WASI sections, the latter has the
741
+ single-thread `-DGGML_OPENMP=OFF -DLLAMA_BUILD_COMMON=OFF` recipe),
742
+ `.github/workflows/build-wasm.yml` (the flags upstream actually builds with),
743
+ `docs/autoparser.md` (PEG auto-parser design), `docs/models.md` /
744
+ `models/templates/README.md` (templates), `tools/mtmd/README*.md` (multimodal),
745
+ `docs/function-calling.md`, plus source-of-truth `include/llama.h`,
746
+ `src/llama-chat.cpp`, `common/arg.cpp`, `ggml/src/ggml-cpu/arch/wasm/` and
747
+ `ggml/src/ggml-cpu/ggml-cpu.c`.
748
+
749
+ ## Limitations / next steps
750
+
751
+ * **Multimodal is vision-projector only, and untested beyond images.** `mtmd`
752
+ is compiled in and loads any `.gguf` mmproj, but only LFM2.5-VL's vision
753
+ projector was run; audio projectors compile and were never tried, and video is
754
+ off (it needs ffmpeg). Images are CPU-bound here: the 64Γ—64 test image took
755
+ ~12 s to encode and ~6 s to decode in wasm, so a photo costs tens of seconds
756
+ before the first token. Image runs bypass the text prefix cache (the token
757
+ stream changes with the image), so a subsequent text turn starts cold.
758
+ * **No `json_schema`** (compiling `common/json-schema-to-grammar.cpp` would add
759
+ it). GBNF `grammar` works.
760
+ * **No reasoning-effort control.** MiniCPM5 advertises
761
+ `supports_reasoning_effort: false`; thinking is on/off via her
762
+ `enable_thinking` template kwarg only. The sampling stack with thinking budgets
763
+ is not compiled here, so the control is left out rather than faked.
764
+ * **Stop granularity is one token**, and stop sequences are text-level: they are
765
+ matched on decoded text (that is why a stop can appear immediately, not after
766
+ the next token). There is no token-id ban list yet.
767
+ * **Prefix reuse depends on the template and the memory type.** The wrapper
768
+ keeps the tokens that sit in the KV cache and re-evaluates only what a new
769
+ prompt adds. A repeated prompt drops from 6.5 s to 0.3 s of prompt eval
770
+ (26/27 tokens reused, `test/kv-cache-test.mjs`). Two limits are inherent: a
771
+ reasoning template injects its `think` block only in the generation prompt, so
772
+ with thinking on the reply after that point cannot be reused; and recurrent /
773
+ hybrid memories (Mamba, LFM2's conv+attention) cannot roll their state back,
774
+ so they only reuse the append case β€” never a prefix that needs trimming. That
775
+ case is the multi-turn one, and it still helps: 32/46 tokens on LFM2.5.
776
+ * **KV memory is only bounded by `n_ctx`.** There is no separate cache pool in
777
+ this build (the 8 GiB `--cache-ram` default belongs to `llama-server`); a
778
+ context of N tokens costs the model's per-token KV size times N, times two if
779
+ K and V stay `f16`. The quantized types in the rail are the lever.
780
+ * **The parser only knows the formats llama.cpp ships** (`common/parsers/`). A
781
+ model whose reply format has no parser there still generates fine; its reply
782
+ just stays plain text (`stats.parsed` is `null` or has no tool calls).
783
+ * **Exceptions cost size and some speed**: linking the Jinja engine raised the
784
+ wasm from 3.2 MB to 3.9 MB and requires `-fexceptions`
785
+ `-sDISABLE_EXCEPTION_CATCHING=0`; a browser without wasm exception support is
786
+ not a concern (Emscripten's scheme is JS-based), but the JS glue is larger and
787
+ every `try/catch` in llama.cpp now works instead of aborting.
788
+ * **Threads are opt-in and experimental in the browser.** A pthread build
789
+ exists and is 3.2Γ— faster in Node at 4 threads (`test/threads-test.mjs`), but
790
+ the first multi-thread decode stalls in headless Chromium, so the page keeps
791
+ one thread by default and `?threads=N` turns the pthread module on. Threads
792
+ also need cross-origin isolation, i.e. host-controlled COOP/COEP headers.
793
+ Wasm SIMD is already enabled; the single-thread baseline stays ~8 tok/s on
794
+ this container.
795
+ * **A local model has to be re-picked after a reload** (one click, no network)
796
+ unless you tick *keep a copy in browser storage*. Keeping a
797
+ `FileSystemFileHandle` in IndexedDB would remove that click, but the API is
798
+ Chromium-only and brings a permission prompt, so it is left out for now.
799
+ * **Hashing costs one read of the model** (~2-4 s for 219 MiB here) and happens
800
+ once, on add; the digest is cached in the record.
801
+ * **One browser only** β€” the demo is checked in headless Chromium via
802
+ Playwright (`test/browser-test.py`); Firefox and Safari were not run, and the
803
+ IndexedDB quota/eviction behaviour on a nearly-full disk was not exercised.
804
+ * Similar, more actively maintained projects to compare against: `wllama`
805
+ (ngxson) and llama.cpp's own WebGPU example.