mlx-bun 0.0.10 → 0.0.12

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 (216) hide show
  1. package/README.md +107 -32
  2. package/THIRD_PARTY_LICENSES.md +10 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/colibri-glm52-port.md +1479 -0
  10. package/docs/design/compat-cli-surface-design.md +4 -3
  11. package/docs/design/decode-speed-program.md +172 -0
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +371 -0
  14. package/docs/design/dspark-speculative-decoding.md +167 -5
  15. package/docs/design/faithful-l1-consolidation.md +310 -0
  16. package/docs/design/generic-model-support.md +8 -5
  17. package/docs/design/grammar-spec-batching-integration.md +301 -0
  18. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  19. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  20. package/docs/design/omlx-adoption-map.md +14 -6
  21. package/docs/design/orpo-training.md +34 -30
  22. package/docs/design/paged-kv-cache.md +214 -0
  23. package/docs/design/parallel-slots.md +82 -49
  24. package/docs/design/parity-tier-dag.md +43 -33
  25. package/docs/design/pre-colibri-stabilization.md +329 -0
  26. package/docs/design/repo-cleanup-plan.md +189 -0
  27. package/docs/design/runtime-isolation.md +19 -3
  28. package/docs/design/segmented-backward-training.md +3 -2
  29. package/docs/design/ssd-kv-cold-tier.md +130 -0
  30. package/docs/design/structured-output.md +484 -0
  31. package/docs/design/the-dreaming-master-plan.md +2 -3
  32. package/docs/design/turboquant-kv.md +235 -0
  33. package/docs/design/unified-engine-frontier-plan.md +774 -0
  34. package/docs/design/web-chat-beat-matrix.md +315 -0
  35. package/docs/design/web-chat-redesign.md +1112 -0
  36. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  37. package/docs/investigations/dspark-handoff.md +182 -16
  38. package/docs/investigations/indexshare-performance-spike.md +357 -0
  39. package/docs/investigations/lab-build-journal.md +2 -2
  40. package/docs/investigations/openwiki-evaluation.md +249 -0
  41. package/docs/planning/IDEAS.md +14 -0
  42. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  43. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  44. package/docs/planning/chat-ui-vision.md +50 -0
  45. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  46. package/docs/planning/release-notes-v0.0.11.md +239 -0
  47. package/docs/planning/release-notes-v0.0.12.md +80 -0
  48. package/docs/reference/cli.md +63 -18
  49. package/docs/reference/distribution.md +37 -6
  50. package/docs/reference/embedding.md +40 -16
  51. package/docs/reference/features-matrix.md +99 -0
  52. package/docs/reference/library-api.md +69 -20
  53. package/docs/reference/memory.md +26 -6
  54. package/docs/reference/models.md +60 -3
  55. package/docs/reference/orpo-quickstart.md +3 -4
  56. package/docs/reference/server-api.md +717 -24
  57. package/docs/reference/server-config.md +265 -129
  58. package/docs/reference/training.md +24 -16
  59. package/package.json +5 -3
  60. package/src/anthropic.ts +67 -44
  61. package/src/audio/conformer.ts +647 -0
  62. package/src/audio/decode.ts +133 -0
  63. package/src/audio/features.ts +202 -0
  64. package/src/audio/transcode.ts +67 -0
  65. package/src/chat-template.ts +155 -10
  66. package/src/cli.ts +483 -113
  67. package/src/config.ts +116 -24
  68. package/src/diffusion/diffusion-generate.ts +14 -8
  69. package/src/download.ts +143 -12
  70. package/src/eval/README.md +7 -6
  71. package/src/eval/ifeval.ts +63 -120
  72. package/src/eval/kl.ts +59 -3
  73. package/src/eval/quality-db.ts +2 -2
  74. package/src/eval/runner.ts +17 -8
  75. package/src/eval/tasks/ifeval.ts +167 -31
  76. package/src/expert-io.ts +391 -0
  77. package/src/expert-offload.ts +15 -2
  78. package/src/expert-residency.ts +1134 -0
  79. package/src/expert-usage.ts +415 -0
  80. package/src/flags.ts +13 -0
  81. package/src/generate.ts +438 -35
  82. package/src/grammar.ts +585 -0
  83. package/src/hf-push.ts +1 -1
  84. package/src/hub-rest.ts +242 -0
  85. package/src/index.ts +14 -2
  86. package/src/jobs/runner.ts +37 -18
  87. package/src/kv-store.ts +586 -93
  88. package/src/lora.ts +85 -14
  89. package/src/md-modules.d.ts +42 -0
  90. package/src/media-fetch.ts +426 -0
  91. package/src/memory/rest.ts +245 -0
  92. package/src/memory/vault.ts +74 -0
  93. package/src/mlx/array.ts +149 -19
  94. package/src/mlx/autograd.ts +53 -16
  95. package/src/mlx/checkpoint.ts +46 -13
  96. package/src/mlx/custom-vjp.ts +83 -35
  97. package/src/mlx/ffi.ts +27 -3
  98. package/src/mlx/metal-kernel.ts +7 -4
  99. package/src/mlx/ops.ts +153 -23
  100. package/src/mlx/turboquant-ops.ts +831 -0
  101. package/src/mlx/turboquant-tables.ts +237 -0
  102. package/src/model/batched-mask.ts +62 -1
  103. package/src/model/batched-quant.ts +206 -0
  104. package/src/model/batched-rotating-quant.ts +346 -0
  105. package/src/model/batched-rotating.ts +46 -1
  106. package/src/model/compiled-decode.ts +4 -15
  107. package/src/model/factory.ts +103 -6
  108. package/src/model/gemma4-base.ts +395 -39
  109. package/src/model/gemma4.ts +122 -33
  110. package/src/model/generated/gemma4-12b.ts +9 -13
  111. package/src/model/generated/gemma4-26b.ts +10 -5
  112. package/src/model/generated/gemma4-e4b.ts +14 -9
  113. package/src/model/glm52-atlas.ts +511 -0
  114. package/src/model/glm52-cache.ts +706 -0
  115. package/src/model/glm52-config.ts +262 -0
  116. package/src/model/glm52-container.ts +305 -0
  117. package/src/model/glm52-coupling.ts +388 -0
  118. package/src/model/glm52-dsa.ts +602 -0
  119. package/src/model/glm52-expert-layout.ts +253 -0
  120. package/src/model/glm52-layout.ts +152 -0
  121. package/src/model/glm52-memory.ts +473 -0
  122. package/src/model/glm52-mla.ts +786 -0
  123. package/src/model/glm52-moe.ts +223 -0
  124. package/src/model/glm52-pilot.ts +537 -0
  125. package/src/model/glm52-reference.ts +445 -0
  126. package/src/model/glm52-residency.ts +557 -0
  127. package/src/model/glm52-resident-weights.ts +135 -0
  128. package/src/model/glm52-streamed-experts.ts +453 -0
  129. package/src/model/glm52-streamed-metal.ts +610 -0
  130. package/src/model/glm52-weights.ts +503 -0
  131. package/src/model/glm52.ts +1153 -0
  132. package/src/model/minicpm5.ts +58 -102
  133. package/src/model/paged-kv.ts +253 -0
  134. package/src/model/qwen3-delta.ts +76 -15
  135. package/src/model/qwen3-moe.ts +404 -0
  136. package/src/model/qwen3.ts +9 -8
  137. package/src/model/qwen3_5.ts +88 -23
  138. package/src/model/support.ts +19 -1
  139. package/src/model/universal/dense.ts +40 -5
  140. package/src/model/universal/rope.ts +36 -21
  141. package/src/native/expert_io.c +523 -0
  142. package/src/native-pack.ts +6 -4
  143. package/src/pi-web.ts +1299 -82
  144. package/src/preflight.ts +27 -8
  145. package/src/prompt-cache.ts +293 -33
  146. package/src/quantize/quantizer.ts +16 -1
  147. package/src/registry.ts +57 -2
  148. package/src/responses.ts +62 -49
  149. package/src/serve/batch-scheduler.ts +1050 -78
  150. package/src/serve/generation-gateway.ts +252 -24
  151. package/src/serve/isolate.ts +417 -0
  152. package/src/serve/lane-registry.ts +44 -0
  153. package/src/serve/session-search.ts +326 -0
  154. package/src/server.ts +1701 -175
  155. package/src/spec/assistant-source.ts +150 -0
  156. package/src/spec/deepspec-source.ts +143 -0
  157. package/src/spec/dflash-source.ts +124 -0
  158. package/src/spec/dspark/ab-stats.ts +136 -0
  159. package/src/spec/dspark/calibration.ts +112 -0
  160. package/src/spec/dspark/deepspec-module.ts +801 -0
  161. package/src/spec/dspark/generate-dflash.ts +72 -20
  162. package/src/spec/dspark/loader.ts +23 -0
  163. package/src/spec/dspark/module-dflash.ts +278 -36
  164. package/src/spec/dspark/quantize-drafter.ts +91 -0
  165. package/src/spec/generate.ts +59 -25
  166. package/src/spec/glm52-mtp-source.ts +392 -0
  167. package/src/spec/ngram-source.ts +132 -0
  168. package/src/spec/serve-loop.ts +617 -0
  169. package/src/spec/source.ts +118 -0
  170. package/src/spec/two-model.ts +171 -0
  171. package/src/ssd-cache.ts +111 -35
  172. package/src/tokenizer.ts +288 -2
  173. package/src/tool-approvals.ts +131 -0
  174. package/src/tool-call.ts +438 -22
  175. package/src/train/flash-cce.ts +2 -2
  176. package/src/train/job.ts +0 -12
  177. package/src/train/lora-params.ts +17 -6
  178. package/src/train/trainer.ts +60 -30
  179. package/src/vision/prompt.ts +217 -48
  180. package/src/web/app.html +1197 -1791
  181. package/src/web/app.js +5694 -0
  182. package/src/web/icon.svg +13 -0
  183. package/src/web/manifest.webmanifest +24 -0
  184. package/src/web/src/adapters-panel.ts +288 -0
  185. package/src/web/src/api.ts +57 -0
  186. package/src/web/src/assistant.ts +485 -0
  187. package/src/web/src/chat.ts +1050 -0
  188. package/src/web/src/composer.ts +959 -0
  189. package/src/web/src/dataset.ts +132 -0
  190. package/src/web/src/finetune.ts +267 -0
  191. package/src/web/src/hub.ts +428 -0
  192. package/src/web/src/main.ts +98 -0
  193. package/src/web/src/markdown.ts +0 -0
  194. package/src/web/src/memory-panel.ts +572 -0
  195. package/src/web/src/model-picker.ts +159 -0
  196. package/src/web/src/palette.ts +346 -0
  197. package/src/web/src/protocol.ts +62 -0
  198. package/src/web/src/quantize.ts +167 -0
  199. package/src/web/src/rag.ts +274 -0
  200. package/src/web/src/sessions.ts +469 -0
  201. package/src/web/src/shell.ts +804 -0
  202. package/src/web/src/status.ts +300 -0
  203. package/src/web/src/ui-catalog.ts +92 -0
  204. package/src/web/sw.js +71 -0
  205. package/src/web/vendor/README.md +61 -0
  206. package/src/web/vendor/hljs-LICENSE +29 -0
  207. package/src/web/vendor/hljs-theme.css +59 -0
  208. package/src/web/vendor/hljs.js +26 -0
  209. package/src/web-tools.ts +54 -2
  210. package/src/weights.ts +82 -19
  211. package/src/model/fused-decode-kernel.ts +0 -312
  212. package/src/model/fused-geglu-kernel.ts +0 -198
  213. package/src/model/fused-mlp-kernel.ts +0 -169
  214. package/src/model/fused-swiglu-kernel.ts +0 -240
  215. package/src/model/steel-linear-kernel.ts +0 -96
  216. package/src/status-page.html +0 -497
@@ -0,0 +1,159 @@
1
+ // GENERATED-ADJACENT source module — part of the src/web/src/* split (plan
2
+ // §7/§9 Phase 2). Built into src/web/app.js by scripts/build-web.ts.
3
+ //
4
+ // Model picker (plan §5.6, §9 Phase 2): makes the nav model label
5
+ // (#nav-model, previously a dead <span> with no click handler) open a
6
+ // popover of every downloaded model from GET /library, each with its own
7
+ // fit verdict computed on THIS Mac (`assessment`, from src/fit.ts) — not a
8
+ // generic heuristic like LM Studio's. Investigated: there is no in-process
9
+ // model reload/swap path anywhere in src/server.ts or src/cli.ts (grepped
10
+ // for reload/swapModel/switchModel/loadModel — none exist; the runtime is
11
+ // one model per process by design, same as mlx-lm). So rows offer a
12
+ // copy-able `mlx-bun serve <id>` restart command instead of faking a live
13
+ // swap — live swap is Phase 3's Hub item (docs/design/web-chat-redesign.md
14
+ // §9 Phase 3 "Model Hub"), not invented here.
15
+
16
+ import { $, setModelPopClose, openHubFromModelPicker } from "./shell";
17
+ import { api } from "./api";
18
+ import { esc } from "./markdown";
19
+
20
+ export interface LibraryRow {
21
+ repo_id: string;
22
+ model_type: string;
23
+ size_bytes: number;
24
+ quant_bits: number | null;
25
+ vision: boolean;
26
+ supported: boolean;
27
+ support_tier: "targeted" | "generic" | null;
28
+ serving: boolean;
29
+ assessment: { fits: boolean; max_safe_context: number; predicted_decode_tps: number } | null;
30
+ }
31
+
32
+ function gb(n: number): string { return (n / 2 ** 30).toFixed(1) + " GB"; }
33
+
34
+ /** Three-tier verdict from the server's honest {fits, predicted_decode_tps}
35
+ * (src/fit.ts) — LM Studio's green/yellow/red convention layered on our own
36
+ * per-machine numbers, not a second prediction model. green: fits with
37
+ * headroom (≥10 tok/s decode — comfortably interactive); yellow: fits but
38
+ * slow (<10 tok/s — usable, sluggish); red: doesn't fit the memory budget;
39
+ * null: no assessment (unreadable config). Exported so renderRow and any
40
+ * future caller share one threshold instead of two copies drifting apart. */
41
+ export function fitVerdict(a: LibraryRow["assessment"]): "green" | "yellow" | "red" | null {
42
+ if (!a) return null;
43
+ if (!a.fits) return "red";
44
+ return a.predicted_decode_tps >= 10 ? "green" : "yellow";
45
+ }
46
+
47
+ function pickModel(models: LibraryRow[]): LibraryRow[] {
48
+ // Supported models first (unsupported ones can still be listed so a user
49
+ // understands "this exists on disk but mlx-bun can't serve it" rather
50
+ // than it silently vanishing), each group sorted by repo id for a stable,
51
+ // scannable order — /library itself has no ordering guarantee.
52
+ return [...models].sort((a, b) => {
53
+ if (a.serving !== b.serving) return a.serving ? -1 : 1;
54
+ if (a.supported !== b.supported) return a.supported ? -1 : 1;
55
+ return a.repo_id.localeCompare(b.repo_id);
56
+ });
57
+ }
58
+
59
+ function renderRow(m: LibraryRow): string {
60
+ const verdict = fitVerdict(m.assessment);
61
+ const dot = verdict ? '<span class="mp-fit-dot ' + verdict + '" aria-hidden="true"></span>' : '<span class="mp-fit-dot" aria-hidden="true" style="background:var(--dimmer)"></span>';
62
+ const name = m.repo_id.split("/").pop() || m.repo_id;
63
+ const quant = m.quant_bits ? m.quant_bits + "-bit" : "unquantized";
64
+ const metaBits = [gb(m.size_bytes), quant];
65
+ if (m.vision) metaBits.push("vision");
66
+ if (!m.supported) metaBits.push("unsupported model family");
67
+ const tpsBit = m.assessment && m.assessment.fits
68
+ ? m.assessment.predicted_decode_tps.toFixed(0) + " tok/s predicted"
69
+ : m.assessment ? "doesn't fit this Mac's memory" : "fit unknown";
70
+ const servingTag = m.serving ? '<div class="mp-serving-tag">● currently serving</div>' : "";
71
+ const cmd = "mlx-bun serve " + m.repo_id;
72
+ const cmdRow = m.serving || !m.supported
73
+ ? ""
74
+ : '<div class="mp-cmd"><code>' + esc(cmd) + '</code><button type="button" class="mp-copy" data-cmd="' + esc(cmd) + '">Copy</button></div>';
75
+ return (
76
+ '<div class="mp-row' + (m.serving ? " serving" : "") + '">' +
77
+ dot +
78
+ '<div class="mp-main">' +
79
+ '<div class="mp-name" title="' + esc(m.repo_id) + '">' + esc(name) + "</div>" +
80
+ '<div class="mp-meta">' + esc(metaBits.join(" · ")) + " · " + esc(tpsBit) + "</div>" +
81
+ servingTag + cmdRow +
82
+ "</div>" +
83
+ "</div>"
84
+ );
85
+ }
86
+
87
+ /** Pure render of the popover body from a /library response. Exported for
88
+ * tests/web-app.test.ts's esc() discipline check (repo ids are
89
+ * user-controlled HF strings — the same class of interpolation hazard
90
+ * finding #15 flagged for the adapter dropdown). */
91
+ export function renderModelPopBodyHtml(models: LibraryRow[]): string {
92
+ const browseRow = '<button type="button" class="mp-browse-hub" id="model-pop-browse">Browse models…</button>';
93
+ if (!models.length) {
94
+ return '<div class="mp-empty">No models downloaded yet. Use <code>mlx-bun get &lt;repo-id&gt;</code> to fetch one, ' +
95
+ "or browse Hugging Face below.</div>" + browseRow;
96
+ }
97
+ const rows = pickModel(models).map(renderRow).join("");
98
+ return (
99
+ rows +
100
+ '<div class="mp-foot-note">Fit is predicted for THIS Mac (src/fit.ts), not a generic guess. ' +
101
+ "Switching the served model restarts the process — there's no live " +
102
+ "in-process swap yet; copy the command above and restart.</div>" +
103
+ browseRow
104
+ );
105
+ }
106
+
107
+ async function refreshModelPop(): Promise<void> {
108
+ const body = $("model-pop-body");
109
+ if (!body) return;
110
+ body.innerHTML = '<div class="mp-empty">Loading…</div>';
111
+ try {
112
+ const d = await api("/library");
113
+ const models = ((d as { models?: LibraryRow[] }).models) || [];
114
+ body.innerHTML = renderModelPopBodyHtml(models);
115
+ body.querySelectorAll<HTMLButtonElement>(".mp-copy").forEach((btn) => {
116
+ btn.onclick = () => {
117
+ const cmd = btn.dataset.cmd || "";
118
+ if (navigator.clipboard) {
119
+ navigator.clipboard.writeText(cmd).then(() => {
120
+ const prev = btn.textContent; btn.textContent = "Copied";
121
+ setTimeout(() => { btn.textContent = prev; }, 1200);
122
+ }).catch(() => {});
123
+ }
124
+ };
125
+ });
126
+ const browseBtn = body.querySelector<HTMLButtonElement>("#model-pop-browse");
127
+ if (browseBtn) {
128
+ browseBtn.onclick = () => {
129
+ setModelPopOpen(false);
130
+ if (openHubFromModelPicker) openHubFromModelPicker();
131
+ };
132
+ }
133
+ } catch {
134
+ body.innerHTML = '<div class="mp-empty">Could not reach the server.</div>';
135
+ }
136
+ }
137
+
138
+ let modelPopOpen = false;
139
+ function setModelPopOpen(open: boolean): void {
140
+ if (open === modelPopOpen) return;
141
+ modelPopOpen = open;
142
+ $("model-pop").classList.toggle("open", open);
143
+ $("nav-model").setAttribute("aria-expanded", String(open));
144
+ if (open) refreshModelPop();
145
+ }
146
+ export function closeModelPop(): void { setModelPopOpen(false); }
147
+ export function isModelPopOpen(): boolean { return modelPopOpen; }
148
+
149
+ /** Wire the nav model button + popover. Escape/click-away follow the same
150
+ * pattern as the sampling popover (setSamplingPopoverClose) — see
151
+ * shell.ts's closeTopOverlay, extended to check this popover too. */
152
+ export function initModelPicker(): void {
153
+ const btn = $("nav-model");
154
+ if (!btn) return;
155
+ btn.onclick = (e) => { e.stopPropagation(); setModelPopOpen(!modelPopOpen); };
156
+ $("model-pop").addEventListener("click", (e) => e.stopPropagation());
157
+ document.addEventListener("click", () => setModelPopOpen(false));
158
+ setModelPopClose(closeModelPop);
159
+ }
@@ -0,0 +1,346 @@
1
+ // Command palette (Cmd/Ctrl+K) — plan §9 Phase 3, beat-matrix Axis 10
2
+ // "Command palette" row: ChatGPT/Claude's universal "do anything" entry
3
+ // point. Built 100% via createElement (zero app.html markup — a parallel
4
+ // agent owns chat.ts/app.html this wave); its own CSS is injected once via
5
+ // shell.ts's injectStyles() the same way the panel it opens (hub.ts) has
6
+ // its markup live in app.html but this module can't add any of its own.
7
+ //
8
+ // Actions call EXPORTED functions from the modules that own the behavior
9
+ // (newChat/copyLastResponse via controllers.chat — the same
10
+ // loosely-typed-registry indirection shell.ts's own global keydown sweep
11
+ // and finetune.ts already use to reach chat.ts without a direct import;
12
+ // openMemPanel/openHubPanel are plain exports; theme/developer-mode toggles
13
+ // are plain exports; thinking-toggle and session-open reuse the existing
14
+ // DOM click handlers chat.ts/sessions.ts already wired, via a `.click()` on
15
+ // the real control — never a copy of chat.ts's logic).
16
+ //
17
+ // Full-text "Search messages" delegates to the same GET /api/sessions/search
18
+ // endpoint sessions.ts's sidebar fallback uses (src/serve/session-search.ts)
19
+ // — one server-side implementation, two presentations.
20
+
21
+ import { controllers, currentRoute, el, injectStyles, isDeveloperMode, setDeveloperMode, setPaletteClose, setPaletteIsOpen, setPaletteOpen, setTheme, trapFocus, type FocusTrap } from "./shell";
22
+ import { openSessionRowByPath, exportSession } from "./sessions";
23
+ import { openMemPanel } from "./memory-panel";
24
+ import { openHubPanel } from "./hub";
25
+ import { api } from "./api";
26
+
27
+ /* ────────────────────────────────────────────────────────────────────
28
+ Action model
29
+ ──────────────────────────────────────────────────────────────────── */
30
+
31
+ interface PaletteAction {
32
+ id: string;
33
+ label: string;
34
+ hint?: string;
35
+ /** Actions that only make sense on the chat route are hidden elsewhere
36
+ * (matching the existing Cmd/Ctrl+Shift+O/C guards in shell.ts). */
37
+ chatOnly?: boolean;
38
+ run(): void | Promise<void>;
39
+ }
40
+
41
+ /** Pure: the static action list, independent of any live session data —
42
+ * independently testable (tests/web-app.test.ts asserts labels/ids without
43
+ * needing a DOM). Session rows and message-search hits are appended
44
+ * dynamically in refreshResults() below, not part of this list. */
45
+ export function staticActions(): PaletteAction[] {
46
+ return [
47
+ {
48
+ id: "new-chat", label: "New chat", hint: "⌘⇧O", chatOnly: true,
49
+ run() { const fn = controllers.chat && controllers.chat.newChat as (() => void) | undefined; fn && fn(); },
50
+ },
51
+ {
52
+ id: "toggle-thinking", label: "Toggle thinking", chatOnly: true,
53
+ run() { const btn = document.getElementById("chat-think"); if (btn) btn.click(); },
54
+ },
55
+ {
56
+ id: "toggle-theme", label: "Toggle theme",
57
+ run() {
58
+ const order = ["auto", "dark", "light"];
59
+ const active = document.querySelector<HTMLButtonElement>("#theme-toggle button.active");
60
+ const cur = (active && active.dataset.themeChoice) || "auto";
61
+ const next = order[(order.indexOf(cur) + 1) % order.length]!;
62
+ setTheme(next);
63
+ },
64
+ },
65
+ {
66
+ id: "toggle-developer", label: "Toggle Developer mode",
67
+ run() { setDeveloperMode(!isDeveloperMode()); },
68
+ },
69
+ {
70
+ id: "open-memory", label: "Open Memory panel",
71
+ run() { openMemPanel(); },
72
+ },
73
+ {
74
+ id: "browse-models", label: "Browse models (Hub)",
75
+ run() { openHubPanel(); },
76
+ },
77
+ {
78
+ id: "open-shortcuts", label: "Open shortcut sheet",
79
+ run() {
80
+ const btn = document.getElementById("nav-shortcuts");
81
+ if (btn) btn.click();
82
+ },
83
+ },
84
+ {
85
+ id: "export-chat", label: "Export this chat", chatOnly: true,
86
+ async run() {
87
+ const activeRow = document.querySelector<HTMLElement>("#chat-sessions .sess.active");
88
+ if (!activeRow || !activeRow.dataset.path) return;
89
+ const title = activeRow.querySelector(".stitle")?.textContent || "chat";
90
+ await exportSession(activeRow.dataset.path, title, "md");
91
+ },
92
+ },
93
+ ];
94
+ }
95
+
96
+ /** Fuzzy-ish filter: every character of `query` (in order, case-insensitive)
97
+ * must appear as a subsequence of the candidate string. Pure. Matches the
98
+ * lightweight "fuzzy-filterable" bar the task calls for — not a scored
99
+ * fuzzy-match algorithm, a simple subsequence test, which is what every
100
+ * Cmd+K palette's basic case actually needs. */
101
+ export function fuzzyMatch(query: string, candidate: string): boolean {
102
+ const q = query.toLowerCase();
103
+ const c = candidate.toLowerCase();
104
+ if (!q) return true;
105
+ let qi = 0;
106
+ for (let ci = 0; ci < c.length && qi < q.length; ci++) {
107
+ if (c[ci] === q[qi]) qi++;
108
+ }
109
+ return qi === q.length;
110
+ }
111
+
112
+ /* ────────────────────────────────────────────────────────────────────
113
+ Row shapes for the two dynamic sections (session titles / message hits)
114
+ ──────────────────────────────────────────────────────────────────── */
115
+
116
+ interface SessionRowRef { path: string; title: string; }
117
+ interface MessageHit { sessionPath: string; sessionTitle: string; snippet: string; }
118
+
119
+ type PaletteEntry =
120
+ | { kind: "action"; action: PaletteAction }
121
+ | { kind: "session"; row: SessionRowRef }
122
+ | { kind: "message"; hit: MessageHit };
123
+
124
+ /* ────────────────────────────────────────────────────────────────────
125
+ Panel chrome
126
+ ──────────────────────────────────────────────────────────────────── */
127
+
128
+ let overlay: HTMLElement | null = null;
129
+ let input: HTMLInputElement | null = null;
130
+ let listEl: HTMLElement | null = null;
131
+ let trap: FocusTrap | null = null;
132
+ let selectedIndex = 0;
133
+ let entries: PaletteEntry[] = [];
134
+ let searchSeq = 0;
135
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
136
+
137
+ const STYLE_ID = "mlxbun-palette-style";
138
+ const DEBOUNCE_MS = 200;
139
+
140
+ function injectPaletteStyles(): void {
141
+ injectStyles(STYLE_ID, `
142
+ #palette-overlay{position:fixed;inset:0;z-index:900;display:none;
143
+ align-items:flex-start;justify-content:center;padding-top:12vh;
144
+ background:rgba(0,0,0,.45);backdrop-filter:blur(2px);}
145
+ #palette-overlay.open{display:flex;}
146
+ @media (prefers-reduced-motion:no-preference){
147
+ #palette-overlay.open .palette-box{animation:palette-in .15s ease-out;}
148
+ }
149
+ @keyframes palette-in{from{opacity:0;transform:translateY(-6px) scale(.98);}to{opacity:1;transform:none;}}
150
+ .palette-box{width:min(560px,92vw);max-height:70vh;display:flex;flex-direction:column;
151
+ background:var(--bg,#000);border:1px solid var(--hairline-strong,rgba(255,255,255,.28));
152
+ border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.5);overflow:hidden;}
153
+ .palette-input-row{border-bottom:1px solid var(--hairline,rgba(255,255,255,.14));padding:4px 4px;}
154
+ #palette-input{width:100%;box-sizing:border-box;background:transparent;border:0;outline:0;
155
+ color:var(--ink,#fff);font-size:15px;padding:12px 14px;}
156
+ #palette-input::placeholder{color:var(--dimmer,#515154);}
157
+ .palette-list{overflow-y:auto;flex:1;padding:6px;}
158
+ .palette-section-hdr{font-size:11px;color:var(--dim,#86868b);text-transform:uppercase;
159
+ letter-spacing:.04em;padding:8px 10px 4px;}
160
+ .palette-row{display:flex;align-items:center;justify-content:space-between;gap:8px;
161
+ padding:9px 10px;border-radius:8px;cursor:pointer;color:var(--ink,#fff);font-size:13.5px;}
162
+ .palette-row:hover,.palette-row.active{background:var(--card-hover,rgba(255,255,255,.08));}
163
+ .palette-row .prow-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
164
+ .palette-row .prow-snippet{color:var(--dim,#86868b);font-size:11.5px;overflow:hidden;
165
+ text-overflow:ellipsis;white-space:nowrap;margin-top:1px;}
166
+ .palette-row .prow-main{min-width:0;flex:1;}
167
+ .palette-row .prow-hint{color:var(--dimmer,#515154);font-size:11px;font-family:var(--mono,monospace);
168
+ flex:0 0 auto;}
169
+ .palette-empty{color:var(--dim,#86868b);font-size:13px;padding:16px 10px;text-align:center;}
170
+ .palette-row mark{background:rgba(255,214,10,.35);color:inherit;border-radius:2px;}
171
+ `);
172
+ }
173
+
174
+ export function isPaletteOpen(): boolean {
175
+ return !!overlay && overlay.classList.contains("open");
176
+ }
177
+
178
+ export function closePalette(): void {
179
+ if (!overlay) return;
180
+ overlay.classList.remove("open");
181
+ if (trap) trap.restore();
182
+ }
183
+
184
+ export function openPalette(): void {
185
+ ensureBuilt();
186
+ if (!overlay || !input || !trap) return;
187
+ trap.capture();
188
+ overlay.classList.add("open");
189
+ input.value = "";
190
+ refreshResults("");
191
+ setTimeout(() => input && input.focus(), 20);
192
+ }
193
+
194
+ function ensureBuilt(): void {
195
+ if (overlay) return;
196
+ injectPaletteStyles();
197
+ overlay = el("div", "", document.body);
198
+ overlay.id = "palette-overlay";
199
+ overlay.setAttribute("role", "dialog");
200
+ overlay.setAttribute("aria-modal", "true");
201
+ overlay.setAttribute("aria-label", "Command palette");
202
+
203
+ const box = el("div", "palette-box", overlay);
204
+ const inputRow = el("div", "palette-input-row", box);
205
+ input = el("input", "", inputRow);
206
+ input.id = "palette-input";
207
+ input.type = "text";
208
+ input.placeholder = "Type a command or search…";
209
+ input.autocomplete = "off";
210
+ input.spellcheck = false;
211
+ listEl = el("div", "palette-list", box);
212
+ listEl.setAttribute("role", "listbox");
213
+
214
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) closePalette(); });
215
+ input.addEventListener("input", () => {
216
+ if (debounceTimer) clearTimeout(debounceTimer);
217
+ const q = input!.value;
218
+ debounceTimer = setTimeout(() => refreshResults(q), DEBOUNCE_MS);
219
+ });
220
+ input.addEventListener("keydown", onKeydown);
221
+
222
+ trap = trapFocus(overlay, isPaletteOpen);
223
+ }
224
+
225
+ function onKeydown(e: KeyboardEvent): void {
226
+ if (e.key === "ArrowDown") { e.preventDefault(); move(1); return; }
227
+ if (e.key === "ArrowUp") { e.preventDefault(); move(-1); return; }
228
+ if (e.key === "Enter") { e.preventDefault(); activate(selectedIndex); return; }
229
+ // Escape is handled by shell.ts's global closeTopOverlay sweep (this
230
+ // module registers isOpen/close via setPaletteIsOpen/setPaletteClose in
231
+ // ensureBuilt() above) — no bespoke Escape listener needed here.
232
+ }
233
+
234
+ function move(delta: number): void {
235
+ if (!entries.length) return;
236
+ selectedIndex = (selectedIndex + delta + entries.length) % entries.length;
237
+ renderRows();
238
+ const activeEl = listEl?.querySelector(".palette-row.active");
239
+ if (activeEl) activeEl.scrollIntoView({ block: "nearest" });
240
+ }
241
+
242
+ function activate(index: number): void {
243
+ const entry = entries[index];
244
+ if (!entry) return;
245
+ if (entry.kind === "action") { closePalette(); entry.action.run(); return; }
246
+ if (entry.kind === "session") { closePalette(); openSessionRowByPath(entry.row.path); return; }
247
+ if (entry.kind === "message") { closePalette(); openSessionRowByPath(entry.hit.sessionPath); return; }
248
+ }
249
+
250
+ /* ────────────────────────────────────────────────────────────────────
251
+ Results: static actions (fuzzy-filtered) + session title matches +
252
+ full-text message matches (debounced server round trip).
253
+ ──────────────────────────────────────────────────────────────────── */
254
+
255
+ function matchingActions(query: string): PaletteAction[] {
256
+ const onChat = currentRoute() === "chat";
257
+ return staticActions()
258
+ .filter((a) => (onChat || !a.chatOnly))
259
+ .filter((a) => fuzzyMatch(query, a.label));
260
+ }
261
+
262
+ function matchingSessionRows(query: string): SessionRowRef[] {
263
+ const rows = document.querySelectorAll<HTMLElement>("#chat-sessions .sess");
264
+ const out: SessionRowRef[] = [];
265
+ rows.forEach((row) => {
266
+ const title = row.querySelector(".stitle")?.textContent || "New chat";
267
+ const path = row.dataset.path;
268
+ if (!path) return;
269
+ if (fuzzyMatch(query, title)) out.push({ path, title });
270
+ });
271
+ return out.slice(0, 6);
272
+ }
273
+
274
+ async function refreshResults(query: string): Promise<void> {
275
+ selectedIndex = 0;
276
+ const q = query.trim();
277
+ const actionEntries: PaletteEntry[] = matchingActions(q).map((action) => ({ kind: "action", action }));
278
+ const sessionEntries: PaletteEntry[] = q ? matchingSessionRows(q).map((row) => ({ kind: "session", row })) : [];
279
+ entries = [...actionEntries, ...sessionEntries];
280
+ renderRows();
281
+
282
+ if (!q || q.length < 2) return;
283
+ const seq = ++searchSeq;
284
+ const d = await api("/api/sessions/search?q=" + encodeURIComponent(q)).catch(() => ({ ok: false } as { ok: boolean }));
285
+ if (seq !== searchSeq) return; // superseded by a newer keystroke
286
+ if (input && input.value.trim() !== q) return;
287
+ const body = d as { ok: boolean; results?: Array<{ sessionPath: string; sessionTitle: string; matches: Array<{ snippet: string }> }> };
288
+ if (!body.ok || !body.results || !body.results.length) return;
289
+ const messageEntries: PaletteEntry[] = body.results.slice(0, 6).map((r) => ({
290
+ kind: "message",
291
+ hit: { sessionPath: r.sessionPath, sessionTitle: r.sessionTitle, snippet: r.matches[0]?.snippet || "" },
292
+ }));
293
+ entries = [...actionEntries, ...sessionEntries, ...messageEntries];
294
+ renderRows();
295
+ }
296
+
297
+ function esc(s: unknown): string {
298
+ return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" } as Record<string, string>)[c]!);
299
+ }
300
+
301
+ function renderRows(): void {
302
+ if (!listEl) return;
303
+ if (!entries.length) {
304
+ listEl.innerHTML = '<div class="palette-empty">No matching commands or chats.</div>';
305
+ return;
306
+ }
307
+ listEl.innerHTML = "";
308
+ let sawActionHdr = false, sawSessionHdr = false, sawMessageHdr = false;
309
+ entries.forEach((entry, i) => {
310
+ if (entry.kind === "action" && !sawActionHdr) { el("div", "palette-section-hdr", listEl!).textContent = "Commands"; sawActionHdr = true; }
311
+ if (entry.kind === "session" && !sawSessionHdr) { el("div", "palette-section-hdr", listEl!).textContent = "Chats"; sawSessionHdr = true; }
312
+ if (entry.kind === "message" && !sawMessageHdr) { el("div", "palette-section-hdr", listEl!).textContent = "In messages"; sawMessageHdr = true; }
313
+
314
+ const row = el("div", "palette-row" + (i === selectedIndex ? " active" : ""), listEl!);
315
+ row.setAttribute("role", "option");
316
+ row.dataset.index = String(i);
317
+ if (entry.kind === "action") {
318
+ row.innerHTML =
319
+ '<div class="prow-main"><div class="prow-label">' + esc(entry.action.label) + '</div></div>' +
320
+ (entry.action.hint ? '<span class="prow-hint">' + esc(entry.action.hint) + '</span>' : "");
321
+ } else if (entry.kind === "session") {
322
+ row.innerHTML = '<div class="prow-main"><div class="prow-label">' + esc(entry.row.title) + '</div></div>';
323
+ } else {
324
+ row.innerHTML =
325
+ '<div class="prow-main"><div class="prow-label">' + esc(entry.hit.sessionTitle) + '</div>' +
326
+ '<div class="prow-snippet">' + esc(entry.hit.snippet) + '</div></div>';
327
+ }
328
+ row.addEventListener("click", () => activate(i));
329
+ row.addEventListener("mousemove", () => { if (selectedIndex !== i) { selectedIndex = i; renderRows(); } });
330
+ });
331
+ }
332
+
333
+ /** Wire the palette module: styles + registered callbacks so shell.ts's
334
+ * Cmd/Ctrl+K binding and Escape sweep can reach it. Call once at boot
335
+ * (main.ts), same lifecycle as initHubPanel()/initMemoryPanel(). Building
336
+ * the DOM itself is deferred to the first open (ensureBuilt(), called from
337
+ * openPalette()) so a browser session that never opens the palette pays
338
+ * zero DOM cost for it — consistent with the other overlays' lazy-init
339
+ * posture is NOT how they work today (they build eagerly at init()), but
340
+ * this module has no app.html anchor element to hang an eager build off
341
+ * of, so lazy-on-first-open is the natural shape here instead. */
342
+ export function initPalette(): void {
343
+ setPaletteClose(closePalette);
344
+ setPaletteIsOpen(isPaletteOpen);
345
+ setPaletteOpen(openPalette);
346
+ }
@@ -0,0 +1,62 @@
1
+ // Shared frontend-only protocol shapes that don't already live on the
2
+ // server side as exported types. The WS contract itself (ClientMessage /
3
+ // ServerMessage / HistoryItem / SessionListItem / ReadyGenDefaults) is
4
+ // imported type-only from ../../pi-web — see chat.ts. This file only holds
5
+ // shapes with no server-side export to anchor to (e.g. the SSE job-stream
6
+ // events, which live as a doc-comment in app.html today, not a type).
7
+ //
8
+ // Pointer comment for the server side: src/web/src/protocol.ts mirrors the
9
+ // SSE line protocol documented at the jobStream() call site (api.ts) —
10
+ // keep the two adjacent if the job event shape changes server-side
11
+ // (scripts/ that emit `data:` lines for /api/jobs/:id/stream).
12
+
13
+ /** One line of the /api/jobs/:id/stream SSE protocol (JSON per `data:` line). */
14
+ export type JobEvent =
15
+ | { type: "log"; line: string }
16
+ | {
17
+ type: "stage";
18
+ stage: string;
19
+ progress?: number;
20
+ message?: string;
21
+ output_dir?: string;
22
+ adapter_path?: string;
23
+ // Dataset-generation-specific row counts (present on the dataset
24
+ // builder's job stream only; absent/ignored for quantize/finetune).
25
+ n_train?: number;
26
+ n_valid?: number;
27
+ }
28
+ | {
29
+ type: "metric";
30
+ kind: "train" | "val";
31
+ step: number;
32
+ loss: number;
33
+ learning_rate?: number;
34
+ tokens_per_sec?: number;
35
+ progress?: number;
36
+ message?: string;
37
+ }
38
+ | { type: "done"; n_train?: number; n_valid?: number }
39
+ | { type: "failed"; error?: string };
40
+
41
+ /** Handlers passed to jobStream(); each key is an optional callback for the
42
+ * matching JobEvent variant, plus an `error` callback for the EventSource's
43
+ * own onerror (transport failure, not a `failed` protocol event). */
44
+ export interface JobStreamHandlers {
45
+ log?: (e: Extract<JobEvent, { type: "log" }>) => void;
46
+ stage?: (e: Extract<JobEvent, { type: "stage" }>) => void;
47
+ metric?: (e: Extract<JobEvent, { type: "metric" }>) => void;
48
+ done?: (e: Extract<JobEvent, { type: "done" }>) => void;
49
+ failed?: (e: Extract<JobEvent, { type: "failed" }>) => void;
50
+ error?: () => void;
51
+ }
52
+
53
+ /** Generic envelope every /api/* JSON endpoint returns on success or
54
+ * failure (api() in api.ts unwraps {error:{message}} to a plain string —
55
+ * see the doc-comment there). Individual endpoints add their own fields
56
+ * on top via intersection at the call site; this is just the shared base. */
57
+ export interface ApiEnvelope {
58
+ ok?: boolean;
59
+ error?: string;
60
+ message?: string;
61
+ [key: string]: unknown;
62
+ }