dsh-local-models 0.3.2 → 0.5.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/README-es.md +1 -1
- package/README-hi.md +1 -1
- package/README-pt.md +1 -1
- package/README-zh.md +1 -1
- package/README.md +4 -2
- package/lib/client.js +76 -2
- package/lib/index.js +699 -24
- package/package.json +1 -1
- package/skills/dsh-local-models-ops/SKILL.md +10 -3
package/README-es.md
CHANGED
|
@@ -67,7 +67,7 @@ dsh plugin --profile web remove dsh-local-models
|
|
|
67
67
|
|
|
68
68
|
## Usage
|
|
69
69
|
|
|
70
|
-
1. **Choose GGUF…** — elige un archivo de modelo (
|
|
70
|
+
1. **Choose GGUF…** — elige un archivo de modelo (botones Home / Root, atajos Models, navegación con Up). Up sube hasta `/`, así que un modelo en un disco montado (`/mnt/…`, `/media/…`) se abre sin registrar antes su carpeta.
|
|
71
71
|
2. Ajusta **context**, **KV cache K / V**, **Max MTP head** (borrador fijo, 0-7; 3 es el punto óptimo ajustado — más profundidad colapsa con ctx grandes), **thinking level** + casilla **preserve thinking**, y los ajustes opcionales de **mmproj** y **MoE**.
|
|
72
72
|
3. **Load model**, observa la tarjeta de estado, inspecciona la salida con **Open terminal**.
|
|
73
73
|
4. **Register in dsh** — la ruta (por defecto `local-<alias>`) aparece en el selector de modelos.
|
package/README-hi.md
CHANGED
|
@@ -53,7 +53,7 @@ dsh plugin --profile web remove dsh-local-models
|
|
|
53
53
|
|
|
54
54
|
## Usage
|
|
55
55
|
|
|
56
|
-
1. **Choose GGUF…** — कोई model फ़ाइल चुनें (Home / Models shortcuts, Up navigation)।
|
|
56
|
+
1. **Choose GGUF…** — कोई model फ़ाइल चुनें (Home / Root buttons, Models shortcuts, Up navigation)। Up सीधे `/` तक जाता है, इसलिए mounted disk पर रखा model (`/mnt/…`, `/media/…`) बिना folder दर्ज किए खुल जाता है।
|
|
57
57
|
2. **context**, **KV cache K / V**, **Max MTP head** (fixed draft, 0-7; 3 ही ट्यून किया गया sweet spot है — इससे गहरा draft बड़े ctx पर collapse हो जाता है), **thinking level** + **preserve thinking** checkbox, वैकल्पिक **mmproj** और **MoE** settings ट्यून करें।
|
|
58
58
|
3. **Load model**, status card देखें, **Open terminal** से output जाँचें।
|
|
59
59
|
4. **Register in dsh** — route (default `local-<alias>`) Models picker में दिखने लगता है।
|
package/README-pt.md
CHANGED
|
@@ -67,7 +67,7 @@ dsh plugin --profile web remove dsh-local-models
|
|
|
67
67
|
|
|
68
68
|
## Usage
|
|
69
69
|
|
|
70
|
-
1. **Choose GGUF…** — escolha um arquivo de modelo (
|
|
70
|
+
1. **Choose GGUF…** — escolha um arquivo de modelo (botões Home / Root, atalhos Models, navegação Up). O Up sobe até `/`, então um modelo em um disco montado (`/mnt/…`, `/media/…`) abre sem cadastrar a pasta antes.
|
|
71
71
|
2. Ajuste **context**, **KV cache K / V**, **Max MTP head** (draft fixo, 0-7; 3 é o ponto ideal ajustado — profundidades maiores colapsam em ctx grande), **thinking level** + checkbox **preserve thinking**, e as configurações opcionais de **mmproj** e **MoE**.
|
|
72
72
|
3. **Load model**, acompanhe o card de status e inspecione a saída via **Open terminal**.
|
|
73
73
|
4. **Register in dsh** — a rota (padrão `local-<alias>`) aparece no seletor de modelos.
|
package/README-zh.md
CHANGED
|
@@ -67,7 +67,7 @@ dsh plugin --profile web remove dsh-local-models
|
|
|
67
67
|
|
|
68
68
|
## Usage
|
|
69
69
|
|
|
70
|
-
1. **Choose GGUF…** —— 选择模型文件(Home / Models 快捷入口,Up 向上导航)。
|
|
70
|
+
1. **Choose GGUF…** —— 选择模型文件(Home / Root 按钮,Models 快捷入口,Up 向上导航)。Up 可以一直走到 `/`,因此挂载磁盘上的模型(`/mnt/…`、`/media/…`)无需先登记文件夹即可打开。
|
|
71
71
|
2. 调整 **context**、**KV cache K / V**、**Max MTP head**(固定草稿,0-7;3 是调校好的甜点值 —— 更深的草稿在大 ctx 下会崩溃)、**thinking level** + **preserve thinking** 复选框,以及可选的 **mmproj** 和 **MoE** 设置。
|
|
72
72
|
3. **Load model**,观察状态卡片,通过 **Open terminal** 查看输出。
|
|
73
73
|
4. **Register in dsh** —— 该路由(默认 `local-<alias>`)会出现在 Models 选择器中。
|
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ dsh plugin --profile web remove dsh-local-models
|
|
|
69
69
|
|
|
70
70
|
## Usage
|
|
71
71
|
|
|
72
|
-
1. **Choose GGUF…** — pick a model file (Home / Models shortcuts, Up navigation).
|
|
72
|
+
1. **Choose GGUF…** — pick a model file (Home / Root buttons, Models shortcuts, Up navigation). Up goes all the way to `/`, so a model on a mounted disk (`/mnt/…`, `/media/…`, a network export) opens without adding its folder to Model folders first.
|
|
73
73
|
2. Tune **context**, **KV cache K / V**, **Max MTP head** (fixed draft, 0-7; 3 is the tuned sweet spot — deeper collapses at large ctx), **thinking level** + **preserve thinking** checkbox, optional **mmproj** and **MoE** settings.
|
|
74
74
|
3. **Load model**, watch the status card, inspect output via **Open terminal**.
|
|
75
75
|
4. **Register in dsh** — the route (default `local-<alias>`) appears in the Models picker.
|
|
@@ -113,7 +113,9 @@ Launch flags are fixed to the validated daily config: full offload, `-b 2048 -ub
|
|
|
113
113
|
| `POST /local-models/profiles/remove` | delete a profile |
|
|
114
114
|
| `GET /local-models/settings` / `POST` | read / update plugin settings (`autostartRouter`, `autoUnloadMins`, `binPath`, `shortcuts`, `vramGb`) |
|
|
115
115
|
| `POST /local-models/runtime/check` | `{binPath}` → resolve + `<bin> --version` (the Runtime card's Check) |
|
|
116
|
-
| `POST /local-models/router/start` | build presets from profiles + start router |
|
|
116
|
+
| `POST /local-models/router/start` | build presets from profiles + start router (also syncs the compaction headroom patch — `{compactionSync:false}` opts out) |
|
|
117
|
+
| `GET /local-models/compaction[?profile=]` | per-model compaction threshold coverage |
|
|
118
|
+
| `POST /local-models/compaction/sync` | merge headroom overrides into the profile patch (`{profile,dryRun}`) |
|
|
117
119
|
| `POST /local-models/router/unload` | unload one router model |
|
|
118
120
|
| `POST /local-models/router/unload-all` | unload all router models |
|
|
119
121
|
| `POST /local-models/register` | add the ready server as an `llm-pi-ai` route |
|
package/lib/client.js
CHANGED
|
@@ -326,6 +326,11 @@ window.__ModuleLoader__.load({
|
|
|
326
326
|
var err = errPair[0], setErr = errPair[1];
|
|
327
327
|
var selPair = useStatePair(null);
|
|
328
328
|
var selected = selPair[0], setSelected = selPair[1];
|
|
329
|
+
// The server stops a huge folder at 2000 entries. Root browsing
|
|
330
|
+
// makes /proc and /usr/lib reachable, so say so rather than let the
|
|
331
|
+
// list look complete when it is not.
|
|
332
|
+
var truncPair = useStatePair(false);
|
|
333
|
+
var truncated = truncPair[0], setTrunc = truncPair[1];
|
|
329
334
|
// Remembered folders (persisted): jump buttons + toggle for the current dir.
|
|
330
335
|
var LS_FOLDERS = "dsh-local-models:remembered-folders";
|
|
331
336
|
var readFolders = function () {
|
|
@@ -355,6 +360,10 @@ window.__ModuleLoader__.load({
|
|
|
355
360
|
// empty-looking folder reads as a broken picker.
|
|
356
361
|
var dirMode = props.dirMode === true;
|
|
357
362
|
var allFiles = props.allFiles === true || dirMode;
|
|
363
|
+
// The listing starts at the filesystem root, so the naive
|
|
364
|
+
// base + "/" + name would ask for "//etc" and paint a path bar
|
|
365
|
+
// reading "//etc", with every deeper click stacking a slash.
|
|
366
|
+
var into = function (base, name) { return (base === "/" ? "" : base) + "/" + name; };
|
|
358
367
|
var load = useCallback(function (d) {
|
|
359
368
|
setLoading(true);
|
|
360
369
|
setErr(null);
|
|
@@ -364,6 +373,7 @@ window.__ModuleLoader__.load({
|
|
|
364
373
|
setDir(body.path || d);
|
|
365
374
|
setEntries(body.entries);
|
|
366
375
|
setParent(body.parent != null ? body.parent : null);
|
|
376
|
+
setTrunc(Boolean(body.truncated));
|
|
367
377
|
setSelected(null);
|
|
368
378
|
} else {
|
|
369
379
|
setErr((body && body.error) || "could not list directory");
|
|
@@ -387,6 +397,7 @@ window.__ModuleLoader__.load({
|
|
|
387
397
|
"div",
|
|
388
398
|
{ style: style.row },
|
|
389
399
|
h("button", { style: style.secondaryButton, onClick: function () { load(""); } }, "Home"),
|
|
400
|
+
h("button", { style: style.secondaryButton, title: "the filesystem root — mounted disks hang off it (/mnt, /media, /run/media)", onClick: function () { load("/"); } }, "Root"),
|
|
390
401
|
(props.shortcuts || []).map(function (sc, i) {
|
|
391
402
|
return h("button", { key: i, style: style.secondaryButton, onClick: function () { load(sc.path); }, title: sc.path }, sc.label);
|
|
392
403
|
}),
|
|
@@ -403,7 +414,7 @@ window.__ModuleLoader__.load({
|
|
|
403
414
|
{ style: style.list },
|
|
404
415
|
(entries || []).map(function (en, i) {
|
|
405
416
|
if (en.type === "dir") {
|
|
406
|
-
return h("li", { key: i, style: Object.assign({}, style.item, style.itemDir), onClick: function () { load(dir
|
|
417
|
+
return h("li", { key: i, style: Object.assign({}, style.item, style.itemDir), onClick: function () { load(into(dir, en.name)); } }, en.name + "/");
|
|
407
418
|
}
|
|
408
419
|
var sel = selected === en.name;
|
|
409
420
|
return h("li", {
|
|
@@ -414,13 +425,14 @@ window.__ModuleLoader__.load({
|
|
|
414
425
|
}, en.name + (en.size != null ? " (" + fmtGiB(en.size) + ")" : ""));
|
|
415
426
|
}),
|
|
416
427
|
),
|
|
428
|
+
truncated ? h("p", { style: style.warn }, "showing the first 2000 entries — open a subfolder to narrow it down") : null,
|
|
417
429
|
h(
|
|
418
430
|
"div",
|
|
419
431
|
{ style: style.row },
|
|
420
432
|
h("button", {
|
|
421
433
|
style: (dirMode ? dir : selected) ? style.button : Object.assign({}, style.button, style.disabled),
|
|
422
434
|
disabled: dirMode ? !dir : !selected,
|
|
423
|
-
onClick: function () { if (dirMode) { if (dir) props.onPick(dir); } else if (selected) props.onPick(dir
|
|
435
|
+
onClick: function () { if (dirMode) { if (dir) props.onPick(dir); } else if (selected) props.onPick(into(dir, selected)); },
|
|
424
436
|
}, dirMode ? "Use this folder" : "Use this file"),
|
|
425
437
|
h("button", { style: style.secondaryButton, onClick: props.onClose }, "Cancel"),
|
|
426
438
|
),
|
|
@@ -746,6 +758,11 @@ window.__ModuleLoader__.load({
|
|
|
746
758
|
var error = errPair[0], setError = errPair[1];
|
|
747
759
|
var regPair = useStatePair(null);
|
|
748
760
|
var registered = regPair[0], setRegistered = regPair[1];
|
|
761
|
+
// Last compaction-sync note (transient action feedback like
|
|
762
|
+
// `registered`: derived display, no localStorage — the poll's
|
|
763
|
+
// status.compaction is the durable source of truth).
|
|
764
|
+
var cmpPair = useStatePair(null);
|
|
765
|
+
var compactionNote = cmpPair[0], setCompactionNote = cmpPair[1];
|
|
749
766
|
var profPair = useStatePair([]);
|
|
750
767
|
var profiles = profPair[0], setProfiles = profPair[1];
|
|
751
768
|
var profNamePair = useStatePair("");
|
|
@@ -1287,15 +1304,43 @@ window.__ModuleLoader__.load({
|
|
|
1287
1304
|
});
|
|
1288
1305
|
};
|
|
1289
1306
|
|
|
1307
|
+
var compactionSummary = function (sync) {
|
|
1308
|
+
if (!sync) return null;
|
|
1309
|
+
if (!sync.ok) return "Compaction sync failed: " + (sync.error || "unknown error");
|
|
1310
|
+
var parts = [];
|
|
1311
|
+
if (sync.added && sync.added.length > 0) parts.push("+" + sync.added.length + " (" + sync.added.join(", ") + ")");
|
|
1312
|
+
if (sync.updated && sync.updated.length > 0) parts.push("~" + sync.updated.length + " (" + sync.updated.join(", ") + ")");
|
|
1313
|
+
if (parts.length === 0) {
|
|
1314
|
+
return "Compaction headroom already covers " + (sync.policies ? sync.policies.length : 0) + " router models.";
|
|
1315
|
+
}
|
|
1316
|
+
return "Compaction sync (" + (sync.profile || "web") + "): " + parts.join(" ") + " — restart dsh web to apply.";
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1290
1319
|
var doStartRouter = function () {
|
|
1291
1320
|
runAction("/local-models/router/start", {},
|
|
1292
1321
|
function (res) {
|
|
1293
1322
|
if (!res.ok) setError((res.body && res.body.error) || "router start failed");
|
|
1294
1323
|
else pendingRouterRegRef.current = true;
|
|
1324
|
+
if (res.body && res.body.compactionSync) {
|
|
1325
|
+
var msg = compactionSummary(res.body.compactionSync);
|
|
1326
|
+
if (res.body.compactionSync.ok) setCompactionNote(msg);
|
|
1327
|
+
else setError(msg);
|
|
1328
|
+
}
|
|
1295
1329
|
refresh();
|
|
1296
1330
|
});
|
|
1297
1331
|
};
|
|
1298
1332
|
|
|
1333
|
+
var doSyncCompaction = function () {
|
|
1334
|
+
setCompactionNote(null);
|
|
1335
|
+
post("/local-models/compaction/sync", { profile: "web" })
|
|
1336
|
+
.then(function (res) {
|
|
1337
|
+
if (!res.ok) setError((res.body && res.body.error) || "compaction sync failed");
|
|
1338
|
+
else setCompactionNote(compactionSummary(res.body));
|
|
1339
|
+
})
|
|
1340
|
+
.catch(function (e2) { setError(String(e2)); })
|
|
1341
|
+
.finally(refresh);
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1299
1344
|
var doUpdateSettings = function (patch) {
|
|
1300
1345
|
runAction("/local-models/settings", patch,
|
|
1301
1346
|
function (res) {
|
|
@@ -1817,6 +1862,35 @@ window.__ModuleLoader__.load({
|
|
|
1817
1862
|
stale ? h("p", { style: style.warn }, "Timer changed — restart the server to apply it now.") : null,
|
|
1818
1863
|
);
|
|
1819
1864
|
})(),
|
|
1865
|
+
(function () {
|
|
1866
|
+
// Compaction headroom coverage for the daily profile: the
|
|
1867
|
+
// server reports it on every poll (status.compaction), so
|
|
1868
|
+
// this is display-only — no local edits to fight the poll.
|
|
1869
|
+
var c = status.compaction;
|
|
1870
|
+
if (!c) return null;
|
|
1871
|
+
var bad = !!(c.error || (c.missing && c.missing.length > 0) || c.stale > 0);
|
|
1872
|
+
var line;
|
|
1873
|
+
if (c.error) line = "Compaction headroom: unavailable (" + c.error + ").";
|
|
1874
|
+
else if (c.total === 0) line = "Compaction headroom: no saved profiles yet.";
|
|
1875
|
+
else if (!bad) line = "Compaction headroom: " + c.covered + "/" + c.total + " router models covered.";
|
|
1876
|
+
else {
|
|
1877
|
+
var bits = [];
|
|
1878
|
+
if (c.missing && c.missing.length > 0) bits.push("missing: " + c.missing.join(", "));
|
|
1879
|
+
if (c.stale > 0) bits.push(c.stale + " stale");
|
|
1880
|
+
line = "Compaction headroom: " + c.covered + "/" + c.total + " covered (" + bits.join("; ") + ") — sync, then restart dsh web.";
|
|
1881
|
+
}
|
|
1882
|
+
return h(
|
|
1883
|
+
"div",
|
|
1884
|
+
{ style: { display: "flex", flexDirection: "column", gap: 4 } },
|
|
1885
|
+
h("p", { style: bad ? style.warn : style.small }, line),
|
|
1886
|
+
h(
|
|
1887
|
+
"div",
|
|
1888
|
+
{ style: style.row },
|
|
1889
|
+
h("button", { style: style.secondaryButton, onClick: doSyncCompaction, disabled: busy }, "Sync compaction headroom"),
|
|
1890
|
+
),
|
|
1891
|
+
compactionNote !== null ? h("p", { style: style.small }, compactionNote) : null,
|
|
1892
|
+
);
|
|
1893
|
+
})(),
|
|
1820
1894
|
);
|
|
1821
1895
|
|
|
1822
1896
|
// ---- render: page ----
|
package/lib/index.js
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
* GET /local-models/logs?offset=&max= → tail of llama-server.log (terminal)
|
|
13
13
|
* POST /local-models/run → {path, ctx, mtp, kv} → spawn llama-server
|
|
14
14
|
* POST /local-models/stop → stop the child (or reap the port)
|
|
15
|
+
* POST /local-models/router/start → write router presets, sync the
|
|
16
|
+
* compaction headroom patch, spawn
|
|
17
|
+
* GET /local-models/compaction[?profile=] → per-model threshold coverage
|
|
18
|
+
* POST /local-models/compaction/sync → merge headroom overrides into the
|
|
19
|
+
* profile's cordis.patch.yml
|
|
15
20
|
* POST /local-models/settings → {autostartRouter, autoUnloadMins,
|
|
16
21
|
* binPath, shortcuts} → settings.json
|
|
17
22
|
* POST /local-models/runtime/check → {binPath} → resolve + `<bin> --version`
|
|
@@ -33,6 +38,7 @@ import {
|
|
|
33
38
|
existsSync,
|
|
34
39
|
mkdirSync,
|
|
35
40
|
openSync,
|
|
41
|
+
copyFileSync,
|
|
36
42
|
readFileSync,
|
|
37
43
|
readSync,
|
|
38
44
|
readdirSync,
|
|
@@ -43,6 +49,7 @@ import {
|
|
|
43
49
|
} from "node:fs";
|
|
44
50
|
import { basename, join, dirname, resolve, sep, delimiter, isAbsolute } from "node:path";
|
|
45
51
|
import { fileURLToPath } from "node:url";
|
|
52
|
+
import { createRequire } from "node:module";
|
|
46
53
|
import { homedir } from "node:os";
|
|
47
54
|
import process from "node:process";
|
|
48
55
|
|
|
@@ -180,6 +187,364 @@ export function compactionBudgetFor(contextWindow, opts = {}) {
|
|
|
180
187
|
recommendedHeadroom,
|
|
181
188
|
};
|
|
182
189
|
}
|
|
190
|
+
/** Provider key the router registers under: every router modelPolicy targets
|
|
191
|
+
* this provider with the profile id as the model. Single-mode registrations
|
|
192
|
+
* use per-model `local-<alias>` routes instead and are NOT auto-synced
|
|
193
|
+
* (their route only exists while that server runs) — see syncCompactionPatch. */
|
|
194
|
+
export const COMPACTION_ROUTER_PROVIDER = "local-router";
|
|
195
|
+
/** `provider`/`model` charset shared by routes and patch policies. */
|
|
196
|
+
const POLICY_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
197
|
+
|
|
198
|
+
/** One router profile's compaction policy: the headroom override that lands
|
|
199
|
+
* its proactive threshold near 70% of the window instead of the core
|
|
200
|
+
* default's early trigger (32K on a 128K window = the compaction loop).
|
|
201
|
+
* Pure; sorted by model so generated YAML is deterministic. */
|
|
202
|
+
export function desiredCompactionPolicies(profiles) {
|
|
203
|
+
const list = Array.isArray(profiles) ? profiles : [];
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const p of list) {
|
|
206
|
+
if (!p || typeof p !== "object") continue;
|
|
207
|
+
const model = typeof p.id === "string" && p.id
|
|
208
|
+
? p.id
|
|
209
|
+
: slugProfile(typeof p.name === "string" ? p.name : "");
|
|
210
|
+
if (!POLICY_ID_RE.test(model)) continue;
|
|
211
|
+
const ctx = Number.isInteger(p.ctx) && p.ctx > 0 ? p.ctx : 8192;
|
|
212
|
+
out.push({
|
|
213
|
+
provider: COMPACTION_ROUTER_PROVIDER,
|
|
214
|
+
model,
|
|
215
|
+
headroomTokens: compactionBudgetFor(ctx).recommendedHeadroom,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
out.sort((a, b) => a.model < b.model ? -1 : a.model > b.model ? 1 : 0);
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Per-profile compaction plan: default-threshold math next to the on-disk
|
|
223
|
+
* override state, so the tab can name the models that would still loop.
|
|
224
|
+
* `existing` is readModelPolicies() output (null = patch state unknown).
|
|
225
|
+
* Pure. */
|
|
226
|
+
export function compactionPlan(profiles, existing) {
|
|
227
|
+
const desired = desiredCompactionPolicies(profiles);
|
|
228
|
+
const have = new Map();
|
|
229
|
+
for (const e of Array.isArray(existing) ? existing : []) {
|
|
230
|
+
if (e && typeof e === "object" && POLICY_ID_RE.test(e.model ?? "")) {
|
|
231
|
+
have.set(policyKey(e.provider, e.model), e.headroomTokens);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return desired.map((d) => {
|
|
235
|
+
const p = (Array.isArray(profiles) ? profiles : []).find((q) =>
|
|
236
|
+
q && typeof q === "object" && ((q.id || slugProfile(q.name ?? "")) === d.model));
|
|
237
|
+
const ctx = p && Number.isInteger(p.ctx) && p.ctx > 0 ? p.ctx : 8192;
|
|
238
|
+
const budget = compactionBudgetFor(ctx);
|
|
239
|
+
const tuned = compactionBudgetFor(ctx, { headroomTokens: d.headroomTokens });
|
|
240
|
+
const coveredHeadroom = have.has(policyKey(d.provider, d.model))
|
|
241
|
+
? have.get(policyKey(d.provider, d.model))
|
|
242
|
+
: null;
|
|
243
|
+
return {
|
|
244
|
+
provider: d.provider,
|
|
245
|
+
model: d.model,
|
|
246
|
+
ctx,
|
|
247
|
+
maxTokens: budget.maxTokens,
|
|
248
|
+
thresholdTokens: budget.thresholdTokens,
|
|
249
|
+
tunedThresholdTokens: tuned.thresholdTokens,
|
|
250
|
+
recommendedHeadroom: d.headroomTokens,
|
|
251
|
+
coveredHeadroom,
|
|
252
|
+
status: coveredHeadroom === null ? "missing" : coveredHeadroom === d.headroomTokens ? "covered" : "stale",
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// preset-standard patch surgery (pure, exported for tests).
|
|
259
|
+
//
|
|
260
|
+
// The live compaction engines run inside the agent-preset scopes, so the
|
|
261
|
+
// headroom override must ride a wholesale `config` on the `- id:
|
|
262
|
+
// preset-standard` row of the profile's cordis.patch.yml — a patch on the
|
|
263
|
+
// top-level (disabled) compaction-basic row is dead config. This surgery
|
|
264
|
+
// touches ONLY the nested modelPolicies list and preserves everything else
|
|
265
|
+
// (including the !!js expressions a YAML parser would choke on, which is why
|
|
266
|
+
// this is line-based instead of parse-and-reserialize). Anything it does not
|
|
267
|
+
// recognize is a thrown Error, never a half-written file.
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
/** Map key for one policy target. \u0000 separates the two ids so no two
|
|
271
|
+
* distinct provider/model pairs share a key. */
|
|
272
|
+
function policyKey(provider, model) {
|
|
273
|
+
return provider + "\u0000" + model;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function patchLines(text) {
|
|
277
|
+
return String(text ?? "").replace(/\r\n?/g, "\n").split("\n");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function indentOf(line) {
|
|
281
|
+
const m = /^ */.exec(line);
|
|
282
|
+
return m[0].length;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isBlankOrComment(line) {
|
|
286
|
+
const t = line.trim();
|
|
287
|
+
return t === "" || t.startsWith("#");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** End of the block starting at `start` (indent `base`): first later line
|
|
291
|
+
* that is neither blank nor a comment and dedents to `base` or less. */
|
|
292
|
+
function blockEnd(lines, start, base) {
|
|
293
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
294
|
+
if (isBlankOrComment(lines[i])) continue;
|
|
295
|
+
if (indentOf(lines[i]) <= base) return i;
|
|
296
|
+
}
|
|
297
|
+
return lines.length;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function listItemAt(lines, i) {
|
|
301
|
+
const m = /^(\s*)- id: (\S+)\s*(#.*)?$/.exec(lines[i]);
|
|
302
|
+
return m ? { indent: m[1].length, id: m[2] } : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function findItem(lines, start, end, base, wantId) {
|
|
306
|
+
for (let i = start; i < end; i++) {
|
|
307
|
+
if (isBlankOrComment(lines[i])) continue;
|
|
308
|
+
const item = listItemAt(lines, i);
|
|
309
|
+
if (item && item.indent === base && item.id === wantId) return i;
|
|
310
|
+
}
|
|
311
|
+
return -1;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Locate the nested compaction-basic item inside a preset-standard entry
|
|
315
|
+
* spanning [entryStart, entryEnd). Returns {item, nameLine, configLine} line
|
|
316
|
+
* indexes (configLine -1 when absent) or throws on unrecognized shape. */
|
|
317
|
+
function locateNestedCompactionBasic(lines, entryStart, entryEnd) {
|
|
318
|
+
const entryIndent = indentOf(lines[entryStart]);
|
|
319
|
+
const group = findItem(lines, entryStart, entryEnd, entryIndent + 6, "compaction");
|
|
320
|
+
if (group < 0) throw new Error("preset-standard has no nested `- id: compaction` group — re-generate it from the installed preset file");
|
|
321
|
+
const groupEnd = blockEnd(lines, group, indentOf(lines[group]));
|
|
322
|
+
let configLine = -1;
|
|
323
|
+
for (let i = group + 1; i < groupEnd; i++) {
|
|
324
|
+
if (isBlankOrComment(lines[i])) continue;
|
|
325
|
+
const m = /^(\s*)config:\s*(#.*)?$/.exec(lines[i]);
|
|
326
|
+
if (m && m[1].length === indentOf(lines[group]) + 2) { configLine = i; break; }
|
|
327
|
+
if (indentOf(lines[i]) <= indentOf(lines[group])) break;
|
|
328
|
+
}
|
|
329
|
+
if (configLine < 0) throw new Error("nested compaction group has no `config:` list — re-generate preset-standard from the installed preset file");
|
|
330
|
+
const item = findItem(lines, configLine, groupEnd, indentOf(lines[configLine]) + 2, "compaction-basic");
|
|
331
|
+
if (item < 0) throw new Error("nested compaction group has no `- id: compaction-basic` — re-generate preset-standard from the installed preset file");
|
|
332
|
+
const itemEnd = blockEnd(lines, item, indentOf(lines[item]));
|
|
333
|
+
let nameLine = -1, cfgLine = -1;
|
|
334
|
+
for (let i = item + 1; i < itemEnd; i++) {
|
|
335
|
+
if (isBlankOrComment(lines[i])) continue;
|
|
336
|
+
if (indentOf(lines[i]) !== indentOf(lines[item]) + 2) {
|
|
337
|
+
if (indentOf(lines[i]) <= indentOf(lines[item])) break;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (/^(\s*)name:\s/.test(lines[i]) && nameLine < 0) nameLine = i;
|
|
341
|
+
const cm = /^(\s*)config:\s*(#.*)?$/.exec(lines[i]);
|
|
342
|
+
if (cm && cfgLine < 0) cfgLine = i;
|
|
343
|
+
else if (/^(\s*)config:\s*\S/.test(lines[i])) {
|
|
344
|
+
throw new Error("nested compaction-basic has a non-map `config:` — edit it by hand, refusing to guess");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { item, itemEnd, nameLine, configLine: cfgLine };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function pad(n) {
|
|
351
|
+
return " ".repeat(n);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Render the modelPolicies value (the `modelPolicies:` line plus its items)
|
|
355
|
+
* at base indent `base`. Policies carry only headroomTokens: under the core
|
|
356
|
+
* defaults the per-request reservation still comes from the adapter, and the
|
|
357
|
+
* existing on-disk entries this merges with carry the same single key. */
|
|
358
|
+
function renderModelPolicies(base, policies) {
|
|
359
|
+
const out = [pad(base) + "modelPolicies:"];
|
|
360
|
+
if (policies.length === 0) {
|
|
361
|
+
out.push(pad(base + 2) + "[]");
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
for (const p of policies) {
|
|
365
|
+
out.push(pad(base + 2) + "- provider: " + p.provider);
|
|
366
|
+
out.push(pad(base + 4) + "model: " + p.model);
|
|
367
|
+
out.push(pad(base + 4) + "headroomTokens: " + p.headroomTokens);
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function checkPolicy(p) {
|
|
373
|
+
if (!p || typeof p !== "object") throw new Error("compaction policy must be an object");
|
|
374
|
+
if (p.provider !== COMPACTION_ROUTER_PROVIDER || !POLICY_ID_RE.test(p.model ?? "")) {
|
|
375
|
+
throw new Error("bad compaction policy target: " + JSON.stringify(p));
|
|
376
|
+
}
|
|
377
|
+
if (!Number.isInteger(p.headroomTokens) || p.headroomTokens < 0) {
|
|
378
|
+
throw new Error("bad headroomTokens for " + p.model + ": " + String(p.headroomTokens));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Read the nested modelPolicies list from a profile patch text. Returns the
|
|
383
|
+
* list, [] when the engine entry exists but carries no policies, or null when
|
|
384
|
+
* there is no usable preset-standard entry at all (nothing to merge with).
|
|
385
|
+
* Lenient on purpose: diagnostics must never throw. Throws only on tabs. */
|
|
386
|
+
export function readModelPolicies(patchText) {
|
|
387
|
+
if (typeof patchText !== "string" || patchText.includes(" ")) return null;
|
|
388
|
+
const lines = patchLines(patchText);
|
|
389
|
+
let entry = -1;
|
|
390
|
+
for (let i = 0; i < lines.length; i++) {
|
|
391
|
+
if (isBlankOrComment(lines[i])) continue;
|
|
392
|
+
const item = listItemAt(lines, i);
|
|
393
|
+
if (item && item.id === "preset-standard") { entry = i; break; }
|
|
394
|
+
}
|
|
395
|
+
if (entry < 0) return null;
|
|
396
|
+
let loc;
|
|
397
|
+
try {
|
|
398
|
+
loc = locateNestedCompactionBasic(lines, entry, blockEnd(lines, entry, indentOf(lines[entry])));
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
if (loc.configLine < 0) return [];
|
|
403
|
+
const cfgIndent = indentOf(lines[loc.configLine]);
|
|
404
|
+
let mpLine = -1, inlineEmpty = false;
|
|
405
|
+
for (let i = loc.configLine + 1; i < loc.itemEnd; i++) {
|
|
406
|
+
if (isBlankOrComment(lines[i])) continue;
|
|
407
|
+
if (indentOf(lines[i]) < cfgIndent + 2) break;
|
|
408
|
+
if (indentOf(lines[i]) !== cfgIndent + 2) continue;
|
|
409
|
+
const m = /^(\s*)modelPolicies:\s*(#.*)?$/.exec(lines[i]);
|
|
410
|
+
if (m) { mpLine = i; break; }
|
|
411
|
+
const e = /^(\s*)modelPolicies:\s*\[\]\s*(#.*)?$/.exec(lines[i]);
|
|
412
|
+
if (e) { inlineEmpty = true; mpLine = i; break; }
|
|
413
|
+
if (/^(\s*)modelPolicies:\s*\S/.test(lines[i])) return null;
|
|
414
|
+
}
|
|
415
|
+
if (mpLine < 0) return [];
|
|
416
|
+
if (inlineEmpty) return [];
|
|
417
|
+
const mpIndent = indentOf(lines[mpLine]);
|
|
418
|
+
const mpEnd = blockEnd(lines, mpLine, mpIndent);
|
|
419
|
+
const out = [];
|
|
420
|
+
let cur = null;
|
|
421
|
+
for (let i = mpLine + 1; i < mpEnd; i++) {
|
|
422
|
+
if (isBlankOrComment(lines[i])) continue;
|
|
423
|
+
const pm = /^(\s*)- provider:\s*(\S+)\s*$/.exec(lines[i]);
|
|
424
|
+
if (pm && pm[1].length === mpIndent + 2) {
|
|
425
|
+
cur = { provider: pm[2], model: null, headroomTokens: null };
|
|
426
|
+
out.push(cur);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (cur && indentOf(lines[i]) === mpIndent + 4) {
|
|
430
|
+
const mm = /^\s*model:\s*(\S+)\s*$/.exec(lines[i]);
|
|
431
|
+
if (mm) cur.model = mm[1];
|
|
432
|
+
const hm = /^\s*headroomTokens:\s*(\S+)\s*$/.exec(lines[i]);
|
|
433
|
+
if (hm) cur.headroomTokens = Number(hm[1]);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return out.filter((e) => typeof e.provider === "string" && typeof e.model === "string");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Merge desired policies into a profile patch text (or build the
|
|
440
|
+
* preset-standard entry from the installed preset text when the patch has
|
|
441
|
+
* none). Returns {text, added, updated, unchanged, created}. Pure: no
|
|
442
|
+
* filesystem here, so every shape is unit-testable. Throws with an
|
|
443
|
+
* actionable message on anything unrecognized. */
|
|
444
|
+
export function syncPresetStandardPatch(userPatchText, installedPresetText, policies) {
|
|
445
|
+
const list = Array.isArray(policies) ? policies : [];
|
|
446
|
+
for (const p of list) checkPolicy(p);
|
|
447
|
+
const desired = new Map(list.map((p) => [policyKey(p.provider, p.model), p]));
|
|
448
|
+
let lines, entry, created = false;
|
|
449
|
+
if (userPatchText === null || userPatchText === undefined) {
|
|
450
|
+
if (typeof installedPresetText !== "string" || !installedPresetText.trim()) {
|
|
451
|
+
throw new Error("no preset-standard entry and the installed preset file was not found — create the entry once from dsh-web-app/presets/standard.patch.yml, then sync maintains it");
|
|
452
|
+
}
|
|
453
|
+
if (installedPresetText.includes(" ")) throw new Error("installed preset uses tabs — refusing to reindent");
|
|
454
|
+
const inst = patchLines(installedPresetText);
|
|
455
|
+
let instEntry = -1;
|
|
456
|
+
for (let i = 0; i < inst.length; i++) {
|
|
457
|
+
if (isBlankOrComment(inst[i])) continue;
|
|
458
|
+
const item = listItemAt(inst, i);
|
|
459
|
+
if (item && item.id === "preset-standard") { instEntry = i; break; }
|
|
460
|
+
}
|
|
461
|
+
if (instEntry < 0) throw new Error("installed preset has no preset-standard entry — refusing to invent one");
|
|
462
|
+
const instBase = indentOf(inst[instEntry]);
|
|
463
|
+
const instEnd = blockEnd(inst, instEntry, instBase);
|
|
464
|
+
lines = inst.slice(instEntry, instEnd).map((l) => {
|
|
465
|
+
if (isBlankOrComment(l) || l.trim() === "") return l.trim() === "" ? "" : l;
|
|
466
|
+
if (indentOf(l) < instBase) throw new Error("installed preset dedent failed — refusing to guess");
|
|
467
|
+
return l.slice(instBase);
|
|
468
|
+
});
|
|
469
|
+
entry = 0;
|
|
470
|
+
created = true;
|
|
471
|
+
} else {
|
|
472
|
+
if (typeof userPatchText !== "string" || userPatchText.includes(" ")) {
|
|
473
|
+
throw new Error("profile patch uses tabs — edit the modelPolicies by hand");
|
|
474
|
+
}
|
|
475
|
+
lines = patchLines(userPatchText);
|
|
476
|
+
entry = -1;
|
|
477
|
+
for (let i = 0; i < lines.length; i++) {
|
|
478
|
+
if (isBlankOrComment(lines[i])) continue;
|
|
479
|
+
const item = listItemAt(lines, i);
|
|
480
|
+
if (item && item.id === "preset-standard") { entry = i; break; }
|
|
481
|
+
}
|
|
482
|
+
if (entry < 0) {
|
|
483
|
+
// Same build as the null-patch path; recursion keeps one code path.
|
|
484
|
+
const built = syncPresetStandardPatch(null, installedPresetText, policies);
|
|
485
|
+
const sep = lines.length > 0 && lines[lines.length - 1].trim() !== "" ? [""] : [];
|
|
486
|
+
const header = [
|
|
487
|
+
"# Compaction for local models: auto-synced modelPolicies (Start",
|
|
488
|
+
"# Router keeps this list covering every saved router profile — restart",
|
|
489
|
+
"# dsh web after a sync. Re-generate after any dsh upgrade touching",
|
|
490
|
+
"# dsh-web-app/presets/standard.patch.yml.",
|
|
491
|
+
];
|
|
492
|
+
return {
|
|
493
|
+
text: [...lines, ...sep, ...header, ...patchLines(built.text)].join("\n"),
|
|
494
|
+
added: built.added,
|
|
495
|
+
updated: built.updated,
|
|
496
|
+
unchanged: built.unchanged,
|
|
497
|
+
created: true,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const entryEnd = blockEnd(lines, entry, indentOf(lines[entry]));
|
|
502
|
+
const loc = locateNestedCompactionBasic(lines, entry, entryEnd);
|
|
503
|
+
const existing = readModelPolicies(lines.join("\n")) ?? [];
|
|
504
|
+
const have = new Map(existing.map((e) => [policyKey(e.provider, e.model), e.headroomTokens]));
|
|
505
|
+
const merged = new Map();
|
|
506
|
+
for (const e of existing) merged.set(policyKey(e.provider, e.model), { provider: e.provider, model: e.model, headroomTokens: e.headroomTokens });
|
|
507
|
+
const added = [], updated = [], unchanged = [];
|
|
508
|
+
for (const [key, p] of desired) {
|
|
509
|
+
if (!have.has(key)) { added.push(p.model); merged.set(key, { ...p }); }
|
|
510
|
+
else if (have.get(key) !== p.headroomTokens) { updated.push(p.model); merged.set(key, { ...p }); }
|
|
511
|
+
else unchanged.push(p.model);
|
|
512
|
+
}
|
|
513
|
+
const ordered = [...merged.values()].sort((a, b) =>
|
|
514
|
+
a.provider !== b.provider ? (a.provider < b.provider ? -1 : 1) : a.model < b.model ? -1 : a.model > b.model ? 1 : 0);
|
|
515
|
+
if (loc.configLine < 0) {
|
|
516
|
+
const anchor = loc.nameLine >= 0 ? loc.nameLine : loc.item;
|
|
517
|
+
const base = indentOf(lines[loc.item]) + 2;
|
|
518
|
+
const insert = [pad(base) + "config:", ...renderModelPolicies(base + 2, ordered)];
|
|
519
|
+
lines.splice(anchor + 1, 0, ...insert);
|
|
520
|
+
} else {
|
|
521
|
+
const cfgIndent = indentOf(lines[loc.configLine]);
|
|
522
|
+
let mpLine = -1, mpInline = false;
|
|
523
|
+
for (let i = loc.configLine + 1; i < loc.itemEnd; i++) {
|
|
524
|
+
if (isBlankOrComment(lines[i])) continue;
|
|
525
|
+
if (indentOf(lines[i]) < cfgIndent + 2) break;
|
|
526
|
+
if (indentOf(lines[i]) !== cfgIndent + 2) continue;
|
|
527
|
+
if (/^(\s*)modelPolicies:\s*(#.*)?$/.test(lines[i])) { mpLine = i; break; }
|
|
528
|
+
const e = /^(\s*)modelPolicies:\s*\[\]\s*(#.*)?$/.exec(lines[i]);
|
|
529
|
+
if (e) { mpLine = i; mpInline = true; break; }
|
|
530
|
+
if (/^(\s*)modelPolicies:\s*\S/.test(lines[i])) {
|
|
531
|
+
throw new Error("nested modelPolicies has an inline value — edit it by hand, refusing to guess");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const rendered = renderModelPolicies(cfgIndent + 2, ordered);
|
|
535
|
+
if (mpLine < 0) {
|
|
536
|
+
lines.splice(loc.configLine + 1, 0, ...rendered);
|
|
537
|
+
} else if (mpInline) {
|
|
538
|
+
lines.splice(mpLine, 1, ...rendered);
|
|
539
|
+
} else {
|
|
540
|
+
const mpEnd = blockEnd(lines, mpLine, indentOf(lines[mpLine]));
|
|
541
|
+
lines.splice(mpLine, mpEnd - mpLine, ...rendered);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
let text = lines.join("\n");
|
|
545
|
+
if (!text.endsWith("\n")) text += "\n";
|
|
546
|
+
return { text, added, updated, unchanged, created };
|
|
547
|
+
}
|
|
183
548
|
/** Fixed-MTP ceiling. The tab offers 0-7 and the API clamps here: upstream
|
|
184
549
|
* accepts any `--spec-draft-n-max` and clamps the effective depth to the
|
|
185
550
|
* model's own nextn depth at load. Depth 3 is the measured 16 GiB sweet spot
|
|
@@ -1509,6 +1874,14 @@ function createManager() {
|
|
|
1509
1874
|
s.routerMax = ROUTER_MAX;
|
|
1510
1875
|
s.autostartRouter = readSettings().autostartRouter;
|
|
1511
1876
|
s.autoUnloadMins = readSettings().autoUnloadMins;
|
|
1877
|
+
// Compaction coverage for the daily profile: which saved router models
|
|
1878
|
+
// have a headroom override and which would still loop. Read-only here;
|
|
1879
|
+
// the sync itself runs on router start (and on demand below).
|
|
1880
|
+
try {
|
|
1881
|
+
s.compaction = readCompactionState("web", cachedProfiles());
|
|
1882
|
+
} catch {
|
|
1883
|
+
s.compaction = { profile: "web", error: "unavailable", total: 0, covered: 0, stale: 0, missing: [] };
|
|
1884
|
+
}
|
|
1512
1885
|
s.sleepIdleSecs = state.sleepIdleSecs;
|
|
1513
1886
|
s.logSize = 0;
|
|
1514
1887
|
try { s.logSize = statSync(LOG_PATH).size; } catch { /* no log yet */ }
|
|
@@ -1566,19 +1939,36 @@ function sendJson(res, code, body) {
|
|
|
1566
1939
|
res.end(payload);
|
|
1567
1940
|
}
|
|
1568
1941
|
|
|
1942
|
+
/** The filesystem root, in the form resolve() produces (posix "/"). Every
|
|
1943
|
+
* absolute path is under it, so isPathAllowed() treats it as containing the
|
|
1944
|
+
* whole tree — the one root that makes "Up" from any folder land somewhere
|
|
1945
|
+
* real. */
|
|
1946
|
+
const FILESYSTEM_ROOT = resolve(sep);
|
|
1947
|
+
|
|
1948
|
+
/** Single wording for a path the file endpoints refuse. With the root in
|
|
1949
|
+
* allowedRoots() this is unreachable for any absolute path; it stays as the
|
|
1950
|
+
* one place the wording lives, so narrowing the roots again has something to
|
|
1951
|
+
* narrow. */
|
|
1952
|
+
const NOT_READABLE = "this tab may not read that path";
|
|
1953
|
+
|
|
1569
1954
|
/** Roots the file endpoints (browse/gguf-meta/run, and profile saves) may
|
|
1570
|
-
* touch
|
|
1571
|
-
*
|
|
1572
|
-
*
|
|
1573
|
-
*
|
|
1574
|
-
*
|
|
1575
|
-
*
|
|
1955
|
+
* touch. The list LEADS WITH THE FILESYSTEM ROOT: the picker walks up to "/"
|
|
1956
|
+
* on purpose, so a model can live on a mounted disk the user never declared
|
|
1957
|
+
* here (/mnt/data, /media/<user>/<label>, a network export) and reaching it
|
|
1958
|
+
* must not depend on having typed its path into Model folders first.
|
|
1959
|
+
*
|
|
1960
|
+
* Everything below the root is therefore readable, which makes the remaining
|
|
1961
|
+
* entries — home, the shortcut dirs (Runtime card, else
|
|
1962
|
+
* LOCAL_MODELS_SHORTCUTS), the model dirs of saved profiles, the llama.cpp
|
|
1963
|
+
* dir — jump buttons and back-compat rather than a confinement. They stay
|
|
1964
|
+
* because the picker offers them, and because a saved profile must stay
|
|
1965
|
+
* loadable whatever else happens.
|
|
1576
1966
|
*
|
|
1577
|
-
*
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1967
|
+
* The guard that is still load-bearing is isLlamaServerPath(): it, not this
|
|
1968
|
+
* list, decides what may be spawned (POST /settings, /runtime/check and
|
|
1969
|
+
* requireBin()), so nothing a browse opens can turn into the executable. */
|
|
1580
1970
|
function allowedRoots() {
|
|
1581
|
-
const roots = [homedir(), ...shortcutDirs().map((s) => s.path)];
|
|
1971
|
+
const roots = [FILESYSTEM_ROOT, homedir(), ...shortcutDirs().map((s) => s.path)];
|
|
1582
1972
|
try {
|
|
1583
1973
|
for (const profile of cachedProfiles()) {
|
|
1584
1974
|
for (const declared of [profile?.modelPath, profile?.mmprojPath]) {
|
|
@@ -1616,9 +2006,10 @@ function allowedRoots() {
|
|
|
1616
2006
|
return roots;
|
|
1617
2007
|
}
|
|
1618
2008
|
|
|
1619
|
-
/** A path we are willing to
|
|
1620
|
-
* segment, and
|
|
1621
|
-
*
|
|
2009
|
+
/** A path we are willing to show as a picker button: absolute, no ".."
|
|
2010
|
+
* segment, and naming one folder rather than the whole tree. The root is
|
|
2011
|
+
* browsable on its own (there is a Root button and an Up that reaches it), so
|
|
2012
|
+
* this is button hygiene, not confinement. Exported for tests. */
|
|
1622
2013
|
export function isSafeRootPath(p) {
|
|
1623
2014
|
if (typeof p !== "string" || !p.trim()) return false;
|
|
1624
2015
|
const raw = p.trim();
|
|
@@ -1644,7 +2035,9 @@ function isPathAllowed(p) {
|
|
|
1644
2035
|
} catch {
|
|
1645
2036
|
return false;
|
|
1646
2037
|
}
|
|
1647
|
-
|
|
2038
|
+
// The root needs its own clause: the prefix test below would only ever
|
|
2039
|
+
// match "/" itself (every other path starts "/…", not "//").
|
|
2040
|
+
return abs === r || r === FILESYSTEM_ROOT || abs.startsWith(r + sep);
|
|
1648
2041
|
});
|
|
1649
2042
|
}
|
|
1650
2043
|
|
|
@@ -1793,6 +2186,199 @@ function writeJsonAtomic(file, value) {
|
|
|
1793
2186
|
renameSync(tmp, file);
|
|
1794
2187
|
}
|
|
1795
2188
|
|
|
2189
|
+
function writeTextAtomic(file, text) {
|
|
2190
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
2191
|
+
const tmp = file + ".tmp-" + process.pid;
|
|
2192
|
+
writeFileSync(tmp, text, "utf8");
|
|
2193
|
+
renameSync(tmp, file);
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
// ---------------------------------------------------------------------------
|
|
2197
|
+
// Compaction patch sync (filesystem layer over the pure surgery above).
|
|
2198
|
+
//
|
|
2199
|
+
// The Start Router button runs this automatically so a newly saved profile
|
|
2200
|
+
// can never boot into the compaction loop: every saved router profile gets
|
|
2201
|
+
// a modelPolicies headroom override in the profile's cordis.patch.yml,
|
|
2202
|
+
// computed from its own ctx. Edits are idempotent (re-running changes
|
|
2203
|
+
// nothing), merge-only (entries for removed profiles are kept, never
|
|
2204
|
+
// deleted), and always backed up first. Composition applies at dsh boot, so
|
|
2205
|
+
// a sync reports needRestart — the running process keeps the old thresholds
|
|
2206
|
+
// until then.
|
|
2207
|
+
// ---------------------------------------------------------------------------
|
|
2208
|
+
|
|
2209
|
+
/** Profile patch file for a dsh profile name. The name charset is the guard:
|
|
2210
|
+
* anything else (including ".." or "/") is rejected instead of joined. */
|
|
2211
|
+
export function profilePatchFile(profileName) {
|
|
2212
|
+
if (typeof profileName !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(profileName)) {
|
|
2213
|
+
throw new Error("unknown dsh profile: " + String(profileName));
|
|
2214
|
+
}
|
|
2215
|
+
return join(dshHome(), "profiles", profileName, "cordis.patch.yml");
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/** dsh profile names that have a patch file. Best-effort: an unreadable
|
|
2219
|
+
* profiles dir is "no profiles", never a throw (status poll reads this). */
|
|
2220
|
+
export function listProfileNames(opts = {}) {
|
|
2221
|
+
const root = join(dshHome(), "profiles");
|
|
2222
|
+
const listDir = opts.listDir ?? readdirSync;
|
|
2223
|
+
let entries;
|
|
2224
|
+
try {
|
|
2225
|
+
entries = listDir(root);
|
|
2226
|
+
} catch {
|
|
2227
|
+
return [];
|
|
2228
|
+
}
|
|
2229
|
+
return entries.filter((n) => /^[a-zA-Z0-9_-]{1,64}$/.test(n));
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
/** Locate the installed dsh-web-app preset this machine booted from, without
|
|
2233
|
+
* hardcoding any one machine's layout. Sources tried in order: an explicitly
|
|
2234
|
+
* injected path (tests), the resolvable package, then the npm-global
|
|
2235
|
+
* `<prefix>/lib/node_modules/...` layout derived from the `dsh` binary on
|
|
2236
|
+
* PATH and the usual global roots. Returns {path, source} or null. Only
|
|
2237
|
+
* needed when the profile patch has no preset-standard entry yet (merge-only
|
|
2238
|
+
* syncs never touch the installed file). */
|
|
2239
|
+
export function locateInstalledStandardPreset(opts = {}) {
|
|
2240
|
+
const isFile = opts.isFile ?? ((p) => {
|
|
2241
|
+
try {
|
|
2242
|
+
return statSync(p).isFile();
|
|
2243
|
+
} catch {
|
|
2244
|
+
return false;
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
const exists = opts.exists ?? isFile;
|
|
2248
|
+
const tail = join("@deepseek-ai", "dsh-web-app", "presets", "standard.patch.yml");
|
|
2249
|
+
const tryPath = (p, source) => {
|
|
2250
|
+
if (typeof p === "string" && p && exists(p)) return { path: p, source };
|
|
2251
|
+
return null;
|
|
2252
|
+
};
|
|
2253
|
+
if (typeof opts.resolved === "string" && opts.resolved) {
|
|
2254
|
+
return tryPath(opts.resolved, "resolve");
|
|
2255
|
+
}
|
|
2256
|
+
try {
|
|
2257
|
+
const hit = tryPath(
|
|
2258
|
+
createRequire(import.meta.url).resolve("@deepseek-ai/dsh-web-app/presets/standard.patch.yml"),
|
|
2259
|
+
"resolve",
|
|
2260
|
+
);
|
|
2261
|
+
if (hit) return hit;
|
|
2262
|
+
} catch { /* not importable from here — try the filesystem layouts */ }
|
|
2263
|
+
const roots = [];
|
|
2264
|
+
const pathEnv = opts.pathEnv ?? process.env.PATH ?? "";
|
|
2265
|
+
for (const dir of String(pathEnv).split(delimiter)) {
|
|
2266
|
+
const t = dir.trim();
|
|
2267
|
+
if (!t) continue;
|
|
2268
|
+
try {
|
|
2269
|
+
if (isFile(join(t, "dsh"))) roots.push({ dir: join(dirname(t), "lib", "node_modules"), source: "npm-global" });
|
|
2270
|
+
} catch { /* no dsh here */ }
|
|
2271
|
+
}
|
|
2272
|
+
const home = opts.home ?? homedir();
|
|
2273
|
+
if (home) roots.push({ dir: join(home, ".npm-global", "lib", "node_modules"), source: "npm-global" });
|
|
2274
|
+
for (const sys of opts.systemRoots ?? ["/usr/lib/node_modules", "/usr/local/lib/node_modules"]) {
|
|
2275
|
+
roots.push({ dir: sys, source: "system" });
|
|
2276
|
+
}
|
|
2277
|
+
const seen = new Set();
|
|
2278
|
+
for (const r of roots) {
|
|
2279
|
+
for (const base of [join(r.dir, "@deepseek-ai", "dsh"), join(r.dir, "dsh")]) {
|
|
2280
|
+
const candidate = join(base, "node_modules", tail);
|
|
2281
|
+
if (seen.has(candidate)) continue;
|
|
2282
|
+
seen.add(candidate);
|
|
2283
|
+
const hit = tryPath(candidate, r.source);
|
|
2284
|
+
if (hit) return hit;
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
return null;
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/** On-disk compaction state for one profile: whether its patch covers the
|
|
2291
|
+
* saved router profiles. Never throws — diagnostics must not break the poll. */
|
|
2292
|
+
export function readCompactionState(profileName, profiles) {
|
|
2293
|
+
try {
|
|
2294
|
+
const patchFile = profilePatchFile(profileName);
|
|
2295
|
+
let text = null;
|
|
2296
|
+
try {
|
|
2297
|
+
text = readFileSync(patchFile, "utf8");
|
|
2298
|
+
} catch (err) {
|
|
2299
|
+
if (err?.code !== "ENOENT") throw err;
|
|
2300
|
+
}
|
|
2301
|
+
const existing = text === null ? null : readModelPolicies(text);
|
|
2302
|
+
const plan = compactionPlan(profiles, existing);
|
|
2303
|
+
return {
|
|
2304
|
+
profile: profileName,
|
|
2305
|
+
patchFile,
|
|
2306
|
+
patchExists: text !== null,
|
|
2307
|
+
// null = no usable preset-standard entry (unknown coverage);
|
|
2308
|
+
// [] = entry present, no policies yet.
|
|
2309
|
+
policies: existing,
|
|
2310
|
+
plan,
|
|
2311
|
+
covered: plan.filter((p) => p.status === "covered").length,
|
|
2312
|
+
stale: plan.filter((p) => p.status === "stale").length,
|
|
2313
|
+
missing: plan.filter((p) => p.status === "missing").map((p) => p.model),
|
|
2314
|
+
total: plan.length,
|
|
2315
|
+
};
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
return { profile: profileName, error: err?.message ?? String(err), total: 0, covered: 0, stale: 0, missing: [] };
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/** Merge the desired policies into one profile's patch file. Idempotent,
|
|
2322
|
+
* backup-first, atomic. Throws on unrecognized shape (never half-writes).
|
|
2323
|
+
* `profiles` defaults to the saved store; `allowCreate: false` (the
|
|
2324
|
+
* autostart sweep) skips patches without a preset-standard entry instead of
|
|
2325
|
+
* building one; `dryRun` computes without writing. */
|
|
2326
|
+
export function syncCompactionPatch(profileName, opts = {}) {
|
|
2327
|
+
const file = profilePatchFile(profileName);
|
|
2328
|
+
const profiles = opts.profiles !== undefined ? opts.profiles : readProfiles();
|
|
2329
|
+
const allowCreate = opts.allowCreate !== false;
|
|
2330
|
+
const dryRun = opts.dryRun === true;
|
|
2331
|
+
const policies = desiredCompactionPolicies(profiles);
|
|
2332
|
+
let oldText = null;
|
|
2333
|
+
try {
|
|
2334
|
+
oldText = readFileSync(file, "utf8");
|
|
2335
|
+
} catch (err) {
|
|
2336
|
+
if (err?.code !== "ENOENT") throw new Error("cannot read " + file + ": " + (err?.message ?? String(err)));
|
|
2337
|
+
}
|
|
2338
|
+
const hasEntry = typeof oldText === "string" && readModelPolicies(oldText) !== null;
|
|
2339
|
+
let installedText = null;
|
|
2340
|
+
if (oldText === null || !hasEntry) {
|
|
2341
|
+
if (!allowCreate) {
|
|
2342
|
+
return {
|
|
2343
|
+
ok: true, profile: profileName, patchFile: file, created: false, changed: false,
|
|
2344
|
+
backup: null, added: [], updated: [], unchanged: [], skipped: "no preset-standard entry",
|
|
2345
|
+
needRestart: false, policies,
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
const found = locateInstalledStandardPreset(opts.locate ?? {});
|
|
2349
|
+
if (!found) {
|
|
2350
|
+
throw new Error("no preset-standard entry in " + file + " and the installed preset file was not found — create the entry once from dsh-web-app/presets/standard.patch.yml, then sync maintains it");
|
|
2351
|
+
}
|
|
2352
|
+
installedText = readFileSync(found.path, "utf8");
|
|
2353
|
+
}
|
|
2354
|
+
const synced = syncPresetStandardPatch(oldText, installedText, policies);
|
|
2355
|
+
if (synced.text === (oldText ?? "")) {
|
|
2356
|
+
return {
|
|
2357
|
+
ok: true, profile: profileName, patchFile: file, created: synced.created, changed: false,
|
|
2358
|
+
backup: null, added: [], updated: [], unchanged: synced.unchanged, needRestart: false, policies,
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
if (dryRun) {
|
|
2362
|
+
return {
|
|
2363
|
+
ok: true, profile: profileName, patchFile: file, created: synced.created, changed: true,
|
|
2364
|
+
backup: null, added: synced.added, updated: synced.updated, unchanged: synced.unchanged,
|
|
2365
|
+
needRestart: true, policies, text: synced.text,
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
let backup = null;
|
|
2369
|
+
if (oldText !== null) {
|
|
2370
|
+
backup = file + ".bak-" + new Date().toISOString().replace(/[:.]/g, "-");
|
|
2371
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
2372
|
+
copyFileSync(file, backup);
|
|
2373
|
+
}
|
|
2374
|
+
writeTextAtomic(file, synced.text);
|
|
2375
|
+
return {
|
|
2376
|
+
ok: true, profile: profileName, patchFile: file, created: synced.created, changed: true,
|
|
2377
|
+
backup, added: synced.added, updated: synced.updated, unchanged: synced.unchanged,
|
|
2378
|
+
needRestart: true, policies,
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
|
|
1796
2382
|
function writeProfiles(list) {
|
|
1797
2383
|
writeJsonAtomic(profilesFile(), list);
|
|
1798
2384
|
presetsCache = list;
|
|
@@ -1868,12 +2454,12 @@ function cachedProfiles() {
|
|
|
1868
2454
|
export function normalizeProfileConfig(raw) {
|
|
1869
2455
|
const modelPath = typeof raw?.modelPath === "string" && raw.modelPath ? raw.modelPath : "";
|
|
1870
2456
|
if (!modelPath || !existsSync(modelPath)) throw new Error("profile needs a modelPath that exists on disk");
|
|
1871
|
-
if (!isPathAllowed(modelPath)) throw new Error("modelPath
|
|
2457
|
+
if (!isPathAllowed(modelPath)) throw new Error("modelPath " + NOT_READABLE + ": " + modelPath);
|
|
1872
2458
|
const ctx = Number.isInteger(raw?.ctx) && raw.ctx > 0 ? raw.ctx : 8192;
|
|
1873
2459
|
const mtpHeads = Number.isInteger(raw?.mtpHeads) && raw.mtpHeads >= 0 ? Math.min(raw.mtpHeads, MTP_MAX) : 0;
|
|
1874
2460
|
const mmprojPath = typeof raw?.mmprojPath === "string" && raw.mmprojPath ? raw.mmprojPath : null;
|
|
1875
2461
|
if (mmprojPath && !existsSync(mmprojPath)) throw new Error("mmprojPath does not exist: " + mmprojPath);
|
|
1876
|
-
if (mmprojPath && !isPathAllowed(mmprojPath)) throw new Error("mmprojPath
|
|
2462
|
+
if (mmprojPath && !isPathAllowed(mmprojPath)) throw new Error("mmprojPath " + NOT_READABLE + ": " + mmprojPath);
|
|
1877
2463
|
const effort = normalizeEffort(raw?.effort);
|
|
1878
2464
|
const preserveThinking = normalizePreserveThinking(raw);
|
|
1879
2465
|
// Multi-GPU placement. Legacy profiles have neither field and keep
|
|
@@ -2088,11 +2674,16 @@ export function apply(ctx) {
|
|
|
2088
2674
|
const url = new URL(req.url ?? "/", "http://x");
|
|
2089
2675
|
const dir = url.searchParams.get("dir");
|
|
2090
2676
|
const allFiles = url.searchParams.get("all") === "1";
|
|
2091
|
-
const
|
|
2677
|
+
const raw = dir && dir.indexOf("/") === 0
|
|
2092
2678
|
? dir
|
|
2093
2679
|
: dir ? join(homedir(), dir) : homedir();
|
|
2680
|
+
// Canonical before anything else. The picker joins with "/" and
|
|
2681
|
+
// starts at the root, so a click on /etc arrives as "//etc" — and
|
|
2682
|
+
// the path bar would then read "//etc", with every deeper click
|
|
2683
|
+
// stacking another slash. resolve() also collapses "..".
|
|
2684
|
+
const target = resolve(raw);
|
|
2094
2685
|
if (!isPathAllowed(target)) {
|
|
2095
|
-
sendJson(res, 400, { error:
|
|
2686
|
+
sendJson(res, 400, { error: NOT_READABLE + ": " + target });
|
|
2096
2687
|
return;
|
|
2097
2688
|
}
|
|
2098
2689
|
const listing = listDir(target, allFiles);
|
|
@@ -2120,7 +2711,7 @@ export function apply(ctx) {
|
|
|
2120
2711
|
return;
|
|
2121
2712
|
}
|
|
2122
2713
|
if (!isPathAllowed(path)) {
|
|
2123
|
-
sendJson(res, 400, { error:
|
|
2714
|
+
sendJson(res, 400, { error: NOT_READABLE + ": " + path });
|
|
2124
2715
|
return;
|
|
2125
2716
|
}
|
|
2126
2717
|
try {
|
|
@@ -2213,11 +2804,11 @@ export function apply(ctx) {
|
|
|
2213
2804
|
return;
|
|
2214
2805
|
}
|
|
2215
2806
|
if (!isPathAllowed(path)) {
|
|
2216
|
-
sendJson(res, 400, { error: "model
|
|
2807
|
+
sendJson(res, 400, { error: "model " + NOT_READABLE + ": " + path });
|
|
2217
2808
|
return;
|
|
2218
2809
|
}
|
|
2219
2810
|
if (mmproj && !isPathAllowed(mmproj)) {
|
|
2220
|
-
sendJson(res, 400, { error: "mmproj
|
|
2811
|
+
sendJson(res, 400, { error: "mmproj " + NOT_READABLE + ": " + mmproj });
|
|
2221
2812
|
return;
|
|
2222
2813
|
}
|
|
2223
2814
|
const result = await manager.run(path, Number.isFinite(ctx) ? ctx : 8192, Number.isFinite(mtp) ? mtp : 0, mmproj, effort, typeof body.mmprojCpu === "boolean" ? body.mmprojCpu : MMPROJ_CPU, moe, normalizePreserveThinking(body), kvTypeK, kvTypeV, normalizeSplitMode(body.splitMode), normalizeTensorSplit(body.tensorSplit));
|
|
@@ -2362,8 +2953,8 @@ export function apply(ctx) {
|
|
|
2362
2953
|
}
|
|
2363
2954
|
patch.binPath = wanted;
|
|
2364
2955
|
}
|
|
2365
|
-
// File-browser shortcuts
|
|
2366
|
-
//
|
|
2956
|
+
// File-browser shortcuts become one button each in the picker,
|
|
2957
|
+
// so a value here has to name a folder a person would: absolute,
|
|
2367
2958
|
// no "..", never the filesystem root, at most 12 entries.
|
|
2368
2959
|
// Missing dirs are still kept (an unmounted disk must not
|
|
2369
2960
|
// silently lose its shortcut); the tab marks them as missing.
|
|
@@ -2443,6 +3034,13 @@ export function apply(ctx) {
|
|
|
2443
3034
|
path: "/local-models/router/start",
|
|
2444
3035
|
handler: async (req, res) => {
|
|
2445
3036
|
if (req.method !== "POST") { sendJson(res, 405, { error: "POST required" }); return; }
|
|
3037
|
+
let body = {};
|
|
3038
|
+
try {
|
|
3039
|
+
body = JSON.parse((await readBody(req)) || "{}");
|
|
3040
|
+
} catch {
|
|
3041
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
2446
3044
|
let profiles;
|
|
2447
3045
|
try {
|
|
2448
3046
|
profiles = readProfiles();
|
|
@@ -2451,13 +3049,76 @@ export function apply(ctx) {
|
|
|
2451
3049
|
return;
|
|
2452
3050
|
}
|
|
2453
3051
|
if (profiles.length === 0) { sendJson(res, 400, { error: "save at least one profile first" }); return; }
|
|
3052
|
+
// Compaction guard first: every saved router profile gets its
|
|
3053
|
+
// headroom override synced into the profile patch, so the next
|
|
3054
|
+
// dsh boot cannot compact-loop on it. Best-effort — a sync
|
|
3055
|
+
// failure is reported, never a start failure. Opt out with
|
|
3056
|
+
// { compactionSync: false }; target another dsh profile with
|
|
3057
|
+
// { compactionProfile: "<name>" } (default "web").
|
|
3058
|
+
let compactionSync = null;
|
|
3059
|
+
if (body.compactionSync !== false) {
|
|
3060
|
+
const target = typeof body.compactionProfile === "string" && body.compactionProfile
|
|
3061
|
+
? body.compactionProfile
|
|
3062
|
+
: "web";
|
|
3063
|
+
try {
|
|
3064
|
+
compactionSync = syncCompactionPatch(target, { profiles, allowCreate: true });
|
|
3065
|
+
} catch (err) {
|
|
3066
|
+
compactionSync = { ok: false, profile: target, error: err?.message ?? String(err) };
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
2454
3069
|
try {
|
|
2455
3070
|
mkdirSync(dataDir(), { recursive: true });
|
|
2456
3071
|
writeFileSync(ROUTER_PRESETS_FILE, generateRouterPresets(profiles), "utf8");
|
|
2457
3072
|
const result = await manager.startRouter(ROUTER_PRESETS_FILE);
|
|
2458
|
-
sendJson(res, result.ok ? 200 : 409, { ok: result.ok, error: result.error, presets: profiles.length, iniPath: ROUTER_PRESETS_FILE });
|
|
3073
|
+
sendJson(res, result.ok ? 200 : 409, { ok: result.ok, error: result.error, presets: profiles.length, iniPath: ROUTER_PRESETS_FILE, compactionSync });
|
|
3074
|
+
} catch (err) {
|
|
3075
|
+
sendJson(res, 500, { error: err?.message ?? String(err), compactionSync });
|
|
3076
|
+
}
|
|
3077
|
+
},
|
|
3078
|
+
},
|
|
3079
|
+
{
|
|
3080
|
+
// Compaction coverage diagnostics: per-profile default threshold vs
|
|
3081
|
+
// the on-disk override. Query ?profile=<name> (default "web").
|
|
3082
|
+
kind: "exact",
|
|
3083
|
+
path: "/local-models/compaction",
|
|
3084
|
+
handler: async (req, res) => {
|
|
3085
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
3086
|
+
const target = url.searchParams.get("profile") || "web";
|
|
3087
|
+
let profiles;
|
|
3088
|
+
try {
|
|
3089
|
+
profiles = readProfiles();
|
|
2459
3090
|
} catch (err) {
|
|
2460
3091
|
sendJson(res, 500, { error: err?.message ?? String(err) });
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
3094
|
+
try {
|
|
3095
|
+
profilePatchFile(target); // name charset first: 400, not a state error
|
|
3096
|
+
sendJson(res, 200, readCompactionState(target, profiles));
|
|
3097
|
+
} catch (err) {
|
|
3098
|
+
sendJson(res, 400, { error: err?.message ?? String(err) });
|
|
3099
|
+
}
|
|
3100
|
+
},
|
|
3101
|
+
},
|
|
3102
|
+
{
|
|
3103
|
+
// On-demand compaction sync. Body { profile = "web", dryRun = false }:
|
|
3104
|
+
// dryRun previews { added, updated, text } without writing.
|
|
3105
|
+
kind: "exact",
|
|
3106
|
+
path: "/local-models/compaction/sync",
|
|
3107
|
+
handler: async (req, res) => {
|
|
3108
|
+
if (req.method !== "POST") { sendJson(res, 405, { error: "POST required" }); return; }
|
|
3109
|
+
let body;
|
|
3110
|
+
try {
|
|
3111
|
+
body = JSON.parse((await readBody(req)) || "{}");
|
|
3112
|
+
} catch {
|
|
3113
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
3116
|
+
const target = typeof body.profile === "string" && body.profile ? body.profile : "web";
|
|
3117
|
+
try {
|
|
3118
|
+
const report = syncCompactionPatch(target, { allowCreate: true, dryRun: body.dryRun === true });
|
|
3119
|
+
sendJson(res, 200, report);
|
|
3120
|
+
} catch (err) {
|
|
3121
|
+
sendJson(res, 400, { error: err?.message ?? String(err) });
|
|
2461
3122
|
}
|
|
2462
3123
|
},
|
|
2463
3124
|
},
|
|
@@ -2585,6 +3246,20 @@ export function apply(ctx) {
|
|
|
2585
3246
|
note("starting router (" + profiles.length + " profiles)...");
|
|
2586
3247
|
mkdirSync(dataDir(), { recursive: true });
|
|
2587
3248
|
writeFileSync(ROUTER_PRESETS_FILE, generateRouterPresets(profiles), "utf8");
|
|
3249
|
+
// Compaction guard, update-only: keep every profile patch that
|
|
3250
|
+
// already carries a preset-standard entry covering the saved
|
|
3251
|
+
// router models (never create entries here — boot must not invent
|
|
3252
|
+
// composition; the tab's Start Router does the full sync).
|
|
3253
|
+
for (const name of listProfileNames()) {
|
|
3254
|
+
try {
|
|
3255
|
+
const r = syncCompactionPatch(name, { profiles, allowCreate: false });
|
|
3256
|
+
if (r.changed) {
|
|
3257
|
+
note("compaction sync (" + name + "): +" + r.added.length + " ~" + r.updated.length + " (restart dsh to apply)");
|
|
3258
|
+
}
|
|
3259
|
+
} catch (err) {
|
|
3260
|
+
note("compaction sync (" + name + ") skipped: " + (err?.message ?? String(err)));
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
2588
3263
|
const result = await manager.startRouter(ROUTER_PRESETS_FILE);
|
|
2589
3264
|
if (!result.ok) {
|
|
2590
3265
|
note("router failed to start: " + (result.error ?? "unknown error"));
|
package/package.json
CHANGED
|
@@ -37,9 +37,10 @@ cpuMoe, nCpuMoe, expertUsed, arch, updatedAt }`
|
|
|
37
37
|
|
|
38
38
|
- **Paths must exist and live on the fast mount**. Referencing the failing/
|
|
39
39
|
legacy `/mnt/disco1` is a red flag - targets are under `/mnt/raid0/GGUF/`.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
The tab can reach any absolute path (the picker's Up goes all the way to `/`,
|
|
41
|
+
so mounted disks are browsable without declaring them), so a path pointing
|
|
42
|
+
somewhere else is an operational mistake the guard will not catch - check it
|
|
43
|
+
by eye.
|
|
43
44
|
- **MTP depth must be <= 7** (the tab's ceiling; upstream clamps the
|
|
44
45
|
effective depth to the model's nextn depth) and **3 is the tuned value** —
|
|
45
46
|
deeper collapses at large ctx. Old profiles may still carry 4-6 from the
|
|
@@ -126,6 +127,12 @@ is wholesale, the row freezes: re-generate after any dsh upgrade that
|
|
|
126
127
|
touches the preset file, or upstream changes to that preset are silently
|
|
127
128
|
dropped. Only patch presets actually used (`standard` here); `ptc` /
|
|
128
129
|
`cordis` / `minimal` need the same treatment if adopted.
|
|
130
|
+
**Since 0.5.0 this manual path is the fallback: `POST
|
|
131
|
+
/local-models/router/start` runs `syncCompactionPatch()` automatically
|
|
132
|
+
(merge-only, idempotent, backup-first; `GET /local-models/compaction`
|
|
133
|
+
for diagnostics, `POST /local-models/compaction/sync` on demand), so a
|
|
134
|
+
newly saved profile can no longer boot uncovered — the remaining manual
|
|
135
|
+
step is the `dsh web` restart that composition requires.**
|
|
129
136
|
|
|
130
137
|
Shortcut with zero config edits: run the bigger window instead — the
|
|
131
138
|
200K/250K profiles threshold at ~106K/158K under defaults.
|