lemonteaa commited on
Commit
685ad0d
·
1 Parent(s): f375a7a

update dist bundle (2)

Browse files
Files changed (6) hide show
  1. index.html +271 -63
  2. llm.js +3 -1
  3. wasm/llm-wasm-mt.build.txt +1 -1
  4. wasm/llm-wasm-mt.js +0 -0
  5. wasm/llm-wasm-mt.wasm +2 -2
  6. worker.js +53 -21
index.html CHANGED
@@ -36,6 +36,21 @@
36
  button:disabled { opacity: .45; cursor: default; }
37
  #bar { height: 8px; background: #0d0f12; border: 1px solid #2a2f37; border-radius: 999px; overflow: hidden; margin: 10px 0 8px; }
38
  #barFill { height: 100%; width: 0%; background: linear-gradient(90deg, #2f6fed, #5aa0ff); transition: width .15s linear; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  #status { color: #9aa1ab; font-size: 13px; min-height: 20px; }
40
  #out { white-space: pre-wrap; word-break: break-word; background: #0d0f12; border: 1px solid #2a2f37;
41
  border-radius: 6px; padding: 12px; min-height: 140px; }
@@ -44,16 +59,39 @@
44
  summary { cursor: pointer; color: #9aa1ab; font-size: 13px; }
45
  #log { max-height: 180px; overflow: auto; background: #0d0f12; border: 1px solid #2a2f37; border-radius: 6px;
46
  padding: 8px 10px; margin-top: 8px; font-size: 12px; color: #8a8f98; white-space: pre-wrap; }
 
 
 
47
  .pill { display: inline-block; padding: 1px 7px; border-radius: 999px; background: #23303f; color: #9cc3ff;
48
  font-size: 11px; margin-left: 6px; }
49
 
50
  /* --- top bar ---------------------------------------------------------- */
51
  .appbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
52
  .appbar label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: #8a8f98; }
53
- .appbar select { flex: 1 1 260px; width: auto; }
 
 
54
  .appbar button { padding: 7px 12px; font-size: 13px; }
55
  #currentModel { flex: 1 1 100%; font-size: 12px; }
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  /* --- tabs ------------------------------------------------------------- */
58
  .tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 18px 0 0; border-bottom: 1px solid #2a2f37; }
59
  .tab { background: transparent; color: #9aa1ab; border: 1px solid transparent; border-bottom: 0;
@@ -186,6 +224,43 @@ downloaded from a URL or read straight off your disk — never both.</small>
186
  <button id="stop" class="danger" disabled title="stop generating, keeping what has arrived">Stop</button>
187
  <div id="currentModel" class="muted"></div>
188
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  <div id="bar"><div id="barFill"></div></div>
190
  <div id="status">idle</div>
191
 
@@ -239,38 +314,6 @@ downloaded from a URL or read straight off your disk — never both.</small>
239
  </div>
240
 
241
  <aside class="params" id="paramsRail">
242
- <h2>engine <span class="pill" title="fixed when the context is created">launch</span></h2>
243
- <label class="field">context length <input type="number" id="ctxSize" value="1024" step="256"
244
- min="128" max="16384" title="tokens; KV memory grows with this"></label>
245
- <label class="field">KV cache K <select id="kvTypeK" aria-label="KV cache type for K">
246
- <option value="f16" selected>f16</option>
247
- <option value="q8_0">q8_0</option>
248
- <option value="q4_0">q4_0</option>
249
- </select></label>
250
- <label class="field">KV cache V <select id="kvTypeV" aria-label="KV cache type for V">
251
- <option value="f16" selected>f16</option>
252
- <option value="q8_0">q8_0</option>
253
- <option value="q4_0">q4_0</option>
254
- </select></label>
255
- <label class="field">flash attention <select id="flashAttn" aria-label="flash attention">
256
- <option value="-1" selected>auto</option>
257
- <option value="0">off</option>
258
- <option value="1">on</option>
259
- </select></label>
260
- <label class="field">threads <input type="number" id="threads" min="1" max="16" step="1" value="1"
261
- aria-label="CPU threads"></label>
262
- <p class="hint" id="threadsHint" style="margin-top:-4px"></p>
263
- <label class="row"><input type="checkbox" id="kvUnified" checked> unified KV cache</label>
264
- <label class="row"><input type="checkbox" id="useMmproj"> load multimodal projector</label>
265
- <label class="field">mmproj <select id="mmprojSelect" aria-label="multimodal projector" disabled></select></label>
266
- <p class="hint" style="margin-top:6px">Flag a GGUF as <b>mmproj</b> in the model library to
267
- choose it here. Then attach images to a message (button or drag &amp; drop); the image goes
268
- into the prompt as the projector's media marker.</p>
269
- <p class="hint" style="margin-top:6px">Applied when a model loads \u2014 press
270
- <b>Reload engine</b> after changing them. One parallel sequence: extra slots would only
271
- duplicate a cache a single user cannot fill. A quantized V cache needs flash attention;
272
- asking for one switches it on. KV memory grows with context length.</p>
273
-
274
  <h2>sampling</h2>
275
  <label class="field">temperature <input type="number" id="temp" value="0.7" step="0.05" min="0" max="2"></label>
276
  <label class="field">top_p <input type="number" id="topP" value="0.9" step="0.05" min="0" max="1"></label>
@@ -284,17 +327,38 @@ downloaded from a URL or read straight off your disk — never both.</small>
284
  <label class="field wide">stop sequences
285
  <textarea id="stopSequences" spellcheck="false" placeholder="one per line (&#92;n and &#92;t are expanded)"></textarea>
286
  </label>
287
- <h2>tools</h2>
288
- <label class="field wide">tool definitions (OpenAI JSON)
 
 
 
 
 
 
 
 
289
  <textarea id="tools" spellcheck="false" placeholder='[{"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}]'></textarea>
290
  </label>
291
- <p class="hint" style="margin-top:8px">Handed to the chat template. A reply that calls a tool is
292
- parsed by the model's own parser and shown as JSON next to the assistant turn.</p>
293
-
294
- <h2>thinking</h2>
 
 
 
 
 
 
 
 
 
 
 
295
  <label class="row"><input type="checkbox" id="enableThinking"> enable_thinking</label>
296
- <p class="hint" style="margin-top:6px">Writes the <code>enable_thinking</code> template parameter into
297
- the parameters JSON on the Chat template tab.</p>
 
 
298
  </aside>
299
  </div>
300
 
@@ -331,6 +395,11 @@ downloaded from a URL or read straight off your disk — never both.</small>
331
  <section class="panel" id="panel-models" hidden>
332
  <h2>model library</h2>
333
 
 
 
 
 
 
334
  <div class="add">
335
  <h3>from a URL</h3>
336
  <div class="addrow">
@@ -394,16 +463,53 @@ downloaded from a URL or read straight off your disk — never both.</small>
394
  const pct = (loaded, total) => (total ? Math.min(100, (loaded / total) * 100) : 0);
395
 
396
  const statusText = (t) => { $("status").textContent = t; };
397
- const log = (t) => {
 
 
 
398
  const el = $("log");
399
- el.textContent += t.endsWith("\n") ? t : t + "\n";
 
 
 
 
 
 
 
 
400
  el.scrollTop = el.scrollHeight;
401
  };
402
  const setBar = (loaded, total) => { $("barFill").style.width = pct(loaded, total).toFixed(1) + "%"; };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
 
404
  let app = null;
405
  let models = [];
406
  let loadedId = null; // model currently in the wasm instance
 
407
  let currentId = null; // model the user last asked to load
408
  let pendingFile = null; // file picked for the "add" form
409
  let reattachFor = null; // model id waiting for its file to be re-picked
@@ -517,6 +623,10 @@ downloaded from a URL or read straight off your disk — never both.</small>
517
  stop: stopList(),
518
  chat_template_source: templateOverride(),
519
  tools: $("tools").value.trim(),
 
 
 
 
520
  template_kwargs: $("templateKwargs").value.trim() || "{}",
521
  };
522
  }
@@ -553,7 +663,9 @@ downloaded from a URL or read straight off your disk — never both.</small>
553
  parent.postMessage({ type: "llmjs:ready", model: m.name,
554
  n_ctx: info.n_ctx, threads: info.threads || 1 }, "*");
555
  }
556
- setBar(1, 1);
 
 
557
  setState("ready");
558
  refreshModels();
559
  },
@@ -631,31 +743,53 @@ downloaded from a URL or read straight off your disk — never both.</small>
631
  chatRow.querySelector(".content").readOnly = false;
632
  chatRow = null;
633
  }
634
- statusText("error: " + err);
 
 
 
 
 
 
 
635
  log("ERROR: " + err);
636
  setState(loadedId ? "ready" : "idle");
637
  },
638
  (p) => {
 
639
  if (p.phase === "download") {
 
640
  setState("downloading");
641
- statusText(p.complete ? "downloaded, preparing…"
642
- : `downloading ${fmt(p.loaded)} / ${fmt(p.total)} (${pct(p.loaded, p.total).toFixed(1)}%)`);
643
- setBar(p.loaded, p.total);
644
  } else if (p.phase === "copy") {
645
  setState("copying");
646
- statusText(`copying into browser storage ${fmt(p.loaded)} / ${fmt(p.total)}`);
647
- setBar(p.loaded, p.total);
 
 
648
  } else if (p.phase === "hash") {
649
  setState("hashing");
650
- statusText(`sha256 ${pct(p.loaded, p.total).toFixed(0)}% of ${fmt(p.total)}`);
651
- setBar(p.loaded, p.total);
 
 
 
 
 
 
 
 
 
 
652
  } else if (p.phase === "load") {
 
653
  setState("loading");
654
- statusText(`loading into wasm ${fmt(p.loaded)} / ${fmt(p.total)}`);
655
  setBar(p.loaded, p.total);
656
  }
657
  },
658
- (line) => log(line.trimEnd()),
659
  );
660
 
661
  app.on_event = (msg) => {
@@ -667,17 +801,27 @@ downloaded from a URL or read straight off your disk — never both.</small>
667
  $("fileLabel").value = "";
668
  $("urlMmproj").checked = false;
669
  $("fileMmproj").checked = false;
 
670
  // adding a projector does not load anything, so nothing reports INITIALIZED
671
  setState(loadedId ? "ready" : "idle");
672
  refreshModels();
673
  break;
674
 
675
  case action.MODEL_UPDATED:
 
 
 
 
 
 
 
 
676
  refreshModels();
677
  break;
678
 
679
  case action.PAUSED:
680
- statusText("paused — progress is saved, press Resume to continue");
 
681
  setState("paused");
682
  refreshModels();
683
  break;
@@ -1039,7 +1183,7 @@ downloaded from a URL or read straight off your disk — never both.</small>
1039
 
1040
  function startLoad(m) {
1041
  if (m.multimodal) {
1042
- statusText(`${m.name} is an mmproj — tick "load multimodal projector" in the rail and pick it there`);
1043
  return;
1044
  }
1045
  // a local file that was never copied needs its File again after a reload
@@ -1062,7 +1206,17 @@ downloaded from a URL or read straight off your disk — never both.</small>
1062
  currentId = m.id;
1063
  $("out").textContent = "";
1064
  $("stats").textContent = "";
1065
- statusText("starting…");
 
 
 
 
 
 
 
 
 
 
1066
  setState("starting");
1067
  app.post({
1068
  event: action.LOAD,
@@ -1082,6 +1236,8 @@ downloaded from a URL or read straight off your disk — never both.</small>
1082
  if (!app || !currentId) return;
1083
  $("out").textContent = "";
1084
  $("stats").textContent = "";
 
 
1085
  statusText("freeing the model and reloading the engine…");
1086
  setState("starting");
1087
  const mpId = engineParams().mmproj_id;
@@ -1118,7 +1274,19 @@ downloaded from a URL or read straight off your disk — never both.</small>
1118
  ensureApp();
1119
  $("out").textContent = "";
1120
  $("stats").textContent = "";
1121
- statusText("starting…");
 
 
 
 
 
 
 
 
 
 
 
 
1122
  setState("starting");
1123
  app.post({
1124
  event: action.ADD_FILE,
@@ -1137,7 +1305,15 @@ downloaded from a URL or read straight off your disk — never both.</small>
1137
  ensureApp();
1138
  $("out").textContent = "";
1139
  $("stats").textContent = "";
1140
- statusText("starting…");
 
 
 
 
 
 
 
 
1141
  setState("starting");
1142
  app.post({
1143
  event: action.ADD_URL,
@@ -1281,12 +1457,13 @@ downloaded from a URL or read straight off your disk — never both.</small>
1281
  }
1282
 
1283
  $("pause").onclick = () => {
1284
- if (app) { app.pause(); statusText("pausing…"); }
1285
  };
1286
 
1287
  $("resume").onclick = () => {
1288
  if (!app) return;
1289
- statusText("resuming…");
 
1290
  setState("downloading");
1291
  app.resume();
1292
  };
@@ -1316,6 +1493,16 @@ downloaded from a URL or read straight off your disk — never both.</small>
1316
  syncTemplateUI();
1317
  };
1318
 
 
 
 
 
 
 
 
 
 
 
1319
  $("previewTemplate").onclick = () => {
1320
  if (!app) { statusText("load a model first"); return; }
1321
  $("templatePreview").textContent = "rendering…";
@@ -1538,15 +1725,26 @@ downloaded from a URL or read straight off your disk — never both.</small>
1538
  }
1539
  }
1540
 
 
 
 
 
 
 
 
 
 
 
 
 
1541
  $("enableThinking").onchange = () => {
1542
  const kw = readKwargs();
1543
  if ($("enableThinking").checked) kw.enable_thinking = true;
1544
  else delete kw.enable_thinking;
1545
  $("templateKwargs").value = JSON.stringify(kw, null, 2);
 
1546
  };
1547
 
1548
- $("templateKwargs").value = "{}";
1549
-
1550
  $("useMmproj").onchange = () => {
1551
  $("mmprojSelect").disabled = !$("useMmproj").checked || !models.some((m) => m.multimodal);
1552
  statusText($("useMmproj").checked
@@ -1554,6 +1752,16 @@ downloaded from a URL or read straight off your disk — never both.</small>
1554
  : "no multimodal projector will be loaded");
1555
  };
1556
 
 
 
 
 
 
 
 
 
 
 
1557
  // The threaded module needs SharedArrayBuffer, i.e. a cross-origin isolated
1558
  // page. Without COOP/COEP the single-thread module is what the worker loads,
1559
  // and asking for threads would only be a lie.
 
36
  button:disabled { opacity: .45; cursor: default; }
37
  #bar { height: 8px; background: #0d0f12; border: 1px solid #2a2f37; border-radius: 999px; overflow: hidden; margin: 10px 0 8px; }
38
  #barFill { height: 100%; width: 0%; background: linear-gradient(90deg, #2f6fed, #5aa0ff); transition: width .15s linear; }
39
+
40
+ /* --- model ingest (Models tab) ---------------------------------------- */
41
+ /* separate from the engine bar on purpose: getting bytes into the library
42
+ and starting a context are different operations with different outcomes */
43
+ #ingest { border: 1px solid #2a2f37; border-radius: 8px; background: #171a1f;
44
+ padding: 9px 11px; margin: 0 0 12px; }
45
+ #ingest.error { border-color: #5a2530; }
46
+ .ingest-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline;
47
+ font-size: 12px; color: #cfd4da; margin-bottom: 7px; }
48
+ #ingestPercent { color: #8a8f98; font-size: 11px; }
49
+ #ingestBar { height: 8px; background: #0d0f12; border: 1px solid #2a2f37;
50
+ border-radius: 999px; overflow: hidden; }
51
+ #ingestFill { height: 100%; width: 0%; background: linear-gradient(90deg, #6752c8, #a98bff);
52
+ transition: width .15s linear; }
53
+ #ingest.error #ingestFill { background: #c05050; }
54
  #status { color: #9aa1ab; font-size: 13px; min-height: 20px; }
55
  #out { white-space: pre-wrap; word-break: break-word; background: #0d0f12; border: 1px solid #2a2f37;
56
  border-radius: 6px; padding: 12px; min-height: 140px; }
 
59
  summary { cursor: pointer; color: #9aa1ab; font-size: 13px; }
60
  #log { max-height: 180px; overflow: auto; background: #0d0f12; border: 1px solid #2a2f37; border-radius: 6px;
61
  padding: 8px 10px; margin-top: 8px; font-size: 12px; color: #8a8f98; white-space: pre-wrap; }
62
+ #log .logline { display: block; }
63
+ #log .warn { color: #d8b25f; }
64
+ #log .err { color: #e07070; }
65
  .pill { display: inline-block; padding: 1px 7px; border-radius: 999px; background: #23303f; color: #9cc3ff;
66
  font-size: 11px; margin-left: 6px; }
67
 
68
  /* --- top bar ---------------------------------------------------------- */
69
  .appbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
70
  .appbar label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: #8a8f98; }
71
+ /* a flex item refuses to shrink below its longest option, so a long model name
72
+ would push the whole page sideways on a phone; min-width lets it ellipsize */
73
+ .appbar select { flex: 1 1 260px; width: auto; min-width: 0; max-width: 100%; }
74
  .appbar button { padding: 7px 12px; font-size: 13px; }
75
  #currentModel { flex: 1 1 100%; font-size: 12px; }
76
 
77
+ /* --- engine launch row (top bar) -------------------------------------- */
78
+ .enginebar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin-top: 8px;
79
+ padding: 7px 10px; border: 1px solid #2a2f37; border-radius: 8px; background: #171a1f; }
80
+ .enginebar-title { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: #8a8f98; }
81
+ .ef { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #bfc5cd; white-space: nowrap; }
82
+ .ef input[type=number] { width: 66px; background: #0d0f12; color: #e6e6e6; border: 1px solid #2a2f37;
83
+ border-radius: 6px; padding: 3px 6px; font: inherit; font-size: 12px; }
84
+ .ef select { width: auto; background: #0d0f12; color: #e6e6e6; border: 1px solid #2a2f37;
85
+ border-radius: 6px; padding: 3px 5px; font: inherit; font-size: 12px; }
86
+ .enginebar #mmprojSelect { width: 118px; font-size: 12px; }
87
+ .ef.check { gap: 5px; }
88
+ .ef input[type=checkbox] { margin: 0; }
89
+ #threadsHint { flex: 1 1 100%; margin: 0; font-size: 11px; }
90
+ .enginebar details { flex: 1 1 100%; margin: 0; }
91
+ .enginebar summary { font-size: 11px; }
92
+ .enginebar details p { margin: 6px 0 0; }
93
+ .enginebar .hint { margin: 0; }
94
+
95
  /* --- tabs ------------------------------------------------------------- */
96
  .tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 18px 0 0; border-bottom: 1px solid #2a2f37; }
97
  .tab { background: transparent; color: #9aa1ab; border: 1px solid transparent; border-bottom: 0;
 
224
  <button id="stop" class="danger" disabled title="stop generating, keeping what has arrived">Stop</button>
225
  <div id="currentModel" class="muted"></div>
226
  </div>
227
+ <div class="enginebar" role="group" aria-label="engine launch parameters">
228
+ <span class="enginebar-title">engine <span class="pill" title="fixed when the context is created">launch</span></span>
229
+ <label class="ef" title="context length in tokens; KV memory grows with this">ctx
230
+ <input type="number" id="ctxSize" value="1024" step="256" min="128" max="16384"
231
+ aria-label="context length"></label>
232
+ <label class="ef">KV K <select id="kvTypeK" aria-label="KV cache type for K">
233
+ <option value="f16" selected>f16</option>
234
+ <option value="q8_0">q8_0</option>
235
+ <option value="q4_0">q4_0</option>
236
+ </select></label>
237
+ <label class="ef">KV V <select id="kvTypeV" aria-label="KV cache type for V">
238
+ <option value="f16" selected>f16</option>
239
+ <option value="q8_0">q8_0</option>
240
+ <option value="q4_0">q4_0</option>
241
+ </select></label>
242
+ <label class="ef">flash <select id="flashAttn" aria-label="flash attention">
243
+ <option value="-1" selected>auto</option>
244
+ <option value="0">off</option>
245
+ <option value="1">on</option>
246
+ </select></label>
247
+ <label class="ef">threads <input type="number" id="threads" min="1" max="16" step="1" value="1"
248
+ aria-label="CPU threads"></label>
249
+ <label class="ef check"><input type="checkbox" id="kvUnified" checked> unified KV</label>
250
+ <label class="ef check"><input type="checkbox" id="useMmproj"> mmproj</label>
251
+ <label class="ef">projector <select id="mmprojSelect" aria-label="multimodal projector" disabled></select></label>
252
+ <p class="hint" id="threadsHint"></p>
253
+ <details>
254
+ <summary>how launch settings apply</summary>
255
+ <p class="hint">Applied when a model loads — press <b>Reload engine</b> after changing them.
256
+ One parallel sequence: extra slots would only duplicate a cache a single user cannot fill.
257
+ A quantized V cache needs flash attention; asking for one switches it on. KV memory grows
258
+ with context length.</p>
259
+ <p class="hint">Flag a GGUF as <b>mmproj</b> in the model library to choose it here. Then attach
260
+ images to a message (button or drag &amp; drop); the image goes into the prompt as the
261
+ projector's media marker.</p>
262
+ </details>
263
+ </div>
264
  <div id="bar"><div id="barFill"></div></div>
265
  <div id="status">idle</div>
266
 
 
314
  </div>
315
 
316
  <aside class="params" id="paramsRail">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  <h2>sampling</h2>
318
  <label class="field">temperature <input type="number" id="temp" value="0.7" step="0.05" min="0" max="2"></label>
319
  <label class="field">top_p <input type="number" id="topP" value="0.9" step="0.05" min="0" max="1"></label>
 
327
  <label class="field wide">stop sequences
328
  <textarea id="stopSequences" spellcheck="false" placeholder="one per line (&#92;n and &#92;t are expanded)"></textarea>
329
  </label>
330
+ <h2>constrained output</h2>
331
+ <label class="field wide">constrain the reply with
332
+ <select id="constrain">
333
+ <option value="off">nothing (free text)</option>
334
+ <option value="tools" selected>tool calls (definitions below)</option>
335
+ <option value="json_schema">JSON schema</option>
336
+ <option value="grammar">GBNF grammar</option>
337
+ </select>
338
+ </label>
339
+ <label class="field wide" id="toolsWrap">tool definitions (OpenAI JSON)
340
  <textarea id="tools" spellcheck="false" placeholder='[{"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}]'></textarea>
341
  </label>
342
+ <label class="field wide" id="schemaWrap" hidden>JSON schema
343
+ <textarea id="jsonSchema" spellcheck="false" placeholder='{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}'></textarea>
344
+ </label>
345
+ <label class="field wide" id="grammarWrap" hidden>GBNF grammar
346
+ <textarea id="grammar" spellcheck="false" placeholder='root ::= "yes" | "no"'></textarea>
347
+ </label>
348
+ <p class="hint" style="margin-top:8px">Tool definitions go to the chat template and the reply
349
+ is parsed by the model's own parser. A JSON schema or grammar instead constrains the reply
350
+ text itself, and replaces any grammar the template would have used. One constraint at a time.</p>
351
+
352
+ <h2>template</h2>
353
+ <label class="field wide">extra template arguments (JSON)
354
+ <textarea id="runTemplateKwargs" spellcheck="false"
355
+ placeholder='{"enable_thinking": true}'></textarea>
356
+ </label>
357
  <label class="row"><input type="checkbox" id="enableThinking"> enable_thinking</label>
358
+ <p class="hint" style="margin-top:6px">The same object as <em>parameters JSON</em> on the Chat template
359
+ tab: edit it in either place — preview and generation both use it for the next turn.
360
+ <em>enable_thinking</em> only sets the template variable; whether reasoning is emitted and
361
+ separated into <code>reasoning_content</code> depends on the model's template and parser.</p>
362
  </aside>
363
  </div>
364
 
 
395
  <section class="panel" id="panel-models" hidden>
396
  <h2>model library</h2>
397
 
398
+ <div id="ingest" hidden>
399
+ <div class="ingest-head"><span id="ingestLabel">preparing…</span><span id="ingestPercent"></span></div>
400
+ <div id="ingestBar"><div id="ingestFill"></div></div>
401
+ </div>
402
+
403
  <div class="add">
404
  <h3>from a URL</h3>
405
  <div class="addrow">
 
463
  const pct = (loaded, total) => (total ? Math.min(100, (loaded / total) * 100) : 0);
464
 
465
  const statusText = (t) => { $("status").textContent = t; };
466
+ // llama.cpp log levels: 1 debug, 2 info, 3 warn, 4 error, 5 continuation.
467
+ // Only warn/error are coloured; info is llama.cpp's own progress chatter
468
+ // (every image batch logs one line) and must not read as a failure.
469
+ const log = (t, level = 0) => {
470
  const el = $("log");
471
+ const text = t.endsWith("\n") ? t : t + "\n";
472
+ if (level >= 3) {
473
+ const line = document.createElement("span");
474
+ line.className = "logline " + (level >= 4 ? "err" : "warn");
475
+ line.textContent = text;
476
+ el.appendChild(line);
477
+ } else {
478
+ el.appendChild(document.createTextNode(text));
479
+ }
480
  el.scrollTop = el.scrollHeight;
481
  };
482
  const setBar = (loaded, total) => { $("barFill").style.width = pct(loaded, total).toFixed(1) + "%"; };
483
+ const resetBar = () => { $("barFill").style.width = "0%"; };
484
+
485
+ // Model ingest (download / copy into IndexedDB) has its own bar in the Models
486
+ // tab. The top bar tracks engine startup only; one bar for both made a finished
487
+ // download look like a starting engine and vice versa.
488
+ const showIngest = (label, loaded = 0, total = 0) => {
489
+ const box = $("ingest");
490
+ box.hidden = false;
491
+ box.classList.remove("error");
492
+ $("ingestLabel").textContent = label;
493
+ $("ingestPercent").textContent = total ? pct(loaded, total).toFixed(1) + "%" : "";
494
+ $("ingestFill").style.width = pct(loaded, total).toFixed(1) + "%";
495
+ };
496
+ const hideIngest = () => {
497
+ $("ingest").hidden = true;
498
+ $("ingest").classList.remove("error");
499
+ $("ingestFill").style.width = "0%";
500
+ };
501
+ const labelIngest = (label) => { if (!$("ingest").hidden) $("ingestLabel").textContent = label; };
502
+ const failIngest = (why) => {
503
+ $("ingest").hidden = false;
504
+ $("ingest").classList.add("error");
505
+ $("ingestLabel").textContent = "error: " + why;
506
+ $("ingestPercent").textContent = "";
507
+ };
508
 
509
  let app = null;
510
  let models = [];
511
  let loadedId = null; // model currently in the wasm instance
512
+ let ingestingProjector = null; // name while an mmproj URL is being downloaded
513
  let currentId = null; // model the user last asked to load
514
  let pendingFile = null; // file picked for the "add" form
515
  let reattachFor = null; // model id waiting for its file to be re-picked
 
623
  stop: stopList(),
624
  chat_template_source: templateOverride(),
625
  tools: $("tools").value.trim(),
626
+ // the tool path above is untouched: these stay empty unless the picker
627
+ // selects them, and a caller grammar wins over the template's own
628
+ json_schema: $("constrain").value === "json_schema" ? $("jsonSchema").value.trim() : "",
629
+ grammar: $("constrain").value === "grammar" ? $("grammar").value.trim() : "",
630
  template_kwargs: $("templateKwargs").value.trim() || "{}",
631
  };
632
  }
 
663
  parent.postMessage({ type: "llmjs:ready", model: m.name,
664
  n_ctx: info.n_ctx, threads: info.threads || 1 }, "*");
665
  }
666
+ // the bar belongs to the startup, not to the loaded state
667
+ resetBar();
668
+ hideIngest();
669
  setState("ready");
670
  refreshModels();
671
  },
 
743
  chatRow.querySelector(".content").readOnly = false;
744
  chatRow = null;
745
  }
746
+ // an ingest failure belongs to the library surface, not the engine one
747
+ if (uiState === "downloading" || uiState === "copying" || ingestingProjector) {
748
+ failIngest(String(err));
749
+ ingestingProjector = null;
750
+ statusText("engine idle — the model library reported an error");
751
+ } else {
752
+ statusText("error: " + err);
753
+ }
754
  log("ERROR: " + err);
755
  setState(loadedId ? "ready" : "idle");
756
  },
757
  (p) => {
758
+ const done = p.total > 0 && p.loaded >= p.total;
759
  if (p.phase === "download") {
760
+ // library work: the engine bar stays at zero until the bytes are here
761
  setState("downloading");
762
+ resetBar();
763
+ showIngest(p.complete || done ? "downloaded — starting the engine…"
764
+ : `downloading ${fmt(p.loaded)} / ${fmt(p.total)}`, p.loaded, p.total);
765
  } else if (p.phase === "copy") {
766
  setState("copying");
767
+ resetBar();
768
+ if (done) hideIngest();
769
+ else showIngest(`copying into browser storage ${fmt(p.loaded)} / ${fmt(p.total)}`,
770
+ p.loaded, p.total);
771
  } else if (p.phase === "hash") {
772
  setState("hashing");
773
+ if (ingestingProjector) {
774
+ // no engine is starting: the projector is library content
775
+ resetBar();
776
+ showIngest(`verifying the projector (sha256 ${pct(p.loaded, p.total).toFixed(0)}%)`,
777
+ p.loaded, p.total);
778
+ statusText("engine idle — verifying the projector");
779
+ } else {
780
+ // the bytes are in the library; this is startup work
781
+ hideIngest();
782
+ statusText(`engine starting · verifying model bytes (sha256 ${pct(p.loaded, p.total).toFixed(0)}%)`);
783
+ setBar(p.loaded, p.total);
784
+ }
785
  } else if (p.phase === "load") {
786
+ hideIngest();
787
  setState("loading");
788
+ statusText(`engine starting · loading into wasm ${fmt(p.loaded)} / ${fmt(p.total)}`);
789
  setBar(p.loaded, p.total);
790
  }
791
  },
792
+ (line, level) => log(line.trimEnd(), level ?? 0),
793
  );
794
 
795
  app.on_event = (msg) => {
 
801
  $("fileLabel").value = "";
802
  $("urlMmproj").checked = false;
803
  $("fileMmproj").checked = false;
804
+ syncIngestButtons();
805
  // adding a projector does not load anything, so nothing reports INITIALIZED
806
  setState(loadedId ? "ready" : "idle");
807
  refreshModels();
808
  break;
809
 
810
  case action.MODEL_UPDATED:
811
+ if (ingestingProjector) {
812
+ const name = ingestingProjector;
813
+ ingestingProjector = null;
814
+ hideIngest();
815
+ resetBar();
816
+ statusText(`${name} added to the model library — pick it in the engine row`);
817
+ setState(loadedId ? "ready" : "idle");
818
+ }
819
  refreshModels();
820
  break;
821
 
822
  case action.PAUSED:
823
+ labelIngest("paused — press Resume to continue");
824
+ statusText("engine idle — the download is paused");
825
  setState("paused");
826
  refreshModels();
827
  break;
 
1183
 
1184
  function startLoad(m) {
1185
  if (m.multimodal) {
1186
+ statusText(`${m.name} is an mmproj — tick mmproj in the engine row and pick it there`);
1187
  return;
1188
  }
1189
  // a local file that was never copied needs its File again after a reload
 
1206
  currentId = m.id;
1207
  $("out").textContent = "";
1208
  $("stats").textContent = "";
1209
+ resetBar();
1210
+ // a URL model that never finished is an ingest job first; its progress is
1211
+ // reported in the model library, and the engine bar starts at zero
1212
+ if (m.source === "url" && !m.complete) {
1213
+ showTab("models");
1214
+ showIngest("preparing download…");
1215
+ statusText("engine idle — download progress is in the model library");
1216
+ } else {
1217
+ hideIngest();
1218
+ statusText("starting…");
1219
+ }
1220
  setState("starting");
1221
  app.post({
1222
  event: action.LOAD,
 
1236
  if (!app || !currentId) return;
1237
  $("out").textContent = "";
1238
  $("stats").textContent = "";
1239
+ hideIngest();
1240
+ resetBar();
1241
  statusText("freeing the model and reloading the engine…");
1242
  setState("starting");
1243
  const mpId = engineParams().mmproj_id;
 
1274
  ensureApp();
1275
  $("out").textContent = "";
1276
  $("stats").textContent = "";
1277
+ resetBar();
1278
+ hideIngest();
1279
+ const copy = $("keepCopy").checked;
1280
+ const projector = $("fileMmproj").checked;
1281
+ if (projector) {
1282
+ statusText(`adding ${file.name} to the model library…`);
1283
+ if (copy) showIngest("copying the projector into the library…");
1284
+ } else if (copy) {
1285
+ showIngest("copying the file into the library…");
1286
+ statusText("engine idle — copy progress is in the model library");
1287
+ } else {
1288
+ statusText("starting…");
1289
+ }
1290
  setState("starting");
1291
  app.post({
1292
  event: action.ADD_FILE,
 
1305
  ensureApp();
1306
  $("out").textContent = "";
1307
  $("stats").textContent = "";
1308
+ resetBar();
1309
+ hideIngest();
1310
+ // progress lives in the library, so that is where the download happens
1311
+ showTab("models");
1312
+ // a projector is downloaded into the library and nothing is started: it
1313
+ // becomes a launch parameter of the next model load
1314
+ if (mmproj) ingestingProjector = name || url.split("/").pop() || "projector";
1315
+ showIngest("preparing download…");
1316
+ statusText("engine idle — download progress is in the model library");
1317
  setState("starting");
1318
  app.post({
1319
  event: action.ADD_URL,
 
1457
  }
1458
 
1459
  $("pause").onclick = () => {
1460
+ if (app) { app.pause(); labelIngest("pausing…"); statusText("engine idle — pausing the download…"); }
1461
  };
1462
 
1463
  $("resume").onclick = () => {
1464
  if (!app) return;
1465
+ labelIngest("resuming…");
1466
+ statusText("engine idle — resuming the download…");
1467
  setState("downloading");
1468
  app.resume();
1469
  };
 
1493
  syncTemplateUI();
1494
  };
1495
 
1496
+ // one constraint at a time: the picker reveals only the box it applies to
1497
+ function syncConstrain() {
1498
+ const mode = $("constrain").value;
1499
+ $("toolsWrap").hidden = mode !== "tools";
1500
+ $("schemaWrap").hidden = mode !== "json_schema";
1501
+ $("grammarWrap").hidden = mode !== "grammar";
1502
+ }
1503
+ $("constrain").onchange = syncConstrain;
1504
+ syncConstrain();
1505
+
1506
  $("previewTemplate").onclick = () => {
1507
  if (!app) { statusText("load a model first"); return; }
1508
  $("templatePreview").textContent = "rendering…";
 
1725
  }
1726
  }
1727
 
1728
+ // One object, two editors: the Chat template tab (for experiments with the
1729
+ // preview) and the inference rail (per-turn config). They mirror each other,
1730
+ // so whichever place the user edits, preview and generation both see it.
1731
+ const kwargBoxes = [$("templateKwargs"), $("runTemplateKwargs")];
1732
+ const syncKwargs = (src) => {
1733
+ for (const box of kwargBoxes) if (box !== src) box.value = src.value;
1734
+ };
1735
+ for (const box of kwargBoxes) box.addEventListener("input", () => syncKwargs(box));
1736
+
1737
+ $("templateKwargs").value = "{}";
1738
+ syncKwargs($("templateKwargs"));
1739
+
1740
  $("enableThinking").onchange = () => {
1741
  const kw = readKwargs();
1742
  if ($("enableThinking").checked) kw.enable_thinking = true;
1743
  else delete kw.enable_thinking;
1744
  $("templateKwargs").value = JSON.stringify(kw, null, 2);
1745
+ syncKwargs($("templateKwargs"));
1746
  };
1747
 
 
 
1748
  $("useMmproj").onchange = () => {
1749
  $("mmprojSelect").disabled = !$("useMmproj").checked || !models.some((m) => m.multimodal);
1750
  statusText($("useMmproj").checked
 
1752
  : "no multimodal projector will be loaded");
1753
  };
1754
 
1755
+ // The button says what actually happens: a text model is downloaded and then
1756
+ // loaded, a projector is only put into the library (nothing to start).
1757
+ function syncIngestButtons() {
1758
+ $("addUrl").textContent = $("urlMmproj").checked ? "Download projector" : "Download & load";
1759
+ $("addFile").textContent = $("fileMmproj").checked ? "Add to library" : "Add & load";
1760
+ }
1761
+ $("urlMmproj").onchange = syncIngestButtons;
1762
+ $("fileMmproj").onchange = syncIngestButtons;
1763
+ syncIngestButtons();
1764
+
1765
  // The threaded module needs SharedArrayBuffer, i.e. a cross-origin isolated
1766
  // page. Without COOP/COEP the single-thread module is what the worker loads,
1767
  // and asking for threads would only be a lie.
llm.js CHANGED
@@ -95,7 +95,9 @@ class LLM {
95
  break;
96
 
97
  case action.LOG:
98
- if (this.on_log_callback) this.on_log_callback(data.line);
 
 
99
  break;
100
 
101
  case action.ERROR:
 
95
  break;
96
 
97
  case action.LOG:
98
+ // the level decides what is an error and what is llama.cpp
99
+ // chatter: dropping it made INFO lines look like failures
100
+ if (this.on_log_callback) this.on_log_callback(data.line, data.level ?? 0);
101
  break;
102
 
103
  case action.ERROR:
wasm/llm-wasm-mt.build.txt CHANGED
@@ -1,4 +1,4 @@
1
  llama.cpp commit: f7591a6a01f9273de03331732bdc0b39721168be
2
  llama.cpp subject: sycl: add f16 support for SWIGLU_OAI (#30857)
3
  emcc: emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 7.0.0 (777e6e5db7b4990bcaeb1b9998ed642cc84e6629)
4
- built: 2026-11-09T17:00:27Z
 
1
  llama.cpp commit: f7591a6a01f9273de03331732bdc0b39721168be
2
  llama.cpp subject: sycl: add f16 support for SWIGLU_OAI (#30857)
3
  emcc: emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 7.0.0 (777e6e5db7b4990bcaeb1b9998ed642cc84e6629)
4
+ built: 2026-11-10T16:49:06Z
wasm/llm-wasm-mt.js CHANGED
The diff for this file is too large to render. See raw diff
 
wasm/llm-wasm-mt.wasm CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:79a668b5a3846abb512c5ebe90d3b32fa8801c04ba8cb7fafa5a4683795fd05d
3
- size 6922172
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1d34dbe1230fdb116e0aede29c9559e09195a93987883f288de06bae3e5908c3
3
+ size 7040863
worker.js CHANGED
@@ -115,7 +115,11 @@ async function ensureModule() {
115
  }
116
  }
117
  if (!factory) factory = (await import("./wasm/llm-wasm.js")).default;
118
- module = await factory();
 
 
 
 
119
  module._llmjs_init();
120
  log(`llama.cpp ${module.UTF8ToString(module._llmjs_version())}` +
121
  (THREADED ? " (threaded build)" : " (single-thread build)"));
@@ -255,7 +259,11 @@ async function loadIntoFS(model, target = "/models/model.gguf") {
255
  return written;
256
  }
257
 
258
- async function start({ id, file, mmproj_file, context_size, kv, mmproj_id, threads }) {
 
 
 
 
259
  if (busy) {
260
  fail(new Error("a load is already running"));
261
  return;
@@ -270,25 +278,13 @@ async function start({ id, file, mmproj_file, context_size, kv, mmproj_id, threa
270
 
271
  busy = true;
272
  abort = new AbortController();
273
- lastRequest = { id, context_size, kv: { ...currentKv }, mmproj_id, threads: currentThreads };
 
274
 
275
  try {
276
- await ensureModule();
277
- // older single-thread modules predate this export; they clamp to 1 anyway
278
- if (module._llmjs_set_threads) {
279
- module.ccall("llmjs_set_threads", null, ["number"], [currentThreads]);
280
- }
281
-
282
  let model = await getModel(id);
283
  if (!model) throw new Error(`unknown model ${id}`);
284
 
285
- // the wasm instance can only hold one model at a time
286
- if (loaded && loadedId !== id) {
287
- module._llmjs_free();
288
- loaded = false;
289
- loadedId = null;
290
- }
291
-
292
  // 1. get the bytes into the browser (url models only; a local file is
293
  // already where it needs to be)
294
  if (model.source === "url" && !model.complete) {
@@ -305,7 +301,26 @@ async function start({ id, file, mmproj_file, context_size, kv, mmproj_id, threa
305
  // 2. identify the bytes once
306
  if (!model.sha256) model = await hashModel(model);
307
 
 
 
 
 
 
 
308
  // 3. into the wasm FS, then load
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  const bytes = await loadIntoFS(model);
310
  if (model.received && bytes !== model.received) {
311
  log(`stored model size ${bytes} != expected ${model.received}`);
@@ -399,6 +414,7 @@ async function run(opts) {
399
  tools = "[]",
400
  template_kwargs = "{}",
401
  grammar = "",
 
402
  images = [],
403
  } = opts;
404
 
@@ -411,6 +427,18 @@ async function run(opts) {
411
  return;
412
  }
413
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  if (createContext(context_size || currentNctx) < 0) {
415
  fail(new Error("could not create the inference context"));
416
  return;
@@ -450,7 +478,10 @@ async function run(opts) {
450
  );
451
  }
452
  if (rc < 0) {
453
- fail(new Error(`could not start generation (${rc})`));
 
 
 
454
  return;
455
  }
456
 
@@ -508,10 +539,11 @@ self.addEventListener("message", (e) => {
508
  case action.ADD_URL:
509
  (async () => {
510
  const id = await addUrlModel(msg);
511
- // a projector is not a model to run: it is picked in the engine
512
- // parameters and loaded next to whatever model is selected
513
- if (msg.mmproj) return;
514
- await start({ id, context_size: msg.context_size, kv: msg.kv, threads: msg.threads });
 
515
  })().catch(fail);
516
  break;
517
 
 
115
  }
116
  }
117
  if (!factory) factory = (await import("./wasm/llm-wasm.js")).default;
118
+ // emscripten writes to stdout/stderr for anything that does not go
119
+ // through our log callback; that is chatter, not a failure, so it
120
+ // must not land in the console as an error
121
+ const print = (text) => self.llmjs_on_log(2, String(text).replace(/\n?$/, "\n"));
122
+ module = await factory({ print, printErr: print });
123
  module._llmjs_init();
124
  log(`llama.cpp ${module.UTF8ToString(module._llmjs_version())}` +
125
  (THREADED ? " (threaded build)" : " (single-thread build)"));
 
259
  return written;
260
  }
261
 
262
+ // `ingest_only` stops after the bytes are in the library (download + hash): a
263
+ // projector is not something the engine can "start", it is picked as a launch
264
+ // parameter of the next model load.
265
+ async function start({ id, file, mmproj_file, context_size, kv, mmproj_id, threads,
266
+ ingest_only = false }) {
267
  if (busy) {
268
  fail(new Error("a load is already running"));
269
  return;
 
278
 
279
  busy = true;
280
  abort = new AbortController();
281
+ lastRequest = { id, context_size, kv: { ...currentKv }, mmproj_id, threads: currentThreads,
282
+ ingest_only };
283
 
284
  try {
 
 
 
 
 
 
285
  let model = await getModel(id);
286
  if (!model) throw new Error(`unknown model ${id}`);
287
 
 
 
 
 
 
 
 
288
  // 1. get the bytes into the browser (url models only; a local file is
289
  // already where it needs to be)
290
  if (model.source === "url" && !model.complete) {
 
301
  // 2. identify the bytes once
302
  if (!model.sha256) model = await hashModel(model);
303
 
304
+ if (ingest_only) {
305
+ busy = false;
306
+ post({ event: action.MODEL_UPDATED, model });
307
+ return;
308
+ }
309
+
310
  // 3. into the wasm FS, then load
311
+ await ensureModule();
312
+ // older single-thread modules predate this export; they clamp to 1 anyway
313
+ if (module._llmjs_set_threads) {
314
+ module.ccall("llmjs_set_threads", null, ["number"], [currentThreads]);
315
+ }
316
+
317
+ // the wasm instance can only hold one model at a time
318
+ if (loaded && loadedId !== id) {
319
+ module._llmjs_free();
320
+ loaded = false;
321
+ loadedId = null;
322
+ }
323
+
324
  const bytes = await loadIntoFS(model);
325
  if (model.received && bytes !== model.received) {
326
  log(`stored model size ${bytes} != expected ${model.received}`);
 
414
  tools = "[]",
415
  template_kwargs = "{}",
416
  grammar = "",
417
+ json_schema = "",
418
  images = [],
419
  } = opts;
420
 
 
427
  return;
428
  }
429
 
430
+ // structured output: only when the page chose a schema. The tool-call path
431
+ // sends neither, so it runs exactly as before.
432
+ if (!grammar && json_schema && json_schema.trim()) {
433
+ const asGrammar = module.ccall("llmjs_grammar_from_schema", "string",
434
+ ["string"], [json_schema]);
435
+ if (!asGrammar) {
436
+ fail(new Error(`could not use the JSON schema: ${module.UTF8ToString(module._llmjs_last_error())}`));
437
+ return;
438
+ }
439
+ grammar = asGrammar;
440
+ }
441
+
442
  if (createContext(context_size || currentNctx) < 0) {
443
  fail(new Error("could not create the inference context"));
444
  return;
 
478
  );
479
  }
480
  if (rc < 0) {
481
+ // the engine knows why: an image larger than the context, a marker/image
482
+ // mismatch, no projector … - "(-1)" alone tells the user nothing
483
+ const why = module.UTF8ToString(module._llmjs_last_error());
484
+ fail(new Error(`could not start generation (${rc})${why ? `: ${why}` : ""}`));
485
  return;
486
  }
487
 
 
539
  case action.ADD_URL:
540
  (async () => {
541
  const id = await addUrlModel(msg);
542
+ // a projector is not a model to run: download its bytes into the
543
+ // library, then stop - it is picked in the engine parameters and
544
+ // loaded next to whatever model is selected
545
+ await start({ id, context_size: msg.context_size, kv: msg.kv, threads: msg.threads,
546
+ ingest_only: !!msg.mmproj });
547
  })().catch(fail);
548
  break;
549