dsh-local-models 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,1917 @@
1
+ /**
2
+ * dsh-local-models - browser half.
3
+ *
4
+ * Local Models settings tab: pick a .gguf via a file browser, choose context
5
+ * (8K-step slider + fine input), the KV cache types for K and V, and fixed MTP
6
+ * heads (0-7), see a live VRAM estimate against the detected GPU total, then
7
+ * Load / Stop /
8
+ * Register the server. The VRAM math is ported from the user's
9
+ * vram-calculator project.
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: "dsh-local-models",
13
+ factory: (require) => {
14
+ var module = { exports: {} };
15
+ var exports = module.exports;
16
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
17
+
18
+ var React = require("react");
19
+ var h = React.createElement;
20
+ var useState = React.useState, useEffect = React.useEffect, useCallback = React.useCallback, useMemo = React.useMemo;
21
+
22
+ // ---- VRAM math (ported from vram-calculator/src/vram.ts) ----
23
+ // KV cache quantization: the exact set upstream llama-server accepts
24
+ // (common/arg.cpp `kv_cache_types`), highest precision first, with the
25
+ // ggml bytes-per-element each one costs (block header included). The
26
+ // ids match lib/index.js; the defaults are the tuned 16 GiB pair.
27
+ var KV_TYPES = [
28
+ { id: "f32", bpe: 4 },
29
+ { id: "f16", bpe: 2 },
30
+ { id: "bf16", bpe: 2 },
31
+ { id: "q8_0", bpe: 34 / 32 },
32
+ { id: "q5_1", bpe: 24 / 32 },
33
+ { id: "q5_0", bpe: 22 / 32 },
34
+ { id: "q4_1", bpe: 20 / 32 },
35
+ { id: "iq4_nl", bpe: 18 / 32 },
36
+ { id: "q4_0", bpe: 18 / 32 },
37
+ ];
38
+ var KV_BPE = {};
39
+ for (var kvi = 0; kvi < KV_TYPES.length; kvi++) KV_BPE[KV_TYPES[kvi].id] = KV_TYPES[kvi].bpe;
40
+ var KV_DEFAULT_K = "q5_0", KV_DEFAULT_V = "q4_1";
41
+ function normalizeKvType(id, fallback) { return KV_BPE[id] != null ? id : fallback; }
42
+ // Thinking levels the loaded template accepts (mirrors THINKING_LEVELS
43
+ // in lib/index.js; the server's normalizeEffort is the authority).
44
+ var EFFORT_LEVELS = ["off", "low", "medium", "xhigh"];
45
+ function normalizeEffort(effort) { return EFFORT_LEVELS.indexOf(effort) >= 0 ? effort : "medium"; }
46
+ function kvLabel(id) { return String(id).toUpperCase(); }
47
+ function kvOptionLabel(t) { return t.id + " (" + t.bpe.toFixed(2) + " B/elt)"; }
48
+ var GRAPH_BASE = 24 * 1024 * 1024, SCRATCH_PER_TOKEN = 3072;
49
+ var HYBRID_GRAPH_BASE = 172 * 1024 * 1024, HYBRID_SCRATCH_PER_TOKEN = 1025;
50
+ var OVERHEAD_BYTES = 200 * 1024 * 1024;
51
+ // Fallback budget for the estimate: only used before the first status
52
+ // poll, or on hardware the node half could not identify. The real value
53
+ // comes from status.vramTotalBytes (detected, or pinned in the Runtime
54
+ // card). Mirrors VRAM_ASSUMED_BYTES in lib/index.js.
55
+ var TOTAL_VRAM_BYTES = 16 * 1024 * 1024 * 1024;
56
+ var SAFE_MARGIN_BYTES = 700 * 1024 * 1024;
57
+ var CTX_STEP = 8192, FALLBACK_MAX_CTX = 32768;
58
+ // Fixed-MTP ceiling (mirrors MTP_MAX in lib/index.js): upstream accepts any
59
+ // --spec-draft-n-max and clamps the effective depth to the model's nextn
60
+ // depth at load. 0 = off.
61
+ var MTP_MAX = 7;
62
+ var MTP_OPTIONS = [];
63
+ for (var mtpI = 0; mtpI <= MTP_MAX; mtpI++) MTP_OPTIONS.push(mtpI);
64
+ // Multi-GPU placement (mirrors SPLIT_MODES in lib/index.js). "layer" is
65
+ // llama.cpp's own default, so it emits no flag at all.
66
+ var SPLIT_MODES = [
67
+ { id: "layer", label: "layer — pipelined (default)" },
68
+ { id: "row", label: "row — tensor parallel" },
69
+ { id: "tensor", label: "tensor — experimental" },
70
+ { id: "none", label: "none — first GPU only" },
71
+ ];
72
+ var SPLIT_DEFAULT = "layer";
73
+ function normalizeSplitMode(id) {
74
+ for (var si = 0; si < SPLIT_MODES.length; si++) if (SPLIT_MODES[si].id === id) return id;
75
+ return SPLIT_DEFAULT;
76
+ }
77
+ // Even proportions across the detected GPUs, weighted by VRAM (mirrors
78
+ // evenTensorSplit() in lib/index.js).
79
+ function evenSplit(gpus) {
80
+ var list = (gpus || []).filter(function (g) { return g && g.vramBytes > 0; });
81
+ if (list.length < 2) return "";
82
+ var min = Math.min.apply(null, list.map(function (g) { return g.vramBytes; }));
83
+ return list.map(function (g) { return Math.max(1, Math.round(g.vramBytes / min)); }).join(",");
84
+ }
85
+ // Mirrors normalizeTensorSplit() in lib/index.js: proportions or "".
86
+ function normSplitText(v) {
87
+ if (typeof v !== "string") return "";
88
+ var parts = v.trim().split(/[,/\s]+/).filter(Boolean);
89
+ if (parts.length === 0) return "";
90
+ for (var pi = 0; pi < parts.length; pi++) {
91
+ var n = Number(parts[pi]);
92
+ if (!isFinite(n) || n <= 0) return "";
93
+ }
94
+ return parts.join(",");
95
+ }
96
+ function fmtVramGb(bytes) {
97
+ var gb = bytes / (1024 * 1024 * 1024);
98
+ var r = Math.round(gb * 10) / 10;
99
+ return String(r);
100
+ }
101
+
102
+ var DEFAULT_FULL_ATTN_SHARE = 1 / 6;
103
+
104
+ function gdnLayout(nLayers, fullAttnInterval, nextnPredictLayers) {
105
+ var nextn = nextnPredictLayers ?? 0;
106
+ var trunk = Math.max((nLayers ?? 0) - nextn, 0);
107
+ var interval = fullAttnInterval ?? 0;
108
+ var hasSsmSig = interval > 1;
109
+ var attnTrunk = hasSsmSig ? Math.floor(trunk / interval) : trunk;
110
+ return {
111
+ hybrid: hasSsmSig && nextn >= 0 && (nLayers ?? 0) > 0 && attnTrunk < trunk,
112
+ interval: interval || 1,
113
+ trunk: trunk,
114
+ attnTrunk: attnTrunk,
115
+ mtp: nextn,
116
+ };
117
+ }
118
+
119
+ function gdnRecurrentBytes(recrLayerCount, ssmStateSize, ssmInnerSize, ssmNGroup, ssmDtRank, ssmConvKernel, ssmF16) {
120
+ if (!recrLayerCount || !ssmStateSize || !ssmInnerSize) return 0;
121
+ var ssmBytes = ssmStateSize * ssmInnerSize * (ssmF16 ? 2 : 4);
122
+ var convDim = 2 * ssmStateSize * (ssmNGroup || 1) + ssmStateSize * (ssmDtRank || 1);
123
+ var convBytes = convDim * (ssmConvKernel || 4) * 4;
124
+ return recrLayerCount * (ssmBytes + convBytes);
125
+ }
126
+
127
+ function kvBytesFor(context, nLayers, nKvHeads, headDim, kvTypeK, kvTypeV, slidingWindow, fullAttnShare, kvLayersOverride) {
128
+ if (!nKvHeads || !headDim) return 0;
129
+ var bpeK = KV_BPE[kvTypeK] ?? 2;
130
+ var bpeV = KV_BPE[kvTypeV] ?? 2;
131
+ var perTokenPerLayer = nKvHeads * headDim * (bpeK + bpeV);
132
+ if (kvLayersOverride != null && kvLayersOverride > 0) {
133
+ return Math.floor(kvLayersOverride * context * perTokenPerLayer);
134
+ }
135
+ if (!nLayers) return 0;
136
+ var hasSwa = !!slidingWindow && slidingWindow > 0 && context > slidingWindow;
137
+ if (!hasSwa) {
138
+ return Math.floor(nLayers * context * perTokenPerLayer);
139
+ }
140
+ var share = fullAttnShare == null || fullAttnShare < 0 || fullAttnShare > 1 ? DEFAULT_FULL_ATTN_SHARE : fullAttnShare;
141
+ var fullLayers = nLayers * share;
142
+ var localLayers = nLayers - fullLayers;
143
+ var effLocal = Math.min(context, slidingWindow);
144
+ return Math.floor((fullLayers * context + localLayers * effLocal) * perTokenPerLayer);
145
+ }
146
+
147
+ function estimateComputeBytes(context, nVocab, hybrid) {
148
+ var vocab = Math.min(Math.max(nVocab ?? 32000, 1024), 262144);
149
+ var outputs = Math.min(512, context);
150
+ var logits = outputs * vocab * 4;
151
+ var base = hybrid ? HYBRID_GRAPH_BASE : GRAPH_BASE;
152
+ var perToken = hybrid ? HYBRID_SCRATCH_PER_TOKEN : SCRATCH_PER_TOKEN;
153
+ var scratch = perToken * Math.max(context - 1, 0) + base;
154
+ return Math.floor(logits + scratch);
155
+ }
156
+
157
+ function maxContextFor(targetBytes, input) {
158
+ if (!input.nKvHeads || !input.headDim) return null;
159
+ var hybrid = input.kvLayersOverride != null && input.kvLayersOverride > 0;
160
+ var fixedExtra = hybrid ? (input.recurrentFixedBytes ?? 0) : 0;
161
+ var base = hybrid ? HYBRID_GRAPH_BASE : GRAPH_BASE;
162
+ var perToken = hybrid ? HYBRID_SCRATCH_PER_TOKEN : SCRATCH_PER_TOKEN;
163
+ var vocab = Math.min(Math.max(input.nVocab ?? 32000, 1024), 262144);
164
+ var logitsConst = 512 * vocab * 4;
165
+ var computeBase = base + logitsConst;
166
+ var avail = targetBytes - input.weightsBytes - input.overheadBytes - computeBase - fixedExtra;
167
+ if (avail <= 0) return 0;
168
+ var fitsCtx = function (ctx) {
169
+ var kv = kvBytesFor(ctx, input.nLayers, input.nKvHeads, input.headDim, input.kvTypeK, input.kvTypeV, input.slidingWindow, input.fullAttnShare, input.kvLayersOverride);
170
+ return kv + perToken * ctx <= avail;
171
+ };
172
+ if (!fitsCtx(1)) return 0;
173
+ var lo = 1, hi = 2;
174
+ while (hi <= 1073741824 && fitsCtx(hi)) hi = hi * 2;
175
+ if (hi > 1073741824) return hi;
176
+ while (hi - lo > 1) {
177
+ var mid = (lo + hi) >> 1;
178
+ if (fitsCtx(mid)) lo = mid; else hi = mid;
179
+ }
180
+ return lo;
181
+ }
182
+
183
+ function fmtInt(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ","); }
184
+ function fmtCtx(n) { return n % 1024 === 0 ? fmtInt(n / 1024) + "K" : fmtInt(n); }
185
+ function fmtGiB(b) { return (b / (1024 * 1024 * 1024)).toFixed(2) + " GiB"; }
186
+ function fmtMiB(b) { return (b / (1024 * 1024)).toFixed(1) + " MiB"; }
187
+
188
+ // ---- tiny fetch helpers (never throw raw JSON.parse errors) ----
189
+ function parseJson(r) {
190
+ return r.json().catch(function () {
191
+ return { error: "empty or invalid response (HTTP " + r.status + ")" };
192
+ });
193
+ }
194
+ function get(path) {
195
+ return fetch(path).then(function (r) { return parseJson(r); });
196
+ }
197
+ function post(path, body) {
198
+ return fetch(path, {
199
+ method: "POST",
200
+ headers: { "content-type": "application/json" },
201
+ body: JSON.stringify(body),
202
+ }).then(function (r) {
203
+ return parseJson(r).then(function (parsed) { return { ok: r.ok, body: parsed }; });
204
+ });
205
+ }
206
+
207
+ // ---- shared hooks ----
208
+ // Shorthand for the file's dominant state pattern (one line per state).
209
+ function useStatePair(initial) {
210
+ var s = useState(initial);
211
+ return [s[0], s[1]];
212
+ }
213
+ // Poll fn every ms (plus once on mount). The latest fn is always
214
+ // used via a ref, so inline (non-memoized) callbacks don't tear
215
+ // down and rebuild the interval on every render.
216
+ function usePoll(fn, ms) {
217
+ var fnRef = React.useRef(fn);
218
+ fnRef.current = fn;
219
+ useEffect(function () {
220
+ var wrapped = function () { fnRef.current(); };
221
+ wrapped();
222
+ var timer = setInterval(wrapped, ms);
223
+ return function () { clearInterval(timer); };
224
+ }, [ms]);
225
+ }
226
+
227
+ // ---- styles ----
228
+ var style = {
229
+ page: { maxWidth: 720, display: "flex", flexDirection: "column", gap: 10, color: "var(--dsw-alias-label-primary)" },
230
+ title: { margin: 0, fontSize: 16, fontWeight: 500, lineHeight: "24px", color: "var(--dsw-alias-label-primary)" },
231
+ intro: { margin: 0, fontSize: 14, lineHeight: "22px", color: "var(--dsw-alias-label-tertiary)" },
232
+ card: { border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 12, padding: "10px 12px", display: "flex", flexDirection: "column", gap: 8 },
233
+ cardTitle: { margin: 0, fontSize: 13, fontWeight: 500, color: "var(--dsw-alias-label-secondary)" },
234
+ row: { display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" },
235
+ button: { boxSizing: "border-box", height: 34, font: "inherit", cursor: "pointer", border: "none", borderRadius: 17, padding: "0 14px", fontSize: 14, lineHeight: "22px", background: "var(--dsw-alias-button-primary-fill)", color: "var(--dsw-alias-label-primary-foreground)" },
236
+ secondaryButton: { boxSizing: "border-box", height: 34, font: "inherit", cursor: "pointer", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 17, padding: "0 14px", fontSize: 14, lineHeight: "22px", background: "transparent", color: "var(--dsw-alias-label-primary)" },
237
+ disabled: { opacity: 0.4, cursor: "default" },
238
+ small: { fontSize: 12, lineHeight: "18px", color: "var(--dsw-alias-label-tertiary)" },
239
+ mono: { fontFamily: "var(--ds-font-family-code)", fontSize: 12, lineHeight: "18px" },
240
+ input: { boxSizing: "border-box", border: "1px solid var(--dsw-alias-border-l2)", width: 150, height: 32, font: "inherit", background: "var(--dsw-alias-bg-layer-1)", color: "var(--dsw-alias-label-primary)", borderRadius: 8, padding: "0 10px", fontSize: 14 },
241
+ range: { width: 280, accentColor: "var(--dsw-alias-button-primary-fill)" },
242
+ select: { boxSizing: "border-box", border: "1px solid var(--dsw-alias-border-l2)", height: 32, font: "inherit", background: "var(--dsw-alias-bg-layer-1)", color: "var(--dsw-alias-label-primary)", borderRadius: 8, padding: "0 8px", fontSize: 14 },
243
+ error: { margin: 0, fontSize: 12, lineHeight: "18px", color: "var(--dsw-alias-state-error-primary)" },
244
+ warn: { margin: 0, fontSize: 12, lineHeight: "18px", color: "var(--dsw-alias-state-warn-label)" },
245
+ good: { color: "var(--dsw-alias-state-success-primary)", fontWeight: 500 },
246
+ bad: { color: "var(--dsw-alias-state-error-primary)", fontWeight: 500 },
247
+ typedef: { display: "flex", justifyContent: "space-between", gap: 12 },
248
+ overlay: { position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "flex-start", justifyContent: "center", zIndex: 1000, paddingTop: 48 },
249
+ modal: { width: 560, maxWidth: "92vw", maxHeight: "70vh", background: "var(--dsw-alias-bg-layer-1)", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 14, display: "flex", flexDirection: "column", gap: 10, padding: 16, boxShadow: "0 12px 40px rgba(0,0,0,0.4)" },
250
+ terminalOverlay: { position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1100 },
251
+ terminal: { width: 980, maxWidth: "94vw", height: "80vh", background: "var(--dsw-alias-bg-layer-1)", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 14, display: "flex", flexDirection: "column", gap: 8, padding: 14, boxShadow: "0 12px 40px rgba(0,0,0,0.4)" },
252
+ termBody: { flex: 1, overflowY: "auto", margin: 0, background: "var(--dsw-alias-bg-layer-2, rgba(0,0,0,0.05))", border: "1px solid var(--dsw-alias-border-l3)", borderRadius: 10, padding: 10, fontFamily: "var(--ds-font-family-code)", fontSize: 12, lineHeight: "17px", whiteSpace: "pre-wrap", wordBreak: "break-word", color: "var(--dsw-alias-label-primary)" },
253
+ list: { margin: 0, padding: 0, listStyle: "none", overflowY: "auto", flex: 1, minHeight: 160, maxHeight: 320, border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 10 },
254
+ item: { padding: "6px 10px", cursor: "pointer", fontSize: 13, lineHeight: "20px", borderBottom: "1px solid var(--dsw-alias-border-l3)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
255
+ itemSel: { background: "var(--dsw-alias-selection-fill, rgba(120,140,255,0.18))" },
256
+ itemDir: { color: "var(--dsw-alias-label-primary)", fontWeight: 500 },
257
+ itemFile: { color: "var(--dsw-alias-label-secondary)" },
258
+ pathBar: { fontSize: 12, lineHeight: "18px", color: "var(--dsw-alias-label-secondary)", fontFamily: "var(--ds-font-family-code)", wordBreak: "break-all" },
259
+ };
260
+
261
+ // ---- shared presentational pieces ----
262
+ // One-line labeled row: label + control, hint as a caption below.
263
+ function FieldRow(props) {
264
+ return h(
265
+ "div",
266
+ { style: { display: "flex", flexDirection: "column", gap: 2 } },
267
+ h(
268
+ "div",
269
+ { style: style.row },
270
+ h("span", { style: style.cardTitle }, props.label),
271
+ props.control,
272
+ ),
273
+ props.hint ? h("span", { style: style.small }, props.hint) : null,
274
+ );
275
+ }
276
+ // Key/value line for the estimate + status cards.
277
+ function KV(props) {
278
+ return h(
279
+ "div",
280
+ { style: props.strong ? Object.assign({}, style.typedef, { borderTop: "1px solid var(--dsw-alias-border-l2)", paddingTop: 6, marginTop: 2, fontWeight: 500 }) : style.typedef },
281
+ h("span", {}, props.label),
282
+ h("span", { style: props.tone || style.mono }, props.children),
283
+ );
284
+ }
285
+ // One-line status summary for the actions row + status card.
286
+ function statusLine(status, portUp) {
287
+ if (status.status === "ready" && status.mode === "router") {
288
+ var pc = status.presetsCount != null ? status.presetsCount : status.presets;
289
+ return "router ready — " + String((status.loadedModels || []).length) + " model(s) loaded (" + String(pc != null ? pc : "?") + " presets)";
290
+ }
291
+ if (status.status === "ready") {
292
+ var moeBits = status.cpuMoe === true ? ", experts CPU" : (status.nCpuMoe > 0 ? ", experts cpu-first-" + status.nCpuMoe : "");
293
+ if (status.expertUsed != null) moeBits += ", top-" + status.expertUsed;
294
+ var preserveBit = status.preserveThinking === true ? ", preserve" : (status.preserveThinking === false ? ", no-preserve" : "");
295
+ var effortBit = status.reasoningEffort != null ? ", effort " + status.reasoningEffort : "";
296
+ var visionBit = status.mmprojPath ? ", vision " + status.mmprojPath.split("/").pop() + (status.mmprojCpu === false ? " (gpu)" : " (cpu)") : "";
297
+ var splitBit = status.splitMode && status.splitMode !== SPLIT_DEFAULT ? ", split " + status.splitMode + (status.tensorSplit ? " " + status.tensorSplit : "") : (status.tensorSplit ? ", tensor-split " + status.tensorSplit : "");
298
+ return "ready — " + (status.model || "model") + " (pid " + status.pid + ", ctx " + fmtCtx(status.ctx || 0) + ", mtp " + status.mtpHeads + moeBits + preserveBit + effortBit + visionBit + splitBit + ")";
299
+ }
300
+ if (status.status === "starting") {
301
+ return "loading — " + (status.model || "model") + " (pid " + status.pid + ", waiting for /health)";
302
+ }
303
+ if (portUp) {
304
+ return "a server is already listening on port " + status.port + " (not started by this tab — Stop will reap it)";
305
+ }
306
+ if (status.status === "stopped" && status.error) {
307
+ return "stopped — " + status.error;
308
+ }
309
+ if (status.status === "stopped") {
310
+ return "stopped";
311
+ }
312
+ return "nothing loaded";
313
+ }
314
+
315
+ // ---- File browser modal ----
316
+ function FileBrowserModal(props) {
317
+ var dirPair = useStatePair(props.startDir || "");
318
+ var dir = dirPair[0], setDir = dirPair[1];
319
+ var entPair = useStatePair(null);
320
+ var entries = entPair[0], setEntries = entPair[1];
321
+ var parPair = useStatePair(null);
322
+ var parent = parPair[0], setParent = parPair[1];
323
+ var loadPair = useStatePair(false);
324
+ var loading = loadPair[0], setLoading = loadPair[1];
325
+ var errPair = useStatePair(null);
326
+ var err = errPair[0], setErr = errPair[1];
327
+ var selPair = useStatePair(null);
328
+ var selected = selPair[0], setSelected = selPair[1];
329
+ // Remembered folders (persisted): jump buttons + toggle for the current dir.
330
+ var LS_FOLDERS = "dsh-local-models:remembered-folders";
331
+ var readFolders = function () {
332
+ try {
333
+ var a = JSON.parse(localStorage.getItem(LS_FOLDERS) || "[]");
334
+ return Array.isArray(a) ? a.filter(function (x) { return typeof x === "string" && x; }) : [];
335
+ } catch { return []; }
336
+ };
337
+ var remPair = useStatePair(readFolders);
338
+ var remembered = remPair[0], setRemembered = remPair[1];
339
+ var saveFolders = function (list) {
340
+ setRemembered(list);
341
+ try { localStorage.setItem(LS_FOLDERS, JSON.stringify(list)); } catch { /* storage unavailable */ }
342
+ };
343
+ var isRemembered = dir !== "" && remembered.indexOf(dir) !== -1;
344
+ var toggleRemember = function () {
345
+ if (!dir) return;
346
+ saveFolders(isRemembered
347
+ ? remembered.filter(function (d) { return d !== dir; })
348
+ : remembered.concat([dir]).slice(-8));
349
+ };
350
+
351
+ // dirMode: pick a directory (the llama.cpp binaries folder) instead of
352
+ // a file — files are shown for context but never selected, and the
353
+ // confirm button returns the directory currently listed. Listing
354
+ // every file matters here: a build/bin has no .gguf in it, and an
355
+ // empty-looking folder reads as a broken picker.
356
+ var dirMode = props.dirMode === true;
357
+ var allFiles = props.allFiles === true || dirMode;
358
+ var load = useCallback(function (d) {
359
+ setLoading(true);
360
+ setErr(null);
361
+ get("/local-models/browse" + (d ? "?dir=" + encodeURIComponent(d) : "") + (allFiles ? (d ? "&all=1" : "?all=1") : ""))
362
+ .then(function (body) {
363
+ if (body && Array.isArray(body.entries)) {
364
+ setDir(body.path || d);
365
+ setEntries(body.entries);
366
+ setParent(body.parent != null ? body.parent : null);
367
+ setSelected(null);
368
+ } else {
369
+ setErr((body && body.error) || "could not list directory");
370
+ }
371
+ })
372
+ .catch(function (e2) { setErr(String(e2)); })
373
+ .finally(function () { setLoading(false); });
374
+ }, []);
375
+
376
+ useEffect(function () { load(dir); }, [load]);
377
+
378
+ return h(
379
+ "div",
380
+ { style: style.overlay, onClick: props.onClose },
381
+ h(
382
+ "div",
383
+ { style: style.modal, onClick: function (ev) { ev.stopPropagation(); } },
384
+ h("h3", { style: style.cardTitle }, dirMode ? "Choose the llama.cpp binaries folder" : allFiles ? "Choose a vision mmproj file" : "Choose a GGUF model"),
385
+ h("div", { style: style.pathBar }, dir || "~ (home)"),
386
+ h(
387
+ "div",
388
+ { style: style.row },
389
+ h("button", { style: style.secondaryButton, onClick: function () { load(""); } }, "Home"),
390
+ (props.shortcuts || []).map(function (sc, i) {
391
+ return h("button", { key: i, style: style.secondaryButton, onClick: function () { load(sc.path); }, title: sc.path }, sc.label);
392
+ }),
393
+ h("button", { style: parent ? style.secondaryButton : Object.assign({}, style.secondaryButton, style.disabled), disabled: !parent, onClick: function () { if (parent) load(parent); } }, "Up"),
394
+ remembered.map(function (f, i) {
395
+ return h("button", { key: i, style: style.secondaryButton, title: f, onClick: function () { load(f); } }, f);
396
+ }),
397
+ h("button", { style: dir ? style.secondaryButton : Object.assign({}, style.secondaryButton, style.disabled), disabled: !dir, onClick: toggleRemember }, isRemembered ? "Forget Folder" : "Remember Folder"),
398
+ loading ? h("span", { style: style.small }, "loading…") : null,
399
+ err ? h("span", { style: style.error }, String(err)) : null,
400
+ ),
401
+ h(
402
+ "ul",
403
+ { style: style.list },
404
+ (entries || []).map(function (en, i) {
405
+ if (en.type === "dir") {
406
+ return h("li", { key: i, style: Object.assign({}, style.item, style.itemDir), onClick: function () { load(dir + "/" + en.name); } }, en.name + "/");
407
+ }
408
+ var sel = selected === en.name;
409
+ return h("li", {
410
+ key: i,
411
+ style: Object.assign({}, style.item, style.itemFile, sel ? style.itemSel : {}, dirMode ? style.disabled : {}),
412
+ onClick: dirMode ? undefined : function () { setSelected(en.name); },
413
+ title: en.name,
414
+ }, en.name + (en.size != null ? " (" + fmtGiB(en.size) + ")" : ""));
415
+ }),
416
+ ),
417
+ h(
418
+ "div",
419
+ { style: style.row },
420
+ h("button", {
421
+ style: (dirMode ? dir : selected) ? style.button : Object.assign({}, style.button, style.disabled),
422
+ disabled: dirMode ? !dir : !selected,
423
+ onClick: function () { if (dirMode) { if (dir) props.onPick(dir); } else if (selected) props.onPick(dir + "/" + selected); },
424
+ }, dirMode ? "Use this folder" : "Use this file"),
425
+ h("button", { style: style.secondaryButton, onClick: props.onClose }, "Cancel"),
426
+ ),
427
+ ),
428
+ );
429
+ }
430
+ // ---- Terminal modal: live tail of llama-server.log ----
431
+ var TERM_MAX_LINES = 3000; // in-memory line cap (drop head)
432
+ var TERM_SEED_BYTES = 262144; // 256 KiB seed window / per-poll cap
433
+ // ANSI SGR palette, tuned for dark backgrounds (30 maps to dim gray
434
+ // instead of pure black so "black" log text stays readable).
435
+ var ANSI_FG = ["#5f5f5f", "#f07178", "#c3e88d", "#ffcb6b", "#82aaff", "#c792ea", "#89ddff", "#eeffff"];
436
+ var ANSI_FG_BRIGHT = ["#8a8f98", "#ff8b92", "#d4f7a3", "#ffdf80", "#9cc4ff", "#d7aefb", "#a6f0ff", "#ffffff"];
437
+ function ansi256Color(n) {
438
+ if (n < 8) return ANSI_FG[n];
439
+ if (n < 16) return ANSI_FG_BRIGHT[n - 8];
440
+ if (n < 232) {
441
+ var v = n - 16, r = Math.floor(v / 36), g = Math.floor((v % 36) / 6), b = v % 6;
442
+ var comp = function (c) { return c === 0 ? 0 : c * 40 + 55; };
443
+ return "rgb(" + comp(r) + "," + comp(g) + "," + comp(b) + ")";
444
+ }
445
+ var gray = 8 + (n - 232) * 10;
446
+ return "rgb(" + gray + "," + gray + "," + gray + ")";
447
+ }
448
+ function blankSgrStyle() { return { fg: null, bg: null, b: false, i: false, u: false }; }
449
+ // Apply one SGR parameter list onto a style object (mutated).
450
+ function applySgr(style, params) {
451
+ for (var k = 0; k < params.length; k++) {
452
+ var p = params[k];
453
+ if (p === 0) { style.fg = null; style.bg = null; style.b = false; style.i = false; style.u = false; }
454
+ else if (p === 1) style.b = true;
455
+ else if (p === 3) style.i = true;
456
+ else if (p === 4) style.u = true;
457
+ else if (p === 22) style.b = false;
458
+ else if (p === 23) style.i = false;
459
+ else if (p === 24) style.u = false;
460
+ else if (p >= 30 && p <= 37) style.fg = ANSI_FG[p - 30];
461
+ else if (p >= 90 && p <= 97) style.fg = ANSI_FG_BRIGHT[p - 90];
462
+ else if (p === 39) style.fg = null;
463
+ else if (p >= 40 && p <= 47) style.bg = ANSI_FG[p - 40];
464
+ else if (p >= 100 && p <= 107) style.bg = ANSI_FG_BRIGHT[p - 100];
465
+ else if (p === 49) style.bg = null;
466
+ else if ((p === 38 || p === 48) && k + 2 < params.length && params[k + 1] === 5) {
467
+ if (params[k + 2] >= 0 && params[k + 2] <= 255) {
468
+ if (p === 38) style.fg = ansi256Color(params[k + 2]);
469
+ else style.bg = ansi256Color(params[k + 2]);
470
+ }
471
+ k += 2;
472
+ } else if ((p === 38 || p === 48) && k + 4 < params.length && params[k + 1] === 2) {
473
+ var rgb = "rgb(" + params[k + 2] + "," + params[k + 3] + "," + params[k + 4] + ")";
474
+ if (p === 38) style.fg = rgb; else style.bg = rgb;
475
+ k += 4;
476
+ }
477
+ }
478
+ }
479
+ // Parse one physical line (no \n) into styled spans, mutating the
480
+ // running SGR style. Returns { spans, plain }.
481
+ function parseAnsiLine(raw, style) {
482
+ var spans = [], plain = "";
483
+ var parts = raw.split(/(\x1b\[[0-9;]*m)/);
484
+ for (var i = 0; i < parts.length; i++) {
485
+ var seg = parts[i];
486
+ if (seg === "") continue;
487
+ if (seg.charAt(0) === "\x1b") {
488
+ var nums = seg.slice(2, -1).split(";");
489
+ var params = [];
490
+ for (var j = 0; j < nums.length; j++) {
491
+ params.push(nums[j] === "" ? 0 : Number(nums[j]));
492
+ }
493
+ applySgr(style, params);
494
+ } else if (seg) {
495
+ spans.push({ t: seg, fg: style.fg, bg: style.bg, b: style.b, i: style.i, u: style.u });
496
+ plain += seg;
497
+ }
498
+ }
499
+ return { spans: spans, plain: plain };
500
+ }
501
+ // Parse a raw chunk into display lines. A chunk usually ends mid-line,
502
+ // so the text after the last \n is held back raw (st.pending) and
503
+ // prepended to the next chunk — \r overwrites and escape sequences
504
+ // therefore always resolve whole. Style state mutates on parsed
505
+ // (complete-line) text only.
506
+ function parseAnsiChunk(text, st) {
507
+ text = (st.carry || "") + (st.pending || "") + text;
508
+ st.carry = "";
509
+ st.pending = "";
510
+ var cut = text.lastIndexOf("\n");
511
+ var head = cut === -1 ? "" : text.slice(0, cut);
512
+ st.pending = cut === -1 ? text : text.slice(cut + 1);
513
+ if (st.pending.length > 1048576) st.pending = st.pending.slice(-1048576);
514
+ var tail = /(\x1b(?:\[[0-9;]*)?)$/.exec(st.pending);
515
+ if (tail) { st.pending = st.pending.slice(0, st.pending.length - tail[1].length); st.carry = tail[1]; }
516
+ if (head === "") return [];
517
+ head = head.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
518
+ head = head.replace(/\x1b[()][0-9A-Z]/g, "");
519
+ // Strip non-SGR CSI (SGR ends in m/M: m feeds the color parser
520
+ // below, M deletes a line so dropping it is a no-op for a tail).
521
+ head = head.replace(/\x1b\[[0-9;?]*[A-LN-Za-ln-z]/g, "");
522
+ var out = [];
523
+ var raws = head.split("\n");
524
+ for (var i = 0; i < raws.length; i++) {
525
+ var line = raws[i];
526
+ if (line.charAt(line.length - 1) === "\r") line = line.slice(0, -1);
527
+ var segs = line.split("\r");
528
+ out.push(parseAnsiLine(segs[segs.length - 1], st.style));
529
+ }
530
+ return out;
531
+ }
532
+ function newTermState() { return { style: blankSgrStyle(), carry: "" }; }
533
+ function spanStyle(sp) {
534
+ var s = {};
535
+ if (sp.fg) s.color = sp.fg;
536
+ if (sp.bg) s.backgroundColor = sp.bg;
537
+ if (sp.b) s.fontWeight = 700;
538
+ if (sp.i) s.fontStyle = "italic";
539
+ if (sp.u) s.textDecoration = "underline";
540
+ return s;
541
+ }
542
+ function TerminalModal(props) {
543
+ var linesPair = useStatePair([]);
544
+ var lines = linesPair[0], setLines = linesPair[1];
545
+ var errPair = useStatePair(null);
546
+ var err = errPair[0], setErr = errPair[1];
547
+ var filterPair = useStatePair("");
548
+ var filter = filterPair[0], setFilter = filterPair[1];
549
+ var newPair = useStatePair(0);
550
+ var newCount = newPair[0], setNewCount = newPair[1];
551
+ var bodyRef = React.useRef(null);
552
+ var cursorRef = React.useRef(null); // server's nextOffset; null until the seed fetch lands
553
+ var pinnedRef = React.useRef(true); // auto-follow while the user is at the bottom
554
+ var inflightRef = React.useRef(false);
555
+ var termRef = React.useRef(null); // parser state {style, carry, pending}, survives polls
556
+ var lineNoRef = React.useRef(0); // stable keys for appended lines
557
+ var prevLenRef = React.useRef(0); // follow-scroll only when lines actually grew
558
+ if (termRef.current === null) termRef.current = newTermState();
559
+ var raf = typeof requestAnimationFrame === "function"
560
+ ? requestAnimationFrame
561
+ : function (f) { f(); };
562
+ var rafIdRef = React.useRef(0);
563
+
564
+ var onScroll = function () {
565
+ if (rafIdRef.current) return;
566
+ rafIdRef.current = 1;
567
+ raf(function () {
568
+ rafIdRef.current = 0;
569
+ var el = bodyRef.current;
570
+ if (!el) return;
571
+ var pinned = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
572
+ pinnedRef.current = pinned;
573
+ if (pinned) setNewCount(0);
574
+ });
575
+ };
576
+
577
+ var appendText = function (text) {
578
+ var parsed = parseAnsiChunk(text, termRef.current);
579
+ if (parsed.length === 0) return;
580
+ var numbered = [];
581
+ for (var i = 0; i < parsed.length; i++) {
582
+ numbered.push({ n: lineNoRef.current++, spans: parsed[i].spans, plain: parsed[i].plain });
583
+ }
584
+ setLines(function (prev) {
585
+ var next = prev.concat(numbered);
586
+ return next.length > TERM_MAX_LINES ? next.slice(next.length - TERM_MAX_LINES) : next;
587
+ });
588
+ if (!pinnedRef.current) {
589
+ setNewCount(function (c) { return c + numbered.length; });
590
+ }
591
+ };
592
+
593
+ useEffect(function () {
594
+ var cancelled = false;
595
+ var timer = null;
596
+ var pull = function (params) {
597
+ if (inflightRef.current) return;
598
+ inflightRef.current = true;
599
+ get("/local-models/logs" + params)
600
+ .then(function (body) {
601
+ if (cancelled || !body || typeof body.text !== "string") return;
602
+ cursorRef.current = body.nextOffset;
603
+ setErr(null);
604
+ if (body.text.length > 0) appendText(body.text);
605
+ })
606
+ .catch(function (e2) { if (!cancelled) setErr(String(e2)); })
607
+ .finally(function () { inflightRef.current = false; });
608
+ };
609
+ setLines([]);
610
+ setNewCount(0);
611
+ cursorRef.current = null;
612
+ pinnedRef.current = true;
613
+ pull("?max=" + TERM_SEED_BYTES);
614
+ timer = setInterval(function () {
615
+ pull(cursorRef.current === null ? "" : "?offset=" + cursorRef.current + "&max=" + TERM_SEED_BYTES);
616
+ }, 2000);
617
+ return function () {
618
+ cancelled = true;
619
+ clearInterval(timer);
620
+ };
621
+ }, []);
622
+
623
+ // Follow: scroll to bottom after renders that actually grew the buffer.
624
+ useEffect(function () {
625
+ if (lines.length !== prevLenRef.current) {
626
+ prevLenRef.current = lines.length;
627
+ if (pinnedRef.current) {
628
+ var el = bodyRef.current;
629
+ if (el) el.scrollTop = el.scrollHeight;
630
+ }
631
+ }
632
+ return undefined;
633
+ });
634
+
635
+ var jumpToEnd = function () {
636
+ pinnedRef.current = true;
637
+ setNewCount(0);
638
+ var el = bodyRef.current;
639
+ if (el) el.scrollTop = el.scrollHeight;
640
+ };
641
+ var clearView = function () {
642
+ setLines([]);
643
+ setNewCount(0);
644
+ };
645
+
646
+ var q = filter.trim().toLowerCase();
647
+ var view = q ? lines.filter(function (l) { return l.plain.toLowerCase().indexOf(q) !== -1; }) : lines;
648
+
649
+ return h(
650
+ "div",
651
+ { style: style.terminalOverlay, onClick: props.onClose },
652
+ h(
653
+ "div",
654
+ { style: style.terminal, onClick: function (ev) { ev.stopPropagation(); } },
655
+ h(
656
+ "div",
657
+ { style: style.row },
658
+ h("h3", { style: style.cardTitle }, "llama-server terminal"),
659
+ h("span", { style: style.small }, "live tail of " + (props.logPath || "llama-server.log")),
660
+ h("span", { style: { flex: 1 } }),
661
+ h("input", {
662
+ style: Object.assign({}, style.input, { width: 170, height: 30 }),
663
+ value: filter, placeholder: "Filter lines…",
664
+ onChange: function (ev) { setFilter(ev.target.value); }, spellCheck: false,
665
+ }),
666
+ !pinnedRef.current && newCount > 0 && !q
667
+ ? h("button", { style: style.secondaryButton, onClick: jumpToEnd }, "↓ " + newCount + " new")
668
+ : h("button", { style: style.secondaryButton, onClick: jumpToEnd }, "Jump to end"),
669
+ h("button", { style: style.secondaryButton, onClick: clearView }, "Clear view"),
670
+ h("button", { style: style.secondaryButton, onClick: props.onClose }, "Close"),
671
+ ),
672
+ err ? h("p", { style: style.error }, String(err)) : null,
673
+ h(
674
+ "div",
675
+ { ref: bodyRef, style: style.termBody, onScroll: onScroll },
676
+ view.length > 0
677
+ ? view.map(function (l) {
678
+ return h("div", { key: l.n }, l.spans.length > 0
679
+ ? l.spans.map(function (sp, si) {
680
+ return h("span", { key: si, style: spanStyle(sp) }, sp.t);
681
+ })
682
+ : "\u00a0");
683
+ })
684
+ : h("span", { style: style.small }, lines.length > 0 ? "no lines match the filter" : "no output yet"),
685
+ ),
686
+ ),
687
+ );
688
+ }
689
+
690
+ // ---- main section ----
691
+ function LocalModelsSection() {
692
+ var selPair = useStatePair(null);
693
+ var selectedPath = selPair[0], setSelectedPath = selPair[1];
694
+ var metaPair = useStatePair(null);
695
+ var metaRoot = metaPair[0], setMetaRoot = metaPair[1];
696
+ var metaErrPair = useStatePair(null);
697
+ var metaErr = metaErrPair[0], setMetaErr = metaErrPair[1];
698
+ var metaBusyPair = useStatePair(false);
699
+ var metaBusy = metaBusyPair[0], setMetaBusy = metaBusyPair[1];
700
+ var pickerPair = useStatePair(false);
701
+ var pickerOpen = pickerPair[0], setPickerOpen = pickerPair[1];
702
+ var ctxPair = useStatePair(8192);
703
+ var ctx = ctxPair[0], setCtx = ctxPair[1];
704
+ var ctxTextPair = useStatePair("8192");
705
+ var ctxText = ctxTextPair[0], setCtxText = ctxTextPair[1];
706
+ var mtpPair = useStatePair(2);
707
+ var mtp = mtpPair[0], setMtp = mtpPair[1];
708
+ // MoE expert placement: "gpu" (all experts in VRAM), "cpu" (all in
709
+ // RAM via --cpu-moe), "layers" (first N layers in RAM via
710
+ // --n-cpu-moe N). expertUsedText "" = stock top-k from the GGUF.
711
+ var moeModePair = useStatePair("gpu");
712
+ var moeMode = moeModePair[0], setMoeMode = moeModePair[1];
713
+ var nCpuMoePair = useStatePair("8");
714
+ var nCpuMoeText = nCpuMoePair[0], setNCpuMoeText = nCpuMoePair[1];
715
+ var expUsedPair = useStatePair("");
716
+ var expertUsedText = expUsedPair[0], setExpertUsedText = expUsedPair[1];
717
+ var effortPair = useStatePair("medium");
718
+ var effort = effortPair[0], setEffort = effortPair[1];
719
+ // KV cache quantization, one selector per store (--cache-type-k /
720
+ // --cache-type-v). Defaults = the tuned 16 GiB pair.
721
+ var kvKPair = useStatePair(KV_DEFAULT_K);
722
+ var kvTypeK = kvKPair[0], setKvTypeK = kvKPair[1];
723
+ var kvVPair = useStatePair(KV_DEFAULT_V);
724
+ var kvTypeV = kvVPair[0], setKvTypeV = kvVPair[1];
725
+ // Preserve the full reasoning trace in history (--reasoning-preserve).
726
+ // Off (default, historical behavior) sends --no-reasoning-preserve.
727
+ var preservePair = useStatePair(false);
728
+ var preserveThinking = preservePair[0], setPreserveThinking = preservePair[1];
729
+ var mmPair = useStatePair(null);
730
+ var mmprojPath = mmPair[0], setMmprojPath = mmPair[1];
731
+ var mmCpuPair = useStatePair(true);
732
+ var mmprojCpu = mmCpuPair[0], setMmprojCpu = mmCpuPair[1];
733
+ var mmMetaPair = useStatePair(null);
734
+ var mmMetaRoot = mmMetaPair[0], setMmMetaRoot = mmMetaPair[1];
735
+ var mmErrPair = useStatePair(null);
736
+ var mmErr = mmErrPair[0], setMmErr = mmErrPair[1];
737
+ var mmPickerPair = useStatePair(false);
738
+ var mmPickerOpen = mmPickerPair[0], setMmPickerOpen = mmPickerPair[1];
739
+ var termPair = useStatePair(false);
740
+ var termOpen = termPair[0], setTermOpen = termPair[1];
741
+ var statusPair = useStatePair({ status: "idle", portUp: false });
742
+ var status = statusPair[0], setStatus = statusPair[1];
743
+ var busyPair = useStatePair(false);
744
+ var busy = busyPair[0], setBusy = busyPair[1];
745
+ var errPair = useStatePair(null);
746
+ var error = errPair[0], setError = errPair[1];
747
+ var regPair = useStatePair(null);
748
+ var registered = regPair[0], setRegistered = regPair[1];
749
+ var profPair = useStatePair([]);
750
+ var profiles = profPair[0], setProfiles = profPair[1];
751
+ var profNamePair = useStatePair("");
752
+ var profileName = profNamePair[0], setProfileName = profNamePair[1];
753
+ // Runtime card (llama.cpp binaries folder + browser shortcuts). This
754
+ // is a server-wide setting, not a per-launch field: it is persisted
755
+ // through /local-models/settings and adopted from the status poll,
756
+ // and profiles deliberately do NOT carry it — a profile must stay
757
+ // portable between machines, and the router uses one binary anyway.
758
+ var binPair = useStatePair("");
759
+ var binDraft = binPair[0], setBinDraft = binPair[1];
760
+ // Last value the server confirmed: binDraft !== binSaved means the
761
+ // user has an unsaved edit the poll must not overwrite.
762
+ var binSavedPair = useStatePair("");
763
+ var binSaved = binSavedPair[0], setBinSaved = binSavedPair[1];
764
+ var binChkPair = useStatePair(null);
765
+ var binCheck = binChkPair[0], setBinCheck = binChkPair[1];
766
+ var binBusyPair = useStatePair(false);
767
+ var binBusy = binBusyPair[0], setBinBusy = binBusyPair[1];
768
+ var binPickerPair = useStatePair(false);
769
+ var binPickerOpen = binPickerPair[0], setBinPickerOpen = binPickerPair[1];
770
+ var scDraftPair = useStatePair("");
771
+ var shortcutDraft = scDraftPair[0], setShortcutDraft = scDraftPair[1];
772
+ var scBusyPair = useStatePair(false);
773
+ var scBusy = scBusyPair[0], setScBusy = scBusyPair[1];
774
+ // Mutation base for the folder list: the poll snapshot is up to 2 s
775
+ // old, so adding/removing from it loses a just-applied change. This
776
+ // ref is updated from every POST response (authoritative), and a poll
777
+ // may only overwrite it once it reports the list we just wrote (or
778
+ // after a 5 s grace, in case someone else changed it meanwhile).
779
+ var scListRef = React.useRef(null);
780
+ var scPendingRef = React.useRef(null);
781
+ // VRAM budget override (GiB, "" = auto-detect): a machine-level
782
+ // setting like binPath, so it follows the same draft/saved dance.
783
+ var vramPair = useStatePair("");
784
+ var vramDraft = vramPair[0], setVramDraft = vramPair[1];
785
+ var vramSavedPair = useStatePair("");
786
+ var vramSaved = vramSavedPair[0], setVramSaved = vramSavedPair[1];
787
+ // Multi-GPU placement. Defaults reproduce llama.cpp's own behavior
788
+ // (layer split, proportions from VRAM), so an untouched tab launches
789
+ // exactly what it launched before this control existed.
790
+ var splitPair = useStatePair(SPLIT_DEFAULT);
791
+ var splitMode = splitPair[0], setSplitMode = splitPair[1];
792
+ var tsPair = useStatePair("");
793
+ var tensorSplitText = tsPair[0], setTensorSplitText = tsPair[1];
794
+
795
+ var meta = metaRoot ? metaRoot.meta : null;
796
+ var trainCtx = meta ? meta.contextLength : null;
797
+ var ctxMax = trainCtx && trainCtx > 0 ? trainCtx : FALLBACK_MAX_CTX;
798
+
799
+ // MLA models (DeepSeek-style latent KV) reject mixed K/V cache
800
+ // types in llama.cpp — surface it and keep Load disabled. Mirrors
801
+ // kvTypesMustMatch() in lib/index.js (`is_mla() || deepseek4`).
802
+ var kvMustMatch = !!(meta && (meta.isMla === true || meta.arch === "deepseek4"));
803
+ var kvPairOk = !kvMustMatch || kvTypeK === kvTypeV;
804
+
805
+ var LS_PATH = "dsh-local-models:model-path";
806
+ var LS_CTX = "dsh-local-models:ctx";
807
+ var LS_MTP = "dsh-local-models:mtp";
808
+ var LS_EFFORT = "dsh-local-models:effort";
809
+ var LS_KVTYPE_K = "dsh-local-models:kv-type-k";
810
+ var LS_KVTYPE_V = "dsh-local-models:kv-type-v";
811
+ var LS_PRESERVE = "dsh-local-models:preserve-thinking";
812
+ var LS_MMPROJ = "dsh-local-models:mmproj-path";
813
+ var LS_MMPROJ_CPU = "dsh-local-models:mmproj-cpu";
814
+ var LS_EXPERTUSED = "dsh-local-models:expert-used";
815
+ var LS_MOEMODE = "dsh-local-models:moe-mode";
816
+ var LS_NCPUMOE = "dsh-local-models:n-cpu-moe";
817
+ // Runtime: the saved value is restored into BOTH the draft and the
818
+ // "saved" baseline, so a tab that opens with a stale cache still
819
+ // adopts the server's value on the first poll (equal values = clean).
820
+ var LS_BIN = "dsh-local-models:bin-path";
821
+ var LS_VRAM = "dsh-local-models:vram-gb";
822
+ var LS_SPLIT = "dsh-local-models:split-mode";
823
+ var LS_TSPLIT = "dsh-local-models:tensor-split";
824
+
825
+ // Restore the previous selection on mount (session survived a refresh).
826
+ useEffect(function () {
827
+ try {
828
+ var p = localStorage.getItem(LS_PATH);
829
+ if (p) { setSelectedPath(p); fitAppliedRef.current = true; }
830
+ var mp = localStorage.getItem(LS_MMPROJ);
831
+ if (mp) setMmprojPath(mp);
832
+ var mc = localStorage.getItem(LS_MMPROJ_CPU);
833
+ if (mc === "1") setMmprojCpu(true);
834
+ else if (mc === "0") setMmprojCpu(false);
835
+ var c = Number(localStorage.getItem(LS_CTX));
836
+ if (Number.isFinite(c) && c > 0) { setCtx(c); setCtxText(String(c)); }
837
+ var m = Number(localStorage.getItem(LS_MTP));
838
+ if (Number.isFinite(m) && m >= 0 && m <= MTP_MAX) setMtp(m);
839
+ setEffort(normalizeEffort(localStorage.getItem(LS_EFFORT)));
840
+ setKvTypeK(normalizeKvType(localStorage.getItem(LS_KVTYPE_K), KV_DEFAULT_K));
841
+ setKvTypeV(normalizeKvType(localStorage.getItem(LS_KVTYPE_V), KV_DEFAULT_V));
842
+ if (localStorage.getItem(LS_PRESERVE) === "1") setPreserveThinking(true);
843
+ var mm = localStorage.getItem(LS_MOEMODE);
844
+ if (mm === "cpu" || mm === "layers" || mm === "gpu") setMoeMode(mm);
845
+ var nc = Number(localStorage.getItem(LS_NCPUMOE));
846
+ if (Number.isFinite(nc) && nc > 0) setNCpuMoeText(String(Math.floor(nc)));
847
+ var eu = localStorage.getItem(LS_EXPERTUSED);
848
+ if (eu != null && eu !== "" && Number.isFinite(Number(eu)) && Number(eu) > 0) setExpertUsedText(String(Math.floor(Number(eu))));
849
+ var bp = localStorage.getItem(LS_BIN);
850
+ if (bp != null) { setBinDraft(bp); setBinSaved(bp); }
851
+ var vg = localStorage.getItem(LS_VRAM);
852
+ if (vg != null) { setVramDraft(vg); setVramSaved(vg); }
853
+ setSplitMode(normalizeSplitMode(localStorage.getItem(LS_SPLIT)));
854
+ var ts = localStorage.getItem(LS_TSPLIT);
855
+ if (ts) setTensorSplitText(ts);
856
+ } catch { /* storage unavailable */ }
857
+ return undefined;
858
+ }, []);
859
+
860
+ // Plain (non-memoized) function: usePoll always invokes the latest
861
+ // render's copy via its ref, so the poll sees fresh state (e.g.
862
+ // the current doRegister/status instead of first-render ones).
863
+ var refresh = function () {
864
+ get("/local-models/status").then(function (body) {
865
+ if (!body) return;
866
+ setStatus(body);
867
+ // Router auto-register: starting the router always refreshes
868
+ // the local-router route, no manual Register press needed.
869
+ if (pendingRouterRegRef.current && body.mode === "router" && body.status === "ready") {
870
+ pendingRouterRegRef.current = false;
871
+ doRegister();
872
+ return;
873
+ }
874
+ // The server outlives page refreshes: adopt the running model so the
875
+ // tab re-syncs (Stops stay available) even without any stored state.
876
+ if (body.modelPath && !selectedPathRef.current) {
877
+ setSelectedPath(body.modelPath);
878
+ if (body.ctx) { setCtx(body.ctx); setCtxText(String(body.ctx)); }
879
+ if (body.mtpHeads != null && Number.isFinite(Number(body.mtpHeads))) setMtp(Math.min(Math.max(0, Math.floor(Number(body.mtpHeads))), MTP_MAX));
880
+ setKvTypeK(normalizeKvType(body.kvTypeK, KV_DEFAULT_K));
881
+ setKvTypeV(normalizeKvType(body.kvTypeV, KV_DEFAULT_V));
882
+ if (body.reasoningEffort != null) setEffort(normalizeEffort(body.reasoningEffort));
883
+ else if (body.effort != null) setEffort(normalizeEffort(body.effort));
884
+ if (body.preserveThinking != null) setPreserveThinking(body.preserveThinking === true);
885
+ else if (body.preserve_thinking != null) setPreserveThinking(body.preserve_thinking === true);
886
+ if (body.cpuMoe === true) setMoeMode("cpu");
887
+ else if (body.nCpuMoe > 0) { setMoeMode("layers"); setNCpuMoeText(String(body.nCpuMoe)); }
888
+ if (body.expertUsed != null) setExpertUsedText(String(body.expertUsed));
889
+ if (body.mmprojCpu != null) setMmprojCpu(body.mmprojCpu !== false);
890
+ if (body.splitMode != null) setSplitMode(normalizeSplitMode(body.splitMode));
891
+ if (body.tensorSplit != null) setTensorSplitText(String(body.tensorSplit));
892
+ }
893
+ if (body.mmprojPath && !mmprojPathRef.current && !clearedMmprojRef.current) setMmprojPath(body.mmprojPath);
894
+ if (body.mmprojPath && !mmprojPathRef.current && !clearedMmprojRef.current && body.mmprojCpu != null) setMmprojCpu(body.mmprojCpu !== false);
895
+ // Runtime: adopt the server value only while the field is
896
+ // clean — an unsaved edit must survive the 2 s poll.
897
+ if (!binDirtyRef.current) {
898
+ var serverBin = typeof body.binConfigured === "string" ? body.binConfigured : "";
899
+ setBinDraft(serverBin);
900
+ setBinSaved(serverBin);
901
+ }
902
+ var serverPaths = (Array.isArray(body.shortcuts) ? body.shortcuts : []).map(function (x) { return x.path; });
903
+ var pending = scPendingRef.current;
904
+ if (!pending || Date.now() - pending.at > 5000 || samePaths(serverPaths, pending.list)) {
905
+ scListRef.current = serverPaths;
906
+ scPendingRef.current = null;
907
+ }
908
+ if (!vramDirtyRef.current) {
909
+ var serverVram = body.vramOverrideGb > 0 ? String(body.vramOverrideGb) : "";
910
+ setVramDraft(serverVram);
911
+ setVramSaved(serverVram);
912
+ }
913
+ }).catch(function () {});
914
+ };
915
+
916
+ var selectedPathRef = React.useRef(selectedPath);
917
+ selectedPathRef.current = selectedPath;
918
+ var mmprojPathRef = React.useRef(mmprojPath);
919
+ mmprojPathRef.current = mmprojPath;
920
+ // Explicit user "Clear" suppresses the status poll from re-feeding
921
+ // the server's retained mmprojPath until a new file is picked.
922
+ var clearedMmprojRef = React.useRef(false);
923
+ // Set by "Load" on a profile so the GGUF-meta effect keeps the
924
+ // profile's context instead of resetting to min(8192, train_ctx).
925
+ var pendingProfileRef = React.useRef(null);
926
+ // Fit-to-VRAM auto-apply runs once per freshly picked file (not on
927
+ // restore or profile loads — those own their settings already).
928
+ var fitAppliedRef = React.useRef(false);
929
+ // Set when the router starts: the refresh poll registers the
930
+ // router route in dsh as soon as the server reports ready.
931
+ var pendingRouterRegRef = React.useRef(false);
932
+ // Unsaved Runtime edit (typed path ≠ saved path): the poll must not
933
+ // clobber it, exactly like the model/mmproj adopt guards.
934
+ var binDirtyRef = React.useRef(false);
935
+ binDirtyRef.current = binDraft.trim() !== binSaved;
936
+ var vramDirtyRef = React.useRef(false);
937
+ vramDirtyRef.current = vramDraft.trim() !== vramSaved;
938
+ // Same list, same order: what the server echoes for a write of ours.
939
+ var samePaths = function (a, b) {
940
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
941
+ for (var i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
942
+ return true;
943
+ };
944
+
945
+ usePoll(refresh, 2000);
946
+
947
+ // Persist the selection so a refresh restores it.
948
+ useEffect(function () {
949
+ try {
950
+ if (selectedPath) localStorage.setItem(LS_PATH, selectedPath);
951
+ else localStorage.removeItem(LS_PATH);
952
+ localStorage.setItem(LS_CTX, String(ctx));
953
+ localStorage.setItem(LS_MTP, String(mtp));
954
+ localStorage.setItem(LS_EFFORT, effort);
955
+ localStorage.setItem(LS_KVTYPE_K, kvTypeK);
956
+ localStorage.setItem(LS_KVTYPE_V, kvTypeV);
957
+ localStorage.setItem(LS_PRESERVE, preserveThinking ? "1" : "0");
958
+ localStorage.setItem(LS_MOEMODE, moeMode);
959
+ localStorage.setItem(LS_NCPUMOE, nCpuMoeText);
960
+ localStorage.setItem(LS_MMPROJ_CPU, mmprojCpu ? "1" : "0");
961
+ if (expertUsedText !== "" && Number.isFinite(Number(expertUsedText))) localStorage.setItem(LS_EXPERTUSED, String(expertUsedText));
962
+ else localStorage.removeItem(LS_EXPERTUSED);
963
+ if (mmprojPath) localStorage.setItem(LS_MMPROJ, mmprojPath);
964
+ else localStorage.removeItem(LS_MMPROJ);
965
+ localStorage.setItem(LS_BIN, binDraft);
966
+ localStorage.setItem(LS_VRAM, vramDraft);
967
+ localStorage.setItem(LS_SPLIT, splitMode);
968
+ if (tensorSplitText) localStorage.setItem(LS_TSPLIT, tensorSplitText);
969
+ else localStorage.removeItem(LS_TSPLIT);
970
+ } catch { /* storage unavailable */ }
971
+ return undefined;
972
+ }, [selectedPath, ctx, mtp, effort, preserveThinking, mmprojPath, mmprojCpu, moeMode, nCpuMoeText, expertUsedText, kvTypeK, kvTypeV, binDraft, splitMode, tensorSplitText, vramDraft]);
973
+ // Fetch GGUF metadata when a file is picked.
974
+ useEffect(function () {
975
+ if (!selectedPath) return;
976
+ var cancelled = false;
977
+ setMetaBusy(true);
978
+ setMetaErr(null);
979
+ // New file: back to the stock top-k until the user overrides it
980
+ // (a profile load re-applies its own value after this effect).
981
+ if (!pendingProfileRef.current || pendingProfileRef.current.expertUsed == null) setExpertUsedText("");
982
+ post("/local-models/gguf-meta", { path: selectedPath })
983
+ .then(function (res) {
984
+ if (cancelled) return;
985
+ if (res.ok && res.body && res.body.meta) {
986
+ setMetaRoot(res.body);
987
+ var tc = res.body.meta.contextLength;
988
+ var pending = pendingProfileRef.current;
989
+ var def = pending && pending.ctx ? pending.ctx : (tc && tc > 0 ? Math.min(8192, tc) : 8192);
990
+ setCtx(def);
991
+ setCtxText(String(def));
992
+ } else {
993
+ setMetaRoot(null);
994
+ setMetaErr((res.body && res.body.error) || "could not parse GGUF");
995
+ }
996
+ })
997
+ .catch(function (e2) { if (!cancelled) { setMetaRoot(null); setMetaErr(String(e2)); } })
998
+ .finally(function () { pendingProfileRef.current = null; if (!cancelled) setMetaBusy(false); });
999
+ return function () { cancelled = true; };
1000
+ }, [selectedPath]);
1001
+
1002
+ // ---- GPU / VRAM budget (detected by the node half, or pinned) ----
1003
+ // The estimate and the "fits" verdict are only as good as this
1004
+ // number, so the source is always shown next to it.
1005
+ var gpus = Array.isArray(status.gpus) ? status.gpus : [];
1006
+ var gpuCount = gpus.length;
1007
+ var vramBytes = Number.isFinite(status.vramTotalBytes) && status.vramTotalBytes > 0
1008
+ ? status.vramTotalBytes
1009
+ : TOTAL_VRAM_BYTES;
1010
+ var vramGb = fmtVramGb(vramBytes);
1011
+ var vramSource = status.vramSource || "assumed";
1012
+ var splitVisible = gpuCount > 1 || splitMode !== SPLIT_DEFAULT || tensorSplitText.trim() !== "";
1013
+ var gpuLabel = gpuCount > 0
1014
+ ? gpus.map(function (g) { return g.name + " (" + fmtVramGb(g.vramBytes) + " GB)"; }).join(" + ")
1015
+ : vramSource === "probing" ? "detecting…"
1016
+ : vramSource === "override" ? "manual budget"
1017
+ : "not detected — assuming " + vramGb + " GB";
1018
+
1019
+ // Live VRAM estimate.
1020
+ var estimate = useMemo(function () {
1021
+ if (!meta) return null;
1022
+ if (!meta.nLayers || !meta.nKvHeads || !meta.headDim) {
1023
+ return { unavailable: true };
1024
+ }
1025
+ var gdn = gdnLayout(meta.nLayers, meta.fullAttnInterval, meta.nextnPredictLayers);
1026
+ // The fixed MTP draft is always on when mtp > 0: no ctx ceiling
1027
+ // (the old softcap is gone), so the draft KV always counts.
1028
+ var specOn = mtp > 0;
1029
+ var kvLayersOverride = gdn.hybrid ? gdn.attnTrunk + (specOn ? gdn.mtp : 0) : null;
1030
+ // Upstream keeps the SSM recurrent state in f32 (the fork's
1031
+ // f16-state env is gone), so estimate with f32 state bytes.
1032
+ var recrFixed = gdn.hybrid && meta.ssmInnerSize
1033
+ ? gdnRecurrentBytes(gdn.trunk - gdn.attnTrunk, meta.ssmStateSize ?? 128, meta.ssmInnerSize, meta.ssmNGroup ?? 16, meta.ssmDtRank ?? 48, meta.ssmConvKernel ?? 4, false)
1034
+ : 0;
1035
+ var kv = kvBytesFor(ctx, meta.nLayers, meta.nKvHeads, meta.headDim, kvTypeK, kvTypeV, meta.slidingWindow, null, kvLayersOverride);
1036
+ var compute = estimateComputeBytes(ctx, meta.nVocab, gdn.hybrid);
1037
+ // mmproj on CPU lives in RAM: only a GPU mmproj costs VRAM.
1038
+ var mmprojBytes = (!mmprojCpu && mmMetaRoot) ? mmMetaRoot.weightsBytes : 0;
1039
+ // MoE expert split: experts kept on CPU live in RAM, so only
1040
+ // the VRAM-resident weights count toward the total. Mirrors
1041
+ // moeVramWeights() in lib/index.js (kept in sync by hand).
1042
+ var cpuExpertBytes = 0, moeUnknown = false;
1043
+ if (meta.isMoe && moeMode !== "gpu") {
1044
+ if (meta.expertBytes == null) {
1045
+ moeUnknown = true;
1046
+ } else if (moeMode === "cpu") {
1047
+ cpuExpertBytes = meta.expertBytes;
1048
+ } else {
1049
+ var nl = parseInt(nCpuMoeText, 10);
1050
+ var byLayer = meta.expertBytesByLayer || {};
1051
+ var sum = 0, known = false, k;
1052
+ for (k in byLayer) {
1053
+ if (Number(k) < nl) { sum += byLayer[k]; known = true; }
1054
+ }
1055
+ if (!known || !isFinite(nl) || nl <= 0) moeUnknown = true;
1056
+ else cpuExpertBytes = sum;
1057
+ }
1058
+ }
1059
+ var weightsVram = Math.max(metaRoot.weightsBytes - cpuExpertBytes, 0);
1060
+ var total = weightsVram + mmprojBytes + recrFixed + kv + compute + OVERHEAD_BYTES;
1061
+ // Fit-to-VRAM: smallest first-N-layers CPU split that brings the
1062
+ // total within the safe budget. n = -1 means even all experts
1063
+ // on CPU miss (KV too big → reduce ctx). Null when the expert
1064
+ // split is unknown or the model already fits on GPU.
1065
+ var fit = null;
1066
+ if (meta.isMoe && meta.expertBytes != null && meta.expertBytesByLayer) {
1067
+ var baseNonWeights = mmprojBytes + recrFixed + kv + compute + OVERHEAD_BYTES;
1068
+ var budget = vramBytes - SAFE_MARGIN_BYTES;
1069
+ var byL = meta.expertBytesByLayer, maxL = -1, kk;
1070
+ for (kk in byL) { maxL = Math.max(maxL, Number(kk)); }
1071
+ var layerCount = meta.nLayers || (maxL + 1);
1072
+ var prefix = function (n) { var s = 0, key; for (key in byL) { if (Number(key) < n) s += byL[key]; } return s; };
1073
+ var fitsAt = function (n) { return metaRoot.weightsBytes - prefix(n) + baseNonWeights <= budget; };
1074
+ if (!fitsAt(0) && layerCount > 0) {
1075
+ if (!fitsAt(layerCount)) {
1076
+ fit = { n: -1, total: null, cpuAll: false };
1077
+ } else {
1078
+ var lo = 0, hi = layerCount, mid;
1079
+ while (hi - lo > 1) { mid = (lo + hi) >> 1; if (fitsAt(mid)) hi = mid; else lo = mid; }
1080
+ fit = { n: hi, total: metaRoot.weightsBytes - prefix(hi) + baseNonWeights, cpuAll: hi >= layerCount };
1081
+ }
1082
+ }
1083
+ }
1084
+ var input = {
1085
+ weightsBytes: weightsVram + mmprojBytes,
1086
+ nLayers: meta.nLayers, nKvHeads: meta.nKvHeads, headDim: meta.headDim,
1087
+ kvTypeK: kvTypeK, kvTypeV: kvTypeV,
1088
+ nVocab: meta.nVocab, slidingWindow: meta.slidingWindow, fullAttnShare: null,
1089
+ kvLayersOverride: kvLayersOverride, recurrentFixedBytes: recrFixed,
1090
+ overheadBytes: OVERHEAD_BYTES,
1091
+ };
1092
+ var maxCtx = maxContextFor(vramBytes, input);
1093
+ return {
1094
+ gdn: gdn, specOn: specOn, kvLayersOverride: kvLayersOverride, recrFixed: recrFixed,
1095
+ kv: kv, compute: compute, total: total, weights: metaRoot.weightsBytes, weightsVram: weightsVram,
1096
+ cpuExpertBytes: cpuExpertBytes, moeUnknown: moeUnknown, mmprojBytes: mmprojBytes,
1097
+ fit: fit, overBudget: total > vramBytes - SAFE_MARGIN_BYTES,
1098
+ fits: total <= vramBytes,
1099
+ fitsSafe: total <= vramBytes - SAFE_MARGIN_BYTES,
1100
+ maxCtx: maxCtx,
1101
+ pct: Math.round(100 * total / vramBytes),
1102
+ };
1103
+ }, [meta, metaRoot, mmMetaRoot, mmprojCpu, ctx, mtp, moeMode, nCpuMoeText, kvTypeK, kvTypeV, vramBytes]);
1104
+
1105
+ // Smart default: once per freshly picked file, auto-apply the
1106
+ // fit-to-VRAM split when the current settings exceed the budget.
1107
+ // Waits for metaBusy so the context default has landed first.
1108
+ useEffect(function () {
1109
+ if (!estimate || metaBusy || !selectedPath || fitAppliedRef.current) return;
1110
+ fitAppliedRef.current = true;
1111
+ if (estimate.fit && estimate.fit.n >= 0 && estimate.overBudget) applyFit();
1112
+ return undefined;
1113
+ });
1114
+
1115
+ // Fetch the vision projector (mmproj) metadata when picked.
1116
+ useEffect(function () {
1117
+ if (!mmprojPath) { setMmMetaRoot(null); setMmErr(null); return; }
1118
+ var cancelled = false;
1119
+ setMmErr(null);
1120
+ post("/local-models/gguf-meta", { path: mmprojPath })
1121
+ .then(function (res) {
1122
+ if (cancelled) return;
1123
+ if (res.ok && res.body && res.body.meta) setMmMetaRoot(res.body);
1124
+ else setMmErr((res.body && res.body.error) || "could not parse mmproj GGUF");
1125
+ })
1126
+ .catch(function (e2) { if (!cancelled) setMmErr(String(e2)); });
1127
+ return function () { cancelled = true; };
1128
+ }, [mmprojPath]);
1129
+
1130
+ var acting = busy || status.status === "starting";
1131
+ var running = status.status === "ready";
1132
+ var portUp = status.portUp === true;
1133
+ var stoppable = running || portUp;
1134
+ var canRun = !!meta && !!selectedPath && !acting && !running && status.mode !== "router" && kvPairOk;
1135
+ var statusText = statusLine(status, portUp);
1136
+
1137
+ var onCtxSlider = function (ev) {
1138
+ var v = Number(ev.target.value);
1139
+ setCtx(v);
1140
+ setCtxText(String(v));
1141
+ };
1142
+ var onCtxText = function (ev) {
1143
+ setCtxText(ev.target.value);
1144
+ var n = Number(ev.target.value);
1145
+ if (Number.isFinite(n) && n > 0) setCtx(Math.min(Math.floor(n), ctxMax));
1146
+ };
1147
+
1148
+ // MoE launch payload shared by Load and Save-profile. Invalid inputs
1149
+ // fall back to stock behavior (experts on GPU, stock top-k).
1150
+ var moePayload = function () {
1151
+ var n = Math.floor(Number(nCpuMoeText));
1152
+ var k = Math.floor(Number(expertUsedText));
1153
+ return {
1154
+ cpuMoe: moeMode === "cpu",
1155
+ nCpuMoe: moeMode === "layers" && Number.isFinite(n) && n > 0 ? n : 0,
1156
+ expertUsed: expertUsedText !== "" && Number.isFinite(k) && k > 0 ? k : null,
1157
+ arch: meta ? meta.arch : null,
1158
+ };
1159
+ };
1160
+
1161
+ // One-click fit-to-VRAM: switch to the computed expert split.
1162
+ var applyFit = function () {
1163
+ if (!estimate || !estimate.fit || estimate.fit.n < 0) return;
1164
+ if (estimate.fit.cpuAll) {
1165
+ setMoeMode("cpu");
1166
+ } else {
1167
+ setMoeMode("layers");
1168
+ setNCpuMoeText(String(estimate.fit.n));
1169
+ }
1170
+ };
1171
+
1172
+ // Clamp a ctx candidate the way the textbox does: finite, > 0,
1173
+ // capped at the model's train ctx (or the fallback ceiling).
1174
+ // Submit paths must use this — never raw ctxText.
1175
+ var clampCtx = function (n, fallback) {
1176
+ var v = Math.floor(Number(n));
1177
+ if (!Number.isFinite(v) || v <= 0) v = fallback;
1178
+ return Math.min(v, ctxMax);
1179
+ };
1180
+
1181
+ // Shared skeleton for the tab's action buttons: busy spinner,
1182
+ // clear-then-set error, POST, per-action follow-up, always
1183
+ // release busy. `then` receives the post() result.
1184
+ var runAction = function (path, body, then) {
1185
+ setBusy(true);
1186
+ setError(null);
1187
+ post(path, body)
1188
+ .then(then)
1189
+ .catch(function (e2) { setError(String(e2)); })
1190
+ .finally(function () { setBusy(false); });
1191
+ };
1192
+
1193
+ // A 404/405 from a /local-models route means the running dsh still
1194
+ // hosts the plugin from before the change (the node half is composed
1195
+ // at boot), so say what to do instead of showing a bare HTTP code.
1196
+ var withRestartHint = function (msg) {
1197
+ return /HTTP 40[45]/.test(msg)
1198
+ ? msg + " — restart dsh web so it picks up the updated plugin, then try again"
1199
+ : msg;
1200
+ };
1201
+
1202
+ var doRun = function () {
1203
+ runAction("/local-models/run", Object.assign({ path: selectedPath, ctx: clampCtx(ctxText, ctx), mtp: mtp, mmproj: mmprojPath || null, effort: effort, preserveThinking: preserveThinking, mmprojCpu: mmprojCpu, kvTypeK: kvTypeK, kvTypeV: kvTypeV, splitMode: splitMode, tensorSplit: tensorSplitText.trim() || null }, moePayload()),
1204
+ function (res) {
1205
+ if (!res.ok) setError((res.body && res.body.error) || "start failed");
1206
+ refresh();
1207
+ });
1208
+ };
1209
+
1210
+ var doStop = function () {
1211
+ pendingRouterRegRef.current = false;
1212
+ runAction("/local-models/stop", {},
1213
+ function (res) { if (!res.ok) setError((res.body && res.body.error) || "stop failed"); refresh(); });
1214
+ };
1215
+
1216
+ var doRegister = function () {
1217
+ runAction("/local-models/register", { route: "local-" + (status.alias || "model") },
1218
+ function (res) {
1219
+ if (res.ok) setRegistered(res.body.route + " → " + res.body.modelId);
1220
+ else setError((res.body && res.body.error) || "register failed");
1221
+ refresh();
1222
+ });
1223
+ };
1224
+
1225
+ var loadProfiles = function () {
1226
+ get("/local-models/profiles").then(function (body) {
1227
+ if (body && Array.isArray(body.profiles)) setProfiles(body.profiles);
1228
+ }).catch(function () {});
1229
+ };
1230
+ useEffect(function () { loadProfiles(); }, []);
1231
+
1232
+ var doSaveProfile = function () {
1233
+ var name = profileName.trim();
1234
+ if (!name || !selectedPath) return;
1235
+ runAction("/local-models/profiles", Object.assign({ name: name, modelPath: selectedPath, ctx: clampCtx(ctxText, ctx), mtpHeads: mtp, mmprojPath: mmprojPath || null, mmprojCpu: mmprojCpu, effort: effort, preserveThinking: preserveThinking, kvTypeK: kvTypeK, kvTypeV: kvTypeV, splitMode: splitMode, tensorSplit: tensorSplitText.trim() || null }, moePayload()),
1236
+ function (res) {
1237
+ if (!res.ok) setError((res.body && res.body.error) || "profile save failed");
1238
+ else { setProfileName(""); loadProfiles(); }
1239
+ });
1240
+ };
1241
+
1242
+ var doLoadProfile = function (p) {
1243
+ pendingProfileRef.current = { ctx: p.ctx, expertUsed: p.expertUsed };
1244
+ // Profiles own their MoE settings — no auto-fit override.
1245
+ fitAppliedRef.current = true;
1246
+ setSelectedPath(p.modelPath || "");
1247
+ if (p.ctx) { setCtx(p.ctx); setCtxText(String(p.ctx)); }
1248
+ // Clamp: old profiles may carry MTP 4-6, unsupported since the
1249
+ // move to upstream fixed MTP (depth > 3 collapses at large ctx).
1250
+ if (p.mtpHeads != null) setMtp(Math.min(p.mtpHeads, MTP_MAX));
1251
+ // Legacy profiles carry no KV pair: fall back to the defaults.
1252
+ setKvTypeK(normalizeKvType(p.kvTypeK, KV_DEFAULT_K));
1253
+ setKvTypeV(normalizeKvType(p.kvTypeV, KV_DEFAULT_V));
1254
+ setEffort(normalizeEffort(p.effort));
1255
+ setPreserveThinking(p.preserveThinking === true || p.preserve_thinking === true);
1256
+ // Profiles without MoE fields predate expert placement (launched
1257
+ // all-GPU), so reset to GPU instead of keeping a stale selection;
1258
+ // expertUsed "" = stock top-k.
1259
+ if (p.cpuMoe === true) setMoeMode("cpu");
1260
+ else if (p.nCpuMoe > 0) { setMoeMode("layers"); setNCpuMoeText(String(p.nCpuMoe)); }
1261
+ else setMoeMode("gpu");
1262
+ if (p.expertUsed != null) setExpertUsedText(String(p.expertUsed));
1263
+ else setExpertUsedText("");
1264
+ if (p.mmprojCpu === false) setMmprojCpu(false);
1265
+ else setMmprojCpu(true);
1266
+ // Legacy profiles have no split fields: llama.cpp's own default
1267
+ // (layer, proportions from VRAM) is what they launched with.
1268
+ setSplitMode(normalizeSplitMode(p.splitMode));
1269
+ setTensorSplitText(typeof p.tensorSplit === "string" ? p.tensorSplit : "");
1270
+ if (p.mmprojPath) {
1271
+ setMmprojPath(p.mmprojPath);
1272
+ clearedMmprojRef.current = false;
1273
+ } else {
1274
+ // No mmproj in this profile: drop it AND keep the status poll
1275
+ // from resurrecting the previous server's retained value.
1276
+ setMmprojPath(null);
1277
+ clearedMmprojRef.current = true;
1278
+ }
1279
+ setError(null);
1280
+ };
1281
+
1282
+ var doRemoveProfile = function (p) {
1283
+ runAction("/local-models/profiles/remove", { name: p.name },
1284
+ function (res) {
1285
+ if (!res.ok) setError((res.body && res.body.error) || "profile delete failed");
1286
+ loadProfiles();
1287
+ });
1288
+ };
1289
+
1290
+ var doStartRouter = function () {
1291
+ runAction("/local-models/router/start", {},
1292
+ function (res) {
1293
+ if (!res.ok) setError((res.body && res.body.error) || "router start failed");
1294
+ else pendingRouterRegRef.current = true;
1295
+ refresh();
1296
+ });
1297
+ };
1298
+
1299
+ var doUpdateSettings = function (patch) {
1300
+ runAction("/local-models/settings", patch,
1301
+ function (res) {
1302
+ if (!res.ok) {
1303
+ setError(withRestartHint((res.body && res.body.error) || "settings update failed"));
1304
+ }
1305
+ refresh();
1306
+ });
1307
+ };
1308
+
1309
+ var doSetAutostart = function (enabled) {
1310
+ doUpdateSettings({ autostartRouter: enabled });
1311
+ };
1312
+
1313
+ var doSetAutoUnload = function (mins) {
1314
+ doUpdateSettings({ autoUnloadMins: mins });
1315
+ };
1316
+
1317
+ // ---- Runtime card (llama.cpp binaries + browser folders) ----
1318
+ // "Check" tests a candidate without saving it: it resolves the path
1319
+ // through the same chain the launcher uses and runs `<bin> --version`.
1320
+ // Returns its promise so Save can chain the verification.
1321
+ var doCheckBin = function (candidate) {
1322
+ setBinBusy(true);
1323
+ setBinCheck(null);
1324
+ return post("/local-models/runtime/check", { binPath: candidate })
1325
+ .then(function (res) {
1326
+ if (!res.ok) setBinCheck({ ok: false, error: withRestartHint((res.body && res.body.error) || "check failed") });
1327
+ else setBinCheck(res.body);
1328
+ })
1329
+ .catch(function (e2) { setBinCheck({ ok: false, error: String(e2) }); })
1330
+ .finally(function () { setBinBusy(false); });
1331
+ };
1332
+
1333
+ // "Save" persists the path. The node half refuses a path that does
1334
+ // not resolve to a real llama-server, so a typo cannot be stored.
1335
+ var doSaveBin = function () {
1336
+ var next = binDraft.trim();
1337
+ setBinBusy(true);
1338
+ setError(null);
1339
+ post("/local-models/settings", { binPath: next })
1340
+ .then(function (res) {
1341
+ if (!res.ok) {
1342
+ setError(withRestartHint((res.body && res.body.error) || "could not save the binaries folder"));
1343
+ return undefined;
1344
+ }
1345
+ var saved = res.body.settings && typeof res.body.settings.binPath === "string"
1346
+ ? res.body.settings.binPath
1347
+ : next;
1348
+ setBinDraft(saved);
1349
+ setBinSaved(saved);
1350
+ // Verify what was just stored (and read its --version).
1351
+ return doCheckBin(saved);
1352
+ })
1353
+ .catch(function (e2) { setError(String(e2)); })
1354
+ .finally(function () { setBinBusy(false); refresh(); });
1355
+ };
1356
+
1357
+ // VRAM budget: committed on blur/Enter (no second Save button in the
1358
+ // card). "" or 0 = auto-detect through the node half's GPU probes.
1359
+ var doSaveVram = function () {
1360
+ if (!vramDirtyRef.current) return;
1361
+ var raw = vramDraft.trim().replace(",", ".");
1362
+ var gb = raw === "" ? 0 : Number(raw);
1363
+ if (!Number.isFinite(gb) || gb < 0 || gb > 1024) {
1364
+ setError("VRAM budget must be a number of GB between 0 and 1024 (empty = auto-detect)");
1365
+ return;
1366
+ }
1367
+ setError(null);
1368
+ post("/local-models/settings", { vramGb: gb })
1369
+ .then(function (res) {
1370
+ if (!res.ok) {
1371
+ setError(withRestartHint((res.body && res.body.error) || "could not save the VRAM budget"));
1372
+ return;
1373
+ }
1374
+ var savedGb = res.body.settings && res.body.settings.vramGb > 0 ? res.body.settings.vramGb : 0;
1375
+ var txt = savedGb > 0 ? String(savedGb) : "";
1376
+ setVramDraft(txt);
1377
+ setVramSaved(txt);
1378
+ })
1379
+ .catch(function (e2) { setError(String(e2)); })
1380
+ .finally(refresh);
1381
+ };
1382
+
1383
+ // Current list to mutate from: the local ref when we have one, else
1384
+ // the poll snapshot (first render after a page load).
1385
+ var shortcutBase = function () {
1386
+ if (Array.isArray(scListRef.current)) return scListRef.current;
1387
+ return (Array.isArray(status.shortcuts) ? status.shortcuts : []).map(function (s) { return s.path; });
1388
+ };
1389
+
1390
+ var saveShortcuts = function (list, failMsg) {
1391
+ setScBusy(true);
1392
+ setError(null);
1393
+ post("/local-models/settings", { shortcuts: list })
1394
+ .then(function (res) {
1395
+ if (!res.ok) {
1396
+ setError(withRestartHint((res.body && res.body.error) || failMsg));
1397
+ scPendingRef.current = null;
1398
+ return;
1399
+ }
1400
+ // The server echoes the stored list: adopt it instead of
1401
+ // guessing, so a rapid second edit builds on the real state.
1402
+ var saved = res.body.settings && Array.isArray(res.body.settings.shortcuts)
1403
+ ? res.body.settings.shortcuts
1404
+ : list;
1405
+ scListRef.current = saved;
1406
+ scPendingRef.current = { list: saved, at: Date.now() };
1407
+ })
1408
+ .catch(function (e2) { setError(String(e2)); })
1409
+ .finally(function () { setScBusy(false); refresh(); });
1410
+ };
1411
+
1412
+ var doAddShortcut = function () {
1413
+ var next = shortcutDraft.trim();
1414
+ if (!next) return;
1415
+ var list = shortcutBase();
1416
+ if (list.indexOf(next) >= 0) { setShortcutDraft(""); return; }
1417
+ setShortcutDraft("");
1418
+ saveShortcuts(list.concat([next]), "could not add the folder");
1419
+ };
1420
+
1421
+ var doRemoveShortcut = function (target) {
1422
+ saveShortcuts(shortcutBase().filter(function (p) { return p !== target; }), "could not remove the folder");
1423
+ };
1424
+
1425
+ var doUnloadAll = function () {
1426
+ runAction("/local-models/router/unload-all", {},
1427
+ function (res) {
1428
+ if (!res.ok) setError((res.body && res.body.error) || "unload-all failed");
1429
+ refresh();
1430
+ });
1431
+ };
1432
+
1433
+ // ---- render: actions + status (Model tab only) ----
1434
+ var loadBtnStyle = canRun ? style.button : Object.assign({}, style.button, style.disabled);
1435
+ var stopBtnStyle = !stoppable ? Object.assign({}, style.secondaryButton, style.disabled) : style.secondaryButton;
1436
+ var actionsRow = h(
1437
+ "div",
1438
+ { style: style.row },
1439
+ h("button", { style: loadBtnStyle, onClick: doRun, disabled: !canRun }, "Load model"),
1440
+ h("button", { style: stopBtnStyle, onClick: doStop, disabled: !stoppable }, "Stop"),
1441
+ // Single-model mode only: the router registers itself on start.
1442
+ running && status.mode !== "router" ? h("button", { style: style.button, onClick: doRegister, disabled: busy }, "Register in dsh") : null,
1443
+ h("span", { style: style.small }, statusText),
1444
+ );
1445
+ var statusCard = h(
1446
+ "div",
1447
+ { style: { display: "flex", flexDirection: "column", gap: 4, borderTop: "1px solid var(--dsw-alias-border-l2)", paddingTop: 8 } },
1448
+ h("div", {}, h("span", {}, "Status: "), h("span", { style: style.mono }, statusText)),
1449
+ h(KV, { label: "PID" }, String(status.pid || "—")),
1450
+ h(KV, { label: "ctx" }, String(status.ctx != null ? fmtCtx(status.ctx) : "—")),
1451
+ h(KV, { label: "mtp" }, String(status.mtpHeads != null ? status.mtpHeads : "—")),
1452
+ status.kvTypeK && status.kvTypeV ? h(KV, { label: "KV cache" }, kvLabel(status.kvTypeK) + " K / " + kvLabel(status.kvTypeV) + " V") : null,
1453
+ status.reasoningEffort != null ? h(KV, { label: "effort" }, String(status.reasoningEffort)) : null,
1454
+ status.preserveThinking != null ? h(KV, { label: "preserve" }, status.preserveThinking === true ? "on (--reasoning-preserve)" : "off (--no-reasoning-preserve)") : null,
1455
+ status.mmprojPath ? h(KV, { label: "vision" }, status.mmprojPath.split("/").pop() + (status.mmprojCpu === false ? " (gpu)" : " (cpu)")) : null,
1456
+ status.cpuMoe === true ? h(KV, { label: "experts" }, "CPU (all in RAM)" + (status.expertUsed != null ? ", top-" + status.expertUsed : "")) : (status.nCpuMoe > 0 ? h(KV, { label: "experts" }, "cpu-first-" + status.nCpuMoe + (status.expertUsed != null ? ", top-" + status.expertUsed : "")) : (status.expertUsed != null ? h(KV, { label: "experts" }, "top-" + status.expertUsed) : null)),
1457
+ status.splitMode && (status.splitMode !== SPLIT_DEFAULT || status.tensorSplit) ? h(KV, { label: "split" }, status.splitMode + (status.tensorSplit ? " " + status.tensorSplit : "")) : null,
1458
+ status.logPath ? h("div", {}, h("span", {}, "Log: "), h("span", { style: style.mono }, status.logPath)) : null,
1459
+ h("div", { style: style.row },
1460
+ h("button", { style: style.secondaryButton, onClick: function () { setTermOpen(true); } }, "Open terminal"),
1461
+ h("span", { style: style.small }, "live tail of the llama-server output (the log above)"),
1462
+ ),
1463
+ );
1464
+ // ---- render: pick + meta + launch options ----
1465
+ var pickedName = selectedPath ? selectedPath.split("/").pop() : "";
1466
+ var modelBody = h(
1467
+ "div",
1468
+ { style: { display: "flex", flexDirection: "column", gap: 8 } },
1469
+ h(
1470
+ "div",
1471
+ { style: style.row },
1472
+ h("button", { style: style.button, onClick: function () { setPickerOpen(true); } }, "Choose GGUF…"),
1473
+ selectedPath
1474
+ ? h("span", { style: { fontSize: 13, fontWeight: 500 } }, pickedName)
1475
+ : h("span", { style: style.small }, "no model selected — pick a .gguf file"),
1476
+ ),
1477
+ selectedPath ? h("div", { style: { fontSize: 12, lineHeight: "18px", color: "var(--dsw-alias-label-secondary)", fontFamily: "var(--ds-font-family-code)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, title: selectedPath }, selectedPath) : null,
1478
+ selectedPath ? h(
1479
+ "div",
1480
+ { style: { display: "flex", flexDirection: "column", gap: 2 } },
1481
+ metaBusy ? h("span", { style: style.small }, "parsing header…") : null,
1482
+ metaErr ? h("p", { style: style.error }, String(metaErr)) : null,
1483
+ meta ? h("span", { style: style.small }, "weights " + fmtGiB(metaRoot.weightsBytes)) : null,
1484
+ (meta && meta.warnings && meta.warnings.length > 0 ? meta.warnings.map(function (w, i) { return h("p", { key: i, style: style.warn }, "⚠ " + w); }) : null),
1485
+ ) : null,
1486
+ meta ? h(
1487
+ "div",
1488
+ { style: style.row },
1489
+ h("span", { style: style.cardTitle }, "Context"),
1490
+ h("input", { type: "range", min: 8192, max: Math.max(8192, ctxMax), step: CTX_STEP, value: ctx, style: style.range, onChange: onCtxSlider, disabled: !meta }),
1491
+ h("input", { style: style.input, value: ctxText, onChange: onCtxText, disabled: !meta, spellCheck: false }),
1492
+ ) : null,
1493
+ meta ? h(FieldRow, {
1494
+ label: "KV cache",
1495
+ control: h("span", { style: style.row },
1496
+ h("span", { style: style.small }, "K"),
1497
+ h("select", { style: style.select, value: kvTypeK, title: "--cache-type-k (key store)", onChange: function (ev) { setKvTypeK(ev.target.value); }, disabled: !meta },
1498
+ KV_TYPES.map(function (t) { return h("option", { key: t.id, value: t.id }, kvOptionLabel(t)); }),
1499
+ ),
1500
+ h("span", { style: style.small }, "V"),
1501
+ h("select", { style: style.select, value: kvTypeV, title: "--cache-type-v (value store)", onChange: function (ev) { setKvTypeV(ev.target.value); }, disabled: !meta },
1502
+ KV_TYPES.map(function (t) { return h("option", { key: t.id, value: t.id }, kvOptionLabel(t)); }),
1503
+ ),
1504
+ ),
1505
+ hint: "key / value cache quantization (--cache-type-k / --cache-type-v). This pair costs ~" + Math.round(100 * (KV_BPE[kvTypeK] + KV_BPE[kvTypeV]) / (KV_BPE[KV_DEFAULT_K] + KV_BPE[KV_DEFAULT_V])) + "% of the default " + KV_DEFAULT_K + "/" + KV_DEFAULT_V + " KV bytes; quantized V needs flash-attn (always on here) and the MTP draft KV stays q4_0.",
1506
+ }) : null,
1507
+ meta && !kvPairOk ? h("p", { style: style.warn }, "⚠ MLA model (latent KV): llama-server requires the same cache type for K and V — set both to " + kvTypeK + ".") : null,
1508
+ meta ? h(FieldRow, {
1509
+ label: "Max MTP head",
1510
+ control: h("select", { style: style.select, value: mtp, onChange: function (ev) { setMtp(Number(ev.target.value)); }, disabled: !meta },
1511
+ MTP_OPTIONS.map(function (n) { return h("option", { key: n, value: n }, n === 0 ? "off" : String(n)); }),
1512
+ ),
1513
+ }) : null,
1514
+ // Rendered with >1 GPU, and also when a profile or the stored tab
1515
+ // state carries a non-default split — otherwise that value would
1516
+ // keep reaching /run with no way to see or clear it.
1517
+ splitVisible ? h(FieldRow, {
1518
+ label: "Multi-GPU split",
1519
+ control: h("span", { style: style.row },
1520
+ h("select", {
1521
+ style: style.select,
1522
+ value: splitMode,
1523
+ title: "--split-mode: how the model is spread over the " + gpuCount + " detected GPUs",
1524
+ onChange: function (ev) { setSplitMode(ev.target.value); },
1525
+ disabled: !meta,
1526
+ },
1527
+ SPLIT_MODES.map(function (m) { return h("option", { key: m.id, value: m.id }, m.label); }),
1528
+ ),
1529
+ h("input", {
1530
+ style: Object.assign({}, style.input, { width: 96 }),
1531
+ value: tensorSplitText,
1532
+ placeholder: evenSplit(gpus) || "1,1",
1533
+ title: "--tensor-split: fraction of the model per GPU, e.g. 3,1",
1534
+ onChange: function (ev) { setTensorSplitText(ev.target.value); },
1535
+ disabled: !meta,
1536
+ spellCheck: false,
1537
+ }),
1538
+ h("span", { style: style.small }, "per-GPU proportions (--tensor-split)"),
1539
+ ),
1540
+ hint: gpuCount > 1
1541
+ ? gpuCount + " GPUs detected — layer pipelines the model across them, row/tensor parallelize each layer (tensor parallelism). Empty proportions = split in proportion to VRAM. The estimate stays a whole-machine budget: only a real per-GPU measurement can tell whether one card is over."
1542
+ : "set by the loaded profile or the stored tab state, but only " + gpuCount + " GPU was detected — llama.cpp ignores the split flags with a single device. Switch back to layer and clear the proportions to drop them.",
1543
+ }) : null,
1544
+ meta ? h(FieldRow, {
1545
+ label: "Force thinking level",
1546
+ control: h("select", { style: style.select, value: effort, onChange: function (ev) { setEffort(ev.target.value); }, disabled: !meta },
1547
+ EFFORT_LEVELS.map(function (level) { return h("option", { key: level, value: level }, level); }),
1548
+ ),
1549
+ }) : null,
1550
+ meta && effort !== "off" ? h(
1551
+ "div",
1552
+ { style: style.row },
1553
+ h("label", { style: { display: "flex", flexDirection: "row", alignItems: "center", gap: 6 } },
1554
+ h("input", { type: "checkbox", checked: preserveThinking, onChange: function (ev) { setPreserveThinking(ev.target.checked); } }),
1555
+ h("span", { style: style.small }, "preserve thinking across turns (--reasoning-preserve; off = --no-reasoning-preserve)"),
1556
+ ),
1557
+ ) : null,
1558
+ h(
1559
+ "div",
1560
+ { style: style.row },
1561
+ h("button", { style: style.secondaryButton, onClick: function () { setMmPickerOpen(true); } }, "Vision mmproj (optional)"),
1562
+ mmprojPath ? h("span", { style: style.mono }, mmprojName(mmprojPath) + (mmMetaRoot ? " (" + fmtGiB(mmMetaRoot.weightsBytes) + ")" : "")) : h("span", { style: style.small }, "none — vision models need --mmproj"),
1563
+ mmprojPath ? h("button", { style: style.secondaryButton, onClick: function () { setMmprojPath(null); setMmMetaRoot(null); setMmErr(null); clearedMmprojRef.current = true; } }, "Clear") : null,
1564
+ mmprojPath ? h("label", { style: { display: "flex", flexDirection: "row", alignItems: "center", gap: 6 } },
1565
+ h("input", { type: "checkbox", checked: mmprojCpu, onChange: function (ev) { setMmprojCpu(ev.target.checked); } }),
1566
+ h("span", { style: style.small }, "mmproj on CPU (weights in RAM, frees ~0.9 GiB VRAM; encode ~15-22 s)"),
1567
+ ) : null,
1568
+ mmErr ? h("span", { style: style.error }, String(mmErr)) : null,
1569
+ ),
1570
+ moeCard(meta, estimate, applyFit, moeMode, setMoeMode, nCpuMoeText, setNCpuMoeText, expertUsedText, setExpertUsedText, vramGb),
1571
+ estimate ? h(
1572
+ "div",
1573
+ { style: { display: "flex", flexDirection: "column", gap: 4, borderTop: "1px solid var(--dsw-alias-border-l2)", paddingTop: 8 } },
1574
+ h("span", { style: style.cardTitle }, "VRAM estimate"),
1575
+ estimateBlock(),
1576
+ ) : null,
1577
+ actionsRow,
1578
+ statusCard,
1579
+ );
1580
+
1581
+ // ---- render: VRAM estimate (lives inside the Model tab) ----
1582
+ function estimateBlock() {
1583
+ if (!estimate) return null;
1584
+ if (estimate.unavailable) {
1585
+ return h("p", { style: style.small }, "Attention dimensions missing from the GGUF header — KV estimate unavailable.");
1586
+ }
1587
+ var layout = estimate.gdn.hybrid
1588
+ ? String(estimate.gdn.attnTrunk) + " attn + " + (estimate.specOn ? estimate.gdn.mtp : 0) + " MTP · " + (estimate.gdn.trunk - estimate.gdn.attnTrunk) + " linear (GDN)"
1589
+ : (meta.nLayers != null ? String(meta.nLayers) + " attn" : "—");
1590
+ return h(
1591
+ "div",
1592
+ { style: { display: "flex", flexDirection: "column", gap: 4, fontSize: 13, lineHeight: "20px" } },
1593
+ h(KV, { label: "Weights" + (estimate.cpuExpertBytes > 0 ? " (VRAM-resident)" : "") }, fmtGiB(estimate.weightsVram != null ? estimate.weightsVram : estimate.weights)),
1594
+ estimate.cpuExpertBytes > 0 ? h(KV, { label: "Experts in RAM" }, fmtGiB(estimate.cpuExpertBytes)) : null,
1595
+ estimate.moeUnknown ? h("p", { style: style.small }, "Expert split unknown for this layout — the estimate assumes all weights in VRAM.") : null,
1596
+ h(KV, { label: "KV cache (" + kvLabel(kvTypeK) + " K / " + kvLabel(kvTypeV) + " V, " + layout + ")" }, fmtGiB(estimate.kv)),
1597
+ estimate.recrFixed > 0 ? h(KV, { label: "GDN recurrent state" }, fmtGiB(estimate.recrFixed)) : null,
1598
+ h(KV, { label: "Compute / graph" }, fmtGiB(estimate.compute)),
1599
+ estimate.mmprojBytes > 0 ? h(KV, { label: "Vision encoder (mmproj)" }, fmtGiB(estimate.mmprojBytes)) : null,
1600
+ h(KV, { label: "Overhead" }, fmtMiB(OVERHEAD_BYTES)),
1601
+ h(KV, { strong: true, label: "Total (" + vramGb + " GB VRAM)" }, fmtGiB(estimate.total) + " (" + estimate.pct + "%)"),
1602
+ h(KV, { label: "GPU", tone: gpuCount > 1 ? style.good : undefined }, gpuLabel),
1603
+ h(KV, { label: "Fits " + vramGb + " GB", tone: estimate.fits ? style.good : style.bad }, estimate.fits ? "yes" : "no"),
1604
+ h(KV, { label: "Fits with 700 MB margin", tone: estimate.fitsSafe ? style.good : style.bad }, estimate.fitsSafe ? "yes" : "no"),
1605
+ estimate.maxCtx != null ? h(KV, { label: "Max ctx that fits " + vramGb + " GB" }, fmtCtx(estimate.maxCtx)) : null,
1606
+ );
1607
+ }
1608
+
1609
+ // ---- render: profiles ----
1610
+ var profilesBody = h(
1611
+ "div",
1612
+ { style: { display: "flex", flexDirection: "column", gap: 8 } },
1613
+ h("p", { style: style.small }, "Save the current selection to reload it in one click (model, ctx, MTP, thinking level + preserve, mmproj)."),
1614
+ h("div", { style: style.row },
1615
+ h("input", { style: Object.assign({}, style.input, { width: 260 }), value: profileName, onChange: function (ev) { setProfileName(ev.target.value); }, placeholder: "Profile name", disabled: !selectedPath }),
1616
+ h("button", { style: style.button, onClick: doSaveProfile, disabled: !selectedPath || busy }, "Save current"),
1617
+ ),
1618
+ profiles.length === 0
1619
+ ? h("p", { style: style.small }, "No profiles yet — save one to reload everything in a click.")
1620
+ : h("div", { style: { display: "flex", flexDirection: "column", gap: 8 } },
1621
+ profiles.map(function (p) {
1622
+ var pSplit = normSplitText(p.tensorSplit);
1623
+ return h("div", { key: String(p.id || p.name), style: { border: "1px solid var(--dsw-alias-border-l3)", borderRadius: 10, padding: "6px 10px", display: "flex", flexDirection: "row", alignItems: "center", gap: 8 } },
1624
+ h("div", { style: { display: "flex", flexDirection: "column", gap: 0, flex: 1, minWidth: 0 } },
1625
+ h("span", { style: style.mono }, String(p.name || p.id || "")),
1626
+ h("span", { style: style.small }, String(p.modelPath || "").split("/").pop() + " · ctx " + fmtCtx(p.ctx || 0) + " · " + (p.effort || "medium") + ((p.preserveThinking === true || p.preserve_thinking === true) && p.effort !== "off" ? " · preserve" : "") + (p.mtpHeads > 0 ? " · mtp " + p.mtpHeads : "") + " · KV " + normalizeKvType(p.kvTypeK, KV_DEFAULT_K) + "/" + normalizeKvType(p.kvTypeV, KV_DEFAULT_V) + (p.mmprojPath ? " · vision(" + (p.mmprojCpu === false ? "gpu" : "cpu") + ")" : "") + (p.cpuMoe === true ? " · experts CPU" : (p.nCpuMoe > 0 ? " · experts cpu×" + p.nCpuMoe : "")) + (p.expertUsed > 0 ? " · top-" + p.expertUsed : "") + (normalizeSplitMode(p.splitMode) !== SPLIT_DEFAULT || pSplit ? " · split " + normalizeSplitMode(p.splitMode) + (pSplit ? " " + pSplit : "") : "")),
1627
+ ),
1628
+ h("button", { style: style.secondaryButton, onClick: function () { doLoadProfile(p); } }, "Load"),
1629
+ h("button", { style: Object.assign({}, style.secondaryButton, { color: "var(--dsw-alias-state-error-primary)" }), onClick: function () { doRemoveProfile(p); } }, "Delete"),
1630
+ );
1631
+ }),
1632
+ ),
1633
+ );
1634
+
1635
+ // ---- render: runtime (llama.cpp binaries + browser folders) ----
1636
+ // The binary is resolved server-side on every launch; this card only
1637
+ // reports what that chain picks and lets the user repoint it.
1638
+ var binInfo = status.bin || null;
1639
+ var binResolved = binInfo && binInfo.resolved ? binInfo.resolved : null;
1640
+ var binOk = Boolean(binResolved) && binInfo.executable === true;
1641
+ var binLine = !binInfo
1642
+ ? "resolving…"
1643
+ : binResolved
1644
+ ? binResolved + " · " + (binInfo.sourceLabel || binInfo.source) + (status.binVersion ? " · " + status.binVersion : "") + (binOk ? "" : " · not executable — chmod +x it")
1645
+ : "none found — set the folder above, or install llama-server on PATH";
1646
+ var binTone = !binInfo
1647
+ ? style.mono
1648
+ : binOk ? Object.assign({}, style.mono, style.good) : Object.assign({}, style.mono, style.bad);
1649
+ var binDirtyNow = binDraft.trim() !== binSaved;
1650
+ var binCheckBlock = binCheck
1651
+ ? (function () {
1652
+ var cb = binCheck.bin || null;
1653
+ var cv = binCheck.version || null;
1654
+ if (binCheck.ok && cb && cb.resolved) {
1655
+ return h("p", { style: Object.assign({}, style.small, style.good) },
1656
+ "✓ " + cb.resolved + (cv && cv.version ? " — " + cv.version : " — starts"));
1657
+ }
1658
+ if (cb && cb.resolved) {
1659
+ return h("p", { style: Object.assign({}, style.small, style.bad) },
1660
+ "✗ " + (binCheck.error || (cb.resolved + (cv && cv.error ? " — " + cv.error : (cb.executable ? "" : " — not executable (chmod +x)")))));
1661
+ }
1662
+ return h(
1663
+ "div",
1664
+ { style: { display: "flex", flexDirection: "column", gap: 2 } },
1665
+ h("p", { style: Object.assign({}, style.small, style.bad) },
1666
+ "✗ no llama-server in " + (binDraft.trim() || "(auto-detect)") + (binCheck.error ? " — " + binCheck.error : "")),
1667
+ cb && cb.candidates && cb.candidates.length > 0
1668
+ ? h("span", { style: style.small }, "looked at: " + cb.candidates.slice(0, 6).map(function (c) { return c.path; }).join(" · "))
1669
+ : null,
1670
+ );
1671
+ })()
1672
+ : null;
1673
+ var shortcuts = Array.isArray(status.shortcuts) ? status.shortcuts : [];
1674
+ // Optimistic view for the chips: the local list (refreshed from every
1675
+ // POST response) wins over the poll snapshot, which can be up to 2 s
1676
+ // behind — otherwise a removed folder stays on screen and the next
1677
+ // click lands on a stale row. `exists` still comes from the poll.
1678
+ var shortcutsView = Array.isArray(scListRef.current)
1679
+ ? scListRef.current.map(function (path) {
1680
+ var hit = shortcuts.filter(function (x) { return x.path === path; })[0];
1681
+ return { label: (hit && hit.label) || path.split("/").pop() || path, path: path, exists: hit ? hit.exists : undefined };
1682
+ })
1683
+ : shortcuts;
1684
+ var chipStyle = { display: "flex", alignItems: "center", gap: 6, border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 14, padding: "2px 6px 2px 10px" };
1685
+ var chipXStyle = { border: "none", background: "transparent", color: "inherit", font: "inherit", fontSize: 14, lineHeight: "18px", cursor: "pointer", padding: "0 2px" };
1686
+ var runtimeBody = h(
1687
+ "div",
1688
+ { style: { display: "flex", flexDirection: "column", gap: 8 } },
1689
+ // Label on its own line, controls below: the label + a path input
1690
+ // + three buttons do not fit one row in this card.
1691
+ h(
1692
+ "div",
1693
+ { style: { display: "flex", flexDirection: "column", gap: 4 } },
1694
+ h("span", { style: style.cardTitle }, "llama.cpp binaries"),
1695
+ h("div",
1696
+ { style: style.row },
1697
+ h("input", {
1698
+ style: Object.assign({}, style.input, { flex: "1 1 240px", width: "auto", minWidth: 160 }),
1699
+ value: binDraft,
1700
+ placeholder: "auto-detect (PATH, ~/llama.cpp/build/bin…)",
1701
+ onChange: function (ev) { setBinDraft(ev.target.value); setBinCheck(null); },
1702
+ spellCheck: false,
1703
+ }),
1704
+ h("button", { style: style.secondaryButton, onClick: function () { setBinPickerOpen(true); } }, "Browse…"),
1705
+ h("button", { style: binBusy ? Object.assign({}, style.secondaryButton, style.disabled) : style.secondaryButton, disabled: binBusy, onClick: function () { doCheckBin(binDraft.trim()); } }, binBusy ? "Checking…" : "Check"),
1706
+ h("button", { style: !binDirtyNow || binBusy ? Object.assign({}, style.button, style.disabled) : style.button, disabled: !binDirtyNow || binBusy, onClick: doSaveBin }, "Save"),
1707
+ ),
1708
+ h("span", { style: style.small }, "the folder holding llama-server (or the binary itself). Empty = auto-detect: LOCAL_MODELS_BIN, then PATH, then the usual build dirs. Applied on the next start — no dsh restart."),
1709
+ ),
1710
+ h(
1711
+ "div",
1712
+ { style: { display: "flex", flexDirection: "column", gap: 2 } },
1713
+ h("span", { style: style.cardTitle }, "in use"),
1714
+ h("span", { style: Object.assign({}, binTone, { wordBreak: "break-all" }) }, binLine),
1715
+ // A saved path that did not win the chain means the setting is
1716
+ // stale (bad path, or a llama.cpp tree that moved): say so
1717
+ // instead of quietly launching a different binary.
1718
+ binInfo && binInfo.configured && binInfo.source !== "setting"
1719
+ ? h("p", { style: style.warn }, "the saved path (" + binInfo.configured + ") did not resolve — this fallback is what will launch. Fix it above and Save.")
1720
+ : null,
1721
+ // No `bin` in the poll answer = the running dsh still hosts the
1722
+ // node half from before this card existed (it loads at boot).
1723
+ !binInfo ? h("span", { style: style.small }, "if this never fills in, restart dsh web (the node half of the plugin loads at boot) and reload this page.") : null,
1724
+ ),
1725
+ binCheckBlock,
1726
+ binDirtyNow ? h("p", { style: style.warn }, "unsaved change — press Save to persist it (Check only tests it).") : null,
1727
+ h(FieldRow, {
1728
+ label: "Model folders",
1729
+ control: h("span", { style: style.row },
1730
+ h("input", {
1731
+ style: Object.assign({}, style.input, { width: 260 }),
1732
+ value: shortcutDraft,
1733
+ placeholder: "/mnt/models",
1734
+ onChange: function (ev) { setShortcutDraft(ev.target.value); },
1735
+ onKeyDown: function (ev) { if (ev.key === "Enter") doAddShortcut(); },
1736
+ spellCheck: false,
1737
+ }),
1738
+ h("button", { style: !shortcutDraft.trim() || scBusy ? Object.assign({}, style.secondaryButton, style.disabled) : style.secondaryButton, disabled: !shortcutDraft.trim() || scBusy, onClick: doAddShortcut }, "Add folder"),
1739
+ ),
1740
+ hint: "shortcut buttons in the file picker. " + (status.shortcutsSource === "env" ? "Currently from LOCAL_MODELS_SHORTCUTS (add one here to take over)." : "Home is always reachable."),
1741
+ }),
1742
+ shortcutsView.length === 0
1743
+ ? h("span", { style: style.small }, "no shortcut folders")
1744
+ : h("div", { style: style.row },
1745
+ shortcutsView.map(function (s, i) {
1746
+ return h("span", { key: i, style: s.exists === false ? Object.assign({}, chipStyle, style.disabled) : chipStyle, title: s.exists === false ? s.path + " (missing)" : s.path },
1747
+ h("span", { style: style.mono }, s.label || s.path),
1748
+ s.exists === false ? h("span", { style: style.small }, "not found") : null,
1749
+ h("button", { style: chipXStyle, disabled: scBusy, title: "remove " + s.path, onClick: function () { doRemoveShortcut(s.path); } }, "×"));
1750
+ }),
1751
+ ),
1752
+ h(FieldRow, {
1753
+ label: "VRAM budget",
1754
+ control: h("span", { style: style.row },
1755
+ h("input", {
1756
+ style: Object.assign({}, style.input, { width: 90 }),
1757
+ value: vramDraft,
1758
+ placeholder: fmtVramGb(vramBytes),
1759
+ title: "GiB used by the estimator; empty = auto-detect",
1760
+ onChange: function (ev) { setVramDraft(ev.target.value); },
1761
+ onBlur: doSaveVram,
1762
+ onKeyDown: function (ev) { if (ev.key === "Enter") doSaveVram(); },
1763
+ spellCheck: false,
1764
+ }),
1765
+ h("span", { style: style.small }, "GB — empty = auto (" + gpuLabel + ")"),
1766
+ ),
1767
+ hint: "what the fit estimate counts as available VRAM. Auto reads nvidia-smi (NVIDIA) or sysfs (AMD) and sums every GPU; pin it when the hardware is not detected.",
1768
+ }),
1769
+ );
1770
+
1771
+ // ---- render: router ----
1772
+ var routerBody = h(
1773
+ "div",
1774
+ { style: { display: "flex", flexDirection: "column", gap: 8 } },
1775
+ h(
1776
+ "div",
1777
+ { style: style.row },
1778
+ h("button", { style: style.button, onClick: doStartRouter, disabled: busy || status.status === "ready" }, "Start Router"),
1779
+ status.mode === "router" && running ? h("button", { style: style.secondaryButton, onClick: doStop, disabled: !stoppable }, "Kill Router") : null,
1780
+ status.mode === "router" && (status.loadedModels || []).length > 0 ? h("button", { style: style.secondaryButton, onClick: doUnloadAll, disabled: busy }, "Unload all models") : null,
1781
+ ),
1782
+ h(
1783
+ "label",
1784
+ { style: style.small },
1785
+ h("input", {
1786
+ type: "checkbox",
1787
+ checked: status.autostartRouter === true,
1788
+ disabled: busy,
1789
+ onChange: function (ev) { doSetAutostart(ev.target.checked); },
1790
+ }),
1791
+ " Start the router automatically when dsh starts (needs at least one saved profile)",
1792
+ ),
1793
+ (function () {
1794
+ var mins = status.autoUnloadMins != null ? status.autoUnloadMins : 30;
1795
+ var desired = mins > 0 ? mins * 60 : null;
1796
+ var live = status.status === "ready" || status.status === "starting";
1797
+ var stale = live && status.sleepIdleSecs !== desired;
1798
+ return h(
1799
+ "div",
1800
+ { style: { display: "flex", flexDirection: "column", gap: 4 } },
1801
+ h(
1802
+ "label",
1803
+ { style: style.small },
1804
+ "Unload models after ",
1805
+ h("select", {
1806
+ style: style.select,
1807
+ value: mins,
1808
+ disabled: busy,
1809
+ onChange: function (ev) { doSetAutoUnload(Number(ev.target.value)); },
1810
+ },
1811
+ [0, 5, 15, 30, 60, 120].map(function (m) {
1812
+ return h("option", { key: m, value: m }, m === 0 ? "Off (never)" : m + " min idle");
1813
+ })),
1814
+ " of idleness.",
1815
+ ),
1816
+ h("p", { style: style.small }, "Frees VRAM while idle; the next request reloads automatically (one slow request). Takes effect on next start."),
1817
+ stale ? h("p", { style: style.warn }, "Timer changed — restart the server to apply it now.") : null,
1818
+ );
1819
+ })(),
1820
+ );
1821
+
1822
+ // ---- render: page ----
1823
+ function titledCard(title, body) {
1824
+ return h(
1825
+ "div",
1826
+ { style: style.card },
1827
+ h("h3", { style: style.cardTitle }, title),
1828
+ body,
1829
+ );
1830
+ }
1831
+ return h(
1832
+ "div",
1833
+ { style: style.page },
1834
+ h("h2", { style: style.title }, "Local Models"),
1835
+ h("p", { style: style.intro }, "Pick a GGUF, tune context and MTP, and load it through llama-server — it lives with this dsh process and gets killed when dsh shuts down."),
1836
+ titledCard("Runtime", runtimeBody),
1837
+ titledCard("Router", routerBody),
1838
+ titledCard("Profiles", profilesBody),
1839
+ titledCard("Model", modelBody),
1840
+ registered !== null ? h("div", {}, h("span", {}, "Registered: "), h("span", { style: style.mono }, registered)) : null,
1841
+ error !== null ? h("p", { style: style.error }, String(error)) : null,
1842
+ pickerOpen ? h(FileBrowserModal, { allFiles: false, shortcuts: status.shortcuts || [], onClose: function () { setPickerOpen(false); }, onPick: function (p) { setPickerOpen(false); setSelectedPath(p); setMetaRoot(null); fitAppliedRef.current = false; }, startDir: "" }) : null,
1843
+ mmPickerOpen ? h(FileBrowserModal, { allFiles: true, shortcuts: status.shortcuts || [], onClose: function () { setMmPickerOpen(false); }, onPick: function (p) { setMmPickerOpen(false); setMmprojPath(p); setMmMetaRoot(null); clearedMmprojRef.current = false; }, startDir: "" }) : null,
1844
+ binPickerOpen ? h(FileBrowserModal, {
1845
+ dirMode: true,
1846
+ shortcuts: status.shortcuts || [],
1847
+ onClose: function () { setBinPickerOpen(false); },
1848
+ onPick: function (p) { setBinPickerOpen(false); setBinDraft(p); setBinCheck(null); },
1849
+ // Start where the binary already is, so "use this folder" is
1850
+ // one click for the common case of a rebuild in place.
1851
+ startDir: binResolved ? binResolved.replace(/\/[^/]*$/, "") : "",
1852
+ }) : null,
1853
+ termOpen ? h(TerminalModal, { logPath: status.logPath || "", onClose: function () { setTermOpen(false); } }) : null,
1854
+ );
1855
+ }
1856
+
1857
+ // ---- MoE card (render helper, not a component: needs live estimate) ----
1858
+ function mmprojName(p) { return p ? p.split("/").pop() : ""; }
1859
+ function moeCard(meta, estimate, applyFit, moeMode, setMoeMode, nCpuMoeText, setNCpuMoeText, expertUsedText, setExpertUsedText, vramGb) {
1860
+ if (!meta || !meta.isMoe) return null;
1861
+ var stockTopK = meta.expertUsedCount != null ? String(meta.expertUsedCount) : "?";
1862
+ var moeInfo = String(meta.expertCount != null ? meta.expertCount : "?") + " experts · top-" + stockTopK + " active" +
1863
+ (meta.expertSharedCount > 0 ? " (+" + meta.expertSharedCount + " shared)" : "") +
1864
+ (meta.valueExpertCount > 0 ? " · attention MoE " + meta.valueExpertCount + "/top-" + (meta.valueExpertUsedCount != null ? meta.valueExpertUsedCount : "?") : "") +
1865
+ (meta.moeSource === "tensors" ? " · detected from tensors (no metadata)" : "");
1866
+ return h(
1867
+ "div",
1868
+ { style: { display: "flex", flexDirection: "column", gap: 8 } },
1869
+ h("span", { style: style.cardTitle }, "Mixture of Experts"),
1870
+ h("span", { style: style.mono }, moeInfo),
1871
+ estimate && estimate.fit && estimate.fit.n >= 0 && estimate.overBudget ? h(
1872
+ "div",
1873
+ { style: style.row },
1874
+ h("span", { style: style.warn }, "Doesn't fit " + vramGb + " GB as configured — " + (estimate.fit.cpuAll ? "all experts to CPU" : "cpu-first-" + estimate.fit.n) + " fits (~" + fmtGiB(estimate.fit.total) + ")."),
1875
+ h("button", { style: style.secondaryButton, onClick: applyFit }, "Apply fit"),
1876
+ ) : null,
1877
+ estimate && estimate.fit && estimate.fit.n === -1 ? h("p", { style: style.warn }, "Even all experts on CPU exceeds the " + vramGb + " GB budget — reduce context.") : null,
1878
+ h(FieldRow, {
1879
+ label: "Experts on",
1880
+ control: h("span", { style: style.row },
1881
+ h("select", { style: style.select, value: moeMode, onChange: function (ev) { setMoeMode(ev.target.value); } },
1882
+ h("option", { value: "gpu" }, "GPU (all in VRAM)"),
1883
+ h("option", { value: "cpu" }, "CPU (all in RAM)"),
1884
+ h("option", { value: "layers" }, "CPU (first N layers)"),
1885
+ ),
1886
+ moeMode === "layers" ? h("input", { style: Object.assign({}, style.input, { width: 90 }), value: nCpuMoeText, onChange: function (ev) { setNCpuMoeText(ev.target.value); }, spellCheck: false }) : null,
1887
+ moeMode === "layers" ? h("span", { style: style.small }, "layers") : null,
1888
+ ),
1889
+ }),
1890
+ h(FieldRow, {
1891
+ label: "Active experts (top-k)",
1892
+ control: h("input", { style: Object.assign({}, style.input, { width: 90 }), value: expertUsedText, placeholder: stockTopK, onChange: function (ev) { setExpertUsedText(ev.target.value); }, spellCheck: false }),
1893
+ hint: "empty = stock (" + stockTopK + ") — launches with --override-kv when set",
1894
+ }),
1895
+ h("span", { style: style.small }, "CPU experts live in RAM and compute on the CPU (not paged to VRAM per token): only the VRAM-resident weights count in the estimate — expect slower decode than full-VRAM loads."),
1896
+ );
1897
+ }
1898
+ var inject = ["slots"];
1899
+ function apply(ctx) {
1900
+ ctx.slots.inject("settings.section", function () {
1901
+ return ctx.slots.register(
1902
+ {
1903
+ name: "settings.section",
1904
+ id: "local-models",
1905
+ order: 20,
1906
+ label: function () { return "Local Models"; },
1907
+ inject: function () { return {}; },
1908
+ },
1909
+ LocalModelsSection,
1910
+ );
1911
+ });
1912
+ }
1913
+ exports.apply = apply;
1914
+ exports.inject = inject;
1915
+ return module.exports;
1916
+ },
1917
+ });