mlx-bun 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +58 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  5. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  6. package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
  7. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  8. package/docs/design/batching.md +1535 -50
  9. package/docs/design/decode-speed-program.md +394 -1171
  10. package/docs/design/generic-model-support.md +195 -3
  11. package/docs/design/kv-cache.md +538 -225
  12. package/docs/design/speculative-decoding.md +552 -50
  13. package/docs/design/turboquant.md +140 -17
  14. package/docs/design/unified-engine-frontier-plan.md +259 -150
  15. package/docs/planning/PRODUCT_ROADMAP.md +21 -6
  16. package/docs/planning/public-readiness-handoff.md +244 -0
  17. package/docs/planning/public-readiness.md +149 -0
  18. package/docs/reference/benchmarks.md +3537 -1112
  19. package/docs/reference/cli.md +102 -2
  20. package/docs/reference/distribution.md +45 -13
  21. package/docs/reference/environment.md +159 -13
  22. package/docs/reference/library-api.md +18 -4
  23. package/docs/reference/memory.md +1 -1
  24. package/docs/reference/models.md +28 -7
  25. package/docs/reference/server-api.md +257 -33
  26. package/docs/reference/server-config.md +372 -151
  27. package/docs/reference/troubleshooting.md +13 -21
  28. package/package.json +2 -2
  29. package/src/audio/audiotoolbox.ts +112 -0
  30. package/src/audio/conformer.ts +8 -0
  31. package/src/audio/mic-capture.ts +98 -0
  32. package/src/audio/silero-vad.ts +379 -0
  33. package/src/audio/whisper-decode.ts +774 -0
  34. package/src/audio/whisper-fast.ts +732 -0
  35. package/src/audio/whisper-format.ts +73 -0
  36. package/src/audio/whisper-mel.ts +185 -0
  37. package/src/audio/whisper-timing.ts +274 -0
  38. package/src/audio/whisper-tokenizer.ts +239 -0
  39. package/src/audio/whisper-transcribe.ts +410 -0
  40. package/src/backends/mlx/assistant-target.ts +46 -0
  41. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  42. package/src/backends/mlx/autoregressive.ts +22 -1
  43. package/src/backends/mlx/batch-group.ts +464 -236
  44. package/src/backends/mlx/cache-attachment.ts +67 -0
  45. package/src/backends/mlx/cache-layout.ts +31 -0
  46. package/src/backends/mlx/checkpoint-state.ts +53 -0
  47. package/src/backends/mlx/continuation-persistence.ts +107 -0
  48. package/src/backends/mlx/continuation-request.ts +43 -0
  49. package/src/backends/mlx/continuation.ts +77 -0
  50. package/src/backends/mlx/draft-target.ts +7 -22
  51. package/src/backends/mlx/encoder-cache.ts +25 -0
  52. package/src/backends/mlx/fill-append.ts +21 -8
  53. package/src/backends/mlx/fill-group.ts +497 -0
  54. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  55. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  56. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  57. package/src/backends/mlx/gateway-binding.ts +122 -19
  58. package/src/backends/mlx/kv-codec.ts +9 -0
  59. package/src/backends/mlx/kv-maintenance.ts +161 -0
  60. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  61. package/src/backends/mlx/mixed-iteration.ts +62 -0
  62. package/src/backends/mlx/model-prompt.ts +46 -32
  63. package/src/backends/mlx/model-serving.ts +5 -4
  64. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  65. package/src/backends/mlx/prefill-cohort.ts +100 -0
  66. package/src/backends/mlx/prefill-policy.ts +26 -0
  67. package/src/backends/mlx/prefill-rows.ts +244 -0
  68. package/src/backends/mlx/prefill.ts +1 -1
  69. package/src/backends/mlx/projected-context-rows.ts +72 -0
  70. package/src/backends/mlx/prompt-input.ts +29 -0
  71. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  72. package/src/backends/mlx/request-state-policy.ts +54 -0
  73. package/src/backends/mlx/rollback.ts +45 -4
  74. package/src/backends/mlx/row-sampling.ts +49 -0
  75. package/src/backends/mlx/serial-executor.ts +51 -73
  76. package/src/backends/mlx/speculative-group.ts +438 -0
  77. package/src/backends/mlx/speculative-round.ts +161 -0
  78. package/src/backends/mlx/speculative.ts +24 -7
  79. package/src/backends/mlx/state-rows.ts +104 -0
  80. package/src/backends/mlx/state-views.ts +21 -1
  81. package/src/backends/mlx/target-layout-capability.ts +35 -0
  82. package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
  83. package/src/cli.ts +333 -36
  84. package/src/contracts/execution.ts +21 -0
  85. package/src/contracts/object-cache.ts +8 -0
  86. package/src/contracts/pi-provider.ts +1 -1
  87. package/src/contracts/pi.ts +3 -1
  88. package/src/contracts/prefix-cache.ts +33 -0
  89. package/src/contracts/resources.ts +7 -0
  90. package/src/contracts/sampling.ts +23 -0
  91. package/src/contracts/scheduling.ts +27 -0
  92. package/src/engine/execution-plan.ts +38 -25
  93. package/src/engine/execution-tasks.ts +44 -0
  94. package/src/engine/namespaced-cache.ts +13 -0
  95. package/src/engine/resources.ts +12 -1
  96. package/src/engine/scheduler.ts +32 -7
  97. package/src/fill/fill-session.ts +14 -3
  98. package/src/fit.ts +29 -15
  99. package/src/generate.ts +105 -195
  100. package/src/grammar.ts +13 -0
  101. package/src/inference/draft-acceptance.ts +41 -0
  102. package/src/inference/draft-output.ts +49 -0
  103. package/src/inference/prefill.ts +6 -0
  104. package/src/inference/rollback.ts +2 -2
  105. package/src/kv-scheme.ts +92 -12
  106. package/src/kv-store.ts +183 -93
  107. package/src/lab/README.md +5 -1
  108. package/src/lab/paged-kv/paged-attention.ts +117 -0
  109. package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
  110. package/src/lab/paged-kv/paged-kv.ts +126 -46
  111. package/src/md-modules.d.ts +5 -0
  112. package/src/mlx/array.ts +39 -1
  113. package/src/mlx/ffi.ts +19 -0
  114. package/src/mlx/normalized-argmax.ts +116 -0
  115. package/src/mlx/ops.ts +97 -10
  116. package/src/model/artifact-identity.ts +73 -0
  117. package/src/model/batched-kv.ts +235 -0
  118. package/src/model/batched-mask.ts +12 -4
  119. package/src/model/batched-quant.ts +8 -2
  120. package/src/model/batched-quantized-kv.ts +144 -0
  121. package/src/model/batched-rotating-quant.ts +76 -15
  122. package/src/model/batched-rotating-state.ts +76 -0
  123. package/src/model/batched-rotating.ts +64 -22
  124. package/src/model/batched-row-storage.ts +52 -0
  125. package/src/model/batched-ssm.ts +52 -0
  126. package/src/model/batched-turboquant-kv.ts +94 -0
  127. package/src/model/compiled-decode.ts +7 -1
  128. package/src/model/decoded-kv-donor.ts +12 -0
  129. package/src/model/delayed-quantized-kv.ts +72 -0
  130. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  131. package/src/model/delayed-turboquant-kv.ts +57 -0
  132. package/src/model/factory.ts +26 -0
  133. package/src/model/fingerprint.ts +12 -0
  134. package/src/model/full-prefill-padding.ts +56 -0
  135. package/src/model/full-prefill-row.ts +105 -0
  136. package/src/model/gemma4-base.ts +183 -82
  137. package/src/model/gemma4.ts +116 -28
  138. package/src/model/generated/gemma4-12b.ts +4 -1
  139. package/src/model/generated/gemma4-26b.ts +4 -1
  140. package/src/model/generated/gemma4-e4b.ts +4 -1
  141. package/src/model/glm52-cache.ts +80 -4
  142. package/src/model/glm52-mla.ts +12 -6
  143. package/src/model/glm52.ts +19 -40
  144. package/src/model/kv-attention-view.ts +70 -0
  145. package/src/model/minicpm5.ts +11 -7
  146. package/src/model/profile.ts +29 -4
  147. package/src/model/quantized-append-attention.ts +31 -0
  148. package/src/model/qwen3-delta-state.ts +74 -0
  149. package/src/model/qwen3-delta.ts +59 -36
  150. package/src/model/qwen3-mrope.ts +7 -9
  151. package/src/model/qwen3_5.ts +177 -75
  152. package/src/model/rotating-kv-donor.ts +35 -0
  153. package/src/model/rotating-kv-layout.ts +262 -0
  154. package/src/model/rotating-row-transaction.ts +35 -0
  155. package/src/model/speculative-rotating-kv.ts +101 -0
  156. package/src/model/ssm-prefill-padding.ts +39 -0
  157. package/src/model/support.ts +8 -0
  158. package/src/model/token-groups.ts +66 -0
  159. package/src/model/trellis-linear.ts +4 -4
  160. package/src/model/turboquant-codec.ts +89 -0
  161. package/src/model/universal/dense.ts +14 -13
  162. package/src/model/whisper.ts +435 -0
  163. package/src/native/mic_capture.swift +92 -0
  164. package/src/native-pack.ts +6 -5
  165. package/src/pi-web.ts +9 -1
  166. package/src/preflight.ts +31 -15
  167. package/src/prompt-cache.ts +306 -86
  168. package/src/runtime-config.ts +1 -5
  169. package/src/safetensors.ts +9 -2
  170. package/src/sampler.ts +37 -25
  171. package/src/serve/audio-routes.ts +323 -0
  172. package/src/serve/chat-request.ts +2 -0
  173. package/src/serve/chat-stage.ts +19 -14
  174. package/src/serve/completion-executor.ts +9 -1
  175. package/src/serve/discovery-routes.ts +17 -1
  176. package/src/serve/generated-token-history.ts +57 -0
  177. package/src/serve/generation-gateway.ts +88 -38
  178. package/src/serve/model-binding.ts +7 -1
  179. package/src/serve/model-host.ts +3 -4
  180. package/src/serve/preparation.ts +3 -3
  181. package/src/serve/prompt-response-trace.ts +14 -0
  182. package/src/serve/request-plan.ts +6 -3
  183. package/src/serve/request-prep.ts +40 -25
  184. package/src/serve/text-completion-stage.ts +2 -2
  185. package/src/serve/transcription-server.ts +64 -0
  186. package/src/serve/transcription-service.ts +502 -0
  187. package/src/server.ts +185 -261
  188. package/src/spec/assistant-rows.ts +93 -0
  189. package/src/spec/assistant-source.ts +28 -18
  190. package/src/spec/cache-identity.ts +22 -0
  191. package/src/spec/deepspec-rows.ts +29 -0
  192. package/src/spec/deepspec-source.ts +12 -10
  193. package/src/spec/dflash-source.ts +28 -4
  194. package/src/spec/drafter.ts +153 -142
  195. package/src/spec/dspark/deepspec-module.ts +95 -120
  196. package/src/spec/dspark/module-dflash.ts +126 -170
  197. package/src/spec/glm52-mtp-graph.ts +29 -0
  198. package/src/spec/glm52-mtp-rows.ts +132 -0
  199. package/src/spec/glm52-mtp-source.ts +54 -136
  200. package/src/spec/glm52-mtp-state.ts +33 -0
  201. package/src/spec/ngram-source.ts +115 -21
  202. package/src/spec/projected-draft-rows.ts +73 -0
  203. package/src/spec/qwen-mtp-module.ts +171 -0
  204. package/src/spec/qwen-mtp-rows.ts +230 -0
  205. package/src/spec/qwen-mtp-source.ts +84 -226
  206. package/src/spec/qwen-mtp-state.ts +30 -0
  207. package/src/spec/serve-loop.ts +73 -69
  208. package/src/spec/source.ts +93 -15
  209. package/src/spec/two-model-rows.ts +62 -0
  210. package/src/spec/two-model.ts +45 -103
  211. package/src/ssd-cache.ts +107 -36
  212. package/src/ssd-durability.ts +29 -27
  213. package/src/storage/host-buffer.ts +24 -0
  214. package/src/storage/kv-writer.ts +83 -0
  215. package/src/storage/kv-writer.worker.js +264 -0
  216. package/src/storage/retention-policy.ts +37 -0
  217. package/src/tiered-prompt-cache.ts +45 -0
  218. package/src/vision/embedder.ts +8 -0
  219. package/src/vision/prompt.ts +101 -65
  220. package/src/vision/qwen3vl-prompt.ts +101 -64
  221. package/src/vision/qwen3vl-tower.ts +11 -0
  222. package/src/vision/siglip.ts +8 -0
  223. package/src/web/app.html +12 -0
  224. package/src/web/app.js +217 -57
  225. package/src/web/src/chat.ts +4 -1
  226. package/src/web/src/composer.ts +3 -0
  227. package/src/web/src/voice.ts +141 -0
  228. package/src/web/sw.js +1 -1
  229. package/src/backends/mlx/memory-guard.ts +0 -40
  230. package/src/spec/prefix-state.ts +0 -45
  231. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
package/src/web/app.js CHANGED
@@ -994,14 +994,14 @@ function highlightIn(container) {
994
994
  return;
995
995
  const blocks = container.querySelectorAll("pre code");
996
996
  for (const block of blocks) {
997
- const el2 = block;
998
- if (el2.dataset.highlighted)
997
+ const el = block;
998
+ if (el.dataset.highlighted)
999
999
  continue;
1000
- const hasLang = /\blanguage-/.test(el2.className);
1001
- if (!hasLang && (el2.textContent || "").length > 20000)
1000
+ const hasLang = /\blanguage-/.test(el.className);
1001
+ if (!hasLang && (el.textContent || "").length > 20000)
1002
1002
  continue;
1003
1003
  try {
1004
- hljs.highlightElement(el2);
1004
+ hljs.highlightElement(el);
1005
1005
  } catch {}
1006
1006
  }
1007
1007
  }
@@ -1209,6 +1209,7 @@ function readText(file) {
1209
1209
  class ComposerState {
1210
1210
  attachments = [];
1211
1211
  visionCapable = false;
1212
+ transcriptionCapable = false;
1212
1213
  thinkingCapable = false;
1213
1214
  thinkingOn = true;
1214
1215
  attachSeq = 0;
@@ -1321,6 +1322,9 @@ function updateAttachHint(state) {
1321
1322
  const btn = $("chat-attach-btn");
1322
1323
  if (btn)
1323
1324
  btn.title = state.visionCapable ? "Attach files or images" : "Attach files (this model can't see images)";
1325
+ const mic = $("chat-mic-btn");
1326
+ if (mic)
1327
+ mic.style.display = state.transcriptionCapable ? "" : "none";
1324
1328
  }
1325
1329
  function escHtml2(s) {
1326
1330
  return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
@@ -1983,6 +1987,160 @@ function renderQueue(q) {
1983
1987
  bar.style.display = parts.length ? "flex" : "none";
1984
1988
  }
1985
1989
 
1990
+ // src/web/src/voice.ts
1991
+ var SAMPLE_RATE = 16000;
1992
+ var CHUNK_SECONDS = 0.25;
1993
+ var take = null;
1994
+ var starting = false;
1995
+ async function postChunk(id, pcm) {
1996
+ const r = await fetch(`/v1/audio/sessions/${id}/audio`, {
1997
+ method: "POST",
1998
+ headers: { "content-type": "audio/pcm;rate=16000" },
1999
+ body: new Uint8Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength))
2000
+ });
2001
+ if (!r.ok)
2002
+ throw new Error(`audio chunk rejected (${r.status})`);
2003
+ }
2004
+ function flush(t) {
2005
+ if (t.pendingLen === 0)
2006
+ return;
2007
+ const buf = new Float32Array(t.pendingLen);
2008
+ let o = 0;
2009
+ for (const p of t.pending) {
2010
+ buf.set(p, o);
2011
+ o += p.length;
2012
+ }
2013
+ t.pending = [];
2014
+ t.pendingLen = 0;
2015
+ t.sending = t.sending.then(() => postChunk(t.id, buf)).catch((e) => {
2016
+ t.failed = e.message;
2017
+ });
2018
+ }
2019
+ async function begin(btn) {
2020
+ if (take || starting)
2021
+ return;
2022
+ starting = true;
2023
+ btn.classList.add("busy");
2024
+ try {
2025
+ const r = await fetch("/v1/audio/sessions", {
2026
+ method: "POST",
2027
+ headers: { "content-type": "application/json" },
2028
+ body: JSON.stringify({ vad: true, vad_min_speech_ms: 120, temperature: 0 })
2029
+ });
2030
+ if (!r.ok)
2031
+ throw new Error(r.status === 503 ? "no Whisper model on this server" : `session failed (${r.status})`);
2032
+ const { id } = await r.json();
2033
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
2034
+ const ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
2035
+ const src = ctx.createMediaStreamSource(stream);
2036
+ const node = ctx.createScriptProcessor(4096, 1, 1);
2037
+ const t = { id, ctx, stream, node, pending: [], pendingLen: 0, sending: Promise.resolve(), failed: null };
2038
+ node.onaudioprocess = (e) => {
2039
+ if (take !== t)
2040
+ return;
2041
+ const data = e.inputBuffer.getChannelData(0);
2042
+ t.pending.push(new Float32Array(data));
2043
+ t.pendingLen += data.length;
2044
+ if (t.pendingLen >= SAMPLE_RATE * CHUNK_SECONDS)
2045
+ flush(t);
2046
+ };
2047
+ src.connect(node);
2048
+ node.connect(ctx.destination);
2049
+ take = t;
2050
+ btn.classList.add("recording");
2051
+ } catch (e) {
2052
+ toast(`Voice input failed: ${e.message}`, "err");
2053
+ } finally {
2054
+ starting = false;
2055
+ btn.classList.remove("busy");
2056
+ }
2057
+ }
2058
+ async function end(btn, box) {
2059
+ const t = take;
2060
+ if (!t)
2061
+ return;
2062
+ take = null;
2063
+ btn.classList.remove("recording");
2064
+ btn.classList.add("busy");
2065
+ try {
2066
+ t.node.disconnect();
2067
+ for (const track of t.stream.getTracks())
2068
+ track.stop();
2069
+ t.ctx.close();
2070
+ flush(t);
2071
+ await t.sending;
2072
+ if (t.failed)
2073
+ throw new Error(t.failed);
2074
+ const r = await fetch(`/v1/audio/sessions/${t.id}/finish`, { method: "POST" });
2075
+ if (!r.ok)
2076
+ throw new Error(`transcription failed (${r.status})`);
2077
+ const body = await r.json();
2078
+ const text = (body.text || "").trim();
2079
+ if (!text) {
2080
+ toast(body.mlx_bun?.vad?.speech === false ? "No speech detected" : "Nothing transcribed", "");
2081
+ return;
2082
+ }
2083
+ insertAtCaret(box, text);
2084
+ } catch (e) {
2085
+ toast(`Voice input failed: ${e.message}`, "err");
2086
+ } finally {
2087
+ btn.classList.remove("busy");
2088
+ }
2089
+ }
2090
+ function insertAtCaret(box, text) {
2091
+ const start = box.selectionStart ?? box.value.length;
2092
+ const endPos = box.selectionEnd ?? start;
2093
+ const before = box.value.slice(0, start);
2094
+ const after = box.value.slice(endPos);
2095
+ const lead = before.length && !/\s$/.test(before) ? " " : "";
2096
+ const trail = after.length && !/^\s/.test(after) ? " " : "";
2097
+ box.value = before + lead + text + trail + after;
2098
+ const caret = (before + lead + text + trail).length;
2099
+ box.setSelectionRange(caret, caret);
2100
+ box.dispatchEvent(new Event("input", { bubbles: true }));
2101
+ box.focus();
2102
+ }
2103
+ function initVoiceInput(btn, box) {
2104
+ if (!btn)
2105
+ return;
2106
+ if (!navigator.mediaDevices?.getUserMedia) {
2107
+ btn.title = "Microphone capture needs a secure context (https or localhost)";
2108
+ }
2109
+ btn.addEventListener("pointerdown", (e) => {
2110
+ e.preventDefault();
2111
+ btn.setPointerCapture(e.pointerId);
2112
+ begin(btn);
2113
+ });
2114
+ const release = () => {
2115
+ end(btn, box);
2116
+ };
2117
+ btn.addEventListener("pointerup", release);
2118
+ btn.addEventListener("pointercancel", release);
2119
+ btn.addEventListener("lostpointercapture", release);
2120
+ let keyHeld = false;
2121
+ btn.addEventListener("keydown", (e) => {
2122
+ if ((e.key === " " || e.key === "Enter") && !keyHeld) {
2123
+ e.preventDefault();
2124
+ keyHeld = true;
2125
+ begin(btn);
2126
+ }
2127
+ });
2128
+ btn.addEventListener("keyup", (e) => {
2129
+ if ((e.key === " " || e.key === "Enter") && keyHeld) {
2130
+ e.preventDefault();
2131
+ keyHeld = false;
2132
+ release();
2133
+ }
2134
+ });
2135
+ btn.addEventListener("blur", () => {
2136
+ if (keyHeld) {
2137
+ keyHeld = false;
2138
+ release();
2139
+ }
2140
+ });
2141
+ $.voice = { begin: () => begin(btn), end: () => end(btn, box) };
2142
+ }
2143
+
1986
2144
  // src/web/src/sessions.ts
1987
2145
  var SESSIONS_STYLE_ID = "mlxbun-sessions-style";
1988
2146
  function injectSessionsStyles() {
@@ -2611,12 +2769,12 @@ async function renderListBody() {
2611
2769
  async function renderArticleLists(query) {
2612
2770
  const listEl = $("mem-list");
2613
2771
  if (!query) {
2614
- const d2 = await getJson("/api/memory/list").catch(() => ({ ok: false }));
2615
- if (!d2.ok) {
2772
+ const d = await getJson("/api/memory/list").catch(() => ({ ok: false }));
2773
+ if (!d.ok) {
2616
2774
  listEl.innerHTML = `<div class="mem-list-empty">Couldn't load the vault contents.</div>`;
2617
2775
  return;
2618
2776
  }
2619
- const articles = d2.articles || [], reference = d2.reference || [];
2777
+ const articles = d.articles || [], reference = d.reference || [];
2620
2778
  listEl.innerHTML = '<div class="mem-sec"><div class="mem-sec-title">Articles</div>' + (articles.length ? articles.map((a) => articleRowHtml(a)).join("") : '<div class="mem-list-empty">No articles yet — nightly synthesis writes here after a few conversations.</div>') + "</div>" + (reference.length ? '<div class="mem-sec"><div class="mem-sec-title">Reference</div>' + reference.map((r) => articleRowHtml(r, undefined, "\uD83D\uDCD8")).join("") + "</div>" : "");
2621
2779
  wireArticleRows(listEl);
2622
2780
  return;
@@ -2953,11 +3111,11 @@ var INTERACTIVE_SELECTOR = [
2953
3111
  "[data-spotlight]"
2954
3112
  ].join(", ");
2955
3113
  var MAX_ELEMENTS = 120;
2956
- function isAgentChrome(el2) {
2957
- return !!el2.closest("[data-ui-chrome]") || !!el2.closest("#toasts");
3114
+ function isAgentChrome(el) {
3115
+ return !!el.closest("[data-ui-chrome]") || !!el.closest("#toasts");
2958
3116
  }
2959
- function isHiddenSelf(el2) {
2960
- const html = el2;
3117
+ function isHiddenSelf(el) {
3118
+ const html = el;
2961
3119
  if (html.hidden)
2962
3120
  return true;
2963
3121
  const inline = html.style?.display;
@@ -2968,21 +3126,21 @@ function isHiddenSelf(el2) {
2968
3126
  return true;
2969
3127
  return false;
2970
3128
  }
2971
- function isVisible(el2) {
2972
- const html = el2;
3129
+ function isVisible(el) {
3130
+ const html = el;
2973
3131
  if (html.offsetParent === null) {
2974
3132
  const style = globalThis.getComputedStyle?.(html);
2975
3133
  if (style && style.position === "fixed")
2976
3134
  return true;
2977
3135
  }
2978
- for (let node = el2;node; node = node.parentElement) {
3136
+ for (let node = el;node; node = node.parentElement) {
2979
3137
  if (isHiddenSelf(node))
2980
3138
  return false;
2981
3139
  }
2982
3140
  return true;
2983
3141
  }
2984
- function elementLabel(el2) {
2985
- const html = el2;
3142
+ function elementLabel(el) {
3143
+ const html = el;
2986
3144
  const uiLabel = html.getAttribute("data-ui-label")?.trim();
2987
3145
  if (uiLabel)
2988
3146
  return uiLabel;
@@ -3017,13 +3175,13 @@ function captureUiSnapshot(route) {
3017
3175
  const elements = [];
3018
3176
  const seenRefs = new Set;
3019
3177
  let index = 0;
3020
- for (const el2 of nodes) {
3021
- if (isAgentChrome(el2) || !isVisible(el2))
3178
+ for (const el of nodes) {
3179
+ if (isAgentChrome(el) || !isVisible(el))
3022
3180
  continue;
3023
- const label = elementLabel(el2);
3181
+ const label = elementLabel(el);
3024
3182
  if (!label)
3025
3183
  continue;
3026
- const html = el2;
3184
+ const html = el;
3027
3185
  const spotlightId = html.getAttribute("data-spotlight")?.trim() || undefined;
3028
3186
  const existingRef = html.getAttribute("data-ui-ref");
3029
3187
  const ref = existingRef ?? `ui-${route.replace(/[^a-zA-Z0-9]/g, "_") || "root"}-${index}`;
@@ -3067,12 +3225,12 @@ function findByLabel(snapshot, label) {
3067
3225
  return;
3068
3226
  let best;
3069
3227
  let bestScore = 0;
3070
- for (const el2 of snapshot.elements) {
3071
- const hay = [el2.label, el2.spotlightId].filter(Boolean).join(" ");
3228
+ for (const el of snapshot.elements) {
3229
+ const hay = [el.label, el.spotlightId].filter(Boolean).join(" ");
3072
3230
  const score = matchScore(hay, label);
3073
3231
  if (score > bestScore) {
3074
3232
  bestScore = score;
3075
- best = el2;
3233
+ best = el;
3076
3234
  }
3077
3235
  }
3078
3236
  return bestScore >= 50 ? best : undefined;
@@ -3089,9 +3247,9 @@ function safeQuery(selector) {
3089
3247
  }
3090
3248
  function resolveSpotlightTarget(request, snapshot) {
3091
3249
  if (request.ref) {
3092
- const el2 = snapshot?.elements.find((e) => e.ref === request.ref);
3093
- if (el2)
3094
- return { selector: el2.selector, title: el2.label, message: request.message };
3250
+ const el = snapshot?.elements.find((e) => e.ref === request.ref);
3251
+ if (el)
3252
+ return { selector: el.selector, title: el.label, message: request.message };
3095
3253
  const selector = `[data-ui-ref="${attrEscape(request.ref)}"]`;
3096
3254
  if (safeQuery(selector)) {
3097
3255
  return { selector, title: request.label ?? request.ref, message: request.message };
@@ -3101,9 +3259,9 @@ function resolveSpotlightTarget(request, snapshot) {
3101
3259
  return { selector: request.selector, title: request.label ?? "Here", message: request.message };
3102
3260
  }
3103
3261
  if (request.label) {
3104
- const el2 = findByLabel(snapshot, request.label);
3105
- if (el2)
3106
- return { selector: el2.selector, title: el2.label, message: request.message };
3262
+ const el = findByLabel(snapshot, request.label);
3263
+ if (el)
3264
+ return { selector: el.selector, title: el.label, message: request.message };
3107
3265
  let bestEl = null;
3108
3266
  let bestScore = 0;
3109
3267
  for (const node of document.querySelectorAll("[data-ui-label]")) {
@@ -3144,15 +3302,15 @@ var AUTO_DISMISS_MS = 3000;
3144
3302
  var dismissTimer = null;
3145
3303
  var dismissListeners = null;
3146
3304
  function ensureOverlayEl() {
3147
- let el2 = document.getElementById("assistant-spotlight");
3148
- if (!el2) {
3149
- el2 = document.createElement("div");
3150
- el2.id = "assistant-spotlight";
3151
- el2.setAttribute("data-ui-chrome", "assistant");
3152
- el2.innerHTML = '<div class="asr-ring"></div><div class="asr-pop"><div class="asr-pop-text"></div></div>';
3153
- document.body.appendChild(el2);
3305
+ let el = document.getElementById("assistant-spotlight");
3306
+ if (!el) {
3307
+ el = document.createElement("div");
3308
+ el.id = "assistant-spotlight";
3309
+ el.setAttribute("data-ui-chrome", "assistant");
3310
+ el.innerHTML = '<div class="asr-ring"></div><div class="asr-pop"><div class="asr-pop-text"></div></div>';
3311
+ document.body.appendChild(el);
3154
3312
  }
3155
- return el2;
3313
+ return el;
3156
3314
  }
3157
3315
  function dismissSpotlight() {
3158
3316
  if (dismissTimer) {
@@ -3163,9 +3321,9 @@ function dismissSpotlight() {
3163
3321
  dismissListeners();
3164
3322
  dismissListeners = null;
3165
3323
  }
3166
- const el2 = document.getElementById("assistant-spotlight");
3167
- if (el2)
3168
- el2.classList.remove("show");
3324
+ const el = document.getElementById("assistant-spotlight");
3325
+ if (el)
3326
+ el.classList.remove("show");
3169
3327
  }
3170
3328
  function showSpotlight(resolved) {
3171
3329
  const target = safeQuery(resolved.selector);
@@ -3332,7 +3490,7 @@ function createChatController() {
3332
3490
  }
3333
3491
  return false;
3334
3492
  }
3335
- function currentView2() {
3493
+ function currentView() {
3336
3494
  if ($("mem-overlay")?.classList.contains("open"))
3337
3495
  return "memory-panel";
3338
3496
  if ($("hub-overlay")?.classList.contains("open"))
@@ -3345,16 +3503,16 @@ function createChatController() {
3345
3503
  const route = currentRoute();
3346
3504
  if (!isRouteId(route))
3347
3505
  return;
3348
- const ctx = buildAppContext(route, currentView2());
3506
+ const ctx = buildAppContext(route, currentView());
3349
3507
  lastSnapshot = ctx.snapshot;
3350
3508
  send({ type: "context", context: ctx });
3351
3509
  }
3352
3510
  function watchWizardSteps() {
3353
3511
  const mo = new MutationObserver(() => pushAppContext());
3354
3512
  for (const id of ["q-steps", "f-steps", "d-steps"]) {
3355
- const el2 = $(id);
3356
- if (el2)
3357
- mo.observe(el2, { childList: true, subtree: true });
3513
+ const el = $(id);
3514
+ if (el)
3515
+ mo.observe(el, { childList: true, subtree: true });
3358
3516
  }
3359
3517
  }
3360
3518
  function setChatStatus(s) {
@@ -3548,8 +3706,8 @@ function createChatController() {
3548
3706
  if (t)
3549
3707
  return t;
3550
3708
  if (isMemoryToolName(tool || "")) {
3551
- const handle2 = memoryToolChip(a.bubble, tool, args);
3552
- t = { kind: "memchip", wrap: handle2.wrap, handle: handle2, chunks: "" };
3709
+ const handle = memoryToolChip(a.bubble, tool, args);
3710
+ t = { kind: "memchip", wrap: handle.wrap, handle, chunks: "" };
3553
3711
  a.tools.set(callId, t);
3554
3712
  stick();
3555
3713
  return t;
@@ -3763,9 +3921,9 @@ function createChatController() {
3763
3921
  }
3764
3922
  for (const t of item.tools || []) {
3765
3923
  if (isMemoryToolName(t.name || "")) {
3766
- const handle2 = memoryToolChip(bubble, t.name, t.args);
3924
+ const handle = memoryToolChip(bubble, t.name, t.args);
3767
3925
  if (t.result)
3768
- handle2.setResult(t.result);
3926
+ handle.setResult(t.result);
3769
3927
  continue;
3770
3928
  }
3771
3929
  const wrap = el("div", "tool ok", bubble);
@@ -3809,6 +3967,7 @@ function createChatController() {
3809
3967
  case "ready":
3810
3968
  $("nav-model").textContent = m.model || $("nav-model").textContent || "no model";
3811
3969
  composer.visionCapable = !!m.vision;
3970
+ composer.transcriptionCapable = !!m.transcription;
3812
3971
  updateAttachHint(composer);
3813
3972
  composer.thinkingCapable = !!m.thinking;
3814
3973
  updateThinkingToggle(composer);
@@ -4026,6 +4185,7 @@ function createChatController() {
4026
4185
  });
4027
4186
  initMemoryPanel();
4028
4187
  $("chat-attach-btn").onclick = () => $("chat-file-input").click();
4188
+ initVoiceInput($("chat-mic-btn"), box);
4029
4189
  $("chat-file-input").addEventListener("change", (e) => {
4030
4190
  addFiles(composer, [...e.target.files || []]);
4031
4191
  e.target.value = "";
@@ -4081,9 +4241,9 @@ function createChatController() {
4081
4241
  watchWizardSteps();
4082
4242
  const overlayMo = new MutationObserver(() => pushAppContext());
4083
4243
  for (const id of ["mem-overlay", "hub-overlay", "adapters-overlay"]) {
4084
- const el2 = $(id);
4085
- if (el2)
4086
- overlayMo.observe(el2, { attributes: true, attributeFilter: ["class"] });
4244
+ const el = $(id);
4245
+ if (el)
4246
+ overlayMo.observe(el, { attributes: true, attributeFilter: ["class"] });
4087
4247
  }
4088
4248
  },
4089
4249
  enter() {
@@ -4893,7 +5053,7 @@ function createStatusController() {
4893
5053
  async function loadFit() {
4894
5054
  let f;
4895
5055
  try {
4896
- f = await fetch("/fit").then((r2) => r2.json());
5056
+ f = await fetch("/fit").then((r) => r.json());
4897
5057
  } catch {
4898
5058
  return;
4899
5059
  }
@@ -5319,9 +5479,9 @@ async function openHubPanel() {
5319
5479
  if (inFlightDownloads.size)
5320
5480
  ensureDownloadPolling();
5321
5481
  setTimeout(() => {
5322
- const el22 = $("hub-search-input");
5323
- if (el22)
5324
- el22.focus();
5482
+ const el2 = $("hub-search-input");
5483
+ if (el2)
5484
+ el2.focus();
5325
5485
  }, 30);
5326
5486
  }
5327
5487
  function setOfflineFooter(offline) {
@@ -30,6 +30,7 @@ import {
30
30
  renderAttachments, renderContext, renderLane, renderQueue, updateAttachHint,
31
31
  updateThinkingToggle, type ContextFrame,
32
32
  } from "./composer";
33
+ import { initVoiceInput } from "./voice";
33
34
  import {
34
35
  addMsgActions, newSiblingInfo, newSidebarState, renderSessions, renderSiblingToggle,
35
36
  switchSiblingTarget, applySessSearch, type SidebarState, type SiblingInfo,
@@ -764,7 +765,7 @@ export function createChatController() {
764
765
  switch (m.type) {
765
766
  case "ready":
766
767
  $("nav-model").textContent = m.model || ($("nav-model").textContent as string) || "no model";
767
- composer.visionCapable = !!m.vision; updateAttachHint(composer);
768
+ composer.visionCapable = !!m.vision; composer.transcriptionCapable = !!m.transcription; updateAttachHint(composer);
768
769
  composer.thinkingCapable = !!m.thinking; updateThinkingToggle(composer);
769
770
  // Per-model recommended sampling (generation_config.json, server-CLI
770
771
  // overrides applied) — replaces the old hardcoded SAMP_REC shape.
@@ -979,6 +980,8 @@ export function createChatController() {
979
980
  initMemoryPanel();
980
981
  // Attachments: + button, file picker, clipboard paste, drag-and-drop.
981
982
  ($("chat-attach-btn") as HTMLButtonElement).onclick = () => ($("chat-file-input") as HTMLInputElement).click();
983
+ // Hold-to-talk mic → /v1/audio/sessions → text into the composer (voice.ts).
984
+ initVoiceInput($("chat-mic-btn") as HTMLButtonElement, box as HTMLTextAreaElement);
982
985
  $("chat-file-input").addEventListener("change", (e) => { addFiles(composer, [...(e.target as HTMLInputElement).files || []]); (e.target as HTMLInputElement).value = ""; });
983
986
  box.addEventListener("paste", (e) => {
984
987
  const items = e.clipboardData && e.clipboardData.items;
@@ -55,6 +55,7 @@ function readText(file: File): Promise<string> {
55
55
  export class ComposerState {
56
56
  attachments: Attachment[] = [];
57
57
  visionCapable = false; // from the `ready` frame; gates image attachments
58
+ transcriptionCapable = false; // from the `ready` frame; shows the hold-to-talk mic
58
59
  thinkingCapable = false; // from `ready`; gates the reasoning toggle
59
60
  // default on: with tools (read/web_search) the reasoning step gives the
60
61
  // small model far better tool judgment (answers math/writing itself,
@@ -179,6 +180,8 @@ export function buildMessageText(state: ComposerState, userText: string): BuiltM
179
180
  export function updateAttachHint(state: ComposerState): void {
180
181
  const btn = $("chat-attach-btn");
181
182
  if (btn) btn.title = state.visionCapable ? "Attach files or images" : "Attach files (this model can't see images)";
183
+ const mic = $("chat-mic-btn");
184
+ if (mic) mic.style.display = state.transcriptionCapable ? "" : "none";
182
185
  }
183
186
 
184
187
  /* ────────────────────────────────────────────────────────────────────
@@ -0,0 +1,141 @@
1
+ // Hold-to-talk voice input for the composer: microphone → 16 kHz float32
2
+ // (an AudioContext opened at 16 kHz lets the browser resample the mic) →
3
+ // 250 ms chunks streamed to /v1/audio/sessions/<id>/audio while the button
4
+ // is held → finish on release → transcript inserted at the caret. Works
5
+ // with ANY served chat model: transcription is the Whisper companion, not
6
+ // the chat model's audio input. Shown only when `ready.transcription` is
7
+ // true (composer.ts updateAttachHint).
8
+
9
+ import { $, toast } from "./shell";
10
+
11
+ const SAMPLE_RATE = 16_000;
12
+ const CHUNK_SECONDS = 0.25;
13
+
14
+ interface Take {
15
+ id: string;
16
+ ctx: AudioContext;
17
+ stream: MediaStream;
18
+ node: ScriptProcessorNode;
19
+ pending: Float32Array[];
20
+ pendingLen: number;
21
+ sending: Promise<void>;
22
+ failed: string | null;
23
+ }
24
+
25
+ let take: Take | null = null;
26
+ let starting = false;
27
+
28
+ async function postChunk(id: string, pcm: Float32Array): Promise<void> {
29
+ const r = await fetch(`/v1/audio/sessions/${id}/audio`, {
30
+ method: "POST", headers: { "content-type": "audio/pcm;rate=16000" },
31
+ body: new Uint8Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength) as ArrayBuffer),
32
+ });
33
+ if (!r.ok) throw new Error(`audio chunk rejected (${r.status})`);
34
+ }
35
+
36
+ function flush(t: Take): void {
37
+ if (t.pendingLen === 0) return;
38
+ const buf = new Float32Array(t.pendingLen);
39
+ let o = 0;
40
+ for (const p of t.pending) { buf.set(p, o); o += p.length; }
41
+ t.pending = []; t.pendingLen = 0;
42
+ t.sending = t.sending.then(() => postChunk(t.id, buf)).catch((e) => { t.failed = (e as Error).message; });
43
+ }
44
+
45
+ async function begin(btn: HTMLButtonElement): Promise<void> {
46
+ if (take || starting) return;
47
+ starting = true;
48
+ btn.classList.add("busy");
49
+ try {
50
+ const r = await fetch("/v1/audio/sessions", {
51
+ method: "POST", headers: { "content-type": "application/json" },
52
+ body: JSON.stringify({ vad: true, vad_min_speech_ms: 120, temperature: 0 }),
53
+ });
54
+ if (!r.ok) throw new Error(r.status === 503 ? "no Whisper model on this server" : `session failed (${r.status})`);
55
+ const { id } = await r.json() as { id: string };
56
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
57
+ const ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
58
+ const src = ctx.createMediaStreamSource(stream);
59
+ // ScriptProcessorNode is deprecated but universally available and enough
60
+ // for 16 kHz mono capture; an AudioWorklet needs a separately served file.
61
+ const node = ctx.createScriptProcessor(4096, 1, 1);
62
+ const t: Take = { id, ctx, stream, node, pending: [], pendingLen: 0, sending: Promise.resolve(), failed: null };
63
+ node.onaudioprocess = (e) => {
64
+ if (take !== t) return;
65
+ const data = e.inputBuffer.getChannelData(0);
66
+ t.pending.push(new Float32Array(data));
67
+ t.pendingLen += data.length;
68
+ if (t.pendingLen >= SAMPLE_RATE * CHUNK_SECONDS) flush(t);
69
+ };
70
+ src.connect(node);
71
+ node.connect(ctx.destination); // required for onaudioprocess to fire in some browsers
72
+ take = t;
73
+ btn.classList.add("recording");
74
+ } catch (e) {
75
+ toast(`Voice input failed: ${(e as Error).message}`, "err");
76
+ } finally {
77
+ starting = false;
78
+ btn.classList.remove("busy");
79
+ }
80
+ }
81
+
82
+ async function end(btn: HTMLButtonElement, box: HTMLTextAreaElement): Promise<void> {
83
+ const t = take;
84
+ if (!t) return;
85
+ take = null;
86
+ btn.classList.remove("recording");
87
+ btn.classList.add("busy");
88
+ try {
89
+ t.node.disconnect();
90
+ for (const track of t.stream.getTracks()) track.stop();
91
+ void t.ctx.close();
92
+ flush(t);
93
+ await t.sending;
94
+ if (t.failed) throw new Error(t.failed);
95
+ const r = await fetch(`/v1/audio/sessions/${t.id}/finish`, { method: "POST" });
96
+ if (!r.ok) throw new Error(`transcription failed (${r.status})`);
97
+ const body = await r.json() as { text: string; mlx_bun?: { vad?: { speech: boolean } } };
98
+ const text = (body.text || "").trim();
99
+ if (!text) { toast(body.mlx_bun?.vad?.speech === false ? "No speech detected" : "Nothing transcribed", ""); return; }
100
+ insertAtCaret(box, text);
101
+ } catch (e) {
102
+ toast(`Voice input failed: ${(e as Error).message}`, "err");
103
+ } finally {
104
+ btn.classList.remove("busy");
105
+ }
106
+ }
107
+
108
+ function insertAtCaret(box: HTMLTextAreaElement, text: string): void {
109
+ const start = box.selectionStart ?? box.value.length;
110
+ const endPos = box.selectionEnd ?? start;
111
+ const before = box.value.slice(0, start);
112
+ const after = box.value.slice(endPos);
113
+ const lead = before.length && !/\s$/.test(before) ? " " : "";
114
+ const trail = after.length && !/^\s/.test(after) ? " " : "";
115
+ box.value = before + lead + text + trail + after;
116
+ const caret = (before + lead + text + trail).length;
117
+ box.setSelectionRange(caret, caret);
118
+ box.dispatchEvent(new Event("input", { bubbles: true }));
119
+ box.focus();
120
+ }
121
+
122
+ /** Wire hold-to-talk on `btn`: pointer hold, or Space/Enter held while focused. */
123
+ export function initVoiceInput(btn: HTMLButtonElement, box: HTMLTextAreaElement): void {
124
+ if (!btn) return;
125
+ if (!navigator.mediaDevices?.getUserMedia) { btn.title = "Microphone capture needs a secure context (https or localhost)"; }
126
+ btn.addEventListener("pointerdown", (e) => { e.preventDefault(); btn.setPointerCapture(e.pointerId); void begin(btn); });
127
+ const release = () => { void end(btn, box); };
128
+ btn.addEventListener("pointerup", release);
129
+ btn.addEventListener("pointercancel", release);
130
+ btn.addEventListener("lostpointercapture", release);
131
+ let keyHeld = false;
132
+ btn.addEventListener("keydown", (e) => {
133
+ if ((e.key === " " || e.key === "Enter") && !keyHeld) { e.preventDefault(); keyHeld = true; void begin(btn); }
134
+ });
135
+ btn.addEventListener("keyup", (e) => {
136
+ if ((e.key === " " || e.key === "Enter") && keyHeld) { e.preventDefault(); keyHeld = false; release(); }
137
+ });
138
+ btn.addEventListener("blur", () => { if (keyHeld) { keyHeld = false; release(); } });
139
+ // expose for tests / the palette
140
+ ($ as unknown as { voice?: unknown }).voice = { begin: () => begin(btn), end: () => end(btn, box) };
141
+ }
package/src/web/sw.js CHANGED
@@ -18,7 +18,7 @@
18
18
  //
19
19
  // Versioned by CACHE_NAME so a deploy invalidates old shells; bump the
20
20
  // suffix here if the shell file set changes.
21
- const CACHE_NAME = "mlxbun-shell-v1";
21
+ const CACHE_NAME = "mlxbun-shell-v2";
22
22
  const SHELL_FILES = [
23
23
  "/",
24
24
  "/assets/app.js",