mlx-bun 0.0.10 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +14 -6
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +3 -2
  27. package/docs/design/ssd-kv-cold-tier.md +130 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/dspark-handoff.md +182 -16
  35. package/docs/investigations/lab-build-journal.md +2 -2
  36. package/docs/investigations/openwiki-evaluation.md +249 -0
  37. package/docs/planning/IDEAS.md +14 -0
  38. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  39. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  40. package/docs/planning/chat-ui-vision.md +50 -0
  41. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  42. package/docs/planning/release-notes-v0.0.11.md +239 -0
  43. package/docs/reference/cli.md +35 -16
  44. package/docs/reference/distribution.md +13 -6
  45. package/docs/reference/embedding.md +35 -14
  46. package/docs/reference/features-matrix.md +97 -0
  47. package/docs/reference/library-api.md +30 -9
  48. package/docs/reference/memory.md +26 -6
  49. package/docs/reference/models.md +11 -1
  50. package/docs/reference/orpo-quickstart.md +3 -4
  51. package/docs/reference/server-api.md +634 -22
  52. package/docs/reference/server-config.md +250 -121
  53. package/docs/reference/training.md +14 -15
  54. package/package.json +5 -3
  55. package/src/anthropic.ts +13 -0
  56. package/src/audio/conformer.ts +647 -0
  57. package/src/audio/decode.ts +133 -0
  58. package/src/audio/features.ts +202 -0
  59. package/src/audio/transcode.ts +67 -0
  60. package/src/cli.ts +356 -105
  61. package/src/config.ts +49 -2
  62. package/src/download.ts +40 -0
  63. package/src/eval/README.md +7 -6
  64. package/src/eval/kl.ts +59 -3
  65. package/src/eval/quality-db.ts +2 -2
  66. package/src/eval/runner.ts +3 -3
  67. package/src/expert-offload.ts +15 -2
  68. package/src/flags.ts +13 -0
  69. package/src/generate.ts +330 -24
  70. package/src/grammar.ts +585 -0
  71. package/src/hub-rest.ts +242 -0
  72. package/src/kv-store.ts +442 -88
  73. package/src/lora.ts +8 -0
  74. package/src/md-modules.d.ts +42 -0
  75. package/src/media-fetch.ts +284 -0
  76. package/src/memory/rest.ts +245 -0
  77. package/src/memory/vault.ts +74 -0
  78. package/src/mlx/array.ts +106 -19
  79. package/src/mlx/autograd.ts +2 -2
  80. package/src/mlx/ffi.ts +27 -3
  81. package/src/mlx/metal-kernel.ts +7 -4
  82. package/src/mlx/ops.ts +153 -23
  83. package/src/mlx/turboquant-ops.ts +831 -0
  84. package/src/mlx/turboquant-tables.ts +237 -0
  85. package/src/model/batched-mask.ts +62 -1
  86. package/src/model/batched-quant.ts +206 -0
  87. package/src/model/batched-rotating-quant.ts +346 -0
  88. package/src/model/batched-rotating.ts +46 -1
  89. package/src/model/compiled-decode.ts +4 -15
  90. package/src/model/factory.ts +10 -4
  91. package/src/model/gemma4-base.ts +363 -39
  92. package/src/model/gemma4.ts +122 -33
  93. package/src/model/generated/gemma4-12b.ts +9 -13
  94. package/src/model/generated/gemma4-26b.ts +10 -5
  95. package/src/model/generated/gemma4-e4b.ts +14 -9
  96. package/src/model/minicpm5.ts +58 -102
  97. package/src/model/paged-kv.ts +253 -0
  98. package/src/model/qwen3-delta.ts +76 -15
  99. package/src/model/qwen3-moe.ts +404 -0
  100. package/src/model/qwen3.ts +9 -8
  101. package/src/model/qwen3_5.ts +88 -23
  102. package/src/model/support.ts +12 -1
  103. package/src/model/universal/dense.ts +40 -5
  104. package/src/model/universal/rope.ts +36 -21
  105. package/src/pi-web.ts +1299 -82
  106. package/src/preflight.ts +27 -8
  107. package/src/prompt-cache.ts +293 -33
  108. package/src/quantize/quantizer.ts +16 -1
  109. package/src/registry.ts +57 -2
  110. package/src/serve/batch-scheduler.ts +965 -72
  111. package/src/serve/generation-gateway.ts +214 -11
  112. package/src/serve/isolate.ts +417 -0
  113. package/src/serve/lane-registry.ts +44 -0
  114. package/src/serve/session-search.ts +326 -0
  115. package/src/server.ts +1380 -135
  116. package/src/spec/assistant-source.ts +150 -0
  117. package/src/spec/deepspec-source.ts +143 -0
  118. package/src/spec/dflash-source.ts +124 -0
  119. package/src/spec/dspark/ab-stats.ts +136 -0
  120. package/src/spec/dspark/calibration.ts +112 -0
  121. package/src/spec/dspark/deepspec-module.ts +801 -0
  122. package/src/spec/dspark/generate-dflash.ts +72 -20
  123. package/src/spec/dspark/loader.ts +23 -0
  124. package/src/spec/dspark/module-dflash.ts +278 -36
  125. package/src/spec/dspark/quantize-drafter.ts +91 -0
  126. package/src/spec/generate.ts +59 -25
  127. package/src/spec/ngram-source.ts +132 -0
  128. package/src/spec/serve-loop.ts +536 -0
  129. package/src/spec/source.ts +93 -0
  130. package/src/spec/two-model.ts +171 -0
  131. package/src/ssd-cache.ts +107 -35
  132. package/src/tokenizer.ts +288 -2
  133. package/src/tool-approvals.ts +131 -0
  134. package/src/tool-call.ts +408 -20
  135. package/src/train/flash-cce.ts +2 -2
  136. package/src/train/job.ts +0 -2
  137. package/src/train/trainer.ts +1 -4
  138. package/src/vision/prompt.ts +217 -48
  139. package/src/web/app.html +1184 -1789
  140. package/src/web/app.js +5667 -0
  141. package/src/web/icon.svg +13 -0
  142. package/src/web/manifest.webmanifest +24 -0
  143. package/src/web/src/adapters-panel.ts +288 -0
  144. package/src/web/src/api.ts +57 -0
  145. package/src/web/src/assistant.ts +485 -0
  146. package/src/web/src/chat.ts +1050 -0
  147. package/src/web/src/composer.ts +959 -0
  148. package/src/web/src/dataset.ts +132 -0
  149. package/src/web/src/finetune.ts +267 -0
  150. package/src/web/src/hub.ts +428 -0
  151. package/src/web/src/main.ts +98 -0
  152. package/src/web/src/markdown.ts +0 -0
  153. package/src/web/src/memory-panel.ts +572 -0
  154. package/src/web/src/model-picker.ts +159 -0
  155. package/src/web/src/palette.ts +346 -0
  156. package/src/web/src/protocol.ts +62 -0
  157. package/src/web/src/quantize.ts +167 -0
  158. package/src/web/src/rag.ts +274 -0
  159. package/src/web/src/sessions.ts +469 -0
  160. package/src/web/src/shell.ts +804 -0
  161. package/src/web/src/status.ts +252 -0
  162. package/src/web/src/ui-catalog.ts +92 -0
  163. package/src/web/sw.js +71 -0
  164. package/src/web/vendor/README.md +61 -0
  165. package/src/web/vendor/hljs-LICENSE +29 -0
  166. package/src/web/vendor/hljs-theme.css +59 -0
  167. package/src/web/vendor/hljs.js +26 -0
  168. package/src/model/fused-decode-kernel.ts +0 -312
  169. package/src/model/fused-geglu-kernel.ts +0 -198
  170. package/src/model/fused-mlp-kernel.ts +0 -169
  171. package/src/model/fused-swiglu-kernel.ts +0 -240
  172. package/src/model/steel-linear-kernel.ts +0 -96
  173. package/src/status-page.html +0 -497
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(255,255,255,.08);padding:2px 8px;border-radius:6px}
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:rgba(255,255,255,.12);border-radius:6px;border:2px solid transparent;background-clip:content-box}
49
- ::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22);background-clip:content-box}
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,.13), transparent 68%),
64
- radial-gradient(60vw 48vh at 88% 24%, rgba(94,92,230,.16), transparent 68%),
65
- radial-gradient(50vw 40vh at 30% 96%, rgba(10,132,255,.10), transparent 68%);
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:rgba(0,0,0,.62);backdrop-filter:saturate(180%) blur(22px);
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:rgba(255,255,255,.28)}
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:rgba(255,255,255,.28);color:var(--ink)}
113
- #chat-sampling.on{color:var(--ink);border-color:rgba(255,255,255,.28)}
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
- background:rgba(30,30,34,.98);border:1px solid rgba(255,255,255,.12);border-radius:13px;padding:13px 14px;
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,.7);display:none;
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 0 10px}
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:rgba(255,255,255,.28)}
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:rgba(255,255,255,.28)}
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:rgba(0,0,0,.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);padding:calc(var(--nav-h) + 24px) 20px 20px}
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:rgba(22,22,24,.96);border:1px solid var(--hairline);border-radius:20px;
155
- padding:24px 26px;box-shadow:0 30px 80px rgba(0,0,0,.6)}
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){[data-reveal]{transition:none;opacity:1;transform:none}*{scroll-behavior:auto}}
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:rgba(0,0,0,.42);border:1px solid var(--hairline-soft);border-radius:14px;
297
- padding:14px 16px;max-height:340px;overflow-y:auto;font:11.5px/1.55 var(--mono);color:var(--dim);white-space:pre-wrap;word-break:break-word}
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:rgba(30,30,32,.92);backdrop-filter:blur(20px);border:1px solid var(--hairline);
309
- border-radius:13px;padding:12px 18px;font-size:14px;font-weight:500;box-shadow:0 16px 50px rgba(0,0,0,.6);
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
- @media (max-width:760px){#chat-sidebar{display:none}}
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 inside bubbles */
361
- .bubble p{margin:0 0 10px}.bubble p:last-child{margin-bottom:0}
362
- .bubble ul,.bubble ol{margin:0 0 10px;padding-left:22px}
363
- .bubble li{margin:3px 0}
364
- .bubble strong{font-weight:700;color:var(--ink)}
365
- .bubble em{font-style:italic}
366
- .bubble code{font-family:var(--mono);font-size:.86em;background:rgba(255,255,255,.10);padding:2px 7px;border-radius:6px}
367
- .bubble pre{background:rgba(0,0,0,.45);border:1px solid var(--hairline-soft);border-radius:12px;
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,.bubble h2,.bubble h3,.bubble h4,.bubble h5,.bubble h6{font-weight:700;margin:14px 0 7px;letter-spacing:-.01em;line-height:1.3}
371
- .bubble h1{font-size:1.28em}.bubble h2{font-size:1.16em}.bubble h3{font-size:1.06em}
372
- .bubble h4,.bubble h5,.bubble h6{font-size:1em;color:var(--dim)}
373
- .bubble h1:first-child,.bubble h2:first-child,.bubble h3:first-child{margin-top:0}
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,.bubble table.md-table td{border:1px solid var(--hairline-soft);padding:6px 11px;text-align:left}
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:rgba(0,0,0,.45)}
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:var(--dim);letter-spacing:.02em}
389
- .bubble .cbcopy{font:11px/1 var(--mono);color:var(--dim);background:transparent;border:1px solid var(--hairline);border-radius:6px;padding:4px 10px;cursor:pointer;transition:color .15s,border-color .15s}
390
- .bubble .cbcopy:hover{color:var(--ink);border-color:var(--dim)}
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:rgba(0,0,0,.4);border-radius:9px;padding:10px 12px;overflow-x:auto;
416
- font:12px/1.55 var(--mono);color:var(--dim);white-space:pre-wrap;word-break:break-word;max-height:260px;overflow-y:auto}
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:rgba(0,0,0,.45);border-radius:10px;padding:12px 14px;overflow-x:auto;
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:rgba(255,255,255,.32);background:var(--card-hover)}
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:rgba(0,0,0,.4);backdrop-filter:blur(16px)}
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;background:var(--card);border:1px solid var(--hairline);border-radius:16px;
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
- .chat-hint{max-width:840px;margin:8px auto 0;font-size:11.5px;color:var(--dimmer);display:flex;justify-content:space-between;gap:10px}
466
- .chat-hint .hint-right{display:flex;gap:12px;align-items:center}
467
- #chat-context{font:11px/1 var(--mono);letter-spacing:.01em;white-space:nowrap}
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
- .chart-wrap{position:relative;background:rgba(0,0,0,.32);border:1px solid var(--hairline-soft);border-radius:14px;padding:14px;margin-top:6px}
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
- <a class="tab" href="/curves" data-tab="curves">Curves</a>
521
- <a class="tab" href="#/routes" data-tab="routes">Routes</a>
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
- <span class="model" id="nav-model" title="active model">connecting…</span>
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
- <!-- ═══════════════════ HF SETTINGS OVERLAY ═══════════════════ -->
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">Hugging Face</div>
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
- <p class="hf-modal-sub" id="hf-state">Checking for a saved token…</p>
541
- <div class="field" style="margin-bottom:10px">
542
- <label>Write token</label>
543
- <input type="password" id="hf-token-input" placeholder="hf_…" autocomplete="off" spellcheck="false">
544
- <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>
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
- <div id="hf-settings-msg"></div>
547
- <div class="btnrow" style="margin-top:14px">
548
- <button class="btn primary sm" id="hf-save">Save token</button>
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)">+&nbsp; New chat</button>
1438
+ <button id="chat-new" data-spotlight="new-chat" title="Start a new chat (or type /new)">+&nbsp; 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
- <div id="chat-thread"></div>
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
- <textarea id="chat-box" rows="1" placeholder="Message the local agent… (Enter to send · Shift+Enter for newline)"></textarea>
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
- <h4>Sampling</h4>
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 the recommended value — temperature follows the thinking toggle. Reset returns to defaults.</span>
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
- <script>
1046
- /* ════════════════════════════════════════════════════════════════════
1047
- SHARED HELPERS
1048
- ════════════════════════════════════════════════════════════════════ */
1049
- const $ = (id) => document.getElementById(id);
1050
- const gb = (b) => b == null ? "—" : (b / 2 ** 30).toFixed(2) + " GB";
1051
- const mb = (b) => b == null ? "—" : (b / 2 ** 20).toFixed(1) + " MB";
1052
- const num = (n) => n == null ? "—" : Math.round(n).toLocaleString();
1053
- const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
1054
-
1055
- /** Build a DOM node quickly. */
1056
- function el(tag, cls, parent) {
1057
- const e = document.createElement(tag);
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+&quot;[^)]*&quot;)?\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 &amp; 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
- return {
2717
- init() {
2718
- // trigger scroll reveals once mounted
2719
- const page = $("status-page");
2720
- requestAnimationFrame(() => page.classList.add("lit"));
2721
- page.classList.add("lit");
2722
- },
2723
- enter() { tick(); timer = setInterval(tick, 2000); },
2724
- leave() { clearInterval(timer); timer = null; },
2725
- };
2726
- })();
2727
-
2728
- /* ════════════════════════════════════════════════════════════════════
2729
- BOOT
2730
- ════════════════════════════════════════════════════════════════════ */
2731
- if (!location.hash) location.replace("#/chat");
2732
- router();
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>