mlx-bun 0.0.10 → 0.0.11

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 (173) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -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/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +14 -6
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +3 -2
  27. package/docs/design/ssd-kv-cold-tier.md +130 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/dspark-handoff.md +182 -16
  35. package/docs/investigations/lab-build-journal.md +2 -2
  36. package/docs/investigations/openwiki-evaluation.md +249 -0
  37. package/docs/planning/IDEAS.md +14 -0
  38. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  39. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  40. package/docs/planning/chat-ui-vision.md +50 -0
  41. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  42. package/docs/planning/release-notes-v0.0.11.md +239 -0
  43. package/docs/reference/cli.md +35 -16
  44. package/docs/reference/distribution.md +13 -6
  45. package/docs/reference/embedding.md +35 -14
  46. package/docs/reference/features-matrix.md +97 -0
  47. package/docs/reference/library-api.md +30 -9
  48. package/docs/reference/memory.md +26 -6
  49. package/docs/reference/models.md +11 -1
  50. package/docs/reference/orpo-quickstart.md +3 -4
  51. package/docs/reference/server-api.md +634 -22
  52. package/docs/reference/server-config.md +250 -121
  53. package/docs/reference/training.md +14 -15
  54. package/package.json +5 -3
  55. package/src/anthropic.ts +13 -0
  56. package/src/audio/conformer.ts +647 -0
  57. package/src/audio/decode.ts +133 -0
  58. package/src/audio/features.ts +202 -0
  59. package/src/audio/transcode.ts +67 -0
  60. package/src/cli.ts +356 -105
  61. package/src/config.ts +49 -2
  62. package/src/download.ts +40 -0
  63. package/src/eval/README.md +7 -6
  64. package/src/eval/kl.ts +59 -3
  65. package/src/eval/quality-db.ts +2 -2
  66. package/src/eval/runner.ts +3 -3
  67. package/src/expert-offload.ts +15 -2
  68. package/src/flags.ts +13 -0
  69. package/src/generate.ts +330 -24
  70. package/src/grammar.ts +585 -0
  71. package/src/hub-rest.ts +242 -0
  72. package/src/kv-store.ts +442 -88
  73. package/src/lora.ts +8 -0
  74. package/src/md-modules.d.ts +42 -0
  75. package/src/media-fetch.ts +284 -0
  76. package/src/memory/rest.ts +245 -0
  77. package/src/memory/vault.ts +74 -0
  78. package/src/mlx/array.ts +106 -19
  79. package/src/mlx/autograd.ts +2 -2
  80. package/src/mlx/ffi.ts +27 -3
  81. package/src/mlx/metal-kernel.ts +7 -4
  82. package/src/mlx/ops.ts +153 -23
  83. package/src/mlx/turboquant-ops.ts +831 -0
  84. package/src/mlx/turboquant-tables.ts +237 -0
  85. package/src/model/batched-mask.ts +62 -1
  86. package/src/model/batched-quant.ts +206 -0
  87. package/src/model/batched-rotating-quant.ts +346 -0
  88. package/src/model/batched-rotating.ts +46 -1
  89. package/src/model/compiled-decode.ts +4 -15
  90. package/src/model/factory.ts +10 -4
  91. package/src/model/gemma4-base.ts +363 -39
  92. package/src/model/gemma4.ts +122 -33
  93. package/src/model/generated/gemma4-12b.ts +9 -13
  94. package/src/model/generated/gemma4-26b.ts +10 -5
  95. package/src/model/generated/gemma4-e4b.ts +14 -9
  96. package/src/model/minicpm5.ts +58 -102
  97. package/src/model/paged-kv.ts +253 -0
  98. package/src/model/qwen3-delta.ts +76 -15
  99. package/src/model/qwen3-moe.ts +404 -0
  100. package/src/model/qwen3.ts +9 -8
  101. package/src/model/qwen3_5.ts +88 -23
  102. package/src/model/support.ts +12 -1
  103. package/src/model/universal/dense.ts +40 -5
  104. package/src/model/universal/rope.ts +36 -21
  105. package/src/pi-web.ts +1299 -82
  106. package/src/preflight.ts +27 -8
  107. package/src/prompt-cache.ts +293 -33
  108. package/src/quantize/quantizer.ts +16 -1
  109. package/src/registry.ts +57 -2
  110. package/src/serve/batch-scheduler.ts +965 -72
  111. package/src/serve/generation-gateway.ts +214 -11
  112. package/src/serve/isolate.ts +417 -0
  113. package/src/serve/lane-registry.ts +44 -0
  114. package/src/serve/session-search.ts +326 -0
  115. package/src/server.ts +1380 -135
  116. package/src/spec/assistant-source.ts +150 -0
  117. package/src/spec/deepspec-source.ts +143 -0
  118. package/src/spec/dflash-source.ts +124 -0
  119. package/src/spec/dspark/ab-stats.ts +136 -0
  120. package/src/spec/dspark/calibration.ts +112 -0
  121. package/src/spec/dspark/deepspec-module.ts +801 -0
  122. package/src/spec/dspark/generate-dflash.ts +72 -20
  123. package/src/spec/dspark/loader.ts +23 -0
  124. package/src/spec/dspark/module-dflash.ts +278 -36
  125. package/src/spec/dspark/quantize-drafter.ts +91 -0
  126. package/src/spec/generate.ts +59 -25
  127. package/src/spec/ngram-source.ts +132 -0
  128. package/src/spec/serve-loop.ts +536 -0
  129. package/src/spec/source.ts +93 -0
  130. package/src/spec/two-model.ts +171 -0
  131. package/src/ssd-cache.ts +107 -35
  132. package/src/tokenizer.ts +288 -2
  133. package/src/tool-approvals.ts +131 -0
  134. package/src/tool-call.ts +408 -20
  135. package/src/train/flash-cce.ts +2 -2
  136. package/src/train/job.ts +0 -2
  137. package/src/train/trainer.ts +1 -4
  138. package/src/vision/prompt.ts +217 -48
  139. package/src/web/app.html +1184 -1789
  140. package/src/web/app.js +5667 -0
  141. package/src/web/icon.svg +13 -0
  142. package/src/web/manifest.webmanifest +24 -0
  143. package/src/web/src/adapters-panel.ts +288 -0
  144. package/src/web/src/api.ts +57 -0
  145. package/src/web/src/assistant.ts +485 -0
  146. package/src/web/src/chat.ts +1050 -0
  147. package/src/web/src/composer.ts +959 -0
  148. package/src/web/src/dataset.ts +132 -0
  149. package/src/web/src/finetune.ts +267 -0
  150. package/src/web/src/hub.ts +428 -0
  151. package/src/web/src/main.ts +98 -0
  152. package/src/web/src/markdown.ts +0 -0
  153. package/src/web/src/memory-panel.ts +572 -0
  154. package/src/web/src/model-picker.ts +159 -0
  155. package/src/web/src/palette.ts +346 -0
  156. package/src/web/src/protocol.ts +62 -0
  157. package/src/web/src/quantize.ts +167 -0
  158. package/src/web/src/rag.ts +274 -0
  159. package/src/web/src/sessions.ts +469 -0
  160. package/src/web/src/shell.ts +804 -0
  161. package/src/web/src/status.ts +252 -0
  162. package/src/web/src/ui-catalog.ts +92 -0
  163. package/src/web/sw.js +71 -0
  164. package/src/web/vendor/README.md +61 -0
  165. package/src/web/vendor/hljs-LICENSE +29 -0
  166. package/src/web/vendor/hljs-theme.css +59 -0
  167. package/src/web/vendor/hljs.js +26 -0
  168. package/src/model/fused-decode-kernel.ts +0 -312
  169. package/src/model/fused-geglu-kernel.ts +0 -198
  170. package/src/model/fused-mlp-kernel.ts +0 -169
  171. package/src/model/fused-swiglu-kernel.ts +0 -240
  172. package/src/model/steel-linear-kernel.ts +0 -96
  173. package/src/status-page.html +0 -497
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <defs>
3
+ <linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" stop-color="#0a84ff"/>
5
+ <stop offset="55%" stop-color="#5e5ce6"/>
6
+ <stop offset="100%" stop-color="#bf5af2"/>
7
+ </linearGradient>
8
+ </defs>
9
+ <rect width="512" height="512" rx="112" fill="#000"/>
10
+ <circle cx="256" cy="256" r="150" fill="none" stroke="url(#g)" stroke-width="28"/>
11
+ <path d="M256 148 L256 364 M180 190 L180 322 M332 190 L332 322"
12
+ stroke="url(#g)" stroke-width="26" stroke-linecap="round" fill="none"/>
13
+ </svg>
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "mlx-bun",
3
+ "short_name": "mlx-bun",
4
+ "description": "Local, on-device chat served by mlx-bun — an MLX-only Bun runtime for Apple Silicon.",
5
+ "start_url": "/",
6
+ "scope": "/",
7
+ "display": "standalone",
8
+ "background_color": "#000000",
9
+ "theme_color": "#000000",
10
+ "icons": [
11
+ {
12
+ "src": "/assets/icon.svg",
13
+ "sizes": "any",
14
+ "type": "image/svg+xml",
15
+ "purpose": "any"
16
+ },
17
+ {
18
+ "src": "/assets/icon.svg",
19
+ "sizes": "any",
20
+ "type": "image/svg+xml",
21
+ "purpose": "maskable"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,288 @@
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
+ // Adapter routing table (plan §5.6, §9 Phase 2): the deep surface behind
5
+ // the composer's ⚙ button beside #chat-adapter. Shows every on-disk
6
+ // adapter with its three states (available / loaded+mounted /
7
+ // selected-for-this-chat), base model, rank, size on disk, and RAM cost
8
+ // when mounted (GET /v1/adapters now reports `ram_bytes` — see
9
+ // docs/reference/server-api.md), with compatible-graying and a why-not
10
+ // tooltip. Actions: mount, select, unselect, and stack two compatible
11
+ // adapters as "a+b" — resolveSpec/injectAdapter already support composite
12
+ // ids end to end (proved in tests/pi-web.test.ts's stacking test; the
13
+ // shape-validated mount is proved against real weights in the
14
+ // MLX_BUN_TEST_LORA=1-gated tests/lora.test.ts).
15
+ //
16
+ // Single source of SELECTION state, not a single fetch: composer.ts's quick
17
+ // <select> and this table each hit /v1/adapters(+/available) independently
18
+ // (same endpoints, same shapes) rather than sharing one in-memory cache —
19
+ // but chat.ts keeps them from disagreeing about what's SELECTED by writing
20
+ // through both on every change (adapterSel.onchange syncs
21
+ // adaptersPanel.selectedSpec; this table's actions sync #chat-adapter's
22
+ // value back). What must never drift is which adapter(s) are active for
23
+ // the chat, not which HTTP call fetched the list.
24
+
25
+ import { $, toast, trapFocus, setAdaptersPanelClose, type FocusTrap } from "./shell";
26
+ import { api } from "./api";
27
+ import { esc } from "./markdown";
28
+
29
+ /** Shape returned by GET /v1/adapters/available (on-disk, unfiltered). */
30
+ export interface AvailableAdapterRow {
31
+ id: string;
32
+ path: string;
33
+ rank: number | null;
34
+ scale: number;
35
+ base_model: string | null;
36
+ mounted: boolean;
37
+ compatible: boolean;
38
+ }
39
+
40
+ /** Shape returned by GET /v1/adapters (mounted only, with RAM cost). */
41
+ export interface MountedAdapterRow {
42
+ id: string;
43
+ path: string;
44
+ rank: number | null;
45
+ scale: number;
46
+ size_bytes: number;
47
+ mounted_layers: number;
48
+ ram_bytes: number;
49
+ }
50
+
51
+ function fmtBytes(n: number | null | undefined): string {
52
+ if (n == null) return "—";
53
+ if (n >= 2 ** 20) return (n / 2 ** 20).toFixed(1) + " MB";
54
+ if (n >= 2 ** 10) return (n / 2 ** 10).toFixed(1) + " KB";
55
+ return n + " B";
56
+ }
57
+
58
+ /** Send callback type shared with the rest of chat.ts's WS wiring. */
59
+ type Send = (obj: unknown) => boolean;
60
+
61
+ export class AdaptersPanelState {
62
+ available: AvailableAdapterRow[] = [];
63
+ mounted: Map<string, MountedAdapterRow> = new Map();
64
+ /** The composer's current single-select value ("" = none), kept in sync
65
+ * so the table can show which one/ones are "selected for this chat". */
66
+ selectedSpec: string | null = null;
67
+ /** Multi-select scratch state for the "stack" flow — ids the user has
68
+ * ticked in the table before hitting "apply stack". Cleared on apply. */
69
+ stackPicks: Set<string> = new Set();
70
+ }
71
+
72
+ let trap: FocusTrap | null = null;
73
+
74
+ /** Fetch both adapter endpoints once and refresh every dependent view: the
75
+ * table body (if open, with its actions re-wired against `send`) and the
76
+ * composer's quick <select> (renderAdapterOptionsHtml, imported from
77
+ * composer.ts — one source of truth, not a second fetch). `send` is
78
+ * required (not optional) so a refresh never silently leaves the table's
79
+ * mount/select/stack buttons wired to a no-op. */
80
+ export async function refreshAdaptersPanel(state: AdaptersPanelState, send: Send): Promise<void> {
81
+ const [availD, mountedD] = await Promise.all([
82
+ api("/v1/adapters/available").catch(() => ({ ok: false } as const)),
83
+ api("/v1/adapters").catch(() => ({ ok: false } as const)),
84
+ ]);
85
+ state.available = ((availD as { adapters?: AvailableAdapterRow[] }).adapters) || [];
86
+ const mountedList = ((mountedD as { adapters?: MountedAdapterRow[] }).adapters) || [];
87
+ state.mounted = new Map(mountedList.map((a) => [a.id, a]));
88
+ // Drop stack picks for adapters that vanished from disk (e.g. an
89
+ // in-flight delete elsewhere).
90
+ for (const id of [...state.stackPicks]) if (!state.available.some((a) => a.id === id)) state.stackPicks.delete(id);
91
+ renderAdaptersBody(state, send);
92
+ }
93
+
94
+ /** Parse the composer's currently-selected spec ("" | "id" | "a+b") into
95
+ * the set of ids it names, for the table's per-row "selected" badge. */
96
+ function selectedIds(state: AdaptersPanelState): Set<string> {
97
+ const spec = state.selectedSpec;
98
+ if (!spec) return new Set();
99
+ return new Set(spec.split("+").map((s) => s.trim()).filter(Boolean));
100
+ }
101
+
102
+ /** Render one adapter row: id/badges, base-model/rank/size/RAM meta line,
103
+ * incompatible-why note, and the mount/select/unselect/stack actions.
104
+ * Pure enough to unit-test (no fetch inside), takes callbacks for the
105
+ * actions so the DOM wiring lives in one place (initAdaptersPanel). */
106
+ export function renderAdapterRow(
107
+ a: AvailableAdapterRow,
108
+ opts: {
109
+ mountedInfo: MountedAdapterRow | undefined;
110
+ isSelected: boolean;
111
+ stackPicked: boolean;
112
+ stackModeOn: boolean;
113
+ },
114
+ ): string {
115
+ const { mountedInfo, isSelected, stackPicked, stackModeOn } = opts;
116
+ const rowClasses = ["ad-row"];
117
+ if (!a.compatible) rowClasses.push("incompatible");
118
+ if (isSelected) rowClasses.push("selected");
119
+ const badges: string[] = [];
120
+ if (mountedInfo) badges.push('<span class="ad-badge mounted">mounted</span>');
121
+ if (isSelected) badges.push('<span class="ad-badge selected">selected</span>');
122
+ const meta: string[] = [];
123
+ if (a.base_model) meta.push("base <b>" + esc(a.base_model.split("/").pop()) + "</b>");
124
+ if (a.rank) meta.push("rank <b>" + esc(a.rank) + "</b>");
125
+ meta.push("disk <b>" + esc(fmtBytes(mountedInfo ? mountedInfo.size_bytes : null) === "—" ? "—" : fmtBytes(mountedInfo ? mountedInfo.size_bytes : null)) + "</b>");
126
+ if (mountedInfo) meta.push("RAM <b>" + esc(fmtBytes(mountedInfo.ram_bytes)) + "</b>");
127
+ const why = !a.compatible
128
+ ? '<div class="ad-why">' + (a.base_model
129
+ ? "trained for " + esc(a.base_model) + ", not the currently-served model"
130
+ : "not compatible with the currently-served model") + "</div>"
131
+ : "";
132
+ const actions: string[] = [];
133
+ if (a.compatible) {
134
+ if (stackModeOn) {
135
+ actions.push(
136
+ '<label class="ad-stack-chk"><input type="checkbox" class="ad-stack-pick" data-id="' + esc(a.id) + '"' +
137
+ (stackPicked ? " checked" : "") + (!mountedInfo ? " disabled title=\"mount first to stack it\"" : "") + '> stack</label>',
138
+ );
139
+ }
140
+ if (!mountedInfo) {
141
+ actions.push('<button type="button" class="ad-mount" data-id="' + esc(a.id) + '" data-path="' + esc(a.path) + '">Mount</button>');
142
+ } else if (!isSelected) {
143
+ actions.push('<button type="button" class="ad-select primary" data-id="' + esc(a.id) + '">Select</button>');
144
+ } else {
145
+ actions.push('<button type="button" class="ad-unselect" data-id="' + esc(a.id) + '">Unselect</button>');
146
+ }
147
+ }
148
+ return (
149
+ '<div class="' + rowClasses.join(" ") + '" data-adapter-row="' + esc(a.id) + '">' +
150
+ '<div class="ad-row-main">' +
151
+ '<div class="ad-row-head"><span class="ad-row-id" title="' + esc(a.id) + '">' + esc(a.id) + "</span>" + badges.join("") + "</div>" +
152
+ '<div class="ad-meta">' + meta.join(" · ") + "</div>" +
153
+ why +
154
+ "</div>" +
155
+ '<div class="ad-actions">' + actions.join("") + "</div>" +
156
+ "</div>"
157
+ );
158
+ }
159
+
160
+ /** Full table body: a stack-mode toggle + composed-spec bar (when picks
161
+ * exist), then one section of rows. Exported so tests/web-app.test.ts can
162
+ * exercise esc() discipline without a live DOM/network. */
163
+ export function renderAdaptersBodyHtml(state: AdaptersPanelState): string {
164
+ if (!state.available.length) {
165
+ return (
166
+ '<div class="ad-empty">No adapters found on disk yet.<br>' +
167
+ "Fine-tune one in the Developer tab, or drop an adapter directory into " +
168
+ "<code>~/.cache/mlx-bun/adapters</code>.</div>"
169
+ );
170
+ }
171
+ const selected = selectedIds(state);
172
+ const stackModeOn = state.stackPicks.size > 0 || selected.size > 1;
173
+ const rows = state.available.map((a) => renderAdapterRow(a, {
174
+ mountedInfo: state.mounted.get(a.id),
175
+ isSelected: selected.has(a.id),
176
+ stackPicked: state.stackPicks.has(a.id),
177
+ stackModeOn,
178
+ })).join("");
179
+ const picks = [...state.stackPicks];
180
+ const stackBar = picks.length
181
+ ? '<div class="ad-stack-bar"><span class="lbl">Stack:</span><span class="expr">' +
182
+ esc(picks.join(" + ")) + '</span><button type="button" id="ad-stack-apply" class="ad-actions-btn">Apply</button>' +
183
+ '<button type="button" id="ad-stack-clear" class="ad-actions-btn">Clear</button></div>'
184
+ : "";
185
+ return (
186
+ '<div class="ad-note">Every adapter found on disk, mounted or not. Mounting loads it into ' +
187
+ "memory (RAM cost shown once mounted); selecting makes it active for this chat's next turn " +
188
+ "(a fresh KV segment). Tick two mounted adapters' “stack” boxes to compose them as " +
189
+ '<code>a+b</code> — the server already supports it end to end.</div>' +
190
+ stackBar +
191
+ '<div class="ad-sec-title">On disk (' + state.available.length + ")</div>" +
192
+ rows
193
+ );
194
+ }
195
+
196
+ function renderAdaptersBody(state: AdaptersPanelState, send: Send): void {
197
+ const body = $("adapters-body");
198
+ if (!body) return;
199
+ body.innerHTML = renderAdaptersBodyHtml(state);
200
+ wireRowActions(state, send);
201
+ }
202
+
203
+ function wireRowActions(state: AdaptersPanelState, send: Send): void {
204
+ const body = $("adapters-body");
205
+ if (!body) return;
206
+ body.querySelectorAll<HTMLButtonElement>(".ad-mount").forEach((btn) => {
207
+ btn.onclick = async () => {
208
+ const id = btn.dataset.id!, path = btn.dataset.path!;
209
+ btn.disabled = true; btn.textContent = "Mounting…";
210
+ const r = await api("/v1/adapters", { method: "POST", body: { id, path } });
211
+ if (r && (r.error || r.ok === false)) {
212
+ toast("adapter: " + (r.error || "mount failed"), "err");
213
+ btn.disabled = false; btn.textContent = "Mount";
214
+ return;
215
+ }
216
+ toast("mounted " + id, "ok");
217
+ await refreshAdaptersPanel(state, send);
218
+ };
219
+ });
220
+ body.querySelectorAll<HTMLButtonElement>(".ad-select").forEach((btn) => {
221
+ btn.onclick = () => {
222
+ const id = btn.dataset.id!;
223
+ state.selectedSpec = id;
224
+ state.stackPicks.clear();
225
+ send({ type: "set_adapter", id });
226
+ const sel = $("chat-adapter") as HTMLSelectElement | null;
227
+ if (sel && [...sel.options].some((o) => o.value === id)) sel.value = id;
228
+ toast("adapter selected — new turns start a fresh KV segment", "ok");
229
+ renderAdaptersBody(state, send);
230
+ };
231
+ });
232
+ body.querySelectorAll<HTMLButtonElement>(".ad-unselect").forEach((btn) => {
233
+ btn.onclick = () => {
234
+ state.selectedSpec = null;
235
+ send({ type: "set_adapter", id: null });
236
+ const sel = $("chat-adapter") as HTMLSelectElement | null;
237
+ if (sel) sel.value = "";
238
+ renderAdaptersBody(state, send);
239
+ };
240
+ });
241
+ body.querySelectorAll<HTMLInputElement>(".ad-stack-pick").forEach((chk) => {
242
+ chk.onchange = () => {
243
+ const id = chk.dataset.id!;
244
+ if (chk.checked) state.stackPicks.add(id); else state.stackPicks.delete(id);
245
+ renderAdaptersBody(state, send);
246
+ };
247
+ });
248
+ const apply = $("ad-stack-apply") as HTMLButtonElement | null;
249
+ if (apply) apply.onclick = () => {
250
+ if (state.stackPicks.size < 2) { toast("stack needs at least two ticked adapters", "err"); return; }
251
+ const spec = [...state.stackPicks].join("+");
252
+ state.selectedSpec = spec;
253
+ send({ type: "set_adapter", id: spec });
254
+ const sel = $("chat-adapter") as HTMLSelectElement | null;
255
+ if (sel) sel.value = ""; // composite spec has no matching single <option>
256
+ toast("stacked " + spec + " — new turns start a fresh KV segment", "ok");
257
+ renderAdaptersBody(state, send);
258
+ };
259
+ const clear = $("ad-stack-clear") as HTMLButtonElement | null;
260
+ if (clear) clear.onclick = () => { state.stackPicks.clear(); renderAdaptersBody(state, send); };
261
+ }
262
+
263
+ function openPanel(state: AdaptersPanelState, send: Send): void {
264
+ const ov = $("adapters-overlay");
265
+ ov.classList.add("open");
266
+ $("adapters-manage").setAttribute("aria-expanded", "true");
267
+ if (trap) trap.capture();
268
+ refreshAdaptersPanel(state, send);
269
+ }
270
+ export function closeAdaptersPanel(): void {
271
+ $("adapters-overlay").classList.remove("open");
272
+ $("adapters-manage").setAttribute("aria-expanded", "false");
273
+ if (trap) trap.restore();
274
+ }
275
+
276
+ /** Wire the ⚙ button + overlay close affordances. Escape is handled by the
277
+ * global closeTopOverlay sweep in shell.ts (one mechanism, not a bespoke
278
+ * listener per overlay — same pattern as the HF modal / sampling popover). */
279
+ export function initAdaptersPanel(state: AdaptersPanelState, send: Send): void {
280
+ const btn = $("adapters-manage");
281
+ if (!btn) return;
282
+ trap = trapFocus($("adapters-panel"), () => $("adapters-overlay").classList.contains("open"));
283
+ btn.onclick = () => openPanel(state, send);
284
+ const close = $("adapters-close");
285
+ if (close) close.onclick = closeAdaptersPanel;
286
+ $("adapters-overlay").addEventListener("click", (e) => { if (e.target === $("adapters-overlay")) closeAdaptersPanel(); });
287
+ setAdaptersPanelClose(closeAdaptersPanel);
288
+ }
@@ -0,0 +1,57 @@
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
+ // Thin JSON-fetch helper + the SSE job-stream wrapper used by the
5
+ // quantize/finetune/dataset controllers. Behavior-identical port of the
6
+ // original inline <script> in app.html (api()/jobStream()).
7
+
8
+ import type { ApiEnvelope, JobEvent, JobStreamHandlers } from "./protocol";
9
+
10
+ export type ApiOpts = (Omit<RequestInit, "body"> & { body?: unknown }) | undefined;
11
+
12
+ /** Fetch JSON with graceful error handling. Throws only on transport
13
+ * failure (network error) — HTTP-level failure is reflected in the
14
+ * returned envelope, never a throw, so callers can always `await api(...)`
15
+ * without try/catch for the common case. */
16
+ export async function api<T extends ApiEnvelope = ApiEnvelope>(path: string, opts?: ApiOpts): Promise<T> {
17
+ const { body, ...rest } = opts || {};
18
+ const init: RequestInit = {
19
+ headers: { "content-type": "application/json" },
20
+ ...rest,
21
+ ...(body !== undefined
22
+ ? { body: typeof body === "string" ? body : JSON.stringify(body) }
23
+ : {}),
24
+ };
25
+ const r = await fetch(path, init);
26
+ const text = await r.text();
27
+ let data: ApiEnvelope;
28
+ try { data = text ? JSON.parse(text) : {}; } catch { data = { ok: false, error: text.slice(0, 400) || ("HTTP " + r.status) }; }
29
+ // OpenAI-envelope errors are {error:{message,…}} — unwrap to a string so
30
+ // callers can concatenate without printing "[object Object]"
31
+ // (web-ui-pass-plan.md #4).
32
+ if (data && data.error && typeof data.error === "object") {
33
+ const errObj = data.error as { message?: string };
34
+ data.error = errObj.message || JSON.stringify(errObj).slice(0, 400);
35
+ }
36
+ if (!r.ok && data.ok === undefined) data = { ok: false, error: (data.error as string) || data.message || ("HTTP " + r.status) };
37
+ return data as T;
38
+ }
39
+
40
+ /**
41
+ * Wraps an EventSource over /api/jobs/:id/stream and dispatches typed
42
+ * events. Server -> client line protocol (JSON per `data:` line) — see
43
+ * JobEvent in ./protocol.ts (pointer comment there to the server-side
44
+ * source). Returns the EventSource so callers can .close().
45
+ */
46
+ export function jobStream(jobId: string, handlers: JobStreamHandlers): EventSource {
47
+ const es = new EventSource("/api/jobs/" + encodeURIComponent(jobId) + "/stream");
48
+ es.onmessage = (ev: MessageEvent) => {
49
+ let e: JobEvent;
50
+ try { e = JSON.parse(ev.data); } catch { return; }
51
+ const fn = handlers[e.type] as ((e: JobEvent) => void) | undefined;
52
+ if (fn) fn(e);
53
+ };
54
+ es.addEventListener("end", () => es.close());
55
+ es.onerror = () => { if (handlers.error) handlers.error(); };
56
+ return es;
57
+ }