@jmcombs/pi-steward 0.0.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/LICENSE +21 -0
- package/README.md +140 -0
- package/core/disconnected-source.ts +110 -0
- package/core/drift.ts +247 -0
- package/core/format.ts +317 -0
- package/core/host-metrics.ts +121 -0
- package/core/llama-config.ts +72 -0
- package/core/llama-connection.ts +215 -0
- package/core/llama-models.ts +261 -0
- package/core/llama-slots.ts +104 -0
- package/core/llama-source.ts +1523 -0
- package/core/log-parse.ts +440 -0
- package/core/model-color.ts +59 -0
- package/core/select.ts +2923 -0
- package/core/slot-activity.ts +658 -0
- package/core/source.ts +84 -0
- package/core/state.ts +609 -0
- package/core/status-widget.ts +222 -0
- package/core/temperature.ts +149 -0
- package/core/types.ts +431 -0
- package/index.ts +503 -0
- package/package.json +51 -0
- package/server/api.ts +216 -0
- package/server/assets.ts +198 -0
- package/server/config-wiring.ts +490 -0
- package/server/drift-probe.ts +150 -0
- package/server/host-collector.ts +272 -0
- package/server/index.ts +228 -0
- package/server/log-tailer.ts +432 -0
- package/server/service-control.ts +337 -0
- package/server/service-probe.ts +71 -0
- package/server/steward-config.ts +430 -0
- package/setup/init-prompt.ts +214 -0
- package/setup/steward-setup.d.mts +16 -0
- package/setup/steward-setup.mjs +1398 -0
- package/ui/components/console.ts +511 -0
- package/ui/components/gauges.ts +120 -0
- package/ui/components/metrics.ts +63 -0
- package/ui/components/models.ts +296 -0
- package/ui/components/service.ts +358 -0
- package/ui/components/slots.ts +114 -0
- package/ui/components/sparkline.ts +59 -0
- package/ui/components/toolbar.ts +211 -0
- package/ui/dom.ts +120 -0
- package/ui/favicon.svg +17 -0
- package/ui/index.html +34 -0
- package/ui/main.ts +678 -0
- package/ui/steward.css +2008 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The llama.cpp log-line parser.
|
|
3
|
+
*
|
|
4
|
+
* One raw line in, one structured record out. It is pure and Node-free on
|
|
5
|
+
* purpose: the file tailer (`server/log-tailer.ts`) owns the I/O, the sequence
|
|
6
|
+
* numbers and the port→model map, and this module owns nothing but the grammar.
|
|
7
|
+
*
|
|
8
|
+
* **The grammar is looser than it looks.** llama.cpp's log framework prepends at
|
|
9
|
+
* most three optional things, and *everything after the level letter is
|
|
10
|
+
* free-form message text*:
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* ^(?:\[ *(?<port>\d+) *\]\s+)? # router-added child prefix
|
|
14
|
+
* (?:(?<elapsed>\d+\.\d{2}\.\d{3}\.\d{3})\s+)? # per-process elapsed M+.SS.mmm.uuu
|
|
15
|
+
* (?:(?<level>[IWED])\s+)? # level letter
|
|
16
|
+
* (?<message>.*)$ # message, verbatim
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Three details of that shape are dictated by llama.cpp's `printf` formats, and
|
|
20
|
+
* each of them has bitten a parser written from a sample of one log:
|
|
21
|
+
*
|
|
22
|
+
* - **The `[port]` prefix is space-padded.** The router forwards child output as
|
|
23
|
+
* `LOG("[%5d] %s", port, buffer)`, so the port is right-aligned in a
|
|
24
|
+
* five-wide field: `[57409]` for an ephemeral port, but `[ 8080]` for a
|
|
25
|
+
* four-digit one. Requiring `\[\d+\]` silently demotes every such line to a
|
|
26
|
+
* router line and throws away its model attribution.
|
|
27
|
+
* - **The elapsed stamp's leading field is unbounded.** It is printed
|
|
28
|
+
* `"%d.%02d.%03d.%03d"` from a running total of *minutes* — only the last
|
|
29
|
+
* three fields are fixed-width. A process up for three hours stamps
|
|
30
|
+
* `180.05.123.456`, and a long-lived router reaches four digits.
|
|
31
|
+
* - **A forwarded child line carries no router-side framing at all.** `LOG` is
|
|
32
|
+
* `GGML_LOG_LEVEL_NONE`, and the whole prefix block — timestamp *and* level
|
|
33
|
+
* letter — is skipped for that level. What follows `[port] ` is the child's
|
|
34
|
+
* own line, complete with whatever prefix the child itself emitted, or none
|
|
35
|
+
* at all when the child used `LOG` too (the `cmd_child_to_router:state:` IPC
|
|
36
|
+
* records). Every field ahead of the message is therefore independently
|
|
37
|
+
* optional, which is why they are matched that way rather than as a unit.
|
|
38
|
+
*
|
|
39
|
+
* The `<component> <fn>:` shape (`srv proxy_reques:`, `slot print_timing:`) is a
|
|
40
|
+
* convention of *some* call sites, NOT log structure, and requiring it is wrong:
|
|
41
|
+
* library-level lines carry no component at all (`E gguf_init_from_file: …`,
|
|
42
|
+
* `W load: …`), and every line of a fatal model-load failure — the highest-value
|
|
43
|
+
* lines in the whole stream — is in that class. So the parser never requires it.
|
|
44
|
+
*
|
|
45
|
+
* Two further rules follow from what real logs contain:
|
|
46
|
+
*
|
|
47
|
+
* - **No level letter means INFO.** 98.95% of a real corpus is `I`, the only
|
|
48
|
+
* level-less lines are the router↔child IPC records, and an operator running
|
|
49
|
+
* `--no-log-prefix` loses the letter entirely. Guessing anything else would
|
|
50
|
+
* manufacture severity.
|
|
51
|
+
* - **The elapsed stamp is never turned into a time.** It is per-process and
|
|
52
|
+
* resets for every child, so it is not sortable across processes (a router
|
|
53
|
+
* line stamped `1408.02.766` really does precede a child line stamped
|
|
54
|
+
* `1408.01.683` for the same request). It stays inside the message, where it
|
|
55
|
+
* is what it is, and ordering comes from the tailer's `seq`.
|
|
56
|
+
*
|
|
57
|
+
* Nothing here throws. A line that matches nothing degrades to an INFO event
|
|
58
|
+
* carrying the raw text, because a log console that drops what it cannot parse
|
|
59
|
+
* is worse than one that shows it.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import type { LogFamily, LogFrame, LogKind, LogLevel, LogOrigin } from "./types.js";
|
|
63
|
+
|
|
64
|
+
/** One parsed line, before the tailer stamps it with a `seq` and a `ts`. */
|
|
65
|
+
export interface ParsedLogLine {
|
|
66
|
+
level: LogLevel;
|
|
67
|
+
/** `child` when the router prefixed the line with `[port]`, else `router`. */
|
|
68
|
+
origin: LogOrigin;
|
|
69
|
+
/** The `[port]` prefix's port for a child line, else `null`. */
|
|
70
|
+
port: number | null;
|
|
71
|
+
/**
|
|
72
|
+
* The model this line names *in its own text* (`name=X`, `proxying request to
|
|
73
|
+
* model X`), or `null`. Router-wide lines — the boot banner, the preset
|
|
74
|
+
* catalogue, the launch-args block — name nobody, and that is not a gap to
|
|
75
|
+
* fill in: 26% of a filtered real log is genuinely about no single model.
|
|
76
|
+
*/
|
|
77
|
+
modelName: string | null;
|
|
78
|
+
/**
|
|
79
|
+
* The port {@link modelName} was named *with* (`… name=X on port P`), or
|
|
80
|
+
* `null`. The tailer folds this into its port→model map so a child that spawns
|
|
81
|
+
* while Steward is watching is attributed before the next `/models` poll.
|
|
82
|
+
*/
|
|
83
|
+
namedPort: number | null;
|
|
84
|
+
kind: LogKind;
|
|
85
|
+
/**
|
|
86
|
+
* The `SLT_*` macro's pipe frame, or `null` when the line did not carry one.
|
|
87
|
+
* A nullable enrichment like every other: no match means the line is returned
|
|
88
|
+
* whole and the console renders it exactly as it did before this existed.
|
|
89
|
+
*/
|
|
90
|
+
frame: LogFrame | null;
|
|
91
|
+
/** Which console chip the line answers to. Never `null` — see {@link classifyFamily}. */
|
|
92
|
+
family: LogFamily;
|
|
93
|
+
/** `truncated = 1` on a release line. `false` means the line did not say so. */
|
|
94
|
+
contextLost: boolean;
|
|
95
|
+
/** `sim_best` as a 0–1 fraction, or `null` where the line reported none. */
|
|
96
|
+
cacheHit: number | null;
|
|
97
|
+
/**
|
|
98
|
+
* Everything after {@link frame} (or after the level letter when unframed),
|
|
99
|
+
* verbatim — what the console renders. `frame.raw + message` re-forms the
|
|
100
|
+
* line the file wrote, byte for byte.
|
|
101
|
+
*/
|
|
102
|
+
message: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `[port]`, elapsed and level are each optional; the message is whatever is
|
|
107
|
+
* left. Deliberately no component/function group — see the module comment.
|
|
108
|
+
*
|
|
109
|
+
* The padding inside the brackets is tolerated on both sides so a change of
|
|
110
|
+
* field width or alignment upstream cannot cost us attribution; only digits and
|
|
111
|
+
* spaces are accepted there, so message text such as `[warn] …` is still text.
|
|
112
|
+
* The minutes field is `\d+` because it counts minutes without wrapping.
|
|
113
|
+
*/
|
|
114
|
+
const LINE = /^(?:\[ *(\d+) *\]\s+)?(?:(\d+\.\d{2}\.\d{3}\.\d{3})\s+)?(?:([IWED])\s+)?([\s\S]*)$/;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ANSI SGR sequences. A file sink never contains them (verified: 0 of 15,842
|
|
118
|
+
* lines), but `--log-colors on`, or a source that hands us a TTY-captured
|
|
119
|
+
* stream, would put them in front of the level letter and break every match
|
|
120
|
+
* below. Built from a string so the escape byte never appears in a regex
|
|
121
|
+
* literal.
|
|
122
|
+
*/
|
|
123
|
+
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
|
|
124
|
+
|
|
125
|
+
/** Steward's own polling causes most of these — see {@link LogKind}. */
|
|
126
|
+
const PROXY = /(?:^|\s)proxy_reques\b|proxying request to model\b/;
|
|
127
|
+
|
|
128
|
+
/** The header that opens a launch-args run. It is a normal line, not an `args` one. */
|
|
129
|
+
const ARGS_HEADER = /spawning server instance with args:/;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A continuation *inside* an args run: `srv load: --ctx-size`. Two or
|
|
133
|
+
* more spaces after `load:` is what separates it from the header (one space) and
|
|
134
|
+
* from `load: spawning …`.
|
|
135
|
+
*/
|
|
136
|
+
const ARGS_CONTINUATION = /(?:^|\s)load:\s{2,}\S/;
|
|
137
|
+
|
|
138
|
+
/** The same continuation with `--no-log-prefix`: the bare, indented value. */
|
|
139
|
+
const ARGS_BARE = /^\s{2,}\S/;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The in-flight generation line: `… | n_decoded = 764, tg = 84.60 t/s, tg_3s = …`.
|
|
143
|
+
* Both halves are required so a future completion summary that merely counts
|
|
144
|
+
* decoded tokens is not mistaken for the ~3 s live-rate readout.
|
|
145
|
+
*/
|
|
146
|
+
const RATE_DECODED = /\bn_decoded\s*=/;
|
|
147
|
+
const RATE_TG = /\btg(?:_3s)?\s*=|\bt\/s\b/;
|
|
148
|
+
|
|
149
|
+
/** `spawning … name=X on port P`, `stopping model instance name=X`, `… name=X exited …`. */
|
|
150
|
+
const NAMED = /\bname=(\S+)/;
|
|
151
|
+
|
|
152
|
+
/** `proxy_reques: proxying request to model X on port P`. */
|
|
153
|
+
const PROXIED = /proxying request to model (\S+)(?: on port (\d+))?/;
|
|
154
|
+
|
|
155
|
+
/** The port a `name=`-bearing line associates the model with, when it states one. */
|
|
156
|
+
const NAMED_PORT = /\bon port (\d+)/;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `slot print_timing: id 0 | task 81259 | ` — the `SLT_*` macro's frame,
|
|
160
|
+
* applied to the text after the level letter.
|
|
161
|
+
*
|
|
162
|
+
* Group 1 is the WHOLE prefix through the second pipe, head included, because
|
|
163
|
+
* that is the only split under which `frame.raw + message` re-forms the line
|
|
164
|
+
* byte for byte — and byte-exact export is what makes relocating the frame a
|
|
165
|
+
* relocation rather than a rewrite. The `[\s\S]*` tail is what keeps this a
|
|
166
|
+
* nullable enrichment: a line that does not match comes back whole and
|
|
167
|
+
* untouched, with the frame still in its message where it always was.
|
|
168
|
+
*/
|
|
169
|
+
const FRAME = /^(.*?\bid\s+(-?\d+)\s\|\stask\s(-?\d+)\s\|\s)([\s\S]*)$/;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `truncated = 1` on a `release` line: a context shift discarded the front of
|
|
173
|
+
* this request's conversation before the reply was written. Only ever read as a
|
|
174
|
+
* positive — `truncated = 0` is 217/217 of a measured corpus and a badge on
|
|
175
|
+
* every one of them would train the eye to skip the pixel where the real thing
|
|
176
|
+
* appears.
|
|
177
|
+
*/
|
|
178
|
+
const TRUNCATED = /\btruncated\s*=\s*1\b/;
|
|
179
|
+
|
|
180
|
+
/** `sim_best = 0.473` — the fraction of the prompt already in the KV cache. */
|
|
181
|
+
const SIM_BEST = /\bsim_best\s*=\s*([01]?\.\d+|\d+)/;
|
|
182
|
+
|
|
183
|
+
/** The args block's header, which carries no `name=` of its own. */
|
|
184
|
+
const SPAWNING = /spawning server instance/;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The router's own boot vocabulary — the ONE place the family classifier reads
|
|
188
|
+
* prose, and the reason it is a list of literal substrings rather than a
|
|
189
|
+
* function name: `operator()` is 41% of llama.cpp's log call sites and covers
|
|
190
|
+
* four unrelated concerns, so any rule keyed on a function name is broken
|
|
191
|
+
* before it ships. A phrase that churns costs one row moving to `other`, which
|
|
192
|
+
* is visible, countable and never a dropped line.
|
|
193
|
+
*/
|
|
194
|
+
/**
|
|
195
|
+
* A line INDENTED under a header — the shape of a continuation, whatever it
|
|
196
|
+
* continues. Two or more spaces after the `<component> <fn>:` head is what
|
|
197
|
+
* separates it from an ordinary line, and it reads the component literal (a
|
|
198
|
+
* macro constant, byte-stable for 18 months) and the indentation, never the
|
|
199
|
+
* function name.
|
|
200
|
+
*
|
|
201
|
+
* The router's preset catalogue is emitted this way: one `Available models (N)`
|
|
202
|
+
* header followed by a line per preset, each carrying nothing but a model id.
|
|
203
|
+
* There is no literal in those lines to key on — so, exactly like the launch
|
|
204
|
+
* args, membership is positional.
|
|
205
|
+
*/
|
|
206
|
+
const INDENTED_CONTINUATION = /^srv\s+\S+:\s{2,}\S/;
|
|
207
|
+
|
|
208
|
+
/** The header the preset catalogue hangs off. */
|
|
209
|
+
const CATALOGUE_HEADER = /Available models \(/;
|
|
210
|
+
|
|
211
|
+
const STARTUP_PHRASES: readonly string[] = [
|
|
212
|
+
"starting server in router mode",
|
|
213
|
+
"listening on",
|
|
214
|
+
"router mode is experimental",
|
|
215
|
+
"untrusted environments",
|
|
216
|
+
"model presets",
|
|
217
|
+
"Available models (",
|
|
218
|
+
"common_params_print_info",
|
|
219
|
+
"chat template supports",
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
function toLevel(letter: string | undefined): LogLevel {
|
|
223
|
+
switch (letter) {
|
|
224
|
+
case "W":
|
|
225
|
+
return "WARN";
|
|
226
|
+
case "E":
|
|
227
|
+
return "ERROR";
|
|
228
|
+
case "D":
|
|
229
|
+
return "DEBUG";
|
|
230
|
+
default:
|
|
231
|
+
// No letter is INFO, not "unknown": that is what llama.cpp means by it.
|
|
232
|
+
return "INFO";
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A real TCP port from a captured group, or `null`. */
|
|
237
|
+
function toPort(raw: string | undefined): number | null {
|
|
238
|
+
if (raw === undefined) return null;
|
|
239
|
+
const port = Number.parseInt(raw, 10);
|
|
240
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Which model a line names, and the port it names it with. Router lines that
|
|
245
|
+
* name a model are attributable even though they are router-emitted — a spawn,
|
|
246
|
+
* an unload, an exit and a proxied request all say who they are about, and the
|
|
247
|
+
* console has a model column to fill.
|
|
248
|
+
*/
|
|
249
|
+
function readNamed(message: string): { modelName: string | null; namedPort: number | null } {
|
|
250
|
+
const proxied = PROXIED.exec(message);
|
|
251
|
+
if (proxied !== null) {
|
|
252
|
+
return { modelName: proxied[1] ?? null, namedPort: toPort(proxied[2]) };
|
|
253
|
+
}
|
|
254
|
+
const named = NAMED.exec(message);
|
|
255
|
+
if (named !== null) {
|
|
256
|
+
return { modelName: named[1] ?? null, namedPort: toPort(NAMED_PORT.exec(message)?.[1]) };
|
|
257
|
+
}
|
|
258
|
+
return { modelName: null, namedPort: null };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Splits the pipe frame off the front of a message, or leaves it alone.
|
|
263
|
+
*
|
|
264
|
+
* The split point is the second pipe, so what comes back satisfies
|
|
265
|
+
* `frame.raw + body === message` for every input, matched or not. A frame whose
|
|
266
|
+
* numbers do not parse is treated as no frame at all: an enrichment that cannot
|
|
267
|
+
* be trusted is better absent than wrong, and the row renders exactly as it did
|
|
268
|
+
* before.
|
|
269
|
+
*/
|
|
270
|
+
function readFrame(message: string): { frame: LogFrame | null; body: string } {
|
|
271
|
+
const match = FRAME.exec(message);
|
|
272
|
+
if (match === null) return { frame: null, body: message };
|
|
273
|
+
const slot = Number.parseInt(match[2] ?? "", 10);
|
|
274
|
+
const task = Number.parseInt(match[3] ?? "", 10);
|
|
275
|
+
if (!Number.isInteger(slot) || !Number.isInteger(task)) return { frame: null, body: message };
|
|
276
|
+
return { frame: { slot, task, raw: match[1] ?? "" }, body: match[4] ?? "" };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** `sim_best` as a 0–1 fraction, or `null` when the line reported none. */
|
|
280
|
+
function readCacheHit(message: string): number | null {
|
|
281
|
+
const match = SIM_BEST.exec(message);
|
|
282
|
+
if (match === null) return null;
|
|
283
|
+
const value = Number.parseFloat(match[1] ?? "");
|
|
284
|
+
return Number.isFinite(value) ? value : null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** What {@link classifyFamily} needs. A subset of a parsed line, so a simulated
|
|
288
|
+
* source can classify its own drafts through the same rules instead of a copy. */
|
|
289
|
+
export interface FamilyInput {
|
|
290
|
+
frame: LogFrame | null;
|
|
291
|
+
kind: LogKind;
|
|
292
|
+
origin: LogOrigin;
|
|
293
|
+
/** The post-frame message — the same text {@link ParsedLogLine.message} carries. */
|
|
294
|
+
message: string;
|
|
295
|
+
/**
|
|
296
|
+
* The line immediately before this one, for the one positional rule: the
|
|
297
|
+
* preset catalogue's members carry no literal of their own and belong to
|
|
298
|
+
* their header. Omit it and they fall to `other`, which is visible and
|
|
299
|
+
* countable — the rule degrades the same way every other one does.
|
|
300
|
+
*/
|
|
301
|
+
previous?: { family: LogFamily; message: string } | null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Whether this line continues a preset-catalogue run. Membership is positional,
|
|
306
|
+
* exactly as it is for the launch-args block and for the same reason: the run
|
|
307
|
+
* is N self-contained lines with no continuation marker of their own, and a
|
|
308
|
+
* line that does not match ends it.
|
|
309
|
+
*/
|
|
310
|
+
function continuesCatalogue(
|
|
311
|
+
previous: { family: LogFamily; message: string } | null | undefined,
|
|
312
|
+
message: string,
|
|
313
|
+
): boolean {
|
|
314
|
+
if (previous === null || previous === undefined) return false;
|
|
315
|
+
const inRun =
|
|
316
|
+
previous.family === "startup" &&
|
|
317
|
+
(CATALOGUE_HEADER.test(previous.message) || INDENTED_CONTINUATION.test(previous.message));
|
|
318
|
+
return inRun && INDENTED_CONTINUATION.test(message);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Which console chip a line answers to. First match wins.
|
|
323
|
+
*
|
|
324
|
+
* **It reads zero function names**, and that is the whole point: llama.cpp's
|
|
325
|
+
* `__func__` values are truncated to 12 characters, collide (`print_timing` is
|
|
326
|
+
* three different functions), mean two unrelated things depending on component
|
|
327
|
+
* (`load`), and are `operator()` for 41% of call sites. The rules key on the
|
|
328
|
+
* 18-month-stable pipe frame, on classifications the parser already made, on
|
|
329
|
+
* the `[port]` prefix, and — once, in rule 6 — on prose.
|
|
330
|
+
*
|
|
331
|
+
* Only rule 6 can rot, and when it does a row moves to `other`. Nothing is ever
|
|
332
|
+
* dropped, and `other`'s count is the alarm.
|
|
333
|
+
*/
|
|
334
|
+
export function classifyFamily(input: FamilyInput): LogFamily {
|
|
335
|
+
// 1. Pipe-framed: the line is a slot doing work on a request.
|
|
336
|
+
if (input.frame !== null) return "requests";
|
|
337
|
+
// 2. A proxied request IS a request, even though the toggle owns showing it.
|
|
338
|
+
if (input.kind === "proxy") return "requests";
|
|
339
|
+
// 3. The launch-args run is part of the model coming up.
|
|
340
|
+
if (input.kind === "args") return "models";
|
|
341
|
+
// 4. Anything that names an instance — spawn, unload, LRU eviction, exit. The
|
|
342
|
+
// args HEADER carries no `name=` and is caught by name, or it would land in
|
|
343
|
+
// `other`, orphaned from the fold it introduces.
|
|
344
|
+
if (NAMED.test(input.message) || SPAWNING.test(input.message)) return "models";
|
|
345
|
+
// 5. A child process wrote it, so it is that model's own boot/vocab/KV output.
|
|
346
|
+
// This is what catches the component-less vocab warnings without ever
|
|
347
|
+
// naming `llama_vocab::impl::load`.
|
|
348
|
+
if (input.origin === "child") return "models";
|
|
349
|
+
// 6. The one prose-dependent rule, plus the catalogue members that hang off
|
|
350
|
+
// one of its phrases positionally.
|
|
351
|
+
if (STARTUP_PHRASES.some((phrase) => input.message.includes(phrase))) return "startup";
|
|
352
|
+
if (continuesCatalogue(input.previous, input.message)) return "startup";
|
|
353
|
+
// 7. Everything else stays visible, and countable.
|
|
354
|
+
return "other";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Whether this line continues the previous one's launch-args run. Membership is
|
|
359
|
+
* positional — a contiguous run under the `with args:` header — because that is
|
|
360
|
+
* how the block is actually emitted: N independent, self-contained lines with no
|
|
361
|
+
* continuation marker of their own. A line that does not match ends the run.
|
|
362
|
+
*/
|
|
363
|
+
function continuesArgs(previous: ParsedLogLine | null, message: string): boolean {
|
|
364
|
+
if (previous === null) return false;
|
|
365
|
+
const inRun = previous.kind === "args" || ARGS_HEADER.test(previous.message);
|
|
366
|
+
if (!inRun) return false;
|
|
367
|
+
return ARGS_CONTINUATION.test(message) || ARGS_BARE.test(message);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Classifies a line. Order matters only in that `proxy` and `rate` are decided
|
|
372
|
+
* by shape and `args` by position, and no line is ever two of them in practice.
|
|
373
|
+
*/
|
|
374
|
+
function classify(message: string, origin: LogOrigin, previous: ParsedLogLine | null): LogKind {
|
|
375
|
+
if (PROXY.test(message)) return "proxy";
|
|
376
|
+
if (RATE_DECODED.test(message) && RATE_TG.test(message)) return "rate";
|
|
377
|
+
// The args block is router-emitted; a child line that happened to look like a
|
|
378
|
+
// continuation is not part of it.
|
|
379
|
+
if (origin === "router" && !ARGS_HEADER.test(message) && continuesArgs(previous, message)) {
|
|
380
|
+
return "args";
|
|
381
|
+
}
|
|
382
|
+
return "event";
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Parses one raw line.
|
|
387
|
+
*
|
|
388
|
+
* `previous` is the line immediately before it in the file, and is used for one
|
|
389
|
+
* thing only: deciding whether this line continues a launch-args run. Pass
|
|
390
|
+
* `null` at the start of a stream, or after a truncation or a reopen — the worst
|
|
391
|
+
* a wrong answer costs is one fold boundary.
|
|
392
|
+
*
|
|
393
|
+
* Never throws. A line that is empty, malformed, or from a future llama.cpp
|
|
394
|
+
* still comes back as an INFO event whose message is the text as read.
|
|
395
|
+
*/
|
|
396
|
+
export function parseLogLine(raw: string, previous: ParsedLogLine | null = null): ParsedLogLine {
|
|
397
|
+
const clean = raw.replace(ANSI, "").replace(/\r$/, "");
|
|
398
|
+
const match = LINE.exec(clean);
|
|
399
|
+
// `LINE` cannot fail (every group is optional and the tail is `[^]*`), but a
|
|
400
|
+
// parser that would throw on a line is not one to trust a live tail to.
|
|
401
|
+
if (match === null) {
|
|
402
|
+
return {
|
|
403
|
+
level: "INFO",
|
|
404
|
+
origin: "router",
|
|
405
|
+
port: null,
|
|
406
|
+
modelName: null,
|
|
407
|
+
namedPort: null,
|
|
408
|
+
kind: "event",
|
|
409
|
+
frame: null,
|
|
410
|
+
family: "other",
|
|
411
|
+
contextLost: false,
|
|
412
|
+
cacheHit: null,
|
|
413
|
+
message: clean,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const port = toPort(match[1]);
|
|
418
|
+
const level = toLevel(match[3]);
|
|
419
|
+
const origin: LogOrigin = port === null ? "router" : "child";
|
|
420
|
+
// The frame comes off first, so every enrichment below reads the same text
|
|
421
|
+
// the console will paint. Framed lines carry no `name=` and are never proxy
|
|
422
|
+
// or args records, so nothing the older rules depended on moved.
|
|
423
|
+
const { frame, body } = readFrame(match[4] ?? "");
|
|
424
|
+
const { modelName, namedPort } = readNamed(body);
|
|
425
|
+
const kind = classify(body, origin, previous);
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
level,
|
|
429
|
+
origin,
|
|
430
|
+
port,
|
|
431
|
+
modelName,
|
|
432
|
+
namedPort,
|
|
433
|
+
kind,
|
|
434
|
+
frame,
|
|
435
|
+
family: classifyFamily({ frame, kind, origin, message: body, previous }),
|
|
436
|
+
contextLost: TRUNCATED.test(body),
|
|
437
|
+
cacheHit: readCacheHit(body),
|
|
438
|
+
message: body,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A model's card color.
|
|
3
|
+
*
|
|
4
|
+
* The color is a deterministic function of the model id — a stable hash into a
|
|
5
|
+
* fixed palette — so the same model is always the same color, across reloads and
|
|
6
|
+
* regardless of whether it happens to be loaded right now. There is one
|
|
7
|
+
* override: embedding models always take a reserved hue, so they read as a class
|
|
8
|
+
* rather than as "whatever the hash landed on". The color says nothing about
|
|
9
|
+
* what the model is used for; that was a fiction the old role field encoded.
|
|
10
|
+
*
|
|
11
|
+
* Keep this module free of Node and DOM APIs — see `./types.ts`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The categorical palette, drawn from the design system's tokens. Blue is held
|
|
16
|
+
* back for embedding models (see {@link EMBED_TOKEN}) so it never collides with
|
|
17
|
+
* a hashed assignment.
|
|
18
|
+
*/
|
|
19
|
+
const PALETTE = [
|
|
20
|
+
"--latte-mauve",
|
|
21
|
+
"--latte-teal",
|
|
22
|
+
"--latte-peach",
|
|
23
|
+
"--latte-sapphire",
|
|
24
|
+
"--latte-pink",
|
|
25
|
+
"--latte-lavender",
|
|
26
|
+
"--latte-yellow",
|
|
27
|
+
"--latte-maroon",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
/** The hue reserved for embedding models. */
|
|
31
|
+
const EMBED_TOKEN = "--latte-blue";
|
|
32
|
+
|
|
33
|
+
/** A non-empty fallback so palette indexing never yields `undefined`. */
|
|
34
|
+
const FALLBACK_TOKEN = PALETTE[0];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A stable 32-bit hash of a string (FNV-1a). Deterministic and dependency-free,
|
|
38
|
+
* so a given id maps to the same palette slot on every run — the property the
|
|
39
|
+
* whole color scheme rests on. `Math.imul` keeps the multiply in 32-bit range.
|
|
40
|
+
*/
|
|
41
|
+
function hash32(value: string): number {
|
|
42
|
+
let hash = 0x811c_9dc5;
|
|
43
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
44
|
+
hash ^= value.charCodeAt(i);
|
|
45
|
+
hash = Math.imul(hash, 0x0100_0193);
|
|
46
|
+
}
|
|
47
|
+
return hash >>> 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The CSS custom-property reference for a model's color, e.g.
|
|
52
|
+
* `var(--latte-teal)`. Embedding models always get the reserved hue; every
|
|
53
|
+
* other model is hashed into the palette by its id.
|
|
54
|
+
*/
|
|
55
|
+
export function modelColor(id: string, embedding: boolean): string {
|
|
56
|
+
if (embedding) return `var(${EMBED_TOKEN})`;
|
|
57
|
+
const token = PALETTE[hash32(id) % PALETTE.length] ?? FALLBACK_TOKEN;
|
|
58
|
+
return `var(${token})`;
|
|
59
|
+
}
|