mlx-bun 0.0.9 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -21
- package/THIRD_PARTY_LICENSES.md +1 -0
- package/docs/archive/mlx-bun-lab-report.html +275 -0
- package/docs/archive/wwdc-mlx-bun.html +848 -0
- package/docs/design/audio-input-plan.md +356 -0
- package/docs/design/batching-perf-path.md +255 -0
- package/docs/design/batching-v2-plan.md +23 -9
- package/docs/design/bucketing-stage.md +2 -1
- package/docs/design/compat-cli-surface-design.md +4 -3
- package/docs/design/decode-speed-program.md +172 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dspark-serving-program.md +371 -0
- package/docs/design/dspark-speculative-decoding.md +167 -5
- package/docs/design/faithful-l1-consolidation.md +310 -0
- package/docs/design/generic-model-support.md +8 -5
- package/docs/design/grammar-spec-batching-integration.md +301 -0
- package/docs/design/minicpm5-decode-megakernel.md +16 -11
- package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
- package/docs/design/omlx-adoption-map.md +98 -0
- package/docs/design/orpo-training.md +34 -30
- package/docs/design/paged-kv-cache.md +214 -0
- package/docs/design/parallel-slots.md +82 -49
- package/docs/design/parity-tier-dag.md +43 -33
- package/docs/design/repo-cleanup-plan.md +189 -0
- package/docs/design/runtime-isolation.md +19 -3
- package/docs/design/segmented-backward-training.md +18 -2
- package/docs/design/ssd-kv-cold-tier.md +288 -0
- package/docs/design/structured-output.md +484 -0
- package/docs/design/the-dreaming-master-plan.md +2 -3
- package/docs/design/turboquant-kv.md +235 -0
- package/docs/design/unified-engine-frontier-plan.md +774 -0
- package/docs/design/web-chat-beat-matrix.md +315 -0
- package/docs/design/web-chat-redesign.md +1112 -0
- package/docs/investigations/decode-roofline-lookagain.md +92 -6
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
- package/docs/investigations/lab-build-journal.md +2 -2
- package/docs/investigations/openwiki-evaluation.md +249 -0
- package/docs/planning/IDEAS.md +14 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -3
- package/docs/planning/chat-ui-vision-memory-first.md +199 -0
- package/docs/planning/chat-ui-vision.md +50 -0
- package/docs/planning/memory-docs-and-dag-plan.md +15 -6
- package/docs/planning/release-notes-v0.0.10.md +57 -0
- package/docs/planning/release-notes-v0.0.11.md +239 -0
- package/docs/reference/cli.md +35 -16
- package/docs/reference/distribution.md +13 -6
- package/docs/reference/embedding.md +35 -14
- package/docs/reference/features-matrix.md +97 -0
- package/docs/reference/library-api.md +30 -9
- package/docs/reference/memory.md +26 -6
- package/docs/reference/models.md +11 -1
- package/docs/reference/orpo-quickstart.md +3 -4
- package/docs/reference/server-api.md +634 -22
- package/docs/reference/server-config.md +251 -119
- package/docs/reference/training.md +20 -16
- package/package.json +5 -3
- package/src/anthropic.ts +13 -0
- package/src/audio/conformer.ts +647 -0
- package/src/audio/decode.ts +133 -0
- package/src/audio/features.ts +202 -0
- package/src/audio/transcode.ts +67 -0
- package/src/cli.ts +402 -107
- package/src/config.ts +49 -2
- package/src/download.ts +40 -0
- package/src/eval/README.md +7 -6
- package/src/eval/kl.ts +59 -3
- package/src/eval/quality-db.ts +2 -2
- package/src/eval/runner.ts +3 -3
- package/src/expert-offload.ts +15 -2
- package/src/flags.ts +13 -0
- package/src/generate.ts +330 -16
- package/src/grammar.ts +585 -0
- package/src/hub-rest.ts +242 -0
- package/src/kv-store.ts +618 -122
- package/src/lora.ts +8 -0
- package/src/md-modules.d.ts +42 -0
- package/src/media-fetch.ts +284 -0
- package/src/memory/rest.ts +245 -0
- package/src/memory/vault.ts +74 -0
- package/src/mlx/array.ts +127 -19
- package/src/mlx/autograd.ts +2 -2
- package/src/mlx/ffi.ts +27 -3
- package/src/mlx/metal-kernel.ts +7 -4
- package/src/mlx/ops.ts +179 -23
- package/src/mlx/turboquant-ops.ts +831 -0
- package/src/mlx/turboquant-tables.ts +237 -0
- package/src/model/batched-mask.ts +62 -1
- package/src/model/batched-quant.ts +206 -0
- package/src/model/batched-rotating-quant.ts +346 -0
- package/src/model/batched-rotating.ts +46 -1
- package/src/model/compiled-decode.ts +4 -15
- package/src/model/factory.ts +10 -4
- package/src/model/gemma4-base.ts +391 -34
- package/src/model/gemma4.ts +122 -33
- package/src/model/generated/gemma4-12b.ts +9 -13
- package/src/model/generated/gemma4-26b.ts +10 -5
- package/src/model/generated/gemma4-e4b.ts +14 -9
- package/src/model/minicpm5.ts +58 -102
- package/src/model/paged-kv.ts +253 -0
- package/src/model/qwen3-delta.ts +123 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +99 -25
- package/src/model/support.ts +12 -1
- package/src/model/universal/dense.ts +40 -5
- package/src/model/universal/rope.ts +36 -21
- package/src/pi-web.ts +1299 -82
- package/src/preflight.ts +27 -8
- package/src/prompt-cache.ts +338 -32
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +61 -3
- package/src/serve/batch-scheduler.ts +1001 -76
- package/src/serve/generation-gateway.ts +292 -30
- package/src/serve/isolate.ts +417 -0
- package/src/serve/lane-registry.ts +44 -0
- package/src/serve/session-search.ts +326 -0
- package/src/server.ts +1460 -105
- package/src/spec/assistant-source.ts +150 -0
- package/src/spec/deepspec-source.ts +143 -0
- package/src/spec/dflash-source.ts +124 -0
- package/src/spec/dspark/ab-stats.ts +136 -0
- package/src/spec/dspark/calibration.ts +112 -0
- package/src/spec/dspark/deepspec-module.ts +801 -0
- package/src/spec/dspark/generate-dflash.ts +72 -20
- package/src/spec/dspark/loader.ts +23 -0
- package/src/spec/dspark/module-dflash.ts +278 -36
- package/src/spec/dspark/quantize-drafter.ts +91 -0
- package/src/spec/generate.ts +59 -25
- package/src/spec/ngram-source.ts +132 -0
- package/src/spec/serve-loop.ts +536 -0
- package/src/spec/source.ts +93 -0
- package/src/spec/two-model.ts +171 -0
- package/src/ssd-cache.ts +285 -0
- package/src/tokenizer.ts +288 -2
- package/src/tool-approvals.ts +131 -0
- package/src/tool-call.ts +408 -20
- package/src/train/flash-cce.ts +29 -26
- package/src/train/job.ts +0 -2
- package/src/train/loss.ts +53 -11
- package/src/train/prefix-shared.ts +30 -7
- package/src/train/segmented.ts +150 -46
- package/src/train/trainer.ts +22 -15
- package/src/vision/prompt.ts +217 -48
- package/src/web/app.html +1184 -1789
- package/src/web/app.js +5667 -0
- package/src/web/icon.svg +13 -0
- package/src/web/manifest.webmanifest +24 -0
- package/src/web/src/adapters-panel.ts +288 -0
- package/src/web/src/api.ts +57 -0
- package/src/web/src/assistant.ts +485 -0
- package/src/web/src/chat.ts +1050 -0
- package/src/web/src/composer.ts +959 -0
- package/src/web/src/dataset.ts +132 -0
- package/src/web/src/finetune.ts +267 -0
- package/src/web/src/hub.ts +428 -0
- package/src/web/src/main.ts +98 -0
- package/src/web/src/markdown.ts +0 -0
- package/src/web/src/memory-panel.ts +572 -0
- package/src/web/src/model-picker.ts +159 -0
- package/src/web/src/palette.ts +346 -0
- package/src/web/src/protocol.ts +62 -0
- package/src/web/src/quantize.ts +167 -0
- package/src/web/src/rag.ts +274 -0
- package/src/web/src/sessions.ts +469 -0
- package/src/web/src/shell.ts +804 -0
- package/src/web/src/status.ts +252 -0
- package/src/web/src/ui-catalog.ts +92 -0
- package/src/web/sw.js +71 -0
- package/src/web/vendor/README.md +61 -0
- package/src/web/vendor/hljs-LICENSE +29 -0
- package/src/web/vendor/hljs-theme.css +59 -0
- package/src/web/vendor/hljs.js +26 -0
- package/src/model/fused-decode-kernel.ts +0 -312
- package/src/model/fused-geglu-kernel.ts +0 -198
- package/src/model/fused-mlp-kernel.ts +0 -169
- package/src/model/fused-swiglu-kernel.ts +0 -240
- package/src/model/steel-linear-kernel.ts +0 -96
- package/src/status-page.html +0 -497
package/src/web/app.html
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
--dimmer:#515154;
|
|
22
22
|
--hairline:rgba(255,255,255,.14);
|
|
23
23
|
--hairline-soft:rgba(255,255,255,.07);
|
|
24
|
+
--hairline-strong:rgba(255,255,255,.28);
|
|
24
25
|
--card:rgba(255,255,255,.045);
|
|
25
26
|
--card-hover:rgba(255,255,255,.08);
|
|
26
27
|
--card-2:rgba(255,255,255,.025);
|
|
@@ -30,6 +31,47 @@
|
|
|
30
31
|
--ease:cubic-bezier(.22,.61,.21,1);
|
|
31
32
|
--nav-h:60px;
|
|
32
33
|
--mono:"SF Mono",ui-monospace,Menlo,Monaco,monospace;
|
|
34
|
+
/* bloom radial-gradient stop opacities — separate tokens so the light
|
|
35
|
+
theme can dial them down (the gradient identity/positions/colors stay
|
|
36
|
+
identical, only intensity changes — plan §7 "keep the gradient identity") */
|
|
37
|
+
--bloom-a:.13; --bloom-b:.16; --bloom-c:.10;
|
|
38
|
+
--overlay-scrim:rgba(0,0,0,.55);
|
|
39
|
+
--overlay-panel:rgba(22,22,24,.96);
|
|
40
|
+
--popover-panel:rgba(30,30,34,.98);
|
|
41
|
+
/* Code/log/tool-output surfaces (pre blocks, .logbox, edit textarea)
|
|
42
|
+
deliberately stay dark-chrome in BOTH themes — the conventional
|
|
43
|
+
"code block keeps its own dark surface" pattern (GitHub, VS Code
|
|
44
|
+
light themes, etc.), so monospace content keeps consistent contrast
|
|
45
|
+
regardless of the surrounding chat theme.
|
|
46
|
+
These MUST be opaque: the original rgba(0,0,0,.45) composited to
|
|
47
|
+
mid-GRAY over the light theme's white page, washing out the
|
|
48
|
+
near-white code ink + #86868b hljs comments (2026-07-06 visual QA).
|
|
49
|
+
Opaque values render identically over any page background. */
|
|
50
|
+
--code-bg:#121214; --code-bg-soft:#1a1a1d; --code-ink:#f5f5f7;
|
|
51
|
+
--nav-bg:rgba(0,0,0,.62);
|
|
52
|
+
--scrollbar-thumb:rgba(255,255,255,.12); --scrollbar-thumb-hover:rgba(255,255,255,.22);
|
|
53
|
+
}
|
|
54
|
+
/* ─── light theme: tasteful equivalents, same gradient identity ───
|
|
55
|
+
Applied via [data-theme="light"] on <html> (explicit user choice,
|
|
56
|
+
persisted to localStorage) OR by prefers-color-scheme:light when the
|
|
57
|
+
user's choice is "auto" (default — see initTheme()). */
|
|
58
|
+
[data-theme="light"]{
|
|
59
|
+
--bg:#f4f4f6;
|
|
60
|
+
--ink:#1d1d1f;
|
|
61
|
+
--dim:#6e6e73;
|
|
62
|
+
--dimmer:#a1a1a6;
|
|
63
|
+
--hairline:rgba(0,0,0,.12);
|
|
64
|
+
--hairline-soft:rgba(0,0,0,.06);
|
|
65
|
+
--hairline-strong:rgba(0,0,0,.28);
|
|
66
|
+
--card:rgba(0,0,0,.035);
|
|
67
|
+
--card-hover:rgba(0,0,0,.065);
|
|
68
|
+
--card-2:rgba(0,0,0,.02);
|
|
69
|
+
--bloom-a:.09; --bloom-b:.10; --bloom-c:.07;
|
|
70
|
+
--overlay-scrim:rgba(0,0,0,.28);
|
|
71
|
+
--overlay-panel:rgba(255,255,255,.97);
|
|
72
|
+
--popover-panel:rgba(255,255,255,.98);
|
|
73
|
+
--nav-bg:rgba(255,255,255,.72);
|
|
74
|
+
--scrollbar-thumb:rgba(0,0,0,.14); --scrollbar-thumb-hover:rgba(0,0,0,.26);
|
|
33
75
|
}
|
|
34
76
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
35
77
|
html,body{height:100%}
|
|
@@ -42,11 +84,11 @@
|
|
|
42
84
|
::selection{background:rgba(10,132,255,.4)}
|
|
43
85
|
.num{font-variant-numeric:tabular-nums}
|
|
44
86
|
code,.mono{font-family:var(--mono)}
|
|
45
|
-
code{font-size:.86em;background:rgba(
|
|
87
|
+
code{font-size:.86em;background:rgba(127,127,127,.16);padding:2px 8px;border-radius:6px}
|
|
46
88
|
a{color:inherit}
|
|
47
89
|
::-webkit-scrollbar{width:10px;height:10px}
|
|
48
|
-
::-webkit-scrollbar-thumb{background:
|
|
49
|
-
::-webkit-scrollbar-thumb:hover{background:
|
|
90
|
+
::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);border-radius:6px;border:2px solid transparent;background-clip:content-box}
|
|
91
|
+
::-webkit-scrollbar-thumb:hover{background:var(--scrollbar-thumb-hover);background-clip:content-box}
|
|
50
92
|
::-webkit-scrollbar-track{background:transparent}
|
|
51
93
|
|
|
52
94
|
/* gradient text helpers (lifted from status/chat pages) */
|
|
@@ -60,25 +102,44 @@
|
|
|
60
102
|
/* ─── ambient blooms behind everything ─── */
|
|
61
103
|
.bloom{position:fixed;inset:-25%;pointer-events:none;z-index:0;
|
|
62
104
|
background:
|
|
63
|
-
radial-gradient(55vw 42vh at 12% 6%, rgba(255,159,10
|
|
64
|
-
radial-gradient(60vw 48vh at 88% 24%, rgba(94,92,230
|
|
65
|
-
radial-gradient(50vw 40vh at 30% 96%, rgba(10,132,255
|
|
105
|
+
radial-gradient(55vw 42vh at 12% 6%, rgba(255,159,10,var(--bloom-a)), transparent 68%),
|
|
106
|
+
radial-gradient(60vw 48vh at 88% 24%, rgba(94,92,230,var(--bloom-b)), transparent 68%),
|
|
107
|
+
radial-gradient(50vw 40vh at 30% 96%, rgba(10,132,255,var(--bloom-c)), transparent 68%);
|
|
66
108
|
filter:blur(74px);
|
|
67
109
|
transition:opacity 1.2s var(--ease);
|
|
68
110
|
}
|
|
111
|
+
/* Ambient bloom is a slow continuous drift, gated behind
|
|
112
|
+
prefers-reduced-motion (plan §7): with no explicit opacity set inline
|
|
113
|
+
by the route dimmer, it just sits static — no keyframe loop to begin
|
|
114
|
+
with, so the only "animation" to guard is the opacity transition
|
|
115
|
+
above, which is a one-shot per-route dim, not a decorative loop. The
|
|
116
|
+
shimmer/pulse/blink loops below are the actual ambient animations. */
|
|
117
|
+
@media (prefers-reduced-motion:reduce){
|
|
118
|
+
.bloom{transition:none}
|
|
119
|
+
}
|
|
69
120
|
|
|
70
121
|
/* ═══════════════════ top navigation ═══════════════════ */
|
|
71
122
|
nav{
|
|
72
123
|
position:fixed;top:0;left:0;right:0;height:var(--nav-h);z-index:50;
|
|
73
124
|
display:flex;align-items:center;gap:18px;padding:0 22px;
|
|
74
125
|
border-bottom:1px solid var(--hairline);
|
|
75
|
-
background:
|
|
126
|
+
background:var(--nav-bg);backdrop-filter:saturate(180%) blur(22px);
|
|
76
127
|
-webkit-backdrop-filter:saturate(180%) blur(22px);
|
|
77
128
|
}
|
|
78
129
|
nav .brand{font-size:18px;font-weight:700;letter-spacing:-.02em;text-decoration:none;
|
|
79
130
|
white-space:nowrap;flex:0 0 auto}
|
|
80
131
|
nav .tabs{display:flex;gap:2px;flex:1 1 auto;overflow-x:auto;scrollbar-width:none}
|
|
81
132
|
nav .tabs::-webkit-scrollbar{display:none}
|
|
133
|
+
/* Scroll-edge fades (2026-07-06 jank fix): the tab row scrolls with its
|
|
134
|
+
scrollbar hidden, so a clipped "Build Datase" read as broken/overlapped.
|
|
135
|
+
JS (updateTabFades) toggles .fade-r/.fade-l per scroll position so a
|
|
136
|
+
soft fade signals "more tabs this way" only when actually clipped. */
|
|
137
|
+
nav .tabs.fade-r{-webkit-mask-image:linear-gradient(90deg,#000 0,#000 calc(100% - 34px),transparent);
|
|
138
|
+
mask-image:linear-gradient(90deg,#000 0,#000 calc(100% - 34px),transparent)}
|
|
139
|
+
nav .tabs.fade-l{-webkit-mask-image:linear-gradient(90deg,transparent,#000 34px,#000);
|
|
140
|
+
mask-image:linear-gradient(90deg,transparent,#000 34px,#000)}
|
|
141
|
+
nav .tabs.fade-l.fade-r{-webkit-mask-image:linear-gradient(90deg,transparent,#000 34px,#000 calc(100% - 34px),transparent);
|
|
142
|
+
mask-image:linear-gradient(90deg,transparent,#000 34px,#000 calc(100% - 34px),transparent)}
|
|
82
143
|
nav .tab{
|
|
83
144
|
appearance:none;background:none;border:none;cursor:pointer;
|
|
84
145
|
color:var(--dim);font:600 14px/1 inherit;letter-spacing:-.01em;
|
|
@@ -89,7 +150,43 @@
|
|
|
89
150
|
nav .right{display:flex;align-items:center;gap:12px;flex:0 0 auto}
|
|
90
151
|
nav .model{font:12px var(--mono);color:var(--dim);
|
|
91
152
|
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
|
|
153
|
+
max-width:34vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
|
154
|
+
appearance:none;-webkit-appearance:none;cursor:pointer;transition:color .18s var(--ease),border-color .18s var(--ease)}
|
|
155
|
+
nav .model:hover{color:var(--ink);border-color:var(--hairline-strong)}
|
|
156
|
+
/* Model picker anchor + popover (plan §5.6) — same positioning/transition
|
|
157
|
+
language as #chat-sampling-wrap/.samplepop, anchored below-left instead
|
|
158
|
+
of above-right since the nav sits at the top of the page. */
|
|
159
|
+
#nav-model-wrap{position:relative;display:inline-flex}
|
|
160
|
+
#nav-model-wrap #model-pop{top:calc(100% + 9px);bottom:auto;left:0;right:auto;width:340px}
|
|
161
|
+
/* Model picker rows — same list-row language as the adapter table, one
|
|
162
|
+
row per /library entry with its own fit verdict + restart command
|
|
163
|
+
(model-picker.ts; no in-process swap exists, see docs/reference/cli.md). */
|
|
164
|
+
.mp-row{display:flex;align-items:flex-start;gap:9px;padding:9px 10px;border-radius:10px;
|
|
165
|
+
border:1px solid transparent;margin-bottom:4px;transition:background .12s}
|
|
166
|
+
.mp-row:last-child{margin-bottom:0}
|
|
167
|
+
.mp-row.serving{border-color:rgba(48,209,88,.35);background:rgba(48,209,88,.06)}
|
|
168
|
+
.mp-fit-dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto;margin-top:4px}
|
|
169
|
+
.mp-fit-dot.green{background:var(--green);box-shadow:0 0 7px var(--green)}
|
|
170
|
+
.mp-fit-dot.yellow{background:var(--yellow);box-shadow:0 0 7px var(--yellow)}
|
|
171
|
+
.mp-fit-dot.red{background:var(--red)}
|
|
172
|
+
.mp-main{flex:1 1 auto;min-width:0}
|
|
173
|
+
.mp-name{font-size:12.5px;color:var(--ink);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
174
|
+
.mp-meta{font-size:10.5px;color:var(--dimmer);margin-top:2px}
|
|
175
|
+
.mp-meta b{color:var(--dim);font-variant-numeric:tabular-nums}
|
|
176
|
+
.mp-serving-tag{font:10px var(--mono);color:var(--green);letter-spacing:.03em;margin-top:2px}
|
|
177
|
+
.mp-cmd{display:flex;align-items:center;gap:6px;margin-top:6px;background:var(--code-bg-soft);border-radius:7px;
|
|
178
|
+
padding:5px 8px;overflow-x:auto}
|
|
179
|
+
.mp-cmd code{font:11px var(--mono);color:var(--ink);white-space:nowrap}
|
|
180
|
+
.mp-cmd button{appearance:none;cursor:pointer;background:var(--card);border:1px solid var(--hairline);
|
|
181
|
+
color:var(--dim);border-radius:6px;padding:2px 8px;font-size:10.5px;font-weight:600;flex:0 0 auto;white-space:nowrap}
|
|
182
|
+
.mp-cmd button:hover{color:var(--ink);border-color:var(--hairline-strong)}
|
|
183
|
+
.mp-empty{color:var(--dim);font-size:12px;padding:10px 4px;line-height:1.5}
|
|
184
|
+
.mp-foot-note{font-size:10.5px;color:var(--dimmer);line-height:1.4;margin-top:10px;padding-top:10px;border-top:1px solid var(--hairline-soft)}
|
|
185
|
+
/* "Browse models…" entry -> Model Hub panel (plan §9 Phase 3) */
|
|
186
|
+
.mp-browse-hub{display:block;width:100%;appearance:none;cursor:pointer;background:var(--card);
|
|
187
|
+
border:1px solid var(--hairline);color:var(--ink);border-radius:8px;padding:8px 10px;
|
|
188
|
+
font-size:12px;font-weight:600;margin-top:8px;text-align:center;transition:border-color .18s var(--ease),background .18s var(--ease)}
|
|
189
|
+
.mp-browse-hub:hover{border-color:var(--hairline-strong);background:var(--card-hover)}
|
|
93
190
|
.pill{display:inline-flex;align-items:center;gap:8px;
|
|
94
191
|
border:1px solid var(--hairline);border-radius:999px;padding:6px 13px;
|
|
95
192
|
font-size:12.5px;font-weight:600;color:var(--dim);letter-spacing:.01em;white-space:nowrap}
|
|
@@ -100,7 +197,7 @@
|
|
|
100
197
|
#chat-think{display:none;cursor:pointer;gap:7px;padding:5px 11px;font-size:12px;user-select:none;-webkit-user-select:none;
|
|
101
198
|
transition:color .22s var(--ease),border-color .22s var(--ease)}
|
|
102
199
|
#chat-think .dot{width:7px;height:7px}
|
|
103
|
-
#chat-think:hover{border-color:
|
|
200
|
+
#chat-think:hover{border-color:var(--hairline-strong)}
|
|
104
201
|
#chat-think.think-on{color:var(--ink)}
|
|
105
202
|
#chat-think.think-on .dot{background:var(--blue);box-shadow:0 0 11px var(--blue)}
|
|
106
203
|
.pill.bad .dot{background:var(--red);box-shadow:0 0 11px var(--red)}
|
|
@@ -109,18 +206,35 @@
|
|
|
109
206
|
#chat-sampling-wrap{position:relative;display:inline-flex}
|
|
110
207
|
#chat-sampling{cursor:pointer;gap:6px;padding:5px 11px;font-size:12px;user-select:none;-webkit-user-select:none;
|
|
111
208
|
transition:color .22s var(--ease),border-color .22s var(--ease)}
|
|
112
|
-
#chat-sampling:hover{border-color:
|
|
113
|
-
#chat-sampling.on{color:var(--ink);border-color:
|
|
209
|
+
#chat-sampling:hover{border-color:var(--hairline-strong);color:var(--ink)}
|
|
210
|
+
#chat-sampling.on{color:var(--ink);border-color:var(--hairline-strong)}
|
|
114
211
|
#chat-sampling.dirty .dot{background:var(--blue);box-shadow:0 0 11px var(--blue)}
|
|
212
|
+
/* Distinct one-shot-armed dot state (task brief item 2) — orange/amber
|
|
213
|
+
reads as "temporary/pending" against the plain blue "overridden"
|
|
214
|
+
state, matching the same semantic the orange dot already carries
|
|
215
|
+
elsewhere (.pill.warn) rather than inventing a new color meaning. */
|
|
216
|
+
#chat-sampling.armed .dot{background:var(--orange);box-shadow:0 0 11px var(--orange);animation:pulse-armed 1.6s var(--ease) infinite}
|
|
217
|
+
@media (prefers-reduced-motion:reduce){#chat-sampling.armed .dot{animation:none}}
|
|
218
|
+
@keyframes pulse-armed{0%,100%{opacity:1}50%{opacity:.45}}
|
|
115
219
|
#chat-sampling .dot{width:7px;height:7px}
|
|
116
220
|
.samplepop{position:absolute;bottom:calc(100% + 9px);right:0;z-index:40;width:300px;
|
|
117
|
-
|
|
221
|
+
max-height:min(70vh,560px);overflow-y:auto;
|
|
222
|
+
background:var(--popover-panel);border:1px solid var(--hairline);border-radius:13px;padding:13px 14px;
|
|
118
223
|
backdrop-filter:blur(22px) saturate(1.3);-webkit-backdrop-filter:blur(22px) saturate(1.3);
|
|
119
|
-
box-shadow:0 18px 50px rgba(0,0,0,.
|
|
224
|
+
box-shadow:0 18px 50px rgba(0,0,0,.35);display:none;
|
|
120
225
|
transform:translateY(4px);opacity:0;transition:opacity .16s var(--ease),transform .16s var(--ease)}
|
|
121
226
|
.samplepop.open{display:block;transform:translateY(0);opacity:1}
|
|
122
227
|
.samplepop h4{font-size:12px;font-weight:600;color:var(--dim);letter-spacing:.02em;
|
|
123
|
-
text-transform:uppercase;margin:0
|
|
228
|
+
text-transform:uppercase;margin:0}
|
|
229
|
+
.samp-head-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 10px}
|
|
230
|
+
.samp-oneshot-chip{font:10.5px/1 var(--mono);color:var(--orange);background:rgba(255,159,10,.12);
|
|
231
|
+
border:1px solid rgba(255,159,10,.3);border-radius:999px;padding:3px 8px;letter-spacing:.01em;white-space:nowrap}
|
|
232
|
+
.samp-scope-row{display:flex;align-items:center;gap:9px;margin:0 0 12px;padding-bottom:11px;border-bottom:1px solid var(--hairline-soft)}
|
|
233
|
+
.samp-scope-row label{font-size:12.5px;color:var(--ink);width:74px;flex:0 0 auto}
|
|
234
|
+
.samp-scope-row select{flex:1 1 auto;background:var(--card);border:1px solid var(--hairline);border-radius:7px;
|
|
235
|
+
color:var(--ink);font-size:12px;padding:5px 8px;cursor:pointer;appearance:none;-webkit-appearance:none;
|
|
236
|
+
transition:border-color .18s var(--ease)}
|
|
237
|
+
.samp-scope-row select:hover,.samp-scope-row select:focus{border-color:var(--hairline-strong)}
|
|
124
238
|
.samprow{display:flex;align-items:center;gap:9px;margin:0 0 11px}
|
|
125
239
|
.samprow label{font-size:12.5px;color:var(--ink);width:74px;flex:0 0 auto}
|
|
126
240
|
.samprow input[type=range]{flex:1 1 auto;-webkit-appearance:none;appearance:none;height:4px;margin:0;
|
|
@@ -132,27 +246,119 @@
|
|
|
132
246
|
.samprow input[type=range]:focus::-webkit-slider-thumb{background:var(--blue)}
|
|
133
247
|
.samp-val{width:40px;flex:0 0 auto;text-align:right;font-family:var(--mono);font-size:12px;color:var(--ink)}
|
|
134
248
|
.samp-val.auto{color:var(--dimmer)}
|
|
249
|
+
.samp-num{width:74px;flex:0 0 auto;text-align:right;background:var(--card);border:1px solid var(--hairline);
|
|
250
|
+
border-radius:6px;color:var(--ink);font-family:var(--mono);font-size:11.5px;padding:3px 6px}
|
|
251
|
+
.samp-num.auto{color:var(--dimmer)}
|
|
135
252
|
.samppop-foot{display:flex;justify-content:space-between;align-items:center;margin-top:3px}
|
|
136
253
|
.samppop-foot .note{font-size:10.5px;color:var(--dimmer);line-height:1.3;flex:1 1 auto;padding-right:8px}
|
|
137
254
|
.samppop-foot button{appearance:none;cursor:pointer;background:var(--card);border:1px solid var(--hairline);
|
|
138
255
|
color:var(--dim);border-radius:8px;padding:5px 11px;font-size:11.5px;font-weight:600;flex:0 0 auto;
|
|
139
256
|
transition:color .18s var(--ease),border-color .18s var(--ease)}
|
|
140
|
-
.samppop-foot button:hover{color:var(--ink);border-color:
|
|
257
|
+
.samppop-foot button:hover{color:var(--ink);border-color:var(--hairline-strong)}
|
|
258
|
+
/* Advanced sampling disclosure — collapsed by default, same visual language
|
|
259
|
+
as the primary rows so it doesn't read as a separate control system. */
|
|
260
|
+
.samp-adv{margin:2px 0 12px}
|
|
261
|
+
.samp-adv > summary{cursor:pointer;font-size:11.5px;font-weight:600;color:var(--dim);letter-spacing:.02em;
|
|
262
|
+
text-transform:uppercase;list-style:none;padding:2px 0 8px;user-select:none;-webkit-user-select:none}
|
|
263
|
+
.samp-adv > summary::-webkit-details-marker{display:none}
|
|
264
|
+
.samp-adv > summary::before{content:"▸ ";display:inline-block;transition:transform .15s var(--ease)}
|
|
265
|
+
.samp-adv[open] > summary::before{transform:rotate(90deg)}
|
|
266
|
+
.samp-adv > summary:hover{color:var(--ink)}
|
|
267
|
+
.samp-adv .seed-row{display:flex;align-items:center;gap:9px;margin:0 0 4px}
|
|
268
|
+
.samp-adv .seed-row label{font-size:12.5px;color:var(--ink);width:74px;flex:0 0 auto}
|
|
269
|
+
.samp-adv .seed-row input[type=text]{flex:1 1 auto;background:var(--card);border:1px solid var(--hairline);
|
|
270
|
+
border-radius:6px;color:var(--ink);font-family:var(--mono);font-size:12px;padding:5px 8px}
|
|
271
|
+
.samp-adv .seed-row input[type=text]::placeholder{color:var(--dimmer)}
|
|
272
|
+
/* System prompt pill + popover (plan §9 Phase 2) — same chrome as
|
|
273
|
+
#chat-sampling-wrap/.samplepop, just to its left in the hint row. */
|
|
274
|
+
#chat-sysprompt-wrap{position:relative;display:inline-flex}
|
|
275
|
+
#chat-sysprompt{cursor:pointer;gap:6px;padding:5px 11px;font-size:12px;user-select:none;-webkit-user-select:none;
|
|
276
|
+
transition:color .22s var(--ease),border-color .22s var(--ease)}
|
|
277
|
+
#chat-sysprompt:hover{border-color:var(--hairline-strong);color:var(--ink)}
|
|
278
|
+
#chat-sysprompt.on{color:var(--ink);border-color:var(--hairline-strong)}
|
|
279
|
+
#chat-sysprompt.dirty .dot{background:var(--purple);box-shadow:0 0 11px var(--purple)}
|
|
280
|
+
#chat-sysprompt .dot{width:7px;height:7px}
|
|
281
|
+
.sysprompt-pop{width:320px}
|
|
282
|
+
#sysprompt-text{width:100%;resize:vertical;min-height:80px;background:var(--card);
|
|
283
|
+
border:1px solid var(--hairline);border-radius:9px;color:var(--ink);
|
|
284
|
+
font:12.5px/1.5 inherit;padding:9px 10px;margin:0 0 6px}
|
|
285
|
+
#sysprompt-text::placeholder{color:var(--dimmer)}
|
|
286
|
+
#sysprompt-text:focus{outline:none;border-color:var(--hairline-strong)}
|
|
287
|
+
.sysprompt-foot-row{display:flex;justify-content:space-between;align-items:center;margin:0 0 10px}
|
|
288
|
+
.sysprompt-foot-row .note{font-size:10.5px;color:var(--dimmer)}
|
|
289
|
+
.sysprompt-foot-row button,.sysprompt-preset-row button{appearance:none;cursor:pointer;background:var(--card);
|
|
290
|
+
border:1px solid var(--hairline);color:var(--dim);border-radius:8px;padding:5px 11px;font-size:11.5px;
|
|
291
|
+
font-weight:600;flex:0 0 auto;transition:color .18s var(--ease),border-color .18s var(--ease)}
|
|
292
|
+
.sysprompt-foot-row button:hover,.sysprompt-preset-row button:hover{color:var(--ink);border-color:var(--hairline-strong)}
|
|
293
|
+
.sysprompt-presets{margin-top:12px;padding-top:12px;border-top:1px solid var(--hairline-soft)}
|
|
294
|
+
.sysprompt-preset-row{display:flex;align-items:center;gap:6px;margin:0 0 6px}
|
|
295
|
+
#sysprompt-preset-select{flex:1 1 auto;min-width:0;max-width:none;appearance:none;-webkit-appearance:none;
|
|
296
|
+
cursor:pointer;background:var(--card);text-overflow:ellipsis}
|
|
141
297
|
#nav-download{color:var(--ink);font-family:var(--mono);font-size:11.5px;font-weight:500}
|
|
142
298
|
#nav-download .dot{background:var(--orange);box-shadow:0 0 11px var(--orange);animation:dlpulse 1.4s ease-in-out infinite}
|
|
143
299
|
@keyframes dlpulse{0%,100%{opacity:.4}50%{opacity:1}}
|
|
144
|
-
.hf-gear{appearance:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;
|
|
300
|
+
.hf-gear,.icon-btn{appearance:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;
|
|
145
301
|
width:34px;height:34px;border-radius:10px;background:var(--card);border:1px solid var(--hairline);
|
|
146
302
|
color:var(--dim);flex:0 0 auto;transition:color .22s var(--ease),background .22s var(--ease),border-color .22s var(--ease)}
|
|
147
|
-
.hf-gear:hover{color:var(--ink);background:var(--card-hover);border-color:
|
|
303
|
+
.hf-gear:hover,.icon-btn:hover{color:var(--ink);background:var(--card-hover);border-color:var(--hairline-strong)}
|
|
148
304
|
.hf-gear.saved{color:var(--green);border-color:rgba(48,209,88,.4)}
|
|
149
305
|
|
|
306
|
+
/* mobile drawer hamburger — chat route only, hidden ≥760px via the
|
|
307
|
+
media query beside #chat-sidebar above */
|
|
308
|
+
#chat-hamburger{align-items:center;justify-content:center;gap:3px}
|
|
309
|
+
|
|
310
|
+
/* theme toggle: 3-way auto/dark/light segmented control, nav-pill sized */
|
|
311
|
+
#theme-toggle{display:inline-flex;border:1px solid var(--hairline);border-radius:10px;overflow:hidden;flex:0 0 auto}
|
|
312
|
+
#theme-toggle button{appearance:none;cursor:pointer;background:transparent;border:none;color:var(--dim);
|
|
313
|
+
padding:0 9px;height:34px;font-size:13px;line-height:1;transition:color .18s var(--ease),background .18s var(--ease)}
|
|
314
|
+
#theme-toggle button:not(:last-child){border-right:1px solid var(--hairline)}
|
|
315
|
+
#theme-toggle button:hover{color:var(--ink);background:var(--card-hover)}
|
|
316
|
+
#theme-toggle button.active{color:var(--ink);background:var(--card-hover)}
|
|
317
|
+
#theme-toggle button:focus-visible{outline:2px solid var(--blue);outline-offset:-2px}
|
|
318
|
+
/* Auto's ◐ icon only appears when the text label hides at narrow widths
|
|
319
|
+
(see the 900px media block) — never both, never neither. */
|
|
320
|
+
#theme-toggle .tt-icon{display:none}
|
|
321
|
+
|
|
322
|
+
/* Developer toggle (plan §8/§9 Phase 2): a small switch, same nav-pill
|
|
323
|
+
sizing language as #theme-toggle, showing/hiding the Quantize/Fine-tune/
|
|
324
|
+
Build Dataset/Status/Curves/Routes tabs. aria-pressed reflects state
|
|
325
|
+
(kept in sync by shell.ts's applyDeveloperMode). */
|
|
326
|
+
#nav-developer{appearance:none;cursor:pointer;display:inline-flex;align-items:center;gap:7px;
|
|
327
|
+
border:1px solid var(--hairline);border-radius:10px;background:transparent;color:var(--dim);
|
|
328
|
+
padding:0 10px;height:34px;font-size:12.5px;font-weight:600;letter-spacing:.01em;flex:0 0 auto;
|
|
329
|
+
transition:color .18s var(--ease),background .18s var(--ease),border-color .18s var(--ease)}
|
|
330
|
+
#nav-developer:hover{color:var(--ink);background:var(--card-hover);border-color:var(--hairline-strong)}
|
|
331
|
+
#nav-developer:focus-visible{outline:2px solid var(--blue);outline-offset:-2px}
|
|
332
|
+
#nav-developer .nd-dot{width:8px;height:8px;border-radius:50%;background:var(--dimmer);flex:0 0 auto;
|
|
333
|
+
transition:background .18s var(--ease)}
|
|
334
|
+
#nav-developer .nd-dot.on{background:var(--green)}
|
|
335
|
+
#nav-developer[aria-checked="true"]{color:var(--ink)}
|
|
336
|
+
|
|
337
|
+
/* ─── keyboard shortcut sheet (Cmd/Ctrl+/) ─── */
|
|
338
|
+
#shortcut-overlay{position:fixed;inset:0;z-index:210;display:none;align-items:flex-start;justify-content:center;
|
|
339
|
+
background:var(--overlay-scrim);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);padding:calc(var(--nav-h) + 24px) 20px 20px}
|
|
340
|
+
#shortcut-overlay.open{display:flex;animation:fade .2s var(--ease)}
|
|
341
|
+
#shortcut-modal{width:min(480px,100%);max-height:min(76vh,620px);overflow-y:auto;background:var(--overlay-panel);
|
|
342
|
+
border:1px solid var(--hairline);border-radius:20px;padding:22px 26px;box-shadow:0 30px 80px rgba(0,0,0,.35)}
|
|
343
|
+
.sk-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
|
|
344
|
+
.sk-title{font-size:18px;font-weight:700;letter-spacing:-.02em}
|
|
345
|
+
.sk-close{appearance:none;background:none;border:none;cursor:pointer;color:var(--dim);font-size:16px;
|
|
346
|
+
width:30px;height:30px;border-radius:8px;transition:color .2s,background .2s}
|
|
347
|
+
.sk-close:hover{color:var(--ink);background:var(--card-hover)}
|
|
348
|
+
.sk-row{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:8px 0;
|
|
349
|
+
border-bottom:1px solid var(--hairline-soft)}
|
|
350
|
+
.sk-row:last-child{border-bottom:none}
|
|
351
|
+
.sk-desc{font-size:13.5px;color:var(--ink)}
|
|
352
|
+
.sk-keys{display:flex;gap:5px;flex:0 0 auto}
|
|
353
|
+
.sk-key{font:600 11.5px var(--mono);color:var(--ink);background:var(--card);border:1px solid var(--hairline);
|
|
354
|
+
border-radius:6px;padding:3px 8px;min-width:20px;text-align:center}
|
|
355
|
+
|
|
150
356
|
/* ─── HF settings overlay ─── */
|
|
151
357
|
#hf-overlay{position:fixed;inset:0;z-index:200;display:none;align-items:flex-start;justify-content:center;
|
|
152
|
-
background:
|
|
358
|
+
background:var(--overlay-scrim);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);padding:calc(var(--nav-h) + 24px) 20px 20px}
|
|
153
359
|
#hf-overlay.open{display:flex;animation:fade .3s var(--ease)}
|
|
154
|
-
#hf-modal{width:min(460px,100%);background:
|
|
155
|
-
padding:24px 26px;box-shadow:0 30px 80px rgba(0,0,0,.
|
|
360
|
+
#hf-modal{width:min(460px,100%);background:var(--overlay-panel);border:1px solid var(--hairline);border-radius:20px;
|
|
361
|
+
padding:24px 26px;box-shadow:0 30px 80px rgba(0,0,0,.35)}
|
|
156
362
|
.hf-modal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
|
|
157
363
|
.hf-modal-title{font-size:20px;font-weight:700;letter-spacing:-.02em}
|
|
158
364
|
.hf-modal-sub{font-size:13.5px;color:var(--dim);margin-bottom:20px;line-height:1.5}
|
|
@@ -160,12 +366,213 @@
|
|
|
160
366
|
width:30px;height:30px;border-radius:8px;transition:color .2s,background .2s}
|
|
161
367
|
.hf-close:hover{color:var(--ink);background:var(--card-hover)}
|
|
162
368
|
|
|
369
|
+
/* ─── Settings modal sections (plan §5.4/§6.5) ─── */
|
|
370
|
+
.settings-section{margin:0 0 20px;padding:0 0 20px;border-bottom:1px solid var(--hairline-soft)}
|
|
371
|
+
.settings-section:last-child{border-bottom:none;padding-bottom:0;margin-bottom:0}
|
|
372
|
+
.settings-h{font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--dim);margin:0 0 12px}
|
|
373
|
+
.settings-toggle-row{display:flex;align-items:center;gap:10px;font-size:13.5px;color:var(--ink);
|
|
374
|
+
cursor:pointer;font-weight:500;margin-bottom:8px}
|
|
375
|
+
.settings-toggle-row input{width:auto;margin:0;accent-color:var(--blue)}
|
|
376
|
+
.settings-note{font-size:12px;color:var(--orange);margin:8px 0 0;line-height:1.5}
|
|
377
|
+
#settings-approvals-list{margin-top:12px}
|
|
378
|
+
.settings-approvals-empty{font-size:12.5px;color:var(--dimmer)}
|
|
379
|
+
.settings-approval-row{display:flex;align-items:center;gap:10px;padding:7px 0;font-size:13px;
|
|
380
|
+
border-top:1px solid var(--hairline-soft)}
|
|
381
|
+
.settings-approval-row:first-child{border-top:none}
|
|
382
|
+
.settings-approval-row .satool{font:600 12.5px var(--mono);color:var(--ink);flex:1 1 auto}
|
|
383
|
+
.settings-approval-row .saforget{appearance:none;cursor:pointer;background:none;border:1px solid var(--hairline);
|
|
384
|
+
color:var(--dim);border-radius:7px;padding:3px 9px;font-size:11.5px;font-weight:600;flex:0 0 auto;
|
|
385
|
+
transition:color .18s var(--ease),border-color .18s var(--ease)}
|
|
386
|
+
.settings-approval-row .saforget:hover{color:var(--red);border-color:rgba(255,69,58,.4)}
|
|
387
|
+
|
|
163
388
|
/* ─── inline push-to-hub panel (in done-step cards) ─── */
|
|
164
389
|
.pushpanel{margin-top:14px;border-top:1px solid var(--hairline-soft);padding-top:14px}
|
|
165
390
|
.pushpanel .field{margin-bottom:12px}
|
|
166
391
|
.pushpanel label.chk{display:flex;align-items:center;gap:9px;font-size:13.5px;color:var(--ink);cursor:pointer;font-weight:500}
|
|
167
392
|
.pushpanel label.chk input{width:auto;margin:0;accent-color:var(--blue)}
|
|
168
393
|
|
|
394
|
+
/* ─── Memory panel (plan §5.5/§9 Phase 2) — a full-height overlay beside
|
|
395
|
+
chat, same drawer/overlay language as the mobile sidebar and the HF
|
|
396
|
+
modal: fixed, scrimmed, focus-trapped, Escape closes. Sidebar entry
|
|
397
|
+
is a quiet row (not a nav tab) — see #chat-memory-entry markup. ─── */
|
|
398
|
+
#mem-overlay{position:fixed;inset:var(--nav-h) 0 0 0;z-index:150;display:none;justify-content:flex-end;
|
|
399
|
+
background:var(--overlay-scrim);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
|
|
400
|
+
#mem-overlay.open{display:flex;animation:fade .22s var(--ease)}
|
|
401
|
+
#mem-panel{width:min(480px,100%);height:100%;background:var(--overlay-panel);border-left:1px solid var(--hairline);
|
|
402
|
+
display:flex;flex-direction:column;box-shadow:-24px 0 60px rgba(0,0,0,.4)}
|
|
403
|
+
.mem-head{display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--hairline-soft);flex:0 0 auto}
|
|
404
|
+
.mem-head h2{font-size:16px;font-weight:700;letter-spacing:-.01em;flex:1 1 auto}
|
|
405
|
+
.mem-close{appearance:none;background:none;border:none;cursor:pointer;color:var(--dim);font-size:15px;
|
|
406
|
+
width:30px;height:30px;border-radius:8px;transition:color .2s,background .2s;flex:0 0 auto}
|
|
407
|
+
.mem-close:hover{color:var(--ink);background:var(--card-hover)}
|
|
408
|
+
.mem-back{appearance:none;background:none;border:none;cursor:pointer;color:var(--dim);font-size:13px;
|
|
409
|
+
display:none;align-items:center;gap:4px;padding:4px 2px;flex:0 0 auto}
|
|
410
|
+
.mem-back:hover{color:var(--ink)}
|
|
411
|
+
.mem-back.show{display:inline-flex}
|
|
412
|
+
/* status strip: article count · last synthesis · git state */
|
|
413
|
+
.mem-status{display:flex;gap:16px;padding:12px 18px;border-bottom:1px solid var(--hairline-soft);flex:0 0 auto;flex-wrap:wrap}
|
|
414
|
+
.mem-stat{font-size:11.5px;color:var(--dim)}
|
|
415
|
+
.mem-stat b{color:var(--ink);font-weight:600;font-variant-numeric:tabular-nums}
|
|
416
|
+
.mem-body{flex:1 1 auto;overflow-y:auto;padding:14px 18px 24px}
|
|
417
|
+
/* no-vault state (consent already declined earlier, or panel opened directly) */
|
|
418
|
+
.mem-empty{text-align:center;color:var(--dim);padding:50px 18px;font-size:13.5px;line-height:1.6}
|
|
419
|
+
.mem-empty .mcta{margin-top:16px}
|
|
420
|
+
/* search + list */
|
|
421
|
+
#mem-search{width:100%;background:var(--card);border:1px solid var(--hairline);border-radius:10px;
|
|
422
|
+
color:var(--ink);font-size:12.5px;padding:9px 12px;outline:none;transition:border-color .2s;margin-bottom:14px}
|
|
423
|
+
#mem-search:focus{border-color:rgba(10,132,255,.5)}
|
|
424
|
+
#mem-search::placeholder{color:var(--dimmer)}
|
|
425
|
+
.mem-sec{margin-bottom:16px}
|
|
426
|
+
.mem-sec:last-child{margin-bottom:0}
|
|
427
|
+
.mem-sec-title{font-size:10.5px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--dimmer);
|
|
428
|
+
margin-bottom:7px;display:flex;align-items:center;gap:6px}
|
|
429
|
+
.mem-art{display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:10px;cursor:pointer;
|
|
430
|
+
border:1px solid transparent;transition:background .12s,border-color .12s}
|
|
431
|
+
.mem-art:hover{background:var(--card)}
|
|
432
|
+
.mem-art-name{font-size:13px;color:var(--ink);flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
433
|
+
.mem-art-excerpt{font-size:11px;color:var(--dim);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
434
|
+
.mem-art-ico{font-size:12px;color:var(--dimmer);flex:0 0 auto}
|
|
435
|
+
.mem-list-empty{color:var(--dim);font-size:12.5px;padding:8px 10px;line-height:1.5}
|
|
436
|
+
/* article view */
|
|
437
|
+
.mem-art-head{margin-bottom:12px}
|
|
438
|
+
.mem-art-title{font-size:19px;font-weight:700;letter-spacing:-.02em;margin-bottom:4px}
|
|
439
|
+
.mem-art-tabs{display:flex;gap:6px;margin:10px 0 14px}
|
|
440
|
+
.mem-art-tab{font:600 11.5px/1 inherit;color:var(--dim);background:var(--card);border:1px solid var(--hairline);
|
|
441
|
+
border-radius:999px;padding:6px 13px;cursor:pointer;transition:all .18s var(--ease)}
|
|
442
|
+
.mem-art-tab:hover{color:var(--ink)}
|
|
443
|
+
.mem-art-tab.active{color:var(--ink);border-color:var(--hairline-strong);background:var(--card-hover)}
|
|
444
|
+
.mem-article-render{font-size:13.5px;line-height:1.65;color:var(--ink)}
|
|
445
|
+
.mem-article-render h1,.mem-article-render h2,.mem-article-render h3{font-weight:700;margin:16px 0 7px;letter-spacing:-.01em}
|
|
446
|
+
.mem-article-render h1:first-child,.mem-article-render h2:first-child{margin-top:0}
|
|
447
|
+
.mem-article-render p{margin:0 0 10px}
|
|
448
|
+
.mem-article-render ul,.mem-article-render ol{margin:0 0 10px;padding-left:22px}
|
|
449
|
+
.mem-article-render code{font-family:var(--mono);font-size:.86em;background:rgba(127,127,127,.18);padding:2px 7px;border-radius:6px}
|
|
450
|
+
.mem-article-render pre{background:var(--code-bg);color:var(--code-ink);border:1px solid var(--hairline-soft);border-radius:12px;
|
|
451
|
+
padding:12px 14px;overflow-x:auto;margin:10px 0;font:12px/1.6 var(--mono)}
|
|
452
|
+
.mem-article-render a{color:var(--cyan);text-decoration:none;border-bottom:1px solid rgba(100,210,255,.34)}
|
|
453
|
+
/* links line (inbound/outbound) */
|
|
454
|
+
.mem-links{display:flex;flex-direction:column;gap:8px;margin-top:16px;padding-top:14px;border-top:1px solid var(--hairline-soft)}
|
|
455
|
+
.mem-links-row{display:flex;flex-wrap:wrap;gap:6px;align-items:center;font-size:11.5px}
|
|
456
|
+
.mem-links-row .lbl{color:var(--dimmer);flex:0 0 auto;font-weight:600;letter-spacing:.03em}
|
|
457
|
+
.mem-link-chip{font-size:12px;color:var(--dim);background:var(--card);border:1px solid var(--hairline);
|
|
458
|
+
border-radius:999px;padding:4px 11px;cursor:pointer;transition:all .15s var(--ease)}
|
|
459
|
+
.mem-link-chip:hover{color:var(--ink);border-color:var(--hairline-strong)}
|
|
460
|
+
/* history / diff (code-panel visual language, matching .tool pre / .approval pre) */
|
|
461
|
+
.mem-hist-entry{display:flex;align-items:baseline;gap:10px;padding:9px 10px;border-radius:10px;cursor:pointer;
|
|
462
|
+
border:1px solid transparent;transition:background .12s}
|
|
463
|
+
.mem-hist-entry:hover{background:var(--card)}
|
|
464
|
+
.mem-hist-entry.active{background:var(--card);border-color:var(--hairline)}
|
|
465
|
+
.mem-hist-subject{font-size:13px;color:var(--ink);flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
466
|
+
.mem-hist-date{font-size:11px;color:var(--dimmer);font-variant-numeric:tabular-nums;flex:0 0 auto}
|
|
467
|
+
.mem-hist-hash{font:11px var(--mono);color:var(--dimmer);flex:0 0 auto}
|
|
468
|
+
.mem-diff{background:var(--code-bg-soft);border-radius:9px;padding:11px 13px;overflow-x:auto;margin-top:10px;
|
|
469
|
+
font:11.5px/1.55 var(--mono);white-space:pre-wrap;word-break:break-word;max-height:420px;overflow-y:auto}
|
|
470
|
+
.mem-diff .diffadd{color:var(--green)}
|
|
471
|
+
.mem-diff .diffdel{color:var(--red)}
|
|
472
|
+
.mem-diff .diffctx{color:var(--dim)}
|
|
473
|
+
.mem-diff .diffhunk{color:var(--cyan)}
|
|
474
|
+
|
|
475
|
+
/* sidebar entry to the panel (quiet, count-bearing — not a nav tab) */
|
|
476
|
+
#chat-memory-entry{display:flex;align-items:center;gap:9px;padding:9px 11px;border-radius:10px;cursor:pointer;
|
|
477
|
+
border:1px solid transparent;color:var(--dim);font-size:12.5px;transition:background .12s,color .12s;margin-top:auto}
|
|
478
|
+
#chat-memory-entry:hover{background:var(--card);color:var(--ink)}
|
|
479
|
+
#chat-memory-entry .mi{font-size:13px;flex:0 0 auto;color:var(--purple)}
|
|
480
|
+
#chat-memory-entry .mt{flex:1 1 auto}
|
|
481
|
+
#chat-memory-entry .mcount{font:11px var(--mono);color:var(--dimmer);background:var(--card);border-radius:999px;padding:2px 8px;flex:0 0 auto}
|
|
482
|
+
|
|
483
|
+
/* ─── Adapter routing table (plan §5.6/§9 Phase 2) — same right-side
|
|
484
|
+
overlay/panel/head/body chrome as the Memory panel (mem-overlay/
|
|
485
|
+
mem-panel/mem-head/mem-body), new top-level ids since it's a distinct
|
|
486
|
+
panel opened from the composer instead of the sidebar. ─── */
|
|
487
|
+
#adapters-overlay{position:fixed;inset:var(--nav-h) 0 0 0;z-index:150;display:none;justify-content:flex-end;
|
|
488
|
+
background:var(--overlay-scrim);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
|
|
489
|
+
#adapters-overlay.open{display:flex;animation:fade .22s var(--ease)}
|
|
490
|
+
#adapters-panel{width:min(520px,100%);height:100%;background:var(--overlay-panel);border-left:1px solid var(--hairline);
|
|
491
|
+
display:flex;flex-direction:column;box-shadow:-24px 0 60px rgba(0,0,0,.4)}
|
|
492
|
+
#adapters-body{flex:1 1 auto;overflow-y:auto;padding:14px 18px 24px}
|
|
493
|
+
.ad-empty{text-align:center;color:var(--dim);padding:50px 18px;font-size:13.5px;line-height:1.6}
|
|
494
|
+
.ad-sec-title{font-size:10.5px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--dimmer);margin:0 0 9px}
|
|
495
|
+
.ad-note{font-size:11.5px;color:var(--dim);line-height:1.5;margin:0 0 16px}
|
|
496
|
+
.ad-row{display:flex;align-items:flex-start;gap:11px;padding:11px 12px;border-radius:12px;
|
|
497
|
+
border:1px solid var(--hairline-soft);margin-bottom:9px;transition:border-color .15s var(--ease)}
|
|
498
|
+
.ad-row.incompatible{opacity:.55}
|
|
499
|
+
.ad-row.selected{border-color:rgba(10,132,255,.5);background:rgba(10,132,255,.06)}
|
|
500
|
+
.ad-row-main{flex:1 1 auto;min-width:0}
|
|
501
|
+
.ad-row-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
502
|
+
.ad-row-id{font-size:13.5px;font-weight:600;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
503
|
+
.ad-badge{font:10.5px var(--mono);letter-spacing:.02em;padding:2px 8px;border-radius:999px;border:1px solid var(--hairline);color:var(--dim);white-space:nowrap}
|
|
504
|
+
.ad-badge.mounted{color:var(--green);border-color:rgba(48,209,88,.4)}
|
|
505
|
+
.ad-badge.selected{color:var(--blue);border-color:rgba(10,132,255,.4)}
|
|
506
|
+
.ad-meta{font-size:11.5px;color:var(--dimmer);margin-top:4px;display:flex;gap:10px;flex-wrap:wrap}
|
|
507
|
+
.ad-meta b{color:var(--dim);font-weight:600;font-variant-numeric:tabular-nums}
|
|
508
|
+
.ad-why{font-size:11px;color:var(--dimmer);margin-top:4px;font-style:italic}
|
|
509
|
+
.ad-actions{display:flex;gap:6px;flex:0 0 auto;flex-wrap:wrap;align-items:center}
|
|
510
|
+
.ad-actions button{appearance:none;cursor:pointer;background:var(--card);border:1px solid var(--hairline);
|
|
511
|
+
color:var(--dim);border-radius:8px;padding:5px 11px;font-size:11.5px;font-weight:600;white-space:nowrap;
|
|
512
|
+
transition:color .18s var(--ease),border-color .18s var(--ease),background .18s var(--ease)}
|
|
513
|
+
.ad-actions button:hover:not(:disabled){color:var(--ink);border-color:var(--hairline-strong)}
|
|
514
|
+
.ad-actions button:disabled{opacity:.4;cursor:default}
|
|
515
|
+
.ad-actions button.primary{color:var(--ink);border-color:rgba(10,132,255,.5);background:rgba(10,132,255,.1)}
|
|
516
|
+
.ad-actions label.ad-stack-chk{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--dimmer);cursor:pointer;white-space:nowrap}
|
|
517
|
+
.ad-actions label.ad-stack-chk input{width:auto;margin:0;accent-color:var(--blue)}
|
|
518
|
+
.ad-stack-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 12px;border-radius:12px;
|
|
519
|
+
background:var(--card);border:1px solid var(--hairline);margin-bottom:14px}
|
|
520
|
+
.ad-stack-bar .lbl{font-size:11.5px;color:var(--dim);flex:0 0 auto}
|
|
521
|
+
.ad-stack-bar .expr{font:12px var(--mono);color:var(--ink);flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
522
|
+
|
|
523
|
+
/* ─── Model Hub (plan §9 Phase 3, beat-matrix Axis 3) — same right-side
|
|
524
|
+
overlay/panel/head/body chrome as Memory + Adapters (mem-overlay/
|
|
525
|
+
mem-panel/mem-head/mem-body), new top-level ids since it's a distinct
|
|
526
|
+
panel opened from the model picker instead of the sidebar/composer. ─── */
|
|
527
|
+
#hub-overlay{position:fixed;inset:var(--nav-h) 0 0 0;z-index:150;display:none;justify-content:flex-end;
|
|
528
|
+
background:var(--overlay-scrim);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
|
|
529
|
+
#hub-overlay.open{display:flex;animation:fade .22s var(--ease)}
|
|
530
|
+
#hub-panel{width:min(480px,100%);height:100%;background:var(--overlay-panel);border-left:1px solid var(--hairline);
|
|
531
|
+
display:flex;flex-direction:column;box-shadow:-24px 0 60px rgba(0,0,0,.4)}
|
|
532
|
+
#hub-body{flex:1 1 auto;overflow-y:auto;padding:14px 18px 24px}
|
|
533
|
+
.hub-sec{margin-bottom:20px}
|
|
534
|
+
.hub-sec:last-child{margin-bottom:0}
|
|
535
|
+
#hub-search-input{width:100%;background:var(--card);border:1px solid var(--hairline);border-radius:10px;
|
|
536
|
+
color:var(--ink);font-size:12.5px;padding:9px 12px;outline:none;transition:border-color .2s;margin-bottom:10px}
|
|
537
|
+
#hub-search-input:focus{border-color:rgba(10,132,255,.5)}
|
|
538
|
+
#hub-search-input::placeholder{color:var(--dimmer)}
|
|
539
|
+
.hub-search-body{min-height:0}
|
|
540
|
+
.hub-empty{color:var(--dim);font-size:12.5px;padding:10px 4px;line-height:1.5}
|
|
541
|
+
/* row language shared by Downloaded + Search results */
|
|
542
|
+
.hub-row{display:flex;align-items:flex-start;gap:10px;padding:10px 11px;border-radius:12px;
|
|
543
|
+
border:1px solid var(--hairline-soft);margin-bottom:8px;transition:border-color .15s var(--ease)}
|
|
544
|
+
.hub-row:last-child{margin-bottom:0}
|
|
545
|
+
.hub-fit-dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto;margin-top:5px}
|
|
546
|
+
.hub-fit-dot.green{background:var(--green);box-shadow:0 0 7px var(--green)}
|
|
547
|
+
.hub-fit-dot.yellow{background:var(--yellow);box-shadow:0 0 7px var(--yellow)}
|
|
548
|
+
.hub-fit-dot.red{background:var(--red)}
|
|
549
|
+
.hub-row-main{flex:1 1 auto;min-width:0}
|
|
550
|
+
.hub-row-name{font-size:13px;font-weight:600;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
551
|
+
.hub-row-meta{font-size:11px;color:var(--dimmer);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
552
|
+
.hub-row-actions{flex:0 0 auto;display:flex;align-items:center;gap:6px}
|
|
553
|
+
.hub-row-actions button{appearance:none;cursor:pointer;background:var(--card);border:1px solid var(--hairline);
|
|
554
|
+
color:var(--dim);border-radius:8px;padding:5px 11px;font-size:11.5px;font-weight:600;white-space:nowrap;
|
|
555
|
+
transition:color .18s var(--ease),border-color .18s var(--ease),background .18s var(--ease)}
|
|
556
|
+
.hub-row-actions button:hover:not(:disabled){color:var(--ink);border-color:rgba(10,132,255,.5);background:rgba(10,132,255,.08)}
|
|
557
|
+
.hub-row-actions button:disabled{opacity:.5;cursor:default}
|
|
558
|
+
.hub-dl-tag{font:11px var(--mono);color:var(--blue);white-space:nowrap}
|
|
559
|
+
.hub-dl-tag.done{color:var(--green)}
|
|
560
|
+
.hub-dl-tag.error{color:var(--red)}
|
|
561
|
+
/* restart-command strip (POST /api/hub/serve's honest restart_required
|
|
562
|
+
outcome) — same code-panel visual language as .mp-cmd, but persistent
|
|
563
|
+
(not a toast) since it needs to stay put long enough to copy. */
|
|
564
|
+
.hub-restart-strip{display:none}
|
|
565
|
+
.hub-restart-strip.show{display:block;margin-bottom:16px;padding:12px 14px;border-radius:12px;
|
|
566
|
+
background:rgba(10,132,255,.08);border:1px solid rgba(10,132,255,.3)}
|
|
567
|
+
.hub-restart-note{font-size:12px;color:var(--ink);line-height:1.5;margin-bottom:8px}
|
|
568
|
+
.hub-cmd{display:flex;align-items:center;gap:8px;background:var(--code-bg-soft);border-radius:8px;
|
|
569
|
+
padding:7px 10px;overflow-x:auto}
|
|
570
|
+
.hub-cmd code{font:12px var(--mono);color:var(--ink);white-space:nowrap;flex:1 1 auto}
|
|
571
|
+
.hub-copy-btn{appearance:none;cursor:pointer;background:var(--card);border:1px solid var(--hairline);
|
|
572
|
+
color:var(--dim);border-radius:6px;padding:3px 9px;font-size:11px;font-weight:600;flex:0 0 auto;white-space:nowrap}
|
|
573
|
+
.hub-copy-btn:hover{color:var(--ink);border-color:var(--hairline-strong)}
|
|
574
|
+
.hub-offline-note{font-size:11.5px;color:var(--dim);line-height:1.5;margin-top:4px;padding-top:14px;border-top:1px solid var(--hairline-soft)}
|
|
575
|
+
|
|
169
576
|
/* ═══════════════════ app shell / routing ═══════════════════ */
|
|
170
577
|
#app{position:fixed;inset:var(--nav-h) 0 0 0;z-index:1}
|
|
171
578
|
section[data-route]{display:none;position:absolute;inset:0}
|
|
@@ -190,7 +597,19 @@
|
|
|
190
597
|
[data-reveal]{opacity:0;transform:translateY(22px);
|
|
191
598
|
transition:opacity .8s var(--ease),transform .8s var(--ease);transition-delay:calc(var(--d,0)*1ms)}
|
|
192
599
|
.lit [data-reveal]{opacity:1;transform:none}
|
|
193
|
-
@media (prefers-reduced-motion:reduce){
|
|
600
|
+
@media (prefers-reduced-motion:reduce){
|
|
601
|
+
[data-reveal]{transition:none;opacity:1;transform:none}
|
|
602
|
+
*{scroll-behavior:auto}
|
|
603
|
+
/* Ambient/decorative infinite-loop animations (plan §7): shimmer
|
|
604
|
+
skeleton sweep, download-pulse dot, streaming cursor blink, running-
|
|
605
|
+
tool status dot. These loop forever by design and carry no state —
|
|
606
|
+
freezing them (not hiding) keeps the element visible+legible without
|
|
607
|
+
motion. One-shot transitions (fade-in on route change, toast slide-in,
|
|
608
|
+
popover open) are left alone; they're brief and convey state change,
|
|
609
|
+
not ambient decoration. */
|
|
610
|
+
#nav-download .dot,.shimmer,.cursor,.tool.running .sdot{animation:none!important}
|
|
611
|
+
.shimmer{color:inherit!important;background:var(--card)}
|
|
612
|
+
}
|
|
194
613
|
|
|
195
614
|
/* ─── cards ─── */
|
|
196
615
|
.grid{display:grid;gap:14px}
|
|
@@ -293,8 +712,8 @@
|
|
|
293
712
|
background:linear-gradient(90deg,var(--cyan),var(--blue));transition:width .5s var(--ease)}
|
|
294
713
|
.progressbar.green i{background:linear-gradient(90deg,#a8ff9e,var(--green))}
|
|
295
714
|
.progmeta{display:flex;justify-content:space-between;font-size:12.5px;color:var(--dim);margin-top:8px}
|
|
296
|
-
.logbox{margin-top:18px;background:
|
|
297
|
-
padding:14px 16px;max-height:340px;overflow-y:auto;font:11.5px/1.55 var(--mono);color:
|
|
715
|
+
.logbox{margin-top:18px;background:var(--code-bg-soft);border:1px solid var(--hairline-soft);border-radius:14px;
|
|
716
|
+
padding:14px 16px;max-height:340px;overflow-y:auto;font:11.5px/1.55 var(--mono);color:rgba(245,245,247,.7);white-space:pre-wrap;word-break:break-word}
|
|
298
717
|
.logbox:empty::before{content:"waiting for output…";color:var(--dimmer)}
|
|
299
718
|
|
|
300
719
|
/* shimmer skeletons */
|
|
@@ -305,13 +724,33 @@
|
|
|
305
724
|
/* toasts */
|
|
306
725
|
#toasts{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:100;
|
|
307
726
|
display:flex;flex-direction:column;gap:10px;align-items:center;pointer-events:none}
|
|
308
|
-
.toast{background:
|
|
309
|
-
border-radius:13px;padding:12px 18px;font-size:14px;font-weight:500;box-shadow:0 16px 50px rgba(0,0,0,.
|
|
727
|
+
.toast{background:var(--overlay-panel);backdrop-filter:blur(20px);border:1px solid var(--hairline);
|
|
728
|
+
border-radius:13px;padding:12px 18px;font-size:14px;font-weight:500;box-shadow:0 16px 50px rgba(0,0,0,.35);
|
|
310
729
|
animation:toastIn .4s var(--ease);max-width:min(560px,90vw)}
|
|
311
730
|
.toast.err{border-color:rgba(255,69,58,.5)}
|
|
312
731
|
.toast.ok{border-color:rgba(48,209,88,.5)}
|
|
313
732
|
@keyframes toastIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
|
|
314
733
|
|
|
734
|
+
/* App-aware assistant spotlight (plan §6.6, §9 Phase 3, beat matrix Axis
|
|
735
|
+
12) — NEVER-HIJACK overlay: pointer-events:none on the whole thing (the
|
|
736
|
+
highlighted control stays clickable straight through the ring/popover),
|
|
737
|
+
no focus trap, transform/opacity-only animation, auto-dismiss handled
|
|
738
|
+
in assistant.ts. Positioned in fixed viewport coordinates by JS
|
|
739
|
+
(getBoundingClientRect of the target), not CSS layout. */
|
|
740
|
+
#assistant-spotlight{position:fixed;inset:0;z-index:95;pointer-events:none;
|
|
741
|
+
opacity:0;transform:scale(.98);transition:opacity .22s var(--ease),transform .22s var(--ease)}
|
|
742
|
+
#assistant-spotlight.show{opacity:1;transform:none}
|
|
743
|
+
#assistant-spotlight .asr-ring{position:fixed;border-radius:12px;
|
|
744
|
+
box-shadow:0 0 0 3px var(--blue),0 0 0 9999px rgba(0,0,0,.32),0 0 24px rgba(10,132,255,.5);
|
|
745
|
+
transition:left .22s var(--ease),top .22s var(--ease),width .22s var(--ease),height .22s var(--ease)}
|
|
746
|
+
#assistant-spotlight .asr-pop{position:fixed;max-width:min(320px,80vw);background:var(--overlay-panel);
|
|
747
|
+
backdrop-filter:blur(20px);border:1px solid var(--hairline);border-radius:12px;padding:10px 14px;
|
|
748
|
+
font-size:13px;line-height:1.4;box-shadow:0 16px 50px rgba(0,0,0,.35)}
|
|
749
|
+
@media (prefers-reduced-motion:reduce){
|
|
750
|
+
#assistant-spotlight{transition:opacity .01ms;transform:none}
|
|
751
|
+
#assistant-spotlight .asr-ring{transition:none}
|
|
752
|
+
}
|
|
753
|
+
|
|
315
754
|
/* empty state */
|
|
316
755
|
.empty{text-align:center;color:var(--dim);padding:60px 20px}
|
|
317
756
|
.empty h3{font-size:18px;font-weight:600;color:var(--ink);margin-bottom:8px;letter-spacing:-.01em}
|
|
@@ -328,10 +767,18 @@
|
|
|
328
767
|
background:var(--card);border:1px solid var(--hairline);color:var(--ink);font-size:13.5px;font-weight:600;
|
|
329
768
|
transition:background .15s,border-color .15s}
|
|
330
769
|
#chat-new:hover{background:var(--card-hover);border-color:rgba(10,132,255,.5)}
|
|
770
|
+
/* Session search — trivial client-side substring filter over titles
|
|
771
|
+
(plan §5.7 / §9 Phase 1 item 4). No server change: SessionListItem
|
|
772
|
+
carries no preview text today, so title-only is the honest scope. */
|
|
773
|
+
#chat-sess-search{width:100%;background:var(--card);border:1px solid var(--hairline);border-radius:10px;
|
|
774
|
+
color:var(--ink);font-size:12.5px;padding:8px 11px;outline:none;transition:border-color .2s}
|
|
775
|
+
#chat-sess-search:focus{border-color:rgba(10,132,255,.5)}
|
|
776
|
+
#chat-sess-search::placeholder{color:var(--dimmer)}
|
|
331
777
|
.sesslist{display:flex;flex-direction:column;gap:2px;margin-top:4px}
|
|
332
778
|
.sess{position:relative;padding:9px 50px 9px 11px;border-radius:10px;cursor:pointer;border:1px solid transparent}
|
|
333
779
|
.sess:hover{background:var(--card)}
|
|
334
780
|
.sess.active{background:var(--card);border-color:var(--hairline)}
|
|
781
|
+
.sess.sess-hidden{display:none}
|
|
335
782
|
.sess .stitle{font-size:13px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
336
783
|
.sess .smeta{font-size:11px;color:var(--dim);margin-top:2px;display:flex;gap:5px;align-items:center;white-space:nowrap}
|
|
337
784
|
.sess .sbtn{position:absolute;top:7px;width:20px;height:20px;border:none;background:transparent;color:var(--dim);
|
|
@@ -342,7 +789,26 @@
|
|
|
342
789
|
.sess .sfork:hover{background:var(--card-hover);color:var(--ink);opacity:1}
|
|
343
790
|
.sess .sdel:hover{background:rgba(255,69,58,.18);color:var(--red);opacity:1}
|
|
344
791
|
.sessempty{color:var(--dim);font-size:12px;padding:8px 11px;line-height:1.5}
|
|
345
|
-
|
|
792
|
+
|
|
793
|
+
/* ─── mobile drawer (plan §5.7/§7/§9 Phase 1): the sidebar becomes a
|
|
794
|
+
slide-over panel on narrow viewports instead of vanishing outright. ─── */
|
|
795
|
+
#chat-hamburger{display:none}
|
|
796
|
+
@media (max-width:760px){
|
|
797
|
+
#chat-hamburger{display:inline-flex}
|
|
798
|
+
#chat-sidebar{
|
|
799
|
+
position:fixed;top:var(--nav-h);bottom:0;left:0;width:min(84vw,320px);z-index:120;
|
|
800
|
+
border-right:1px solid var(--hairline);
|
|
801
|
+
transform:translateX(-100%);transition:transform .28s var(--ease);
|
|
802
|
+
box-shadow:0 0 0 rgba(0,0,0,0);
|
|
803
|
+
}
|
|
804
|
+
#chat-sidebar.drawer-open{transform:translateX(0);box-shadow:24px 0 60px rgba(0,0,0,.45)}
|
|
805
|
+
#chat-drawer-backdrop{display:none;position:fixed;inset:var(--nav-h) 0 0 0;z-index:110;
|
|
806
|
+
background:var(--overlay-scrim);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
|
|
807
|
+
#chat-drawer-backdrop.open{display:block;animation:fade .2s var(--ease)}
|
|
808
|
+
}
|
|
809
|
+
@media (max-width:760px) and (prefers-reduced-motion:reduce){
|
|
810
|
+
#chat-sidebar{transition:none}
|
|
811
|
+
}
|
|
346
812
|
#chat-scroll{flex:1;overflow-y:auto}
|
|
347
813
|
#chat-thread{max-width:840px;margin:0 auto;padding:30px 22px 16px;display:flex;flex-direction:column;gap:18px}
|
|
348
814
|
.msg{display:flex;flex-direction:column;gap:6px;max-width:90%}
|
|
@@ -354,41 +820,88 @@
|
|
|
354
820
|
.msg.assistant .bubble{background:var(--card);border:1px solid var(--hairline)}
|
|
355
821
|
.msg .meta{font-size:11.5px;color:var(--dimmer);font-variant-numeric:tabular-nums}
|
|
356
822
|
.msg .meta b{color:var(--green);font-weight:600}
|
|
823
|
+
/* Message actions (plan §5.2): a quiet hover/focus row under each message,
|
|
824
|
+
same visual language as the code-block copy button (.cbcopy) — dim by
|
|
825
|
+
default, brightens on hover, never shouts. Kept visible via :focus-within
|
|
826
|
+
too so keyboard users can tab to the buttons without hovering. */
|
|
827
|
+
.msg-actions{display:flex;gap:6px;opacity:0;transition:opacity .15s;margin-top:2px}
|
|
828
|
+
.msg:hover .msg-actions,.msg:focus-within .msg-actions,.msg-actions.pinned{opacity:1}
|
|
829
|
+
.msg.user .msg-actions{align-self:flex-end}
|
|
830
|
+
.maction{font:11px/1 var(--mono);color:var(--dim);background:transparent;border:1px solid var(--hairline);
|
|
831
|
+
border-radius:6px;padding:4px 9px;cursor:pointer;transition:color .15s,border-color .15s}
|
|
832
|
+
.maction:hover{color:var(--ink);border-color:var(--dim)}
|
|
833
|
+
.maction:disabled{opacity:.4;cursor:default}
|
|
834
|
+
.maction:disabled:hover{color:var(--dim);border-color:var(--hairline)}
|
|
835
|
+
/* Edit-and-resend as sibling branch: ChatGPT/Claude's `< i/n >` linear
|
|
836
|
+
toggle (plan §5.2 explicitly rules out a tree view this phase). Only
|
|
837
|
+
rendered on the LAST user message, and only when count > 1. */
|
|
838
|
+
.sib-toggle{display:flex;align-items:center;gap:5px;font:11px/1 var(--mono);color:var(--dimmer)}
|
|
839
|
+
.sib-toggle button{background:transparent;border:1px solid var(--hairline);border-radius:6px;color:var(--dim);
|
|
840
|
+
cursor:pointer;padding:2px 7px;line-height:1.4}
|
|
841
|
+
.sib-toggle button:hover:not(:disabled){color:var(--ink);border-color:var(--dim)}
|
|
842
|
+
.sib-toggle button:disabled{opacity:.35;cursor:default}
|
|
843
|
+
.msg-edit-box{width:100%;box-sizing:border-box;background:var(--card-2);border:1px solid var(--hairline);
|
|
844
|
+
border-radius:10px;padding:9px 11px;color:var(--ink);font:inherit;line-height:1.5;resize:vertical;min-height:44px}
|
|
845
|
+
.msg-edit-actions{display:flex;gap:8px;margin-top:6px;justify-content:flex-end}
|
|
357
846
|
.thinkbox{margin:0 0 12px;border:1px solid rgba(10,132,255,.18);border-radius:12px;background:rgba(10,132,255,.06);overflow:hidden}
|
|
358
847
|
.thinkbox summary{cursor:pointer;padding:7px 10px;font-size:12px;font-weight:600;color:var(--dim);user-select:none}
|
|
359
848
|
.thinkbody{padding:0 12px 10px;color:var(--dim);font-size:.92em;white-space:pre-wrap}
|
|
360
|
-
/* markdown-ish rendering
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
.bubble
|
|
365
|
-
.bubble
|
|
366
|
-
.bubble
|
|
367
|
-
.bubble
|
|
849
|
+
/* markdown-ish rendering — shared by chat bubbles AND the memory panel's
|
|
850
|
+
article view (.mem-article-render renders vault/Reference docs through
|
|
851
|
+
the same mdToHtml; without this scope the panel's code fences lost
|
|
852
|
+
their header chrome — "shCopy" mash, 2026-07-07 visual QA). */
|
|
853
|
+
:is(.bubble,.mem-article-render) p{margin:0 0 10px}:is(.bubble,.mem-article-render) p:last-child{margin-bottom:0}
|
|
854
|
+
:is(.bubble,.mem-article-render) ul,:is(.bubble,.mem-article-render) ol{margin:0 0 10px;padding-left:22px}
|
|
855
|
+
:is(.bubble,.mem-article-render) li{margin:3px 0}
|
|
856
|
+
:is(.bubble,.mem-article-render) strong{font-weight:700;color:var(--ink)}
|
|
857
|
+
:is(.bubble,.mem-article-render) em{font-style:italic}
|
|
858
|
+
:is(.bubble,.mem-article-render) code{font-family:var(--mono);font-size:.86em;background:rgba(127,127,127,.18);padding:2px 7px;border-radius:6px}
|
|
859
|
+
:is(.bubble,.mem-article-render) pre{background:var(--code-bg);color:var(--code-ink);border:1px solid var(--hairline-soft);border-radius:12px;
|
|
368
860
|
padding:13px 15px;overflow-x:auto;margin:10px 0;font:12.5px/1.6 var(--mono)}
|
|
369
|
-
.bubble pre code{background:none;padding:0;font-size:inherit}
|
|
370
|
-
.bubble h1,.
|
|
371
|
-
.bubble h1{font-size:1.28em}.bubble h2{font-size:1.16em}.bubble h3{font-size:1.06em}
|
|
372
|
-
.bubble h4,.
|
|
373
|
-
.bubble h1:first-child,.
|
|
374
|
-
.bubble a{color:var(--cyan);text-decoration:none;border-bottom:1px solid rgba(100,210,255,.34)}
|
|
375
|
-
.bubble a:hover{border-bottom-color:var(--cyan)}
|
|
376
|
-
.bubble del{opacity:.55}
|
|
377
|
-
.bubble blockquote{margin:9px 0;padding:3px 14px;border-left:3px solid var(--hairline);color:var(--dim)}
|
|
378
|
-
.bubble blockquote p:last-child{margin-bottom:0}
|
|
379
|
-
.bubble hr{border:none;border-top:1px solid var(--hairline);margin:15px 0}
|
|
380
|
-
.bubble li.task{list-style:none;margin-left:-18px}
|
|
381
|
-
.bubble li.task input{margin-right:7px;accent-color:var(--blue);vertical-align:-1px}
|
|
382
|
-
.bubble .md-tablewrap{overflow-x:auto;margin:11px 0}
|
|
383
|
-
.bubble table.md-table{width:100%;border-collapse:collapse;font-size:.92em}
|
|
384
|
-
.bubble table.md-table th,.
|
|
385
|
-
.bubble table.md-table th{background:rgba(255,255,255,.05);font-weight:600;color:var(--ink)}
|
|
386
|
-
.bubble .codeblock{margin:11px 0;border:1px solid var(--hairline-soft);border-radius:12px;overflow:hidden;background:
|
|
387
|
-
.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)}
|
|
388
|
-
.bubble .cblang{font:11px/1 var(--mono);color:
|
|
389
|
-
.bubble .cbcopy{font:11px/1 var(--mono);color:
|
|
390
|
-
.bubble .cbcopy:hover{color:var(--ink);border-color:
|
|
391
|
-
.bubble .codeblock pre{margin:0;border:none;border-radius:0;background:transparent}
|
|
861
|
+
:is(.bubble,.mem-article-render) pre code{background:none;padding:0;font-size:inherit;color:inherit}
|
|
862
|
+
:is(.bubble,.mem-article-render) h1,:is(.bubble,.mem-article-render) h2,:is(.bubble,.mem-article-render) h3,:is(.bubble,.mem-article-render) h4,:is(.bubble,.mem-article-render) h5,:is(.bubble,.mem-article-render) h6{font-weight:700;margin:14px 0 7px;letter-spacing:-.01em;line-height:1.3}
|
|
863
|
+
:is(.bubble,.mem-article-render) h1{font-size:1.28em}:is(.bubble,.mem-article-render) h2{font-size:1.16em}:is(.bubble,.mem-article-render) h3{font-size:1.06em}
|
|
864
|
+
:is(.bubble,.mem-article-render) h4,:is(.bubble,.mem-article-render) h5,:is(.bubble,.mem-article-render) h6{font-size:1em;color:var(--dim)}
|
|
865
|
+
:is(.bubble,.mem-article-render) h1:first-child,:is(.bubble,.mem-article-render) h2:first-child,:is(.bubble,.mem-article-render) h3:first-child{margin-top:0}
|
|
866
|
+
:is(.bubble,.mem-article-render) a{color:var(--cyan);text-decoration:none;border-bottom:1px solid rgba(100,210,255,.34)}
|
|
867
|
+
:is(.bubble,.mem-article-render) a:hover{border-bottom-color:var(--cyan)}
|
|
868
|
+
:is(.bubble,.mem-article-render) del{opacity:.55}
|
|
869
|
+
:is(.bubble,.mem-article-render) blockquote{margin:9px 0;padding:3px 14px;border-left:3px solid var(--hairline);color:var(--dim)}
|
|
870
|
+
:is(.bubble,.mem-article-render) blockquote p:last-child{margin-bottom:0}
|
|
871
|
+
:is(.bubble,.mem-article-render) hr{border:none;border-top:1px solid var(--hairline);margin:15px 0}
|
|
872
|
+
:is(.bubble,.mem-article-render) li.task{list-style:none;margin-left:-18px}
|
|
873
|
+
:is(.bubble,.mem-article-render) li.task input{margin-right:7px;accent-color:var(--blue);vertical-align:-1px}
|
|
874
|
+
:is(.bubble,.mem-article-render) .md-tablewrap{overflow-x:auto;margin:11px 0}
|
|
875
|
+
:is(.bubble,.mem-article-render) table.md-table{width:100%;border-collapse:collapse;font-size:.92em}
|
|
876
|
+
:is(.bubble,.mem-article-render) table.md-table th,:is(.bubble,.mem-article-render) table.md-table td{border:1px solid var(--hairline-soft);padding:6px 11px;text-align:left}
|
|
877
|
+
:is(.bubble,.mem-article-render) table.md-table th{background:rgba(255,255,255,.05);font-weight:600;color:var(--ink)}
|
|
878
|
+
:is(.bubble,.mem-article-render) .codeblock{margin:11px 0;border:1px solid var(--hairline-soft);border-radius:12px;overflow:hidden;background:var(--code-bg);color:var(--code-ink)}
|
|
879
|
+
:is(.bubble,.mem-article-render) .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)}
|
|
880
|
+
:is(.bubble,.mem-article-render) .cblang{font:11px/1 var(--mono);color:rgba(245,245,247,.65);letter-spacing:.02em}
|
|
881
|
+
:is(.bubble,.mem-article-render) .cbcopy{font:11px/1 var(--mono);color:rgba(245,245,247,.65);background:transparent;border:1px solid rgba(255,255,255,.2);border-radius:6px;padding:4px 10px;cursor:pointer;transition:color .15s,border-color .15s}
|
|
882
|
+
:is(.bubble,.mem-article-render) .cbcopy:hover{color:var(--code-ink);border-color:rgba(255,255,255,.4)}
|
|
883
|
+
:is(.bubble,.mem-article-render) .codeblock pre{margin:0;border:none;border-radius:0;background:transparent;color:inherit}
|
|
884
|
+
/* Canvas v1 (plan §9 Phase 3, beat matrix Axis 2): Preview|Source toggle
|
|
885
|
+
for html/svg fences, sharing .cbhead with the existing lang tag + Copy
|
|
886
|
+
button. .cbhead keeps lang pinned left; the toggle+copy group sits
|
|
887
|
+
right via margin-left:auto on the first right-side element instead of
|
|
888
|
+
space-between, since space-between with 3 children would push the
|
|
889
|
+
toggle to the dead center of the bar. */
|
|
890
|
+
:is(.bubble,.mem-article-render) .cbhead{justify-content:flex-start}
|
|
891
|
+
:is(.bubble,.mem-article-render) .cbtoggle{display:inline-flex;gap:2px;margin-left:auto;padding:2px;border-radius:7px;background:rgba(255,255,255,.05)}
|
|
892
|
+
:is(.bubble,.mem-article-render) .cbview{font:11px/1 var(--mono);color:rgba(245,245,247,.55);background:transparent;border:none;
|
|
893
|
+
border-radius:5px;padding:4px 9px;cursor:pointer;transition:color .15s,background .15s}
|
|
894
|
+
:is(.bubble,.mem-article-render) .cbview:hover{color:var(--code-ink)}
|
|
895
|
+
:is(.bubble,.mem-article-render) .cbview.active{color:var(--code-ink);background:rgba(255,255,255,.14)}
|
|
896
|
+
/* has-canvas keeps Copy visually last, right after the toggle group. */
|
|
897
|
+
:is(.bubble,.mem-article-render) .codeblock.has-canvas .cbcopy{margin-left:2px}
|
|
898
|
+
:is(.bubble,.mem-article-render) .cbcanvas{position:relative;background:#fff}
|
|
899
|
+
:is(.bubble,.mem-article-render) .cbframe{display:block;width:100%;height:420px;min-height:160px;max-height:80vh;
|
|
900
|
+
border:none;resize:vertical;overflow:auto;background:#fff}
|
|
901
|
+
/* No layout shift on toggle: pre and cbcanvas are simply swapped via
|
|
902
|
+
[hidden] — the codeblock's own border/rounding stays put, and the
|
|
903
|
+
iframe's own default height (420px) plus resize:vertical means the
|
|
904
|
+
block doesn't jump when Preview first opens vs. later re-opens. */
|
|
392
905
|
.cursor{display:inline-block;width:8px;height:17px;vertical-align:-3px;border-radius:2px;
|
|
393
906
|
background:linear-gradient(180deg,var(--pink),var(--purple));animation:blink 1s steps(2) infinite}
|
|
394
907
|
@keyframes blink{50%{opacity:0}}
|
|
@@ -412,8 +925,8 @@
|
|
|
412
925
|
.tool.open .tbody{display:block}
|
|
413
926
|
.tool .blk{margin-bottom:10px}.tool .blk:last-child{margin-bottom:0}
|
|
414
927
|
.tool .blbl{font-size:10.5px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--dimmer);margin-bottom:5px}
|
|
415
|
-
.tool pre{background:
|
|
416
|
-
font:12px/1.55 var(--mono);color:
|
|
928
|
+
.tool pre{background:var(--code-bg-soft);border-radius:9px;padding:10px 12px;overflow-x:auto;
|
|
929
|
+
font:12px/1.55 var(--mono);color:rgba(245,245,247,.7);white-space:pre-wrap;word-break:break-word;max-height:260px;overflow-y:auto}
|
|
417
930
|
|
|
418
931
|
/* approval dialog */
|
|
419
932
|
.approval{margin:12px 0;border:1px solid rgba(255,159,10,.45);border-radius:16px;overflow:hidden;
|
|
@@ -424,7 +937,7 @@
|
|
|
424
937
|
.approval .ahead .at{font-weight:700;font-size:14px}
|
|
425
938
|
.approval .ahead .as{font:12px var(--mono);color:var(--orange);margin-left:auto}
|
|
426
939
|
.approval .abody{padding:13px 16px}
|
|
427
|
-
.approval pre{background:
|
|
940
|
+
.approval pre{background:var(--code-bg);color:var(--code-ink);border-radius:10px;padding:12px 14px;overflow-x:auto;
|
|
428
941
|
font:12.5px/1.6 var(--mono);white-space:pre-wrap;word-break:break-word;max-height:280px;overflow-y:auto}
|
|
429
942
|
.approval .diffadd{color:var(--green)}
|
|
430
943
|
.approval .diffdel{color:var(--red)}
|
|
@@ -432,6 +945,71 @@
|
|
|
432
945
|
.approval .resolved{padding:11px 16px;font-size:13px;font-weight:600;display:flex;align-items:center;gap:8px}
|
|
433
946
|
.approval .resolved.allow{color:var(--green)}
|
|
434
947
|
.approval .resolved.deny{color:var(--red)}
|
|
948
|
+
/* Editable arguments (plan §5.4/§6.5, LM Studio's pattern): pre-filled
|
|
949
|
+
with the proposed args JSON; the diff (edit/write only) stays above as
|
|
950
|
+
a read-only visual aid so large content edits don't only exist as raw
|
|
951
|
+
JSON. Approve re-parses the textarea and ships the edited object. */
|
|
952
|
+
.approval .a-diff{margin-bottom:10px}
|
|
953
|
+
.approval textarea.a-args{width:100%;background:var(--code-bg);color:var(--code-ink);border:1px solid var(--hairline);
|
|
954
|
+
border-radius:10px;padding:12px 14px;font:12.5px/1.6 var(--mono);resize:vertical;min-height:64px;max-height:280px}
|
|
955
|
+
.approval textarea.a-args:focus{outline:none;border-color:var(--orange)}
|
|
956
|
+
.approval .a-argerr{margin-top:8px;font-size:12px;color:var(--red)}
|
|
957
|
+
.approval .a-always{display:flex;align-items:center;gap:9px;padding:0 16px 13px;font-size:12.5px;
|
|
958
|
+
color:var(--dim);cursor:pointer;font-weight:500}
|
|
959
|
+
.approval .a-always input{width:auto;margin:0;accent-color:var(--orange)}
|
|
960
|
+
|
|
961
|
+
/* ─── memory provenance chip (plan §5.4.2) — deliberately NOT the wrench
|
|
962
|
+
tool-card language: a citation, not a shell command. Quiet by default
|
|
963
|
+
(small pill, one line), expands to a lightweight card on click. ─── */
|
|
964
|
+
.memchip{margin:10px 0;border:1px solid rgba(191,90,242,.3);border-radius:999px;overflow:hidden;
|
|
965
|
+
background:rgba(191,90,242,.07);max-width:fit-content;transition:border-radius .18s var(--ease)}
|
|
966
|
+
.memchip.open{border-radius:14px;max-width:none}
|
|
967
|
+
.memchip.fail{border-color:rgba(255,69,58,.45);background:rgba(255,69,58,.06)}
|
|
968
|
+
.memchip.fail .mcicon{color:var(--red)}
|
|
969
|
+
.memchip .mchead{display:flex;align-items:center;gap:8px;padding:7px 14px;cursor:pointer;user-select:none}
|
|
970
|
+
.memchip .mcicon{font-size:12.5px;flex:0 0 auto;color:var(--purple)}
|
|
971
|
+
.memchip .mclabel{font-size:12.5px;color:var(--dim);flex:1 1 auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
972
|
+
.memchip .mclabel b{color:var(--ink);font-weight:600}
|
|
973
|
+
.memchip .mccaret{color:var(--dimmer);font-size:11px;transition:transform .2s var(--ease);flex:0 0 auto}
|
|
974
|
+
.memchip.open .mccaret{transform:rotate(90deg)}
|
|
975
|
+
.memchip .mcbody{display:none;border-top:1px solid rgba(191,90,242,.2);padding:11px 14px 12px}
|
|
976
|
+
.memchip.open .mcbody{display:block}
|
|
977
|
+
.memchip .mcbody pre{background:var(--code-bg-soft);border-radius:9px;padding:9px 11px;overflow-x:auto;
|
|
978
|
+
font:11.5px/1.5 var(--mono);color:rgba(245,245,247,.7);white-space:pre-wrap;word-break:break-word;max-height:200px;overflow-y:auto;margin-bottom:8px}
|
|
979
|
+
.memchip .mcopen{font:11px/1 var(--mono);color:var(--purple);background:transparent;border:1px solid rgba(191,90,242,.4);
|
|
980
|
+
border-radius:6px;padding:5px 10px;cursor:pointer;transition:background .15s}
|
|
981
|
+
.memchip .mcopen:hover{background:rgba(191,90,242,.14)}
|
|
982
|
+
|
|
983
|
+
/* ─── Chat-with-files RAG v1 Sources panel (plan §9 Phase 3, beat matrix
|
|
984
|
+
Axis 5) — deliberately close to the memchip's citation language above:
|
|
985
|
+
a collapsed "Sources · K" line under the reply that expands to
|
|
986
|
+
filename+snippet per retrieved chunk, plus [n] markers in the reply
|
|
987
|
+
text rendered as small round citation buttons that jump to their row. */
|
|
988
|
+
.sources{margin-top:11px;border:1px solid var(--hairline-soft);border-radius:12px;overflow:hidden;
|
|
989
|
+
background:rgba(255,255,255,.03)}
|
|
990
|
+
.sources summary{list-style:none;display:flex;align-items:center;gap:7px;padding:8px 13px;
|
|
991
|
+
cursor:pointer;user-select:none;font-size:12.5px;font-weight:600;color:var(--dim)}
|
|
992
|
+
.sources summary::-webkit-details-marker{display:none}
|
|
993
|
+
.sources summary::before{content:"›";display:inline-block;color:var(--dimmer);
|
|
994
|
+
transition:transform .2s var(--ease)}
|
|
995
|
+
.sources[open] summary::before{transform:rotate(90deg)}
|
|
996
|
+
.src-list{border-top:1px solid var(--hairline-soft);padding:9px 13px 11px;display:flex;flex-direction:column;gap:10px}
|
|
997
|
+
.src-row{border-radius:9px;padding:7px 9px;transition:background .5s var(--ease)}
|
|
998
|
+
.src-row.pulse{background:rgba(10,132,255,.16)}
|
|
999
|
+
.src-n{font:700 11px var(--mono);color:var(--blue);margin-right:7px}
|
|
1000
|
+
.src-meta{display:inline-flex;align-items:baseline;gap:8px;flex-wrap:wrap}
|
|
1001
|
+
.src-file{font-size:12.5px;font-weight:600;color:var(--ink)}
|
|
1002
|
+
.src-range{font:11px var(--mono);color:var(--dimmer)}
|
|
1003
|
+
.src-snippet{margin-top:4px;font-size:12px;line-height:1.5;color:var(--dim);white-space:pre-wrap;word-break:break-word}
|
|
1004
|
+
/* [n] citation markers rendered inline in the assistant's reply text
|
|
1005
|
+
(markdown.ts's linkifyCitations) — small round buttons, never full-size
|
|
1006
|
+
links, so they read as footnote markers rather than ordinary hyperlinks. */
|
|
1007
|
+
:is(.bubble) .cite-mark{display:inline-flex;align-items:center;justify-content:center;
|
|
1008
|
+
min-width:16px;height:16px;padding:0 4px;margin:0 1px;border-radius:999px;border:none;
|
|
1009
|
+
background:rgba(10,132,255,.18);color:var(--blue);font:700 10px var(--mono);
|
|
1010
|
+
cursor:pointer;vertical-align:2px;line-height:1;transition:background .15s}
|
|
1011
|
+
:is(.bubble) .cite-mark:hover{background:rgba(10,132,255,.3)}
|
|
1012
|
+
@media (prefers-reduced-motion:reduce){.src-row{transition:none}.sources summary::before{transition:none}}
|
|
435
1013
|
|
|
436
1014
|
/* chat hello / starters */
|
|
437
1015
|
#chat-hello{max-width:840px;margin:6vh auto 0;padding:0 22px;text-align:center}
|
|
@@ -440,21 +1018,58 @@
|
|
|
440
1018
|
#chat-hello .chips{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin-top:28px}
|
|
441
1019
|
.chip{border:1px solid var(--hairline);background:var(--card);color:var(--dim);border-radius:999px;
|
|
442
1020
|
padding:9px 16px;font-size:13.5px;cursor:pointer;transition:all .25s var(--ease)}
|
|
443
|
-
.chip:hover{color:var(--ink);border-color:
|
|
1021
|
+
.chip:hover{color:var(--ink);border-color:var(--hairline-strong);background:var(--card-hover)}
|
|
1022
|
+
|
|
1023
|
+
/* memory consent card (plan §5.1/§9 Phase 2) — one-line, skippable, in the
|
|
1024
|
+
hero. Never auto-creates the vault; a plain hairline card, not a modal,
|
|
1025
|
+
so it reads as an offer, not an interruption. */
|
|
1026
|
+
#chat-consent{display:none;align-items:center;gap:12px;text-align:left;margin:22px auto 0;max-width:640px;
|
|
1027
|
+
border:1px solid var(--hairline);background:var(--card);border-radius:14px;padding:13px 16px}
|
|
1028
|
+
#chat-consent.show{display:flex}
|
|
1029
|
+
#chat-consent .cc-icon{font-size:18px;flex:0 0 auto}
|
|
1030
|
+
#chat-consent .cc-text{flex:1 1 auto;font-size:13px;color:var(--dim);line-height:1.5}
|
|
1031
|
+
#chat-consent .cc-text b{color:var(--ink);font-weight:600}
|
|
1032
|
+
#chat-consent .cc-actions{display:flex;gap:8px;flex:0 0 auto}
|
|
444
1033
|
|
|
445
1034
|
/* chat composer */
|
|
446
1035
|
#chat-foot{flex:0 0 auto;border-top:1px solid var(--hairline);padding:14px 22px 16px;
|
|
447
|
-
background:
|
|
1036
|
+
background:var(--card-2);backdrop-filter:blur(16px)}
|
|
448
1037
|
.composer{max-width:840px;margin:0 auto}
|
|
449
1038
|
.queuebar{display:none;max-width:840px;margin:0 auto 10px;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dim)}
|
|
450
1039
|
.queuebar .qtag{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--hairline);
|
|
451
1040
|
border-radius:999px;padding:4px 11px;background:var(--card)}
|
|
452
1041
|
.queuebar .qtag b{color:var(--ink);font-weight:600}
|
|
453
1042
|
.inputrow{display:flex;gap:10px;align-items:flex-end}
|
|
454
|
-
#chat-box{flex:1;
|
|
1043
|
+
#chat-box-wrap{position:relative;flex:1;min-width:0}
|
|
1044
|
+
#chat-box{width:100%;background:var(--card);border:1px solid var(--hairline);border-radius:16px;
|
|
455
1045
|
color:var(--ink);font:15px/1.5 inherit;padding:13px 16px;resize:none;outline:none;
|
|
456
|
-
max-height:200px;min-height:50px;transition:border-color .25s}
|
|
1046
|
+
max-height:200px;min-height:50px;transition:border-color .25s;box-sizing:border-box}
|
|
457
1047
|
#chat-box:focus{border-color:rgba(10,132,255,.5)}
|
|
1048
|
+
/* Unified "#" retrieval mention picker (plan §5.2/§9 Phase 2) — same
|
|
1049
|
+
floating-panel chrome as .samplepop, anchored above the textarea it
|
|
1050
|
+
types into instead of a pill. Zero layout shift: absolutely positioned,
|
|
1051
|
+
hidden by default, never reflows the composer underneath it. */
|
|
1052
|
+
.mentionpop{position:absolute;bottom:calc(100% + 8px);left:0;right:0;z-index:40;
|
|
1053
|
+
max-height:min(46vh,320px);overflow-y:auto;
|
|
1054
|
+
background:var(--popover-panel);border:1px solid var(--hairline);border-radius:13px;padding:6px;
|
|
1055
|
+
backdrop-filter:blur(22px) saturate(1.3);-webkit-backdrop-filter:blur(22px) saturate(1.3);
|
|
1056
|
+
box-shadow:0 18px 50px rgba(0,0,0,.35);display:none;
|
|
1057
|
+
transform:translateY(4px);opacity:0;transition:opacity .14s var(--ease),transform .14s var(--ease)}
|
|
1058
|
+
.mentionpop.open{display:block;transform:translateY(0);opacity:1}
|
|
1059
|
+
.mention-sec-title{font-size:10.5px;font-weight:600;color:var(--dimmer);letter-spacing:.04em;
|
|
1060
|
+
text-transform:uppercase;padding:7px 9px 4px}
|
|
1061
|
+
.mention-row{display:flex;align-items:center;gap:8px;padding:7px 9px;border-radius:9px;cursor:pointer}
|
|
1062
|
+
.mention-row:hover,.mention-row.active{background:var(--card-hover)}
|
|
1063
|
+
.mention-ico{font-size:12px;color:var(--dimmer);flex:0 0 auto}
|
|
1064
|
+
.mention-label{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
|
1065
|
+
font-size:13px;color:var(--ink)}
|
|
1066
|
+
.mention-tag{flex:0 0 auto;font-size:10px;color:var(--dimmer);text-transform:uppercase;letter-spacing:.03em}
|
|
1067
|
+
.mention-empty{padding:10px 9px;font-size:12px;color:var(--dim)}
|
|
1068
|
+
/* Pulse a file's attach-chip when it's selected from the mention picker
|
|
1069
|
+
(the file is already attached — this is the only feedback needed). */
|
|
1070
|
+
.attach-chip.pulse{animation:chippulse .5s var(--ease) 2}
|
|
1071
|
+
@keyframes chippulse{0%,100%{box-shadow:0 0 0 rgba(10,132,255,0)}50%{box-shadow:0 0 0 3px rgba(10,132,255,.45)}}
|
|
1072
|
+
@media (prefers-reduced-motion:reduce){.attach-chip.pulse{animation:none}}
|
|
458
1073
|
#chat-send{background:linear-gradient(120deg,var(--blue),var(--purple));color:#fff;
|
|
459
1074
|
border:none;border-radius:14px;height:50px;padding:0 22px;font:600 14px inherit;cursor:pointer;transition:filter .2s}
|
|
460
1075
|
#chat-send:hover{filter:brightness(1.14)}
|
|
@@ -462,9 +1077,43 @@
|
|
|
462
1077
|
#chat-stop{display:none;background:var(--card);border:1px solid var(--hairline);color:var(--ink);
|
|
463
1078
|
border-radius:14px;height:50px;padding:0 20px;font:600 14px inherit;cursor:pointer}
|
|
464
1079
|
#chat-stop:hover{background:var(--card-hover)}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
1080
|
+
/* Wrap + shrink rules (2026-07-06 jank fix): without flex-wrap/min-width:0
|
|
1081
|
+
the right pill cluster overflowed past the viewport edge at tablet
|
|
1082
|
+
widths ("no adapter" clipped). The status line ellipsizes first; the
|
|
1083
|
+
pills wrap to a second row before anything leaves the screen. */
|
|
1084
|
+
.chat-hint{max-width:840px;margin:8px auto 0;font-size:11.5px;color:var(--dimmer);display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap}
|
|
1085
|
+
.chat-hint #chat-status-line{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1086
|
+
.chat-hint .hint-right{display:flex;gap:12px;align-items:center;flex-wrap:wrap;justify-content:flex-end;min-width:0}
|
|
1087
|
+
/* Native <select> in .pill clothes: reset the UA appearance and draw our
|
|
1088
|
+
own chevron — the stock arrow collided with the label text ("no adaptɘr"
|
|
1089
|
+
glyph pile-up), and long adapter ids now ellipsize instead of overflow. */
|
|
1090
|
+
#chat-adapter{appearance:none;-webkit-appearance:none;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
|
1091
|
+
padding-right:27px;cursor:pointer;
|
|
1092
|
+
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' fill='none' stroke='%23909098' stroke-width='1.4' stroke-linecap='round'/%3E%3C/svg%3E");
|
|
1093
|
+
background-repeat:no-repeat;background-position:right 11px center}
|
|
1094
|
+
/* perf strip — always-visible, quiet: replaces the old bare #chat-tps text
|
|
1095
|
+
with tok/s + TTFT + context-fill + (server-driven only) a lane badge. */
|
|
1096
|
+
#chat-perf{max-width:840px;margin:8px auto 0;display:flex;align-items:center;gap:14px;
|
|
1097
|
+
font:11px/1 var(--mono);letter-spacing:.01em;color:var(--dimmer);flex-wrap:wrap}
|
|
1098
|
+
#chat-perf .pf-item{white-space:nowrap}
|
|
1099
|
+
#chat-perf .pf-item b{color:var(--ink);font-weight:600}
|
|
1100
|
+
#chat-perf .pf-lane{display:none;align-items:center;gap:5px;border:1px solid var(--hairline);
|
|
1101
|
+
border-radius:999px;padding:2px 9px;font-size:10.5px;text-transform:uppercase;letter-spacing:.04em}
|
|
1102
|
+
#chat-perf .pf-lane .dot{width:6px;height:6px;border-radius:50%;background:var(--dimmer)}
|
|
1103
|
+
#chat-perf .pf-lane.serial .dot{background:var(--dim)}
|
|
1104
|
+
#chat-perf .pf-lane.spec .dot{background:var(--blue);box-shadow:0 0 8px var(--blue)}
|
|
1105
|
+
#chat-perf .pf-lane.batched .dot{background:var(--green);box-shadow:0 0 8px var(--green)}
|
|
1106
|
+
/* adapter chip — upgraded presentation over the bare <select>: current
|
|
1107
|
+
selection + specialty line, options grayed by /v1/adapters/available's
|
|
1108
|
+
compatible flag rather than hidden outright. */
|
|
1109
|
+
#chat-adapter option:disabled{color:var(--dimmer);font-style:italic}
|
|
1110
|
+
/* "manage adapters" gear beside the quick-switch <select> — opens the
|
|
1111
|
+
routing table (#adapters-overlay), same pill sizing as #chat-think. */
|
|
1112
|
+
#adapters-manage{appearance:none;-webkit-appearance:none;cursor:pointer;display:inline-flex;align-items:center;
|
|
1113
|
+
justify-content:center;width:27px;height:27px;border-radius:999px;background:var(--card);
|
|
1114
|
+
border:1px solid var(--hairline);color:var(--dim);font-size:12.5px;line-height:1;flex:0 0 auto;
|
|
1115
|
+
transition:color .18s var(--ease),border-color .18s var(--ease),background .18s var(--ease)}
|
|
1116
|
+
#adapters-manage:hover{color:var(--ink);background:var(--card-hover);border-color:var(--hairline-strong)}
|
|
468
1117
|
/* attach (+) button + attachment chips */
|
|
469
1118
|
#chat-attach-btn{flex:0 0 auto;width:50px;height:50px;border-radius:14px;background:var(--card);
|
|
470
1119
|
border:1px solid var(--hairline);color:var(--ink);font-size:23px;line-height:1;cursor:pointer;transition:background .15s,border-color .15s}
|
|
@@ -477,6 +1126,14 @@
|
|
|
477
1126
|
.attach-chip .att-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--ink)}
|
|
478
1127
|
.attach-chip .att-x{border:none;background:transparent;color:var(--dim);cursor:pointer;font-size:12px;line-height:1;padding:0 2px}
|
|
479
1128
|
.attach-chip .att-x:hover{color:var(--red)}
|
|
1129
|
+
/* Chat-with-files RAG v1 (plan §9 Phase 3, beat matrix Axis 5): shown on
|
|
1130
|
+
text-file chips once the attached set is large enough that only the
|
|
1131
|
+
most relevant chunks get sent per turn instead of the whole file
|
|
1132
|
+
(composer.ts's shouldRetrieve threshold) — transparency principle, the
|
|
1133
|
+
mode switch must be visible on the chip, not just in prompt content
|
|
1134
|
+
the user never sees. */
|
|
1135
|
+
.attach-chip .att-tag{flex:0 0 auto;font-size:9.5px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
|
|
1136
|
+
color:var(--purple);background:rgba(191,90,242,.14);border-radius:999px;padding:2px 7px}
|
|
480
1137
|
.composer.dragover{outline:2px dashed var(--blue);outline-offset:6px;border-radius:14px}
|
|
481
1138
|
.msg-atts{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px}
|
|
482
1139
|
.msg-att-img{max-width:220px;max-height:220px;border-radius:10px;display:block}
|
|
@@ -485,7 +1142,10 @@
|
|
|
485
1142
|
|
|
486
1143
|
/* loss chart */
|
|
487
1144
|
.chartcard{margin-top:20px}
|
|
488
|
-
|
|
1145
|
+
/* the chart's SVG gridlines are hardcoded white-on-dark (drawChart()), so
|
|
1146
|
+
the wrap keeps the always-dark code-panel surface for contrast rather
|
|
1147
|
+
than following the chat theme — same rationale as .bubble pre above. */
|
|
1148
|
+
.chart-wrap{position:relative;background:var(--code-bg-soft);border:1px solid var(--hairline-soft);border-radius:14px;padding:14px;margin-top:6px}
|
|
489
1149
|
.chart-legend{display:flex;gap:18px;font-size:12px;color:var(--dim);margin-bottom:8px}
|
|
490
1150
|
.chart-legend .lg{display:inline-flex;align-items:center;gap:7px}
|
|
491
1151
|
.chart-legend .sw{width:14px;height:3px;border-radius:2px}
|
|
@@ -493,84 +1153,341 @@
|
|
|
493
1153
|
.chart-stats .cs b{display:block;font-size:11px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--dim);margin-bottom:3px}
|
|
494
1154
|
.chart-stats .cs span{font-size:20px;font-weight:700;letter-spacing:-.02em}
|
|
495
1155
|
|
|
1156
|
+
/* Tablet band (2026-07-06 jank fix): with up to 7 tabs (fewer once the
|
|
1157
|
+
plan §8 Developer toggle is off — see nav .tab[data-dev] above) +
|
|
1158
|
+
hamburger + shortcuts + theme toggle + gear + conn pill, the tab row
|
|
1159
|
+
can overflow well above the 900px cutoff — tighten tab padding first
|
|
1160
|
+
so fewer widths need the scroll-fade at all. */
|
|
1161
|
+
@media (max-width:1160px){
|
|
1162
|
+
nav{gap:12px}
|
|
1163
|
+
nav .tab{padding:8px 9px;font-size:13px}
|
|
1164
|
+
}
|
|
496
1165
|
@media (max-width:900px){
|
|
497
1166
|
.hpgrid{grid-template-columns:1fr}
|
|
498
1167
|
nav .model{display:none}
|
|
499
1168
|
.wrap{padding:34px 18px 100px}
|
|
500
1169
|
nav{gap:12px;padding:0 16px}
|
|
1170
|
+
/* Icon-only theme toggle a bit earlier than 560px — the nav is already
|
|
1171
|
+
tight here (hamburger + shortcuts + toggle + hf-gear + conn pill) and
|
|
1172
|
+
"Auto" as a text label isn't worth the horizontal cost at this width.
|
|
1173
|
+
The ◐ tt-icon stands in so the Auto button never renders as an empty
|
|
1174
|
+
sliver (2026-07-06 QA finding). */
|
|
1175
|
+
#theme-toggle button{padding:0 7px;font-size:15px}
|
|
1176
|
+
#theme-toggle .tt-label{display:none}
|
|
1177
|
+
#theme-toggle .tt-icon{display:inline}
|
|
1178
|
+
/* Developer toggle: dot-only past this width, same reasoning as the
|
|
1179
|
+
theme toggle above — "Developer" as a text label costs more than
|
|
1180
|
+
the dot alone conveys once the row is already tight. */
|
|
1181
|
+
#nav-developer span:not(.nd-dot){display:none}
|
|
1182
|
+
#nav-developer{padding:0;width:34px;justify-content:center}
|
|
1183
|
+
}
|
|
1184
|
+
@media (max-width:760px){
|
|
1185
|
+
/* Touch band (2026-07-06 QA): a keyboard-shortcut sheet button is dead
|
|
1186
|
+
weight on touch viewports, and the nav needs its width back. */
|
|
1187
|
+
#nav-shortcuts{display:none}
|
|
1188
|
+
/* The conn pill collapses to its status dot (title keeps the text for
|
|
1189
|
+
long-press/hover); "live · localhost" was clipping at the viewport
|
|
1190
|
+
edge at 375px. */
|
|
1191
|
+
#nav-conn #nav-conn-text{display:none}
|
|
1192
|
+
#nav-conn{padding:6px 9px}
|
|
1193
|
+
/* Keep a scrollable strip of tabs alive — flex shrank them to 0 width
|
|
1194
|
+
at phone sizes, silently removing Quantize/Status navigation. The
|
|
1195
|
+
scroll-fade masks signal the rest. With the Developer toggle off
|
|
1196
|
+
(the fresh-browser default) this row can be just the single Chat
|
|
1197
|
+
tab — min-width still holds so the fade-mask math in
|
|
1198
|
+
updateTabFades() never divides against a zero-width strip. */
|
|
1199
|
+
nav .tabs{min-width:96px}
|
|
501
1200
|
}
|
|
502
1201
|
@media (max-width:560px){
|
|
503
1202
|
nav .brand{font-size:16px}
|
|
504
1203
|
nav .tab{padding:8px 10px;font-size:13px}
|
|
1204
|
+
nav .right{gap:6px}
|
|
1205
|
+
.icon-btn,.hf-gear{width:30px;height:30px}
|
|
1206
|
+
/* Phones follow the system theme (Auto is the default); the explicit
|
|
1207
|
+
3-way toggle returns ≥560px. Freeing its ~110px is what keeps the
|
|
1208
|
+
tab strip + status dot inside a 375px viewport (2026-07-06 QA). */
|
|
1209
|
+
#theme-toggle{display:none}
|
|
505
1210
|
}
|
|
506
1211
|
</style>
|
|
1212
|
+
<!-- Vendored syntax-highlighting theme (src/web/vendor/hljs-theme.css, hand-derived
|
|
1213
|
+
from the design tokens above — see that file's header). No CDN; served by
|
|
1214
|
+
src/server.ts alongside this page. -->
|
|
1215
|
+
<link rel="stylesheet" href="/assets/hljs.css">
|
|
1216
|
+
<!-- PWA installability (plan §9 Phase 3, beat-matrix Axis 10): manifest +
|
|
1217
|
+
inline SVG icon (src/web/manifest.webmanifest, src/web/icon.svg — no
|
|
1218
|
+
binary PNGs, per the hygiene gate; some browsers won't render an SVG
|
|
1219
|
+
app icon and that's an accepted tradeoff, see features-matrix.md).
|
|
1220
|
+
theme-color matches the app's --bg token so the OS chrome doesn't
|
|
1221
|
+
flash a mismatched color on launch. -->
|
|
1222
|
+
<link rel="manifest" href="/manifest.webmanifest">
|
|
1223
|
+
<link rel="icon" href="/assets/icon.svg" type="image/svg+xml">
|
|
1224
|
+
<link rel="apple-touch-icon" href="/assets/icon.svg">
|
|
1225
|
+
<meta name="theme-color" content="#000000">
|
|
507
1226
|
</head>
|
|
508
1227
|
<body>
|
|
509
1228
|
<div class="bloom" id="bloom"></div>
|
|
510
1229
|
|
|
511
1230
|
<!-- ═══════════════════ TOP NAV ═══════════════════ -->
|
|
512
1231
|
<nav>
|
|
1232
|
+
<!-- Mobile drawer trigger — chat route only (CSS keeps it hidden ≥760px
|
|
1233
|
+
and JS keeps it hidden off the chat route, since there is no
|
|
1234
|
+
sidebar to open elsewhere). aria-controls/expanded kept in sync by
|
|
1235
|
+
the drawer open/close functions. -->
|
|
1236
|
+
<button class="icon-btn" id="chat-hamburger" title="Open recent chats" aria-label="Open recent chats"
|
|
1237
|
+
aria-controls="chat-sidebar" aria-expanded="false">
|
|
1238
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
|
|
1239
|
+
</button>
|
|
513
1240
|
<a class="brand grad g-hero" href="#/chat">mlx-bun</a>
|
|
514
1241
|
<div class="tabs" id="tabs">
|
|
515
1242
|
<a class="tab" href="#/chat" data-tab="chat">Chat</a>
|
|
516
|
-
<a class="tab" href="#/quantize" data-tab="quantize">Quantize</a>
|
|
517
|
-
<a class="tab" href="#/finetune" data-tab="finetune">Fine-tune</a>
|
|
518
|
-
<a class="tab" href="#/dataset" data-tab="dataset">Build Dataset</a>
|
|
519
|
-
<a class="tab" href="#/status" data-tab="status">Status</a>
|
|
520
|
-
|
|
521
|
-
|
|
1243
|
+
<a class="tab" href="#/quantize" data-tab="quantize" data-dev>Quantize</a>
|
|
1244
|
+
<a class="tab" href="#/finetune" data-tab="finetune" data-dev>Fine-tune</a>
|
|
1245
|
+
<a class="tab" href="#/dataset" data-tab="dataset" data-dev>Build Dataset</a>
|
|
1246
|
+
<a class="tab" href="#/status" data-tab="status" data-dev>Status</a>
|
|
1247
|
+
<!-- Full-page link (not hash-routed), so the active-tab highlight logic
|
|
1248
|
+
can never mark it — web-ui-pass-plan.md #19, cosmetic-only per the
|
|
1249
|
+
plan. aria-label spells out that it leaves the SPA rather than
|
|
1250
|
+
leaving the tab looking silently broken. -->
|
|
1251
|
+
<a class="tab" href="/curves" data-tab="curves" data-dev aria-label="Curve designer (opens a separate page)">Curves</a>
|
|
1252
|
+
<a class="tab" href="#/routes" data-tab="routes" data-dev>Routes</a>
|
|
522
1253
|
</div>
|
|
523
1254
|
<div class="right">
|
|
1255
|
+
<!-- Developer toggle (plan §8/§9 Phase 2): collapses Quantize/Fine-tune/
|
|
1256
|
+
Build Dataset/Status/Curves/Routes behind one switch. State + the
|
|
1257
|
+
one-time existing-user default live in shell.ts's
|
|
1258
|
+
initDeveloperToggle/isDeveloperMode. -->
|
|
1259
|
+
<button id="nav-developer" data-spotlight="developer-toggle" type="button" role="switch" aria-checked="false"
|
|
1260
|
+
title="Show developer tools (Quantize, Fine-tune, Build Dataset, Status, Curves, Routes)"
|
|
1261
|
+
aria-label="Toggle developer tools">
|
|
1262
|
+
<span class="nd-dot" id="nav-developer-dot" aria-hidden="true"></span><span>Developer</span>
|
|
1263
|
+
</button>
|
|
1264
|
+
<button class="icon-btn" id="nav-shortcuts" title="Keyboard shortcuts (Cmd/Ctrl+/)" aria-label="Keyboard shortcuts"
|
|
1265
|
+
aria-haspopup="dialog" aria-controls="shortcut-overlay" aria-expanded="false">
|
|
1266
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"></rect><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M6 14h12"></path></svg>
|
|
1267
|
+
</button>
|
|
1268
|
+
<!-- 3-way theme toggle (plan §7): auto follows prefers-color-scheme,
|
|
1269
|
+
dark/light are explicit user overrides — all persisted to
|
|
1270
|
+
localStorage by setTheme() below. -->
|
|
1271
|
+
<div id="theme-toggle" role="group" aria-label="Theme">
|
|
1272
|
+
<button type="button" data-theme-choice="auto" title="Match system theme" aria-label="Auto theme"><span class="tt-icon" aria-hidden="true">◐</span><span class="tt-label">Auto</span></button>
|
|
1273
|
+
<button type="button" data-theme-choice="dark" title="Dark theme" aria-label="Dark theme">🌙</button>
|
|
1274
|
+
<button type="button" data-theme-choice="light" title="Light theme" aria-label="Light theme">☀️</button>
|
|
1275
|
+
</div>
|
|
524
1276
|
<button class="hf-gear" id="nav-hf" title="Hugging Face token settings" aria-label="Hugging Face settings">
|
|
525
1277
|
<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>
|
|
526
1278
|
</button>
|
|
527
|
-
|
|
1279
|
+
<!-- Model picker (plan §5.6/§9 Phase 2): #nav-model was a dead <span>
|
|
1280
|
+
with no click handler; now opens a popover of downloaded models
|
|
1281
|
+
with a per-row /fit-shaped verdict (model-picker.ts). Wrapped for
|
|
1282
|
+
position:relative anchoring, same pattern as #chat-sampling-wrap. -->
|
|
1283
|
+
<span id="nav-model-wrap">
|
|
1284
|
+
<button class="model" id="nav-model" data-spotlight="model-picker" type="button" title="active model — click to browse downloaded models"
|
|
1285
|
+
aria-haspopup="dialog" aria-controls="model-pop" aria-expanded="false">connecting…</button>
|
|
1286
|
+
<div id="model-pop" class="samplepop" role="dialog" aria-label="Downloaded models">
|
|
1287
|
+
<h4>Downloaded models</h4>
|
|
1288
|
+
<div id="model-pop-body"></div>
|
|
1289
|
+
</div>
|
|
1290
|
+
</span>
|
|
528
1291
|
<span class="pill" id="nav-conn"><span class="dot"></span><span id="nav-conn-text">connecting…</span></span>
|
|
529
1292
|
<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>
|
|
530
1293
|
</div>
|
|
531
1294
|
</nav>
|
|
532
1295
|
|
|
533
|
-
<!-- ═══════════════════
|
|
1296
|
+
<!-- ═══════════════════ SETTINGS OVERLAY ═══════════════════
|
|
1297
|
+
(was "HF settings" — widened in place for the Agent tools section,
|
|
1298
|
+
plan §5.4/§6.5/§9 Phase 2. Same ids/behavior for the HF piece; nothing
|
|
1299
|
+
about push-to-hub or the hf-* wiring changed.) -->
|
|
534
1300
|
<div id="hf-overlay">
|
|
535
|
-
<div id="hf-modal">
|
|
1301
|
+
<div id="hf-modal" role="dialog" aria-modal="true" aria-labelledby="hf-modal-title-el">
|
|
536
1302
|
<div class="hf-modal-head">
|
|
537
|
-
<div class="hf-modal-title">
|
|
1303
|
+
<div class="hf-modal-title" id="hf-modal-title-el">Settings</div>
|
|
538
1304
|
<button class="hf-close" id="hf-close" aria-label="Close">✕</button>
|
|
539
1305
|
</div>
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
1306
|
+
|
|
1307
|
+
<!-- Agent tools (plan §5.4/§6.5): the codingTools opt-in + the durable
|
|
1308
|
+
always-allow list. Lives above Hugging Face since it's the more
|
|
1309
|
+
chat-relevant setting. -->
|
|
1310
|
+
<div class="settings-section">
|
|
1311
|
+
<h3 class="settings-h">Agent tools</h3>
|
|
1312
|
+
<label class="chk settings-toggle-row" for="settings-coding-tools">
|
|
1313
|
+
<input type="checkbox" id="settings-coding-tools">
|
|
1314
|
+
<span>Let the agent touch files on this machine</span>
|
|
1315
|
+
</label>
|
|
1316
|
+
<div class="hint">
|
|
1317
|
+
Off by default. When on, the chat assistant can run shell commands and
|
|
1318
|
+
edit or create files — each one asks first, in the chat, unless you
|
|
1319
|
+
choose "always allow" for that action. Nothing runs without your
|
|
1320
|
+
review the first time.
|
|
1321
|
+
</div>
|
|
1322
|
+
<p class="settings-note" id="settings-coding-tools-note" style="display:none"></p>
|
|
1323
|
+
<div id="settings-approvals-list"></div>
|
|
545
1324
|
</div>
|
|
546
|
-
|
|
547
|
-
<div class="
|
|
548
|
-
<
|
|
1325
|
+
|
|
1326
|
+
<div class="settings-section">
|
|
1327
|
+
<h3 class="settings-h">Hugging Face</h3>
|
|
1328
|
+
<p class="hf-modal-sub" id="hf-state">Checking for a saved token…</p>
|
|
1329
|
+
<div class="field" style="margin-bottom:10px">
|
|
1330
|
+
<label>Write token</label>
|
|
1331
|
+
<input type="password" id="hf-token-input" placeholder="hf_…" autocomplete="off" spellcheck="false">
|
|
1332
|
+
<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>
|
|
1333
|
+
</div>
|
|
1334
|
+
<div id="hf-settings-msg"></div>
|
|
1335
|
+
<div class="btnrow" style="margin-top:14px">
|
|
1336
|
+
<button class="btn primary sm" id="hf-save">Save token</button>
|
|
1337
|
+
</div>
|
|
549
1338
|
</div>
|
|
550
1339
|
</div>
|
|
551
1340
|
</div>
|
|
552
1341
|
|
|
1342
|
+
<!-- ═══════════════════ KEYBOARD SHORTCUT SHEET ═══════════════════ -->
|
|
1343
|
+
<div id="shortcut-overlay">
|
|
1344
|
+
<div id="shortcut-modal" role="dialog" aria-modal="true" aria-labelledby="sk-modal-title">
|
|
1345
|
+
<div class="sk-head">
|
|
1346
|
+
<div class="sk-title" id="sk-modal-title">Keyboard shortcuts</div>
|
|
1347
|
+
<button class="sk-close" id="sk-close" aria-label="Close">✕</button>
|
|
1348
|
+
</div>
|
|
1349
|
+
<div class="sk-row"><span class="sk-desc">Show this shortcut sheet</span><span class="sk-keys"><span class="sk-key" id="sk-mod-label">⌘</span><span class="sk-key">/</span></span></div>
|
|
1350
|
+
<div class="sk-row"><span class="sk-desc">New chat</span><span class="sk-keys"><span class="sk-key sk-mod"></span><span class="sk-key">Shift</span><span class="sk-key">O</span></span></div>
|
|
1351
|
+
<div class="sk-row"><span class="sk-desc">Focus the composer</span><span class="sk-keys"><span class="sk-key">Shift</span><span class="sk-key">Esc</span></span></div>
|
|
1352
|
+
<div class="sk-row"><span class="sk-desc">Copy last response</span><span class="sk-keys"><span class="sk-key sk-mod"></span><span class="sk-key">Shift</span><span class="sk-key">C</span></span></div>
|
|
1353
|
+
<div class="sk-row"><span class="sk-desc">Send message</span><span class="sk-keys"><span class="sk-key">Enter</span></span></div>
|
|
1354
|
+
<div class="sk-row"><span class="sk-desc">New line in composer</span><span class="sk-keys"><span class="sk-key">Shift</span><span class="sk-key">Enter</span></span></div>
|
|
1355
|
+
<div class="sk-row"><span class="sk-desc">Close popover / drawer / sheet</span><span class="sk-keys"><span class="sk-key">Esc</span></span></div>
|
|
1356
|
+
</div>
|
|
1357
|
+
</div>
|
|
1358
|
+
|
|
1359
|
+
<!-- ═══════════════════ MEMORY PANEL ═══════════════════ (plan §5.5/§9 Phase 2)
|
|
1360
|
+
Full-height overlay beside chat, same drawer/overlay language as the HF
|
|
1361
|
+
modal and mobile sidebar: fixed, scrimmed, focus-trapped, Escape closes.
|
|
1362
|
+
All content rendered client-side by memory-panel.ts from the
|
|
1363
|
+
/api/memory/* REST wrappers — no new WS frames. -->
|
|
1364
|
+
<div id="mem-overlay">
|
|
1365
|
+
<div id="mem-panel" role="dialog" aria-modal="true" aria-labelledby="mem-title">
|
|
1366
|
+
<div class="mem-head">
|
|
1367
|
+
<button class="mem-back" id="mem-back" type="button" aria-label="Back to article list">‹ Back</button>
|
|
1368
|
+
<h2 id="mem-title">Memory</h2>
|
|
1369
|
+
<button class="mem-close" id="mem-close" aria-label="Close memory panel">✕</button>
|
|
1370
|
+
</div>
|
|
1371
|
+
<div class="mem-status" id="mem-status"></div>
|
|
1372
|
+
<div class="mem-body" id="mem-body"></div>
|
|
1373
|
+
</div>
|
|
1374
|
+
</div>
|
|
1375
|
+
|
|
1376
|
+
<!-- ═══════════════════ ADAPTER ROUTING TABLE ═══════════════════ (plan §5.6/§9 Phase 2)
|
|
1377
|
+
Same right-side overlay/panel chrome as the Memory panel (fixed,
|
|
1378
|
+
scrimmed, focus-trapped, Escape closes) — the deep adapter surface
|
|
1379
|
+
opened from the composer's ⚙ button beside #chat-adapter. Reads the
|
|
1380
|
+
same /v1/adapters + /v1/adapters/available list state the quick
|
|
1381
|
+
<select> uses (adapters-panel.ts), never a second fetch owner. -->
|
|
1382
|
+
<div id="adapters-overlay">
|
|
1383
|
+
<div id="adapters-panel" role="dialog" aria-modal="true" aria-labelledby="adapters-title">
|
|
1384
|
+
<div class="mem-head">
|
|
1385
|
+
<h2 id="adapters-title">Adapters</h2>
|
|
1386
|
+
<button class="mem-close" id="adapters-close" aria-label="Close adapter routing table">✕</button>
|
|
1387
|
+
</div>
|
|
1388
|
+
<div class="mem-body" id="adapters-body"></div>
|
|
1389
|
+
</div>
|
|
1390
|
+
</div>
|
|
1391
|
+
|
|
1392
|
+
<!-- ═══════════════════ MODEL HUB ═══════════════════ (plan §9 Phase 3, beat-matrix Axis 3)
|
|
1393
|
+
Same right-side overlay/panel/head/body chrome as the Memory + Adapters
|
|
1394
|
+
panels (fixed, scrimmed, focus-trapped, Escape closes). Opened from the
|
|
1395
|
+
model picker popover's "Browse models…" action (hub.ts registers the
|
|
1396
|
+
open callback via shell.ts's setOpenHubFromModelPicker). Three sections:
|
|
1397
|
+
Downloaded (from /api/hub/local), Search Hugging Face (/api/hub/search),
|
|
1398
|
+
and a quiet offline footer note. NEVER auto-downloads, NEVER
|
|
1399
|
+
auto-serves — every action here is an explicit click. -->
|
|
1400
|
+
<div id="hub-overlay">
|
|
1401
|
+
<div id="hub-panel" role="dialog" aria-modal="true" aria-labelledby="hub-title">
|
|
1402
|
+
<div class="mem-head">
|
|
1403
|
+
<h2 id="hub-title">Model Hub</h2>
|
|
1404
|
+
<button class="mem-close" id="hub-close" aria-label="Close Model Hub">✕</button>
|
|
1405
|
+
</div>
|
|
1406
|
+
<div class="mem-body" id="hub-body">
|
|
1407
|
+
<div id="hub-restart-strip" class="hub-restart-strip"></div>
|
|
1408
|
+
|
|
1409
|
+
<div class="hub-sec">
|
|
1410
|
+
<div class="mem-sec-title">Downloaded</div>
|
|
1411
|
+
<div id="hub-local-body"></div>
|
|
1412
|
+
</div>
|
|
1413
|
+
|
|
1414
|
+
<div class="hub-sec">
|
|
1415
|
+
<div class="mem-sec-title">Search Hugging Face</div>
|
|
1416
|
+
<input type="search" id="hub-search-input" placeholder="Search MLX models…" aria-label="Search Hugging Face for MLX models" autocomplete="off">
|
|
1417
|
+
<div id="hub-search-body" class="hub-search-body">
|
|
1418
|
+
<div class="hub-empty">Type to search Hugging Face for MLX models.</div>
|
|
1419
|
+
</div>
|
|
1420
|
+
</div>
|
|
1421
|
+
|
|
1422
|
+
<p class="hub-offline-note" id="hub-offline-note" style="display:none">
|
|
1423
|
+
You're offline (or Hugging Face is unreachable) — search needs a network connection,
|
|
1424
|
+
but your downloaded models still work fully offline.
|
|
1425
|
+
</p>
|
|
1426
|
+
</div>
|
|
1427
|
+
</div>
|
|
1428
|
+
</div>
|
|
1429
|
+
|
|
1430
|
+
<!-- Mobile drawer backdrop (chat sidebar only — see #chat-sidebar CSS) -->
|
|
1431
|
+
<div id="chat-drawer-backdrop"></div>
|
|
1432
|
+
|
|
553
1433
|
<div id="app">
|
|
554
1434
|
|
|
555
1435
|
<!-- ═══════════════════ CHAT ═══════════════════ -->
|
|
556
1436
|
<section data-route="chat" id="s-chat">
|
|
557
1437
|
<aside id="chat-sidebar">
|
|
558
|
-
<button id="chat-new" title="Start a new chat (or type /new)">+ New chat</button>
|
|
1438
|
+
<button id="chat-new" data-spotlight="new-chat" title="Start a new chat (or type /new)">+ New chat</button>
|
|
1439
|
+
<input type="search" id="chat-sess-search" data-spotlight="session-search" placeholder="Search chats…" aria-label="Search chats" autocomplete="off">
|
|
559
1440
|
<div id="chat-sessions" class="sesslist"></div>
|
|
1441
|
+
<!-- Memory panel entry (plan §5.5) — quiet, chat-adjacent, NOT a nav
|
|
1442
|
+
tab: memory is the product, not a dev tool. Hidden entirely until
|
|
1443
|
+
the first /api/memory/status poll resolves (see memory-panel.ts),
|
|
1444
|
+
so it never flashes "0 articles" before a real vault is known to
|
|
1445
|
+
exist or not exist. -->
|
|
1446
|
+
<button id="chat-memory-entry" data-spotlight="memory-entry" type="button" style="display:none"
|
|
1447
|
+
aria-haspopup="dialog" aria-controls="mem-overlay" aria-expanded="false">
|
|
1448
|
+
<span class="mi" aria-hidden="true">◆</span>
|
|
1449
|
+
<span class="mt">Memory</span>
|
|
1450
|
+
<span class="mcount" id="chat-memory-count">—</span>
|
|
1451
|
+
</button>
|
|
560
1452
|
</aside>
|
|
561
1453
|
<div id="chat-main">
|
|
562
1454
|
<div id="chat-scroll">
|
|
563
1455
|
<div id="chat-hello">
|
|
564
1456
|
<h1>Local. Private. <span class="grad g-hero">Yours.</span></h1>
|
|
565
1457
|
<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>
|
|
566
|
-
<div class="chips">
|
|
1458
|
+
<div class="chips" id="chat-hello-chips">
|
|
567
1459
|
<button class="chip" data-q="What is mlx-bun and what can it do?">What is mlx-bun?</button>
|
|
568
1460
|
<button class="chip" data-q="How do I load a bigger or different model?">Load a bigger model</button>
|
|
569
1461
|
<button class="chip" data-q="Is this really private? Where does my data go?">Is this private?</button>
|
|
570
1462
|
<button class="chip" data-q="What can you help me with right now?">What can you do?</button>
|
|
571
1463
|
</div>
|
|
1464
|
+
<!-- Memory consent card (plan §5.1/§9 Phase 2): first run only (no
|
|
1465
|
+
vault + never dismissed, see memory-panel.ts's isConsentDismissed).
|
|
1466
|
+
NEVER auto-creates the vault — this is the only path that calls
|
|
1467
|
+
POST /api/memory/init. Hidden by default; shown by
|
|
1468
|
+
maybeShowConsentCard() after the first status poll. -->
|
|
1469
|
+
<div id="chat-consent" role="group" aria-label="Memory setup">
|
|
1470
|
+
<span class="cc-icon" aria-hidden="true">◆</span>
|
|
1471
|
+
<span class="cc-text">Let mlx-bun remember what matters? Creates a local, git-tracked vault you can open anytime.</span>
|
|
1472
|
+
<span class="cc-actions">
|
|
1473
|
+
<button class="btn ghost sm" id="chat-consent-skip" type="button">Skip</button>
|
|
1474
|
+
<button class="btn primary sm" id="chat-consent-yes" type="button">Set up memory</button>
|
|
1475
|
+
</span>
|
|
1476
|
+
</div>
|
|
572
1477
|
</div>
|
|
573
|
-
|
|
1478
|
+
<!-- aria-live is a coarse approximation: it announces at the container
|
|
1479
|
+
level (whole finished messages / completed blocks), not per-token
|
|
1480
|
+
on the live tail — announcing every streamed delta would spam a
|
|
1481
|
+
screen reader with one interruption per token. aria-atomic=false
|
|
1482
|
+
so only the newly-appended content is announced, not the whole
|
|
1483
|
+
growing thread each time. -->
|
|
1484
|
+
<!-- data-ui-chrome="content" excludes the whole rendered conversation
|
|
1485
|
+
(model/user/tool text, links, citations) from captureUiSnapshot
|
|
1486
|
+
(assistant.ts) — that snapshot is re-served to the model verbatim
|
|
1487
|
+
as trusted "current UI state" via get_current_app_context, and an
|
|
1488
|
+
`a[href]` inside a rendered message is untrusted content, not app
|
|
1489
|
+
chrome (see isAgentChrome's doc comment). -->
|
|
1490
|
+
<div id="chat-thread" role="log" aria-live="polite" aria-atomic="false" data-ui-chrome="content"></div>
|
|
574
1491
|
</div>
|
|
575
1492
|
<div id="chat-foot">
|
|
576
1493
|
<div class="queuebar" id="chat-queue"></div>
|
|
@@ -579,18 +1496,78 @@
|
|
|
579
1496
|
<div class="inputrow">
|
|
580
1497
|
<button id="chat-attach-btn" class="iconbtn" title="Attach files or images" aria-label="Attach files">+</button>
|
|
581
1498
|
<input type="file" id="chat-file-input" multiple style="display:none">
|
|
582
|
-
<
|
|
1499
|
+
<div id="chat-box-wrap">
|
|
1500
|
+
<!-- Keep the placeholder SHORT: the Enter/Shift+Enter AND "#"
|
|
1501
|
+
hints live in the chat-hint status line below (and the Cmd+/
|
|
1502
|
+
sheet). A long placeholder wraps to two lines at phone/tablet
|
|
1503
|
+
widths inside this single-row box and clips against the
|
|
1504
|
+
border (2026-07-06 jank; re-introduced + re-fixed 07-07). -->
|
|
1505
|
+
<textarea id="chat-box" data-spotlight="composer" rows="1" placeholder="Message the local agent…"></textarea>
|
|
1506
|
+
<!-- Unified "#" retrieval mention (plan §5.2/§9 Phase 2): typing
|
|
1507
|
+
"#" opens this picker over both attached files and vault
|
|
1508
|
+
articles as one gesture (composer.ts's initMentionPicker).
|
|
1509
|
+
role=listbox for the arrow-key/Enter contract; content is
|
|
1510
|
+
built entirely client-side by renderMentionListHtml(). -->
|
|
1511
|
+
<div id="chat-mention-pop" class="mentionpop" role="listbox" aria-label="Attach a file or recall a memory"></div>
|
|
1512
|
+
</div>
|
|
583
1513
|
<button id="chat-stop" title="Abort the current turn">Stop</button>
|
|
584
|
-
<button id="chat-send">Send</button>
|
|
1514
|
+
<button id="chat-send" data-spotlight="send">Send</button>
|
|
585
1515
|
</div>
|
|
586
1516
|
<div class="chat-hint">
|
|
587
1517
|
<span id="chat-status-line">Enter to send · Shift+Enter for a new line · type while it streams to steer</span>
|
|
588
1518
|
<span class="hint-right">
|
|
1519
|
+
<!-- Per-chat system prompt + presets v1 (plan §9 Phase 2, beat
|
|
1520
|
+
matrix Axis 4): same pill+popover chrome as #chat-sampling
|
|
1521
|
+
(setSysPromptPopoverClose registers with shell.ts's
|
|
1522
|
+
closeTopOverlay the same way). The dot lights up whenever a
|
|
1523
|
+
custom prompt is active — the user must always SEE it's
|
|
1524
|
+
shaping replies (trust rule), never a silent setting. -->
|
|
1525
|
+
<span id="chat-sysprompt-wrap">
|
|
1526
|
+
<span id="chat-sysprompt" data-spotlight="prompt-pill" class="pill" role="button" tabindex="0" aria-haspopup="true" aria-expanded="false"
|
|
1527
|
+
title="System prompt (shapes how the assistant replies in this chat)"><span class="dot"></span>Prompt</span>
|
|
1528
|
+
<div id="chat-sysprompt-pop" class="samplepop sysprompt-pop" role="dialog" aria-label="System prompt">
|
|
1529
|
+
<h4>System prompt</h4>
|
|
1530
|
+
<textarea id="sysprompt-text" rows="6" maxlength="4000"
|
|
1531
|
+
placeholder="e.g. Answer in French. Keep replies under 3 sentences. Prefer Python over JS."
|
|
1532
|
+
aria-label="Custom system prompt"></textarea>
|
|
1533
|
+
<div class="sysprompt-foot-row">
|
|
1534
|
+
<span class="note" id="sysprompt-count">0 / 4000</span>
|
|
1535
|
+
<button id="sysprompt-clear" type="button">Clear</button>
|
|
1536
|
+
</div>
|
|
1537
|
+
<span class="note">Layered on top of the built-in assistant prompt — it doesn't replace mlx-bun's own
|
|
1538
|
+
identity/tool guidance. Takes effect on your next message (not retroactively).</span>
|
|
1539
|
+
<div class="sysprompt-presets">
|
|
1540
|
+
<h4>Presets</h4>
|
|
1541
|
+
<div class="sysprompt-preset-row">
|
|
1542
|
+
<select id="sysprompt-preset-select" class="pill" aria-label="Saved presets">
|
|
1543
|
+
<option value="">— presets —</option>
|
|
1544
|
+
</select>
|
|
1545
|
+
<button id="sysprompt-preset-save" type="button" title="Save the current prompt + sampling as a named preset">Save</button>
|
|
1546
|
+
<button id="sysprompt-preset-delete" type="button" title="Delete the selected preset">Delete</button>
|
|
1547
|
+
</div>
|
|
1548
|
+
<span class="note">A preset bundles this system prompt with the current Sampling settings — applying one sets both at once.</span>
|
|
1549
|
+
</div>
|
|
1550
|
+
</div>
|
|
1551
|
+
</span>
|
|
589
1552
|
<span id="chat-sampling-wrap">
|
|
590
|
-
<span id="chat-sampling" class="pill" role="button" tabindex="0" aria-haspopup="true" aria-expanded="false"
|
|
1553
|
+
<span id="chat-sampling" data-spotlight="sampling-pill" class="pill" role="button" tabindex="0" aria-haspopup="true" aria-expanded="false"
|
|
591
1554
|
title="Sampling controls (temperature · top_p · top_k)"><span class="dot"></span>Sampling</span>
|
|
592
1555
|
<div id="chat-sampling-pop" class="samplepop" role="dialog" aria-label="Sampling controls">
|
|
593
|
-
<
|
|
1556
|
+
<div class="samp-head-row">
|
|
1557
|
+
<h4>Sampling</h4>
|
|
1558
|
+
<span id="samp-oneshot-chip" class="samp-oneshot-chip" style="display:none">next msg only</span>
|
|
1559
|
+
</div>
|
|
1560
|
+
<!-- Per-message sampling scope (plan §9 Phase 3, beat matrix Axis 4):
|
|
1561
|
+
"this session" (default) behaves exactly as today; "next message
|
|
1562
|
+
only" arms a one-shot override consumed by the next completed
|
|
1563
|
+
turn (composer.ts's pushSampling/clearOneShotArmed). -->
|
|
1564
|
+
<div class="samp-scope-row">
|
|
1565
|
+
<label for="samp-scope">Apply</label>
|
|
1566
|
+
<select id="samp-scope" aria-label="Sampling scope">
|
|
1567
|
+
<option value="session">this session</option>
|
|
1568
|
+
<option value="next_turn">next message only</option>
|
|
1569
|
+
</select>
|
|
1570
|
+
</div>
|
|
594
1571
|
<div class="samprow">
|
|
595
1572
|
<label for="samp-temp">Temperature</label>
|
|
596
1573
|
<input id="samp-temp" type="range" min="0" max="2" step="0.05" aria-label="Temperature">
|
|
@@ -606,20 +1583,83 @@
|
|
|
606
1583
|
<input id="samp-topk" type="range" min="0" max="100" step="1" aria-label="top_k">
|
|
607
1584
|
<output id="samp-topk-val" class="samp-val auto">off</output>
|
|
608
1585
|
</div>
|
|
1586
|
+
<details class="samp-adv" id="samp-advanced">
|
|
1587
|
+
<summary>Advanced</summary>
|
|
1588
|
+
<div class="samprow">
|
|
1589
|
+
<label for="samp-minp">min_p</label>
|
|
1590
|
+
<input id="samp-minp" type="range" min="0" max="0.5" step="0.01" aria-label="min_p">
|
|
1591
|
+
<output id="samp-minp-val" class="samp-val auto">off</output>
|
|
1592
|
+
</div>
|
|
1593
|
+
<div class="samprow">
|
|
1594
|
+
<label for="samp-xtcp">XTC prob</label>
|
|
1595
|
+
<input id="samp-xtcp" type="range" min="0" max="1" step="0.01" aria-label="XTC probability">
|
|
1596
|
+
<output id="samp-xtcp-val" class="samp-val auto">off</output>
|
|
1597
|
+
</div>
|
|
1598
|
+
<div class="samprow">
|
|
1599
|
+
<label for="samp-xtct">XTC thresh</label>
|
|
1600
|
+
<input id="samp-xtct" type="range" min="0" max="1" step="0.01" aria-label="XTC threshold">
|
|
1601
|
+
<output id="samp-xtct-val" class="samp-val auto">off</output>
|
|
1602
|
+
</div>
|
|
1603
|
+
<div class="samprow">
|
|
1604
|
+
<label for="samp-reppen">repetition</label>
|
|
1605
|
+
<input id="samp-reppen" type="range" min="1" max="2" step="0.01" aria-label="repetition penalty">
|
|
1606
|
+
<output id="samp-reppen-val" class="samp-val auto">off</output>
|
|
1607
|
+
</div>
|
|
1608
|
+
<div class="samprow">
|
|
1609
|
+
<label for="samp-repctx">rep. window</label>
|
|
1610
|
+
<input id="samp-repctx" type="range" min="0" max="1024" step="16" aria-label="repetition context size">
|
|
1611
|
+
<output id="samp-repctx-val" class="samp-val auto">off</output>
|
|
1612
|
+
</div>
|
|
1613
|
+
<div class="samprow">
|
|
1614
|
+
<label for="samp-prespen">presence</label>
|
|
1615
|
+
<input id="samp-prespen" type="range" min="-2" max="2" step="0.05" aria-label="presence penalty">
|
|
1616
|
+
<output id="samp-prespen-val" class="samp-val auto">off</output>
|
|
1617
|
+
</div>
|
|
1618
|
+
<div class="samprow">
|
|
1619
|
+
<label for="samp-freqpen">frequency</label>
|
|
1620
|
+
<input id="samp-freqpen" type="range" min="-2" max="2" step="0.05" aria-label="frequency penalty">
|
|
1621
|
+
<output id="samp-freqpen-val" class="samp-val auto">off</output>
|
|
1622
|
+
</div>
|
|
1623
|
+
<div class="seed-row">
|
|
1624
|
+
<label for="samp-seed">seed</label>
|
|
1625
|
+
<input id="samp-seed" type="text" inputmode="numeric" pattern="[0-9]*" placeholder="random" aria-label="seed">
|
|
1626
|
+
</div>
|
|
1627
|
+
</details>
|
|
609
1628
|
<div class="samppop-foot">
|
|
610
|
-
<span class="note">Sliders default to
|
|
1629
|
+
<span class="note" id="samp-foot-note">Sliders default to this model's recommended values — temperature also follows the thinking toggle. Reset returns everything to defaults.</span>
|
|
611
1630
|
<button id="samp-reset" type="button">Reset</button>
|
|
612
1631
|
</div>
|
|
613
1632
|
</div>
|
|
614
1633
|
</span>
|
|
615
|
-
<select id="chat-adapter" class="pill" title="LoRA adapter for this chat (none = base model)">
|
|
1634
|
+
<select id="chat-adapter" data-spotlight="adapter-select" class="pill" title="LoRA adapter for this chat (none = base model)">
|
|
616
1635
|
<option value="">no adapter</option>
|
|
617
1636
|
</select>
|
|
1637
|
+
<!-- Adapter routing table entry (plan §5.6/§9 Phase 2): the
|
|
1638
|
+
quick-switch <select> above stays the fast path; this opens
|
|
1639
|
+
the deep three-state surface (available/loaded/selected,
|
|
1640
|
+
RAM cost, stacking) — same adapter list state, not a
|
|
1641
|
+
second source of truth (see adapters-panel.ts). -->
|
|
1642
|
+
<button id="adapters-manage" type="button" title="Manage adapters — routing table, RAM cost, stacking"
|
|
1643
|
+
aria-haspopup="dialog" aria-controls="adapters-overlay" aria-expanded="false">⚙</button>
|
|
618
1644
|
<span id="chat-think" class="pill" role="button" tabindex="0" title="Toggle the model's reasoning"><span class="dot"></span>Thinking</span>
|
|
619
|
-
<span id="chat-context" title="Context window in use — auto-compacts before it fills"></span>
|
|
620
|
-
<span id="chat-tps"></span>
|
|
621
1645
|
</span>
|
|
622
1646
|
</div>
|
|
1647
|
+
<!-- Perf strip: replaces the old bare #chat-tps dim text (plan §5.2,
|
|
1648
|
+
§6.4) with tok/s, TTFT, context-fill, and a server-driven lane
|
|
1649
|
+
badge, always visible (not a popover) — "speed is ambient, not a
|
|
1650
|
+
stat you look up". Context-fill absorbs the old #chat-context
|
|
1651
|
+
span's job (single source of the "how full is my context"
|
|
1652
|
+
reading, no longer duplicated between the hint row and here). -->
|
|
1653
|
+
<div id="chat-perf">
|
|
1654
|
+
<span class="pf-item" id="pf-tps" title="Decode speed for the current/last turn"></span>
|
|
1655
|
+
<span class="pf-item" id="pf-ttft" title="Time to first token"></span>
|
|
1656
|
+
<span class="pf-item" id="pf-fill" title="Context window in use — auto-compacts before it fills"></span>
|
|
1657
|
+
<!-- Lane badge: server-driven only (never client-inferred, plan §2.3
|
|
1658
|
+
risk #5). Hidden until a turn_end frame actually carries `lane`
|
|
1659
|
+
(wave-1 backend work landed the field — src/serve/lane-registry.ts,
|
|
1660
|
+
pi-web.ts turn_end — so this fills in for real turns, not a stub). -->
|
|
1661
|
+
<span class="pf-lane" id="pf-lane"><span class="dot"></span><span id="pf-lane-label"></span></span>
|
|
1662
|
+
</div>
|
|
623
1663
|
</div>
|
|
624
1664
|
</div>
|
|
625
1665
|
</div>
|
|
@@ -642,7 +1682,7 @@
|
|
|
642
1682
|
<div class="field">
|
|
643
1683
|
<label>Model id or path</label>
|
|
644
1684
|
<div id="q-drop">
|
|
645
|
-
<input type="text" id="q-model" placeholder="mlx-community/Qwen3-8B-bf16" autocomplete="off">
|
|
1685
|
+
<input type="text" id="q-model" data-spotlight="quantize-source" placeholder="mlx-community/Qwen3-8B-bf16" autocomplete="off">
|
|
646
1686
|
</div>
|
|
647
1687
|
<input type="file" id="q-folder" webkitdirectory directory style="display:none">
|
|
648
1688
|
<div class="hint">Anything on huggingface.co, an absolute path to a local snapshot, or <strong>choose / drop a folder</strong> below — we read its <code>config.json</code> and find the snapshot on disk.</div>
|
|
@@ -736,7 +1776,7 @@
|
|
|
736
1776
|
<div class="field">
|
|
737
1777
|
<label>Model directory / path</label>
|
|
738
1778
|
<div id="f-drop">
|
|
739
|
-
<input type="text" id="f-model" placeholder="/abs/path/to/model or mlx-community/Qwen3-8B-4bit" autocomplete="off">
|
|
1779
|
+
<input type="text" id="f-model" data-spotlight="finetune-base" placeholder="/abs/path/to/model or mlx-community/Qwen3-8B-4bit" autocomplete="off">
|
|
740
1780
|
</div>
|
|
741
1781
|
<input type="file" id="f-folder" webkitdirectory directory style="display:none">
|
|
742
1782
|
<div class="hint">An OptiQ quant or any supported local model works — type a path/repo id, or <strong>choose / drop a folder</strong> below.</div>
|
|
@@ -797,6 +1837,14 @@
|
|
|
797
1837
|
<div class="field"><label>LR schedule</label>
|
|
798
1838
|
<select id="f-orpo-sched"><option value="cosine">cosine (recommended)</option><option value="constant">constant</option></select></div>
|
|
799
1839
|
</div>
|
|
1840
|
+
<div class="field" style="margin-bottom:18px">
|
|
1841
|
+
<label>SFT scope</label>
|
|
1842
|
+
<div class="seg" id="f-orpo-scope">
|
|
1843
|
+
<button data-v="full" class="on">full</button>
|
|
1844
|
+
<button data-v="response">response</button>
|
|
1845
|
+
</div>
|
|
1846
|
+
<div class="hint">full = paper/TRL chosen-NLL over the whole sequence; response = pre-2026-07 response-only.</div>
|
|
1847
|
+
</div>
|
|
800
1848
|
</div>
|
|
801
1849
|
|
|
802
1850
|
<div class="hpgrid">
|
|
@@ -1033,6 +2081,13 @@
|
|
|
1033
2081
|
</section>
|
|
1034
2082
|
|
|
1035
2083
|
<!-- ═══════════════════ ROUTES ═══════════════════ -->
|
|
2084
|
+
<!-- web-ui-pass-plan.md #17: /dag 404s outside a repo checkout (readFileSync
|
|
2085
|
+
on a repo-relative doc, absent from compiled binaries/npm installs).
|
|
2086
|
+
shell.ts's initRoutesProbe() HEAD-checks /dag at boot: on success this
|
|
2087
|
+
iframe renders as-is; on 404 it hides the Routes tab and swaps this
|
|
2088
|
+
section's innerHTML for a graceful note instead of a raw 404 iframe.
|
|
2089
|
+
Could instead embed the artifact server-side like /curves' fallback
|
|
2090
|
+
does — that's server.ts territory, out of scope this wave. -->
|
|
1036
2091
|
<section data-route="routes" id="s-routes">
|
|
1037
2092
|
<iframe src="/dag" title="Training · inference route map"
|
|
1038
2093
|
style="width:100%;height:100%;border:0;display:block"></iframe>
|
|
@@ -1042,1696 +2097,36 @@
|
|
|
1042
2097
|
|
|
1043
2098
|
<div id="toasts"></div>
|
|
1044
2099
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
if (cls) e.className = cls;
|
|
1059
|
-
if (parent) parent.appendChild(e);
|
|
1060
|
-
return e;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
/** Toast notification. kind: "" | "ok" | "err". */
|
|
1064
|
-
function toast(msg, kind = "", ms = 4200) {
|
|
1065
|
-
const t = el("div", "toast " + kind, $("toasts"));
|
|
1066
|
-
t.textContent = msg;
|
|
1067
|
-
setTimeout(() => { t.style.transition = "opacity .4s,transform .4s"; t.style.opacity = "0"; t.style.transform = "translateY(10px)"; setTimeout(() => t.remove(), 420); }, ms);
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
/** Fetch JSON with graceful error handling. Throws on !ok. */
|
|
1071
|
-
async function api(path, opts) {
|
|
1072
|
-
const r = await fetch(path, {
|
|
1073
|
-
headers: { "content-type": "application/json" },
|
|
1074
|
-
...opts,
|
|
1075
|
-
...(opts && opts.body ? { body: typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body) } : {}),
|
|
1076
|
-
});
|
|
1077
|
-
const text = await r.text();
|
|
1078
|
-
let data;
|
|
1079
|
-
try { data = text ? JSON.parse(text) : {}; } catch { data = { ok: false, error: text.slice(0, 400) || ("HTTP " + r.status) }; }
|
|
1080
|
-
if (!r.ok && data.ok === undefined) data = { ok: false, error: data.error || data.message || ("HTTP " + r.status) };
|
|
1081
|
-
return data;
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
/**
|
|
1085
|
-
* Wraps an EventSource over /api/jobs/:id/stream and dispatches typed events.
|
|
1086
|
-
* Server → client line protocol (JSON per `data:` line):
|
|
1087
|
-
* {type:"log", line}
|
|
1088
|
-
* {type:"stage", stage, progress, message, output_dir?, adapter_path?}
|
|
1089
|
-
* {type:"metric", kind:"train"|"val", step, loss, lr?, tps?, progress?, message?}
|
|
1090
|
-
* {type:"done"} {type:"failed", error}
|
|
1091
|
-
* Returns the EventSource so callers can .close().
|
|
1092
|
-
*/
|
|
1093
|
-
function jobStream(jobId, handlers) {
|
|
1094
|
-
const es = new EventSource("/api/jobs/" + encodeURIComponent(jobId) + "/stream");
|
|
1095
|
-
es.onmessage = (ev) => {
|
|
1096
|
-
let e; try { e = JSON.parse(ev.data); } catch { return; }
|
|
1097
|
-
const fn = handlers[e.type];
|
|
1098
|
-
if (fn) fn(e);
|
|
1099
|
-
};
|
|
1100
|
-
es.addEventListener("end", () => es.close());
|
|
1101
|
-
es.onerror = () => { if (handlers.error) handlers.error(); };
|
|
1102
|
-
return es;
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
/** Allow only safe link targets (http/https/mailto/anchor/relative). */
|
|
1106
|
-
function mdSafeUrl(u) {
|
|
1107
|
-
const s = String(u).trim();
|
|
1108
|
-
return /^(https?:\/\/|mailto:|#|\/|\.{1,2}\/)/i.test(s) ? s : null;
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
/** Inline markdown → HTML. Escapes first, then shields code spans from
|
|
1112
|
-
* further markup so backticked text is never mangled or unsafe. */
|
|
1113
|
-
function mdInline(t) {
|
|
1114
|
-
const codes = [];
|
|
1115
|
-
// Pull code spans out first (any run of backticks), then escape the rest.
|
|
1116
|
-
t = String(t).replace(/(`+)([\s\S]+?)\1/g, (_m, _ticks, c) => {
|
|
1117
|
-
codes.push(c.replace(/^ | $/g, ""));
|
|
1118
|
-
return "\u0000C" + (codes.length - 1) + "\u0000";
|
|
1119
|
-
});
|
|
1120
|
-
let s = esc(t);
|
|
1121
|
-
// [text](url "title") — title optional and ignored.
|
|
1122
|
-
s = s.replace(/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+"[^)]*")?\s*\)/g, (m, text, url) => {
|
|
1123
|
-
const safe = mdSafeUrl(url);
|
|
1124
|
-
return safe ? '<a href="' + safe + '" target="_blank" rel="noopener noreferrer">' + text + "</a>" : m;
|
|
1125
|
-
});
|
|
1126
|
-
s = s
|
|
1127
|
-
.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>")
|
|
1128
|
-
.replace(/\*\*([^*]+?)\*\*/g, "<strong>$1</strong>")
|
|
1129
|
-
.replace(/(^|[^_\w])__([^_]+?)__(?![\w])/g, "$1<strong>$2</strong>")
|
|
1130
|
-
.replace(/(^|[^*\w])\*([^*\s][^*]*?)\*/g, "$1<em>$2</em>")
|
|
1131
|
-
.replace(/(^|[^_\w])_([^_\s][^_]*?)_(?![\w])/g, "$1<em>$2</em>")
|
|
1132
|
-
.replace(/~~([^~]+?)~~/g, "<del>$1</del>");
|
|
1133
|
-
// Autolink bare URLs that markdown didn't already wrap.
|
|
1134
|
-
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+[^\s<).,;:!?'"])/g,
|
|
1135
|
-
(_m, pre, url) => pre + '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + "</a>");
|
|
1136
|
-
return s.replace(/\u0000C(\d+)\u0000/g, (_m, i) => "<code>" + esc(codes[+i]) + "</code>");
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
function mdCodeBlock(lang, code) {
|
|
1140
|
-
return '<div class="codeblock"><div class="cbhead"><span class="cblang">' + esc(lang || "") +
|
|
1141
|
-
'</span><button class="cbcopy" type="button">Copy</button></div>' +
|
|
1142
|
-
"<pre><code>" + esc(code) + "</code></pre></div>";
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
function mdTableSep(s) { return /^[\s|:-]+$/.test(s) && /-/.test(s) && /\|/.test(s); }
|
|
1146
|
-
function mdSplitRow(s) { return s.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()); }
|
|
1147
|
-
function mdBlockStart(s) {
|
|
1148
|
-
return /^\s*(`{3,}|~{3,})/.test(s) || /^(#{1,6})\s/.test(s) || /^\s*>/.test(s) ||
|
|
1149
|
-
/^\s*[-*+]\s+/.test(s) || /^\s*\d+[.)]\s+/.test(s) || /^\s*([-*_])\s*(\1\s*){2,}$/.test(s);
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
/** Safe markdown renderer: code fences (w/ copy), headings, lists, task
|
|
1153
|
-
* lists, tables, blockquotes, rules, links, emphasis, inline code. */
|
|
1154
|
-
function mdToHtml(src) {
|
|
1155
|
-
const lines = String(src).replace(/\r\n?/g, "\n").split("\n");
|
|
1156
|
-
let html = "", i = 0, list = null;
|
|
1157
|
-
const closeList = () => { if (list) { html += "</" + list + ">"; list = null; } };
|
|
1158
|
-
|
|
1159
|
-
while (i < lines.length) {
|
|
1160
|
-
const ln = lines[i];
|
|
1161
|
-
|
|
1162
|
-
// fenced code block
|
|
1163
|
-
const fence = ln.match(/^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/);
|
|
1164
|
-
if (fence) {
|
|
1165
|
-
closeList();
|
|
1166
|
-
const tick = fence[1][0], lang = fence[2] || "", buf = [];
|
|
1167
|
-
const closeRe = new RegExp("^\\s*" + (tick === "`" ? "`{3,}" : "~{3,}") + "\\s*$");
|
|
1168
|
-
i++;
|
|
1169
|
-
while (i < lines.length && !closeRe.test(lines[i])) { buf.push(lines[i]); i++; }
|
|
1170
|
-
i++; // consume closing fence (or run off the end mid-stream)
|
|
1171
|
-
html += mdCodeBlock(lang, buf.join("\n"));
|
|
1172
|
-
continue;
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
// GFM table: a row with pipes followed by a separator row
|
|
1176
|
-
if (ln.includes("|") && i + 1 < lines.length && mdTableSep(lines[i + 1])) {
|
|
1177
|
-
closeList();
|
|
1178
|
-
const header = mdSplitRow(ln);
|
|
1179
|
-
const aligns = mdSplitRow(lines[i + 1]).map((c) => {
|
|
1180
|
-
const l = c.startsWith(":"), r = c.endsWith(":");
|
|
1181
|
-
return l && r ? "center" : r ? "right" : l ? "left" : "";
|
|
1182
|
-
});
|
|
1183
|
-
i += 2;
|
|
1184
|
-
const rows = [];
|
|
1185
|
-
while (i < lines.length && lines[i].includes("|") && lines[i].trim() !== "") { rows.push(mdSplitRow(lines[i])); i++; }
|
|
1186
|
-
const al = (k) => (aligns[k] ? ' style="text-align:' + aligns[k] + '"' : "");
|
|
1187
|
-
let tbl = '<div class="md-tablewrap"><table class="md-table"><thead><tr>';
|
|
1188
|
-
header.forEach((c, k) => { tbl += "<th" + al(k) + ">" + mdInline(c) + "</th>"; });
|
|
1189
|
-
tbl += "</tr></thead><tbody>";
|
|
1190
|
-
rows.forEach((r) => {
|
|
1191
|
-
tbl += "<tr>";
|
|
1192
|
-
for (let k = 0; k < header.length; k++) tbl += "<td" + al(k) + ">" + mdInline(r[k] || "") + "</td>";
|
|
1193
|
-
tbl += "</tr>";
|
|
1194
|
-
});
|
|
1195
|
-
html += tbl + "</tbody></table></div>";
|
|
1196
|
-
continue;
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
// heading
|
|
1200
|
-
const h = ln.match(/^(#{1,6})\s+(.*)$/);
|
|
1201
|
-
if (h) { closeList(); const n = h[1].length; html += "<h" + n + ">" + mdInline(h[2].replace(/\s+#+\s*$/, "")) + "</h" + n + ">"; i++; continue; }
|
|
1202
|
-
|
|
1203
|
-
// horizontal rule
|
|
1204
|
-
if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(ln)) { closeList(); html += "<hr>"; i++; continue; }
|
|
1205
|
-
|
|
1206
|
-
// blockquote (consecutive '>' lines, rendered recursively)
|
|
1207
|
-
if (/^\s*>/.test(ln)) {
|
|
1208
|
-
closeList();
|
|
1209
|
-
const buf = [];
|
|
1210
|
-
while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, "")); i++; }
|
|
1211
|
-
html += "<blockquote>" + mdToHtml(buf.join("\n")) + "</blockquote>";
|
|
1212
|
-
continue;
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
// task list / unordered / ordered list items
|
|
1216
|
-
const task = ln.match(/^\s*[-*+]\s+\[([ xX])\]\s+(.*)$/);
|
|
1217
|
-
const ul = ln.match(/^\s*[-*+]\s+(.*)$/);
|
|
1218
|
-
const ol = ln.match(/^\s*\d+[.)]\s+(.*)$/);
|
|
1219
|
-
if (task) {
|
|
1220
|
-
if (list !== "ul") { closeList(); html += "<ul>"; list = "ul"; }
|
|
1221
|
-
html += '<li class="task"><input type="checkbox" disabled' + (task[1].toLowerCase() === "x" ? " checked" : "") + "> " + mdInline(task[2]) + "</li>";
|
|
1222
|
-
i++; continue;
|
|
1223
|
-
}
|
|
1224
|
-
if (ul) { if (list !== "ul") { closeList(); html += "<ul>"; list = "ul"; } html += "<li>" + mdInline(ul[1]) + "</li>"; i++; continue; }
|
|
1225
|
-
if (ol) { if (list !== "ol") { closeList(); html += "<ol>"; list = "ol"; } html += "<li>" + mdInline(ol[1]) + "</li>"; i++; continue; }
|
|
1226
|
-
|
|
1227
|
-
if (ln.trim() === "") { closeList(); i++; continue; }
|
|
1228
|
-
|
|
1229
|
-
// paragraph: merge following plain lines (soft breaks → <br>)
|
|
1230
|
-
closeList();
|
|
1231
|
-
const para = [ln];
|
|
1232
|
-
i++;
|
|
1233
|
-
while (i < lines.length && lines[i].trim() !== "" && !mdBlockStart(lines[i])) { para.push(lines[i]); i++; }
|
|
1234
|
-
html += "<p>" + para.map(mdInline).join("<br>") + "</p>";
|
|
1235
|
-
}
|
|
1236
|
-
closeList();
|
|
1237
|
-
return html;
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
/** Render a step indicator into a container. names[], current index. */
|
|
1241
|
-
function renderSteps(container, names, cur) {
|
|
1242
|
-
container.innerHTML = names.map((n, i) => {
|
|
1243
|
-
const cls = i === cur ? "cur" : (i < cur ? "done" : "");
|
|
1244
|
-
const arrow = i < names.length - 1 ? '<span class="arrow">→</span>' : "";
|
|
1245
|
-
return '<span class="s ' + cls + '"><span class="n">' + (i + 1) + "</span>" + esc(n) + "</span>" + arrow;
|
|
1246
|
-
}).join("");
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
/* ════════════════════════════════════════════════════════════════════
|
|
1250
|
-
HUGGING FACE — shared token settings + push-to-hub flow
|
|
1251
|
-
════════════════════════════════════════════════════════════════════ */
|
|
1252
|
-
|
|
1253
|
-
/** Reflect saved-token state on the nav gear. */
|
|
1254
|
-
async function refreshHfGear() {
|
|
1255
|
-
try {
|
|
1256
|
-
const d = await api("/api/settings/hf-token");
|
|
1257
|
-
const saved = !!(d && d.ok && d.hasToken);
|
|
1258
|
-
$("nav-hf").classList.toggle("saved", saved);
|
|
1259
|
-
$("nav-hf").title = saved ? "Hugging Face token saved · click to replace" : "Hugging Face token settings";
|
|
1260
|
-
return saved;
|
|
1261
|
-
} catch { return false; }
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
/* — the nav settings modal — */
|
|
1265
|
-
function openHfSettings() {
|
|
1266
|
-
const ov = $("hf-overlay"); ov.classList.add("open");
|
|
1267
|
-
$("hf-token-input").value = ""; $("hf-settings-msg").innerHTML = "";
|
|
1268
|
-
$("hf-state").textContent = "Checking for a saved token…";
|
|
1269
|
-
api("/api/settings/hf-token").then((d) => {
|
|
1270
|
-
const saved = !!(d && d.ok && d.hasToken);
|
|
1271
|
-
$("hf-state").innerHTML = saved
|
|
1272
|
-
? 'A write token is <strong>saved</strong>. Enter a new one below to replace it.'
|
|
1273
|
-
: 'No token saved yet. Add a write token to push models and datasets to the Hub.';
|
|
1274
|
-
}).catch(() => { $("hf-state").textContent = "Could not reach the server."; });
|
|
1275
|
-
setTimeout(() => $("hf-token-input").focus(), 50);
|
|
1276
|
-
}
|
|
1277
|
-
function closeHfSettings() { $("hf-overlay").classList.remove("open"); }
|
|
1278
|
-
|
|
1279
|
-
async function saveHfToken() {
|
|
1280
|
-
const token = $("hf-token-input").value.trim();
|
|
1281
|
-
const msg = $("hf-settings-msg");
|
|
1282
|
-
if (!token) { msg.innerHTML = '<div class="flash err">Enter a token first.</div>'; return; }
|
|
1283
|
-
const btn = $("hf-save"); btn.disabled = true;
|
|
1284
|
-
const d = await api("/api/settings/hf-token", { method: "POST", body: { token } }).catch(() => ({ ok: false, error: "request failed" }));
|
|
1285
|
-
btn.disabled = false;
|
|
1286
|
-
if (!d.ok) { msg.innerHTML = '<div class="flash err">' + esc(d.error || "could not save token") + "</div>"; return; }
|
|
1287
|
-
msg.innerHTML = '<div class="flash ok">Token saved to <code>~/.mlx-bun/hf.json</code>.</div>';
|
|
1288
|
-
$("hf-token-input").value = "";
|
|
1289
|
-
refreshHfGear();
|
|
1290
|
-
toast("Hugging Face token saved", "ok");
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
/**
|
|
1294
|
-
* Shared push-to-hub flow, rendered inline inside a done-step card.
|
|
1295
|
-
* pushToHub(panelEl, { kind, job_id?, source_path? })
|
|
1296
|
-
* kind: "quantize" | "finetune" | "dataset". Posts to /api/{kind}/push.
|
|
1297
|
-
* Walks: ensure token → repo_id + private → POST → success/error flash.
|
|
1298
|
-
*/
|
|
1299
|
-
async function pushToHub(panel, { kind, job_id, source_path }) {
|
|
1300
|
-
panel.innerHTML = '<div class="pushpanel"><div class="flash"><span class="shimmer">checking Hugging Face token…</span></div></div>';
|
|
1301
|
-
const wrap = panel.firstChild;
|
|
1302
|
-
let hasToken = false;
|
|
1303
|
-
try { const s = await api("/api/settings/hf-token"); hasToken = !!(s && s.ok && s.hasToken); } catch {}
|
|
1304
|
-
|
|
1305
|
-
const renderForm = () => {
|
|
1306
|
-
wrap.innerHTML =
|
|
1307
|
-
(!hasToken
|
|
1308
|
-
? '<div class="field"><label>Hugging Face write token</label>' +
|
|
1309
|
-
'<input type="password" class="p-token" placeholder="hf_…" autocomplete="off" spellcheck="false">' +
|
|
1310
|
-
'<div class="hint">A <strong>write</strong> token from huggingface.co/settings/tokens. Stored locally at <code>~/.mlx-bun/hf.json</code>.</div></div>'
|
|
1311
|
-
: "") +
|
|
1312
|
-
'<div class="field"><label>Repository id</label>' +
|
|
1313
|
-
'<input type="text" class="p-repo" placeholder="you/my-' + esc(kind === "dataset" ? "dataset" : "model") + '" autocomplete="off"></div>' +
|
|
1314
|
-
'<div class="field"><label class="chk"><input type="checkbox" class="p-priv">Private repository</label></div>' +
|
|
1315
|
-
'<div class="p-msg"></div>' +
|
|
1316
|
-
'<div class="btnrow" style="margin-top:6px"><button class="btn primary sm p-go">' +
|
|
1317
|
-
(hasToken ? "Push" : "Save token & push") + "</button></div>";
|
|
1318
|
-
wrap.querySelector(".p-go").onclick = go;
|
|
1319
|
-
const focusEl = wrap.querySelector(hasToken ? ".p-repo" : ".p-token");
|
|
1320
|
-
if (focusEl) focusEl.focus();
|
|
1321
|
-
};
|
|
1322
|
-
|
|
1323
|
-
async function go() {
|
|
1324
|
-
const msg = wrap.querySelector(".p-msg");
|
|
1325
|
-
const repo = (wrap.querySelector(".p-repo").value || "").trim();
|
|
1326
|
-
const priv = wrap.querySelector(".p-priv").checked;
|
|
1327
|
-
if (!repo) { msg.innerHTML = '<div class="flash err">Enter a repository id (e.g. <code>you/my-model</code>).</div>'; return; }
|
|
1328
|
-
const btn = wrap.querySelector(".p-go"); btn.disabled = true;
|
|
1329
|
-
|
|
1330
|
-
if (!hasToken) {
|
|
1331
|
-
const token = (wrap.querySelector(".p-token").value || "").trim();
|
|
1332
|
-
if (!token) { msg.innerHTML = '<div class="flash err">Enter a write token first.</div>'; btn.disabled = false; return; }
|
|
1333
|
-
const sv = await api("/api/settings/hf-token", { method: "POST", body: { token } }).catch(() => ({ ok: false, error: "request failed" }));
|
|
1334
|
-
if (!sv.ok) { msg.innerHTML = '<div class="flash err">' + esc(sv.error || "could not save token") + "</div>"; btn.disabled = false; return; }
|
|
1335
|
-
hasToken = true; refreshHfGear();
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
msg.innerHTML = '<div class="flash"><span class="shimmer">pushing to ' + esc(repo) + "…</span></div>";
|
|
1339
|
-
const body = { repo_id: repo, private: priv };
|
|
1340
|
-
if (job_id != null) body.job_id = job_id;
|
|
1341
|
-
if (source_path != null) body.source_path = source_path;
|
|
1342
|
-
const d = await api("/api/" + kind + "/push", { method: "POST", body }).catch(() => ({ ok: false, error: "request failed" }));
|
|
1343
|
-
btn.disabled = false;
|
|
1344
|
-
if (!d.ok) { msg.innerHTML = '<div class="flash err">' + esc(d.error || "push failed") + "</div>"; return; }
|
|
1345
|
-
const url = d.url || ("https://huggingface.co/" + repo);
|
|
1346
|
-
wrap.innerHTML = '<div class="flash ok">Pushed to <a href="' + esc(url) + '" target="_blank" rel="noopener" style="text-decoration:underline">' + esc(url) + "</a></div>";
|
|
1347
|
-
toast("Pushed to Hugging Face", "ok");
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
renderForm();
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
/* wire the nav gear + modal once at boot */
|
|
1354
|
-
(function initHfSettings() {
|
|
1355
|
-
$("nav-hf").onclick = openHfSettings;
|
|
1356
|
-
$("hf-close").onclick = closeHfSettings;
|
|
1357
|
-
$("hf-save").onclick = saveHfToken;
|
|
1358
|
-
$("hf-overlay").addEventListener("click", (e) => { if (e.target === $("hf-overlay")) closeHfSettings(); });
|
|
1359
|
-
$("hf-token-input").addEventListener("keydown", (e) => { if (e.key === "Enter") saveHfToken(); });
|
|
1360
|
-
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("hf-overlay").classList.contains("open")) closeHfSettings(); });
|
|
1361
|
-
refreshHfGear();
|
|
1362
|
-
})();
|
|
1363
|
-
|
|
1364
|
-
/* ════════════════════════════════════════════════════════════════════
|
|
1365
|
-
ROUTER — toggles section[data-route]; lazily inits each controller.
|
|
1366
|
-
════════════════════════════════════════════════════════════════════ */
|
|
1367
|
-
const ROUTES = ["chat", "quantize", "finetune", "dataset", "status", "routes"];
|
|
1368
|
-
const inited = {};
|
|
1369
|
-
const controllers = {}; // name -> { init, enter, leave }
|
|
1370
|
-
|
|
1371
|
-
function currentRoute() {
|
|
1372
|
-
const h = (location.hash || "").replace(/^#\/?/, "").split("?")[0];
|
|
1373
|
-
return ROUTES.includes(h) ? h : "chat";
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
function router() {
|
|
1377
|
-
const route = currentRoute();
|
|
1378
|
-
document.querySelectorAll("section[data-route]").forEach((s) => {
|
|
1379
|
-
const on = s.dataset.route === route;
|
|
1380
|
-
if (on && !s.classList.contains("active")) {
|
|
1381
|
-
s.classList.add("active");
|
|
1382
|
-
const c = controllers[route];
|
|
1383
|
-
if (c) { if (!inited[route]) { inited[route] = true; c.init && c.init(); } c.enter && c.enter(); }
|
|
1384
|
-
} else if (!on && s.classList.contains("active")) {
|
|
1385
|
-
s.classList.remove("active");
|
|
1386
|
-
const c = controllers[s.dataset.route];
|
|
1387
|
-
if (c && c.leave) c.leave();
|
|
1388
|
-
}
|
|
1389
|
-
});
|
|
1390
|
-
document.querySelectorAll("nav .tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === route));
|
|
1391
|
-
// dim the ambient bloom slightly in the chat workspace so text reads cleanly
|
|
1392
|
-
$("bloom").style.opacity = route === "chat" ? "0.55" : "1";
|
|
1393
|
-
}
|
|
1394
|
-
window.addEventListener("hashchange", router);
|
|
1395
|
-
|
|
1396
|
-
/* ════════════════════════════════════════════════════════════════════
|
|
1397
|
-
GLOBAL CONNECTION PILL + MODEL ID (polled lightly, always on)
|
|
1398
|
-
════════════════════════════════════════════════════════════════════ */
|
|
1399
|
-
let activeModelId = null;
|
|
1400
|
-
function setConn(state, text) {
|
|
1401
|
-
const p = $("nav-conn");
|
|
1402
|
-
p.className = "pill " + (state || "");
|
|
1403
|
-
$("nav-conn-text").textContent = text;
|
|
1404
|
-
}
|
|
1405
|
-
let defaultHelloSub = null;
|
|
1406
|
-
async function pollIdentity() {
|
|
1407
|
-
try {
|
|
1408
|
-
const [models, dl] = await Promise.all([
|
|
1409
|
-
fetch("/v1/models").then((r) => r.json()),
|
|
1410
|
-
fetch("/downloads").then((r) => r.json()).catch(() => ({ downloads: [] })),
|
|
1411
|
-
]);
|
|
1412
|
-
activeModelId = models.data && models.data[0] ? models.data[0].id : null;
|
|
1413
|
-
$("nav-model").textContent = activeModelId || "no model";
|
|
1414
|
-
setConn("ok", "live · localhost");
|
|
1415
|
-
updateDownloadIndicator((dl && dl.downloads) || []);
|
|
1416
|
-
} catch {
|
|
1417
|
-
$("nav-model").textContent = "server unreachable";
|
|
1418
|
-
setConn("bad", "unreachable — retrying");
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
/** Surface a background model download — the "bigger model arriving while you
|
|
1423
|
-
* chat on the starter" case — as a live nav pill plus a download-aware
|
|
1424
|
-
* greeting in the empty chat state. Hidden when nothing else is downloading. */
|
|
1425
|
-
function updateDownloadIndicator(downloads) {
|
|
1426
|
-
const sub = $("chat-hello-sub");
|
|
1427
|
-
if (sub && defaultHelloSub === null) defaultHelloSub = sub.textContent;
|
|
1428
|
-
const incoming = downloads.find((d) => d.state === "active" && d.repoId !== activeModelId);
|
|
1429
|
-
const pill = $("nav-download");
|
|
1430
|
-
if (incoming) {
|
|
1431
|
-
const pct = incoming.totalBytes ? Math.floor((incoming.receivedBytes / incoming.totalBytes) * 100) : 0;
|
|
1432
|
-
const name = incoming.repoId.split("/").pop();
|
|
1433
|
-
$("nav-download-text").textContent = "↓ " + name + " · " + pct + "%";
|
|
1434
|
-
pill.style.display = "";
|
|
1435
|
-
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.";
|
|
1436
|
-
} else {
|
|
1437
|
-
pill.style.display = "none";
|
|
1438
|
-
if (sub && defaultHelloSub !== null) sub.textContent = defaultHelloSub;
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
/* ════════════════════════════════════════════════════════════════════
|
|
1443
|
-
CHAT CONTROLLER — full pi agent over WebSocket /ws/chat
|
|
1444
|
-
════════════════════════════════════════════════════════════════════ */
|
|
1445
|
-
controllers.chat = (() => {
|
|
1446
|
-
let ws = null, connected = false, reconnectTimer = null, manualClose = false;
|
|
1447
|
-
let curAssistant = null; // { bubble, cursor, meta, tools:Map, text, t0, tFirst, tokens }
|
|
1448
|
-
let turnActive = false;
|
|
1449
|
-
// This connection's active session path (from `sessions` frames). Each
|
|
1450
|
-
// connect gets a fresh server session; on a transient reconnect we re-open
|
|
1451
|
-
// our own session so a blip doesn't strand us on a blank one.
|
|
1452
|
-
let currentSessionPath = null, pendingResumePath = null;
|
|
1453
|
-
const thread = () => $("chat-thread");
|
|
1454
|
-
|
|
1455
|
-
function wsUrl() {
|
|
1456
|
-
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
1457
|
-
return proto + "//" + location.host + "/ws/chat";
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
function connect() {
|
|
1461
|
-
manualClose = false;
|
|
1462
|
-
// Remember our session across this (re)connect: if we were already in a
|
|
1463
|
-
// session, re-open it once the new connection is ready (the server starts
|
|
1464
|
-
// each connection on a fresh session by default).
|
|
1465
|
-
pendingResumePath = currentSessionPath;
|
|
1466
|
-
try { ws = new WebSocket(wsUrl()); } catch { scheduleReconnect(); return; }
|
|
1467
|
-
ws.onopen = () => { connected = true; setChatStatus("connected"); };
|
|
1468
|
-
ws.onclose = () => { connected = false; setChatStatus("disconnected"); if (!manualClose) scheduleReconnect(); };
|
|
1469
|
-
ws.onerror = () => { setChatStatus("error"); };
|
|
1470
|
-
ws.onmessage = (ev) => { let m; try { m = JSON.parse(ev.data); } catch { return; } handle(m); };
|
|
1471
|
-
}
|
|
1472
|
-
function scheduleReconnect() {
|
|
1473
|
-
clearTimeout(reconnectTimer);
|
|
1474
|
-
reconnectTimer = setTimeout(() => { if (currentRoute() === "chat") connect(); }, 2200);
|
|
1475
|
-
}
|
|
1476
|
-
function send(obj) { if (ws && ws.readyState === 1) { ws.send(JSON.stringify(obj)); return true; } return false; }
|
|
1477
|
-
|
|
1478
|
-
function setChatStatus(s) {
|
|
1479
|
-
const line = $("chat-status-line");
|
|
1480
|
-
if (s === "connected") line.textContent = "connected · type to send · Shift+Enter for newline";
|
|
1481
|
-
else if (s === "disconnected") line.textContent = "reconnecting to agent…";
|
|
1482
|
-
else if (s === "error") line.textContent = "connection error — retrying";
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
/* — message + tool DOM construction — */
|
|
1486
|
-
function addUserMsg(text, atts) {
|
|
1487
|
-
const m = el("div", "msg user", thread());
|
|
1488
|
-
el("div", "who", m).textContent = "you";
|
|
1489
|
-
const bubble = el("div", "bubble", m);
|
|
1490
|
-
if (atts && atts.length) {
|
|
1491
|
-
const wrap = el("div", "msg-atts", bubble);
|
|
1492
|
-
for (const a of atts) {
|
|
1493
|
-
if (a.kind === "image") {
|
|
1494
|
-
const im = el("img", "msg-att-img", wrap);
|
|
1495
|
-
im.src = "data:" + a.mimeType + ";base64," + a.data; im.alt = a.name;
|
|
1496
|
-
} else {
|
|
1497
|
-
el("span", "msg-att-file", wrap).textContent = "📎 " + a.name;
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
if (text) el("div", "msg-text", bubble).textContent = text;
|
|
1502
|
-
stick(true);
|
|
1503
|
-
}
|
|
1504
|
-
function startAssistant() {
|
|
1505
|
-
$("chat-hello").style.display = "none";
|
|
1506
|
-
const m = el("div", "msg assistant", thread());
|
|
1507
|
-
el("div", "who", m).textContent = "local agent";
|
|
1508
|
-
const bubble = el("div", "bubble", m);
|
|
1509
|
-
const thinkBox = el("details", "thinkbox", bubble);
|
|
1510
|
-
thinkBox.open = false; // collapsed by default; the reasoning stays tucked behind "Thinking" — click to expand
|
|
1511
|
-
el("summary", "", thinkBox).textContent = "Thinking";
|
|
1512
|
-
const thinkBody = el("div", "thinkbody", thinkBox);
|
|
1513
|
-
thinkBox.style.display = "none";
|
|
1514
|
-
const textNode = el("span", "atext", bubble);
|
|
1515
|
-
const cursor = el("span", "cursor", bubble);
|
|
1516
|
-
const meta = el("div", "meta", m);
|
|
1517
|
-
curAssistant = { m, bubble, thinkBox, thinkBody, textNode, cursor, meta, tools: new Map(), text: "", thinking: "", t0: performance.now(), tFirst: 0, tokens: 0 };
|
|
1518
|
-
stick(true);
|
|
1519
|
-
}
|
|
1520
|
-
function ensureAssistant() { if (!curAssistant) startAssistant(); }
|
|
1521
|
-
|
|
1522
|
-
function appendDelta(delta) {
|
|
1523
|
-
ensureAssistant();
|
|
1524
|
-
const a = curAssistant;
|
|
1525
|
-
if (!a.tFirst) a.tFirst = performance.now();
|
|
1526
|
-
a.tokens++;
|
|
1527
|
-
a.text += delta;
|
|
1528
|
-
a.textNode.innerHTML = mdToHtml(a.text);
|
|
1529
|
-
updateTps();
|
|
1530
|
-
if (atBottom()) stick();
|
|
1531
|
-
}
|
|
1532
|
-
function appendThinkingDelta(delta) {
|
|
1533
|
-
ensureAssistant();
|
|
1534
|
-
const a = curAssistant;
|
|
1535
|
-
if (!a.tFirst) a.tFirst = performance.now();
|
|
1536
|
-
a.tokens++;
|
|
1537
|
-
a.thinking += delta;
|
|
1538
|
-
a.thinkBody.textContent = a.thinking;
|
|
1539
|
-
a.thinkBox.style.display = a.thinking.trim() ? "" : "none";
|
|
1540
|
-
updateTps();
|
|
1541
|
-
if (atBottom()) stick();
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
/* tool card lifecycle */
|
|
1545
|
-
function toolCard(callId, tool, args) {
|
|
1546
|
-
ensureAssistant();
|
|
1547
|
-
const a = curAssistant;
|
|
1548
|
-
let t = a.tools.get(callId);
|
|
1549
|
-
if (t) return t;
|
|
1550
|
-
const wrap = el("div", "tool running open", a.bubble);
|
|
1551
|
-
wrap.innerHTML =
|
|
1552
|
-
'<div class="thead"><div class="ticon">⚙</div>' +
|
|
1553
|
-
'<span class="tname">' + esc(tool || "tool") + '</span>' +
|
|
1554
|
-
'<span class="targs">' + esc(argSummary(args)) + '</span>' +
|
|
1555
|
-
'<span class="tstat"><span class="sdot"></span><span class="slabel">running</span></span>' +
|
|
1556
|
-
'<span class="caret">›</span></div>' +
|
|
1557
|
-
'<div class="tbody">' +
|
|
1558
|
-
'<div class="blk"><div class="blbl">arguments</div><pre class="aargs">' + esc(prettyArgs(args)) + '</pre></div>' +
|
|
1559
|
-
'<div class="blk tresult" style="display:none"><div class="blbl">result</div><pre class="ares"></pre></div>' +
|
|
1560
|
-
'</div>';
|
|
1561
|
-
wrap.querySelector(".thead").addEventListener("click", () => wrap.classList.toggle("open"));
|
|
1562
|
-
t = { wrap, res: wrap.querySelector(".ares"), resBlk: wrap.querySelector(".tresult"), label: wrap.querySelector(".slabel"), chunks: "" };
|
|
1563
|
-
a.tools.set(callId, t);
|
|
1564
|
-
stick();
|
|
1565
|
-
return t;
|
|
1566
|
-
}
|
|
1567
|
-
function toolUpdate(callId, chunk) {
|
|
1568
|
-
const t = curAssistant && curAssistant.tools.get(callId);
|
|
1569
|
-
if (!t) return;
|
|
1570
|
-
t.chunks += chunk;
|
|
1571
|
-
t.resBlk.style.display = "";
|
|
1572
|
-
t.res.textContent = t.chunks.slice(-8000);
|
|
1573
|
-
if (atBottom()) stick();
|
|
1574
|
-
}
|
|
1575
|
-
function toolEnd(callId, ok, result) {
|
|
1576
|
-
const t = curAssistant && curAssistant.tools.get(callId);
|
|
1577
|
-
if (!t) return;
|
|
1578
|
-
t.wrap.classList.remove("running");
|
|
1579
|
-
t.wrap.classList.add(ok ? "ok" : "fail");
|
|
1580
|
-
t.label.textContent = ok ? "done" : "failed";
|
|
1581
|
-
const body = result != null ? (typeof result === "string" ? result : JSON.stringify(result, null, 2)) : t.chunks;
|
|
1582
|
-
if (body) { t.resBlk.style.display = ""; t.res.textContent = String(body).slice(-12000); }
|
|
1583
|
-
if (!ok) t.wrap.classList.add("open");
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
/* approval dialog */
|
|
1587
|
-
function approvalCard(callId, tool, args) {
|
|
1588
|
-
ensureAssistant();
|
|
1589
|
-
const a = curAssistant;
|
|
1590
|
-
const wrap = el("div", "approval", a.bubble);
|
|
1591
|
-
const isBash = /bash|shell|exec|run/i.test(tool || "");
|
|
1592
|
-
const isEdit = /edit|write|patch|create/i.test(tool || "");
|
|
1593
|
-
let preview;
|
|
1594
|
-
if (isBash) {
|
|
1595
|
-
preview = '<pre>' + esc(args && (args.command || args.cmd || args.script) || prettyArgs(args)) + '</pre>';
|
|
1596
|
-
} else if (isEdit) {
|
|
1597
|
-
preview = '<pre>' + diffView(args) + '</pre>';
|
|
1598
|
-
} else {
|
|
1599
|
-
preview = '<pre>' + esc(prettyArgs(args)) + '</pre>';
|
|
1600
|
-
}
|
|
1601
|
-
wrap.innerHTML =
|
|
1602
|
-
'<div class="ahead"><div class="ai">⚠</div><div class="at">Approval required</div>' +
|
|
1603
|
-
'<div class="as">' + esc(tool || "tool") + '</div></div>' +
|
|
1604
|
-
'<div class="abody"><div class="blbl" style="margin-bottom:7px">' +
|
|
1605
|
-
(isBash ? "command" : isEdit ? "proposed change" : "arguments") + '</div>' + preview + '</div>' +
|
|
1606
|
-
'<div class="actions">' +
|
|
1607
|
-
'<button class="btn ghost sm a-deny">Deny</button>' +
|
|
1608
|
-
'<button class="btn primary sm a-allow">Allow</button></div>';
|
|
1609
|
-
const resolve = (decision) => {
|
|
1610
|
-
send({ type: "approval", callId, decision });
|
|
1611
|
-
wrap.querySelector(".actions").remove();
|
|
1612
|
-
const r = el("div", "resolved " + (decision === "allow" ? "allow" : "deny"), wrap);
|
|
1613
|
-
r.textContent = decision === "allow" ? "✓ allowed" : "✕ denied";
|
|
1614
|
-
};
|
|
1615
|
-
wrap.querySelector(".a-allow").onclick = () => resolve("allow");
|
|
1616
|
-
wrap.querySelector(".a-deny").onclick = () => resolve("deny");
|
|
1617
|
-
stick(true);
|
|
1618
|
-
}
|
|
1619
|
-
function diffView(args) {
|
|
1620
|
-
// Best-effort diff-ish view for edit/write tools.
|
|
1621
|
-
if (!args) return "";
|
|
1622
|
-
if (args.old_string != null || args.new_string != null) {
|
|
1623
|
-
const o = String(args.old_string ?? "").split("\n").map((l) => '<span class="diffdel">- ' + esc(l) + "</span>").join("\n");
|
|
1624
|
-
const n = String(args.new_string ?? "").split("\n").map((l) => '<span class="diffadd">+ ' + esc(l) + "</span>").join("\n");
|
|
1625
|
-
return (args.file_path ? esc(args.file_path) + "\n\n" : "") + o + (o && n ? "\n" : "") + n;
|
|
1626
|
-
}
|
|
1627
|
-
if (args.content != null) {
|
|
1628
|
-
return (args.file_path ? esc(args.file_path) + "\n\n" : "") +
|
|
1629
|
-
String(args.content).split("\n").map((l) => '<span class="diffadd">+ ' + esc(l) + "</span>").join("\n");
|
|
1630
|
-
}
|
|
1631
|
-
return esc(prettyArgs(args));
|
|
1632
|
-
}
|
|
1633
|
-
function argSummary(args) {
|
|
1634
|
-
if (!args) return "";
|
|
1635
|
-
if (typeof args === "string") return args.slice(0, 120);
|
|
1636
|
-
if (args.command || args.cmd) return String(args.command || args.cmd).slice(0, 120);
|
|
1637
|
-
if (args.file_path || args.path) return String(args.file_path || args.path).slice(0, 120);
|
|
1638
|
-
if (args.query || args.url || args.location) return String(args.query || args.url || args.location).slice(0, 120);
|
|
1639
|
-
const s = JSON.stringify(args); return s.length > 120 ? s.slice(0, 117) + "…" : s;
|
|
1640
|
-
}
|
|
1641
|
-
function prettyArgs(args) {
|
|
1642
|
-
if (args == null) return "";
|
|
1643
|
-
if (typeof args === "string") return args;
|
|
1644
|
-
try { return JSON.stringify(args, null, 2); } catch { return String(args); }
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
function showAgentError(message) {
|
|
1648
|
-
const m = el("div", "msg assistant", thread());
|
|
1649
|
-
el("div", "who", m).textContent = "local agent";
|
|
1650
|
-
const bubble = el("div", "bubble", m);
|
|
1651
|
-
bubble.innerHTML = '<strong style="color:var(--red)">Agent error</strong><br>' + esc(message || "unknown error");
|
|
1652
|
-
stick(true);
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
function endTurn() {
|
|
1656
|
-
turnActive = false;
|
|
1657
|
-
if (curAssistant) {
|
|
1658
|
-
curAssistant.cursor.remove();
|
|
1659
|
-
if (!curAssistant.text && !curAssistant.thinking && curAssistant.tools.size === 0) curAssistant.m.remove();
|
|
1660
|
-
}
|
|
1661
|
-
curAssistant = null;
|
|
1662
|
-
$("chat-send").disabled = false;
|
|
1663
|
-
$("chat-stop").style.display = "none";
|
|
1664
|
-
renderQueue({});
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
function updateTps() {
|
|
1668
|
-
const a = curAssistant; if (!a) return;
|
|
1669
|
-
const secs = (performance.now() - (a.tFirst || a.t0)) / 1000;
|
|
1670
|
-
if (a.tokens > 1 && secs > 0) $("chat-tps").innerHTML = "<b style='color:var(--green)'>" + (a.tokens / secs).toFixed(1) + " tok/s</b>";
|
|
1671
|
-
}
|
|
1672
|
-
function finalizeMeta() {
|
|
1673
|
-
const a = curAssistant; if (!a) return;
|
|
1674
|
-
const secs = (performance.now() - (a.tFirst || a.t0)) / 1000;
|
|
1675
|
-
if (a.tokens > 1 && secs > 0)
|
|
1676
|
-
a.meta.innerHTML = "<b>" + (a.tokens / secs).toFixed(1) + " tok/s</b> · " + a.tokens +
|
|
1677
|
-
" tokens · first token " + Math.round((a.tFirst - a.t0)) + "ms";
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
function renderQueue(q) {
|
|
1681
|
-
const bar = $("chat-queue");
|
|
1682
|
-
const parts = [];
|
|
1683
|
-
if (q.steering) parts.push('<span class="qtag">steering: <b>' + esc(String(q.steering).slice(0, 60)) + "</b></span>");
|
|
1684
|
-
if (q.followUp) parts.push('<span class="qtag">queued: <b>' + esc(String(q.followUp).slice(0, 60)) + "</b></span>");
|
|
1685
|
-
bar.innerHTML = parts.join("");
|
|
1686
|
-
bar.style.display = parts.length ? "flex" : "none";
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
/* — context-window indicator — */
|
|
1690
|
-
function fmtTok(n) {
|
|
1691
|
-
if (n == null) return "—";
|
|
1692
|
-
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
|
1693
|
-
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k";
|
|
1694
|
-
return String(n);
|
|
1695
|
-
}
|
|
1696
|
-
function renderContext(m) {
|
|
1697
|
-
const node = $("chat-context");
|
|
1698
|
-
if (!node || !m.contextWindow) return;
|
|
1699
|
-
const pct = m.percent;
|
|
1700
|
-
const pctStr = pct == null ? "" : " (" + Math.round(pct) + "%)";
|
|
1701
|
-
node.textContent = "Context " + fmtTok(m.tokens) + " / " + fmtTok(m.contextWindow) + pctStr;
|
|
1702
|
-
node.style.color = pct == null ? "var(--dimmer)" : pct >= 85 ? "var(--orange)" : pct >= 70 ? "var(--yellow)" : "var(--dimmer)";
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
/* — recent-chats sidebar + transcript replay — */
|
|
1706
|
-
function relTime(ms) {
|
|
1707
|
-
if (!ms) return "";
|
|
1708
|
-
const s = (Date.now() - ms) / 1000;
|
|
1709
|
-
if (s < 60) return "just now";
|
|
1710
|
-
if (s < 3600) return Math.floor(s / 60) + "m ago";
|
|
1711
|
-
if (s < 86400) return Math.floor(s / 3600) + "h ago";
|
|
1712
|
-
if (s < 604800) return Math.floor(s / 86400) + "d ago";
|
|
1713
|
-
try { return new Date(ms).toLocaleDateString(); } catch { return ""; }
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
|
-
function newChat() {
|
|
1717
|
-
if (!send({ type: "new_session" })) { toast("Not connected to the agent yet.", "err"); return; }
|
|
1718
|
-
$("chat-box").focus();
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
function renderSessions(items, activePath) {
|
|
1722
|
-
const box = $("chat-sessions");
|
|
1723
|
-
if (!items || !items.length) { box.innerHTML = '<div class="sessempty">No saved chats yet. Your conversations are saved locally here.</div>'; return; }
|
|
1724
|
-
box.innerHTML = "";
|
|
1725
|
-
for (const s of items) {
|
|
1726
|
-
const row = el("div", "sess" + (s.path === activePath ? " active" : ""), box);
|
|
1727
|
-
row.innerHTML =
|
|
1728
|
-
'<div class="stitle">' + esc(s.title || "New chat") + '</div>' +
|
|
1729
|
-
'<div class="smeta"><span>' + esc(relTime(s.modified)) + '</span>' +
|
|
1730
|
-
(s.messageCount ? '<span>· ' + s.messageCount + ' msg' + (s.messageCount === 1 ? '' : 's') + '</span>' : '') +
|
|
1731
|
-
(s.forked ? '<span>· forked</span>' : '') + '</div>' +
|
|
1732
|
-
'<button class="sbtn sfork" title="New chat from here">⑂</button>' +
|
|
1733
|
-
'<button class="sbtn sdel" title="Delete chat">✕</button>';
|
|
1734
|
-
row.addEventListener("click", () => send({ type: "open_session", path: s.path }));
|
|
1735
|
-
row.querySelector(".sfork").addEventListener("click", (e) => { e.stopPropagation(); send({ type: "fork_session", path: s.path }); });
|
|
1736
|
-
row.querySelector(".sdel").addEventListener("click", (e) => { e.stopPropagation(); send({ type: "delete_session", path: s.path }); });
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
/** Render a finished assistant turn from replayed history (no streaming). */
|
|
1741
|
-
function renderAssistantStatic(item) {
|
|
1742
|
-
const m = el("div", "msg assistant", thread());
|
|
1743
|
-
el("div", "who", m).textContent = "local agent";
|
|
1744
|
-
const bubble = el("div", "bubble", m);
|
|
1745
|
-
if (item.thinking && item.thinking.trim()) {
|
|
1746
|
-
const tb = el("details", "thinkbox", bubble); tb.open = false;
|
|
1747
|
-
el("summary", "", tb).textContent = "Thinking";
|
|
1748
|
-
el("div", "thinkbody", tb).textContent = item.thinking;
|
|
1749
|
-
}
|
|
1750
|
-
if (item.text) { const tn = el("span", "atext", bubble); tn.innerHTML = mdToHtml(item.text); }
|
|
1751
|
-
for (const t of (item.tools || [])) {
|
|
1752
|
-
const wrap = el("div", "tool ok", bubble); // collapsed; click to expand
|
|
1753
|
-
wrap.innerHTML =
|
|
1754
|
-
'<div class="thead"><div class="ticon">⚙</div>' +
|
|
1755
|
-
'<span class="tname">' + esc(t.name || "tool") + '</span>' +
|
|
1756
|
-
'<span class="targs">' + esc(argSummary(t.args)) + '</span>' +
|
|
1757
|
-
'<span class="tstat"><span class="sdot"></span><span class="slabel">done</span></span>' +
|
|
1758
|
-
'<span class="caret">›</span></div>' +
|
|
1759
|
-
'<div class="tbody">' +
|
|
1760
|
-
'<div class="blk"><div class="blbl">arguments</div><pre class="aargs">' + esc(prettyArgs(t.args)) + '</pre></div>' +
|
|
1761
|
-
(t.result ? '<div class="blk tresult"><div class="blbl">result</div><pre class="ares">' + esc(String(t.result).slice(-12000)) + '</pre></div>' : '') +
|
|
1762
|
-
'</div>';
|
|
1763
|
-
wrap.querySelector(".thead").addEventListener("click", () => wrap.classList.toggle("open"));
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
/** Authoritative thread rebuild from a server `history` frame. */
|
|
1768
|
-
function renderHistory(items) {
|
|
1769
|
-
turnActive = false;
|
|
1770
|
-
curAssistant = null;
|
|
1771
|
-
$("chat-send").disabled = false;
|
|
1772
|
-
$("chat-stop").style.display = "none";
|
|
1773
|
-
renderQueue({});
|
|
1774
|
-
thread().innerHTML = "";
|
|
1775
|
-
if (!items || !items.length) { $("chat-hello").style.display = ""; return; }
|
|
1776
|
-
$("chat-hello").style.display = "none";
|
|
1777
|
-
for (const it of items) {
|
|
1778
|
-
if (it.role === "user") addUserMsg(it.text);
|
|
1779
|
-
else renderAssistantStatic(it);
|
|
1780
|
-
}
|
|
1781
|
-
stick(true);
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
/* — server → client dispatch — */
|
|
1785
|
-
function handle(m) {
|
|
1786
|
-
switch (m.type) {
|
|
1787
|
-
case "ready":
|
|
1788
|
-
$("nav-model").textContent = m.model || activeModelId || "no model"; activeModelId = m.model || activeModelId;
|
|
1789
|
-
visionCapable = !!m.vision; updateAttachHint();
|
|
1790
|
-
thinkingCapable = !!m.thinking; updateThinkingToggle();
|
|
1791
|
-
if (thinkingCapable) send({ type: "set_thinking", enabled: thinkingOn });
|
|
1792
|
-
refreshAdapters();
|
|
1793
|
-
// Reconnect: re-open the session we were in (fresh load has none).
|
|
1794
|
-
if (pendingResumePath) { send({ type: "open_session", path: pendingResumePath }); pendingResumePath = null; }
|
|
1795
|
-
break;
|
|
1796
|
-
case "history": renderHistory(m.items); break;
|
|
1797
|
-
case "sessions": currentSessionPath = m.activePath || null; renderSessions(m.items, m.activePath); break;
|
|
1798
|
-
case "context": renderContext(m); break;
|
|
1799
|
-
case "turn_start": turnActive = true; startAssistant(); $("chat-send").disabled = true; $("chat-stop").style.display = "block"; break;
|
|
1800
|
-
case "text_delta": appendDelta(m.delta || ""); break;
|
|
1801
|
-
case "thinking_delta": appendThinkingDelta(m.delta || ""); break;
|
|
1802
|
-
case "tool_start": toolCard(m.callId, m.tool, m.args); break;
|
|
1803
|
-
case "tool_approval_request": approvalCard(m.callId, m.tool, m.args); break;
|
|
1804
|
-
case "tool_update": toolUpdate(m.callId, m.chunk || ""); break;
|
|
1805
|
-
case "tool_end": toolEnd(m.callId, m.ok, m.result); break;
|
|
1806
|
-
case "turn_end": finalizeMeta(); endTurn(); break;
|
|
1807
|
-
case "queue_update": renderQueue(m); break;
|
|
1808
|
-
case "error": showAgentError(m.message || "agent error"); toast(m.message || "agent error", "err"); if (turnActive) endTurn(); break;
|
|
1809
|
-
}
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
/* — composer — */
|
|
1813
|
-
/* — attachments (files + images) — */
|
|
1814
|
-
let attachments = []; // { id, kind:"image"|"text", name, mimeType, data, text, truncated }
|
|
1815
|
-
let visionCapable = false; // from the `ready` frame; gates image attachments
|
|
1816
|
-
let thinkingCapable = false; // from `ready`; gates the reasoning toggle
|
|
1817
|
-
let thinkingOn = true; // default on: with tools (read/web_search) the reasoning step gives the small model far better tool judgment (answers math/writing itself, only searches when needed). The thinking box defaults collapsed. Users can toggle it off.
|
|
1818
|
-
// Per-request sampling. Each field is either a user override (a number) or
|
|
1819
|
-
// null = "use the recommended default" (resolved server-side). The recommended
|
|
1820
|
-
// TEMPERATURE tracks the thinking toggle (0.7 off ↔ 0.9 on); top_p/top_k recs
|
|
1821
|
-
// are fixed mirrors of the server defaults (display only). The placeholder in
|
|
1822
|
-
// each input shows the live recommended value when the field isn't overridden.
|
|
1823
|
-
const sampling = { temperature: null, top_p: null, top_k: null };
|
|
1824
|
-
const SAMP_REC = { topP: 0.95, topK: 0 }; // fixed recommended top_p / top_k (server genDefaults)
|
|
1825
|
-
function recTemp() { return thinkingOn ? 0.9 : 0.7; } // mirrors server toOptions defaultTemp
|
|
1826
|
-
let attachSeq = 0;
|
|
1827
|
-
const MAX_TEXT_BYTES = 256 * 1024;
|
|
1828
|
-
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;
|
|
1829
|
-
|
|
1830
|
-
function isTextFile(f) {
|
|
1831
|
-
if (f.type.startsWith("text/")) return true;
|
|
1832
|
-
if (/(json|xml|javascript|yaml|x-sh|x-toml|csv|markdown)/i.test(f.type)) return true;
|
|
1833
|
-
return TEXT_EXTS.test(f.name || "");
|
|
1834
|
-
}
|
|
1835
|
-
function readDataUrl(file) {
|
|
1836
|
-
return new Promise((res, rej) => {
|
|
1837
|
-
const r = new FileReader(); r.onerror = rej;
|
|
1838
|
-
r.onload = () => { const s = String(r.result); const i = s.indexOf(","); res(i >= 0 ? s.slice(i + 1) : s); };
|
|
1839
|
-
r.readAsDataURL(file);
|
|
1840
|
-
});
|
|
1841
|
-
}
|
|
1842
|
-
function readText(file) {
|
|
1843
|
-
return new Promise((res, rej) => { const r = new FileReader(); r.onerror = rej; r.onload = () => res(String(r.result)); r.readAsText(file); });
|
|
1844
|
-
}
|
|
1845
|
-
async function addFiles(files) {
|
|
1846
|
-
for (const f of files) {
|
|
1847
|
-
if (f.type.startsWith("image/")) {
|
|
1848
|
-
if (!visionCapable) { toast("This model can't see images — switch to the 12B for vision.", "err"); continue; }
|
|
1849
|
-
try { attachments.push({ id: ++attachSeq, kind: "image", name: f.name || "image", mimeType: f.type, data: await readDataUrl(f) }); }
|
|
1850
|
-
catch { toast("Couldn't read " + (f.name || "image"), "err"); }
|
|
1851
|
-
} else if (isTextFile(f)) {
|
|
1852
|
-
try {
|
|
1853
|
-
let text = await readText(f); const truncated = text.length > MAX_TEXT_BYTES; if (truncated) text = text.slice(0, MAX_TEXT_BYTES);
|
|
1854
|
-
attachments.push({ id: ++attachSeq, kind: "text", name: f.name || "file", text, truncated });
|
|
1855
|
-
} catch { toast("Couldn't read " + (f.name || "file"), "err"); }
|
|
1856
|
-
} else {
|
|
1857
|
-
toast("Unsupported file type: " + (f.name || "file"), "err");
|
|
1858
|
-
}
|
|
1859
|
-
}
|
|
1860
|
-
renderAttachments();
|
|
1861
|
-
}
|
|
1862
|
-
function renderAttachments() {
|
|
1863
|
-
const box = $("chat-attach");
|
|
1864
|
-
box.innerHTML = "";
|
|
1865
|
-
box.style.display = attachments.length ? "flex" : "none";
|
|
1866
|
-
for (const a of attachments) {
|
|
1867
|
-
const chip = el("div", "attach-chip", box);
|
|
1868
|
-
if (a.kind === "image") { const im = el("img", "", chip); im.src = "data:" + a.mimeType + ";base64," + a.data; im.alt = a.name; }
|
|
1869
|
-
else el("span", "att-ico", chip).textContent = "📄";
|
|
1870
|
-
el("span", "att-name", chip).textContent = a.name + (a.truncated ? " (truncated)" : "");
|
|
1871
|
-
const x = el("button", "att-x", chip); x.type = "button"; x.textContent = "✕";
|
|
1872
|
-
x.onclick = () => { attachments = attachments.filter((z) => z.id !== a.id); renderAttachments(); };
|
|
1873
|
-
}
|
|
1874
|
-
}
|
|
1875
|
-
function clearAttachments() { attachments = []; renderAttachments(); }
|
|
1876
|
-
/** Prepend text-file contents to the message the model sees (UI shows chips). */
|
|
1877
|
-
function buildMessageText(userText) {
|
|
1878
|
-
const files = attachments.filter((a) => a.kind === "text");
|
|
1879
|
-
if (!files.length) return userText;
|
|
1880
|
-
let pre = "";
|
|
1881
|
-
for (const a of files) pre += "Attached file: " + a.name + "\n```\n" + a.text + "\n```\n\n";
|
|
1882
|
-
return pre + userText;
|
|
1883
|
-
}
|
|
1884
|
-
function updateAttachHint() {
|
|
1885
|
-
const btn = $("chat-attach-btn");
|
|
1886
|
-
if (btn) btn.title = visionCapable ? "Attach files or images" : "Attach files (this model can't see images)";
|
|
1887
|
-
}
|
|
1888
|
-
// — LoRA adapter selector. Pi-native: app.html lists + mounts adapters, then
|
|
1889
|
-
// tells pi-web which one is active; the before_provider_request hook injects
|
|
1890
|
-
// it into every provider request. Default "" = none (base model). —
|
|
1891
|
-
async function refreshAdapters() {
|
|
1892
|
-
const sel = $("chat-adapter");
|
|
1893
|
-
if (!sel) return;
|
|
1894
|
-
let list = [];
|
|
1895
|
-
try { const d = await api("/v1/adapters/available"); list = (d && d.adapters) || []; } catch {}
|
|
1896
|
-
const cur = sel.value;
|
|
1897
|
-
sel.innerHTML = '<option value="">no adapter</option>' + list.map((a) =>
|
|
1898
|
-
`<option value="${a.id}" data-path="${a.path}">${a.id}${a.rank ? " · r" + a.rank : ""}</option>`).join("");
|
|
1899
|
-
if (cur && list.some((a) => a.id === cur)) sel.value = cur; // keep prior selection
|
|
1900
|
-
}
|
|
1901
|
-
async function onSelectAdapter(sel) {
|
|
1902
|
-
const id = sel.value;
|
|
1903
|
-
if (!id) { send({ type: "set_adapter", id: null }); return; } // none → base model
|
|
1904
|
-
const opt = sel.options[sel.selectedIndex];
|
|
1905
|
-
const path = opt && opt.getAttribute("data-path");
|
|
1906
|
-
if (path) { // auto-load on select; mount() is idempotent server-side
|
|
1907
|
-
const r = await api("/v1/adapters", { method: "POST", body: { id, path } });
|
|
1908
|
-
if (r && (r.error || r.ok === false)) {
|
|
1909
|
-
if (typeof toast === "function") toast("adapter: " + (r.error || "mount failed"), "err");
|
|
1910
|
-
sel.value = ""; send({ type: "set_adapter", id: null }); return;
|
|
1911
|
-
}
|
|
1912
|
-
}
|
|
1913
|
-
send({ type: "set_adapter", id });
|
|
1914
|
-
}
|
|
1915
|
-
function updateThinkingToggle() {
|
|
1916
|
-
const b = $("chat-think");
|
|
1917
|
-
if (!b) return;
|
|
1918
|
-
b.style.display = thinkingCapable ? "inline-flex" : "none"; // only for thinking-capable models
|
|
1919
|
-
b.classList.toggle("think-on", thinkingOn);
|
|
1920
|
-
b.title = thinkingOn ? "Reasoning on — click to answer directly" : "Reasoning off — click to let the model think";
|
|
1921
|
-
b.setAttribute("aria-pressed", String(thinkingOn));
|
|
1922
|
-
refreshSamplingRecs(); // recommended temperature follows the thinking state
|
|
1923
|
-
}
|
|
1924
|
-
|
|
1925
|
-
/* — sampling controls — */
|
|
1926
|
-
// Each row is a slider + a live value readout. A field is "auto" (null
|
|
1927
|
-
// override → the server uses its mode-aware default; temperature's default
|
|
1928
|
-
// follows the thinking toggle) until the user drags it, which records an
|
|
1929
|
-
// explicit override. Reset returns every field to auto.
|
|
1930
|
-
const SAMP_FIELDS = [
|
|
1931
|
-
{ id: "samp-temp", field: "temperature", valId: "samp-temp-val", rec: () => recTemp(), fmt: (v) => v.toFixed(2) },
|
|
1932
|
-
{ id: "samp-topp", field: "top_p", valId: "samp-topp-val", rec: () => SAMP_REC.topP, fmt: (v) => v.toFixed(2) },
|
|
1933
|
-
{ id: "samp-topk", field: "top_k", valId: "samp-topk-val", rec: () => SAMP_REC.topK, fmt: (v) => (v >= 1 ? String(Math.round(v)) : "off") },
|
|
1934
|
-
];
|
|
1935
|
-
|
|
1936
|
-
// Sync every slider + readout to current state: an overridden field shows its
|
|
1937
|
-
// set value (bright readout); an auto field shows the recommended value
|
|
1938
|
-
// (dimmed) and tracks it live — e.g. temperature when the thinking toggle
|
|
1939
|
-
// flips. Called on thinking-toggle flips, popover open, and reset.
|
|
1940
|
-
function refreshSamplingRecs() {
|
|
1941
|
-
for (const f of SAMP_FIELDS) {
|
|
1942
|
-
const slider = $(f.id), val = $(f.valId); if (!slider || !val) continue;
|
|
1943
|
-
const over = sampling[f.field];
|
|
1944
|
-
const v = over != null ? over : f.rec();
|
|
1945
|
-
slider.value = String(v);
|
|
1946
|
-
val.textContent = f.fmt(v);
|
|
1947
|
-
val.classList.toggle("auto", over == null);
|
|
1948
|
-
}
|
|
1949
|
-
}
|
|
1950
|
-
|
|
1951
|
-
// User dragged a slider → record the explicit override, brighten the readout,
|
|
1952
|
-
// and push the whole sampling state to the server.
|
|
1953
|
-
function onSlide(f) {
|
|
1954
|
-
const slider = $(f.id); if (!slider) return;
|
|
1955
|
-
const v = Number(slider.value);
|
|
1956
|
-
sampling[f.field] = v;
|
|
1957
|
-
const val = $(f.valId); if (val) { val.textContent = f.fmt(v); val.classList.remove("auto"); }
|
|
1958
|
-
updateSamplingUi();
|
|
1959
|
-
send({ type: "set_sampling", temperature: sampling.temperature, top_p: sampling.top_p, top_k: sampling.top_k });
|
|
1960
|
-
}
|
|
1961
|
-
|
|
1962
|
-
// The pill shows a lit dot whenever any field is overridden.
|
|
1963
|
-
function updateSamplingUi() {
|
|
1964
|
-
const dirty = sampling.temperature != null || sampling.top_p != null || sampling.top_k != null;
|
|
1965
|
-
const pill = $("chat-sampling");
|
|
1966
|
-
if (pill) { pill.classList.toggle("dirty", dirty); pill.title = dirty
|
|
1967
|
-
? "Sampling overridden — click to edit or reset" : "Sampling controls (temperature · top_p · top_k)"; }
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
function resetSampling() {
|
|
1971
|
-
sampling.temperature = null; sampling.top_p = null; sampling.top_k = null;
|
|
1972
|
-
refreshSamplingRecs(); updateSamplingUi();
|
|
1973
|
-
send({ type: "set_sampling", temperature: null, top_p: null, top_k: null });
|
|
1974
|
-
}
|
|
1975
|
-
function initSampling() {
|
|
1976
|
-
const pill = $("chat-sampling"), pop = $("chat-sampling-pop");
|
|
1977
|
-
if (!pill || !pop) return;
|
|
1978
|
-
const setOpen = (open) => {
|
|
1979
|
-
pop.classList.toggle("open", open);
|
|
1980
|
-
pill.classList.toggle("on", open);
|
|
1981
|
-
pill.setAttribute("aria-expanded", String(open));
|
|
1982
|
-
if (open) refreshSamplingRecs();
|
|
1983
|
-
};
|
|
1984
|
-
const toggle = () => setOpen(!pop.classList.contains("open"));
|
|
1985
|
-
pill.onclick = (e) => { e.stopPropagation(); toggle(); };
|
|
1986
|
-
pill.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } });
|
|
1987
|
-
pop.addEventListener("click", (e) => e.stopPropagation());
|
|
1988
|
-
// Click-away / Escape closes the popover.
|
|
1989
|
-
document.addEventListener("click", () => setOpen(false));
|
|
1990
|
-
document.addEventListener("keydown", (e) => { if (e.key === "Escape") setOpen(false); });
|
|
1991
|
-
for (const f of SAMP_FIELDS) {
|
|
1992
|
-
const e = $(f.id); if (!e) continue;
|
|
1993
|
-
e.addEventListener("input", () => onSlide(f));
|
|
1994
|
-
}
|
|
1995
|
-
const reset = $("samp-reset"); if (reset) reset.onclick = (e) => { e.stopPropagation(); resetSampling(); };
|
|
1996
|
-
updateSamplingUi(); refreshSamplingRecs();
|
|
1997
|
-
}
|
|
1998
|
-
|
|
1999
|
-
/* — composer — */
|
|
2000
|
-
function submit() {
|
|
2001
|
-
const box = $("chat-box");
|
|
2002
|
-
const text = box.value.trim();
|
|
2003
|
-
// Client-side slash commands for session control (not sent to the model).
|
|
2004
|
-
const cmd = text.toLowerCase();
|
|
2005
|
-
if (cmd === "/reload" || cmd === "/new" || cmd === "/clear") {
|
|
2006
|
-
box.value = ""; box.style.height = "auto"; clearAttachments(); newChat();
|
|
2007
|
-
return;
|
|
2008
|
-
}
|
|
2009
|
-
if (!text && attachments.length === 0) return;
|
|
2010
|
-
if (!connected) { toast("Not connected to the agent yet — reconnecting.", "err"); return; }
|
|
2011
|
-
const imgs = attachments.filter((a) => a.kind === "image").map((a) => ({ data: a.data, mimeType: a.mimeType }));
|
|
2012
|
-
const combined = buildMessageText(text);
|
|
2013
|
-
addUserMsg(text, attachments);
|
|
2014
|
-
box.value = ""; box.style.height = "auto";
|
|
2015
|
-
const frame = imgs.length ? { text: combined, images: imgs } : { text: combined };
|
|
2016
|
-
// A composer submission is always a new user turn. If the model is still
|
|
2017
|
-
// streaming, the server passes it to pi with streamingBehavior:"followUp"
|
|
2018
|
-
// so it becomes the next turn instead of steering/mutating the current one.
|
|
2019
|
-
send({ type: "prompt", ...frame });
|
|
2020
|
-
clearAttachments();
|
|
2021
|
-
}
|
|
2022
|
-
const scroll = () => $("chat-scroll");
|
|
2023
|
-
const atBottom = () => scroll().scrollHeight - scroll().scrollTop - scroll().clientHeight < 90;
|
|
2024
|
-
function stick(force) { if (force || atBottom()) scroll().scrollTop = scroll().scrollHeight; }
|
|
2025
|
-
|
|
2026
|
-
return {
|
|
2027
|
-
init() {
|
|
2028
|
-
$("chat-send").onclick = submit;
|
|
2029
|
-
$("chat-new").onclick = newChat;
|
|
2030
|
-
$("chat-stop").onclick = () => { send({ type: "abort" }); $("chat-stop").style.display = "none"; };
|
|
2031
|
-
const thinkToggle = () => { thinkingOn = !thinkingOn; updateThinkingToggle(); send({ type: "set_thinking", enabled: thinkingOn }); };
|
|
2032
|
-
$("chat-think").onclick = thinkToggle;
|
|
2033
|
-
$("chat-think").addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); thinkToggle(); } });
|
|
2034
|
-
const adapterSel = $("chat-adapter");
|
|
2035
|
-
if (adapterSel) adapterSel.onchange = () => onSelectAdapter(adapterSel);
|
|
2036
|
-
initSampling();
|
|
2037
|
-
refreshAdapters();
|
|
2038
|
-
const box = $("chat-box");
|
|
2039
|
-
box.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } });
|
|
2040
|
-
box.addEventListener("input", () => { box.style.height = "auto"; box.style.height = Math.min(box.scrollHeight, 200) + "px"; });
|
|
2041
|
-
document.querySelectorAll("#chat-hello .chip").forEach((c) =>
|
|
2042
|
-
c.addEventListener("click", () => { $("chat-box").value = c.dataset.q; submit(); }));
|
|
2043
|
-
// Attachments: + button, file picker, clipboard paste, drag-and-drop.
|
|
2044
|
-
$("chat-attach-btn").onclick = () => $("chat-file-input").click();
|
|
2045
|
-
$("chat-file-input").addEventListener("change", (e) => { addFiles([...e.target.files]); e.target.value = ""; });
|
|
2046
|
-
box.addEventListener("paste", (e) => {
|
|
2047
|
-
const items = e.clipboardData && e.clipboardData.items;
|
|
2048
|
-
if (!items) return;
|
|
2049
|
-
const files = [];
|
|
2050
|
-
for (const it of items) if (it.kind === "file") { const f = it.getAsFile(); if (f) files.push(f); }
|
|
2051
|
-
if (files.length) { e.preventDefault(); addFiles(files); }
|
|
2052
|
-
});
|
|
2053
|
-
const composer = document.querySelector("#s-chat .composer");
|
|
2054
|
-
if (composer) {
|
|
2055
|
-
["dragenter", "dragover"].forEach((ev) => composer.addEventListener(ev, (e) => { e.preventDefault(); composer.classList.add("dragover"); }));
|
|
2056
|
-
["dragleave", "drop"].forEach((ev) => composer.addEventListener(ev, (e) => {
|
|
2057
|
-
e.preventDefault();
|
|
2058
|
-
if (ev === "drop" && e.dataTransfer && e.dataTransfer.files.length) addFiles([...e.dataTransfer.files]);
|
|
2059
|
-
if (ev === "drop" || !composer.contains(e.relatedTarget)) composer.classList.remove("dragover");
|
|
2060
|
-
}));
|
|
2061
|
-
}
|
|
2062
|
-
// Delegated copy buttons for rendered code blocks (the thread element
|
|
2063
|
-
// is stable even though its contents re-render on every token).
|
|
2064
|
-
thread().addEventListener("click", (e) => {
|
|
2065
|
-
const btn = e.target.closest && e.target.closest(".cbcopy");
|
|
2066
|
-
if (!btn) return;
|
|
2067
|
-
const code = btn.closest(".codeblock")?.querySelector("code");
|
|
2068
|
-
if (!code || !navigator.clipboard) return;
|
|
2069
|
-
navigator.clipboard.writeText(code.textContent).then(() => {
|
|
2070
|
-
btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Copy"; }, 1200);
|
|
2071
|
-
}).catch(() => {});
|
|
2072
|
-
});
|
|
2073
|
-
},
|
|
2074
|
-
enter() { if (!ws || ws.readyState > 1) connect(); $("chat-box").focus(); },
|
|
2075
|
-
leave() { /* keep WS warm; closing on every nav would drop in-flight turns */ },
|
|
2076
|
-
};
|
|
2077
|
-
})();
|
|
2078
|
-
|
|
2079
|
-
/* ════════════════════════════════════════════════════════════════════
|
|
2080
|
-
QUANTIZE CONTROLLER — 4-step wizard
|
|
2081
|
-
════════════════════════════════════════════════════════════════════ */
|
|
2082
|
-
controllers.quantize = (() => {
|
|
2083
|
-
const STEPS = ["Source", "Configure", "Run", "Done"];
|
|
2084
|
-
let step = 0, bits = 4, gs = 64, mode = "uniform", bpw = 5, jobId = null, es = null, log = [];
|
|
2085
|
-
|
|
2086
|
-
function show(n) {
|
|
2087
|
-
step = n;
|
|
2088
|
-
document.querySelectorAll("[data-qstep]").forEach((d) => d.style.display = (+d.dataset.qstep === n) ? "" : "none");
|
|
2089
|
-
renderSteps($("q-steps"), STEPS, n);
|
|
2090
|
-
}
|
|
2091
|
-
function seg(container, onPick) {
|
|
2092
|
-
container.querySelectorAll("button").forEach((b) =>
|
|
2093
|
-
b.addEventListener("click", () => {
|
|
2094
|
-
container.querySelectorAll("button").forEach((x) => x.classList.remove("on"));
|
|
2095
|
-
b.classList.add("on"); onPick(b.dataset.v, b);
|
|
2096
|
-
}));
|
|
2097
|
-
}
|
|
2098
|
-
|
|
2099
|
-
// --- OS folder picker / drag-drop → real on-disk snapshot path ---
|
|
2100
|
-
// The browser can't reveal an absolute path, and the HF cache uses symlinked
|
|
2101
|
-
// snapshots over a blobs/ store — so we DON'T read any files. We only take the
|
|
2102
|
-
// picked folder's NAME (which encodes the repo id) and let the server
|
|
2103
|
-
// reconstruct the snapshot path it owns. No upload, no "config.json" hunt.
|
|
2104
|
-
async function resolveByName(name, relPath) {
|
|
2105
|
-
if (!name && !relPath) return;
|
|
2106
|
-
const out = $("q-inspect-out");
|
|
2107
|
-
out.innerHTML = '<div class="flash"><span class="shimmer">locating on disk…</span></div>';
|
|
2108
|
-
const d = await api("/api/quantize/resolve-folder", { method: "POST", body: { folder_name: name || "", rel_path: relPath || "" } })
|
|
2109
|
-
.catch(() => ({ ok: false, error: "request failed" }));
|
|
2110
|
-
if (!d.ok) { out.innerHTML = '<div class="flash err">' + esc(d.error || "could not locate this folder on the server") + "</div>"; return; }
|
|
2111
|
-
$("q-model").value = d.path;
|
|
2112
|
-
inspect();
|
|
2113
|
-
}
|
|
2114
|
-
async function pickFolder() {
|
|
2115
|
-
// Native OS folder dialog with no "upload N files" prompt; we only read the
|
|
2116
|
-
// folder's .name, never its contents.
|
|
2117
|
-
if (window.showDirectoryPicker) {
|
|
2118
|
-
try {
|
|
2119
|
-
const handle = await window.showDirectoryPicker({ id: "mlx-models", mode: "read" });
|
|
2120
|
-
return resolveByName(handle.name);
|
|
2121
|
-
} catch (e) { return; } // user cancelled / denied
|
|
2122
|
-
}
|
|
2123
|
-
$("q-folder").click(); // fallback for browsers without the File System Access API
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
|
-
async function inspect() {
|
|
2127
|
-
const id = $("q-model").value.trim();
|
|
2128
|
-
if (!id) { toast("Enter a model id or path first.", "err"); return; }
|
|
2129
|
-
const out = $("q-inspect-out");
|
|
2130
|
-
out.innerHTML = '<div class="flash"><span class="shimmer">inspecting…</span></div>';
|
|
2131
|
-
const btn = $("q-inspect"); btn.disabled = true;
|
|
2132
|
-
const d = await api("/api/quantize/inspect", { method: "POST", body: { model_id: id } }).catch(() => ({ ok: false, error: "request failed" }));
|
|
2133
|
-
btn.disabled = false;
|
|
2134
|
-
if (!d.ok) { out.innerHTML = '<div class="flash err">' + esc(d.error || "could not inspect this model") + "</div>"; return; }
|
|
2135
|
-
const supported = d.support === "supported" || d.support === true || d.support === "ok";
|
|
2136
|
-
const cls = supported ? "ok" : (d.support === "untested" ? "warn" : "err");
|
|
2137
|
-
out.innerHTML =
|
|
2138
|
-
'<div class="flash ' + cls + '"><strong>' + esc(d.arch || d.model_id || id) + "</strong>" +
|
|
2139
|
-
(d.size_gb != null ? " · " + esc(d.size_gb) + " GB" : "") +
|
|
2140
|
-
" · " + (supported ? "supported" : esc(d.support || "unknown architecture")) + "</div>" +
|
|
2141
|
-
'<div class="btnrow"><button class="btn primary" id="q-continue">Continue</button></div>';
|
|
2142
|
-
$("q-continue").onclick = () => show(1);
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
|
-
async function submit() {
|
|
2146
|
-
const id = $("q-model").value.trim();
|
|
2147
|
-
const btn = $("q-submit"); btn.disabled = true;
|
|
2148
|
-
const body = { model_id: id, bits, group_size: gs };
|
|
2149
|
-
if (mode === "mixed") { body.target_bpw = bpw; body.candidate_bits = [4, 8]; }
|
|
2150
|
-
const d = await api("/api/quantize/submit", { method: "POST", body })
|
|
2151
|
-
.catch(() => ({ ok: false, error: "request failed" }));
|
|
2152
|
-
btn.disabled = false;
|
|
2153
|
-
if (!d.ok) { toast(d.error || "could not start quantize", "err"); return; }
|
|
2154
|
-
jobId = d.job_id || null;
|
|
2155
|
-
$("q-push-panel").innerHTML = "";
|
|
2156
|
-
$("q-out").textContent = d.output_dir || "";
|
|
2157
|
-
log = []; $("q-log").textContent = ""; $("q-bar").style.width = "0%"; $("q-pct").textContent = "0%"; $("q-msg").textContent = "Starting…";
|
|
2158
|
-
show(2);
|
|
2159
|
-
attach(d.job_id);
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
function attach(jobId) {
|
|
2163
|
-
if (es) es.close();
|
|
2164
|
-
es = jobStream(jobId, {
|
|
2165
|
-
log: (e) => { log.push(e.line); $("q-log").textContent = log.slice(-200).join("\n"); $("q-log").scrollTop = $("q-log").scrollHeight; },
|
|
2166
|
-
stage: (e) => {
|
|
2167
|
-
if (e.progress != null) { const p = Math.round(e.progress * 100); $("q-bar").style.width = p + "%"; $("q-pct").textContent = p + "%"; }
|
|
2168
|
-
if (e.message) $("q-msg").textContent = e.message;
|
|
2169
|
-
if (e.stage === "done") { if (e.output_dir) $("q-out").textContent = e.output_dir; finish(); }
|
|
2170
|
-
},
|
|
2171
|
-
done: () => finish(),
|
|
2172
|
-
failed: (e) => { $("q-msg").textContent = "Failed: " + (e.error || "unknown error"); toast(e.error || "quantize failed", "err"); es && es.close(); },
|
|
2173
|
-
});
|
|
2174
|
-
}
|
|
2175
|
-
function finish() { $("q-bar").style.width = "100%"; $("q-pct").textContent = "100%"; es && es.close(); show(3); }
|
|
2176
|
-
|
|
2177
|
-
return {
|
|
2178
|
-
init() {
|
|
2179
|
-
show(0);
|
|
2180
|
-
$("q-inspect").onclick = inspect;
|
|
2181
|
-
$("q-model").addEventListener("keydown", (e) => { if (e.key === "Enter") inspect(); });
|
|
2182
|
-
// OS folder dialog (native; resolves by folder name, no upload)
|
|
2183
|
-
$("q-browse").onclick = pickFolder;
|
|
2184
|
-
$("q-folder").addEventListener("change", (e) => {
|
|
2185
|
-
const f = e.target.files[0]; e.target.value = "";
|
|
2186
|
-
if (f) resolveByName((f.webkitRelativePath || f.name).split("/")[0], f.webkitRelativePath || "");
|
|
2187
|
-
});
|
|
2188
|
-
// drag a folder onto the box — use the directory entry's NAME (no read)
|
|
2189
|
-
const drop = $("q-drop");
|
|
2190
|
-
const hi = (on) => { drop.style.outline = on ? "2px dashed var(--blue)" : ""; drop.style.outlineOffset = on ? "4px" : ""; };
|
|
2191
|
-
["dragenter", "dragover"].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); hi(true); }));
|
|
2192
|
-
["dragleave", "drop"].forEach((ev) => drop.addEventListener(ev, (e) => {
|
|
2193
|
-
e.preventDefault(); hi(false);
|
|
2194
|
-
if (ev !== "drop" || !e.dataTransfer) return;
|
|
2195
|
-
for (const it of [...(e.dataTransfer.items || [])]) {
|
|
2196
|
-
const en = it.webkitGetAsEntry && it.webkitGetAsEntry();
|
|
2197
|
-
if (en && en.isDirectory) return resolveByName(en.name);
|
|
2198
|
-
}
|
|
2199
|
-
const f = e.dataTransfer.files && e.dataTransfer.files[0];
|
|
2200
|
-
if (f) resolveByName((f.webkitRelativePath || f.name).split("/")[0]);
|
|
2201
|
-
}));
|
|
2202
|
-
seg($("q-bits"), (v) => { bits = +v; $("q-bits-hint").textContent = v === "4"
|
|
2203
|
-
? "4-bit · ~4× smaller than bf16 · the recommended default for local serving."
|
|
2204
|
-
: "8-bit · ~2× smaller than bf16 · highest fidelity quant."; });
|
|
2205
|
-
seg($("q-gs"), (v) => { gs = +v; });
|
|
2206
|
-
seg($("q-mode"), (v) => { mode = v; $("q-bpw-field").style.display = v === "mixed" ? "" : "none"; });
|
|
2207
|
-
$("q-bpw").addEventListener("input", (e) => { bpw = +e.target.value; $("q-bpw-val").textContent = bpw.toFixed(1); });
|
|
2208
|
-
document.querySelectorAll("#s-quantize [data-qback]").forEach((b) => b.onclick = () => show(step - 1));
|
|
2209
|
-
$("q-submit").onclick = submit;
|
|
2210
|
-
$("q-push").onclick = () => pushToHub($("q-push-panel"), { kind: "quantize", job_id: jobId });
|
|
2211
|
-
$("q-again").onclick = () => { $("q-inspect-out").innerHTML = ""; $("q-push-panel").innerHTML = ""; show(0); };
|
|
2212
|
-
},
|
|
2213
|
-
};
|
|
2214
|
-
})();
|
|
2215
|
-
|
|
2216
|
-
/* ════════════════════════════════════════════════════════════════════
|
|
2217
|
-
FINE-TUNE CONTROLLER — 5-step wizard with live loss chart
|
|
2218
|
-
════════════════════════════════════════════════════════════════════ */
|
|
2219
|
-
controllers.finetune = (() => {
|
|
2220
|
-
const STEPS = ["Base", "Dataset", "Hyperparams", "Train", "Done"];
|
|
2221
|
-
let step = 0, method = "sft", lrTouched = false, rankTouched = false, es = null, log = [];
|
|
2222
|
-
let datasetOk = false, adapterPath = "";
|
|
2223
|
-
const trainPts = [], valPts = [];
|
|
2224
|
-
|
|
2225
|
-
function show(n) {
|
|
2226
|
-
step = n;
|
|
2227
|
-
document.querySelectorAll("[data-fstep]").forEach((d) => d.style.display = (+d.dataset.fstep === n) ? "" : "none");
|
|
2228
|
-
renderSteps($("f-steps"), STEPS, n);
|
|
2229
|
-
}
|
|
2230
|
-
|
|
2231
|
-
// OS folder picker for the base model — resolve by NAME server-side (no
|
|
2232
|
-
// upload, works with the HF cache's symlinked layout). Mirrors Quantize.
|
|
2233
|
-
async function resolveBaseByName(name, relPath) {
|
|
2234
|
-
if (!name && !relPath) return;
|
|
2235
|
-
const d = await api("/api/model/resolve-folder", { method: "POST", body: { folder_name: name || "", rel_path: relPath || "" } })
|
|
2236
|
-
.catch(() => ({ ok: false, error: "request failed" }));
|
|
2237
|
-
if (!d.ok) { toast(d.error || "could not locate this folder", "err"); return; }
|
|
2238
|
-
$("f-model").value = d.path;
|
|
2239
|
-
}
|
|
2240
|
-
async function pickBaseFolder() {
|
|
2241
|
-
if (window.showDirectoryPicker) {
|
|
2242
|
-
try { const h = await window.showDirectoryPicker({ id: "mlx-models", mode: "read" }); return resolveBaseByName(h.name); }
|
|
2243
|
-
catch (e) { return; }
|
|
2244
|
-
}
|
|
2245
|
-
$("f-folder").click();
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
|
-
async function inspectDataset() {
|
|
2249
|
-
const path = $("f-data").value.trim();
|
|
2250
|
-
if (!path) { toast("Enter a dataset directory.", "err"); return; }
|
|
2251
|
-
const out = $("f-inspect-out");
|
|
2252
|
-
out.innerHTML = '<div class="flash"><span class="shimmer">inspecting…</span></div>';
|
|
2253
|
-
const d = await api("/api/finetune/inspect-dataset", { method: "POST", body: { path } }).catch(() => ({ ok: false, error: "request failed" }));
|
|
2254
|
-
if (!d.ok) { out.innerHTML = '<div class="flash err">' + esc(d.error || "could not read dataset") + "</div>"; datasetOk = false; $("f-next1").disabled = true; return; }
|
|
2255
|
-
datasetOk = true; $("f-next1").disabled = false;
|
|
2256
|
-
out.innerHTML = '<div class="flash ok"><strong>' + num(d.n_train) + "</strong> train rows" +
|
|
2257
|
-
(d.n_valid ? " · <strong>" + num(d.n_valid) + "</strong> valid rows" : "") +
|
|
2258
|
-
(d.format ? " · format <strong>" + esc(d.format) + "</strong>" : "") + "</div>";
|
|
2259
|
-
}
|
|
2260
|
-
|
|
2261
|
-
function setMethod(m) {
|
|
2262
|
-
method = m;
|
|
2263
|
-
$("f-method").querySelectorAll("button").forEach((b) => b.classList.toggle("on", b.dataset.v === m));
|
|
2264
|
-
$("f-dpo-extra").style.display = m === "dpo" ? "" : "none";
|
|
2265
|
-
$("f-orpo-extra").style.display = m === "orpo" ? "" : "none";
|
|
2266
|
-
$("f-method-hint").textContent = m === "dpo"
|
|
2267
|
-
? "Direct Preference Optimization — data is {prompt, chosen, rejected}."
|
|
2268
|
-
: m === "orpo"
|
|
2269
|
-
? "Odds Ratio Preference Optimization (reference-free) — data is {prompt, chosen, rejected}."
|
|
2270
|
-
: "Supervised fine-tuning — data is messages or prompt+completion.";
|
|
2271
|
-
if (!lrTouched) $("f-lr").value = m === "dpo" ? 0.00005 : m === "orpo" ? 0.00001 : 0.0002;
|
|
2272
|
-
// ORPO's validated LoRA recipe uses rank 16 (vs 8 for SFT); apply unless edited.
|
|
2273
|
-
if (!rankTouched) $("f-rank").value = m === "orpo" ? 16 : 8;
|
|
2274
|
-
}
|
|
2275
|
-
|
|
2276
|
-
function collectHP() {
|
|
2277
|
-
const modules = $("f-modules").value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
2278
|
-
const hp = {
|
|
2279
|
-
model_dir: $("f-model").value.trim(),
|
|
2280
|
-
data_dir: $("f-data").value.trim(),
|
|
2281
|
-
method,
|
|
2282
|
-
rank: +$("f-rank").value, scale: +$("f-scale").value, lora_dropout: +$("f-dropout").value,
|
|
2283
|
-
target_modules: modules, num_layers: +$("f-layers").value, iters: +$("f-iters").value,
|
|
2284
|
-
batch_size: +$("f-batch").value, learning_rate: +$("f-lr").value, max_seq_length: +$("f-seq").value,
|
|
2285
|
-
};
|
|
2286
|
-
if (method === "dpo") { hp.dpo_beta = +$("f-beta").value; hp.dpo_lr_schedule = $("f-sched").value; }
|
|
2287
|
-
if (method === "orpo") { hp.orpo_lambda = +$("f-orpo-lambda").value; hp.orpo_lr_schedule = $("f-orpo-sched").value; }
|
|
2288
|
-
return hp;
|
|
2289
|
-
}
|
|
2290
|
-
|
|
2291
|
-
async function submit() {
|
|
2292
|
-
const btn = $("f-submit"); btn.disabled = true;
|
|
2293
|
-
const d = await api("/api/finetune/submit", { method: "POST", body: collectHP() }).catch(() => ({ ok: false, error: "request failed" }));
|
|
2294
|
-
btn.disabled = false;
|
|
2295
|
-
if (!d.ok) { toast(d.error || "could not start training", "err"); return; }
|
|
2296
|
-
adapterPath = d.adapter_path || "";
|
|
2297
|
-
$("f-out").textContent = adapterPath;
|
|
2298
|
-
$("f-merge-out").innerHTML = $("f-exp-out").innerHTML = $("f-push-panel").innerHTML = "";
|
|
2299
|
-
trainPts.length = 0; valPts.length = 0; log = [];
|
|
2300
|
-
$("f-log").textContent = ""; $("f-bar").style.width = "0%"; $("f-pct").textContent = "0%"; $("f-msg").textContent = "Starting…";
|
|
2301
|
-
$("f-step").textContent = $("f-loss").textContent = $("f-curlr").textContent = $("f-curtps").textContent = "—";
|
|
2302
|
-
drawChart();
|
|
2303
|
-
show(3);
|
|
2304
|
-
attach(d.job_id);
|
|
2305
|
-
}
|
|
2306
|
-
|
|
2307
|
-
function attach(jobId) {
|
|
2308
|
-
if (es) es.close();
|
|
2309
|
-
es = jobStream(jobId, {
|
|
2310
|
-
log: (e) => { log.push(e.line); $("f-log").textContent = log.slice(-200).join("\n"); $("f-log").scrollTop = $("f-log").scrollHeight; },
|
|
2311
|
-
metric: (e) => {
|
|
2312
|
-
if (e.kind === "val") { valPts.push({ step: e.step, loss: e.loss }); $("f-leg-val").style.display = ""; }
|
|
2313
|
-
else {
|
|
2314
|
-
trainPts.push({ step: e.step, loss: e.loss });
|
|
2315
|
-
$("f-step").textContent = num(e.step);
|
|
2316
|
-
$("f-loss").textContent = e.loss != null ? e.loss.toFixed(4) : "—";
|
|
2317
|
-
if (e.lr != null) $("f-curlr").textContent = e.lr.toExponential(2);
|
|
2318
|
-
if (e.tps != null) $("f-curtps").textContent = e.tps.toFixed(1);
|
|
2319
|
-
if (e.progress != null) { const p = Math.round(e.progress * 100); $("f-bar").style.width = p + "%"; $("f-pct").textContent = p + "%"; }
|
|
2320
|
-
if (e.message) $("f-msg").textContent = e.message;
|
|
2321
|
-
}
|
|
2322
|
-
drawChart();
|
|
2323
|
-
},
|
|
2324
|
-
stage: (e) => {
|
|
2325
|
-
if (e.progress != null) { const p = Math.round(e.progress * 100); $("f-bar").style.width = p + "%"; $("f-pct").textContent = p + "%"; }
|
|
2326
|
-
if (e.message) $("f-msg").textContent = e.message;
|
|
2327
|
-
if (e.stage === "done") { if (e.adapter_path) { adapterPath = e.adapter_path; $("f-out").textContent = e.adapter_path; } finish(); }
|
|
2328
|
-
},
|
|
2329
|
-
done: () => finish(),
|
|
2330
|
-
failed: (e) => { $("f-msg").textContent = "Failed: " + (e.error || "unknown error"); toast(e.error || "training failed", "err"); es && es.close(); },
|
|
2331
|
-
});
|
|
2332
|
-
}
|
|
2333
|
-
function finish() {
|
|
2334
|
-
$("f-bar").style.width = "100%"; $("f-pct").textContent = "100%"; es && es.close();
|
|
2335
|
-
if (!adapterPath) adapterPath = $("f-out").textContent.trim();
|
|
2336
|
-
const base = $("f-model").value.trim();
|
|
2337
|
-
// prefill the done-step affordances from this run's paths
|
|
2338
|
-
$("f-merge-a").value = adapterPath;
|
|
2339
|
-
$("f-exp-base").value = base;
|
|
2340
|
-
$("f-exp-adapter").value = adapterPath;
|
|
2341
|
-
show(4);
|
|
2342
|
-
}
|
|
2343
|
-
|
|
2344
|
-
async function mergeAdapters() {
|
|
2345
|
-
const a = $("f-merge-a").value.trim(), b = $("f-merge-b").value.trim();
|
|
2346
|
-
const out = $("f-merge-out");
|
|
2347
|
-
if (!a || !b) { out.innerHTML = '<div class="flash err">Enter both adapter paths.</div>'; return; }
|
|
2348
|
-
out.innerHTML = '<div class="flash"><span class="shimmer">merging…</span></div>';
|
|
2349
|
-
const btn = $("f-merge-go"); btn.disabled = true;
|
|
2350
|
-
const d = await api("/api/finetune/merge", { method: "POST", body: { adapter_a: a, adapter_b: b } }).catch(() => ({ ok: false, error: "request failed" }));
|
|
2351
|
-
btn.disabled = false;
|
|
2352
|
-
if (!d.ok) { out.innerHTML = '<div class="flash err">' + esc(d.error || "merge failed") + "</div>"; return; }
|
|
2353
|
-
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) : "");
|
|
2354
|
-
out.innerHTML = '<div class="flash ok">Merged → <code>' + esc(d.merged_path || "") + "</code>" + (stats ? "<br>" + stats : "") + "</div>";
|
|
2355
|
-
toast("Adapters merged", "ok");
|
|
2356
|
-
}
|
|
2357
|
-
|
|
2358
|
-
async function exportModel() {
|
|
2359
|
-
const base = $("f-exp-base").value.trim(), adapter = $("f-exp-adapter").value.trim();
|
|
2360
|
-
const out = $("f-exp-out");
|
|
2361
|
-
if (!base || !adapter) { out.innerHTML = '<div class="flash err">Enter a base model and adapter path.</div>'; return; }
|
|
2362
|
-
out.innerHTML = '<div class="flash"><span class="shimmer">exporting…</span></div>';
|
|
2363
|
-
const btn = $("f-exp-go"); btn.disabled = true;
|
|
2364
|
-
const d = await api("/api/finetune/export", { method: "POST", body: { base_model: base, adapter_path: adapter } }).catch(() => ({ ok: false, error: "request failed" }));
|
|
2365
|
-
btn.disabled = false;
|
|
2366
|
-
if (!d.ok) { out.innerHTML = '<div class="flash err">' + esc(d.error || "export failed") + "</div>"; return; }
|
|
2367
|
-
out.innerHTML = '<div class="flash ok">Exported → <code>' + esc(d.export_path || "") + "</code></div>";
|
|
2368
|
-
toast("Model exported", "ok");
|
|
2369
|
-
}
|
|
2370
|
-
|
|
2371
|
-
/* inline SVG line plot — themed green (train) / blue (val) */
|
|
2372
|
-
function drawChart() {
|
|
2373
|
-
const svg = $("f-chart");
|
|
2374
|
-
const W = 600, H = 200, padL = 8, padR = 8, padT = 12, padB = 12;
|
|
2375
|
-
const all = trainPts.concat(valPts);
|
|
2376
|
-
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; }
|
|
2377
|
-
const losses = all.map((p) => p.loss).filter((x) => x != null && isFinite(x));
|
|
2378
|
-
let minL = Math.min(...losses), maxL = Math.max(...losses);
|
|
2379
|
-
if (minL === maxL) { minL -= 0.5; maxL += 0.5; }
|
|
2380
|
-
const range = maxL - minL || 1;
|
|
2381
|
-
const maxStep = Math.max(...all.map((p) => p.step || 0), 1);
|
|
2382
|
-
const xOf = (s) => padL + (s / maxStep) * (W - padL - padR);
|
|
2383
|
-
const yOf = (l) => padT + (1 - (l - minL) / range) * (H - padT - padB);
|
|
2384
|
-
const path = (pts, color, fillId) => {
|
|
2385
|
-
if (!pts.length) return "";
|
|
2386
|
-
const d = pts.map((p, i) => (i ? "L" : "M") + xOf(p.step).toFixed(1) + " " + yOf(p.loss).toFixed(1)).join(" ");
|
|
2387
|
-
let out = '<path d="' + d + '" fill="none" stroke="' + color + '" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
|
|
2388
|
-
// soft area fill under the curve
|
|
2389
|
-
const area = d + " L" + xOf(pts[pts.length - 1].step).toFixed(1) + " " + (H - padB) + " L" + xOf(pts[0].step).toFixed(1) + " " + (H - padB) + " Z";
|
|
2390
|
-
out = '<path d="' + area + '" fill="url(#' + fillId + ')" opacity="0.18"/>' + out;
|
|
2391
|
-
return out;
|
|
2392
|
-
};
|
|
2393
|
-
// gridlines
|
|
2394
|
-
let grid = "";
|
|
2395
|
-
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"/>'; }
|
|
2396
|
-
svg.innerHTML =
|
|
2397
|
-
'<defs>' +
|
|
2398
|
-
'<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>' +
|
|
2399
|
-
'<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>' +
|
|
2400
|
-
'</defs>' + grid +
|
|
2401
|
-
path(valPts, "#0a84ff", "gf-val") +
|
|
2402
|
-
path(trainPts, "#30d158", "gf-train");
|
|
2403
|
-
}
|
|
2404
|
-
|
|
2405
|
-
return {
|
|
2406
|
-
init() {
|
|
2407
|
-
show(0);
|
|
2408
|
-
$("f-next0").onclick = () => { if (!$("f-model").value.trim()) { toast("Enter a base model path.", "err"); return; } show(1); };
|
|
2409
|
-
// OS folder dialog for the base model (native; resolves by name, no upload)
|
|
2410
|
-
$("f-browse").onclick = pickBaseFolder;
|
|
2411
|
-
$("f-folder").addEventListener("change", (e) => {
|
|
2412
|
-
const f = e.target.files[0]; e.target.value = "";
|
|
2413
|
-
if (f) resolveBaseByName((f.webkitRelativePath || f.name).split("/")[0], f.webkitRelativePath || "");
|
|
2414
|
-
});
|
|
2415
|
-
{
|
|
2416
|
-
const drop = $("f-drop");
|
|
2417
|
-
const hi = (on) => { drop.style.outline = on ? "2px dashed var(--blue)" : ""; drop.style.outlineOffset = on ? "4px" : ""; };
|
|
2418
|
-
["dragenter", "dragover"].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); hi(true); }));
|
|
2419
|
-
["dragleave", "drop"].forEach((ev) => drop.addEventListener(ev, (e) => {
|
|
2420
|
-
e.preventDefault(); hi(false);
|
|
2421
|
-
if (ev !== "drop" || !e.dataTransfer) return;
|
|
2422
|
-
for (const it of [...(e.dataTransfer.items || [])]) {
|
|
2423
|
-
const en = it.webkitGetAsEntry && it.webkitGetAsEntry();
|
|
2424
|
-
if (en && en.isDirectory) return resolveBaseByName(en.name);
|
|
2425
|
-
}
|
|
2426
|
-
const f = e.dataTransfer.files && e.dataTransfer.files[0];
|
|
2427
|
-
if (f) resolveBaseByName((f.webkitRelativePath || f.name).split("/")[0]);
|
|
2428
|
-
}));
|
|
2429
|
-
}
|
|
2430
|
-
$("f-inspect").onclick = inspectDataset;
|
|
2431
|
-
$("f-data").addEventListener("keydown", (e) => { if (e.key === "Enter") inspectDataset(); });
|
|
2432
|
-
$("f-data").addEventListener("input", () => { datasetOk = false; $("f-next1").disabled = true; $("f-inspect-out").innerHTML = ""; });
|
|
2433
|
-
$("f-next1").onclick = () => { if (datasetOk) show(2); };
|
|
2434
|
-
$("f-method").querySelectorAll("button").forEach((b) => b.onclick = () => setMethod(b.dataset.v));
|
|
2435
|
-
$("f-lr").addEventListener("input", () => { lrTouched = true; });
|
|
2436
|
-
$("f-rank").addEventListener("input", () => { rankTouched = true; });
|
|
2437
|
-
document.querySelectorAll("#s-finetune [data-fback]").forEach((b) => b.onclick = () => show(step - 1));
|
|
2438
|
-
$("f-submit").onclick = submit;
|
|
2439
|
-
$("f-merge-go").onclick = mergeAdapters;
|
|
2440
|
-
$("f-exp-go").onclick = exportModel;
|
|
2441
|
-
$("f-push").onclick = () => pushToHub($("f-push-panel"), { kind: "finetune", source_path: adapterPath || $("f-out").textContent.trim() });
|
|
2442
|
-
$("f-again").onclick = () => {
|
|
2443
|
-
$("f-inspect-out").innerHTML = "";
|
|
2444
|
-
$("f-merge-out").innerHTML = $("f-exp-out").innerHTML = $("f-push-panel").innerHTML = "";
|
|
2445
|
-
show(0);
|
|
2446
|
-
};
|
|
2447
|
-
},
|
|
2448
|
-
};
|
|
2449
|
-
})();
|
|
2450
|
-
|
|
2451
|
-
/* ════════════════════════════════════════════════════════════════════
|
|
2452
|
-
DATASET CONTROLLER — template-driven builder
|
|
2453
|
-
════════════════════════════════════════════════════════════════════ */
|
|
2454
|
-
controllers.dataset = (() => {
|
|
2455
|
-
const STEPS = ["Template", "Inputs", "Generate", "Done"];
|
|
2456
|
-
let step = 0, templates = [], selected = null, jobId = null, es = null, log = [];
|
|
2457
|
-
|
|
2458
|
-
function show(n) {
|
|
2459
|
-
step = n;
|
|
2460
|
-
document.querySelectorAll("[data-dstep]").forEach((d) => d.style.display = (+d.dataset.dstep === n) ? "" : "none");
|
|
2461
|
-
renderSteps($("d-steps"), STEPS, n);
|
|
2462
|
-
}
|
|
2463
|
-
|
|
2464
|
-
async function loadTemplates() {
|
|
2465
|
-
const grid = $("d-templates");
|
|
2466
|
-
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("");
|
|
2467
|
-
const d = await api("/api/dataset/templates").catch(() => ({ templates: null }));
|
|
2468
|
-
templates = (d && d.templates) || [];
|
|
2469
|
-
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; }
|
|
2470
|
-
// non-LLM templates first
|
|
2471
|
-
const ordered = templates.slice().sort((a, b) => (a.needs_llm ? 1 : 0) - (b.needs_llm ? 1 : 0));
|
|
2472
|
-
grid.innerHTML = ordered.map((t, i) =>
|
|
2473
|
-
'<div class="card" data-tid="' + esc(t.id) + '" style="cursor:pointer;display:flex;flex-direction:column;gap:8px">' +
|
|
2474
|
-
'<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">' +
|
|
2475
|
-
'<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>" +
|
|
2476
|
-
(t.needs_llm ? '<span class="pill warn" style="padding:3px 9px;font-size:11px"><span class="dot"></span>uses local model</span>' : "") +
|
|
2477
|
-
(t.output_format ? '<span class="soon" style="color:var(--dim)">' + esc(t.output_format) + "</span>" : "") +
|
|
2478
|
-
"</div>" +
|
|
2479
|
-
'<p style="color:var(--dim);font-size:13.5px;line-height:1.5;margin:0">' + esc(t.description || "") + "</p>" +
|
|
2480
|
-
"</div>").join("");
|
|
2481
|
-
grid.querySelectorAll("[data-tid]").forEach((c) =>
|
|
2482
|
-
c.onclick = () => selectTemplate(templates.find((t) => t.id === c.dataset.tid)));
|
|
2483
|
-
}
|
|
2484
|
-
|
|
2485
|
-
function selectTemplate(t) {
|
|
2486
|
-
selected = t;
|
|
2487
|
-
$("d-form-title").textContent = t.label || t.id;
|
|
2488
|
-
$("d-form-desc").textContent = t.description || "";
|
|
2489
|
-
const form = $("d-form");
|
|
2490
|
-
form.innerHTML = (t.fields || []).map((f) => {
|
|
2491
|
-
const lbl = esc(f.label || f.name) + (f.required ? " *" : "");
|
|
2492
|
-
const ph = f.hint ? ' placeholder="' + esc(f.hint) + '"' : "";
|
|
2493
|
-
const dv = f.default != null ? f.default : "";
|
|
2494
|
-
let ctrl;
|
|
2495
|
-
if (f.type === "textarea") ctrl = '<textarea data-fld="' + esc(f.name) + '"' + ph + ">" + esc(dv) + "</textarea>";
|
|
2496
|
-
else if (f.type === "number") ctrl = '<input type="number" data-fld="' + esc(f.name) + '" value="' + esc(dv) + '"' + ph + ">";
|
|
2497
|
-
else ctrl = '<input type="text" data-fld="' + esc(f.name) + '" value="' + esc(dv) + '"' + ph + ">";
|
|
2498
|
-
return '<div class="field"><label>' + lbl + "</label>" + ctrl + (f.hint ? '<div class="hint">' + esc(f.hint) + "</div>" : "") + "</div>";
|
|
2499
|
-
}).join("");
|
|
2500
|
-
show(1);
|
|
2501
|
-
}
|
|
2502
|
-
|
|
2503
|
-
async function submit() {
|
|
2504
|
-
if (!selected) return;
|
|
2505
|
-
const inputs = {};
|
|
2506
|
-
$("d-form").querySelectorAll("[data-fld]").forEach((i) => {
|
|
2507
|
-
inputs[i.dataset.fld] = i.type === "number" ? (i.value === "" ? null : +i.value) : i.value;
|
|
2508
|
-
});
|
|
2509
|
-
const body = { template_id: selected.id, inputs };
|
|
2510
|
-
if (selected.needs_llm && activeModelId) body.model_name = activeModelId;
|
|
2511
|
-
const btn = $("d-submit"); btn.disabled = true;
|
|
2512
|
-
const d = await api("/api/dataset/submit", { method: "POST", body }).catch(() => ({ ok: false, error: "request failed" }));
|
|
2513
|
-
btn.disabled = false;
|
|
2514
|
-
if (!d.ok) { toast(d.error || "could not start generation", "err"); return; }
|
|
2515
|
-
jobId = d.job_id || null;
|
|
2516
|
-
$("d-push-panel").innerHTML = "";
|
|
2517
|
-
$("d-out").textContent = d.output_dir || "";
|
|
2518
|
-
log = []; $("d-log").textContent = ""; $("d-bar").style.width = "0%"; $("d-pct").textContent = "0%"; $("d-msg").textContent = "Starting…";
|
|
2519
|
-
$("d-ntrain").textContent = $("d-nvalid").textContent = "—";
|
|
2520
|
-
show(2);
|
|
2521
|
-
attach(d.job_id);
|
|
2522
|
-
}
|
|
2523
|
-
|
|
2524
|
-
function attach(jobId) {
|
|
2525
|
-
if (es) es.close();
|
|
2526
|
-
es = jobStream(jobId, {
|
|
2527
|
-
log: (e) => { log.push(e.line); $("d-log").textContent = log.slice(-200).join("\n"); $("d-log").scrollTop = $("d-log").scrollHeight; },
|
|
2528
|
-
stage: (e) => {
|
|
2529
|
-
if (e.progress != null) { const p = Math.round(e.progress * 100); $("d-bar").style.width = p + "%"; $("d-pct").textContent = p + "%"; }
|
|
2530
|
-
if (e.message) $("d-msg").textContent = e.message;
|
|
2531
|
-
if (e.n_train != null) $("d-ntrain").textContent = num(e.n_train);
|
|
2532
|
-
if (e.n_valid != null) $("d-nvalid").textContent = num(e.n_valid);
|
|
2533
|
-
if (e.stage === "done") { if (e.output_dir) $("d-out").textContent = e.output_dir; finish(e); }
|
|
2534
|
-
},
|
|
2535
|
-
done: (e) => finish(e),
|
|
2536
|
-
failed: (e) => { $("d-msg").textContent = "Failed: " + (e.error || "unknown error"); toast(e.error || "generation failed", "err"); es && es.close(); },
|
|
2537
|
-
});
|
|
2538
|
-
}
|
|
2539
|
-
function finish(e) {
|
|
2540
|
-
$("d-bar").style.width = "100%"; $("d-pct").textContent = "100%"; es && es.close();
|
|
2541
|
-
if (e && e.n_train != null) $("d-ntrain").textContent = num(e.n_train);
|
|
2542
|
-
if (e && e.n_valid != null) $("d-nvalid").textContent = num(e.n_valid);
|
|
2543
|
-
show(3);
|
|
2544
|
-
}
|
|
2545
|
-
|
|
2546
|
-
return {
|
|
2547
|
-
init() {
|
|
2548
|
-
show(0);
|
|
2549
|
-
loadTemplates();
|
|
2550
|
-
document.querySelectorAll("#s-dataset [data-dback]").forEach((b) => b.onclick = () => show(0));
|
|
2551
|
-
$("d-submit").onclick = submit;
|
|
2552
|
-
$("d-push").onclick = () => pushToHub($("d-push-panel"), { kind: "dataset", job_id: jobId });
|
|
2553
|
-
$("d-again").onclick = () => { selected = null; $("d-push-panel").innerHTML = ""; show(0); };
|
|
2554
|
-
},
|
|
2555
|
-
};
|
|
2556
|
-
})();
|
|
2557
|
-
|
|
2558
|
-
/* ════════════════════════════════════════════════════════════════════
|
|
2559
|
-
STATUS CONTROLLER — ported dashboard (poll /stats + /v1/models + /fit + /library + /downloads)
|
|
2560
|
-
════════════════════════════════════════════════════════════════════ */
|
|
2561
|
-
controllers.status = (() => {
|
|
2562
|
-
let timer = null, fitLoaded = false;
|
|
2563
|
-
const dlRates = {};
|
|
2564
|
-
|
|
2565
|
-
async function tick() {
|
|
2566
|
-
try {
|
|
2567
|
-
const [stats, models] = await Promise.all([
|
|
2568
|
-
fetch("/stats").then((r) => r.json()),
|
|
2569
|
-
fetch("/v1/models").then((r) => r.json()),
|
|
2570
|
-
]);
|
|
2571
|
-
const m = models.data && models.data[0];
|
|
2572
|
-
$("st-model-id").textContent = (m && m.id) || "—";
|
|
2573
|
-
const a = stats.admission || {};
|
|
2574
|
-
$("st-model-meta").textContent = num(m && m.context_window) + " token window · weights " + gb(a.weights_bytes);
|
|
2575
|
-
$("st-safe-ctx").textContent = num(a.max_safe_context);
|
|
2576
|
-
|
|
2577
|
-
$("st-weights").textContent = gb(a.weights_bytes);
|
|
2578
|
-
$("st-usable").textContent = gb(a.usable_bytes);
|
|
2579
|
-
$("st-budget").textContent = a.memory_budget_bytes ? gb(a.memory_budget_bytes) : "off";
|
|
2580
|
-
$("st-mem-bar").style.width = a.usable_bytes ? Math.min(100, 100 * a.weights_bytes / a.usable_bytes) + "%" : "0%";
|
|
2581
|
-
|
|
2582
|
-
const pc = stats.prompt_cache || {};
|
|
2583
|
-
const lookups = (pc.hits || 0) + (pc.misses || 0);
|
|
2584
|
-
$("st-pc-hits").textContent = lookups ? Math.round(100 * pc.hits / lookups) + "%" : "no lookups";
|
|
2585
|
-
$("st-pc-entries").textContent = num(pc.entries);
|
|
2586
|
-
$("st-pc-bytes").textContent = mb(pc.bytes) + " / " + mb(pc.max_bytes);
|
|
2587
|
-
$("st-pc-bar").style.width = pc.max_bytes ? Math.min(100, 100 * pc.bytes / pc.max_bytes) + "%" : "0%";
|
|
2588
|
-
|
|
2589
|
-
const rs = stats.response_store || {};
|
|
2590
|
-
$("st-rs-entries").textContent = num(rs.entries);
|
|
2591
|
-
$("st-rs-bytes").textContent = mb(rs.bytes) + " / " + mb(rs.max_bytes);
|
|
2592
|
-
$("st-rs-ttl").textContent = rs.ttl_ms ? (rs.ttl_ms / 60000) + " min" : "—";
|
|
2593
|
-
$("st-rs-bar").style.width = rs.max_bytes ? Math.min(100, 100 * rs.bytes / rs.max_bytes) + "%" : "0%";
|
|
2594
|
-
|
|
2595
|
-
const kv = stats.kv_quant || {};
|
|
2596
|
-
$("st-kv-mode").innerHTML = "<code>" + esc(kv.mode || "—") + "</code>";
|
|
2597
|
-
const att = kv.attention;
|
|
2598
|
-
$("st-kv-layers").innerHTML = Object.entries(kv.layers || {})
|
|
2599
|
-
.map(([k, v]) => '<div class="kv"><b>' + esc(k) + ' layers</b><span class="num">' + esc(v) + "</span></div>").join("") +
|
|
2600
|
-
(att ? '<div class="kv"><b>attention</b><span class="num">' + esc(att.global) + " global · " + esc(att.sliding_window) + " sliding</span></div>" : "");
|
|
2601
|
-
|
|
2602
|
-
await loadDownloads();
|
|
2603
|
-
if (!fitLoaded) { await loadFit(); await loadLibrary(); }
|
|
2604
|
-
if (stats.server && stats.server.started_at) {
|
|
2605
|
-
const up = (Date.now() - stats.server.started_at) / 1000;
|
|
2606
|
-
$("st-uptime").textContent = up < 90 ? Math.round(up) + "s" : up < 5400 ? Math.round(up / 60) + "m" : (up / 3600).toFixed(1) + "h";
|
|
2607
|
-
}
|
|
2608
|
-
$("st-updated").textContent = new Date().toLocaleTimeString();
|
|
2609
|
-
} catch {
|
|
2610
|
-
/* nav pill already reflects connectivity; leave last-good values on screen */
|
|
2611
|
-
}
|
|
2612
|
-
}
|
|
2613
|
-
|
|
2614
|
-
async function loadDownloads() {
|
|
2615
|
-
let downloads;
|
|
2616
|
-
try { ({ downloads } = await fetch("/downloads").then((r) => r.json())); } catch { return; }
|
|
2617
|
-
const card = $("st-dl-card");
|
|
2618
|
-
if (!downloads || downloads.length === 0) { card.style.display = "none"; return; }
|
|
2619
|
-
card.style.display = "";
|
|
2620
|
-
const order = downloads.slice().sort((a, b) =>
|
|
2621
|
-
(a.state === "active" ? 0 : 1) - (b.state === "active" ? 0 : 1) || b.startedAt - a.startedAt);
|
|
2622
|
-
$("st-dl-list").innerHTML = order.map((d) => {
|
|
2623
|
-
const pct = d.totalBytes ? Math.min(100, 100 * d.receivedBytes / d.totalBytes) : 0;
|
|
2624
|
-
let detail, barCls = "";
|
|
2625
|
-
if (d.state === "active") {
|
|
2626
|
-
let bps = d.bytesPerSec || 0;
|
|
2627
|
-
if (!bps) {
|
|
2628
|
-
const prev = dlRates[d.repoId], now = performance.now();
|
|
2629
|
-
if (prev && now > prev.t) bps = (d.receivedBytes - prev.bytes) / ((now - prev.t) / 1000);
|
|
2630
|
-
dlRates[d.repoId] = { bytes: d.receivedBytes, t: now };
|
|
2631
|
-
}
|
|
2632
|
-
const speed = bps > 0 ? " · " + mb(bps) + "/s · ~" + Math.max(1, Math.round((d.totalBytes - d.receivedBytes) / bps / 60)) + " min left" : "";
|
|
2633
|
-
detail = gb(d.receivedBytes) + " / " + gb(d.totalBytes) + " · " + pct.toFixed(0) + "%" + speed +
|
|
2634
|
-
(d.currentFile ? " · <code>" + esc(d.currentFile) + "</code>" : "");
|
|
2635
|
-
barCls = "warm";
|
|
2636
|
-
} else if (d.state === "done") {
|
|
2637
|
-
detail = gb(d.totalBytes) + ' · <span style="color:var(--green);font-weight:600">complete</span>';
|
|
2638
|
-
} else {
|
|
2639
|
-
detail = '<span style="color:var(--red);font-weight:600">error</span> · ' + esc(d.error || "");
|
|
2640
|
-
}
|
|
2641
|
-
return '<div style="padding:10px 0">' +
|
|
2642
|
-
'<div class="kv" style="border:none"><b>' + esc(d.repoId) + "</b>" +
|
|
2643
|
-
'<span class="num">' + d.filesDone + "/" + d.filesTotal + " files</span></div>" +
|
|
2644
|
-
'<div style="font-size:13.5px;color:var(--dim)" class="num">' + detail + "</div>" +
|
|
2645
|
-
'<div class="meter ' + barCls + '"><i style="width:' + pct + '%"></i></div></div>';
|
|
2646
|
-
}).join("");
|
|
2647
|
-
}
|
|
2648
|
-
|
|
2649
|
-
async function loadLibrary() {
|
|
2650
|
-
let models;
|
|
2651
|
-
try { ({ models } = await fetch("/library").then((r) => r.json())); } catch { return; }
|
|
2652
|
-
if (!models) return;
|
|
2653
|
-
$("st-lib-body").innerHTML = models.slice()
|
|
2654
|
-
.sort((a, b) => (b.serving ? 1 : 0) - (a.serving ? 1 : 0) || b.size_bytes - a.size_bytes)
|
|
2655
|
-
.map((m) => {
|
|
2656
|
-
const a = m.assessment;
|
|
2657
|
-
let status;
|
|
2658
|
-
if (m.serving) status = '<span style="color:var(--blue);font-weight:700">SERVING</span>';
|
|
2659
|
-
else if (!m.supported) status = '<span style="color:var(--dimmer)">unsupported (' + esc(m.model_type) + ")</span>";
|
|
2660
|
-
else if (a && a.fits) status = '<span style="color:var(--green);font-weight:600">fits</span>';
|
|
2661
|
-
else status = '<span style="color:var(--red);font-weight:600">too big</span>';
|
|
2662
|
-
return "<tr" + (m.serving ? ' class="you"' : "") + "><td>" + esc(m.repo_id) +
|
|
2663
|
-
(m.vision ? ' · <span style="color:var(--dim)">vision</span>' : "") +
|
|
2664
|
-
'</td><td class="num">' + gb(m.size_bytes) + "</td><td>" + status + "</td>" +
|
|
2665
|
-
'<td class="num">' + (m.supported && a && a.fits ? num(a.max_safe_context) : "—") + "</td>" +
|
|
2666
|
-
'<td class="num">' + (m.supported && a && a.fits ? a.predicted_decode_tps.toFixed(0) + " tok/s" : "—") + "</td></tr>";
|
|
2667
|
-
}).join("");
|
|
2668
|
-
}
|
|
2669
|
-
|
|
2670
|
-
async function loadFit() {
|
|
2671
|
-
let f;
|
|
2672
|
-
try { f = await fetch("/fit").then((r) => r.json()); } catch { return; }
|
|
2673
|
-
fitLoaded = true;
|
|
2674
|
-
const r = f.report || {};
|
|
2675
|
-
if (f.measured_decode_tps) {
|
|
2676
|
-
$("st-tps").textContent = f.measured_decode_tps.toFixed(1);
|
|
2677
|
-
$("st-tps").closest(".card").querySelector("h3").textContent = "Decode · measured";
|
|
2678
|
-
$("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";
|
|
2679
|
-
} else if (f.typical_decode_tps) {
|
|
2680
|
-
$("st-tps").textContent = f.typical_decode_tps.toFixed(1);
|
|
2681
|
-
$("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)";
|
|
2682
|
-
} else {
|
|
2683
|
-
$("st-tps").textContent = (r.predicted_decode_tps || 0).toFixed(1);
|
|
2684
|
-
}
|
|
2685
|
-
$("st-m-ram").textContent = gb(f.machine && f.machine.ram_bytes);
|
|
2686
|
-
$("st-m-bw").textContent = (f.machine && f.machine.bandwidth_gbs || "—") + " GB/s";
|
|
2687
|
-
$("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>';
|
|
2688
|
-
$("st-fit-ctx").textContent = num(f.context_tokens);
|
|
2689
|
-
$("st-f-weights").textContent = gb(r.weights_bytes);
|
|
2690
|
-
$("st-f-kv").textContent = gb(r.kv_bytes);
|
|
2691
|
-
$("st-f-transient").textContent = gb(r.transient_bytes);
|
|
2692
|
-
$("st-f-total").textContent = gb(r.total_bytes) + " / " + gb(r.usable_bytes);
|
|
2693
|
-
$("st-fit-bar").style.width = r.usable_bytes ? Math.min(100, 100 * r.total_bytes / r.usable_bytes) + "%" : "0%";
|
|
2694
|
-
|
|
2695
|
-
$("st-sku-ctx").textContent = num(f.sku_matrix_ctx);
|
|
2696
|
-
const myRam = Math.round((f.machine && f.machine.ram_bytes || 0) / 2 ** 30);
|
|
2697
|
-
const myChip = (f.machine && f.machine.chip) || null;
|
|
2698
|
-
const rows = f.sku_matrix || [];
|
|
2699
|
-
let youIdx = rows.findIndex((r) => myChip && r.sku === myChip && r.ram_gb === myRam);
|
|
2700
|
-
if (youIdx < 0 && myChip) {
|
|
2701
|
-
let best = Infinity;
|
|
2702
|
-
rows.forEach((r, i) => { if (r.sku === myChip && Math.abs(r.ram_gb - myRam) < best) { best = Math.abs(r.ram_gb - myRam); youIdx = i; } });
|
|
2703
|
-
}
|
|
2704
|
-
if (youIdx < 0) youIdx = rows.findIndex((r) => r.ram_gb === myRam);
|
|
2705
|
-
$("st-sku-body").innerHTML = rows.map((row, i) => {
|
|
2706
|
-
const you = i === youIdx;
|
|
2707
|
-
const dimCls = row.fits ? "num" : "num fit-no";
|
|
2708
|
-
return "<tr" + (you ? ' class="you"' : "") + "><td>" + esc(row.sku) + (you ? " · <b>this machine</b>" : "") +
|
|
2709
|
-
'</td><td class="num">' + row.ram_gb + " GB</td>" +
|
|
2710
|
-
'<td class="' + (row.fits ? "fit-yes" : "fit-no") + '">' + (row.fits ? "fits" : "no") + "</td>" +
|
|
2711
|
-
'<td class="' + dimCls + '">' + (row.max_context > 0 ? num(row.max_context) : "0 — weights alone don’t fit") + "</td>" +
|
|
2712
|
-
'<td class="' + dimCls + '">' + (row.decode_tps > 0 ? "~" + row.decode_tps.toFixed(0) + " tok/s" : "—") + "</td></tr>";
|
|
2713
|
-
}).join("");
|
|
2714
|
-
}
|
|
2100
|
+
<!-- App-aware assistant spotlight overlay (plan §6.6, §9 Phase 3, beat
|
|
2101
|
+
matrix Axis 12) — built/positioned by assistant.ts's showSpotlight().
|
|
2102
|
+
data-ui-chrome="assistant" excludes it (and only it) from the DOM
|
|
2103
|
+
snapshot the assistant itself reads, so it never spotlights or
|
|
2104
|
+
narrates its own overlay. NEVER-HIJACK (verbatim §6.6): the whole
|
|
2105
|
+
overlay is pointer-events:none in CSS below — the highlighted control
|
|
2106
|
+
stays clickable straight through the ring and popover. It traps no
|
|
2107
|
+
focus (deliberately absent from shell.ts's trapFocus/closeTopOverlay
|
|
2108
|
+
set — this is not a modal). -->
|
|
2109
|
+
<div id="assistant-spotlight" data-ui-chrome="assistant" aria-hidden="true">
|
|
2110
|
+
<div class="asr-ring"></div>
|
|
2111
|
+
<div class="asr-pop"><div class="asr-pop-text"></div></div>
|
|
2112
|
+
</div>
|
|
2715
2113
|
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
pollIdentity();
|
|
2734
|
-
setInterval(pollIdentity, 4000);
|
|
2735
|
-
</script>
|
|
2114
|
+
<!-- Vendored syntax highlighter (src/web/vendor/hljs.js — highlight.js core +
|
|
2115
|
+
a curated language set, BSD-3-Clause, see that file's header). `defer`
|
|
2116
|
+
means this loads/executes at DOMContentLoaded, which happens well before
|
|
2117
|
+
any real highlighting call (those only fire later, on streamed content
|
|
2118
|
+
or history replay) — app.js (below) only *calls* highlightIn() from
|
|
2119
|
+
async event handlers, so load order here is safe in practice.
|
|
2120
|
+
highlightIn() still guards with `typeof hljs !== "undefined"` for the
|
|
2121
|
+
case this tag is ever stripped or the asset 404s. No CDN; served by
|
|
2122
|
+
src/server.ts at /assets/hljs.js. -->
|
|
2123
|
+
<script defer src="/assets/hljs.js"></script>
|
|
2124
|
+
<!-- The app itself: GENERATED bundle of src/web/src/*.ts (plan §7/§9 Phase 2
|
|
2125
|
+
module split), built by `bun scripts/build-web.ts` into src/web/app.js
|
|
2126
|
+
and served by src/server.ts at /assets/app.js — see that file's header
|
|
2127
|
+
comment. `defer` (like hljs.js above) means both scripts run in document
|
|
2128
|
+
order at DOMContentLoaded, after the DOM this script queries with $()
|
|
2129
|
+
is fully parsed. Edit src/web/src/*.ts, never this bundle or this tag. -->
|
|
2130
|
+
<script defer src="/assets/app.js"></script>
|
|
2736
2131
|
</body>
|
|
2737
2132
|
</html>
|