@otto-code/brain 0.8.1 → 0.8.2
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/dist/config/paths.d.ts +2 -0
- package/dist/config/paths.js +2 -0
- package/dist/models/index.js +7 -0
- package/dist/models/rename-map.d.ts +12 -0
- package/dist/models/rename-map.js +39 -0
- package/dist/service/host-api.d.ts +4 -0
- package/dist/service/host-api.js +65 -0
- package/dist/service/router.js +19 -0
- package/dist/service/run-log.d.ts +8 -0
- package/dist/service/run-log.js +39 -0
- package/dist/service/serve.js +12 -2
- package/dist/sysmon.d.ts +13 -0
- package/dist/sysmon.js +52 -1
- package/dist/tui/app.d.ts +26 -1
- package/dist/tui/app.js +250 -10
- package/package.json +1 -1
package/dist/config/paths.d.ts
CHANGED
|
@@ -4,11 +4,13 @@ export interface BrainPaths {
|
|
|
4
4
|
configFile: string;
|
|
5
5
|
profilesFile: string;
|
|
6
6
|
catalogFile: string;
|
|
7
|
+
renameMapFile: string;
|
|
7
8
|
modelsDir: string;
|
|
8
9
|
runtimesDir: string;
|
|
9
10
|
pidFile: string;
|
|
10
11
|
activityFile: string;
|
|
11
12
|
logFile: string;
|
|
13
|
+
logsDir: string;
|
|
12
14
|
resultsDir: string;
|
|
13
15
|
}
|
|
14
16
|
export declare function resolveBrainPaths(env?: NodeJS.ProcessEnv): BrainPaths;
|
package/dist/config/paths.js
CHANGED
|
@@ -15,6 +15,7 @@ export function resolveBrainPaths(env = process.env) {
|
|
|
15
15
|
configFile: path.join(root, "config.json"),
|
|
16
16
|
profilesFile: path.join(root, "profiles.json"),
|
|
17
17
|
catalogFile: path.join(root, "catalog.json"),
|
|
18
|
+
renameMapFile: path.join(root, "rename-map.json"),
|
|
18
19
|
modelsDir: path.join(root, "models"),
|
|
19
20
|
runtimesDir: path.join(root, "runtimes"),
|
|
20
21
|
pidFile: path.join(root, "otto-brain.pid"),
|
|
@@ -23,6 +24,7 @@ export function resolveBrainPaths(env = process.env) {
|
|
|
23
24
|
// service - which is what answers /__host/status - never sees them otherwise.
|
|
24
25
|
activityFile: path.join(root, "otto-brain.activity"),
|
|
25
26
|
logFile: path.join(root, "otto-brain.log"),
|
|
27
|
+
logsDir: path.join(root, "logs"),
|
|
26
28
|
resultsDir: path.join(root, "results"),
|
|
27
29
|
};
|
|
28
30
|
}
|
package/dist/models/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { CatalogSchema } from "../config/schema.js";
|
|
|
4
4
|
import { loadCatalog } from "../config/store.js";
|
|
5
5
|
import { resolveModelsDirs } from "./dirs.js";
|
|
6
6
|
import { enrichWithCatalog } from "./enrich.js";
|
|
7
|
+
import { loadRenameMap } from "./rename-map.js";
|
|
7
8
|
import { scan } from "./scan.js";
|
|
8
9
|
export * from "./scan.js";
|
|
9
10
|
export { pickModel, pickAutoModel } from "./pick.js";
|
|
@@ -30,6 +31,12 @@ export function scanModels(config, env = process.env, options = {}) {
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
const enriched = enrichWithCatalog(all, loadCatalogSafe(env));
|
|
34
|
+
const renameMap = loadRenameMap(resolveBrainPaths(env));
|
|
35
|
+
for (const model of enriched) {
|
|
36
|
+
if (renameMap[model.id]) {
|
|
37
|
+
model.displayName = renameMap[model.id];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
33
40
|
enriched.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
34
41
|
return enriched;
|
|
35
42
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type BrainPaths } from "../config/paths.js";
|
|
2
|
+
/** Every function here only ever touches renameMapFile; narrowed to that one
|
|
3
|
+
* field (rather than the full BrainPaths) so a test's fake paths object is
|
|
4
|
+
* actually type-checked instead of passing only because tsconfig excludes
|
|
5
|
+
* *.test.ts from the build. */
|
|
6
|
+
type RenameMapPaths = Pick<BrainPaths, "renameMapFile">;
|
|
7
|
+
export declare function loadRenameMap(paths?: RenameMapPaths): Record<string, string>;
|
|
8
|
+
export declare function saveRenameMap(map: Record<string, string>, paths?: RenameMapPaths): void;
|
|
9
|
+
export declare function updateDisplayName(modelId: string, displayName: string, paths?: RenameMapPaths): Record<string, string>;
|
|
10
|
+
export declare function deleteDisplayName(modelId: string, paths?: RenameMapPaths): Record<string, string>;
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=rename-map.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-defined model display-name overrides, keyed by model id, persisted at
|
|
3
|
+
* `$OTTO_HOME/otto-brain/rename-map.json`. Kept as its own file (rather than a
|
|
4
|
+
* field on the profiles store) so renaming a model never touches the
|
|
5
|
+
* calibration/profile data profiles.json carries.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { resolveBrainPaths } from "../config/paths.js";
|
|
10
|
+
import { writePrivateFileAtomicSync } from "../config/private-files.js";
|
|
11
|
+
const RenameMapSchema = z.record(z.string());
|
|
12
|
+
export function loadRenameMap(paths = resolveBrainPaths()) {
|
|
13
|
+
if (!existsSync(paths.renameMapFile))
|
|
14
|
+
return {};
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(readFileSync(paths.renameMapFile, "utf8"));
|
|
17
|
+
const result = RenameMapSchema.safeParse(parsed);
|
|
18
|
+
return result.success ? result.data : {};
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function saveRenameMap(map, paths = resolveBrainPaths()) {
|
|
25
|
+
writePrivateFileAtomicSync(paths.renameMapFile, `${JSON.stringify(map, null, 2)}\n`);
|
|
26
|
+
}
|
|
27
|
+
export function updateDisplayName(modelId, displayName, paths = resolveBrainPaths()) {
|
|
28
|
+
const map = loadRenameMap(paths);
|
|
29
|
+
map[modelId] = displayName;
|
|
30
|
+
saveRenameMap(map, paths);
|
|
31
|
+
return map;
|
|
32
|
+
}
|
|
33
|
+
export function deleteDisplayName(modelId, paths = resolveBrainPaths()) {
|
|
34
|
+
const map = loadRenameMap(paths);
|
|
35
|
+
delete map[modelId];
|
|
36
|
+
saveRenameMap(map, paths);
|
|
37
|
+
return map;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=rename-map.js.map
|
|
@@ -48,6 +48,10 @@ export interface HostCapabilities {
|
|
|
48
48
|
resources: boolean;
|
|
49
49
|
/** GET /__host/models */
|
|
50
50
|
inventory: boolean;
|
|
51
|
+
/** POST /__host/model/rename */
|
|
52
|
+
rename: boolean;
|
|
53
|
+
/** POST /__host/model/rename/reset */
|
|
54
|
+
reset: boolean;
|
|
51
55
|
/** Whether writes are currently permitted (allowRemoteConfig). */
|
|
52
56
|
writable: boolean;
|
|
53
57
|
}
|
package/dist/service/host-api.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { calibrationInfo, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, } from "../config/profile-edit.js";
|
|
2
2
|
import { forModel, getCalibration, put } from "../config/profiles.js";
|
|
3
3
|
import { deleteModelFiles, diskUsage, planDelete, totalModelBytes } from "../models/manage.js";
|
|
4
|
+
import { deleteDisplayName, updateDisplayName } from "../models/rename-map.js";
|
|
4
5
|
import * as vram from "../vram.js";
|
|
5
6
|
import { errorMessage, readJsonBody, sendError, sendJson } from "./http-util.js";
|
|
6
7
|
const MAX_PATCH_BYTES = 256 * 1024;
|
|
8
|
+
const MAX_DISPLAY_NAME = 200;
|
|
7
9
|
const DEFAULT_LOG_LINES = 200;
|
|
8
10
|
function stateOf(supervisor, model) {
|
|
9
11
|
if (!supervisor.model || supervisor.model.id !== model.id)
|
|
@@ -101,6 +103,8 @@ export function createHostApi(deps) {
|
|
|
101
103
|
load: true,
|
|
102
104
|
resources: true,
|
|
103
105
|
inventory: true,
|
|
106
|
+
rename: true,
|
|
107
|
+
reset: true,
|
|
104
108
|
writable: deps.getAllowWrite(),
|
|
105
109
|
});
|
|
106
110
|
/** Refuse a write unless the owner opted into remote configuration. */
|
|
@@ -180,6 +184,53 @@ export function createHostApi(deps) {
|
|
|
180
184
|
})();
|
|
181
185
|
});
|
|
182
186
|
};
|
|
187
|
+
const handleRename = (req, res, model) => {
|
|
188
|
+
readJsonBody(req, MAX_DISPLAY_NAME + 64, (result) => {
|
|
189
|
+
if (!result.ok) {
|
|
190
|
+
sendError(res, 400, result.error);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const body = result.body;
|
|
194
|
+
const displayName = body.displayName;
|
|
195
|
+
if (typeof displayName !== "string" || displayName.trim().length === 0) {
|
|
196
|
+
sendError(res, 400, "displayName must be a non-empty string");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (displayName.length > MAX_DISPLAY_NAME) {
|
|
200
|
+
sendError(res, 400, `displayName must be at most ${MAX_DISPLAY_NAME} characters`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (/[^\x20-\x7E]/.test(displayName)) {
|
|
204
|
+
sendError(res, 400, "displayName must not contain control characters or non-ASCII");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// /v1/models keys its `id` on displayName (router.ts) and both the
|
|
208
|
+
// completion path and defaultModel/switchTo resolve a model by
|
|
209
|
+
// `displayName === name || id === name` - a collision here would make
|
|
210
|
+
// one of the two models unreachable by name with no error anywhere.
|
|
211
|
+
const conflict = deps
|
|
212
|
+
.getCatalog()
|
|
213
|
+
.find((m) => m.id !== model.id && (m.displayName === displayName || m.id === displayName));
|
|
214
|
+
if (conflict) {
|
|
215
|
+
sendError(res, 409, `another model is already named "${displayName}"`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
updateDisplayName(model.id, displayName);
|
|
219
|
+
sendJson(res, { displayName });
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
const handleReset = (req, res, model) => {
|
|
223
|
+
readJsonBody(req, 4096, (result) => {
|
|
224
|
+
if (!result.ok) {
|
|
225
|
+
sendError(res, 400, result.error);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
deleteDisplayName(model.id);
|
|
229
|
+
const catalog = deps.rescan();
|
|
230
|
+
const updated = resolveModel(catalog, model.id);
|
|
231
|
+
sendJson(res, { displayName: updated ? updated.displayName : model.displayName });
|
|
232
|
+
});
|
|
233
|
+
};
|
|
183
234
|
const handleBudget = (res, model, params) => {
|
|
184
235
|
void (async () => {
|
|
185
236
|
try {
|
|
@@ -326,6 +377,8 @@ export function createHostApi(deps) {
|
|
|
326
377
|
"/__host/model/budget",
|
|
327
378
|
"/__host/model/load",
|
|
328
379
|
"/__host/model/fields",
|
|
380
|
+
"/__host/model/rename",
|
|
381
|
+
"/__host/model/rename/reset",
|
|
329
382
|
]);
|
|
330
383
|
if (!modelRoutes.has(route))
|
|
331
384
|
return false;
|
|
@@ -359,6 +412,18 @@ export function createHostApi(deps) {
|
|
|
359
412
|
handleLoad(res, model);
|
|
360
413
|
return true;
|
|
361
414
|
}
|
|
415
|
+
if (route === "/__host/model/rename" && method === "POST") {
|
|
416
|
+
if (!guardWrite(res))
|
|
417
|
+
return true;
|
|
418
|
+
handleRename(req, res, model);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
if (route === "/__host/model/rename/reset" && method === "POST") {
|
|
422
|
+
if (!guardWrite(res))
|
|
423
|
+
return true;
|
|
424
|
+
handleReset(req, res, model);
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
362
427
|
if (route === "/__host/model" && method === "DELETE") {
|
|
363
428
|
if (!guardWrite(res))
|
|
364
429
|
return true;
|
package/dist/service/router.js
CHANGED
|
@@ -298,6 +298,25 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
298
298
|
}
|
|
299
299
|
res.writeHead(upstreamRes.statusCode ?? 502, outHeaders);
|
|
300
300
|
const isStream = String(upstreamRes.headers["content-type"] || "").includes("event-stream");
|
|
301
|
+
const upstreamResponseFailed = (error) => {
|
|
302
|
+
if (settled)
|
|
303
|
+
return;
|
|
304
|
+
const message = `llama-server response ended unexpectedly: ${error.message}`;
|
|
305
|
+
telemetry.record({
|
|
306
|
+
at: new Date().toISOString(),
|
|
307
|
+
path: req.url,
|
|
308
|
+
verdict: "failed",
|
|
309
|
+
error: message,
|
|
310
|
+
});
|
|
311
|
+
logger?.warn?.(message);
|
|
312
|
+
// Headers may already be on the wire for an SSE response. Destroying
|
|
313
|
+
// it is the only honest result, but `done()` still releases the queue.
|
|
314
|
+
if (!res.writableEnded && !res.destroyed)
|
|
315
|
+
res.destroy(error);
|
|
316
|
+
done();
|
|
317
|
+
};
|
|
318
|
+
upstreamRes.once("aborted", () => upstreamResponseFailed(new Error("upstream response aborted")));
|
|
319
|
+
upstreamRes.once("error", upstreamResponseFailed);
|
|
301
320
|
if (isStream) {
|
|
302
321
|
let sawContent = false;
|
|
303
322
|
let sawReasoning = false;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface BrainRunLog {
|
|
2
|
+
path: string;
|
|
3
|
+
write(line: string): void;
|
|
4
|
+
}
|
|
5
|
+
/** Start a fresh Brain log and prune only expired Brain run logs. */
|
|
6
|
+
export declare function createBrainRunLog(env?: NodeJS.ProcessEnv): BrainRunLog;
|
|
7
|
+
export declare function pruneBrainRunLogs(logsDir: string, now?: number): void;
|
|
8
|
+
//# sourceMappingURL=run-log.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Durable, per-service-run diagnostics for Otto Brain. */
|
|
2
|
+
import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveBrainPaths } from "../config/paths.js";
|
|
5
|
+
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
6
|
+
const RUN_LOG_SUFFIX = "-brain.log";
|
|
7
|
+
/** Start a fresh Brain log and prune only expired Brain run logs. */
|
|
8
|
+
export function createBrainRunLog(env = process.env) {
|
|
9
|
+
const { logsDir } = resolveBrainPaths(env);
|
|
10
|
+
const startedAt = new Date();
|
|
11
|
+
const stamp = startedAt
|
|
12
|
+
.toISOString()
|
|
13
|
+
.replace(/[-:]/g, "")
|
|
14
|
+
.replace(/\.\d{3}Z$/, "Z");
|
|
15
|
+
const filePath = path.join(logsDir, `${stamp}-${process.pid}${RUN_LOG_SUFFIX}`);
|
|
16
|
+
try {
|
|
17
|
+
mkdirSync(logsDir, { recursive: true });
|
|
18
|
+
pruneBrainRunLogs(logsDir, startedAt.getTime());
|
|
19
|
+
}
|
|
20
|
+
catch { }
|
|
21
|
+
const write = (line) => {
|
|
22
|
+
try {
|
|
23
|
+
appendFileSync(filePath, `${new Date().toISOString()} ${line}\n`, "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
};
|
|
27
|
+
write(`Brain service started (pid ${process.pid})`);
|
|
28
|
+
return { path: filePath, write };
|
|
29
|
+
}
|
|
30
|
+
export function pruneBrainRunLogs(logsDir, now = Date.now()) {
|
|
31
|
+
for (const entry of readdirSync(logsDir, { withFileTypes: true })) {
|
|
32
|
+
if (!entry.isFile() || !entry.name.endsWith(RUN_LOG_SUFFIX))
|
|
33
|
+
continue;
|
|
34
|
+
const filePath = path.join(logsDir, entry.name);
|
|
35
|
+
if (now - statSync(filePath).mtimeMs > RETENTION_MS)
|
|
36
|
+
rmSync(filePath, { force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=run-log.js.map
|
package/dist/service/serve.js
CHANGED
|
@@ -27,6 +27,7 @@ import { Supervisor } from "./supervisor.js";
|
|
|
27
27
|
import * as tailscale from "./tailscale.js";
|
|
28
28
|
import { CertManager, resolveTlsOptions } from "./tls.js";
|
|
29
29
|
import { removePidFile, writePidFile } from "./pid-lock.js";
|
|
30
|
+
import { createBrainRunLog } from "./run-log.js";
|
|
30
31
|
/** The effective config with secrets masked, for the `/__host/config` read. */
|
|
31
32
|
function redactConfig(config) {
|
|
32
33
|
return {
|
|
@@ -93,6 +94,11 @@ function withAuth(inner, token) {
|
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
96
|
export async function startService({ config, modelNeedle, env = process.env, onLog = () => { }, }) {
|
|
97
|
+
const runLog = createBrainRunLog(env);
|
|
98
|
+
const log = (line) => {
|
|
99
|
+
runLog.write(line);
|
|
100
|
+
onLog(line);
|
|
101
|
+
};
|
|
96
102
|
const runtime = resolveRuntime(config, env);
|
|
97
103
|
if (!runtime) {
|
|
98
104
|
throw new CommandError({
|
|
@@ -156,15 +162,17 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
156
162
|
});
|
|
157
163
|
}
|
|
158
164
|
if (fit.adjusted && fit.reason)
|
|
159
|
-
|
|
165
|
+
log(`note: ${fit.reason}`);
|
|
160
166
|
profile = fit.profile;
|
|
161
167
|
}
|
|
162
168
|
const telemetry = new Telemetry();
|
|
163
169
|
const supervisor = new Supervisor({ runtime });
|
|
164
170
|
supervisor.on("log", (line) => {
|
|
171
|
+
runLog.write(line);
|
|
165
172
|
if (/error|failed|warn/i.test(line))
|
|
166
173
|
onLog(line);
|
|
167
174
|
});
|
|
175
|
+
supervisor.on("crashed", (error) => runLog.write(`FATAL ${error}`));
|
|
168
176
|
// Serialize model switches: the router queues request-driven switches, but the
|
|
169
177
|
// config path (POST /__host/config) calls loadModel directly. Chaining here
|
|
170
178
|
// guarantees two switches (e.g. a config write racing a request-driven switch)
|
|
@@ -262,7 +270,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
262
270
|
const handler = withAuth(createRouter({
|
|
263
271
|
supervisor,
|
|
264
272
|
telemetry,
|
|
265
|
-
logger: { warn: (m) =>
|
|
273
|
+
logger: { warn: (m) => log(`WARN ${m}`) },
|
|
266
274
|
getCatalog: () => catalog,
|
|
267
275
|
loadModel,
|
|
268
276
|
version: resolveVersion(),
|
|
@@ -311,8 +319,10 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
311
319
|
secure: Boolean(tlsOptions),
|
|
312
320
|
displayHost,
|
|
313
321
|
}, env);
|
|
322
|
+
log(`ready: ${model.displayName} on ${bindHost}:${port}; run log ${runLog.path}`);
|
|
314
323
|
const stop = async () => {
|
|
315
324
|
certManager?.stop();
|
|
325
|
+
log("Brain service stopping");
|
|
316
326
|
await supervisor.stop();
|
|
317
327
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
318
328
|
removePidFile(env);
|
package/dist/sysmon.d.ts
CHANGED
|
@@ -31,6 +31,14 @@ export interface SlotInfo {
|
|
|
31
31
|
/** Slots emitting tokens. */
|
|
32
32
|
decode: number;
|
|
33
33
|
contexts: number[];
|
|
34
|
+
threads?: Array<{
|
|
35
|
+
slot: number;
|
|
36
|
+
phase: "prefill" | "decode";
|
|
37
|
+
promptTokens: number;
|
|
38
|
+
generatedTokens: number;
|
|
39
|
+
promptTokensPerSecond: number | null;
|
|
40
|
+
tokensPerSecond: number | null;
|
|
41
|
+
}>;
|
|
34
42
|
}
|
|
35
43
|
/** One combined reading for the status panel. */
|
|
36
44
|
export interface SystemSample {
|
|
@@ -55,6 +63,11 @@ export declare function createCpuSampler(): CpuSampler;
|
|
|
55
63
|
* the field spellings real llama.cpp builds emit, without a live server.
|
|
56
64
|
*/
|
|
57
65
|
export declare function summariseSlots(rows: unknown[]): SlotInfo;
|
|
66
|
+
/** Measures throughput from successive `/slots` snapshots, without guessing. */
|
|
67
|
+
export declare class SlotActivityTracker {
|
|
68
|
+
#private;
|
|
69
|
+
sample(rows: unknown[], now?: number): SlotInfo;
|
|
70
|
+
}
|
|
58
71
|
/** Slot occupancy from the running server. */
|
|
59
72
|
declare function slots({ host, port }: {
|
|
60
73
|
host: string;
|
package/dist/sysmon.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
2
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
3
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
4
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
5
|
+
};
|
|
6
|
+
var _SlotActivityTracker_previous;
|
|
1
7
|
import os from "node:os";
|
|
2
8
|
import http from "node:http";
|
|
3
9
|
import { query } from "./gpu.js";
|
|
@@ -98,12 +104,57 @@ export function summariseSlots(rows) {
|
|
|
98
104
|
}),
|
|
99
105
|
};
|
|
100
106
|
}
|
|
107
|
+
/** Measures throughput from successive `/slots` snapshots, without guessing. */
|
|
108
|
+
export class SlotActivityTracker {
|
|
109
|
+
constructor() {
|
|
110
|
+
_SlotActivityTracker_previous.set(this, new Map());
|
|
111
|
+
}
|
|
112
|
+
sample(rows, now = Date.now()) {
|
|
113
|
+
const threads = rows.flatMap((row, slot) => {
|
|
114
|
+
const record = row;
|
|
115
|
+
if (!isProcessing(record)) {
|
|
116
|
+
__classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").delete(slot);
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
const promptTokens = counter(record, ["n_past", "n_prompt_tokens_processed"]);
|
|
120
|
+
const generatedTokens = decodedTokens(record);
|
|
121
|
+
const phase = generatedTokens === 0 ? "prefill" : "decode";
|
|
122
|
+
const previous = __classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").get(slot);
|
|
123
|
+
const elapsedSeconds = previous ? (now - previous.at) / 1000 : 0;
|
|
124
|
+
const rate = (current, before) => elapsedSeconds > 0 && before !== undefined && current >= before
|
|
125
|
+
? (current - before) / elapsedSeconds
|
|
126
|
+
: null;
|
|
127
|
+
__classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").set(slot, { at: now, promptTokens, generatedTokens });
|
|
128
|
+
return [
|
|
129
|
+
{
|
|
130
|
+
slot,
|
|
131
|
+
phase,
|
|
132
|
+
promptTokens,
|
|
133
|
+
generatedTokens,
|
|
134
|
+
promptTokensPerSecond: phase === "prefill" ? rate(promptTokens, previous?.promptTokens) : null,
|
|
135
|
+
tokensPerSecond: phase === "decode" ? rate(generatedTokens, previous?.generatedTokens) : null,
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
});
|
|
139
|
+
return { ...summariseSlots(rows), threads };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
_SlotActivityTracker_previous = new WeakMap();
|
|
143
|
+
function counter(record, keys) {
|
|
144
|
+
for (const key of keys) {
|
|
145
|
+
const value = record[key];
|
|
146
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
const slotActivityTracker = new SlotActivityTracker();
|
|
101
152
|
/** Slot occupancy from the running server. */
|
|
102
153
|
async function slots({ host, port }) {
|
|
103
154
|
const data = await fetchJson({ host, port, path: "/slots" });
|
|
104
155
|
if (!Array.isArray(data))
|
|
105
156
|
return null;
|
|
106
|
-
return
|
|
157
|
+
return slotActivityTracker.sample(data);
|
|
107
158
|
}
|
|
108
159
|
export { slots };
|
|
109
160
|
/** One combined reading for the status panel. */
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ interface FieldContext {
|
|
|
19
19
|
}
|
|
20
20
|
/** A pending destructive action awaiting y/n. */
|
|
21
21
|
interface ConfirmState {
|
|
22
|
-
kind: "delete";
|
|
22
|
+
kind: "delete" | "reset-name";
|
|
23
23
|
model: Model;
|
|
24
24
|
}
|
|
25
25
|
/** One downloadable quant in the picker, with whether it is already on disk. */
|
|
@@ -114,6 +114,11 @@ export declare class App {
|
|
|
114
114
|
fit: vram.FitResult;
|
|
115
115
|
} | null;
|
|
116
116
|
filterMode: boolean;
|
|
117
|
+
renaming: boolean;
|
|
118
|
+
renameBuffer: string;
|
|
119
|
+
/** ids with a persisted rename-map override, so "reset name" can tell the
|
|
120
|
+
* user there is nothing to reset instead of round-tripping for a no-op. */
|
|
121
|
+
renamedIds: Set<string>;
|
|
117
122
|
confirming: ConfirmState | null;
|
|
118
123
|
picker: PickerState | null;
|
|
119
124
|
search: SearchState | null;
|
|
@@ -127,6 +132,13 @@ export declare class App {
|
|
|
127
132
|
/** Fit a model to the live VRAM budget and (re)start the server on it. */
|
|
128
133
|
loadModelFitted(target: Model): Promise<void>;
|
|
129
134
|
startRouter(): Promise<void>;
|
|
135
|
+
/** POST /__host/model/rename?id=… - the one host-api route this embedded
|
|
136
|
+
* router serves, so the TUI's rename form and a remote-brain client hit the
|
|
137
|
+
* same wire shape. */
|
|
138
|
+
handleRenameRequest(req: http.IncomingMessage, res: http.ServerResponse): void;
|
|
139
|
+
/** POST /__host/model/rename/reset?id=… - clears this model's rename-map
|
|
140
|
+
* override and returns the scan-derived default name it reverted to. */
|
|
141
|
+
handleResetRequest(req: http.IncomingMessage, res: http.ServerResponse): void;
|
|
130
142
|
reload(): void;
|
|
131
143
|
/** Mean-of-runs rank + score per model, for ordering and badging the list. */
|
|
132
144
|
loadRankings(): void;
|
|
@@ -143,6 +155,16 @@ export declare class App {
|
|
|
143
155
|
onKey(key: string): void;
|
|
144
156
|
onFilterKey(key: string): void;
|
|
145
157
|
onEditKey(key: string): void;
|
|
158
|
+
beginRename(): void;
|
|
159
|
+
onRenameKey(key: string): void;
|
|
160
|
+
/** POST the new display name to this TUI's own local router (see
|
|
161
|
+
* handleRenameRequest / startRouter), then rescan so the renamed catalog
|
|
162
|
+
* entry - and its persisted override - are reflected immediately. */
|
|
163
|
+
submitRename(): Promise<void>;
|
|
164
|
+
beginResetName(): void;
|
|
165
|
+
/** POST to this TUI's own local router (see handleResetRequest), then
|
|
166
|
+
* rescan so the reverted name is reflected immediately. */
|
|
167
|
+
submitResetName(model: Model): Promise<void>;
|
|
146
168
|
move(delta: number): void;
|
|
147
169
|
usableFields(): number[];
|
|
148
170
|
/** Read a profile field by key, without narrowing to a single value type. */
|
|
@@ -189,6 +211,9 @@ export declare class App {
|
|
|
189
211
|
*/
|
|
190
212
|
renderFitted(lines: string[]): void;
|
|
191
213
|
draw(): void;
|
|
214
|
+
/** The rename input line shown above the footer while renaming is active:
|
|
215
|
+
* the current name in grey, the buffer typed so far, and a cursor. */
|
|
216
|
+
renameLine(cols: number): string;
|
|
192
217
|
/**
|
|
193
218
|
* Benchmark workspace: the model list on the left, the selected model's
|
|
194
219
|
* scorecard where Configuration sits in serve mode, and the ranked leaderboard
|
package/dist/tui/app.js
CHANGED
|
@@ -8,6 +8,8 @@ import { calibrate } from "../ops/calibrate.js";
|
|
|
8
8
|
import { sweep } from "../ops/sweep.js";
|
|
9
9
|
import * as results from "../ops/results.js";
|
|
10
10
|
import * as archive from "../ops/archive.js";
|
|
11
|
+
import { deleteDisplayName, loadRenameMap, updateDisplayName } from "../models/rename-map.js";
|
|
12
|
+
import { readJsonBody, sendError, sendJson } from "../service/http-util.js";
|
|
11
13
|
import { Supervisor } from "../service/supervisor.js";
|
|
12
14
|
import { createRouter, Telemetry } from "../service/router.js";
|
|
13
15
|
import * as sysmon from "../sysmon.js";
|
|
@@ -28,6 +30,9 @@ const REASONING_CYCLE = [0, 512, 1024, 1536, 3072, -1];
|
|
|
28
30
|
// hardware; draft models likewise do not help, so nothing boosts for them.
|
|
29
31
|
const VISION_RANK_BONUS = 0.03;
|
|
30
32
|
const THINKING_RANK_BONUS = 0.02;
|
|
33
|
+
// Mirrors host-api.ts's MAX_DISPLAY_NAME - the rename form posts to the same
|
|
34
|
+
// wire shape (see startRouter), so both paths must enforce the same limit.
|
|
35
|
+
const MAX_DISPLAY_NAME = 200;
|
|
31
36
|
/** Editable configuration fields, in display order. */
|
|
32
37
|
export const FIELDS = [
|
|
33
38
|
{
|
|
@@ -121,6 +126,11 @@ export class App {
|
|
|
121
126
|
*/
|
|
122
127
|
this.lastFit = null;
|
|
123
128
|
this.filterMode = false;
|
|
129
|
+
this.renaming = false;
|
|
130
|
+
this.renameBuffer = "";
|
|
131
|
+
/** ids with a persisted rename-map override, so "reset name" can tell the
|
|
132
|
+
* user there is nothing to reset instead of round-tripping for a no-op. */
|
|
133
|
+
this.renamedIds = new Set();
|
|
124
134
|
this.confirming = null;
|
|
125
135
|
this.picker = null;
|
|
126
136
|
this.search = null;
|
|
@@ -231,7 +241,22 @@ export class App {
|
|
|
231
241
|
getCatalog: () => this.catalog,
|
|
232
242
|
loadModel: (m) => this.loadModelFitted(m),
|
|
233
243
|
});
|
|
234
|
-
|
|
244
|
+
// The rename form posts to this local router (see submitRename) rather than
|
|
245
|
+
// calling updateDisplayName in-process, so a future remote-brain TUI hits the
|
|
246
|
+
// same code path this does. The full host-api surface is not wired in here -
|
|
247
|
+
// only these two routes - since nothing else in the TUI needs it over HTTP.
|
|
248
|
+
const server = http.createServer((req, res) => {
|
|
249
|
+
const path = (req.url ?? "").split("?")[0];
|
|
250
|
+
if (req.method === "POST" && path === "/__host/model/rename") {
|
|
251
|
+
this.handleRenameRequest(req, res);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (req.method === "POST" && path === "/__host/model/rename/reset") {
|
|
255
|
+
this.handleResetRequest(req, res);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
handler(req, res);
|
|
259
|
+
});
|
|
235
260
|
this.routerServer = server;
|
|
236
261
|
server.keepAliveTimeout = 75000;
|
|
237
262
|
server.requestTimeout = 0;
|
|
@@ -244,10 +269,73 @@ export class App {
|
|
|
244
269
|
this.routerServer = null;
|
|
245
270
|
});
|
|
246
271
|
}
|
|
272
|
+
/** POST /__host/model/rename?id=… - the one host-api route this embedded
|
|
273
|
+
* router serves, so the TUI's rename form and a remote-brain client hit the
|
|
274
|
+
* same wire shape. */
|
|
275
|
+
handleRenameRequest(req, res) {
|
|
276
|
+
const url = new URL(req.url ?? "", "http://brain.local");
|
|
277
|
+
const id = url.searchParams.get("id");
|
|
278
|
+
const model = id ? this.catalog.find((m) => m.id === id) : null;
|
|
279
|
+
if (!model) {
|
|
280
|
+
sendError(res, 404, id ? `model "${id}" was not found` : "an ?id= is required");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
readJsonBody(req, 4096, (result) => {
|
|
284
|
+
if (!result.ok) {
|
|
285
|
+
sendError(res, 400, result.error);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const body = result.body;
|
|
289
|
+
const displayName = body.displayName;
|
|
290
|
+
if (typeof displayName !== "string" || displayName.trim().length === 0) {
|
|
291
|
+
sendError(res, 400, "displayName must be a non-empty string");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (displayName.length > MAX_DISPLAY_NAME) {
|
|
295
|
+
sendError(res, 400, `displayName must be at most ${MAX_DISPLAY_NAME} characters`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (/[^\x20-\x7E]/.test(displayName)) {
|
|
299
|
+
sendError(res, 400, "displayName must not contain control characters or non-ASCII");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
// Same collision guard as host-api.ts's handleRename - both /v1/models
|
|
303
|
+
// and defaultModel/switchTo resolve a model by displayName, so a
|
|
304
|
+
// duplicate silently strands one of the two models unreachable by name.
|
|
305
|
+
const conflict = this.catalog.find((m) => m.id !== model.id && (m.displayName === displayName || m.id === displayName));
|
|
306
|
+
if (conflict) {
|
|
307
|
+
sendError(res, 409, `another model is already named "${displayName}"`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
updateDisplayName(model.id, displayName);
|
|
311
|
+
sendJson(res, { displayName });
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/** POST /__host/model/rename/reset?id=… - clears this model's rename-map
|
|
315
|
+
* override and returns the scan-derived default name it reverted to. */
|
|
316
|
+
handleResetRequest(req, res) {
|
|
317
|
+
const url = new URL(req.url ?? "", "http://brain.local");
|
|
318
|
+
const id = url.searchParams.get("id");
|
|
319
|
+
const model = id ? this.catalog.find((m) => m.id === id) : null;
|
|
320
|
+
if (!model) {
|
|
321
|
+
sendError(res, 404, id ? `model "${id}" was not found` : "an ?id= is required");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
readJsonBody(req, 4096, (result) => {
|
|
325
|
+
if (!result.ok) {
|
|
326
|
+
sendError(res, 400, result.error);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
deleteDisplayName(model.id);
|
|
330
|
+
const rescanned = scanModels(loadBrainConfig()).find((m) => m.id === model.id);
|
|
331
|
+
sendJson(res, { displayName: rescanned ? rescanned.displayName : model.displayName });
|
|
332
|
+
});
|
|
333
|
+
}
|
|
247
334
|
// -------------------------------------------------------------------- state
|
|
248
335
|
reload() {
|
|
249
336
|
const config = loadBrainConfig();
|
|
250
337
|
this.catalog = scanModels(config);
|
|
338
|
+
this.renamedIds = new Set(Object.keys(loadRenameMap()));
|
|
251
339
|
this.loadRankings();
|
|
252
340
|
this.selected = Math.min(this.selected, Math.max(0, this.visible.length - 1));
|
|
253
341
|
this.syncProfile();
|
|
@@ -346,6 +434,8 @@ export class App {
|
|
|
346
434
|
}
|
|
347
435
|
// -------------------------------------------------------------------- input
|
|
348
436
|
onKey(key) {
|
|
437
|
+
if (this.renaming)
|
|
438
|
+
return this.onRenameKey(key);
|
|
349
439
|
if (this.editing)
|
|
350
440
|
return this.onEditKey(key);
|
|
351
441
|
if (this.filterMode)
|
|
@@ -434,6 +524,14 @@ export class App {
|
|
|
434
524
|
case "D":
|
|
435
525
|
this.beginDelete();
|
|
436
526
|
break;
|
|
527
|
+
case "R":
|
|
528
|
+
if (this.focus === "models")
|
|
529
|
+
this.beginRename();
|
|
530
|
+
break;
|
|
531
|
+
case "u":
|
|
532
|
+
if (this.focus === "models")
|
|
533
|
+
this.beginResetName();
|
|
534
|
+
break;
|
|
437
535
|
case "r":
|
|
438
536
|
this.reload();
|
|
439
537
|
break;
|
|
@@ -500,6 +598,117 @@ export class App {
|
|
|
500
598
|
}
|
|
501
599
|
this.draw();
|
|
502
600
|
}
|
|
601
|
+
beginRename() {
|
|
602
|
+
const model = this.model;
|
|
603
|
+
if (!model)
|
|
604
|
+
return;
|
|
605
|
+
this.renaming = true;
|
|
606
|
+
this.renameBuffer = "";
|
|
607
|
+
this.setStatus(`rename "${model.displayName}" → `, "info");
|
|
608
|
+
}
|
|
609
|
+
onRenameKey(key) {
|
|
610
|
+
if (key === "escape") {
|
|
611
|
+
this.renaming = false;
|
|
612
|
+
this.renameBuffer = "";
|
|
613
|
+
this.setStatus("rename cancelled", "info");
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (key === "enter") {
|
|
617
|
+
void this.submitRename();
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (key === "backspace") {
|
|
621
|
+
this.renameBuffer = this.renameBuffer.slice(0, -1);
|
|
622
|
+
}
|
|
623
|
+
else if (key === "space") {
|
|
624
|
+
if (this.renameBuffer.length < 80)
|
|
625
|
+
this.renameBuffer += " ";
|
|
626
|
+
}
|
|
627
|
+
else if (key.length === 1 && key >= " " && this.renameBuffer.length < 80) {
|
|
628
|
+
this.renameBuffer += key;
|
|
629
|
+
}
|
|
630
|
+
this.draw();
|
|
631
|
+
}
|
|
632
|
+
/** POST the new display name to this TUI's own local router (see
|
|
633
|
+
* handleRenameRequest / startRouter), then rescan so the renamed catalog
|
|
634
|
+
* entry - and its persisted override - are reflected immediately. */
|
|
635
|
+
async submitRename() {
|
|
636
|
+
const model = this.model;
|
|
637
|
+
if (!model) {
|
|
638
|
+
this.renaming = false;
|
|
639
|
+
this.draw();
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const newName = this.renameBuffer.trim();
|
|
643
|
+
if (!newName) {
|
|
644
|
+
this.renaming = false;
|
|
645
|
+
this.renameBuffer = "";
|
|
646
|
+
this.setStatus("rename cancelled - name cannot be empty", "warn");
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
await this.guard("rename", async () => {
|
|
650
|
+
const url = `http://127.0.0.1:${this.listenPort}/__host/model/rename?id=${encodeURIComponent(model.id)}`;
|
|
651
|
+
const res = await fetch(url, {
|
|
652
|
+
method: "POST",
|
|
653
|
+
headers: { "content-type": "application/json" },
|
|
654
|
+
body: JSON.stringify({ displayName: newName }),
|
|
655
|
+
});
|
|
656
|
+
if (!res.ok) {
|
|
657
|
+
let message = `HTTP ${res.status}`;
|
|
658
|
+
try {
|
|
659
|
+
const body = (await res.json());
|
|
660
|
+
if (body.error?.message)
|
|
661
|
+
message = body.error.message;
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
/* non-JSON error body */
|
|
665
|
+
}
|
|
666
|
+
throw new Error(message);
|
|
667
|
+
}
|
|
668
|
+
this.renaming = false;
|
|
669
|
+
this.renameBuffer = "";
|
|
670
|
+
this.reload();
|
|
671
|
+
this.setStatus(`renamed to ${newName}`, "good");
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
beginResetName() {
|
|
675
|
+
const model = this.model;
|
|
676
|
+
if (!model)
|
|
677
|
+
return;
|
|
678
|
+
if (!this.renamedIds.has(model.id)) {
|
|
679
|
+
this.setStatus(`"${model.displayName}" has no custom name to reset`, "info");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
this.confirming = { kind: "reset-name", model };
|
|
683
|
+
this.setStatus(`reset "${model.displayName}" to its default name? y / n`, "warn");
|
|
684
|
+
this.draw();
|
|
685
|
+
}
|
|
686
|
+
/** POST to this TUI's own local router (see handleResetRequest), then
|
|
687
|
+
* rescan so the reverted name is reflected immediately. */
|
|
688
|
+
async submitResetName(model) {
|
|
689
|
+
await this.guard("reset name", async () => {
|
|
690
|
+
const url = `http://127.0.0.1:${this.listenPort}/__host/model/rename/reset?id=${encodeURIComponent(model.id)}`;
|
|
691
|
+
const res = await fetch(url, {
|
|
692
|
+
method: "POST",
|
|
693
|
+
headers: { "content-type": "application/json" },
|
|
694
|
+
});
|
|
695
|
+
if (!res.ok) {
|
|
696
|
+
let message = `HTTP ${res.status}`;
|
|
697
|
+
try {
|
|
698
|
+
const body = (await res.json());
|
|
699
|
+
if (body.error?.message)
|
|
700
|
+
message = body.error.message;
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
/* non-JSON error body */
|
|
704
|
+
}
|
|
705
|
+
throw new Error(message);
|
|
706
|
+
}
|
|
707
|
+
const body = (await res.json());
|
|
708
|
+
this.reload();
|
|
709
|
+
this.setStatus(`reset to ${body.displayName ?? "its default name"}`, "good");
|
|
710
|
+
});
|
|
711
|
+
}
|
|
503
712
|
move(delta) {
|
|
504
713
|
if (this.focus === "models") {
|
|
505
714
|
const list = this.visible;
|
|
@@ -635,16 +844,21 @@ export class App {
|
|
|
635
844
|
return;
|
|
636
845
|
if (key === "y") {
|
|
637
846
|
this.confirming = null;
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
847
|
+
if (confirming.kind === "delete") {
|
|
848
|
+
void this.guard("delete", async () => {
|
|
849
|
+
const plan = deleteModelFiles(confirming.model);
|
|
850
|
+
this.reload();
|
|
851
|
+
void this.refreshDisk();
|
|
852
|
+
this.setStatus(`deleted ${confirming.model.displayName} - freed ${vram.formatGiB(plan.bytes)}`, "good");
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
void this.submitResetName(confirming.model);
|
|
857
|
+
}
|
|
644
858
|
}
|
|
645
859
|
else if (key === "n" || key === "escape") {
|
|
646
860
|
this.confirming = null;
|
|
647
|
-
this.setStatus("delete cancelled", "info");
|
|
861
|
+
this.setStatus(confirming.kind === "delete" ? "delete cancelled" : "reset cancelled", "info");
|
|
648
862
|
this.draw();
|
|
649
863
|
}
|
|
650
864
|
}
|
|
@@ -1250,10 +1464,22 @@ export class App {
|
|
|
1250
1464
|
lines.push(...budget);
|
|
1251
1465
|
lines.push("");
|
|
1252
1466
|
lines.push(...status);
|
|
1467
|
+
if (this.renaming) {
|
|
1468
|
+
lines.push("");
|
|
1469
|
+
lines.push(this.renameLine(cols - 1));
|
|
1470
|
+
}
|
|
1253
1471
|
lines.push("");
|
|
1254
1472
|
lines.push(...footer);
|
|
1255
1473
|
this.renderFitted(lines);
|
|
1256
1474
|
}
|
|
1475
|
+
/** The rename input line shown above the footer while renaming is active:
|
|
1476
|
+
* the current name in grey, the buffer typed so far, and a cursor. */
|
|
1477
|
+
renameLine(cols) {
|
|
1478
|
+
const model = this.model;
|
|
1479
|
+
const name = model ? model.displayName : "";
|
|
1480
|
+
return truncate(`${style.brightYellow}rename${style.reset} ${style.grey}${name}${style.reset} → ` +
|
|
1481
|
+
`${this.renameBuffer}${style.brightCyan}▏${style.reset}`, cols);
|
|
1482
|
+
}
|
|
1257
1483
|
/**
|
|
1258
1484
|
* Benchmark workspace: the model list on the left, the selected model's
|
|
1259
1485
|
* scorecard where Configuration sits in serve mode, and the ranked leaderboard
|
|
@@ -1693,6 +1919,8 @@ export class App {
|
|
|
1693
1919
|
item("f", "find on Hugging Face - search and add a new model");
|
|
1694
1920
|
item("g", "get a quant - pick Q4/Q5/Q6… to download for this repo");
|
|
1695
1921
|
item("D", "delete the selected model (frees disk, asks to confirm)");
|
|
1922
|
+
item("R", "rename the selected model (Enter saves, Esc cancels)");
|
|
1923
|
+
item("u", "reset the selected model's name to its default (asks to confirm)");
|
|
1696
1924
|
section("Views");
|
|
1697
1925
|
item("b", "benchmark mode - rank models, run the coding suite");
|
|
1698
1926
|
item("l", "view the live llama-server log");
|
|
@@ -1719,10 +1947,20 @@ export class App {
|
|
|
1719
1947
|
*/
|
|
1720
1948
|
keybindings(cols) {
|
|
1721
1949
|
let groups;
|
|
1722
|
-
if (this.
|
|
1950
|
+
if (this.renaming) {
|
|
1951
|
+
groups = [
|
|
1952
|
+
[
|
|
1953
|
+
["type", "rename"],
|
|
1954
|
+
["enter", "save"],
|
|
1955
|
+
["esc", "cancel"],
|
|
1956
|
+
],
|
|
1957
|
+
];
|
|
1958
|
+
}
|
|
1959
|
+
else if (this.confirming) {
|
|
1960
|
+
const label = this.confirming.kind === "delete" ? "confirm delete" : "confirm reset";
|
|
1723
1961
|
groups = [
|
|
1724
1962
|
[
|
|
1725
|
-
["y",
|
|
1963
|
+
["y", label],
|
|
1726
1964
|
["n", "cancel"],
|
|
1727
1965
|
],
|
|
1728
1966
|
];
|
|
@@ -1793,6 +2031,8 @@ export class App {
|
|
|
1793
2031
|
["f", "find on HF"],
|
|
1794
2032
|
["g", "get quant"],
|
|
1795
2033
|
["D", "delete"],
|
|
2034
|
+
["R", "rename"],
|
|
2035
|
+
["u", "reset name"],
|
|
1796
2036
|
["b", "benchmarks"],
|
|
1797
2037
|
["l", "logs"],
|
|
1798
2038
|
["/", "filter"],
|
package/package.json
CHANGED