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.
- package/README.md +107 -32
- package/THIRD_PARTY_LICENSES.md +10 -0
- package/docs/archive/mlx-bun-lab-report.html +275 -0
- package/docs/archive/wwdc-mlx-bun.html +848 -0
- package/docs/design/audio-input-plan.md +356 -0
- package/docs/design/batching-perf-path.md +58 -35
- package/docs/design/batching-v2-plan.md +23 -9
- package/docs/design/bucketing-stage.md +2 -1
- package/docs/design/colibri-glm52-port.md +1479 -0
- package/docs/design/compat-cli-surface-design.md +4 -3
- package/docs/design/decode-speed-program.md +172 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dspark-serving-program.md +371 -0
- package/docs/design/dspark-speculative-decoding.md +167 -5
- package/docs/design/faithful-l1-consolidation.md +310 -0
- package/docs/design/generic-model-support.md +8 -5
- package/docs/design/grammar-spec-batching-integration.md +301 -0
- package/docs/design/minicpm5-decode-megakernel.md +16 -11
- package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
- package/docs/design/omlx-adoption-map.md +14 -6
- package/docs/design/orpo-training.md +34 -30
- package/docs/design/paged-kv-cache.md +214 -0
- package/docs/design/parallel-slots.md +82 -49
- package/docs/design/parity-tier-dag.md +43 -33
- package/docs/design/pre-colibri-stabilization.md +329 -0
- package/docs/design/repo-cleanup-plan.md +189 -0
- package/docs/design/runtime-isolation.md +19 -3
- package/docs/design/segmented-backward-training.md +3 -2
- package/docs/design/ssd-kv-cold-tier.md +130 -0
- package/docs/design/structured-output.md +484 -0
- package/docs/design/the-dreaming-master-plan.md +2 -3
- package/docs/design/turboquant-kv.md +235 -0
- package/docs/design/unified-engine-frontier-plan.md +774 -0
- package/docs/design/web-chat-beat-matrix.md +315 -0
- package/docs/design/web-chat-redesign.md +1112 -0
- package/docs/investigations/colibri-oracle-pin.md +1284 -0
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/indexshare-performance-spike.md +357 -0
- package/docs/investigations/lab-build-journal.md +2 -2
- package/docs/investigations/openwiki-evaluation.md +249 -0
- package/docs/planning/IDEAS.md +14 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -3
- package/docs/planning/chat-ui-vision-memory-first.md +199 -0
- package/docs/planning/chat-ui-vision.md +50 -0
- package/docs/planning/memory-docs-and-dag-plan.md +15 -6
- package/docs/planning/release-notes-v0.0.11.md +239 -0
- package/docs/planning/release-notes-v0.0.12.md +80 -0
- package/docs/reference/cli.md +63 -18
- package/docs/reference/distribution.md +37 -6
- package/docs/reference/embedding.md +40 -16
- package/docs/reference/features-matrix.md +99 -0
- package/docs/reference/library-api.md +69 -20
- package/docs/reference/memory.md +26 -6
- package/docs/reference/models.md +60 -3
- package/docs/reference/orpo-quickstart.md +3 -4
- package/docs/reference/server-api.md +717 -24
- package/docs/reference/server-config.md +265 -129
- package/docs/reference/training.md +24 -16
- package/package.json +5 -3
- package/src/anthropic.ts +67 -44
- package/src/audio/conformer.ts +647 -0
- package/src/audio/decode.ts +133 -0
- package/src/audio/features.ts +202 -0
- package/src/audio/transcode.ts +67 -0
- package/src/chat-template.ts +155 -10
- package/src/cli.ts +483 -113
- package/src/config.ts +116 -24
- package/src/diffusion/diffusion-generate.ts +14 -8
- package/src/download.ts +143 -12
- package/src/eval/README.md +7 -6
- package/src/eval/ifeval.ts +63 -120
- package/src/eval/kl.ts +59 -3
- package/src/eval/quality-db.ts +2 -2
- package/src/eval/runner.ts +17 -8
- package/src/eval/tasks/ifeval.ts +167 -31
- package/src/expert-io.ts +391 -0
- package/src/expert-offload.ts +15 -2
- package/src/expert-residency.ts +1134 -0
- package/src/expert-usage.ts +415 -0
- package/src/flags.ts +13 -0
- package/src/generate.ts +438 -35
- package/src/grammar.ts +585 -0
- package/src/hf-push.ts +1 -1
- package/src/hub-rest.ts +242 -0
- package/src/index.ts +14 -2
- package/src/jobs/runner.ts +37 -18
- package/src/kv-store.ts +586 -93
- package/src/lora.ts +85 -14
- package/src/md-modules.d.ts +42 -0
- package/src/media-fetch.ts +426 -0
- package/src/memory/rest.ts +245 -0
- package/src/memory/vault.ts +74 -0
- package/src/mlx/array.ts +149 -19
- package/src/mlx/autograd.ts +53 -16
- package/src/mlx/checkpoint.ts +46 -13
- package/src/mlx/custom-vjp.ts +83 -35
- package/src/mlx/ffi.ts +27 -3
- package/src/mlx/metal-kernel.ts +7 -4
- package/src/mlx/ops.ts +153 -23
- package/src/mlx/turboquant-ops.ts +831 -0
- package/src/mlx/turboquant-tables.ts +237 -0
- package/src/model/batched-mask.ts +62 -1
- package/src/model/batched-quant.ts +206 -0
- package/src/model/batched-rotating-quant.ts +346 -0
- package/src/model/batched-rotating.ts +46 -1
- package/src/model/compiled-decode.ts +4 -15
- package/src/model/factory.ts +103 -6
- package/src/model/gemma4-base.ts +395 -39
- package/src/model/gemma4.ts +122 -33
- package/src/model/generated/gemma4-12b.ts +9 -13
- package/src/model/generated/gemma4-26b.ts +10 -5
- package/src/model/generated/gemma4-e4b.ts +14 -9
- package/src/model/glm52-atlas.ts +511 -0
- package/src/model/glm52-cache.ts +706 -0
- package/src/model/glm52-config.ts +262 -0
- package/src/model/glm52-container.ts +305 -0
- package/src/model/glm52-coupling.ts +388 -0
- package/src/model/glm52-dsa.ts +602 -0
- package/src/model/glm52-expert-layout.ts +253 -0
- package/src/model/glm52-layout.ts +152 -0
- package/src/model/glm52-memory.ts +473 -0
- package/src/model/glm52-mla.ts +786 -0
- package/src/model/glm52-moe.ts +223 -0
- package/src/model/glm52-pilot.ts +537 -0
- package/src/model/glm52-reference.ts +445 -0
- package/src/model/glm52-residency.ts +557 -0
- package/src/model/glm52-resident-weights.ts +135 -0
- package/src/model/glm52-streamed-experts.ts +453 -0
- package/src/model/glm52-streamed-metal.ts +610 -0
- package/src/model/glm52-weights.ts +503 -0
- package/src/model/glm52.ts +1153 -0
- package/src/model/minicpm5.ts +58 -102
- package/src/model/paged-kv.ts +253 -0
- package/src/model/qwen3-delta.ts +76 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +88 -23
- package/src/model/support.ts +19 -1
- package/src/model/universal/dense.ts +40 -5
- package/src/model/universal/rope.ts +36 -21
- package/src/native/expert_io.c +523 -0
- package/src/native-pack.ts +6 -4
- package/src/pi-web.ts +1299 -82
- package/src/preflight.ts +27 -8
- package/src/prompt-cache.ts +293 -33
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +57 -2
- package/src/responses.ts +62 -49
- package/src/serve/batch-scheduler.ts +1050 -78
- package/src/serve/generation-gateway.ts +252 -24
- package/src/serve/isolate.ts +417 -0
- package/src/serve/lane-registry.ts +44 -0
- package/src/serve/session-search.ts +326 -0
- package/src/server.ts +1701 -175
- package/src/spec/assistant-source.ts +150 -0
- package/src/spec/deepspec-source.ts +143 -0
- package/src/spec/dflash-source.ts +124 -0
- package/src/spec/dspark/ab-stats.ts +136 -0
- package/src/spec/dspark/calibration.ts +112 -0
- package/src/spec/dspark/deepspec-module.ts +801 -0
- package/src/spec/dspark/generate-dflash.ts +72 -20
- package/src/spec/dspark/loader.ts +23 -0
- package/src/spec/dspark/module-dflash.ts +278 -36
- package/src/spec/dspark/quantize-drafter.ts +91 -0
- package/src/spec/generate.ts +59 -25
- package/src/spec/glm52-mtp-source.ts +392 -0
- package/src/spec/ngram-source.ts +132 -0
- package/src/spec/serve-loop.ts +617 -0
- package/src/spec/source.ts +118 -0
- package/src/spec/two-model.ts +171 -0
- package/src/ssd-cache.ts +111 -35
- package/src/tokenizer.ts +288 -2
- package/src/tool-approvals.ts +131 -0
- package/src/tool-call.ts +438 -22
- package/src/train/flash-cce.ts +2 -2
- package/src/train/job.ts +0 -12
- package/src/train/lora-params.ts +17 -6
- package/src/train/trainer.ts +60 -30
- package/src/vision/prompt.ts +217 -48
- package/src/web/app.html +1197 -1791
- package/src/web/app.js +5694 -0
- package/src/web/icon.svg +13 -0
- package/src/web/manifest.webmanifest +24 -0
- package/src/web/src/adapters-panel.ts +288 -0
- package/src/web/src/api.ts +57 -0
- package/src/web/src/assistant.ts +485 -0
- package/src/web/src/chat.ts +1050 -0
- package/src/web/src/composer.ts +959 -0
- package/src/web/src/dataset.ts +132 -0
- package/src/web/src/finetune.ts +267 -0
- package/src/web/src/hub.ts +428 -0
- package/src/web/src/main.ts +98 -0
- package/src/web/src/markdown.ts +0 -0
- package/src/web/src/memory-panel.ts +572 -0
- package/src/web/src/model-picker.ts +159 -0
- package/src/web/src/palette.ts +346 -0
- package/src/web/src/protocol.ts +62 -0
- package/src/web/src/quantize.ts +167 -0
- package/src/web/src/rag.ts +274 -0
- package/src/web/src/sessions.ts +469 -0
- package/src/web/src/shell.ts +804 -0
- package/src/web/src/status.ts +300 -0
- package/src/web/src/ui-catalog.ts +92 -0
- package/src/web/sw.js +71 -0
- package/src/web/vendor/README.md +61 -0
- package/src/web/vendor/hljs-LICENSE +29 -0
- package/src/web/vendor/hljs-theme.css +59 -0
- package/src/web/vendor/hljs.js +26 -0
- package/src/web-tools.ts +54 -2
- package/src/weights.ts +82 -19
- package/src/model/fused-decode-kernel.ts +0 -312
- package/src/model/fused-geglu-kernel.ts +0 -198
- package/src/model/fused-mlp-kernel.ts +0 -169
- package/src/model/fused-swiglu-kernel.ts +0 -240
- package/src/model/steel-linear-kernel.ts +0 -96
- 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 <repo-id></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) => ({ "&": "&", "<": "<", ">": ">", '"': """ } 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
|
+
}
|