mlx-bun 0.0.4
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/LICENSE +21 -0
- package/README.md +362 -0
- package/THIRD_PARTY_LICENSES.md +41 -0
- package/bin/mlx-bun.mjs +32 -0
- package/docs/curve-designer.html +303 -0
- package/docs/design/adapters-end-to-end.md +201 -0
- package/docs/design/compat-cli-surface-design.md +227 -0
- package/docs/design/docs-reorg-plan.md +152 -0
- package/docs/design/hlg-sampling.md +340 -0
- package/docs/design/memory-system.md +339 -0
- package/docs/design/optimization_plan.md +344 -0
- package/docs/design/parallel-slots.md +515 -0
- package/docs/design/runtime-isolation.md +247 -0
- package/docs/design/segmented-backward-training.md +508 -0
- package/docs/investigations/chunk-finetune-experiment.md +84 -0
- package/docs/investigations/curve-bisector-routing.md +123 -0
- package/docs/investigations/curve-runs/bisector-route.json +98 -0
- package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
- package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
- package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
- package/docs/investigations/curve-terrain.html +105 -0
- package/docs/investigations/expert-offload-single-user-moe.md +292 -0
- package/docs/investigations/hlg-ab.html +34 -0
- package/docs/investigations/hlg-explorer.html +238 -0
- package/docs/investigations/hlg-figs/cliff.svg +1 -0
- package/docs/investigations/hlg-figs/cross.svg +1 -0
- package/docs/investigations/hlg-figs/edges.svg +1 -0
- package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
- package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
- package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
- package/docs/investigations/hlg-figs/terrain.svg +1 -0
- package/docs/investigations/hlg-report.html +262 -0
- package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
- package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
- package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
- package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
- package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
- package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
- package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
- package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
- package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
- package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
- package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
- package/docs/investigations/hlg-runs/hlg-map.json +383 -0
- package/docs/investigations/hlg-runs/run0-original.log +159 -0
- package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
- package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
- package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
- package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
- package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
- package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
- package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
- package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
- package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
- package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
- package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
- package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
- package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
- package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
- package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
- package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
- package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
- package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
- package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
- package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
- package/docs/investigations/hlg-runs/runN-verify.log +116 -0
- package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
- package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
- package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
- package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
- package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
- package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
- package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
- package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
- package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
- package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
- package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
- package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
- package/docs/investigations/hlg-sampling-investigation.md +970 -0
- package/docs/investigations/lab-build-journal.md +157 -0
- package/docs/investigations/pi-builtin-investigation.md +308 -0
- package/docs/investigations/starter-model-port-handoff.md +323 -0
- package/docs/planning/IDEAS.md +26 -0
- package/docs/planning/PRODUCT_ROADMAP.md +426 -0
- package/docs/planning/ResearchTopics.md +119 -0
- package/docs/planning/chunk-task-roadmap.md +77 -0
- package/docs/planning/journal.md +155 -0
- package/docs/reference/distribution.md +121 -0
- package/docs/reference/embedding.md +168 -0
- package/docs/reference/library-api.md +153 -0
- package/docs/reference/server-api.md +224 -0
- package/docs/reference/server-config.md +243 -0
- package/docs/segmented-backward-handoff.md +148 -0
- package/package.json +68 -0
- package/src/anthropic.ts +491 -0
- package/src/chat-template.ts +186 -0
- package/src/cli.ts +1075 -0
- package/src/config.ts +229 -0
- package/src/curve-sampler.ts +122 -0
- package/src/dataset/generators.ts +1087 -0
- package/src/dataset/index.ts +12 -0
- package/src/dataset/job.ts +81 -0
- package/src/dataset/llm.ts +71 -0
- package/src/dataset/registry.ts +528 -0
- package/src/download.ts +283 -0
- package/src/eval/README.md +86 -0
- package/src/eval/kl-prompts.ts +29 -0
- package/src/eval/kl.ts +294 -0
- package/src/eval/quality-db.ts +92 -0
- package/src/eval/runner.ts +102 -0
- package/src/eval/score.ts +49 -0
- package/src/eval/tasks/bfcl.ts +500 -0
- package/src/eval/tasks/gsm8k.ts +85 -0
- package/src/eval/tasks/hashhop.ts +109 -0
- package/src/eval/tasks/humaneval.ts +252 -0
- package/src/eval/tasks/ifeval.ts +354 -0
- package/src/eval/tasks/mmlu.ts +145 -0
- package/src/evaldb.ts +199 -0
- package/src/expert-offload-build.ts +77 -0
- package/src/expert-offload.ts +61 -0
- package/src/expert-trace.ts +67 -0
- package/src/fit.ts +254 -0
- package/src/generate.ts +451 -0
- package/src/harness-pi.ts +177 -0
- package/src/hf-push.ts +628 -0
- package/src/index.ts +22 -0
- package/src/jobs/db.ts +139 -0
- package/src/jobs/index.ts +29 -0
- package/src/jobs/job-entry.ts +106 -0
- package/src/jobs/runner.ts +304 -0
- package/src/jobs/sse.ts +114 -0
- package/src/jobs/types.ts +58 -0
- package/src/kv-store.ts +185 -0
- package/src/lora.ts +314 -0
- package/src/md-modules.d.ts +7 -0
- package/src/mlx/array.ts +237 -0
- package/src/mlx/autograd.ts +267 -0
- package/src/mlx/checkpoint.ts +116 -0
- package/src/mlx/compile.ts +158 -0
- package/src/mlx/custom-vjp.ts +140 -0
- package/src/mlx/ffi.ts +433 -0
- package/src/mlx/metal-kernel.ts +130 -0
- package/src/mlx/ops.ts +707 -0
- package/src/mmap.ts +90 -0
- package/src/model/batched-mask.ts +204 -0
- package/src/model/batched-rotating.ts +300 -0
- package/src/model/compiled-decode.ts +681 -0
- package/src/model/factory.ts +27 -0
- package/src/model/fingerprint.ts +33 -0
- package/src/model/flash-attention.ts +674 -0
- package/src/model/fused-decode-kernel.ts +305 -0
- package/src/model/fused-geglu-kernel.ts +87 -0
- package/src/model/gemma4-base.ts +1616 -0
- package/src/model/gemma4.ts +1012 -0
- package/src/model/generated/gemma4-12b.ts +526 -0
- package/src/model/generated/gemma4-26b.ts +500 -0
- package/src/model/generated/gemma4-e4b.ts +757 -0
- package/src/model/generated/index.ts +18 -0
- package/src/model/minicpm5.ts +278 -0
- package/src/model/qwen3-delta.ts +224 -0
- package/src/model/qwen3_5.ts +429 -0
- package/src/model/support.ts +46 -0
- package/src/native-pack.ts +152 -0
- package/src/pi-provider.ts +95 -0
- package/src/pi-terminal.ts +264 -0
- package/src/pi-web.ts +924 -0
- package/src/preflight.ts +123 -0
- package/src/prompt-cache.ts +113 -0
- package/src/quantize/allocator.ts +627 -0
- package/src/quantize/calibration.ts +180 -0
- package/src/quantize/config-writer.ts +132 -0
- package/src/quantize/index.ts +57 -0
- package/src/quantize/job.ts +136 -0
- package/src/quantize/quantizer.ts +462 -0
- package/src/quantize/safetensors-writer.ts +171 -0
- package/src/quantize/sensitivity.ts +234 -0
- package/src/registry.ts +336 -0
- package/src/responses.ts +774 -0
- package/src/safetensors.ts +158 -0
- package/src/sampler.ts +517 -0
- package/src/serve/batch-scheduler.ts +362 -0
- package/src/serve/generation-gateway.ts +160 -0
- package/src/server.ts +1891 -0
- package/src/spec/drafter.ts +261 -0
- package/src/spec/generate.ts +221 -0
- package/src/status-page.html +497 -0
- package/src/tokenizer.ts +38 -0
- package/src/tool-call.ts +242 -0
- package/src/train/dataset.ts +461 -0
- package/src/train/export.ts +32 -0
- package/src/train/forward.ts +229 -0
- package/src/train/index.ts +43 -0
- package/src/train/job.ts +148 -0
- package/src/train/lora-params.ts +194 -0
- package/src/train/loss.ts +342 -0
- package/src/train/merge.ts +195 -0
- package/src/train/optimizer.ts +176 -0
- package/src/train/rank.ts +101 -0
- package/src/train/segmented.ts +520 -0
- package/src/train/trainer.ts +600 -0
- package/src/tui.ts +161 -0
- package/src/vision/embedder.ts +168 -0
- package/src/vision/preprocess.ts +247 -0
- package/src/vision/prompt.ts +158 -0
- package/src/vision/siglip.ts +599 -0
- package/src/web/app.html +2471 -0
- package/src/web/skills/web-research/SKILL.md +44 -0
- package/src/web/skills.ts +59 -0
- package/src/web-tools.ts +426 -0
- package/src/weights.ts +91 -0
package/src/web/app.html
ADDED
|
@@ -0,0 +1,2471 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>mlx-bun</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
9
|
+
mlx-bun · unified app
|
|
10
|
+
One product, five sections (Chat · Quantize · Fine-tune · Dataset · Status).
|
|
11
|
+
Apple keynote grammar: black stage, SF Pro, gradient hero type,
|
|
12
|
+
hairline cards, ambient color blooms, numbers that land.
|
|
13
|
+
Fully self-contained — no external fonts, CDNs, or scripts.
|
|
14
|
+
════════════════════════════════════════════════════════════════════ */
|
|
15
|
+
|
|
16
|
+
/* ─── design tokens (shared across every section) ─── */
|
|
17
|
+
:root{
|
|
18
|
+
--bg:#000;
|
|
19
|
+
--ink:#f5f5f7;
|
|
20
|
+
--dim:#86868b;
|
|
21
|
+
--dimmer:#515154;
|
|
22
|
+
--hairline:rgba(255,255,255,.14);
|
|
23
|
+
--hairline-soft:rgba(255,255,255,.07);
|
|
24
|
+
--card:rgba(255,255,255,.045);
|
|
25
|
+
--card-hover:rgba(255,255,255,.08);
|
|
26
|
+
--card-2:rgba(255,255,255,.025);
|
|
27
|
+
--blue:#0a84ff; --cyan:#64d2ff; --indigo:#5e5ce6; --purple:#bf5af2;
|
|
28
|
+
--pink:#ff375f; --orange:#ff9f0a; --yellow:#ffd60a; --green:#30d158;
|
|
29
|
+
--red:#ff453a;
|
|
30
|
+
--ease:cubic-bezier(.22,.61,.21,1);
|
|
31
|
+
--nav-h:60px;
|
|
32
|
+
--mono:"SF Mono",ui-monospace,Menlo,Monaco,monospace;
|
|
33
|
+
}
|
|
34
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
35
|
+
html,body{height:100%}
|
|
36
|
+
body{
|
|
37
|
+
background:var(--bg);color:var(--ink);
|
|
38
|
+
font-family:"SF Pro Display","SF Pro Text",-apple-system,BlinkMacSystemFont,"Helvetica Neue",Helvetica,Arial,sans-serif;
|
|
39
|
+
-webkit-font-smoothing:antialiased;
|
|
40
|
+
overflow:hidden; /* app shell owns its own scroll regions */
|
|
41
|
+
}
|
|
42
|
+
::selection{background:rgba(10,132,255,.4)}
|
|
43
|
+
.num{font-variant-numeric:tabular-nums}
|
|
44
|
+
code,.mono{font-family:var(--mono)}
|
|
45
|
+
code{font-size:.86em;background:rgba(255,255,255,.08);padding:2px 8px;border-radius:6px}
|
|
46
|
+
a{color:inherit}
|
|
47
|
+
::-webkit-scrollbar{width:10px;height:10px}
|
|
48
|
+
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.12);border-radius:6px;border:2px solid transparent;background-clip:content-box}
|
|
49
|
+
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22);background-clip:content-box}
|
|
50
|
+
::-webkit-scrollbar-track{background:transparent}
|
|
51
|
+
|
|
52
|
+
/* gradient text helpers (lifted from status/chat pages) */
|
|
53
|
+
.grad{background-clip:text;-webkit-background-clip:text;color:transparent}
|
|
54
|
+
.g-hero{background-image:linear-gradient(96deg,var(--orange) 0%,var(--pink) 30%,var(--purple) 62%,var(--blue) 100%)}
|
|
55
|
+
.g-blue{background-image:linear-gradient(94deg,var(--cyan),var(--blue) 55%,var(--indigo))}
|
|
56
|
+
.g-green{background-image:linear-gradient(94deg,#a8ff9e,var(--green) 45%,var(--cyan))}
|
|
57
|
+
.g-pink{background-image:linear-gradient(94deg,var(--orange),var(--pink) 55%,var(--purple))}
|
|
58
|
+
.g-purple{background-image:linear-gradient(94deg,var(--purple),var(--indigo) 55%,var(--blue))}
|
|
59
|
+
|
|
60
|
+
/* ─── ambient blooms behind everything ─── */
|
|
61
|
+
.bloom{position:fixed;inset:-25%;pointer-events:none;z-index:0;
|
|
62
|
+
background:
|
|
63
|
+
radial-gradient(55vw 42vh at 12% 6%, rgba(255,159,10,.13), transparent 68%),
|
|
64
|
+
radial-gradient(60vw 48vh at 88% 24%, rgba(94,92,230,.16), transparent 68%),
|
|
65
|
+
radial-gradient(50vw 40vh at 30% 96%, rgba(10,132,255,.10), transparent 68%);
|
|
66
|
+
filter:blur(74px);
|
|
67
|
+
transition:opacity 1.2s var(--ease);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ═══════════════════ top navigation ═══════════════════ */
|
|
71
|
+
nav{
|
|
72
|
+
position:fixed;top:0;left:0;right:0;height:var(--nav-h);z-index:50;
|
|
73
|
+
display:flex;align-items:center;gap:18px;padding:0 22px;
|
|
74
|
+
border-bottom:1px solid var(--hairline);
|
|
75
|
+
background:rgba(0,0,0,.62);backdrop-filter:saturate(180%) blur(22px);
|
|
76
|
+
-webkit-backdrop-filter:saturate(180%) blur(22px);
|
|
77
|
+
}
|
|
78
|
+
nav .brand{font-size:18px;font-weight:700;letter-spacing:-.02em;text-decoration:none;
|
|
79
|
+
white-space:nowrap;flex:0 0 auto}
|
|
80
|
+
nav .tabs{display:flex;gap:2px;flex:1 1 auto;overflow-x:auto;scrollbar-width:none}
|
|
81
|
+
nav .tabs::-webkit-scrollbar{display:none}
|
|
82
|
+
nav .tab{
|
|
83
|
+
appearance:none;background:none;border:none;cursor:pointer;
|
|
84
|
+
color:var(--dim);font:600 14px/1 inherit;letter-spacing:-.01em;
|
|
85
|
+
padding:8px 14px;border-radius:10px;text-decoration:none;white-space:nowrap;
|
|
86
|
+
transition:color .22s var(--ease),background .22s var(--ease)}
|
|
87
|
+
nav .tab:hover{color:var(--ink)}
|
|
88
|
+
nav .tab.active{color:var(--ink);background:rgba(255,255,255,.08)}
|
|
89
|
+
nav .right{display:flex;align-items:center;gap:12px;flex:0 0 auto}
|
|
90
|
+
nav .model{font:12px var(--mono);color:var(--dim);
|
|
91
|
+
background:var(--card);border:1px solid var(--hairline);border-radius:8px;padding:5px 10px;
|
|
92
|
+
max-width:34vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
93
|
+
.pill{display:inline-flex;align-items:center;gap:8px;
|
|
94
|
+
border:1px solid var(--hairline);border-radius:999px;padding:6px 13px;
|
|
95
|
+
font-size:12.5px;font-weight:600;color:var(--dim);letter-spacing:.01em;white-space:nowrap}
|
|
96
|
+
.pill .dot{width:8px;height:8px;border-radius:50%;background:var(--dimmer);
|
|
97
|
+
box-shadow:0 0 0 transparent;transition:all .4s}
|
|
98
|
+
.pill.ok .dot{background:var(--green);box-shadow:0 0 11px var(--green)}
|
|
99
|
+
.pill.ok{color:var(--ink)}
|
|
100
|
+
#chat-think{display:none;cursor:pointer;gap:7px;padding:5px 11px;font-size:12px;user-select:none;-webkit-user-select:none;
|
|
101
|
+
transition:color .22s var(--ease),border-color .22s var(--ease)}
|
|
102
|
+
#chat-think .dot{width:7px;height:7px}
|
|
103
|
+
#chat-think:hover{border-color:rgba(255,255,255,.28)}
|
|
104
|
+
#chat-think.think-on{color:var(--ink)}
|
|
105
|
+
#chat-think.think-on .dot{background:var(--blue);box-shadow:0 0 11px var(--blue)}
|
|
106
|
+
.pill.bad .dot{background:var(--red);box-shadow:0 0 11px var(--red)}
|
|
107
|
+
.pill.warn .dot{background:var(--orange);box-shadow:0 0 11px var(--orange)}
|
|
108
|
+
#nav-download{color:var(--ink);font-family:var(--mono);font-size:11.5px;font-weight:500}
|
|
109
|
+
#nav-download .dot{background:var(--orange);box-shadow:0 0 11px var(--orange);animation:dlpulse 1.4s ease-in-out infinite}
|
|
110
|
+
@keyframes dlpulse{0%,100%{opacity:.4}50%{opacity:1}}
|
|
111
|
+
.hf-gear{appearance:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;
|
|
112
|
+
width:34px;height:34px;border-radius:10px;background:var(--card);border:1px solid var(--hairline);
|
|
113
|
+
color:var(--dim);flex:0 0 auto;transition:color .22s var(--ease),background .22s var(--ease),border-color .22s var(--ease)}
|
|
114
|
+
.hf-gear:hover{color:var(--ink);background:var(--card-hover);border-color:rgba(255,255,255,.28)}
|
|
115
|
+
.hf-gear.saved{color:var(--green);border-color:rgba(48,209,88,.4)}
|
|
116
|
+
|
|
117
|
+
/* ─── HF settings overlay ─── */
|
|
118
|
+
#hf-overlay{position:fixed;inset:0;z-index:200;display:none;align-items:flex-start;justify-content:center;
|
|
119
|
+
background:rgba(0,0,0,.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);padding:calc(var(--nav-h) + 24px) 20px 20px}
|
|
120
|
+
#hf-overlay.open{display:flex;animation:fade .3s var(--ease)}
|
|
121
|
+
#hf-modal{width:min(460px,100%);background:rgba(22,22,24,.96);border:1px solid var(--hairline);border-radius:20px;
|
|
122
|
+
padding:24px 26px;box-shadow:0 30px 80px rgba(0,0,0,.6)}
|
|
123
|
+
.hf-modal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
|
|
124
|
+
.hf-modal-title{font-size:20px;font-weight:700;letter-spacing:-.02em}
|
|
125
|
+
.hf-modal-sub{font-size:13.5px;color:var(--dim);margin-bottom:20px;line-height:1.5}
|
|
126
|
+
.hf-close{appearance:none;background:none;border:none;cursor:pointer;color:var(--dim);font-size:16px;
|
|
127
|
+
width:30px;height:30px;border-radius:8px;transition:color .2s,background .2s}
|
|
128
|
+
.hf-close:hover{color:var(--ink);background:var(--card-hover)}
|
|
129
|
+
|
|
130
|
+
/* ─── inline push-to-hub panel (in done-step cards) ─── */
|
|
131
|
+
.pushpanel{margin-top:14px;border-top:1px solid var(--hairline-soft);padding-top:14px}
|
|
132
|
+
.pushpanel .field{margin-bottom:12px}
|
|
133
|
+
.pushpanel label.chk{display:flex;align-items:center;gap:9px;font-size:13.5px;color:var(--ink);cursor:pointer;font-weight:500}
|
|
134
|
+
.pushpanel label.chk input{width:auto;margin:0;accent-color:var(--blue)}
|
|
135
|
+
|
|
136
|
+
/* ═══════════════════ app shell / routing ═══════════════════ */
|
|
137
|
+
#app{position:fixed;inset:var(--nav-h) 0 0 0;z-index:1}
|
|
138
|
+
section[data-route]{display:none;position:absolute;inset:0}
|
|
139
|
+
section[data-route].active{display:block;animation:fade .5s var(--ease)}
|
|
140
|
+
@keyframes fade{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
|
141
|
+
|
|
142
|
+
/* a scrolling content column used by the doc-style sections */
|
|
143
|
+
.scroller{height:100%;overflow-y:auto}
|
|
144
|
+
.wrap{max-width:1180px;margin:0 auto;padding:46px 28px 120px}
|
|
145
|
+
.wrap.narrow{max-width:860px}
|
|
146
|
+
|
|
147
|
+
/* ─── shared typographic bits ─── */
|
|
148
|
+
.eyebrow{font-size:12.5px;font-weight:600;letter-spacing:.22em;text-transform:uppercase;color:var(--dim);margin-bottom:14px}
|
|
149
|
+
h1.title{font-size:clamp(38px,5.4vw,68px);font-weight:700;letter-spacing:-.032em;line-height:1.02}
|
|
150
|
+
.lead{font-size:clamp(15px,1.4vw,19px);line-height:1.55;color:var(--dim);max-width:66ch;margin-top:18px}
|
|
151
|
+
.lead strong{color:var(--ink);font-weight:600}
|
|
152
|
+
h2{font-size:clamp(26px,3.6vw,46px);font-weight:700;letter-spacing:-.026em;line-height:1.06}
|
|
153
|
+
h2 .soft{color:var(--dimmer)}
|
|
154
|
+
h3.kicker{font-size:12.5px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin-bottom:14px}
|
|
155
|
+
|
|
156
|
+
/* ─── reveals ─── */
|
|
157
|
+
[data-reveal]{opacity:0;transform:translateY(22px);
|
|
158
|
+
transition:opacity .8s var(--ease),transform .8s var(--ease);transition-delay:calc(var(--d,0)*1ms)}
|
|
159
|
+
.lit [data-reveal]{opacity:1;transform:none}
|
|
160
|
+
@media (prefers-reduced-motion:reduce){[data-reveal]{transition:none;opacity:1;transform:none}*{scroll-behavior:auto}}
|
|
161
|
+
|
|
162
|
+
/* ─── cards ─── */
|
|
163
|
+
.grid{display:grid;gap:14px}
|
|
164
|
+
.g4{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
|
|
165
|
+
.card{background:var(--card);border:1px solid var(--hairline);border-radius:20px;
|
|
166
|
+
padding:24px 26px;transition:background .3s var(--ease),border-color .3s var(--ease)}
|
|
167
|
+
.card:hover{background:var(--card-hover)}
|
|
168
|
+
.card h3{font-size:12.5px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin-bottom:14px}
|
|
169
|
+
.stat{font-size:clamp(28px,3.2vw,42px);font-weight:700;letter-spacing:-.025em;line-height:1.05}
|
|
170
|
+
.stat small{font-size:.42em;font-weight:600;color:var(--dim);letter-spacing:0;margin-left:6px}
|
|
171
|
+
.card .cap{margin-top:8px;font-size:13.5px;color:var(--dim);line-height:1.5}
|
|
172
|
+
.kv{display:flex;justify-content:space-between;gap:10px;padding:5px 0;font-size:14.5px;border-bottom:1px solid var(--hairline-soft)}
|
|
173
|
+
.kv:last-child{border-bottom:none}
|
|
174
|
+
.kv b{font-weight:500;color:var(--dim)}
|
|
175
|
+
.kv span{font-weight:600}
|
|
176
|
+
.meter{height:5px;border-radius:3px;background:rgba(255,255,255,.09);margin-top:16px;overflow:hidden}
|
|
177
|
+
.meter i{display:block;height:100%;border-radius:3px;width:0%;
|
|
178
|
+
background:linear-gradient(90deg,var(--cyan),var(--blue));transition:width .8s var(--ease)}
|
|
179
|
+
.meter.warm i{background:linear-gradient(90deg,var(--orange),var(--pink))}
|
|
180
|
+
.meter.green i{background:linear-gradient(90deg,#a8ff9e,var(--green))}
|
|
181
|
+
|
|
182
|
+
/* ─── tables ─── */
|
|
183
|
+
table{width:100%;border-collapse:collapse;font-size:14.5px}
|
|
184
|
+
th,td{text-align:right;padding:13px 16px;border-bottom:1px solid var(--hairline-soft)}
|
|
185
|
+
th:first-child,td:first-child{text-align:left}
|
|
186
|
+
td:not(:first-child),th:not(:first-child){white-space:nowrap}
|
|
187
|
+
th{font-size:11.5px;font-weight:600;letter-spacing:.13em;text-transform:uppercase;color:var(--dim)}
|
|
188
|
+
tr:last-child td{border-bottom:none}
|
|
189
|
+
tbody tr{transition:background .2s}
|
|
190
|
+
tbody tr:hover{background:rgba(255,255,255,.04)}
|
|
191
|
+
td.fit-yes{color:var(--green);font-weight:600}
|
|
192
|
+
td.fit-no{color:var(--dimmer)}
|
|
193
|
+
tr.you td{background:rgba(10,132,255,.10)}
|
|
194
|
+
tr.you td:first-child{border-left:3px solid var(--blue);border-radius:2px}
|
|
195
|
+
.tablecard{padding:8px 6px}
|
|
196
|
+
|
|
197
|
+
/* ─── forms (used by all three wizards) ─── */
|
|
198
|
+
.field{margin-bottom:18px}
|
|
199
|
+
.field>label{display:block;font-size:13px;font-weight:600;color:var(--ink);margin-bottom:8px;letter-spacing:-.01em}
|
|
200
|
+
.field .hint{font-size:12.5px;color:var(--dim);margin-top:7px;line-height:1.5}
|
|
201
|
+
input[type=text],input[type=number],input[type=password],textarea,select{
|
|
202
|
+
width:100%;background:var(--card);border:1px solid var(--hairline);border-radius:12px;
|
|
203
|
+
color:var(--ink);font:14.5px/1.5 inherit;padding:11px 14px;outline:none;
|
|
204
|
+
transition:border-color .22s var(--ease),background .22s var(--ease)}
|
|
205
|
+
input::placeholder,textarea::placeholder{color:var(--dimmer)}
|
|
206
|
+
input:focus,textarea:focus,select:focus{border-color:rgba(10,132,255,.55);background:var(--card-hover)}
|
|
207
|
+
textarea{resize:vertical;min-height:96px;font-family:var(--mono);font-size:13px}
|
|
208
|
+
select{appearance:none;-webkit-appearance:none;cursor:pointer;
|
|
209
|
+
background-image:url("data:image/svg+xml;charset=US-ASCII,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%2386868b' d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
|
|
210
|
+
background-repeat:no-repeat;background-position:right 14px center;padding-right:34px}
|
|
211
|
+
.hpgrid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
|
212
|
+
.hpgrid .field{margin-bottom:0}
|
|
213
|
+
input[type=range]{width:100%;accent-color:var(--blue);background:transparent;cursor:pointer;height:24px}
|
|
214
|
+
|
|
215
|
+
/* segmented control */
|
|
216
|
+
.seg{display:inline-flex;background:var(--card);border:1px solid var(--hairline);border-radius:12px;padding:3px;gap:3px}
|
|
217
|
+
.seg button{appearance:none;background:none;border:none;cursor:pointer;color:var(--dim);
|
|
218
|
+
font:600 13.5px/1 inherit;padding:9px 18px;border-radius:9px;transition:all .22s var(--ease)}
|
|
219
|
+
.seg button.on{background:rgba(255,255,255,.12);color:var(--ink)}
|
|
220
|
+
.seg button:hover:not(.on){color:var(--ink)}
|
|
221
|
+
|
|
222
|
+
/* buttons */
|
|
223
|
+
.btn{appearance:none;border:none;cursor:pointer;font:600 14px/1 inherit;border-radius:13px;
|
|
224
|
+
padding:0 22px;height:46px;display:inline-flex;align-items:center;justify-content:center;gap:8px;
|
|
225
|
+
transition:filter .2s,opacity .2s,background .22s var(--ease)}
|
|
226
|
+
.btn.primary{background:linear-gradient(120deg,var(--blue),var(--purple));color:#fff}
|
|
227
|
+
.btn.primary:hover{filter:brightness(1.13)}
|
|
228
|
+
.btn.ghost{background:var(--card);border:1px solid var(--hairline);color:var(--ink)}
|
|
229
|
+
.btn.ghost:hover{background:var(--card-hover)}
|
|
230
|
+
.btn:disabled{filter:grayscale(.7) brightness(.55);cursor:default;opacity:.7}
|
|
231
|
+
.btn.sm{height:38px;padding:0 16px;font-size:13px;border-radius:11px}
|
|
232
|
+
.btnrow{display:flex;gap:10px;margin-top:24px;flex-wrap:wrap;align-items:center}
|
|
233
|
+
|
|
234
|
+
/* flash / status banners */
|
|
235
|
+
.flash{border-radius:14px;padding:13px 16px;font-size:14px;line-height:1.5;border:1px solid;margin-top:14px}
|
|
236
|
+
.flash.ok{background:rgba(48,209,88,.10);border-color:rgba(48,209,88,.35);color:#b8f5c4}
|
|
237
|
+
.flash.warn{background:rgba(255,159,10,.10);border-color:rgba(255,159,10,.35);color:#ffe0a8}
|
|
238
|
+
.flash.err{background:rgba(255,69,58,.10);border-color:rgba(255,69,58,.35);color:#ffb8b3}
|
|
239
|
+
.flash code{background:rgba(0,0,0,.3)}
|
|
240
|
+
.flash strong{color:var(--ink)}
|
|
241
|
+
|
|
242
|
+
.soon{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600;letter-spacing:.06em;
|
|
243
|
+
text-transform:uppercase;color:var(--dimmer);border:1px solid var(--hairline-soft);border-radius:999px;padding:3px 10px}
|
|
244
|
+
|
|
245
|
+
/* step indicator */
|
|
246
|
+
.steps{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 30px;font:600 12px var(--mono);
|
|
247
|
+
text-transform:uppercase;letter-spacing:.05em;color:var(--dimmer)}
|
|
248
|
+
.steps .s{display:inline-flex;align-items:center;gap:8px}
|
|
249
|
+
.steps .s .n{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;
|
|
250
|
+
border:1px solid var(--hairline);font-size:11px;color:var(--dim);transition:all .3s var(--ease)}
|
|
251
|
+
.steps .s.done .n{background:rgba(255,255,255,.10);color:var(--ink);border-color:transparent}
|
|
252
|
+
.steps .s.cur .n{background:linear-gradient(120deg,var(--blue),var(--purple));color:#fff;border-color:transparent}
|
|
253
|
+
.steps .s.cur{color:var(--ink)}
|
|
254
|
+
.steps .s.done{color:var(--dim)}
|
|
255
|
+
.steps .arrow{color:var(--dimmer);margin:0 2px}
|
|
256
|
+
|
|
257
|
+
/* progress + log shared by every job stream */
|
|
258
|
+
.progressbar{height:8px;background:rgba(255,255,255,.09);border-radius:5px;overflow:hidden}
|
|
259
|
+
.progressbar i{display:block;height:100%;width:0%;border-radius:5px;
|
|
260
|
+
background:linear-gradient(90deg,var(--cyan),var(--blue));transition:width .5s var(--ease)}
|
|
261
|
+
.progressbar.green i{background:linear-gradient(90deg,#a8ff9e,var(--green))}
|
|
262
|
+
.progmeta{display:flex;justify-content:space-between;font-size:12.5px;color:var(--dim);margin-top:8px}
|
|
263
|
+
.logbox{margin-top:18px;background:rgba(0,0,0,.42);border:1px solid var(--hairline-soft);border-radius:14px;
|
|
264
|
+
padding:14px 16px;max-height:340px;overflow-y:auto;font:11.5px/1.55 var(--mono);color:var(--dim);white-space:pre-wrap;word-break:break-word}
|
|
265
|
+
.logbox:empty::before{content:"waiting for output…";color:var(--dimmer)}
|
|
266
|
+
|
|
267
|
+
/* shimmer skeletons */
|
|
268
|
+
.shimmer{background:linear-gradient(100deg,rgba(255,255,255,.04) 30%,rgba(255,255,255,.10) 50%,rgba(255,255,255,.04) 70%);
|
|
269
|
+
background-size:200% 100%;animation:sh 1.4s ease-in-out infinite;border-radius:8px;color:transparent!important}
|
|
270
|
+
@keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}}
|
|
271
|
+
|
|
272
|
+
/* toasts */
|
|
273
|
+
#toasts{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:100;
|
|
274
|
+
display:flex;flex-direction:column;gap:10px;align-items:center;pointer-events:none}
|
|
275
|
+
.toast{background:rgba(30,30,32,.92);backdrop-filter:blur(20px);border:1px solid var(--hairline);
|
|
276
|
+
border-radius:13px;padding:12px 18px;font-size:14px;font-weight:500;box-shadow:0 16px 50px rgba(0,0,0,.6);
|
|
277
|
+
animation:toastIn .4s var(--ease);max-width:min(560px,90vw)}
|
|
278
|
+
.toast.err{border-color:rgba(255,69,58,.5)}
|
|
279
|
+
.toast.ok{border-color:rgba(48,209,88,.5)}
|
|
280
|
+
@keyframes toastIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
|
|
281
|
+
|
|
282
|
+
/* empty state */
|
|
283
|
+
.empty{text-align:center;color:var(--dim);padding:60px 20px}
|
|
284
|
+
.empty h3{font-size:18px;font-weight:600;color:var(--ink);margin-bottom:8px;letter-spacing:-.01em}
|
|
285
|
+
.empty p{font-size:14px;max-width:46ch;margin:0 auto}
|
|
286
|
+
|
|
287
|
+
/* ═══════════════════ CHAT ═══════════════════ */
|
|
288
|
+
#s-chat{display:none}
|
|
289
|
+
#s-chat.active{display:flex!important;flex-direction:row}
|
|
290
|
+
#chat-main{flex:1;min-width:0;display:flex;flex-direction:column}
|
|
291
|
+
/* recent-chats sidebar */
|
|
292
|
+
#chat-sidebar{flex:0 0 252px;display:flex;flex-direction:column;gap:8px;padding:14px 12px;
|
|
293
|
+
border-right:1px solid var(--hairline);overflow-y:auto;background:var(--card-2)}
|
|
294
|
+
#chat-new{width:100%;text-align:left;padding:10px 13px;border-radius:11px;cursor:pointer;
|
|
295
|
+
background:var(--card);border:1px solid var(--hairline);color:var(--ink);font-size:13.5px;font-weight:600;
|
|
296
|
+
transition:background .15s,border-color .15s}
|
|
297
|
+
#chat-new:hover{background:var(--card-hover);border-color:rgba(10,132,255,.5)}
|
|
298
|
+
.sesslist{display:flex;flex-direction:column;gap:2px;margin-top:4px}
|
|
299
|
+
.sess{position:relative;padding:9px 50px 9px 11px;border-radius:10px;cursor:pointer;border:1px solid transparent}
|
|
300
|
+
.sess:hover{background:var(--card)}
|
|
301
|
+
.sess.active{background:var(--card);border-color:var(--hairline)}
|
|
302
|
+
.sess .stitle{font-size:13px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
303
|
+
.sess .smeta{font-size:11px;color:var(--dim);margin-top:2px;display:flex;gap:5px;align-items:center;white-space:nowrap}
|
|
304
|
+
.sess .sbtn{position:absolute;top:7px;width:20px;height:20px;border:none;background:transparent;color:var(--dim);
|
|
305
|
+
cursor:pointer;border-radius:6px;opacity:0;font-size:12px;line-height:1;transition:opacity .12s,background .12s,color .12s}
|
|
306
|
+
.sess:hover .sbtn{opacity:.85}
|
|
307
|
+
.sess .sfork{right:28px}
|
|
308
|
+
.sess .sdel{right:6px}
|
|
309
|
+
.sess .sfork:hover{background:var(--card-hover);color:var(--ink);opacity:1}
|
|
310
|
+
.sess .sdel:hover{background:rgba(255,69,58,.18);color:var(--red);opacity:1}
|
|
311
|
+
.sessempty{color:var(--dim);font-size:12px;padding:8px 11px;line-height:1.5}
|
|
312
|
+
@media (max-width:760px){#chat-sidebar{display:none}}
|
|
313
|
+
#chat-scroll{flex:1;overflow-y:auto}
|
|
314
|
+
#chat-thread{max-width:840px;margin:0 auto;padding:30px 22px 16px;display:flex;flex-direction:column;gap:18px}
|
|
315
|
+
.msg{display:flex;flex-direction:column;gap:6px;max-width:90%}
|
|
316
|
+
.msg .who{font-size:11px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--dimmer)}
|
|
317
|
+
.msg .bubble{border-radius:16px;padding:13px 17px;line-height:1.6;word-break:break-word;overflow-wrap:anywhere}
|
|
318
|
+
.msg.user{align-self:flex-end;align-items:flex-end}
|
|
319
|
+
.msg.user .bubble{background:linear-gradient(120deg,rgba(10,132,255,.26),rgba(94,92,230,.26));border:1px solid rgba(10,132,255,.35)}
|
|
320
|
+
.msg.assistant{align-self:flex-start;width:100%;max-width:840px}
|
|
321
|
+
.msg.assistant .bubble{background:var(--card);border:1px solid var(--hairline)}
|
|
322
|
+
.msg .meta{font-size:11.5px;color:var(--dimmer);font-variant-numeric:tabular-nums}
|
|
323
|
+
.msg .meta b{color:var(--green);font-weight:600}
|
|
324
|
+
.thinkbox{margin:0 0 12px;border:1px solid rgba(10,132,255,.18);border-radius:12px;background:rgba(10,132,255,.06);overflow:hidden}
|
|
325
|
+
.thinkbox summary{cursor:pointer;padding:7px 10px;font-size:12px;font-weight:600;color:var(--dim);user-select:none}
|
|
326
|
+
.thinkbody{padding:0 12px 10px;color:var(--dim);font-size:.92em;white-space:pre-wrap}
|
|
327
|
+
/* markdown-ish rendering inside bubbles */
|
|
328
|
+
.bubble p{margin:0 0 10px}.bubble p:last-child{margin-bottom:0}
|
|
329
|
+
.bubble ul,.bubble ol{margin:0 0 10px;padding-left:22px}
|
|
330
|
+
.bubble li{margin:3px 0}
|
|
331
|
+
.bubble strong{font-weight:700;color:var(--ink)}
|
|
332
|
+
.bubble em{font-style:italic}
|
|
333
|
+
.bubble code{font-family:var(--mono);font-size:.86em;background:rgba(255,255,255,.10);padding:2px 7px;border-radius:6px}
|
|
334
|
+
.bubble pre{background:rgba(0,0,0,.45);border:1px solid var(--hairline-soft);border-radius:12px;
|
|
335
|
+
padding:13px 15px;overflow-x:auto;margin:10px 0;font:12.5px/1.6 var(--mono)}
|
|
336
|
+
.bubble pre code{background:none;padding:0;font-size:inherit}
|
|
337
|
+
.bubble h1,.bubble h2,.bubble h3,.bubble h4,.bubble h5,.bubble h6{font-weight:700;margin:14px 0 7px;letter-spacing:-.01em;line-height:1.3}
|
|
338
|
+
.bubble h1{font-size:1.28em}.bubble h2{font-size:1.16em}.bubble h3{font-size:1.06em}
|
|
339
|
+
.bubble h4,.bubble h5,.bubble h6{font-size:1em;color:var(--dim)}
|
|
340
|
+
.bubble h1:first-child,.bubble h2:first-child,.bubble h3:first-child{margin-top:0}
|
|
341
|
+
.bubble a{color:var(--cyan);text-decoration:none;border-bottom:1px solid rgba(100,210,255,.34)}
|
|
342
|
+
.bubble a:hover{border-bottom-color:var(--cyan)}
|
|
343
|
+
.bubble del{opacity:.55}
|
|
344
|
+
.bubble blockquote{margin:9px 0;padding:3px 14px;border-left:3px solid var(--hairline);color:var(--dim)}
|
|
345
|
+
.bubble blockquote p:last-child{margin-bottom:0}
|
|
346
|
+
.bubble hr{border:none;border-top:1px solid var(--hairline);margin:15px 0}
|
|
347
|
+
.bubble li.task{list-style:none;margin-left:-18px}
|
|
348
|
+
.bubble li.task input{margin-right:7px;accent-color:var(--blue);vertical-align:-1px}
|
|
349
|
+
.bubble .md-tablewrap{overflow-x:auto;margin:11px 0}
|
|
350
|
+
.bubble table.md-table{width:100%;border-collapse:collapse;font-size:.92em}
|
|
351
|
+
.bubble table.md-table th,.bubble table.md-table td{border:1px solid var(--hairline-soft);padding:6px 11px;text-align:left}
|
|
352
|
+
.bubble table.md-table th{background:rgba(255,255,255,.05);font-weight:600;color:var(--ink)}
|
|
353
|
+
.bubble .codeblock{margin:11px 0;border:1px solid var(--hairline-soft);border-radius:12px;overflow:hidden;background:rgba(0,0,0,.45)}
|
|
354
|
+
.bubble .cbhead{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:5px 9px 5px 13px;background:rgba(255,255,255,.04);border-bottom:1px solid var(--hairline-soft)}
|
|
355
|
+
.bubble .cblang{font:11px/1 var(--mono);color:var(--dim);letter-spacing:.02em}
|
|
356
|
+
.bubble .cbcopy{font:11px/1 var(--mono);color:var(--dim);background:transparent;border:1px solid var(--hairline);border-radius:6px;padding:4px 10px;cursor:pointer;transition:color .15s,border-color .15s}
|
|
357
|
+
.bubble .cbcopy:hover{color:var(--ink);border-color:var(--dim)}
|
|
358
|
+
.bubble .codeblock pre{margin:0;border:none;border-radius:0;background:transparent}
|
|
359
|
+
.cursor{display:inline-block;width:8px;height:17px;vertical-align:-3px;border-radius:2px;
|
|
360
|
+
background:linear-gradient(180deg,var(--pink),var(--purple));animation:blink 1s steps(2) infinite}
|
|
361
|
+
@keyframes blink{50%{opacity:0}}
|
|
362
|
+
|
|
363
|
+
/* tool cards */
|
|
364
|
+
.tool{margin:10px 0;border:1px solid var(--hairline);border-radius:14px;overflow:hidden;background:var(--card-2)}
|
|
365
|
+
.tool .thead{display:flex;align-items:center;gap:10px;padding:11px 15px;cursor:pointer;user-select:none;transition:background .2s}
|
|
366
|
+
.tool .thead:hover{background:rgba(255,255,255,.04)}
|
|
367
|
+
.tool .ticon{width:26px;height:26px;border-radius:8px;display:flex;align-items:center;justify-content:center;
|
|
368
|
+
font-size:13px;background:rgba(94,92,230,.18);color:var(--cyan);flex:0 0 auto}
|
|
369
|
+
.tool .tname{font:600 13.5px var(--mono);color:var(--ink)}
|
|
370
|
+
.tool .targs{font:12px var(--mono);color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1 1 auto}
|
|
371
|
+
.tool .tstat{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;flex:0 0 auto;display:flex;align-items:center;gap:6px}
|
|
372
|
+
.tool .tstat .sdot{width:7px;height:7px;border-radius:50%}
|
|
373
|
+
.tool.running .tstat{color:var(--cyan)}.tool.running .sdot{background:var(--cyan);box-shadow:0 0 8px var(--cyan);animation:blink 1.1s infinite}
|
|
374
|
+
.tool.ok .tstat{color:var(--green)}.tool.ok .sdot{background:var(--green)}
|
|
375
|
+
.tool.fail .tstat{color:var(--red)}.tool.fail .sdot{background:var(--red)}
|
|
376
|
+
.tool .caret{color:var(--dimmer);transition:transform .25s var(--ease);flex:0 0 auto}
|
|
377
|
+
.tool.open .caret{transform:rotate(90deg)}
|
|
378
|
+
.tool .tbody{display:none;border-top:1px solid var(--hairline-soft);padding:12px 15px}
|
|
379
|
+
.tool.open .tbody{display:block}
|
|
380
|
+
.tool .blk{margin-bottom:10px}.tool .blk:last-child{margin-bottom:0}
|
|
381
|
+
.tool .blbl{font-size:10.5px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--dimmer);margin-bottom:5px}
|
|
382
|
+
.tool pre{background:rgba(0,0,0,.4);border-radius:9px;padding:10px 12px;overflow-x:auto;
|
|
383
|
+
font:12px/1.55 var(--mono);color:var(--dim);white-space:pre-wrap;word-break:break-word;max-height:260px;overflow-y:auto}
|
|
384
|
+
|
|
385
|
+
/* approval dialog */
|
|
386
|
+
.approval{margin:12px 0;border:1px solid rgba(255,159,10,.45);border-radius:16px;overflow:hidden;
|
|
387
|
+
background:rgba(255,159,10,.06);animation:fade .4s var(--ease)}
|
|
388
|
+
.approval .ahead{display:flex;align-items:center;gap:10px;padding:13px 16px;border-bottom:1px solid rgba(255,159,10,.25)}
|
|
389
|
+
.approval .ahead .ai{width:28px;height:28px;border-radius:8px;display:flex;align-items:center;justify-content:center;
|
|
390
|
+
background:rgba(255,159,10,.2);color:var(--orange);font-size:15px}
|
|
391
|
+
.approval .ahead .at{font-weight:700;font-size:14px}
|
|
392
|
+
.approval .ahead .as{font:12px var(--mono);color:var(--orange);margin-left:auto}
|
|
393
|
+
.approval .abody{padding:13px 16px}
|
|
394
|
+
.approval pre{background:rgba(0,0,0,.45);border-radius:10px;padding:12px 14px;overflow-x:auto;
|
|
395
|
+
font:12.5px/1.6 var(--mono);white-space:pre-wrap;word-break:break-word;max-height:280px;overflow-y:auto}
|
|
396
|
+
.approval .diffadd{color:var(--green)}
|
|
397
|
+
.approval .diffdel{color:var(--red)}
|
|
398
|
+
.approval .actions{display:flex;gap:10px;padding:0 16px 15px}
|
|
399
|
+
.approval .resolved{padding:11px 16px;font-size:13px;font-weight:600;display:flex;align-items:center;gap:8px}
|
|
400
|
+
.approval .resolved.allow{color:var(--green)}
|
|
401
|
+
.approval .resolved.deny{color:var(--red)}
|
|
402
|
+
|
|
403
|
+
/* chat hello / starters */
|
|
404
|
+
#chat-hello{max-width:840px;margin:6vh auto 0;padding:0 22px;text-align:center}
|
|
405
|
+
#chat-hello h1{font-size:clamp(34px,5vw,54px);font-weight:700;letter-spacing:-.03em;line-height:1.08}
|
|
406
|
+
#chat-hello p{margin-top:13px;color:var(--dim);font-size:15.5px}
|
|
407
|
+
#chat-hello .chips{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin-top:28px}
|
|
408
|
+
.chip{border:1px solid var(--hairline);background:var(--card);color:var(--dim);border-radius:999px;
|
|
409
|
+
padding:9px 16px;font-size:13.5px;cursor:pointer;transition:all .25s var(--ease)}
|
|
410
|
+
.chip:hover{color:var(--ink);border-color:rgba(255,255,255,.32);background:var(--card-hover)}
|
|
411
|
+
|
|
412
|
+
/* chat composer */
|
|
413
|
+
#chat-foot{flex:0 0 auto;border-top:1px solid var(--hairline);padding:14px 22px 16px;
|
|
414
|
+
background:rgba(0,0,0,.4);backdrop-filter:blur(16px)}
|
|
415
|
+
.composer{max-width:840px;margin:0 auto}
|
|
416
|
+
.queuebar{display:none;max-width:840px;margin:0 auto 10px;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dim)}
|
|
417
|
+
.queuebar .qtag{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--hairline);
|
|
418
|
+
border-radius:999px;padding:4px 11px;background:var(--card)}
|
|
419
|
+
.queuebar .qtag b{color:var(--ink);font-weight:600}
|
|
420
|
+
.inputrow{display:flex;gap:10px;align-items:flex-end}
|
|
421
|
+
#chat-box{flex:1;background:var(--card);border:1px solid var(--hairline);border-radius:16px;
|
|
422
|
+
color:var(--ink);font:15px/1.5 inherit;padding:13px 16px;resize:none;outline:none;
|
|
423
|
+
max-height:200px;min-height:50px;transition:border-color .25s}
|
|
424
|
+
#chat-box:focus{border-color:rgba(10,132,255,.5)}
|
|
425
|
+
#chat-send{background:linear-gradient(120deg,var(--blue),var(--purple));color:#fff;
|
|
426
|
+
border:none;border-radius:14px;height:50px;padding:0 22px;font:600 14px inherit;cursor:pointer;transition:filter .2s}
|
|
427
|
+
#chat-send:hover{filter:brightness(1.14)}
|
|
428
|
+
#chat-send:disabled{filter:grayscale(.8) brightness(.6);cursor:default}
|
|
429
|
+
#chat-stop{display:none;background:var(--card);border:1px solid var(--hairline);color:var(--ink);
|
|
430
|
+
border-radius:14px;height:50px;padding:0 20px;font:600 14px inherit;cursor:pointer}
|
|
431
|
+
#chat-stop:hover{background:var(--card-hover)}
|
|
432
|
+
.chat-hint{max-width:840px;margin:8px auto 0;font-size:11.5px;color:var(--dimmer);display:flex;justify-content:space-between;gap:10px}
|
|
433
|
+
.chat-hint .hint-right{display:flex;gap:12px;align-items:center}
|
|
434
|
+
#chat-context{font:11px/1 var(--mono);letter-spacing:.01em;white-space:nowrap}
|
|
435
|
+
/* attach (+) button + attachment chips */
|
|
436
|
+
#chat-attach-btn{flex:0 0 auto;width:50px;height:50px;border-radius:14px;background:var(--card);
|
|
437
|
+
border:1px solid var(--hairline);color:var(--ink);font-size:23px;line-height:1;cursor:pointer;transition:background .15s,border-color .15s}
|
|
438
|
+
#chat-attach-btn:hover{background:var(--card-hover);border-color:rgba(10,132,255,.5)}
|
|
439
|
+
.attach-row{display:none;flex-wrap:wrap;gap:8px;max-width:840px;width:100%;margin:0 auto 8px}
|
|
440
|
+
.attach-chip{display:flex;align-items:center;gap:7px;background:var(--card);border:1px solid var(--hairline);
|
|
441
|
+
border-radius:10px;padding:5px 9px;font-size:12px;max-width:240px}
|
|
442
|
+
.attach-chip img{width:34px;height:34px;object-fit:cover;border-radius:6px;display:block}
|
|
443
|
+
.attach-chip .att-ico{font-size:16px}
|
|
444
|
+
.attach-chip .att-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--ink)}
|
|
445
|
+
.attach-chip .att-x{border:none;background:transparent;color:var(--dim);cursor:pointer;font-size:12px;line-height:1;padding:0 2px}
|
|
446
|
+
.attach-chip .att-x:hover{color:var(--red)}
|
|
447
|
+
.composer.dragover{outline:2px dashed var(--blue);outline-offset:6px;border-radius:14px}
|
|
448
|
+
.msg-atts{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px}
|
|
449
|
+
.msg-att-img{max-width:220px;max-height:220px;border-radius:10px;display:block}
|
|
450
|
+
.msg-att-file{display:inline-flex;align-items:center;gap:5px;font-size:12px;background:rgba(255,255,255,.08);border-radius:8px;padding:5px 10px}
|
|
451
|
+
.msg.user .msg-text{white-space:pre-wrap;word-break:break-word}
|
|
452
|
+
|
|
453
|
+
/* loss chart */
|
|
454
|
+
.chartcard{margin-top:20px}
|
|
455
|
+
.chart-wrap{position:relative;background:rgba(0,0,0,.32);border:1px solid var(--hairline-soft);border-radius:14px;padding:14px;margin-top:6px}
|
|
456
|
+
.chart-legend{display:flex;gap:18px;font-size:12px;color:var(--dim);margin-bottom:8px}
|
|
457
|
+
.chart-legend .lg{display:inline-flex;align-items:center;gap:7px}
|
|
458
|
+
.chart-legend .sw{width:14px;height:3px;border-radius:2px}
|
|
459
|
+
.chart-stats{display:flex;gap:22px;flex-wrap:wrap;margin-top:12px;font-size:13px}
|
|
460
|
+
.chart-stats .cs b{display:block;font-size:11px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--dim);margin-bottom:3px}
|
|
461
|
+
.chart-stats .cs span{font-size:20px;font-weight:700;letter-spacing:-.02em}
|
|
462
|
+
|
|
463
|
+
@media (max-width:900px){
|
|
464
|
+
.hpgrid{grid-template-columns:1fr}
|
|
465
|
+
nav .model{display:none}
|
|
466
|
+
.wrap{padding:34px 18px 100px}
|
|
467
|
+
nav{gap:12px;padding:0 16px}
|
|
468
|
+
}
|
|
469
|
+
@media (max-width:560px){
|
|
470
|
+
nav .brand{font-size:16px}
|
|
471
|
+
nav .tab{padding:8px 10px;font-size:13px}
|
|
472
|
+
}
|
|
473
|
+
</style>
|
|
474
|
+
</head>
|
|
475
|
+
<body>
|
|
476
|
+
<div class="bloom" id="bloom"></div>
|
|
477
|
+
|
|
478
|
+
<!-- ═══════════════════ TOP NAV ═══════════════════ -->
|
|
479
|
+
<nav>
|
|
480
|
+
<a class="brand grad g-hero" href="#/chat">mlx-bun</a>
|
|
481
|
+
<div class="tabs" id="tabs">
|
|
482
|
+
<a class="tab" href="#/chat" data-tab="chat">Chat</a>
|
|
483
|
+
<a class="tab" href="#/quantize" data-tab="quantize">Quantize</a>
|
|
484
|
+
<a class="tab" href="#/finetune" data-tab="finetune">Fine-tune</a>
|
|
485
|
+
<a class="tab" href="#/dataset" data-tab="dataset">Build Dataset</a>
|
|
486
|
+
<a class="tab" href="#/status" data-tab="status">Status</a>
|
|
487
|
+
<a class="tab" href="/curves" data-tab="curves">Curves</a>
|
|
488
|
+
</div>
|
|
489
|
+
<div class="right">
|
|
490
|
+
<button class="hf-gear" id="nav-hf" title="Hugging Face token settings" aria-label="Hugging Face settings">
|
|
491
|
+
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
|
492
|
+
</button>
|
|
493
|
+
<span class="model" id="nav-model" title="active model">connecting…</span>
|
|
494
|
+
<span class="pill" id="nav-conn"><span class="dot"></span><span id="nav-conn-text">connecting…</span></span>
|
|
495
|
+
<span class="pill" id="nav-download" style="display:none" title="A more capable model is downloading in the background"><span class="dot"></span><span id="nav-download-text"></span></span>
|
|
496
|
+
</div>
|
|
497
|
+
</nav>
|
|
498
|
+
|
|
499
|
+
<!-- ═══════════════════ HF SETTINGS OVERLAY ═══════════════════ -->
|
|
500
|
+
<div id="hf-overlay">
|
|
501
|
+
<div id="hf-modal">
|
|
502
|
+
<div class="hf-modal-head">
|
|
503
|
+
<div class="hf-modal-title">Hugging Face</div>
|
|
504
|
+
<button class="hf-close" id="hf-close" aria-label="Close">✕</button>
|
|
505
|
+
</div>
|
|
506
|
+
<p class="hf-modal-sub" id="hf-state">Checking for a saved token…</p>
|
|
507
|
+
<div class="field" style="margin-bottom:10px">
|
|
508
|
+
<label>Write token</label>
|
|
509
|
+
<input type="password" id="hf-token-input" placeholder="hf_…" autocomplete="off" spellcheck="false">
|
|
510
|
+
<div class="hint">A <strong>write</strong> token from huggingface.co/settings/tokens. Stored locally at <code>~/.mlx-bun/hf.json</code> — never sent anywhere but your own Hub pushes.</div>
|
|
511
|
+
</div>
|
|
512
|
+
<div id="hf-settings-msg"></div>
|
|
513
|
+
<div class="btnrow" style="margin-top:14px">
|
|
514
|
+
<button class="btn primary sm" id="hf-save">Save token</button>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
|
|
519
|
+
<div id="app">
|
|
520
|
+
|
|
521
|
+
<!-- ═══════════════════ CHAT ═══════════════════ -->
|
|
522
|
+
<section data-route="chat" id="s-chat">
|
|
523
|
+
<aside id="chat-sidebar">
|
|
524
|
+
<button id="chat-new" title="Start a new chat (or type /new)">+ New chat</button>
|
|
525
|
+
<div id="chat-sessions" class="sesslist"></div>
|
|
526
|
+
</aside>
|
|
527
|
+
<div id="chat-main">
|
|
528
|
+
<div id="chat-scroll">
|
|
529
|
+
<div id="chat-hello">
|
|
530
|
+
<h1>Local. Private. <span class="grad g-hero">Yours.</span></h1>
|
|
531
|
+
<p id="chat-hello-sub">A full pi agent on this machine — local model, local tools, zero cloud. Every token below is generated here.</p>
|
|
532
|
+
<div class="chips">
|
|
533
|
+
<button class="chip" data-q="What is mlx-bun and what can it do?">What is mlx-bun?</button>
|
|
534
|
+
<button class="chip" data-q="How do I load a bigger or different model?">Load a bigger model</button>
|
|
535
|
+
<button class="chip" data-q="Is this really private? Where does my data go?">Is this private?</button>
|
|
536
|
+
<button class="chip" data-q="What can you help me with right now?">What can you do?</button>
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
<div id="chat-thread"></div>
|
|
540
|
+
</div>
|
|
541
|
+
<div id="chat-foot">
|
|
542
|
+
<div class="queuebar" id="chat-queue"></div>
|
|
543
|
+
<div class="composer">
|
|
544
|
+
<div id="chat-attach" class="attach-row"></div>
|
|
545
|
+
<div class="inputrow">
|
|
546
|
+
<button id="chat-attach-btn" class="iconbtn" title="Attach files or images" aria-label="Attach files">+</button>
|
|
547
|
+
<input type="file" id="chat-file-input" multiple style="display:none">
|
|
548
|
+
<textarea id="chat-box" rows="1" placeholder="Message the local agent… (Enter to send · Shift+Enter for newline)"></textarea>
|
|
549
|
+
<button id="chat-stop" title="Abort the current turn">Stop</button>
|
|
550
|
+
<button id="chat-send">Send</button>
|
|
551
|
+
</div>
|
|
552
|
+
<div class="chat-hint">
|
|
553
|
+
<span id="chat-status-line">Enter to send · Shift+Enter for a new line · type while it streams to steer</span>
|
|
554
|
+
<span class="hint-right">
|
|
555
|
+
<select id="chat-adapter" class="pill" title="LoRA adapter for this chat (none = base model)">
|
|
556
|
+
<option value="">no adapter</option>
|
|
557
|
+
</select>
|
|
558
|
+
<span id="chat-think" class="pill" role="button" tabindex="0" title="Toggle the model's reasoning"><span class="dot"></span>Thinking</span>
|
|
559
|
+
<span id="chat-context" title="Context window in use — auto-compacts before it fills"></span>
|
|
560
|
+
<span id="chat-tps"></span>
|
|
561
|
+
</span>
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
</section>
|
|
567
|
+
|
|
568
|
+
<!-- ═══════════════════ QUANTIZE ═══════════════════ -->
|
|
569
|
+
<section data-route="quantize" id="s-quantize">
|
|
570
|
+
<div class="scroller"><div class="wrap narrow lit">
|
|
571
|
+
<div class="eyebrow">Workflow · quantize</div>
|
|
572
|
+
<h1 class="title">Quantize <span class="grad g-blue">a model.</span></h1>
|
|
573
|
+
<p class="lead">Shrink any supported model to 4 or 8-bit with OptiQ-grade group quantization — smaller on disk,
|
|
574
|
+
faster to decode, and serveable the moment it's scanned. <strong>No Python in the path.</strong></p>
|
|
575
|
+
|
|
576
|
+
<div class="steps" id="q-steps"></div>
|
|
577
|
+
|
|
578
|
+
<!-- step 1: source -->
|
|
579
|
+
<div class="card" data-qstep="0">
|
|
580
|
+
<h2 style="font-size:24px;margin-bottom:4px">Source model</h2>
|
|
581
|
+
<p class="lead" style="margin:6px 0 22px;font-size:14.5px">Point at a Hugging Face repo id or a local snapshot path. Inspect first to confirm support and size.</p>
|
|
582
|
+
<div class="field">
|
|
583
|
+
<label>Model id or path</label>
|
|
584
|
+
<input type="text" id="q-model" placeholder="mlx-community/Qwen3-8B-bf16" autocomplete="off">
|
|
585
|
+
<div class="hint">Anything on huggingface.co, or an absolute path to a local snapshot.</div>
|
|
586
|
+
</div>
|
|
587
|
+
<div class="btnrow">
|
|
588
|
+
<button class="btn ghost" id="q-inspect">Inspect</button>
|
|
589
|
+
</div>
|
|
590
|
+
<div id="q-inspect-out"></div>
|
|
591
|
+
</div>
|
|
592
|
+
|
|
593
|
+
<!-- step 2: configure -->
|
|
594
|
+
<div class="card" data-qstep="1" style="display:none">
|
|
595
|
+
<h2 style="font-size:24px;margin-bottom:4px">Configure</h2>
|
|
596
|
+
<p class="lead" style="margin:6px 0 22px;font-size:14.5px">Lower bits = smaller and faster, with some quality cost. 4-bit is the balanced default.</p>
|
|
597
|
+
<div class="field">
|
|
598
|
+
<label>Precision</label>
|
|
599
|
+
<div class="seg" id="q-bits">
|
|
600
|
+
<button data-v="4" class="on">4-bit</button>
|
|
601
|
+
<button data-v="8">8-bit</button>
|
|
602
|
+
</div>
|
|
603
|
+
<div class="hint" id="q-bits-hint">4-bit · ~4× smaller than bf16 · the recommended default for local serving.</div>
|
|
604
|
+
</div>
|
|
605
|
+
<div class="field">
|
|
606
|
+
<label>Group size</label>
|
|
607
|
+
<div class="seg" id="q-gs">
|
|
608
|
+
<button data-v="64" class="on">64</button>
|
|
609
|
+
<button data-v="32">32</button>
|
|
610
|
+
</div>
|
|
611
|
+
<div class="hint">Weights per shared scale. 64 is standard; 32 trades a little size for slightly better fidelity.</div>
|
|
612
|
+
</div>
|
|
613
|
+
<div class="field">
|
|
614
|
+
<label>Bit allocation</label>
|
|
615
|
+
<div class="seg" id="q-mode">
|
|
616
|
+
<button data-v="uniform" class="on">Uniform bits</button>
|
|
617
|
+
<button data-v="mixed">Mixed (target bpw)</button>
|
|
618
|
+
</div>
|
|
619
|
+
<div class="hint">Uniform applies the bits above to every layer. Mixed runs OptiQ's sensitivity sweep + knapsack to allocate per-layer precision toward a target average.</div>
|
|
620
|
+
</div>
|
|
621
|
+
<div class="field" id="q-bpw-field" style="display:none">
|
|
622
|
+
<label>Target bits per weight · <span class="num" id="q-bpw-val">5.0</span></label>
|
|
623
|
+
<input type="range" id="q-bpw" min="3" max="8" step="0.5" value="5" style="accent-color:var(--blue)">
|
|
624
|
+
<div class="hint">OptiQ measures each layer's sensitivity, then solves a knapsack to pick per-layer 4/8-bit hitting this average bpw (~2–5 min). Lower = smaller; higher = closer to bf16.</div>
|
|
625
|
+
</div>
|
|
626
|
+
<div class="btnrow">
|
|
627
|
+
<button class="btn ghost" data-qback>Back</button>
|
|
628
|
+
<button class="btn primary" id="q-submit">Start quantize</button>
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
|
|
632
|
+
<!-- step 3: run -->
|
|
633
|
+
<div class="card" data-qstep="2" style="display:none">
|
|
634
|
+
<h2 style="font-size:24px;margin-bottom:16px">Running</h2>
|
|
635
|
+
<div class="progressbar"><i id="q-bar"></i></div>
|
|
636
|
+
<div class="progmeta"><span id="q-msg">Starting…</span><span id="q-pct">0%</span></div>
|
|
637
|
+
<div class="logbox" id="q-log"></div>
|
|
638
|
+
</div>
|
|
639
|
+
|
|
640
|
+
<!-- step 4: done -->
|
|
641
|
+
<div class="card" data-qstep="3" style="display:none">
|
|
642
|
+
<h2 style="font-size:24px;margin-bottom:6px"><span class="grad g-green">Done.</span></h2>
|
|
643
|
+
<p class="lead" style="margin:6px 0 18px;font-size:14.5px">Your quantized model is written to disk.</p>
|
|
644
|
+
<div class="field"><label>Output directory</label>
|
|
645
|
+
<pre class="logbox" id="q-out" style="max-height:none;margin-top:0"></pre></div>
|
|
646
|
+
<div class="flash ok">Run <code>mlx-bun scan</code> (or it's auto-picked up on restart) to make this model serveable from the Library.</div>
|
|
647
|
+
<div class="grid g4" style="margin-top:14px">
|
|
648
|
+
<div class="card"><h3>Push to Hugging Face</h3><div class="cap">Publish this quantized model to a Hugging Face repo.</div>
|
|
649
|
+
<div class="btnrow" style="margin-top:14px"><button class="btn ghost sm" id="q-push">Push to HF</button></div>
|
|
650
|
+
<div id="q-push-panel"></div>
|
|
651
|
+
</div>
|
|
652
|
+
</div>
|
|
653
|
+
<div class="btnrow"><button class="btn ghost" id="q-again">Quantize another</button></div>
|
|
654
|
+
</div>
|
|
655
|
+
</div></div>
|
|
656
|
+
</section>
|
|
657
|
+
|
|
658
|
+
<!-- ═══════════════════ FINE-TUNE ═══════════════════ -->
|
|
659
|
+
<section data-route="finetune" id="s-finetune">
|
|
660
|
+
<div class="scroller"><div class="wrap narrow lit">
|
|
661
|
+
<div class="eyebrow">Workflow · fine-tune</div>
|
|
662
|
+
<h1 class="title">Fine-tune <span class="grad g-green">a LoRA.</span></h1>
|
|
663
|
+
<p class="lead">Train a low-rank adapter on top of any local model — SFT or DPO — and watch the loss curve land
|
|
664
|
+
live. Hot-swappable onto the quantized base, <strong>no full reload.</strong></p>
|
|
665
|
+
|
|
666
|
+
<div class="steps" id="f-steps"></div>
|
|
667
|
+
|
|
668
|
+
<!-- step 1: base -->
|
|
669
|
+
<div class="card" data-fstep="0">
|
|
670
|
+
<h2 style="font-size:24px;margin-bottom:4px">Base model</h2>
|
|
671
|
+
<p class="lead" style="margin:6px 0 22px;font-size:14.5px">The model you're adapting. Point at a local model directory or snapshot path.</p>
|
|
672
|
+
<div class="field">
|
|
673
|
+
<label>Model directory / path</label>
|
|
674
|
+
<input type="text" id="f-model" placeholder="/abs/path/to/model or mlx-community/Qwen3-8B-4bit" autocomplete="off">
|
|
675
|
+
<div class="hint">An OptiQ quant or any supported local model works.</div>
|
|
676
|
+
</div>
|
|
677
|
+
<div class="btnrow"><button class="btn primary" id="f-next0">Continue</button></div>
|
|
678
|
+
</div>
|
|
679
|
+
|
|
680
|
+
<!-- step 2: dataset -->
|
|
681
|
+
<div class="card" data-fstep="1" style="display:none">
|
|
682
|
+
<h2 style="font-size:24px;margin-bottom:4px">Dataset</h2>
|
|
683
|
+
<p class="lead" style="margin:6px 0 22px;font-size:14.5px">A directory with <code>train.jsonl</code> (and optionally <code>valid.jsonl</code>).
|
|
684
|
+
Standard shapes accepted: <code>{"text":…}</code>, <code>{"prompt":…,"completion":…}</code>, <code>{"messages":[…]}</code>.</p>
|
|
685
|
+
<div class="field">
|
|
686
|
+
<label>Dataset directory</label>
|
|
687
|
+
<input type="text" id="f-data" placeholder="/abs/path/to/dataset" autocomplete="off">
|
|
688
|
+
</div>
|
|
689
|
+
<div class="btnrow"><button class="btn ghost" id="f-inspect">Inspect</button></div>
|
|
690
|
+
<div id="f-inspect-out"></div>
|
|
691
|
+
<div class="btnrow">
|
|
692
|
+
<button class="btn ghost" data-fback>Back</button>
|
|
693
|
+
<button class="btn primary" id="f-next1" disabled>Continue</button>
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
|
|
697
|
+
<!-- step 3: hyperparameters -->
|
|
698
|
+
<div class="card" data-fstep="2" style="display:none">
|
|
699
|
+
<h2 style="font-size:24px;margin-bottom:16px">Hyperparameters</h2>
|
|
700
|
+
<div class="field">
|
|
701
|
+
<label>Training objective</label>
|
|
702
|
+
<div class="seg" id="f-method">
|
|
703
|
+
<button data-v="sft" class="on">SFT</button>
|
|
704
|
+
<button data-v="dpo">DPO</button>
|
|
705
|
+
</div>
|
|
706
|
+
<div class="hint" id="f-method-hint">Supervised fine-tuning — data is messages or prompt+completion.</div>
|
|
707
|
+
</div>
|
|
708
|
+
|
|
709
|
+
<div id="f-dpo-extra" style="display:none">
|
|
710
|
+
<div class="flash warn" style="margin-bottom:18px">
|
|
711
|
+
<strong>DPO needs a lower learning rate.</strong> Default 5e-5 (~4× lower than SFT) with warmup. Too high and the preference loss blows out the reward margin. Confirm <em>chosen</em>/<em>rejected</em> are both valid completions of the same prompt.
|
|
712
|
+
</div>
|
|
713
|
+
<div class="hpgrid" style="margin-bottom:18px">
|
|
714
|
+
<div class="field"><label>DPO beta (KL strength)</label><input type="number" id="f-beta" value="0.1" step="0.01" min="0.01" max="1"></div>
|
|
715
|
+
<div class="field"><label>LR schedule</label>
|
|
716
|
+
<select id="f-sched"><option value="cosine">cosine (recommended)</option><option value="constant">constant</option><option value="linear">linear</option></select></div>
|
|
717
|
+
</div>
|
|
718
|
+
</div>
|
|
719
|
+
|
|
720
|
+
<div class="hpgrid">
|
|
721
|
+
<div class="field"><label>Rank</label><input type="number" id="f-rank" value="8" min="1" max="128"></div>
|
|
722
|
+
<div class="field"><label>Scale (alpha = rank × scale)</label><input type="number" id="f-scale" value="20" step="0.5" min="0"></div>
|
|
723
|
+
<div class="field"><label>Dropout</label><input type="number" id="f-dropout" value="0" step="0.01" min="0" max="0.5"></div>
|
|
724
|
+
<div class="field"><label>Target modules</label><input type="text" id="f-modules" value="q_proj,v_proj"></div>
|
|
725
|
+
<div class="field"><label>num_layers (-1 = all)</label><input type="number" id="f-layers" value="16" min="-1"></div>
|
|
726
|
+
<div class="field"><label>iters</label><input type="number" id="f-iters" value="500" min="10"></div>
|
|
727
|
+
<div class="field"><label>batch_size</label><input type="number" id="f-batch" value="1" min="1" max="16"></div>
|
|
728
|
+
<div class="field"><label>learning rate</label><input type="number" id="f-lr" value="0.0002" step="0.00001" min="0"></div>
|
|
729
|
+
<div class="field"><label>max_seq_length</label><input type="number" id="f-seq" value="1024" min="128"></div>
|
|
730
|
+
</div>
|
|
731
|
+
<div class="btnrow">
|
|
732
|
+
<button class="btn ghost" data-fback>Back</button>
|
|
733
|
+
<button class="btn primary" id="f-submit">Start training</button>
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
|
|
737
|
+
<!-- step 4: train -->
|
|
738
|
+
<div class="card" data-fstep="3" style="display:none">
|
|
739
|
+
<h2 style="font-size:24px;margin-bottom:16px">Training</h2>
|
|
740
|
+
<div class="progressbar green"><i id="f-bar"></i></div>
|
|
741
|
+
<div class="progmeta"><span id="f-msg">Starting…</span><span id="f-pct">0%</span></div>
|
|
742
|
+
|
|
743
|
+
<div class="chartcard">
|
|
744
|
+
<div class="chart-legend">
|
|
745
|
+
<span class="lg"><span class="sw" style="background:var(--green)"></span>train loss</span>
|
|
746
|
+
<span class="lg" id="f-leg-val" style="display:none"><span class="sw" style="background:var(--blue)"></span>val loss</span>
|
|
747
|
+
</div>
|
|
748
|
+
<div class="chart-wrap">
|
|
749
|
+
<svg id="f-chart" viewBox="0 0 600 200" preserveAspectRatio="none" style="width:100%;height:200px;display:block">
|
|
750
|
+
<text x="300" y="104" text-anchor="middle" fill="var(--dimmer)" font-size="13" id="f-chart-empty">waiting for the first metric…</text>
|
|
751
|
+
</svg>
|
|
752
|
+
</div>
|
|
753
|
+
<div class="chart-stats">
|
|
754
|
+
<div class="cs"><b>step</b><span class="num" id="f-step">—</span></div>
|
|
755
|
+
<div class="cs"><b>train loss</b><span class="num grad g-green" id="f-loss">—</span></div>
|
|
756
|
+
<div class="cs"><b>learning rate</b><span class="num" id="f-curlr">—</span></div>
|
|
757
|
+
<div class="cs"><b>tok/s</b><span class="num" id="f-curtps">—</span></div>
|
|
758
|
+
</div>
|
|
759
|
+
</div>
|
|
760
|
+
<div class="logbox" id="f-log"></div>
|
|
761
|
+
</div>
|
|
762
|
+
|
|
763
|
+
<!-- step 5: done -->
|
|
764
|
+
<div class="card" data-fstep="4" style="display:none">
|
|
765
|
+
<h2 style="font-size:24px;margin-bottom:6px"><span class="grad g-green">Done.</span></h2>
|
|
766
|
+
<p class="lead" style="margin:6px 0 18px;font-size:14.5px">Adapter trained and saved.</p>
|
|
767
|
+
<div class="field"><label>Adapter path</label>
|
|
768
|
+
<pre class="logbox" id="f-out" style="max-height:none;margin-top:0"></pre></div>
|
|
769
|
+
<div class="grid g4" style="margin-top:8px">
|
|
770
|
+
<div class="card"><h3>Merge into base</h3><div class="cap">Fold two adapters together (or an adapter into a base adapter) into the weights for a standalone model.</div>
|
|
771
|
+
<div class="field" style="margin-top:14px;margin-bottom:10px"><label>Adapter A</label><input type="text" id="f-merge-a" placeholder="/abs/path/to/adapter" autocomplete="off"></div>
|
|
772
|
+
<div class="field" style="margin-bottom:10px"><label>Adapter B</label><input type="text" id="f-merge-b" placeholder="/abs/path/to/other-adapter" autocomplete="off"></div>
|
|
773
|
+
<div class="btnrow" style="margin-top:0"><button class="btn ghost sm" id="f-merge-go">Merge</button></div>
|
|
774
|
+
<div id="f-merge-out"></div>
|
|
775
|
+
</div>
|
|
776
|
+
<div class="card"><h3>Export model dir</h3><div class="cap">Bundle base + adapter into one drop-in directory.</div>
|
|
777
|
+
<div class="field" style="margin-top:14px;margin-bottom:10px"><label>Base model</label><input type="text" id="f-exp-base" placeholder="/abs/path/to/base" autocomplete="off"></div>
|
|
778
|
+
<div class="field" style="margin-bottom:10px"><label>Adapter path</label><input type="text" id="f-exp-adapter" placeholder="/abs/path/to/adapter" autocomplete="off"></div>
|
|
779
|
+
<div class="btnrow" style="margin-top:0"><button class="btn ghost sm" id="f-exp-go">Export</button></div>
|
|
780
|
+
<div id="f-exp-out"></div>
|
|
781
|
+
</div>
|
|
782
|
+
<div class="card"><h3>Push to HF</h3><div class="cap">Publish the trained adapter to a Hugging Face repo.</div>
|
|
783
|
+
<div class="btnrow" style="margin-top:14px"><button class="btn ghost sm" id="f-push">Push to HF</button></div>
|
|
784
|
+
<div id="f-push-panel"></div>
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
787
|
+
<div class="btnrow"><button class="btn ghost" id="f-again">Train another</button></div>
|
|
788
|
+
</div>
|
|
789
|
+
</div></div>
|
|
790
|
+
</section>
|
|
791
|
+
|
|
792
|
+
<!-- ═══════════════════ DATASET ═══════════════════ -->
|
|
793
|
+
<section data-route="dataset" id="s-dataset">
|
|
794
|
+
<div class="scroller"><div class="wrap lit">
|
|
795
|
+
<div class="eyebrow">Workflow · dataset</div>
|
|
796
|
+
<h1 class="title">Build <span class="grad g-pink">a dataset.</span></h1>
|
|
797
|
+
<p class="lead">Turn pairs, docs, code, and seeds into clean JSONL the fine-tune workflow can consume.
|
|
798
|
+
Some templates run entirely offline; <strong>LLM-driven ones use the local model on this server.</strong></p>
|
|
799
|
+
|
|
800
|
+
<div class="steps" id="d-steps"></div>
|
|
801
|
+
|
|
802
|
+
<!-- step 1: pick template -->
|
|
803
|
+
<div data-dstep="0">
|
|
804
|
+
<div id="d-templates" class="grid" style="grid-template-columns:repeat(auto-fill,minmax(300px,1fr));margin-top:6px"></div>
|
|
805
|
+
</div>
|
|
806
|
+
|
|
807
|
+
<!-- step 2: fill form -->
|
|
808
|
+
<div class="card" data-dstep="1" style="display:none;max-width:860px">
|
|
809
|
+
<h2 style="font-size:24px;margin-bottom:4px" id="d-form-title">—</h2>
|
|
810
|
+
<p class="lead" style="margin:6px 0 22px;font-size:14.5px" id="d-form-desc"></p>
|
|
811
|
+
<div id="d-form"></div>
|
|
812
|
+
<div class="btnrow">
|
|
813
|
+
<button class="btn ghost" data-dback>Back</button>
|
|
814
|
+
<button class="btn primary" id="d-submit">Generate</button>
|
|
815
|
+
</div>
|
|
816
|
+
</div>
|
|
817
|
+
|
|
818
|
+
<!-- step 3: run -->
|
|
819
|
+
<div class="card" data-dstep="2" style="display:none;max-width:860px">
|
|
820
|
+
<h2 style="font-size:24px;margin-bottom:16px">Generating</h2>
|
|
821
|
+
<div class="progressbar"><i id="d-bar"></i></div>
|
|
822
|
+
<div class="progmeta"><span id="d-msg">Starting…</span><span id="d-pct">0%</span></div>
|
|
823
|
+
<div class="logbox" id="d-log"></div>
|
|
824
|
+
</div>
|
|
825
|
+
|
|
826
|
+
<!-- step 4: done -->
|
|
827
|
+
<div class="card" data-dstep="3" style="display:none;max-width:860px">
|
|
828
|
+
<h2 style="font-size:24px;margin-bottom:6px"><span class="grad g-green">Done.</span></h2>
|
|
829
|
+
<div class="grid g4" style="margin:6px 0 18px">
|
|
830
|
+
<div class="card"><h3>Train rows</h3><div class="stat num grad g-green" id="d-ntrain">—</div></div>
|
|
831
|
+
<div class="card"><h3>Valid rows</h3><div class="stat num grad g-blue" id="d-nvalid">—</div></div>
|
|
832
|
+
</div>
|
|
833
|
+
<div class="field"><label>Output directory</label>
|
|
834
|
+
<pre class="logbox" id="d-out" style="max-height:none;margin-top:0"></pre></div>
|
|
835
|
+
<div class="flash ok">Point Fine-tune → Dataset at this directory to train on it.</div>
|
|
836
|
+
<div class="grid g4" style="margin-top:14px">
|
|
837
|
+
<div class="card"><h3>Push to HF (dataset repo)</h3><div class="cap">Publish this JSONL dataset to a Hugging Face dataset repo.</div>
|
|
838
|
+
<div class="btnrow" style="margin-top:14px"><button class="btn ghost sm" id="d-push">Push to HF</button></div>
|
|
839
|
+
<div id="d-push-panel"></div>
|
|
840
|
+
</div>
|
|
841
|
+
</div>
|
|
842
|
+
<div class="btnrow"><button class="btn ghost" id="d-again">Build another</button></div>
|
|
843
|
+
</div>
|
|
844
|
+
</div></div>
|
|
845
|
+
</section>
|
|
846
|
+
|
|
847
|
+
<!-- ═══════════════════ STATUS ═══════════════════ -->
|
|
848
|
+
<section data-route="status" id="s-status">
|
|
849
|
+
<div class="scroller"><div class="wrap" id="status-page">
|
|
850
|
+
|
|
851
|
+
<div style="text-align:center;margin-bottom:18px" data-reveal>
|
|
852
|
+
<div class="eyebrow" style="margin-bottom:18px">local ai · apple silicon · one binary</div>
|
|
853
|
+
<h1 class="title grad g-hero" style="font-size:clamp(48px,8vw,110px)">mlx-bun</h1>
|
|
854
|
+
<p class="lead" style="margin:18px auto 0;text-align:center">The Python-free local AI engine. <strong>Bun + MLX, nothing else.</strong></p>
|
|
855
|
+
</div>
|
|
856
|
+
|
|
857
|
+
<section style="margin-top:56px" data-reveal>
|
|
858
|
+
<h2>Now serving<span class="soft">.</span></h2>
|
|
859
|
+
<div class="grid g4" style="margin-top:24px">
|
|
860
|
+
<div class="card" id="st-model-card" style="grid-column:1 / -1;display:flex;flex-wrap:wrap;align-items:baseline;gap:14px 26px">
|
|
861
|
+
<div><h3>Model</h3><div id="st-model-id" style="font-size:clamp(20px,2.6vw,32px);font-weight:700;letter-spacing:-.02em;word-break:break-all">—</div></div>
|
|
862
|
+
<div id="st-model-meta" style="font-size:14.5px;color:var(--dim);font-weight:500">—</div>
|
|
863
|
+
</div>
|
|
864
|
+
<div class="card">
|
|
865
|
+
<h3>Decode · predicted</h3>
|
|
866
|
+
<div class="stat num grad g-green"><span id="st-tps">—</span><small>tok/s</small></div>
|
|
867
|
+
<div class="cap" id="st-tps-cap">from weights ÷ bandwidth on this machine</div>
|
|
868
|
+
</div>
|
|
869
|
+
<div class="card">
|
|
870
|
+
<h3>Max safe context</h3>
|
|
871
|
+
<div class="stat num grad g-blue"><span id="st-safe-ctx">—</span><small>tokens</small></div>
|
|
872
|
+
<div class="cap">solved from KV growth + prefill transient, never guessed</div>
|
|
873
|
+
</div>
|
|
874
|
+
<div class="card">
|
|
875
|
+
<h3>Memory</h3>
|
|
876
|
+
<div class="kv"><b>weights</b><span class="num" id="st-weights">—</span></div>
|
|
877
|
+
<div class="kv"><b>usable</b><span class="num" id="st-usable">—</span></div>
|
|
878
|
+
<div class="kv"><b>budget</b><span class="num" id="st-budget">—</span></div>
|
|
879
|
+
<div class="meter warm"><i id="st-mem-bar"></i></div>
|
|
880
|
+
</div>
|
|
881
|
+
<div class="card">
|
|
882
|
+
<h3>Prompt cache</h3>
|
|
883
|
+
<div class="kv"><b>hit rate</b><span class="num" id="st-pc-hits">—</span></div>
|
|
884
|
+
<div class="kv"><b>entries</b><span class="num" id="st-pc-entries">—</span></div>
|
|
885
|
+
<div class="kv"><b>bytes</b><span class="num" id="st-pc-bytes">—</span></div>
|
|
886
|
+
<div class="meter"><i id="st-pc-bar"></i></div>
|
|
887
|
+
</div>
|
|
888
|
+
<div class="card">
|
|
889
|
+
<h3>Response store</h3>
|
|
890
|
+
<div class="kv"><b>entries</b><span class="num" id="st-rs-entries">—</span></div>
|
|
891
|
+
<div class="kv"><b>bytes</b><span class="num" id="st-rs-bytes">—</span></div>
|
|
892
|
+
<div class="kv"><b>ttl</b><span class="num" id="st-rs-ttl">—</span></div>
|
|
893
|
+
<div class="meter"><i id="st-rs-bar"></i></div>
|
|
894
|
+
</div>
|
|
895
|
+
<div class="card">
|
|
896
|
+
<h3>KV quantization</h3>
|
|
897
|
+
<div class="kv"><b>mode</b><span id="st-kv-mode">—</span></div>
|
|
898
|
+
<div id="st-kv-layers"></div>
|
|
899
|
+
</div>
|
|
900
|
+
<div class="card" id="st-dl-card" style="grid-column:1 / -1;display:none">
|
|
901
|
+
<h3>Downloads</h3>
|
|
902
|
+
<div id="st-dl-list"></div>
|
|
903
|
+
</div>
|
|
904
|
+
</div>
|
|
905
|
+
</section>
|
|
906
|
+
|
|
907
|
+
<section style="margin-top:80px" data-reveal>
|
|
908
|
+
<h2>Library<span class="soft">.</span> <span class="grad g-green">Every model, assessed.</span></h2>
|
|
909
|
+
<p class="lead">Everything in the local cache, each judged by the fit solver for <strong>this machine</strong>.</p>
|
|
910
|
+
<div class="card tablecard" style="margin-top:26px">
|
|
911
|
+
<table>
|
|
912
|
+
<thead><tr><th>Model</th><th>Size</th><th>Status</th><th>Max context</th><th>Decode</th></tr></thead>
|
|
913
|
+
<tbody id="st-lib-body"><tr><td colspan="5" style="text-align:center;color:var(--dimmer)">waiting for server…</td></tr></tbody>
|
|
914
|
+
</table>
|
|
915
|
+
</div>
|
|
916
|
+
</section>
|
|
917
|
+
|
|
918
|
+
<section style="margin-top:80px" data-reveal>
|
|
919
|
+
<h2>Will it fit<span class="soft">?</span> <span class="grad g-blue">Solved, not guessed.</span></h2>
|
|
920
|
+
<p class="lead">Weights + KV growth + prefill transient against wired memory — the same assessment that gates every request.</p>
|
|
921
|
+
<div class="grid g4" style="margin:26px 0 14px">
|
|
922
|
+
<div class="card">
|
|
923
|
+
<h3>This machine</h3>
|
|
924
|
+
<div class="kv"><b>RAM</b><span class="num" id="st-m-ram">—</span></div>
|
|
925
|
+
<div class="kv"><b>bandwidth</b><span class="num" id="st-m-bw">—</span></div>
|
|
926
|
+
<div class="kv"><b>verdict</b><span id="st-m-fits">—</span></div>
|
|
927
|
+
</div>
|
|
928
|
+
<div class="card">
|
|
929
|
+
<h3>At max context · <span class="num" id="st-fit-ctx">—</span> tokens</h3>
|
|
930
|
+
<div class="kv"><b>weights</b><span class="num" id="st-f-weights">—</span></div>
|
|
931
|
+
<div class="kv"><b>kv cache</b><span class="num" id="st-f-kv">—</span></div>
|
|
932
|
+
<div class="kv"><b>transient</b><span class="num" id="st-f-transient">—</span></div>
|
|
933
|
+
<div class="kv"><b>total / usable</b><span class="num" id="st-f-total">—</span></div>
|
|
934
|
+
<div class="meter"><i id="st-fit-bar"></i></div>
|
|
935
|
+
</div>
|
|
936
|
+
</div>
|
|
937
|
+
<div class="card tablecard">
|
|
938
|
+
<table>
|
|
939
|
+
<thead><tr><th>Apple silicon · this model @ <span class="num" id="st-sku-ctx">32,768</span> ctx</th>
|
|
940
|
+
<th>RAM</th><th>Fits</th><th>Max context</th><th>Decode</th></tr></thead>
|
|
941
|
+
<tbody id="st-sku-body"><tr><td colspan="5" style="text-align:center;color:var(--dimmer)">waiting for server…</td></tr></tbody>
|
|
942
|
+
</table>
|
|
943
|
+
</div>
|
|
944
|
+
</section>
|
|
945
|
+
|
|
946
|
+
<footer style="margin-top:90px;border-top:1px solid var(--hairline);padding-top:26px;display:flex;flex-wrap:wrap;justify-content:space-between;gap:12px;font-size:13px;color:var(--dimmer)">
|
|
947
|
+
<span>mlx-bun · M-series Macs · no Python anywhere in the serving path</span>
|
|
948
|
+
<span>server up <span id="st-uptime">—</span> · updated <span id="st-updated">never</span></span>
|
|
949
|
+
</footer>
|
|
950
|
+
</div></div>
|
|
951
|
+
</section>
|
|
952
|
+
|
|
953
|
+
</div>
|
|
954
|
+
|
|
955
|
+
<div id="toasts"></div>
|
|
956
|
+
|
|
957
|
+
<script>
|
|
958
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
959
|
+
SHARED HELPERS
|
|
960
|
+
════════════════════════════════════════════════════════════════════ */
|
|
961
|
+
const $ = (id) => document.getElementById(id);
|
|
962
|
+
const gb = (b) => b == null ? "—" : (b / 2 ** 30).toFixed(2) + " GB";
|
|
963
|
+
const mb = (b) => b == null ? "—" : (b / 2 ** 20).toFixed(1) + " MB";
|
|
964
|
+
const num = (n) => n == null ? "—" : Math.round(n).toLocaleString();
|
|
965
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
966
|
+
|
|
967
|
+
/** Build a DOM node quickly. */
|
|
968
|
+
function el(tag, cls, parent) {
|
|
969
|
+
const e = document.createElement(tag);
|
|
970
|
+
if (cls) e.className = cls;
|
|
971
|
+
if (parent) parent.appendChild(e);
|
|
972
|
+
return e;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** Toast notification. kind: "" | "ok" | "err". */
|
|
976
|
+
function toast(msg, kind = "", ms = 4200) {
|
|
977
|
+
const t = el("div", "toast " + kind, $("toasts"));
|
|
978
|
+
t.textContent = msg;
|
|
979
|
+
setTimeout(() => { t.style.transition = "opacity .4s,transform .4s"; t.style.opacity = "0"; t.style.transform = "translateY(10px)"; setTimeout(() => t.remove(), 420); }, ms);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** Fetch JSON with graceful error handling. Throws on !ok. */
|
|
983
|
+
async function api(path, opts) {
|
|
984
|
+
const r = await fetch(path, {
|
|
985
|
+
headers: { "content-type": "application/json" },
|
|
986
|
+
...opts,
|
|
987
|
+
...(opts && opts.body ? { body: typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body) } : {}),
|
|
988
|
+
});
|
|
989
|
+
const text = await r.text();
|
|
990
|
+
let data;
|
|
991
|
+
try { data = text ? JSON.parse(text) : {}; } catch { data = { ok: false, error: text.slice(0, 400) || ("HTTP " + r.status) }; }
|
|
992
|
+
if (!r.ok && data.ok === undefined) data = { ok: false, error: data.error || data.message || ("HTTP " + r.status) };
|
|
993
|
+
return data;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Wraps an EventSource over /api/jobs/:id/stream and dispatches typed events.
|
|
998
|
+
* Server → client line protocol (JSON per `data:` line):
|
|
999
|
+
* {type:"log", line}
|
|
1000
|
+
* {type:"stage", stage, progress, message, output_dir?, adapter_path?}
|
|
1001
|
+
* {type:"metric", kind:"train"|"val", step, loss, lr?, tps?, progress?, message?}
|
|
1002
|
+
* {type:"done"} {type:"failed", error}
|
|
1003
|
+
* Returns the EventSource so callers can .close().
|
|
1004
|
+
*/
|
|
1005
|
+
function jobStream(jobId, handlers) {
|
|
1006
|
+
const es = new EventSource("/api/jobs/" + encodeURIComponent(jobId) + "/stream");
|
|
1007
|
+
es.onmessage = (ev) => {
|
|
1008
|
+
let e; try { e = JSON.parse(ev.data); } catch { return; }
|
|
1009
|
+
const fn = handlers[e.type];
|
|
1010
|
+
if (fn) fn(e);
|
|
1011
|
+
};
|
|
1012
|
+
es.addEventListener("end", () => es.close());
|
|
1013
|
+
es.onerror = () => { if (handlers.error) handlers.error(); };
|
|
1014
|
+
return es;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** Allow only safe link targets (http/https/mailto/anchor/relative). */
|
|
1018
|
+
function mdSafeUrl(u) {
|
|
1019
|
+
const s = String(u).trim();
|
|
1020
|
+
return /^(https?:\/\/|mailto:|#|\/|\.{1,2}\/)/i.test(s) ? s : null;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** Inline markdown → HTML. Escapes first, then shields code spans from
|
|
1024
|
+
* further markup so backticked text is never mangled or unsafe. */
|
|
1025
|
+
function mdInline(t) {
|
|
1026
|
+
const codes = [];
|
|
1027
|
+
// Pull code spans out first (any run of backticks), then escape the rest.
|
|
1028
|
+
t = String(t).replace(/(`+)([\s\S]+?)\1/g, (_m, _ticks, c) => {
|
|
1029
|
+
codes.push(c.replace(/^ | $/g, ""));
|
|
1030
|
+
return "C" + (codes.length - 1) + "";
|
|
1031
|
+
});
|
|
1032
|
+
let s = esc(t);
|
|
1033
|
+
// [text](url "title") — title optional and ignored.
|
|
1034
|
+
s = s.replace(/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+"[^)]*")?\s*\)/g, (m, text, url) => {
|
|
1035
|
+
const safe = mdSafeUrl(url);
|
|
1036
|
+
return safe ? '<a href="' + safe + '" target="_blank" rel="noopener noreferrer">' + text + "</a>" : m;
|
|
1037
|
+
});
|
|
1038
|
+
s = s
|
|
1039
|
+
.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>")
|
|
1040
|
+
.replace(/\*\*([^*]+?)\*\*/g, "<strong>$1</strong>")
|
|
1041
|
+
.replace(/(^|[^_\w])__([^_]+?)__(?![\w])/g, "$1<strong>$2</strong>")
|
|
1042
|
+
.replace(/(^|[^*\w])\*([^*\s][^*]*?)\*/g, "$1<em>$2</em>")
|
|
1043
|
+
.replace(/(^|[^_\w])_([^_\s][^_]*?)_(?![\w])/g, "$1<em>$2</em>")
|
|
1044
|
+
.replace(/~~([^~]+?)~~/g, "<del>$1</del>");
|
|
1045
|
+
// Autolink bare URLs that markdown didn't already wrap.
|
|
1046
|
+
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+[^\s<).,;:!?'"])/g,
|
|
1047
|
+
(_m, pre, url) => pre + '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + "</a>");
|
|
1048
|
+
return s.replace(/C(\d+)/g, (_m, i) => "<code>" + esc(codes[+i]) + "</code>");
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function mdCodeBlock(lang, code) {
|
|
1052
|
+
return '<div class="codeblock"><div class="cbhead"><span class="cblang">' + esc(lang || "") +
|
|
1053
|
+
'</span><button class="cbcopy" type="button">Copy</button></div>' +
|
|
1054
|
+
"<pre><code>" + esc(code) + "</code></pre></div>";
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function mdTableSep(s) { return /^[\s|:-]+$/.test(s) && /-/.test(s) && /\|/.test(s); }
|
|
1058
|
+
function mdSplitRow(s) { return s.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()); }
|
|
1059
|
+
function mdBlockStart(s) {
|
|
1060
|
+
return /^\s*(`{3,}|~{3,})/.test(s) || /^(#{1,6})\s/.test(s) || /^\s*>/.test(s) ||
|
|
1061
|
+
/^\s*[-*+]\s+/.test(s) || /^\s*\d+[.)]\s+/.test(s) || /^\s*([-*_])\s*(\1\s*){2,}$/.test(s);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** Safe markdown renderer: code fences (w/ copy), headings, lists, task
|
|
1065
|
+
* lists, tables, blockquotes, rules, links, emphasis, inline code. */
|
|
1066
|
+
function mdToHtml(src) {
|
|
1067
|
+
const lines = String(src).replace(/\r\n?/g, "\n").split("\n");
|
|
1068
|
+
let html = "", i = 0, list = null;
|
|
1069
|
+
const closeList = () => { if (list) { html += "</" + list + ">"; list = null; } };
|
|
1070
|
+
|
|
1071
|
+
while (i < lines.length) {
|
|
1072
|
+
const ln = lines[i];
|
|
1073
|
+
|
|
1074
|
+
// fenced code block
|
|
1075
|
+
const fence = ln.match(/^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/);
|
|
1076
|
+
if (fence) {
|
|
1077
|
+
closeList();
|
|
1078
|
+
const tick = fence[1][0], lang = fence[2] || "", buf = [];
|
|
1079
|
+
const closeRe = new RegExp("^\\s*" + (tick === "`" ? "`{3,}" : "~{3,}") + "\\s*$");
|
|
1080
|
+
i++;
|
|
1081
|
+
while (i < lines.length && !closeRe.test(lines[i])) { buf.push(lines[i]); i++; }
|
|
1082
|
+
i++; // consume closing fence (or run off the end mid-stream)
|
|
1083
|
+
html += mdCodeBlock(lang, buf.join("\n"));
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// GFM table: a row with pipes followed by a separator row
|
|
1088
|
+
if (ln.includes("|") && i + 1 < lines.length && mdTableSep(lines[i + 1])) {
|
|
1089
|
+
closeList();
|
|
1090
|
+
const header = mdSplitRow(ln);
|
|
1091
|
+
const aligns = mdSplitRow(lines[i + 1]).map((c) => {
|
|
1092
|
+
const l = c.startsWith(":"), r = c.endsWith(":");
|
|
1093
|
+
return l && r ? "center" : r ? "right" : l ? "left" : "";
|
|
1094
|
+
});
|
|
1095
|
+
i += 2;
|
|
1096
|
+
const rows = [];
|
|
1097
|
+
while (i < lines.length && lines[i].includes("|") && lines[i].trim() !== "") { rows.push(mdSplitRow(lines[i])); i++; }
|
|
1098
|
+
const al = (k) => (aligns[k] ? ' style="text-align:' + aligns[k] + '"' : "");
|
|
1099
|
+
let tbl = '<div class="md-tablewrap"><table class="md-table"><thead><tr>';
|
|
1100
|
+
header.forEach((c, k) => { tbl += "<th" + al(k) + ">" + mdInline(c) + "</th>"; });
|
|
1101
|
+
tbl += "</tr></thead><tbody>";
|
|
1102
|
+
rows.forEach((r) => {
|
|
1103
|
+
tbl += "<tr>";
|
|
1104
|
+
for (let k = 0; k < header.length; k++) tbl += "<td" + al(k) + ">" + mdInline(r[k] || "") + "</td>";
|
|
1105
|
+
tbl += "</tr>";
|
|
1106
|
+
});
|
|
1107
|
+
html += tbl + "</tbody></table></div>";
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// heading
|
|
1112
|
+
const h = ln.match(/^(#{1,6})\s+(.*)$/);
|
|
1113
|
+
if (h) { closeList(); const n = h[1].length; html += "<h" + n + ">" + mdInline(h[2].replace(/\s+#+\s*$/, "")) + "</h" + n + ">"; i++; continue; }
|
|
1114
|
+
|
|
1115
|
+
// horizontal rule
|
|
1116
|
+
if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(ln)) { closeList(); html += "<hr>"; i++; continue; }
|
|
1117
|
+
|
|
1118
|
+
// blockquote (consecutive '>' lines, rendered recursively)
|
|
1119
|
+
if (/^\s*>/.test(ln)) {
|
|
1120
|
+
closeList();
|
|
1121
|
+
const buf = [];
|
|
1122
|
+
while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, "")); i++; }
|
|
1123
|
+
html += "<blockquote>" + mdToHtml(buf.join("\n")) + "</blockquote>";
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// task list / unordered / ordered list items
|
|
1128
|
+
const task = ln.match(/^\s*[-*+]\s+\[([ xX])\]\s+(.*)$/);
|
|
1129
|
+
const ul = ln.match(/^\s*[-*+]\s+(.*)$/);
|
|
1130
|
+
const ol = ln.match(/^\s*\d+[.)]\s+(.*)$/);
|
|
1131
|
+
if (task) {
|
|
1132
|
+
if (list !== "ul") { closeList(); html += "<ul>"; list = "ul"; }
|
|
1133
|
+
html += '<li class="task"><input type="checkbox" disabled' + (task[1].toLowerCase() === "x" ? " checked" : "") + "> " + mdInline(task[2]) + "</li>";
|
|
1134
|
+
i++; continue;
|
|
1135
|
+
}
|
|
1136
|
+
if (ul) { if (list !== "ul") { closeList(); html += "<ul>"; list = "ul"; } html += "<li>" + mdInline(ul[1]) + "</li>"; i++; continue; }
|
|
1137
|
+
if (ol) { if (list !== "ol") { closeList(); html += "<ol>"; list = "ol"; } html += "<li>" + mdInline(ol[1]) + "</li>"; i++; continue; }
|
|
1138
|
+
|
|
1139
|
+
if (ln.trim() === "") { closeList(); i++; continue; }
|
|
1140
|
+
|
|
1141
|
+
// paragraph: merge following plain lines (soft breaks → <br>)
|
|
1142
|
+
closeList();
|
|
1143
|
+
const para = [ln];
|
|
1144
|
+
i++;
|
|
1145
|
+
while (i < lines.length && lines[i].trim() !== "" && !mdBlockStart(lines[i])) { para.push(lines[i]); i++; }
|
|
1146
|
+
html += "<p>" + para.map(mdInline).join("<br>") + "</p>";
|
|
1147
|
+
}
|
|
1148
|
+
closeList();
|
|
1149
|
+
return html;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** Render a step indicator into a container. names[], current index. */
|
|
1153
|
+
function renderSteps(container, names, cur) {
|
|
1154
|
+
container.innerHTML = names.map((n, i) => {
|
|
1155
|
+
const cls = i === cur ? "cur" : (i < cur ? "done" : "");
|
|
1156
|
+
const arrow = i < names.length - 1 ? '<span class="arrow">→</span>' : "";
|
|
1157
|
+
return '<span class="s ' + cls + '"><span class="n">' + (i + 1) + "</span>" + esc(n) + "</span>" + arrow;
|
|
1158
|
+
}).join("");
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
1162
|
+
HUGGING FACE — shared token settings + push-to-hub flow
|
|
1163
|
+
════════════════════════════════════════════════════════════════════ */
|
|
1164
|
+
|
|
1165
|
+
/** Reflect saved-token state on the nav gear. */
|
|
1166
|
+
async function refreshHfGear() {
|
|
1167
|
+
try {
|
|
1168
|
+
const d = await api("/api/settings/hf-token");
|
|
1169
|
+
const saved = !!(d && d.ok && d.hasToken);
|
|
1170
|
+
$("nav-hf").classList.toggle("saved", saved);
|
|
1171
|
+
$("nav-hf").title = saved ? "Hugging Face token saved · click to replace" : "Hugging Face token settings";
|
|
1172
|
+
return saved;
|
|
1173
|
+
} catch { return false; }
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/* — the nav settings modal — */
|
|
1177
|
+
function openHfSettings() {
|
|
1178
|
+
const ov = $("hf-overlay"); ov.classList.add("open");
|
|
1179
|
+
$("hf-token-input").value = ""; $("hf-settings-msg").innerHTML = "";
|
|
1180
|
+
$("hf-state").textContent = "Checking for a saved token…";
|
|
1181
|
+
api("/api/settings/hf-token").then((d) => {
|
|
1182
|
+
const saved = !!(d && d.ok && d.hasToken);
|
|
1183
|
+
$("hf-state").innerHTML = saved
|
|
1184
|
+
? 'A write token is <strong>saved</strong>. Enter a new one below to replace it.'
|
|
1185
|
+
: 'No token saved yet. Add a write token to push models and datasets to the Hub.';
|
|
1186
|
+
}).catch(() => { $("hf-state").textContent = "Could not reach the server."; });
|
|
1187
|
+
setTimeout(() => $("hf-token-input").focus(), 50);
|
|
1188
|
+
}
|
|
1189
|
+
function closeHfSettings() { $("hf-overlay").classList.remove("open"); }
|
|
1190
|
+
|
|
1191
|
+
async function saveHfToken() {
|
|
1192
|
+
const token = $("hf-token-input").value.trim();
|
|
1193
|
+
const msg = $("hf-settings-msg");
|
|
1194
|
+
if (!token) { msg.innerHTML = '<div class="flash err">Enter a token first.</div>'; return; }
|
|
1195
|
+
const btn = $("hf-save"); btn.disabled = true;
|
|
1196
|
+
const d = await api("/api/settings/hf-token", { method: "POST", body: { token } }).catch(() => ({ ok: false, error: "request failed" }));
|
|
1197
|
+
btn.disabled = false;
|
|
1198
|
+
if (!d.ok) { msg.innerHTML = '<div class="flash err">' + esc(d.error || "could not save token") + "</div>"; return; }
|
|
1199
|
+
msg.innerHTML = '<div class="flash ok">Token saved to <code>~/.mlx-bun/hf.json</code>.</div>';
|
|
1200
|
+
$("hf-token-input").value = "";
|
|
1201
|
+
refreshHfGear();
|
|
1202
|
+
toast("Hugging Face token saved", "ok");
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Shared push-to-hub flow, rendered inline inside a done-step card.
|
|
1207
|
+
* pushToHub(panelEl, { kind, job_id?, source_path? })
|
|
1208
|
+
* kind: "quantize" | "finetune" | "dataset". Posts to /api/{kind}/push.
|
|
1209
|
+
* Walks: ensure token → repo_id + private → POST → success/error flash.
|
|
1210
|
+
*/
|
|
1211
|
+
async function pushToHub(panel, { kind, job_id, source_path }) {
|
|
1212
|
+
panel.innerHTML = '<div class="pushpanel"><div class="flash"><span class="shimmer">checking Hugging Face token…</span></div></div>';
|
|
1213
|
+
const wrap = panel.firstChild;
|
|
1214
|
+
let hasToken = false;
|
|
1215
|
+
try { const s = await api("/api/settings/hf-token"); hasToken = !!(s && s.ok && s.hasToken); } catch {}
|
|
1216
|
+
|
|
1217
|
+
const renderForm = () => {
|
|
1218
|
+
wrap.innerHTML =
|
|
1219
|
+
(!hasToken
|
|
1220
|
+
? '<div class="field"><label>Hugging Face write token</label>' +
|
|
1221
|
+
'<input type="password" class="p-token" placeholder="hf_…" autocomplete="off" spellcheck="false">' +
|
|
1222
|
+
'<div class="hint">A <strong>write</strong> token from huggingface.co/settings/tokens. Stored locally at <code>~/.mlx-bun/hf.json</code>.</div></div>'
|
|
1223
|
+
: "") +
|
|
1224
|
+
'<div class="field"><label>Repository id</label>' +
|
|
1225
|
+
'<input type="text" class="p-repo" placeholder="you/my-' + esc(kind === "dataset" ? "dataset" : "model") + '" autocomplete="off"></div>' +
|
|
1226
|
+
'<div class="field"><label class="chk"><input type="checkbox" class="p-priv">Private repository</label></div>' +
|
|
1227
|
+
'<div class="p-msg"></div>' +
|
|
1228
|
+
'<div class="btnrow" style="margin-top:6px"><button class="btn primary sm p-go">' +
|
|
1229
|
+
(hasToken ? "Push" : "Save token & push") + "</button></div>";
|
|
1230
|
+
wrap.querySelector(".p-go").onclick = go;
|
|
1231
|
+
const focusEl = wrap.querySelector(hasToken ? ".p-repo" : ".p-token");
|
|
1232
|
+
if (focusEl) focusEl.focus();
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
async function go() {
|
|
1236
|
+
const msg = wrap.querySelector(".p-msg");
|
|
1237
|
+
const repo = (wrap.querySelector(".p-repo").value || "").trim();
|
|
1238
|
+
const priv = wrap.querySelector(".p-priv").checked;
|
|
1239
|
+
if (!repo) { msg.innerHTML = '<div class="flash err">Enter a repository id (e.g. <code>you/my-model</code>).</div>'; return; }
|
|
1240
|
+
const btn = wrap.querySelector(".p-go"); btn.disabled = true;
|
|
1241
|
+
|
|
1242
|
+
if (!hasToken) {
|
|
1243
|
+
const token = (wrap.querySelector(".p-token").value || "").trim();
|
|
1244
|
+
if (!token) { msg.innerHTML = '<div class="flash err">Enter a write token first.</div>'; btn.disabled = false; return; }
|
|
1245
|
+
const sv = await api("/api/settings/hf-token", { method: "POST", body: { token } }).catch(() => ({ ok: false, error: "request failed" }));
|
|
1246
|
+
if (!sv.ok) { msg.innerHTML = '<div class="flash err">' + esc(sv.error || "could not save token") + "</div>"; btn.disabled = false; return; }
|
|
1247
|
+
hasToken = true; refreshHfGear();
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
msg.innerHTML = '<div class="flash"><span class="shimmer">pushing to ' + esc(repo) + "…</span></div>";
|
|
1251
|
+
const body = { repo_id: repo, private: priv };
|
|
1252
|
+
if (job_id != null) body.job_id = job_id;
|
|
1253
|
+
if (source_path != null) body.source_path = source_path;
|
|
1254
|
+
const d = await api("/api/" + kind + "/push", { method: "POST", body }).catch(() => ({ ok: false, error: "request failed" }));
|
|
1255
|
+
btn.disabled = false;
|
|
1256
|
+
if (!d.ok) { msg.innerHTML = '<div class="flash err">' + esc(d.error || "push failed") + "</div>"; return; }
|
|
1257
|
+
const url = d.url || ("https://huggingface.co/" + repo);
|
|
1258
|
+
wrap.innerHTML = '<div class="flash ok">Pushed to <a href="' + esc(url) + '" target="_blank" rel="noopener" style="text-decoration:underline">' + esc(url) + "</a></div>";
|
|
1259
|
+
toast("Pushed to Hugging Face", "ok");
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
renderForm();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/* wire the nav gear + modal once at boot */
|
|
1266
|
+
(function initHfSettings() {
|
|
1267
|
+
$("nav-hf").onclick = openHfSettings;
|
|
1268
|
+
$("hf-close").onclick = closeHfSettings;
|
|
1269
|
+
$("hf-save").onclick = saveHfToken;
|
|
1270
|
+
$("hf-overlay").addEventListener("click", (e) => { if (e.target === $("hf-overlay")) closeHfSettings(); });
|
|
1271
|
+
$("hf-token-input").addEventListener("keydown", (e) => { if (e.key === "Enter") saveHfToken(); });
|
|
1272
|
+
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("hf-overlay").classList.contains("open")) closeHfSettings(); });
|
|
1273
|
+
refreshHfGear();
|
|
1274
|
+
})();
|
|
1275
|
+
|
|
1276
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
1277
|
+
ROUTER — toggles section[data-route]; lazily inits each controller.
|
|
1278
|
+
════════════════════════════════════════════════════════════════════ */
|
|
1279
|
+
const ROUTES = ["chat", "quantize", "finetune", "dataset", "status"];
|
|
1280
|
+
const inited = {};
|
|
1281
|
+
const controllers = {}; // name -> { init, enter, leave }
|
|
1282
|
+
|
|
1283
|
+
function currentRoute() {
|
|
1284
|
+
const h = (location.hash || "").replace(/^#\/?/, "").split("?")[0];
|
|
1285
|
+
return ROUTES.includes(h) ? h : "chat";
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function router() {
|
|
1289
|
+
const route = currentRoute();
|
|
1290
|
+
document.querySelectorAll("section[data-route]").forEach((s) => {
|
|
1291
|
+
const on = s.dataset.route === route;
|
|
1292
|
+
if (on && !s.classList.contains("active")) {
|
|
1293
|
+
s.classList.add("active");
|
|
1294
|
+
const c = controllers[route];
|
|
1295
|
+
if (c) { if (!inited[route]) { inited[route] = true; c.init && c.init(); } c.enter && c.enter(); }
|
|
1296
|
+
} else if (!on && s.classList.contains("active")) {
|
|
1297
|
+
s.classList.remove("active");
|
|
1298
|
+
const c = controllers[s.dataset.route];
|
|
1299
|
+
if (c && c.leave) c.leave();
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
document.querySelectorAll("nav .tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === route));
|
|
1303
|
+
// dim the ambient bloom slightly in the chat workspace so text reads cleanly
|
|
1304
|
+
$("bloom").style.opacity = route === "chat" ? "0.55" : "1";
|
|
1305
|
+
}
|
|
1306
|
+
window.addEventListener("hashchange", router);
|
|
1307
|
+
|
|
1308
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
1309
|
+
GLOBAL CONNECTION PILL + MODEL ID (polled lightly, always on)
|
|
1310
|
+
════════════════════════════════════════════════════════════════════ */
|
|
1311
|
+
let activeModelId = null;
|
|
1312
|
+
function setConn(state, text) {
|
|
1313
|
+
const p = $("nav-conn");
|
|
1314
|
+
p.className = "pill " + (state || "");
|
|
1315
|
+
$("nav-conn-text").textContent = text;
|
|
1316
|
+
}
|
|
1317
|
+
let defaultHelloSub = null;
|
|
1318
|
+
async function pollIdentity() {
|
|
1319
|
+
try {
|
|
1320
|
+
const [models, dl] = await Promise.all([
|
|
1321
|
+
fetch("/v1/models").then((r) => r.json()),
|
|
1322
|
+
fetch("/downloads").then((r) => r.json()).catch(() => ({ downloads: [] })),
|
|
1323
|
+
]);
|
|
1324
|
+
activeModelId = models.data && models.data[0] ? models.data[0].id : null;
|
|
1325
|
+
$("nav-model").textContent = activeModelId || "no model";
|
|
1326
|
+
setConn("ok", "live · localhost");
|
|
1327
|
+
updateDownloadIndicator((dl && dl.downloads) || []);
|
|
1328
|
+
} catch {
|
|
1329
|
+
$("nav-model").textContent = "server unreachable";
|
|
1330
|
+
setConn("bad", "unreachable — retrying");
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/** Surface a background model download — the "bigger model arriving while you
|
|
1335
|
+
* chat on the starter" case — as a live nav pill plus a download-aware
|
|
1336
|
+
* greeting in the empty chat state. Hidden when nothing else is downloading. */
|
|
1337
|
+
function updateDownloadIndicator(downloads) {
|
|
1338
|
+
const sub = $("chat-hello-sub");
|
|
1339
|
+
if (sub && defaultHelloSub === null) defaultHelloSub = sub.textContent;
|
|
1340
|
+
const incoming = downloads.find((d) => d.state === "active" && d.repoId !== activeModelId);
|
|
1341
|
+
const pill = $("nav-download");
|
|
1342
|
+
if (incoming) {
|
|
1343
|
+
const pct = incoming.totalBytes ? Math.floor((incoming.receivedBytes / incoming.totalBytes) * 100) : 0;
|
|
1344
|
+
const name = incoming.repoId.split("/").pop();
|
|
1345
|
+
$("nav-download-text").textContent = "↓ " + name + " · " + pct + "%";
|
|
1346
|
+
pill.style.display = "";
|
|
1347
|
+
if (sub) sub.textContent = "You're on a small, fast starter model so you can chat right now — a more capable one (" + name + ") is downloading and takes over next launch. Ask me anything, or about mlx-bun itself.";
|
|
1348
|
+
} else {
|
|
1349
|
+
pill.style.display = "none";
|
|
1350
|
+
if (sub && defaultHelloSub !== null) sub.textContent = defaultHelloSub;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
1355
|
+
CHAT CONTROLLER — full pi agent over WebSocket /ws/chat
|
|
1356
|
+
════════════════════════════════════════════════════════════════════ */
|
|
1357
|
+
controllers.chat = (() => {
|
|
1358
|
+
let ws = null, connected = false, reconnectTimer = null, manualClose = false;
|
|
1359
|
+
let curAssistant = null; // { bubble, cursor, meta, tools:Map, text, t0, tFirst, tokens }
|
|
1360
|
+
let turnActive = false;
|
|
1361
|
+
// This connection's active session path (from `sessions` frames). Each
|
|
1362
|
+
// connect gets a fresh server session; on a transient reconnect we re-open
|
|
1363
|
+
// our own session so a blip doesn't strand us on a blank one.
|
|
1364
|
+
let currentSessionPath = null, pendingResumePath = null;
|
|
1365
|
+
const thread = () => $("chat-thread");
|
|
1366
|
+
|
|
1367
|
+
function wsUrl() {
|
|
1368
|
+
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
1369
|
+
return proto + "//" + location.host + "/ws/chat";
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function connect() {
|
|
1373
|
+
manualClose = false;
|
|
1374
|
+
// Remember our session across this (re)connect: if we were already in a
|
|
1375
|
+
// session, re-open it once the new connection is ready (the server starts
|
|
1376
|
+
// each connection on a fresh session by default).
|
|
1377
|
+
pendingResumePath = currentSessionPath;
|
|
1378
|
+
try { ws = new WebSocket(wsUrl()); } catch { scheduleReconnect(); return; }
|
|
1379
|
+
ws.onopen = () => { connected = true; setChatStatus("connected"); };
|
|
1380
|
+
ws.onclose = () => { connected = false; setChatStatus("disconnected"); if (!manualClose) scheduleReconnect(); };
|
|
1381
|
+
ws.onerror = () => { setChatStatus("error"); };
|
|
1382
|
+
ws.onmessage = (ev) => { let m; try { m = JSON.parse(ev.data); } catch { return; } handle(m); };
|
|
1383
|
+
}
|
|
1384
|
+
function scheduleReconnect() {
|
|
1385
|
+
clearTimeout(reconnectTimer);
|
|
1386
|
+
reconnectTimer = setTimeout(() => { if (currentRoute() === "chat") connect(); }, 2200);
|
|
1387
|
+
}
|
|
1388
|
+
function send(obj) { if (ws && ws.readyState === 1) { ws.send(JSON.stringify(obj)); return true; } return false; }
|
|
1389
|
+
|
|
1390
|
+
function setChatStatus(s) {
|
|
1391
|
+
const line = $("chat-status-line");
|
|
1392
|
+
if (s === "connected") line.textContent = "connected · type to send · Shift+Enter for newline";
|
|
1393
|
+
else if (s === "disconnected") line.textContent = "reconnecting to agent…";
|
|
1394
|
+
else if (s === "error") line.textContent = "connection error — retrying";
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/* — message + tool DOM construction — */
|
|
1398
|
+
function addUserMsg(text, atts) {
|
|
1399
|
+
const m = el("div", "msg user", thread());
|
|
1400
|
+
el("div", "who", m).textContent = "you";
|
|
1401
|
+
const bubble = el("div", "bubble", m);
|
|
1402
|
+
if (atts && atts.length) {
|
|
1403
|
+
const wrap = el("div", "msg-atts", bubble);
|
|
1404
|
+
for (const a of atts) {
|
|
1405
|
+
if (a.kind === "image") {
|
|
1406
|
+
const im = el("img", "msg-att-img", wrap);
|
|
1407
|
+
im.src = "data:" + a.mimeType + ";base64," + a.data; im.alt = a.name;
|
|
1408
|
+
} else {
|
|
1409
|
+
el("span", "msg-att-file", wrap).textContent = "📎 " + a.name;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if (text) el("div", "msg-text", bubble).textContent = text;
|
|
1414
|
+
stick(true);
|
|
1415
|
+
}
|
|
1416
|
+
function startAssistant() {
|
|
1417
|
+
$("chat-hello").style.display = "none";
|
|
1418
|
+
const m = el("div", "msg assistant", thread());
|
|
1419
|
+
el("div", "who", m).textContent = "local agent";
|
|
1420
|
+
const bubble = el("div", "bubble", m);
|
|
1421
|
+
const thinkBox = el("details", "thinkbox", bubble);
|
|
1422
|
+
thinkBox.open = true;
|
|
1423
|
+
el("summary", "", thinkBox).textContent = "Thinking";
|
|
1424
|
+
const thinkBody = el("div", "thinkbody", thinkBox);
|
|
1425
|
+
thinkBox.style.display = "none";
|
|
1426
|
+
const textNode = el("span", "atext", bubble);
|
|
1427
|
+
const cursor = el("span", "cursor", bubble);
|
|
1428
|
+
const meta = el("div", "meta", m);
|
|
1429
|
+
curAssistant = { m, bubble, thinkBox, thinkBody, textNode, cursor, meta, tools: new Map(), text: "", thinking: "", t0: performance.now(), tFirst: 0, tokens: 0 };
|
|
1430
|
+
stick(true);
|
|
1431
|
+
}
|
|
1432
|
+
function ensureAssistant() { if (!curAssistant) startAssistant(); }
|
|
1433
|
+
|
|
1434
|
+
function appendDelta(delta) {
|
|
1435
|
+
ensureAssistant();
|
|
1436
|
+
const a = curAssistant;
|
|
1437
|
+
if (!a.tFirst) a.tFirst = performance.now();
|
|
1438
|
+
a.tokens++;
|
|
1439
|
+
a.text += delta;
|
|
1440
|
+
a.textNode.innerHTML = mdToHtml(a.text);
|
|
1441
|
+
updateTps();
|
|
1442
|
+
if (atBottom()) stick();
|
|
1443
|
+
}
|
|
1444
|
+
function appendThinkingDelta(delta) {
|
|
1445
|
+
ensureAssistant();
|
|
1446
|
+
const a = curAssistant;
|
|
1447
|
+
if (!a.tFirst) a.tFirst = performance.now();
|
|
1448
|
+
a.tokens++;
|
|
1449
|
+
a.thinking += delta;
|
|
1450
|
+
a.thinkBody.textContent = a.thinking;
|
|
1451
|
+
a.thinkBox.style.display = a.thinking.trim() ? "" : "none";
|
|
1452
|
+
updateTps();
|
|
1453
|
+
if (atBottom()) stick();
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/* tool card lifecycle */
|
|
1457
|
+
function toolCard(callId, tool, args) {
|
|
1458
|
+
ensureAssistant();
|
|
1459
|
+
const a = curAssistant;
|
|
1460
|
+
let t = a.tools.get(callId);
|
|
1461
|
+
if (t) return t;
|
|
1462
|
+
const wrap = el("div", "tool running open", a.bubble);
|
|
1463
|
+
wrap.innerHTML =
|
|
1464
|
+
'<div class="thead"><div class="ticon">⚙</div>' +
|
|
1465
|
+
'<span class="tname">' + esc(tool || "tool") + '</span>' +
|
|
1466
|
+
'<span class="targs">' + esc(argSummary(args)) + '</span>' +
|
|
1467
|
+
'<span class="tstat"><span class="sdot"></span><span class="slabel">running</span></span>' +
|
|
1468
|
+
'<span class="caret">›</span></div>' +
|
|
1469
|
+
'<div class="tbody">' +
|
|
1470
|
+
'<div class="blk"><div class="blbl">arguments</div><pre class="aargs">' + esc(prettyArgs(args)) + '</pre></div>' +
|
|
1471
|
+
'<div class="blk tresult" style="display:none"><div class="blbl">result</div><pre class="ares"></pre></div>' +
|
|
1472
|
+
'</div>';
|
|
1473
|
+
wrap.querySelector(".thead").addEventListener("click", () => wrap.classList.toggle("open"));
|
|
1474
|
+
t = { wrap, res: wrap.querySelector(".ares"), resBlk: wrap.querySelector(".tresult"), label: wrap.querySelector(".slabel"), chunks: "" };
|
|
1475
|
+
a.tools.set(callId, t);
|
|
1476
|
+
stick();
|
|
1477
|
+
return t;
|
|
1478
|
+
}
|
|
1479
|
+
function toolUpdate(callId, chunk) {
|
|
1480
|
+
const t = curAssistant && curAssistant.tools.get(callId);
|
|
1481
|
+
if (!t) return;
|
|
1482
|
+
t.chunks += chunk;
|
|
1483
|
+
t.resBlk.style.display = "";
|
|
1484
|
+
t.res.textContent = t.chunks.slice(-8000);
|
|
1485
|
+
if (atBottom()) stick();
|
|
1486
|
+
}
|
|
1487
|
+
function toolEnd(callId, ok, result) {
|
|
1488
|
+
const t = curAssistant && curAssistant.tools.get(callId);
|
|
1489
|
+
if (!t) return;
|
|
1490
|
+
t.wrap.classList.remove("running");
|
|
1491
|
+
t.wrap.classList.add(ok ? "ok" : "fail");
|
|
1492
|
+
t.label.textContent = ok ? "done" : "failed";
|
|
1493
|
+
const body = result != null ? (typeof result === "string" ? result : JSON.stringify(result, null, 2)) : t.chunks;
|
|
1494
|
+
if (body) { t.resBlk.style.display = ""; t.res.textContent = String(body).slice(-12000); }
|
|
1495
|
+
if (!ok) t.wrap.classList.add("open");
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/* approval dialog */
|
|
1499
|
+
function approvalCard(callId, tool, args) {
|
|
1500
|
+
ensureAssistant();
|
|
1501
|
+
const a = curAssistant;
|
|
1502
|
+
const wrap = el("div", "approval", a.bubble);
|
|
1503
|
+
const isBash = /bash|shell|exec|run/i.test(tool || "");
|
|
1504
|
+
const isEdit = /edit|write|patch|create/i.test(tool || "");
|
|
1505
|
+
let preview;
|
|
1506
|
+
if (isBash) {
|
|
1507
|
+
preview = '<pre>' + esc(args && (args.command || args.cmd || args.script) || prettyArgs(args)) + '</pre>';
|
|
1508
|
+
} else if (isEdit) {
|
|
1509
|
+
preview = '<pre>' + diffView(args) + '</pre>';
|
|
1510
|
+
} else {
|
|
1511
|
+
preview = '<pre>' + esc(prettyArgs(args)) + '</pre>';
|
|
1512
|
+
}
|
|
1513
|
+
wrap.innerHTML =
|
|
1514
|
+
'<div class="ahead"><div class="ai">⚠</div><div class="at">Approval required</div>' +
|
|
1515
|
+
'<div class="as">' + esc(tool || "tool") + '</div></div>' +
|
|
1516
|
+
'<div class="abody"><div class="blbl" style="margin-bottom:7px">' +
|
|
1517
|
+
(isBash ? "command" : isEdit ? "proposed change" : "arguments") + '</div>' + preview + '</div>' +
|
|
1518
|
+
'<div class="actions">' +
|
|
1519
|
+
'<button class="btn ghost sm a-deny">Deny</button>' +
|
|
1520
|
+
'<button class="btn primary sm a-allow">Allow</button></div>';
|
|
1521
|
+
const resolve = (decision) => {
|
|
1522
|
+
send({ type: "approval", callId, decision });
|
|
1523
|
+
wrap.querySelector(".actions").remove();
|
|
1524
|
+
const r = el("div", "resolved " + (decision === "allow" ? "allow" : "deny"), wrap);
|
|
1525
|
+
r.textContent = decision === "allow" ? "✓ allowed" : "✕ denied";
|
|
1526
|
+
};
|
|
1527
|
+
wrap.querySelector(".a-allow").onclick = () => resolve("allow");
|
|
1528
|
+
wrap.querySelector(".a-deny").onclick = () => resolve("deny");
|
|
1529
|
+
stick(true);
|
|
1530
|
+
}
|
|
1531
|
+
function diffView(args) {
|
|
1532
|
+
// Best-effort diff-ish view for edit/write tools.
|
|
1533
|
+
if (!args) return "";
|
|
1534
|
+
if (args.old_string != null || args.new_string != null) {
|
|
1535
|
+
const o = String(args.old_string ?? "").split("\n").map((l) => '<span class="diffdel">- ' + esc(l) + "</span>").join("\n");
|
|
1536
|
+
const n = String(args.new_string ?? "").split("\n").map((l) => '<span class="diffadd">+ ' + esc(l) + "</span>").join("\n");
|
|
1537
|
+
return (args.file_path ? esc(args.file_path) + "\n\n" : "") + o + (o && n ? "\n" : "") + n;
|
|
1538
|
+
}
|
|
1539
|
+
if (args.content != null) {
|
|
1540
|
+
return (args.file_path ? esc(args.file_path) + "\n\n" : "") +
|
|
1541
|
+
String(args.content).split("\n").map((l) => '<span class="diffadd">+ ' + esc(l) + "</span>").join("\n");
|
|
1542
|
+
}
|
|
1543
|
+
return esc(prettyArgs(args));
|
|
1544
|
+
}
|
|
1545
|
+
function argSummary(args) {
|
|
1546
|
+
if (!args) return "";
|
|
1547
|
+
if (typeof args === "string") return args.slice(0, 120);
|
|
1548
|
+
if (args.command || args.cmd) return String(args.command || args.cmd).slice(0, 120);
|
|
1549
|
+
if (args.file_path || args.path) return String(args.file_path || args.path).slice(0, 120);
|
|
1550
|
+
if (args.query || args.url || args.location) return String(args.query || args.url || args.location).slice(0, 120);
|
|
1551
|
+
const s = JSON.stringify(args); return s.length > 120 ? s.slice(0, 117) + "…" : s;
|
|
1552
|
+
}
|
|
1553
|
+
function prettyArgs(args) {
|
|
1554
|
+
if (args == null) return "";
|
|
1555
|
+
if (typeof args === "string") return args;
|
|
1556
|
+
try { return JSON.stringify(args, null, 2); } catch { return String(args); }
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function showAgentError(message) {
|
|
1560
|
+
const m = el("div", "msg assistant", thread());
|
|
1561
|
+
el("div", "who", m).textContent = "local agent";
|
|
1562
|
+
const bubble = el("div", "bubble", m);
|
|
1563
|
+
bubble.innerHTML = '<strong style="color:var(--red)">Agent error</strong><br>' + esc(message || "unknown error");
|
|
1564
|
+
stick(true);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function endTurn() {
|
|
1568
|
+
turnActive = false;
|
|
1569
|
+
if (curAssistant) {
|
|
1570
|
+
curAssistant.cursor.remove();
|
|
1571
|
+
if (!curAssistant.text && !curAssistant.thinking && curAssistant.tools.size === 0) curAssistant.m.remove();
|
|
1572
|
+
}
|
|
1573
|
+
curAssistant = null;
|
|
1574
|
+
$("chat-send").disabled = false;
|
|
1575
|
+
$("chat-stop").style.display = "none";
|
|
1576
|
+
renderQueue({});
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function updateTps() {
|
|
1580
|
+
const a = curAssistant; if (!a) return;
|
|
1581
|
+
const secs = (performance.now() - (a.tFirst || a.t0)) / 1000;
|
|
1582
|
+
if (a.tokens > 1 && secs > 0) $("chat-tps").innerHTML = "<b style='color:var(--green)'>" + (a.tokens / secs).toFixed(1) + " tok/s</b>";
|
|
1583
|
+
}
|
|
1584
|
+
function finalizeMeta() {
|
|
1585
|
+
const a = curAssistant; if (!a) return;
|
|
1586
|
+
const secs = (performance.now() - (a.tFirst || a.t0)) / 1000;
|
|
1587
|
+
if (a.tokens > 1 && secs > 0)
|
|
1588
|
+
a.meta.innerHTML = "<b>" + (a.tokens / secs).toFixed(1) + " tok/s</b> · " + a.tokens +
|
|
1589
|
+
" tokens · first token " + Math.round((a.tFirst - a.t0)) + "ms";
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
function renderQueue(q) {
|
|
1593
|
+
const bar = $("chat-queue");
|
|
1594
|
+
const parts = [];
|
|
1595
|
+
if (q.steering) parts.push('<span class="qtag">steering: <b>' + esc(String(q.steering).slice(0, 60)) + "</b></span>");
|
|
1596
|
+
if (q.followUp) parts.push('<span class="qtag">queued: <b>' + esc(String(q.followUp).slice(0, 60)) + "</b></span>");
|
|
1597
|
+
bar.innerHTML = parts.join("");
|
|
1598
|
+
bar.style.display = parts.length ? "flex" : "none";
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/* — context-window indicator — */
|
|
1602
|
+
function fmtTok(n) {
|
|
1603
|
+
if (n == null) return "—";
|
|
1604
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
|
1605
|
+
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k";
|
|
1606
|
+
return String(n);
|
|
1607
|
+
}
|
|
1608
|
+
function renderContext(m) {
|
|
1609
|
+
const node = $("chat-context");
|
|
1610
|
+
if (!node || !m.contextWindow) return;
|
|
1611
|
+
const pct = m.percent;
|
|
1612
|
+
const pctStr = pct == null ? "" : " (" + Math.round(pct) + "%)";
|
|
1613
|
+
node.textContent = "Context " + fmtTok(m.tokens) + " / " + fmtTok(m.contextWindow) + pctStr;
|
|
1614
|
+
node.style.color = pct == null ? "var(--dimmer)" : pct >= 85 ? "var(--orange)" : pct >= 70 ? "var(--yellow)" : "var(--dimmer)";
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/* — recent-chats sidebar + transcript replay — */
|
|
1618
|
+
function relTime(ms) {
|
|
1619
|
+
if (!ms) return "";
|
|
1620
|
+
const s = (Date.now() - ms) / 1000;
|
|
1621
|
+
if (s < 60) return "just now";
|
|
1622
|
+
if (s < 3600) return Math.floor(s / 60) + "m ago";
|
|
1623
|
+
if (s < 86400) return Math.floor(s / 3600) + "h ago";
|
|
1624
|
+
if (s < 604800) return Math.floor(s / 86400) + "d ago";
|
|
1625
|
+
try { return new Date(ms).toLocaleDateString(); } catch { return ""; }
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function newChat() {
|
|
1629
|
+
if (!send({ type: "new_session" })) { toast("Not connected to the agent yet.", "err"); return; }
|
|
1630
|
+
$("chat-box").focus();
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function renderSessions(items, activePath) {
|
|
1634
|
+
const box = $("chat-sessions");
|
|
1635
|
+
if (!items || !items.length) { box.innerHTML = '<div class="sessempty">No saved chats yet. Your conversations are saved locally here.</div>'; return; }
|
|
1636
|
+
box.innerHTML = "";
|
|
1637
|
+
for (const s of items) {
|
|
1638
|
+
const row = el("div", "sess" + (s.path === activePath ? " active" : ""), box);
|
|
1639
|
+
row.innerHTML =
|
|
1640
|
+
'<div class="stitle">' + esc(s.title || "New chat") + '</div>' +
|
|
1641
|
+
'<div class="smeta"><span>' + esc(relTime(s.modified)) + '</span>' +
|
|
1642
|
+
(s.messageCount ? '<span>· ' + s.messageCount + ' msg' + (s.messageCount === 1 ? '' : 's') + '</span>' : '') +
|
|
1643
|
+
(s.forked ? '<span>· forked</span>' : '') + '</div>' +
|
|
1644
|
+
'<button class="sbtn sfork" title="New chat from here">⑂</button>' +
|
|
1645
|
+
'<button class="sbtn sdel" title="Delete chat">✕</button>';
|
|
1646
|
+
row.addEventListener("click", () => send({ type: "open_session", path: s.path }));
|
|
1647
|
+
row.querySelector(".sfork").addEventListener("click", (e) => { e.stopPropagation(); send({ type: "fork_session", path: s.path }); });
|
|
1648
|
+
row.querySelector(".sdel").addEventListener("click", (e) => { e.stopPropagation(); send({ type: "delete_session", path: s.path }); });
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/** Render a finished assistant turn from replayed history (no streaming). */
|
|
1653
|
+
function renderAssistantStatic(item) {
|
|
1654
|
+
const m = el("div", "msg assistant", thread());
|
|
1655
|
+
el("div", "who", m).textContent = "local agent";
|
|
1656
|
+
const bubble = el("div", "bubble", m);
|
|
1657
|
+
if (item.thinking && item.thinking.trim()) {
|
|
1658
|
+
const tb = el("details", "thinkbox", bubble); tb.open = false;
|
|
1659
|
+
el("summary", "", tb).textContent = "Thinking";
|
|
1660
|
+
el("div", "thinkbody", tb).textContent = item.thinking;
|
|
1661
|
+
}
|
|
1662
|
+
if (item.text) { const tn = el("span", "atext", bubble); tn.innerHTML = mdToHtml(item.text); }
|
|
1663
|
+
for (const t of (item.tools || [])) {
|
|
1664
|
+
const wrap = el("div", "tool ok", bubble); // collapsed; click to expand
|
|
1665
|
+
wrap.innerHTML =
|
|
1666
|
+
'<div class="thead"><div class="ticon">⚙</div>' +
|
|
1667
|
+
'<span class="tname">' + esc(t.name || "tool") + '</span>' +
|
|
1668
|
+
'<span class="targs">' + esc(argSummary(t.args)) + '</span>' +
|
|
1669
|
+
'<span class="tstat"><span class="sdot"></span><span class="slabel">done</span></span>' +
|
|
1670
|
+
'<span class="caret">›</span></div>' +
|
|
1671
|
+
'<div class="tbody">' +
|
|
1672
|
+
'<div class="blk"><div class="blbl">arguments</div><pre class="aargs">' + esc(prettyArgs(t.args)) + '</pre></div>' +
|
|
1673
|
+
(t.result ? '<div class="blk tresult"><div class="blbl">result</div><pre class="ares">' + esc(String(t.result).slice(-12000)) + '</pre></div>' : '') +
|
|
1674
|
+
'</div>';
|
|
1675
|
+
wrap.querySelector(".thead").addEventListener("click", () => wrap.classList.toggle("open"));
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/** Authoritative thread rebuild from a server `history` frame. */
|
|
1680
|
+
function renderHistory(items) {
|
|
1681
|
+
turnActive = false;
|
|
1682
|
+
curAssistant = null;
|
|
1683
|
+
$("chat-send").disabled = false;
|
|
1684
|
+
$("chat-stop").style.display = "none";
|
|
1685
|
+
renderQueue({});
|
|
1686
|
+
thread().innerHTML = "";
|
|
1687
|
+
if (!items || !items.length) { $("chat-hello").style.display = ""; return; }
|
|
1688
|
+
$("chat-hello").style.display = "none";
|
|
1689
|
+
for (const it of items) {
|
|
1690
|
+
if (it.role === "user") addUserMsg(it.text);
|
|
1691
|
+
else renderAssistantStatic(it);
|
|
1692
|
+
}
|
|
1693
|
+
stick(true);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/* — server → client dispatch — */
|
|
1697
|
+
function handle(m) {
|
|
1698
|
+
switch (m.type) {
|
|
1699
|
+
case "ready":
|
|
1700
|
+
$("nav-model").textContent = m.model || activeModelId || "no model"; activeModelId = m.model || activeModelId;
|
|
1701
|
+
visionCapable = !!m.vision; updateAttachHint();
|
|
1702
|
+
thinkingCapable = !!m.thinking; updateThinkingToggle();
|
|
1703
|
+
if (thinkingCapable) send({ type: "set_thinking", enabled: thinkingOn });
|
|
1704
|
+
refreshAdapters();
|
|
1705
|
+
// Reconnect: re-open the session we were in (fresh load has none).
|
|
1706
|
+
if (pendingResumePath) { send({ type: "open_session", path: pendingResumePath }); pendingResumePath = null; }
|
|
1707
|
+
break;
|
|
1708
|
+
case "history": renderHistory(m.items); break;
|
|
1709
|
+
case "sessions": currentSessionPath = m.activePath || null; renderSessions(m.items, m.activePath); break;
|
|
1710
|
+
case "context": renderContext(m); break;
|
|
1711
|
+
case "turn_start": turnActive = true; startAssistant(); $("chat-send").disabled = true; $("chat-stop").style.display = "block"; break;
|
|
1712
|
+
case "text_delta": appendDelta(m.delta || ""); break;
|
|
1713
|
+
case "thinking_delta": appendThinkingDelta(m.delta || ""); break;
|
|
1714
|
+
case "tool_start": toolCard(m.callId, m.tool, m.args); break;
|
|
1715
|
+
case "tool_approval_request": approvalCard(m.callId, m.tool, m.args); break;
|
|
1716
|
+
case "tool_update": toolUpdate(m.callId, m.chunk || ""); break;
|
|
1717
|
+
case "tool_end": toolEnd(m.callId, m.ok, m.result); break;
|
|
1718
|
+
case "turn_end": finalizeMeta(); endTurn(); break;
|
|
1719
|
+
case "queue_update": renderQueue(m); break;
|
|
1720
|
+
case "error": showAgentError(m.message || "agent error"); toast(m.message || "agent error", "err"); if (turnActive) endTurn(); break;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/* — composer — */
|
|
1725
|
+
/* — attachments (files + images) — */
|
|
1726
|
+
let attachments = []; // { id, kind:"image"|"text", name, mimeType, data, text, truncated }
|
|
1727
|
+
let visionCapable = false; // from the `ready` frame; gates image attachments
|
|
1728
|
+
let thinkingCapable = false; // from `ready`; gates the reasoning toggle
|
|
1729
|
+
let thinkingOn = true; // on/off state for the model's reasoning channel
|
|
1730
|
+
let attachSeq = 0;
|
|
1731
|
+
const MAX_TEXT_BYTES = 256 * 1024;
|
|
1732
|
+
const TEXT_EXTS = /\.(txt|md|markdown|csv|tsv|json|jsonl|ya?ml|toml|ini|cfg|conf|log|xml|html?|css|scss|js|mjs|cjs|ts|tsx|jsx|py|rs|go|java|c|h|cpp|hpp|cc|cs|rb|php|sh|bash|zsh|fish|sql|swift|kt|lua|r|jl|tex|svg|env|gitignore|dockerfile|makefile)$/i;
|
|
1733
|
+
|
|
1734
|
+
function isTextFile(f) {
|
|
1735
|
+
if (f.type.startsWith("text/")) return true;
|
|
1736
|
+
if (/(json|xml|javascript|yaml|x-sh|x-toml|csv|markdown)/i.test(f.type)) return true;
|
|
1737
|
+
return TEXT_EXTS.test(f.name || "");
|
|
1738
|
+
}
|
|
1739
|
+
function readDataUrl(file) {
|
|
1740
|
+
return new Promise((res, rej) => {
|
|
1741
|
+
const r = new FileReader(); r.onerror = rej;
|
|
1742
|
+
r.onload = () => { const s = String(r.result); const i = s.indexOf(","); res(i >= 0 ? s.slice(i + 1) : s); };
|
|
1743
|
+
r.readAsDataURL(file);
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
function readText(file) {
|
|
1747
|
+
return new Promise((res, rej) => { const r = new FileReader(); r.onerror = rej; r.onload = () => res(String(r.result)); r.readAsText(file); });
|
|
1748
|
+
}
|
|
1749
|
+
async function addFiles(files) {
|
|
1750
|
+
for (const f of files) {
|
|
1751
|
+
if (f.type.startsWith("image/")) {
|
|
1752
|
+
if (!visionCapable) { toast("This model can't see images — switch to the 12B for vision.", "err"); continue; }
|
|
1753
|
+
try { attachments.push({ id: ++attachSeq, kind: "image", name: f.name || "image", mimeType: f.type, data: await readDataUrl(f) }); }
|
|
1754
|
+
catch { toast("Couldn't read " + (f.name || "image"), "err"); }
|
|
1755
|
+
} else if (isTextFile(f)) {
|
|
1756
|
+
try {
|
|
1757
|
+
let text = await readText(f); const truncated = text.length > MAX_TEXT_BYTES; if (truncated) text = text.slice(0, MAX_TEXT_BYTES);
|
|
1758
|
+
attachments.push({ id: ++attachSeq, kind: "text", name: f.name || "file", text, truncated });
|
|
1759
|
+
} catch { toast("Couldn't read " + (f.name || "file"), "err"); }
|
|
1760
|
+
} else {
|
|
1761
|
+
toast("Unsupported file type: " + (f.name || "file"), "err");
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
renderAttachments();
|
|
1765
|
+
}
|
|
1766
|
+
function renderAttachments() {
|
|
1767
|
+
const box = $("chat-attach");
|
|
1768
|
+
box.innerHTML = "";
|
|
1769
|
+
box.style.display = attachments.length ? "flex" : "none";
|
|
1770
|
+
for (const a of attachments) {
|
|
1771
|
+
const chip = el("div", "attach-chip", box);
|
|
1772
|
+
if (a.kind === "image") { const im = el("img", "", chip); im.src = "data:" + a.mimeType + ";base64," + a.data; im.alt = a.name; }
|
|
1773
|
+
else el("span", "att-ico", chip).textContent = "📄";
|
|
1774
|
+
el("span", "att-name", chip).textContent = a.name + (a.truncated ? " (truncated)" : "");
|
|
1775
|
+
const x = el("button", "att-x", chip); x.type = "button"; x.textContent = "✕";
|
|
1776
|
+
x.onclick = () => { attachments = attachments.filter((z) => z.id !== a.id); renderAttachments(); };
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
function clearAttachments() { attachments = []; renderAttachments(); }
|
|
1780
|
+
/** Prepend text-file contents to the message the model sees (UI shows chips). */
|
|
1781
|
+
function buildMessageText(userText) {
|
|
1782
|
+
const files = attachments.filter((a) => a.kind === "text");
|
|
1783
|
+
if (!files.length) return userText;
|
|
1784
|
+
let pre = "";
|
|
1785
|
+
for (const a of files) pre += "Attached file: " + a.name + "\n```\n" + a.text + "\n```\n\n";
|
|
1786
|
+
return pre + userText;
|
|
1787
|
+
}
|
|
1788
|
+
function updateAttachHint() {
|
|
1789
|
+
const btn = $("chat-attach-btn");
|
|
1790
|
+
if (btn) btn.title = visionCapable ? "Attach files or images" : "Attach files (this model can't see images)";
|
|
1791
|
+
}
|
|
1792
|
+
// — LoRA adapter selector. Pi-native: app.html lists + mounts adapters, then
|
|
1793
|
+
// tells pi-web which one is active; the before_provider_request hook injects
|
|
1794
|
+
// it into every provider request. Default "" = none (base model). —
|
|
1795
|
+
async function refreshAdapters() {
|
|
1796
|
+
const sel = $("chat-adapter");
|
|
1797
|
+
if (!sel) return;
|
|
1798
|
+
let list = [];
|
|
1799
|
+
try { const d = await api("/v1/adapters/available"); list = (d && d.adapters) || []; } catch {}
|
|
1800
|
+
const cur = sel.value;
|
|
1801
|
+
sel.innerHTML = '<option value="">no adapter</option>' + list.map((a) =>
|
|
1802
|
+
`<option value="${a.id}" data-path="${a.path}">${a.id}${a.rank ? " · r" + a.rank : ""}</option>`).join("");
|
|
1803
|
+
if (cur && list.some((a) => a.id === cur)) sel.value = cur; // keep prior selection
|
|
1804
|
+
}
|
|
1805
|
+
async function onSelectAdapter(sel) {
|
|
1806
|
+
const id = sel.value;
|
|
1807
|
+
if (!id) { send({ type: "set_adapter", id: null }); return; } // none → base model
|
|
1808
|
+
const opt = sel.options[sel.selectedIndex];
|
|
1809
|
+
const path = opt && opt.getAttribute("data-path");
|
|
1810
|
+
if (path) { // auto-load on select; mount() is idempotent server-side
|
|
1811
|
+
const r = await api("/v1/adapters", { method: "POST", body: { id, path } });
|
|
1812
|
+
if (r && (r.error || r.ok === false)) {
|
|
1813
|
+
if (typeof toast === "function") toast("adapter: " + (r.error || "mount failed"), "err");
|
|
1814
|
+
sel.value = ""; send({ type: "set_adapter", id: null }); return;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
send({ type: "set_adapter", id });
|
|
1818
|
+
}
|
|
1819
|
+
function updateThinkingToggle() {
|
|
1820
|
+
const b = $("chat-think");
|
|
1821
|
+
if (!b) return;
|
|
1822
|
+
b.style.display = thinkingCapable ? "inline-flex" : "none"; // only for thinking-capable models
|
|
1823
|
+
b.classList.toggle("think-on", thinkingOn);
|
|
1824
|
+
b.title = thinkingOn ? "Reasoning on — click to answer directly" : "Reasoning off — click to let the model think";
|
|
1825
|
+
b.setAttribute("aria-pressed", String(thinkingOn));
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/* — composer — */
|
|
1829
|
+
function submit() {
|
|
1830
|
+
const box = $("chat-box");
|
|
1831
|
+
const text = box.value.trim();
|
|
1832
|
+
// Client-side slash commands for session control (not sent to the model).
|
|
1833
|
+
const cmd = text.toLowerCase();
|
|
1834
|
+
if (cmd === "/reload" || cmd === "/new" || cmd === "/clear") {
|
|
1835
|
+
box.value = ""; box.style.height = "auto"; clearAttachments(); newChat();
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
if (!text && attachments.length === 0) return;
|
|
1839
|
+
if (!connected) { toast("Not connected to the agent yet — reconnecting.", "err"); return; }
|
|
1840
|
+
const imgs = attachments.filter((a) => a.kind === "image").map((a) => ({ data: a.data, mimeType: a.mimeType }));
|
|
1841
|
+
const combined = buildMessageText(text);
|
|
1842
|
+
addUserMsg(text, attachments);
|
|
1843
|
+
box.value = ""; box.style.height = "auto";
|
|
1844
|
+
const frame = imgs.length ? { text: combined, images: imgs } : { text: combined };
|
|
1845
|
+
if (turnActive) send({ type: "steer", ...frame }); // mid-turn: steer/queue
|
|
1846
|
+
else send({ type: "prompt", ...frame });
|
|
1847
|
+
clearAttachments();
|
|
1848
|
+
}
|
|
1849
|
+
const scroll = () => $("chat-scroll");
|
|
1850
|
+
const atBottom = () => scroll().scrollHeight - scroll().scrollTop - scroll().clientHeight < 90;
|
|
1851
|
+
function stick(force) { if (force || atBottom()) scroll().scrollTop = scroll().scrollHeight; }
|
|
1852
|
+
|
|
1853
|
+
return {
|
|
1854
|
+
init() {
|
|
1855
|
+
$("chat-send").onclick = submit;
|
|
1856
|
+
$("chat-new").onclick = newChat;
|
|
1857
|
+
$("chat-stop").onclick = () => { send({ type: "abort" }); $("chat-stop").style.display = "none"; };
|
|
1858
|
+
const thinkToggle = () => { thinkingOn = !thinkingOn; updateThinkingToggle(); send({ type: "set_thinking", enabled: thinkingOn }); };
|
|
1859
|
+
$("chat-think").onclick = thinkToggle;
|
|
1860
|
+
$("chat-think").addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); thinkToggle(); } });
|
|
1861
|
+
const adapterSel = $("chat-adapter");
|
|
1862
|
+
if (adapterSel) adapterSel.onchange = () => onSelectAdapter(adapterSel);
|
|
1863
|
+
refreshAdapters();
|
|
1864
|
+
const box = $("chat-box");
|
|
1865
|
+
box.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } });
|
|
1866
|
+
box.addEventListener("input", () => { box.style.height = "auto"; box.style.height = Math.min(box.scrollHeight, 200) + "px"; });
|
|
1867
|
+
document.querySelectorAll("#chat-hello .chip").forEach((c) =>
|
|
1868
|
+
c.addEventListener("click", () => { $("chat-box").value = c.dataset.q; submit(); }));
|
|
1869
|
+
// Attachments: + button, file picker, clipboard paste, drag-and-drop.
|
|
1870
|
+
$("chat-attach-btn").onclick = () => $("chat-file-input").click();
|
|
1871
|
+
$("chat-file-input").addEventListener("change", (e) => { addFiles([...e.target.files]); e.target.value = ""; });
|
|
1872
|
+
box.addEventListener("paste", (e) => {
|
|
1873
|
+
const items = e.clipboardData && e.clipboardData.items;
|
|
1874
|
+
if (!items) return;
|
|
1875
|
+
const files = [];
|
|
1876
|
+
for (const it of items) if (it.kind === "file") { const f = it.getAsFile(); if (f) files.push(f); }
|
|
1877
|
+
if (files.length) { e.preventDefault(); addFiles(files); }
|
|
1878
|
+
});
|
|
1879
|
+
const composer = document.querySelector("#s-chat .composer");
|
|
1880
|
+
if (composer) {
|
|
1881
|
+
["dragenter", "dragover"].forEach((ev) => composer.addEventListener(ev, (e) => { e.preventDefault(); composer.classList.add("dragover"); }));
|
|
1882
|
+
["dragleave", "drop"].forEach((ev) => composer.addEventListener(ev, (e) => {
|
|
1883
|
+
e.preventDefault();
|
|
1884
|
+
if (ev === "drop" && e.dataTransfer && e.dataTransfer.files.length) addFiles([...e.dataTransfer.files]);
|
|
1885
|
+
if (ev === "drop" || !composer.contains(e.relatedTarget)) composer.classList.remove("dragover");
|
|
1886
|
+
}));
|
|
1887
|
+
}
|
|
1888
|
+
// Delegated copy buttons for rendered code blocks (the thread element
|
|
1889
|
+
// is stable even though its contents re-render on every token).
|
|
1890
|
+
thread().addEventListener("click", (e) => {
|
|
1891
|
+
const btn = e.target.closest && e.target.closest(".cbcopy");
|
|
1892
|
+
if (!btn) return;
|
|
1893
|
+
const code = btn.closest(".codeblock")?.querySelector("code");
|
|
1894
|
+
if (!code || !navigator.clipboard) return;
|
|
1895
|
+
navigator.clipboard.writeText(code.textContent).then(() => {
|
|
1896
|
+
btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Copy"; }, 1200);
|
|
1897
|
+
}).catch(() => {});
|
|
1898
|
+
});
|
|
1899
|
+
},
|
|
1900
|
+
enter() { if (!ws || ws.readyState > 1) connect(); $("chat-box").focus(); },
|
|
1901
|
+
leave() { /* keep WS warm; closing on every nav would drop in-flight turns */ },
|
|
1902
|
+
};
|
|
1903
|
+
})();
|
|
1904
|
+
|
|
1905
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
1906
|
+
QUANTIZE CONTROLLER — 4-step wizard
|
|
1907
|
+
════════════════════════════════════════════════════════════════════ */
|
|
1908
|
+
controllers.quantize = (() => {
|
|
1909
|
+
const STEPS = ["Source", "Configure", "Run", "Done"];
|
|
1910
|
+
let step = 0, bits = 4, gs = 64, mode = "uniform", bpw = 5, jobId = null, es = null, log = [];
|
|
1911
|
+
|
|
1912
|
+
function show(n) {
|
|
1913
|
+
step = n;
|
|
1914
|
+
document.querySelectorAll("[data-qstep]").forEach((d) => d.style.display = (+d.dataset.qstep === n) ? "" : "none");
|
|
1915
|
+
renderSteps($("q-steps"), STEPS, n);
|
|
1916
|
+
}
|
|
1917
|
+
function seg(container, onPick) {
|
|
1918
|
+
container.querySelectorAll("button").forEach((b) =>
|
|
1919
|
+
b.addEventListener("click", () => {
|
|
1920
|
+
container.querySelectorAll("button").forEach((x) => x.classList.remove("on"));
|
|
1921
|
+
b.classList.add("on"); onPick(b.dataset.v, b);
|
|
1922
|
+
}));
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
async function inspect() {
|
|
1926
|
+
const id = $("q-model").value.trim();
|
|
1927
|
+
if (!id) { toast("Enter a model id or path first.", "err"); return; }
|
|
1928
|
+
const out = $("q-inspect-out");
|
|
1929
|
+
out.innerHTML = '<div class="flash"><span class="shimmer">inspecting…</span></div>';
|
|
1930
|
+
const btn = $("q-inspect"); btn.disabled = true;
|
|
1931
|
+
const d = await api("/api/quantize/inspect", { method: "POST", body: { model_id: id } }).catch(() => ({ ok: false, error: "request failed" }));
|
|
1932
|
+
btn.disabled = false;
|
|
1933
|
+
if (!d.ok) { out.innerHTML = '<div class="flash err">' + esc(d.error || "could not inspect this model") + "</div>"; return; }
|
|
1934
|
+
const supported = d.support === "supported" || d.support === true || d.support === "ok";
|
|
1935
|
+
const cls = supported ? "ok" : (d.support === "untested" ? "warn" : "err");
|
|
1936
|
+
out.innerHTML =
|
|
1937
|
+
'<div class="flash ' + cls + '"><strong>' + esc(d.arch || d.model_id || id) + "</strong>" +
|
|
1938
|
+
(d.size_gb != null ? " · " + esc(d.size_gb) + " GB" : "") +
|
|
1939
|
+
" · " + (supported ? "supported" : esc(d.support || "unknown architecture")) + "</div>" +
|
|
1940
|
+
'<div class="btnrow"><button class="btn primary" id="q-continue">Continue</button></div>';
|
|
1941
|
+
$("q-continue").onclick = () => show(1);
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
async function submit() {
|
|
1945
|
+
const id = $("q-model").value.trim();
|
|
1946
|
+
const btn = $("q-submit"); btn.disabled = true;
|
|
1947
|
+
const body = { model_id: id, bits, group_size: gs };
|
|
1948
|
+
if (mode === "mixed") { body.target_bpw = bpw; body.candidate_bits = [4, 8]; }
|
|
1949
|
+
const d = await api("/api/quantize/submit", { method: "POST", body })
|
|
1950
|
+
.catch(() => ({ ok: false, error: "request failed" }));
|
|
1951
|
+
btn.disabled = false;
|
|
1952
|
+
if (!d.ok) { toast(d.error || "could not start quantize", "err"); return; }
|
|
1953
|
+
jobId = d.job_id || null;
|
|
1954
|
+
$("q-push-panel").innerHTML = "";
|
|
1955
|
+
$("q-out").textContent = d.output_dir || "";
|
|
1956
|
+
log = []; $("q-log").textContent = ""; $("q-bar").style.width = "0%"; $("q-pct").textContent = "0%"; $("q-msg").textContent = "Starting…";
|
|
1957
|
+
show(2);
|
|
1958
|
+
attach(d.job_id);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
function attach(jobId) {
|
|
1962
|
+
if (es) es.close();
|
|
1963
|
+
es = jobStream(jobId, {
|
|
1964
|
+
log: (e) => { log.push(e.line); $("q-log").textContent = log.slice(-200).join("\n"); $("q-log").scrollTop = $("q-log").scrollHeight; },
|
|
1965
|
+
stage: (e) => {
|
|
1966
|
+
if (e.progress != null) { const p = Math.round(e.progress * 100); $("q-bar").style.width = p + "%"; $("q-pct").textContent = p + "%"; }
|
|
1967
|
+
if (e.message) $("q-msg").textContent = e.message;
|
|
1968
|
+
if (e.stage === "done") { if (e.output_dir) $("q-out").textContent = e.output_dir; finish(); }
|
|
1969
|
+
},
|
|
1970
|
+
done: () => finish(),
|
|
1971
|
+
failed: (e) => { $("q-msg").textContent = "Failed: " + (e.error || "unknown error"); toast(e.error || "quantize failed", "err"); es && es.close(); },
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
function finish() { $("q-bar").style.width = "100%"; $("q-pct").textContent = "100%"; es && es.close(); show(3); }
|
|
1975
|
+
|
|
1976
|
+
return {
|
|
1977
|
+
init() {
|
|
1978
|
+
show(0);
|
|
1979
|
+
$("q-inspect").onclick = inspect;
|
|
1980
|
+
$("q-model").addEventListener("keydown", (e) => { if (e.key === "Enter") inspect(); });
|
|
1981
|
+
seg($("q-bits"), (v) => { bits = +v; $("q-bits-hint").textContent = v === "4"
|
|
1982
|
+
? "4-bit · ~4× smaller than bf16 · the recommended default for local serving."
|
|
1983
|
+
: "8-bit · ~2× smaller than bf16 · highest fidelity quant."; });
|
|
1984
|
+
seg($("q-gs"), (v) => { gs = +v; });
|
|
1985
|
+
seg($("q-mode"), (v) => { mode = v; $("q-bpw-field").style.display = v === "mixed" ? "" : "none"; });
|
|
1986
|
+
$("q-bpw").addEventListener("input", (e) => { bpw = +e.target.value; $("q-bpw-val").textContent = bpw.toFixed(1); });
|
|
1987
|
+
document.querySelectorAll("#s-quantize [data-qback]").forEach((b) => b.onclick = () => show(step - 1));
|
|
1988
|
+
$("q-submit").onclick = submit;
|
|
1989
|
+
$("q-push").onclick = () => pushToHub($("q-push-panel"), { kind: "quantize", job_id: jobId });
|
|
1990
|
+
$("q-again").onclick = () => { $("q-inspect-out").innerHTML = ""; $("q-push-panel").innerHTML = ""; show(0); };
|
|
1991
|
+
},
|
|
1992
|
+
};
|
|
1993
|
+
})();
|
|
1994
|
+
|
|
1995
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
1996
|
+
FINE-TUNE CONTROLLER — 5-step wizard with live loss chart
|
|
1997
|
+
════════════════════════════════════════════════════════════════════ */
|
|
1998
|
+
controllers.finetune = (() => {
|
|
1999
|
+
const STEPS = ["Base", "Dataset", "Hyperparams", "Train", "Done"];
|
|
2000
|
+
let step = 0, method = "sft", lrTouched = false, es = null, log = [];
|
|
2001
|
+
let datasetOk = false, adapterPath = "";
|
|
2002
|
+
const trainPts = [], valPts = [];
|
|
2003
|
+
|
|
2004
|
+
function show(n) {
|
|
2005
|
+
step = n;
|
|
2006
|
+
document.querySelectorAll("[data-fstep]").forEach((d) => d.style.display = (+d.dataset.fstep === n) ? "" : "none");
|
|
2007
|
+
renderSteps($("f-steps"), STEPS, n);
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
async function inspectDataset() {
|
|
2011
|
+
const path = $("f-data").value.trim();
|
|
2012
|
+
if (!path) { toast("Enter a dataset directory.", "err"); return; }
|
|
2013
|
+
const out = $("f-inspect-out");
|
|
2014
|
+
out.innerHTML = '<div class="flash"><span class="shimmer">inspecting…</span></div>';
|
|
2015
|
+
const d = await api("/api/finetune/inspect-dataset", { method: "POST", body: { path } }).catch(() => ({ ok: false, error: "request failed" }));
|
|
2016
|
+
if (!d.ok) { out.innerHTML = '<div class="flash err">' + esc(d.error || "could not read dataset") + "</div>"; datasetOk = false; $("f-next1").disabled = true; return; }
|
|
2017
|
+
datasetOk = true; $("f-next1").disabled = false;
|
|
2018
|
+
out.innerHTML = '<div class="flash ok"><strong>' + num(d.n_train) + "</strong> train rows" +
|
|
2019
|
+
(d.n_valid ? " · <strong>" + num(d.n_valid) + "</strong> valid rows" : "") +
|
|
2020
|
+
(d.format ? " · format <strong>" + esc(d.format) + "</strong>" : "") + "</div>";
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
function setMethod(m) {
|
|
2024
|
+
method = m;
|
|
2025
|
+
$("f-method").querySelectorAll("button").forEach((b) => b.classList.toggle("on", b.dataset.v === m));
|
|
2026
|
+
$("f-dpo-extra").style.display = m === "dpo" ? "" : "none";
|
|
2027
|
+
$("f-method-hint").textContent = m === "dpo"
|
|
2028
|
+
? "Direct Preference Optimization — data is {prompt, chosen, rejected}."
|
|
2029
|
+
: "Supervised fine-tuning — data is messages or prompt+completion.";
|
|
2030
|
+
if (!lrTouched) $("f-lr").value = m === "dpo" ? 0.00005 : 0.0002;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
function collectHP() {
|
|
2034
|
+
const modules = $("f-modules").value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
2035
|
+
const hp = {
|
|
2036
|
+
model_dir: $("f-model").value.trim(),
|
|
2037
|
+
data_dir: $("f-data").value.trim(),
|
|
2038
|
+
method,
|
|
2039
|
+
rank: +$("f-rank").value, scale: +$("f-scale").value, dropout: +$("f-dropout").value,
|
|
2040
|
+
target_modules: modules, num_layers: +$("f-layers").value, iters: +$("f-iters").value,
|
|
2041
|
+
batch_size: +$("f-batch").value, learning_rate: +$("f-lr").value, max_seq_length: +$("f-seq").value,
|
|
2042
|
+
};
|
|
2043
|
+
if (method === "dpo") { hp.dpo_beta = +$("f-beta").value; hp.lr_schedule = $("f-sched").value; }
|
|
2044
|
+
return hp;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
async function submit() {
|
|
2048
|
+
const btn = $("f-submit"); btn.disabled = true;
|
|
2049
|
+
const d = await api("/api/finetune/submit", { method: "POST", body: collectHP() }).catch(() => ({ ok: false, error: "request failed" }));
|
|
2050
|
+
btn.disabled = false;
|
|
2051
|
+
if (!d.ok) { toast(d.error || "could not start training", "err"); return; }
|
|
2052
|
+
adapterPath = d.adapter_path || "";
|
|
2053
|
+
$("f-out").textContent = adapterPath;
|
|
2054
|
+
$("f-merge-out").innerHTML = $("f-exp-out").innerHTML = $("f-push-panel").innerHTML = "";
|
|
2055
|
+
trainPts.length = 0; valPts.length = 0; log = [];
|
|
2056
|
+
$("f-log").textContent = ""; $("f-bar").style.width = "0%"; $("f-pct").textContent = "0%"; $("f-msg").textContent = "Starting…";
|
|
2057
|
+
$("f-step").textContent = $("f-loss").textContent = $("f-curlr").textContent = $("f-curtps").textContent = "—";
|
|
2058
|
+
drawChart();
|
|
2059
|
+
show(3);
|
|
2060
|
+
attach(d.job_id);
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function attach(jobId) {
|
|
2064
|
+
if (es) es.close();
|
|
2065
|
+
es = jobStream(jobId, {
|
|
2066
|
+
log: (e) => { log.push(e.line); $("f-log").textContent = log.slice(-200).join("\n"); $("f-log").scrollTop = $("f-log").scrollHeight; },
|
|
2067
|
+
metric: (e) => {
|
|
2068
|
+
if (e.kind === "val") { valPts.push({ step: e.step, loss: e.loss }); $("f-leg-val").style.display = ""; }
|
|
2069
|
+
else {
|
|
2070
|
+
trainPts.push({ step: e.step, loss: e.loss });
|
|
2071
|
+
$("f-step").textContent = num(e.step);
|
|
2072
|
+
$("f-loss").textContent = e.loss != null ? e.loss.toFixed(4) : "—";
|
|
2073
|
+
if (e.lr != null) $("f-curlr").textContent = e.lr.toExponential(2);
|
|
2074
|
+
if (e.tps != null) $("f-curtps").textContent = e.tps.toFixed(1);
|
|
2075
|
+
if (e.progress != null) { const p = Math.round(e.progress * 100); $("f-bar").style.width = p + "%"; $("f-pct").textContent = p + "%"; }
|
|
2076
|
+
if (e.message) $("f-msg").textContent = e.message;
|
|
2077
|
+
}
|
|
2078
|
+
drawChart();
|
|
2079
|
+
},
|
|
2080
|
+
stage: (e) => {
|
|
2081
|
+
if (e.progress != null) { const p = Math.round(e.progress * 100); $("f-bar").style.width = p + "%"; $("f-pct").textContent = p + "%"; }
|
|
2082
|
+
if (e.message) $("f-msg").textContent = e.message;
|
|
2083
|
+
if (e.stage === "done") { if (e.adapter_path) { adapterPath = e.adapter_path; $("f-out").textContent = e.adapter_path; } finish(); }
|
|
2084
|
+
},
|
|
2085
|
+
done: () => finish(),
|
|
2086
|
+
failed: (e) => { $("f-msg").textContent = "Failed: " + (e.error || "unknown error"); toast(e.error || "training failed", "err"); es && es.close(); },
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
function finish() {
|
|
2090
|
+
$("f-bar").style.width = "100%"; $("f-pct").textContent = "100%"; es && es.close();
|
|
2091
|
+
if (!adapterPath) adapterPath = $("f-out").textContent.trim();
|
|
2092
|
+
const base = $("f-model").value.trim();
|
|
2093
|
+
// prefill the done-step affordances from this run's paths
|
|
2094
|
+
$("f-merge-a").value = adapterPath;
|
|
2095
|
+
$("f-exp-base").value = base;
|
|
2096
|
+
$("f-exp-adapter").value = adapterPath;
|
|
2097
|
+
show(4);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
async function mergeAdapters() {
|
|
2101
|
+
const a = $("f-merge-a").value.trim(), b = $("f-merge-b").value.trim();
|
|
2102
|
+
const out = $("f-merge-out");
|
|
2103
|
+
if (!a || !b) { out.innerHTML = '<div class="flash err">Enter both adapter paths.</div>'; return; }
|
|
2104
|
+
out.innerHTML = '<div class="flash"><span class="shimmer">merging…</span></div>';
|
|
2105
|
+
const btn = $("f-merge-go"); btn.disabled = true;
|
|
2106
|
+
const d = await api("/api/finetune/merge", { method: "POST", body: { adapter_a: a, adapter_b: b } }).catch(() => ({ ok: false, error: "request failed" }));
|
|
2107
|
+
btn.disabled = false;
|
|
2108
|
+
if (!d.ok) { out.innerHTML = '<div class="flash err">' + esc(d.error || "merge failed") + "</div>"; return; }
|
|
2109
|
+
const stats = d.stats && typeof d.stats === "object" ? Object.entries(d.stats).map(([k, v]) => esc(k) + " " + esc(v)).join(" · ") : (d.stats ? esc(d.stats) : "");
|
|
2110
|
+
out.innerHTML = '<div class="flash ok">Merged → <code>' + esc(d.merged_path || "") + "</code>" + (stats ? "<br>" + stats : "") + "</div>";
|
|
2111
|
+
toast("Adapters merged", "ok");
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
async function exportModel() {
|
|
2115
|
+
const base = $("f-exp-base").value.trim(), adapter = $("f-exp-adapter").value.trim();
|
|
2116
|
+
const out = $("f-exp-out");
|
|
2117
|
+
if (!base || !adapter) { out.innerHTML = '<div class="flash err">Enter a base model and adapter path.</div>'; return; }
|
|
2118
|
+
out.innerHTML = '<div class="flash"><span class="shimmer">exporting…</span></div>';
|
|
2119
|
+
const btn = $("f-exp-go"); btn.disabled = true;
|
|
2120
|
+
const d = await api("/api/finetune/export", { method: "POST", body: { base_model: base, adapter_path: adapter } }).catch(() => ({ ok: false, error: "request failed" }));
|
|
2121
|
+
btn.disabled = false;
|
|
2122
|
+
if (!d.ok) { out.innerHTML = '<div class="flash err">' + esc(d.error || "export failed") + "</div>"; return; }
|
|
2123
|
+
out.innerHTML = '<div class="flash ok">Exported → <code>' + esc(d.export_path || "") + "</code></div>";
|
|
2124
|
+
toast("Model exported", "ok");
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/* inline SVG line plot — themed green (train) / blue (val) */
|
|
2128
|
+
function drawChart() {
|
|
2129
|
+
const svg = $("f-chart");
|
|
2130
|
+
const W = 600, H = 200, padL = 8, padR = 8, padT = 12, padB = 12;
|
|
2131
|
+
const all = trainPts.concat(valPts);
|
|
2132
|
+
if (!all.length) { svg.innerHTML = '<text x="300" y="104" text-anchor="middle" fill="var(--dimmer)" font-size="13">waiting for the first metric…</text>'; return; }
|
|
2133
|
+
const losses = all.map((p) => p.loss).filter((x) => x != null && isFinite(x));
|
|
2134
|
+
let minL = Math.min(...losses), maxL = Math.max(...losses);
|
|
2135
|
+
if (minL === maxL) { minL -= 0.5; maxL += 0.5; }
|
|
2136
|
+
const range = maxL - minL || 1;
|
|
2137
|
+
const maxStep = Math.max(...all.map((p) => p.step || 0), 1);
|
|
2138
|
+
const xOf = (s) => padL + (s / maxStep) * (W - padL - padR);
|
|
2139
|
+
const yOf = (l) => padT + (1 - (l - minL) / range) * (H - padT - padB);
|
|
2140
|
+
const path = (pts, color, fillId) => {
|
|
2141
|
+
if (!pts.length) return "";
|
|
2142
|
+
const d = pts.map((p, i) => (i ? "L" : "M") + xOf(p.step).toFixed(1) + " " + yOf(p.loss).toFixed(1)).join(" ");
|
|
2143
|
+
let out = '<path d="' + d + '" fill="none" stroke="' + color + '" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
|
|
2144
|
+
// soft area fill under the curve
|
|
2145
|
+
const area = d + " L" + xOf(pts[pts.length - 1].step).toFixed(1) + " " + (H - padB) + " L" + xOf(pts[0].step).toFixed(1) + " " + (H - padB) + " Z";
|
|
2146
|
+
out = '<path d="' + area + '" fill="url(#' + fillId + ')" opacity="0.18"/>' + out;
|
|
2147
|
+
return out;
|
|
2148
|
+
};
|
|
2149
|
+
// gridlines
|
|
2150
|
+
let grid = "";
|
|
2151
|
+
for (let i = 1; i < 4; i++) { const y = padT + (i / 4) * (H - padT - padB); grid += '<line x1="0" y1="' + y.toFixed(1) + '" x2="' + W + '" y2="' + y.toFixed(1) + '" stroke="rgba(255,255,255,.06)" stroke-width="1"/>'; }
|
|
2152
|
+
svg.innerHTML =
|
|
2153
|
+
'<defs>' +
|
|
2154
|
+
'<linearGradient id="gf-train" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#30d158"/><stop offset="1" stop-color="#30d158" stop-opacity="0"/></linearGradient>' +
|
|
2155
|
+
'<linearGradient id="gf-val" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0a84ff"/><stop offset="1" stop-color="#0a84ff" stop-opacity="0"/></linearGradient>' +
|
|
2156
|
+
'</defs>' + grid +
|
|
2157
|
+
path(valPts, "#0a84ff", "gf-val") +
|
|
2158
|
+
path(trainPts, "#30d158", "gf-train");
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
return {
|
|
2162
|
+
init() {
|
|
2163
|
+
show(0);
|
|
2164
|
+
$("f-next0").onclick = () => { if (!$("f-model").value.trim()) { toast("Enter a base model path.", "err"); return; } show(1); };
|
|
2165
|
+
$("f-inspect").onclick = inspectDataset;
|
|
2166
|
+
$("f-data").addEventListener("keydown", (e) => { if (e.key === "Enter") inspectDataset(); });
|
|
2167
|
+
$("f-data").addEventListener("input", () => { datasetOk = false; $("f-next1").disabled = true; $("f-inspect-out").innerHTML = ""; });
|
|
2168
|
+
$("f-next1").onclick = () => { if (datasetOk) show(2); };
|
|
2169
|
+
$("f-method").querySelectorAll("button").forEach((b) => b.onclick = () => setMethod(b.dataset.v));
|
|
2170
|
+
$("f-lr").addEventListener("input", () => { lrTouched = true; });
|
|
2171
|
+
document.querySelectorAll("#s-finetune [data-fback]").forEach((b) => b.onclick = () => show(step - 1));
|
|
2172
|
+
$("f-submit").onclick = submit;
|
|
2173
|
+
$("f-merge-go").onclick = mergeAdapters;
|
|
2174
|
+
$("f-exp-go").onclick = exportModel;
|
|
2175
|
+
$("f-push").onclick = () => pushToHub($("f-push-panel"), { kind: "finetune", source_path: adapterPath || $("f-out").textContent.trim() });
|
|
2176
|
+
$("f-again").onclick = () => {
|
|
2177
|
+
$("f-inspect-out").innerHTML = "";
|
|
2178
|
+
$("f-merge-out").innerHTML = $("f-exp-out").innerHTML = $("f-push-panel").innerHTML = "";
|
|
2179
|
+
show(0);
|
|
2180
|
+
};
|
|
2181
|
+
},
|
|
2182
|
+
};
|
|
2183
|
+
})();
|
|
2184
|
+
|
|
2185
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
2186
|
+
DATASET CONTROLLER — template-driven builder
|
|
2187
|
+
════════════════════════════════════════════════════════════════════ */
|
|
2188
|
+
controllers.dataset = (() => {
|
|
2189
|
+
const STEPS = ["Template", "Inputs", "Generate", "Done"];
|
|
2190
|
+
let step = 0, templates = [], selected = null, jobId = null, es = null, log = [];
|
|
2191
|
+
|
|
2192
|
+
function show(n) {
|
|
2193
|
+
step = n;
|
|
2194
|
+
document.querySelectorAll("[data-dstep]").forEach((d) => d.style.display = (+d.dataset.dstep === n) ? "" : "none");
|
|
2195
|
+
renderSteps($("d-steps"), STEPS, n);
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
async function loadTemplates() {
|
|
2199
|
+
const grid = $("d-templates");
|
|
2200
|
+
grid.innerHTML = Array.from({ length: 4 }, () => '<div class="card" style="height:130px"><div class="shimmer" style="height:18px;width:50%;margin-bottom:12px"></div><div class="shimmer" style="height:13px;width:90%;margin-bottom:6px"></div><div class="shimmer" style="height:13px;width:70%"></div></div>').join("");
|
|
2201
|
+
const d = await api("/api/dataset/templates").catch(() => ({ templates: null }));
|
|
2202
|
+
templates = (d && d.templates) || [];
|
|
2203
|
+
if (!templates.length) { grid.innerHTML = '<div class="empty" style="grid-column:1/-1"><h3>No templates available</h3><p>The server didn\'t return any dataset templates. Make sure the server is running.</p></div>'; return; }
|
|
2204
|
+
// non-LLM templates first
|
|
2205
|
+
const ordered = templates.slice().sort((a, b) => (a.needs_llm ? 1 : 0) - (b.needs_llm ? 1 : 0));
|
|
2206
|
+
grid.innerHTML = ordered.map((t, i) =>
|
|
2207
|
+
'<div class="card" data-tid="' + esc(t.id) + '" style="cursor:pointer;display:flex;flex-direction:column;gap:8px">' +
|
|
2208
|
+
'<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">' +
|
|
2209
|
+
'<h3 style="margin:0;font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--ink);text-transform:none">' + esc(t.label || t.id) + "</h3>" +
|
|
2210
|
+
(t.needs_llm ? '<span class="pill warn" style="padding:3px 9px;font-size:11px"><span class="dot"></span>uses local model</span>' : "") +
|
|
2211
|
+
(t.output_format ? '<span class="soon" style="color:var(--dim)">' + esc(t.output_format) + "</span>" : "") +
|
|
2212
|
+
"</div>" +
|
|
2213
|
+
'<p style="color:var(--dim);font-size:13.5px;line-height:1.5;margin:0">' + esc(t.description || "") + "</p>" +
|
|
2214
|
+
"</div>").join("");
|
|
2215
|
+
grid.querySelectorAll("[data-tid]").forEach((c) =>
|
|
2216
|
+
c.onclick = () => selectTemplate(templates.find((t) => t.id === c.dataset.tid)));
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function selectTemplate(t) {
|
|
2220
|
+
selected = t;
|
|
2221
|
+
$("d-form-title").textContent = t.label || t.id;
|
|
2222
|
+
$("d-form-desc").textContent = t.description || "";
|
|
2223
|
+
const form = $("d-form");
|
|
2224
|
+
form.innerHTML = (t.fields || []).map((f) => {
|
|
2225
|
+
const lbl = esc(f.label || f.name) + (f.required ? " *" : "");
|
|
2226
|
+
const ph = f.hint ? ' placeholder="' + esc(f.hint) + '"' : "";
|
|
2227
|
+
const dv = f.default != null ? f.default : "";
|
|
2228
|
+
let ctrl;
|
|
2229
|
+
if (f.type === "textarea") ctrl = '<textarea data-fld="' + esc(f.name) + '"' + ph + ">" + esc(dv) + "</textarea>";
|
|
2230
|
+
else if (f.type === "number") ctrl = '<input type="number" data-fld="' + esc(f.name) + '" value="' + esc(dv) + '"' + ph + ">";
|
|
2231
|
+
else ctrl = '<input type="text" data-fld="' + esc(f.name) + '" value="' + esc(dv) + '"' + ph + ">";
|
|
2232
|
+
return '<div class="field"><label>' + lbl + "</label>" + ctrl + (f.hint ? '<div class="hint">' + esc(f.hint) + "</div>" : "") + "</div>";
|
|
2233
|
+
}).join("");
|
|
2234
|
+
show(1);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
async function submit() {
|
|
2238
|
+
if (!selected) return;
|
|
2239
|
+
const inputs = {};
|
|
2240
|
+
$("d-form").querySelectorAll("[data-fld]").forEach((i) => {
|
|
2241
|
+
inputs[i.dataset.fld] = i.type === "number" ? (i.value === "" ? null : +i.value) : i.value;
|
|
2242
|
+
});
|
|
2243
|
+
const body = { template_id: selected.id, inputs };
|
|
2244
|
+
if (selected.needs_llm && activeModelId) body.model_name = activeModelId;
|
|
2245
|
+
const btn = $("d-submit"); btn.disabled = true;
|
|
2246
|
+
const d = await api("/api/dataset/submit", { method: "POST", body }).catch(() => ({ ok: false, error: "request failed" }));
|
|
2247
|
+
btn.disabled = false;
|
|
2248
|
+
if (!d.ok) { toast(d.error || "could not start generation", "err"); return; }
|
|
2249
|
+
jobId = d.job_id || null;
|
|
2250
|
+
$("d-push-panel").innerHTML = "";
|
|
2251
|
+
$("d-out").textContent = d.output_dir || "";
|
|
2252
|
+
log = []; $("d-log").textContent = ""; $("d-bar").style.width = "0%"; $("d-pct").textContent = "0%"; $("d-msg").textContent = "Starting…";
|
|
2253
|
+
$("d-ntrain").textContent = $("d-nvalid").textContent = "—";
|
|
2254
|
+
show(2);
|
|
2255
|
+
attach(d.job_id);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
function attach(jobId) {
|
|
2259
|
+
if (es) es.close();
|
|
2260
|
+
es = jobStream(jobId, {
|
|
2261
|
+
log: (e) => { log.push(e.line); $("d-log").textContent = log.slice(-200).join("\n"); $("d-log").scrollTop = $("d-log").scrollHeight; },
|
|
2262
|
+
stage: (e) => {
|
|
2263
|
+
if (e.progress != null) { const p = Math.round(e.progress * 100); $("d-bar").style.width = p + "%"; $("d-pct").textContent = p + "%"; }
|
|
2264
|
+
if (e.message) $("d-msg").textContent = e.message;
|
|
2265
|
+
if (e.n_train != null) $("d-ntrain").textContent = num(e.n_train);
|
|
2266
|
+
if (e.n_valid != null) $("d-nvalid").textContent = num(e.n_valid);
|
|
2267
|
+
if (e.stage === "done") { if (e.output_dir) $("d-out").textContent = e.output_dir; finish(e); }
|
|
2268
|
+
},
|
|
2269
|
+
done: (e) => finish(e),
|
|
2270
|
+
failed: (e) => { $("d-msg").textContent = "Failed: " + (e.error || "unknown error"); toast(e.error || "generation failed", "err"); es && es.close(); },
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
function finish(e) {
|
|
2274
|
+
$("d-bar").style.width = "100%"; $("d-pct").textContent = "100%"; es && es.close();
|
|
2275
|
+
if (e && e.n_train != null) $("d-ntrain").textContent = num(e.n_train);
|
|
2276
|
+
if (e && e.n_valid != null) $("d-nvalid").textContent = num(e.n_valid);
|
|
2277
|
+
show(3);
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
return {
|
|
2281
|
+
init() {
|
|
2282
|
+
show(0);
|
|
2283
|
+
loadTemplates();
|
|
2284
|
+
document.querySelectorAll("#s-dataset [data-dback]").forEach((b) => b.onclick = () => show(0));
|
|
2285
|
+
$("d-submit").onclick = submit;
|
|
2286
|
+
$("d-push").onclick = () => pushToHub($("d-push-panel"), { kind: "dataset", job_id: jobId });
|
|
2287
|
+
$("d-again").onclick = () => { selected = null; $("d-push-panel").innerHTML = ""; show(0); };
|
|
2288
|
+
},
|
|
2289
|
+
};
|
|
2290
|
+
})();
|
|
2291
|
+
|
|
2292
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
2293
|
+
STATUS CONTROLLER — ported dashboard (poll /stats + /v1/models + /fit + /library + /downloads)
|
|
2294
|
+
════════════════════════════════════════════════════════════════════ */
|
|
2295
|
+
controllers.status = (() => {
|
|
2296
|
+
let timer = null, fitLoaded = false;
|
|
2297
|
+
const dlRates = {};
|
|
2298
|
+
|
|
2299
|
+
async function tick() {
|
|
2300
|
+
try {
|
|
2301
|
+
const [stats, models] = await Promise.all([
|
|
2302
|
+
fetch("/stats").then((r) => r.json()),
|
|
2303
|
+
fetch("/v1/models").then((r) => r.json()),
|
|
2304
|
+
]);
|
|
2305
|
+
const m = models.data && models.data[0];
|
|
2306
|
+
$("st-model-id").textContent = (m && m.id) || "—";
|
|
2307
|
+
const a = stats.admission || {};
|
|
2308
|
+
$("st-model-meta").textContent = num(m && m.context_window) + " token window · weights " + gb(a.weights_bytes);
|
|
2309
|
+
$("st-safe-ctx").textContent = num(a.max_safe_context);
|
|
2310
|
+
|
|
2311
|
+
$("st-weights").textContent = gb(a.weights_bytes);
|
|
2312
|
+
$("st-usable").textContent = gb(a.usable_bytes);
|
|
2313
|
+
$("st-budget").textContent = a.memory_budget_bytes ? gb(a.memory_budget_bytes) : "off";
|
|
2314
|
+
$("st-mem-bar").style.width = a.usable_bytes ? Math.min(100, 100 * a.weights_bytes / a.usable_bytes) + "%" : "0%";
|
|
2315
|
+
|
|
2316
|
+
const pc = stats.prompt_cache || {};
|
|
2317
|
+
const lookups = (pc.hits || 0) + (pc.misses || 0);
|
|
2318
|
+
$("st-pc-hits").textContent = lookups ? Math.round(100 * pc.hits / lookups) + "%" : "no lookups";
|
|
2319
|
+
$("st-pc-entries").textContent = num(pc.entries);
|
|
2320
|
+
$("st-pc-bytes").textContent = mb(pc.bytes) + " / " + mb(pc.max_bytes);
|
|
2321
|
+
$("st-pc-bar").style.width = pc.max_bytes ? Math.min(100, 100 * pc.bytes / pc.max_bytes) + "%" : "0%";
|
|
2322
|
+
|
|
2323
|
+
const rs = stats.response_store || {};
|
|
2324
|
+
$("st-rs-entries").textContent = num(rs.entries);
|
|
2325
|
+
$("st-rs-bytes").textContent = mb(rs.bytes) + " / " + mb(rs.max_bytes);
|
|
2326
|
+
$("st-rs-ttl").textContent = rs.ttl_ms ? (rs.ttl_ms / 60000) + " min" : "—";
|
|
2327
|
+
$("st-rs-bar").style.width = rs.max_bytes ? Math.min(100, 100 * rs.bytes / rs.max_bytes) + "%" : "0%";
|
|
2328
|
+
|
|
2329
|
+
const kv = stats.kv_quant || {};
|
|
2330
|
+
$("st-kv-mode").innerHTML = "<code>" + esc(kv.mode || "—") + "</code>";
|
|
2331
|
+
const att = kv.attention;
|
|
2332
|
+
$("st-kv-layers").innerHTML = Object.entries(kv.layers || {})
|
|
2333
|
+
.map(([k, v]) => '<div class="kv"><b>' + esc(k) + ' layers</b><span class="num">' + esc(v) + "</span></div>").join("") +
|
|
2334
|
+
(att ? '<div class="kv"><b>attention</b><span class="num">' + esc(att.global) + " global · " + esc(att.sliding_window) + " sliding</span></div>" : "");
|
|
2335
|
+
|
|
2336
|
+
await loadDownloads();
|
|
2337
|
+
if (!fitLoaded) { await loadFit(); await loadLibrary(); }
|
|
2338
|
+
if (stats.server && stats.server.started_at) {
|
|
2339
|
+
const up = (Date.now() - stats.server.started_at) / 1000;
|
|
2340
|
+
$("st-uptime").textContent = up < 90 ? Math.round(up) + "s" : up < 5400 ? Math.round(up / 60) + "m" : (up / 3600).toFixed(1) + "h";
|
|
2341
|
+
}
|
|
2342
|
+
$("st-updated").textContent = new Date().toLocaleTimeString();
|
|
2343
|
+
} catch {
|
|
2344
|
+
/* nav pill already reflects connectivity; leave last-good values on screen */
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
async function loadDownloads() {
|
|
2349
|
+
let downloads;
|
|
2350
|
+
try { ({ downloads } = await fetch("/downloads").then((r) => r.json())); } catch { return; }
|
|
2351
|
+
const card = $("st-dl-card");
|
|
2352
|
+
if (!downloads || downloads.length === 0) { card.style.display = "none"; return; }
|
|
2353
|
+
card.style.display = "";
|
|
2354
|
+
const order = downloads.slice().sort((a, b) =>
|
|
2355
|
+
(a.state === "active" ? 0 : 1) - (b.state === "active" ? 0 : 1) || b.startedAt - a.startedAt);
|
|
2356
|
+
$("st-dl-list").innerHTML = order.map((d) => {
|
|
2357
|
+
const pct = d.totalBytes ? Math.min(100, 100 * d.receivedBytes / d.totalBytes) : 0;
|
|
2358
|
+
let detail, barCls = "";
|
|
2359
|
+
if (d.state === "active") {
|
|
2360
|
+
let bps = d.bytesPerSec || 0;
|
|
2361
|
+
if (!bps) {
|
|
2362
|
+
const prev = dlRates[d.repoId], now = performance.now();
|
|
2363
|
+
if (prev && now > prev.t) bps = (d.receivedBytes - prev.bytes) / ((now - prev.t) / 1000);
|
|
2364
|
+
dlRates[d.repoId] = { bytes: d.receivedBytes, t: now };
|
|
2365
|
+
}
|
|
2366
|
+
const speed = bps > 0 ? " · " + mb(bps) + "/s · ~" + Math.max(1, Math.round((d.totalBytes - d.receivedBytes) / bps / 60)) + " min left" : "";
|
|
2367
|
+
detail = gb(d.receivedBytes) + " / " + gb(d.totalBytes) + " · " + pct.toFixed(0) + "%" + speed +
|
|
2368
|
+
(d.currentFile ? " · <code>" + esc(d.currentFile) + "</code>" : "");
|
|
2369
|
+
barCls = "warm";
|
|
2370
|
+
} else if (d.state === "done") {
|
|
2371
|
+
detail = gb(d.totalBytes) + ' · <span style="color:var(--green);font-weight:600">complete</span>';
|
|
2372
|
+
} else {
|
|
2373
|
+
detail = '<span style="color:var(--red);font-weight:600">error</span> · ' + esc(d.error || "");
|
|
2374
|
+
}
|
|
2375
|
+
return '<div style="padding:10px 0">' +
|
|
2376
|
+
'<div class="kv" style="border:none"><b>' + esc(d.repoId) + "</b>" +
|
|
2377
|
+
'<span class="num">' + d.filesDone + "/" + d.filesTotal + " files</span></div>" +
|
|
2378
|
+
'<div style="font-size:13.5px;color:var(--dim)" class="num">' + detail + "</div>" +
|
|
2379
|
+
'<div class="meter ' + barCls + '"><i style="width:' + pct + '%"></i></div></div>';
|
|
2380
|
+
}).join("");
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
async function loadLibrary() {
|
|
2384
|
+
let models;
|
|
2385
|
+
try { ({ models } = await fetch("/library").then((r) => r.json())); } catch { return; }
|
|
2386
|
+
if (!models) return;
|
|
2387
|
+
$("st-lib-body").innerHTML = models.slice()
|
|
2388
|
+
.sort((a, b) => (b.serving ? 1 : 0) - (a.serving ? 1 : 0) || b.size_bytes - a.size_bytes)
|
|
2389
|
+
.map((m) => {
|
|
2390
|
+
const a = m.assessment;
|
|
2391
|
+
let status;
|
|
2392
|
+
if (m.serving) status = '<span style="color:var(--blue);font-weight:700">SERVING</span>';
|
|
2393
|
+
else if (!m.supported) status = '<span style="color:var(--dimmer)">unsupported (' + esc(m.model_type) + ")</span>";
|
|
2394
|
+
else if (a && a.fits) status = '<span style="color:var(--green);font-weight:600">fits</span>';
|
|
2395
|
+
else status = '<span style="color:var(--red);font-weight:600">too big</span>';
|
|
2396
|
+
return "<tr" + (m.serving ? ' class="you"' : "") + "><td>" + esc(m.repo_id) +
|
|
2397
|
+
(m.vision ? ' · <span style="color:var(--dim)">vision</span>' : "") +
|
|
2398
|
+
'</td><td class="num">' + gb(m.size_bytes) + "</td><td>" + status + "</td>" +
|
|
2399
|
+
'<td class="num">' + (m.supported && a && a.fits ? num(a.max_safe_context) : "—") + "</td>" +
|
|
2400
|
+
'<td class="num">' + (m.supported && a && a.fits ? a.predicted_decode_tps.toFixed(0) + " tok/s" : "—") + "</td></tr>";
|
|
2401
|
+
}).join("");
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
async function loadFit() {
|
|
2405
|
+
let f;
|
|
2406
|
+
try { f = await fetch("/fit").then((r) => r.json()); } catch { return; }
|
|
2407
|
+
fitLoaded = true;
|
|
2408
|
+
const r = f.report || {};
|
|
2409
|
+
if (f.measured_decode_tps) {
|
|
2410
|
+
$("st-tps").textContent = f.measured_decode_tps.toFixed(1);
|
|
2411
|
+
$("st-tps").closest(".card").querySelector("h3").textContent = "Decode · measured";
|
|
2412
|
+
$("st-tps-cap").textContent = "mlx-bun benchmark on this machine · predicted " + (f.typical_decode_tps || 0).toFixed(1) + " at " + num(f.typical_context_tokens) + " ctx";
|
|
2413
|
+
} else if (f.typical_decode_tps) {
|
|
2414
|
+
$("st-tps").textContent = f.typical_decode_tps.toFixed(1);
|
|
2415
|
+
$("st-tps-cap").textContent = "at " + num(f.typical_context_tokens) + " context · " + (r.predicted_decode_tps || 0).toFixed(1) + " tok/s at the " + num(f.context_tokens) + " max (full-KV reads)";
|
|
2416
|
+
} else {
|
|
2417
|
+
$("st-tps").textContent = (r.predicted_decode_tps || 0).toFixed(1);
|
|
2418
|
+
}
|
|
2419
|
+
$("st-m-ram").textContent = gb(f.machine && f.machine.ram_bytes);
|
|
2420
|
+
$("st-m-bw").textContent = (f.machine && f.machine.bandwidth_gbs || "—") + " GB/s";
|
|
2421
|
+
$("st-m-fits").innerHTML = r.fits ? '<span style="color:var(--green);font-weight:700">FITS</span>' : '<span style="color:var(--red);font-weight:700">DOES NOT FIT</span>';
|
|
2422
|
+
$("st-fit-ctx").textContent = num(f.context_tokens);
|
|
2423
|
+
$("st-f-weights").textContent = gb(r.weights_bytes);
|
|
2424
|
+
$("st-f-kv").textContent = gb(r.kv_bytes);
|
|
2425
|
+
$("st-f-transient").textContent = gb(r.transient_bytes);
|
|
2426
|
+
$("st-f-total").textContent = gb(r.total_bytes) + " / " + gb(r.usable_bytes);
|
|
2427
|
+
$("st-fit-bar").style.width = r.usable_bytes ? Math.min(100, 100 * r.total_bytes / r.usable_bytes) + "%" : "0%";
|
|
2428
|
+
|
|
2429
|
+
$("st-sku-ctx").textContent = num(f.sku_matrix_ctx);
|
|
2430
|
+
const myRam = Math.round((f.machine && f.machine.ram_bytes || 0) / 2 ** 30);
|
|
2431
|
+
const myChip = (f.machine && f.machine.chip) || null;
|
|
2432
|
+
const rows = f.sku_matrix || [];
|
|
2433
|
+
let youIdx = rows.findIndex((r) => myChip && r.sku === myChip && r.ram_gb === myRam);
|
|
2434
|
+
if (youIdx < 0 && myChip) {
|
|
2435
|
+
let best = Infinity;
|
|
2436
|
+
rows.forEach((r, i) => { if (r.sku === myChip && Math.abs(r.ram_gb - myRam) < best) { best = Math.abs(r.ram_gb - myRam); youIdx = i; } });
|
|
2437
|
+
}
|
|
2438
|
+
if (youIdx < 0) youIdx = rows.findIndex((r) => r.ram_gb === myRam);
|
|
2439
|
+
$("st-sku-body").innerHTML = rows.map((row, i) => {
|
|
2440
|
+
const you = i === youIdx;
|
|
2441
|
+
const dimCls = row.fits ? "num" : "num fit-no";
|
|
2442
|
+
return "<tr" + (you ? ' class="you"' : "") + "><td>" + esc(row.sku) + (you ? " · <b>this machine</b>" : "") +
|
|
2443
|
+
'</td><td class="num">' + row.ram_gb + " GB</td>" +
|
|
2444
|
+
'<td class="' + (row.fits ? "fit-yes" : "fit-no") + '">' + (row.fits ? "fits" : "no") + "</td>" +
|
|
2445
|
+
'<td class="' + dimCls + '">' + (row.max_context > 0 ? num(row.max_context) : "0 — weights alone don’t fit") + "</td>" +
|
|
2446
|
+
'<td class="' + dimCls + '">' + (row.decode_tps > 0 ? "~" + row.decode_tps.toFixed(0) + " tok/s" : "—") + "</td></tr>";
|
|
2447
|
+
}).join("");
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
return {
|
|
2451
|
+
init() {
|
|
2452
|
+
// trigger scroll reveals once mounted
|
|
2453
|
+
const page = $("status-page");
|
|
2454
|
+
requestAnimationFrame(() => page.classList.add("lit"));
|
|
2455
|
+
page.classList.add("lit");
|
|
2456
|
+
},
|
|
2457
|
+
enter() { tick(); timer = setInterval(tick, 2000); },
|
|
2458
|
+
leave() { clearInterval(timer); timer = null; },
|
|
2459
|
+
};
|
|
2460
|
+
})();
|
|
2461
|
+
|
|
2462
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
2463
|
+
BOOT
|
|
2464
|
+
════════════════════════════════════════════════════════════════════ */
|
|
2465
|
+
if (!location.hash) location.replace("#/chat");
|
|
2466
|
+
router();
|
|
2467
|
+
pollIdentity();
|
|
2468
|
+
setInterval(pollIdentity, 4000);
|
|
2469
|
+
</script>
|
|
2470
|
+
</body>
|
|
2471
|
+
</html>
|