@hugpy/video-intelligence-ui 0.1.1 → 0.3.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/package.json +1 -3
- package/src/config.ts +36 -0
- package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
- package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
- package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
- package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
- package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
- package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
- package/src/demo/assets/generate2/s0.png +0 -0
- package/src/demo/assets/generate2/s1.png +0 -0
- package/src/demo/assets/generate2/s10.png +0 -0
- package/src/demo/assets/generate2/s11.png +0 -0
- package/src/demo/assets/generate2/s12.png +0 -0
- package/src/demo/assets/generate2/s13.png +0 -0
- package/src/demo/assets/generate2/s14.png +0 -0
- package/src/demo/assets/generate2/s15.png +0 -0
- package/src/demo/assets/generate2/s16.png +0 -0
- package/src/demo/assets/generate2/s17.png +0 -0
- package/src/demo/assets/generate2/s18.png +0 -0
- package/src/demo/assets/generate2/s19.png +0 -0
- package/src/demo/assets/generate2/s2.png +0 -0
- package/src/demo/assets/generate2/s20.png +0 -0
- package/src/demo/assets/generate2/s21.mp4 +0 -0
- package/src/demo/assets/generate2/s22.mp4 +0 -0
- package/src/demo/assets/generate2/s23.mp4 +0 -0
- package/src/demo/assets/generate2/s24.mp4 +0 -0
- package/src/demo/assets/generate2/s25.mp4 +0 -0
- package/src/demo/assets/generate2/s26.mp4 +0 -0
- package/src/demo/assets/generate2/s27.mp4 +0 -0
- package/src/demo/assets/generate2/s28.mp4 +0 -0
- package/src/demo/assets/generate2/s29.mp4 +0 -0
- package/src/demo/assets/generate2/s3.png +0 -0
- package/src/demo/assets/generate2/s30.mp4 +0 -0
- package/src/demo/assets/generate2/s4.png +0 -0
- package/src/demo/assets/generate2/s5.png +0 -0
- package/src/demo/assets/generate2/s6.png +0 -0
- package/src/demo/assets/generate2/s7.png +0 -0
- package/src/demo/assets/generate2/s8.png +0 -0
- package/src/demo/assets/generate2/s9.png +0 -0
- package/src/demo/demoFetch.ts +126 -3
- package/src/demo/fixtures.ts +72 -4
- package/src/demo/promptAssist.ts +25 -0
- package/src/demo/sampleData.ts +960 -0
- package/src/demo/sampleSets.ts +172 -0
- package/src/stations/ActiveProcessesStation.tsx +18 -3
- package/src/stations/GenerateStation.tsx +901 -366
- package/src/stations/GoalComposer.tsx +9 -1
- package/src/stations/ScriptFirstStation.tsx +1081 -0
- package/src/stations/ToksPanel.tsx +188 -0
- package/src/stations/WorkbenchDrawer.tsx +4 -3
- package/src/stations/WorkbenchSidebar.tsx +6 -3
- package/src/stations/oracle/DagPanel.tsx +391 -0
- package/src/stations/oracle/OracleStation.tsx +304 -0
- package/src/stations/oracle/SpatialTab.tsx +185 -0
- package/src/stations/oracle/StewardPanel.tsx +165 -0
- package/src/stations/oracle/oracle.css +467 -0
- package/src/stations/oracle/oracleShared.tsx +160 -0
- package/src/stations/oracle/oracleTypes.ts +304 -0
- package/src/stations/oracle/useOracle.ts +560 -0
- package/src/stations/registry.ts +37 -0
- package/src/stations/scriptFirst.css +350 -0
- package/src/stations/studio/IdentitiesStation.tsx +219 -292
- package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
- package/src/stations/studio/StudioGenerateTab.tsx +10 -17
- package/src/stations/studio/StudioMovieComposer.tsx +296 -59
- package/src/stations/studio/StudioPlane.tsx +35 -3
- package/src/stations/studio/StudioViewer.tsx +116 -14
- package/src/stations/studio/coordinationBadge.tsx +206 -0
- package/src/stations/studio/identityUpload.ts +66 -0
- package/src/stations/studio/promptCard.tsx +362 -59
- package/src/stations/studio/useIdentityCreateJob.ts +184 -0
- package/src/stations/studio/useIdentityProfiles.ts +66 -0
- package/src/stations/useScriptFirstRun.ts +596 -0
- package/src/style/app.css +157 -0
- package/src/video/LengthRow.tsx +189 -0
- package/src/video/coordinationReport.ts +266 -0
- package/src/video/jobClock.ts +104 -0
- package/src/video/spreadAssist.ts +26 -0
- package/src/video/useMediaJobs.ts +26 -0
- package/src/video/useOperatorHistory.ts +5 -0
- package/src/video/useToks.ts +193 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* k117 — the feed's clock, told honestly.
|
|
3
|
+
*
|
|
4
|
+
* THE BUG the operator reported: the Active-Processes feed showed jobs at 875+
|
|
5
|
+
* minutes in "loading", 25h in "awaiting_capacity", and DONE rows whose timers
|
|
6
|
+
* kept ticking under a stale stage label ("archiving"). Keeper triage found the
|
|
7
|
+
* server rows were CORRECT — the "done · 946m · archiving" row had really run
|
|
8
|
+
* 9.4 seconds. Two client-side lies produced that display:
|
|
9
|
+
*
|
|
10
|
+
* 1. The row rendered `elapsedSince(createdAt)` — i.e. `now - created`, a
|
|
11
|
+
* ticking AGE — as if it were runtime. For a terminal row that number keeps
|
|
12
|
+
* counting for ever; for a live row it conflates a 25h queue wait with a
|
|
13
|
+
* 9s render.
|
|
14
|
+
* 2. The row rendered `current_stage` — the last IN-FLIGHT stage — on terminal
|
|
15
|
+
* rows, so a finished render was labelled "archiving" permanently.
|
|
16
|
+
*
|
|
17
|
+
* The server (video_intel/job_lifecycle.py) now sends the facts this file needs:
|
|
18
|
+
* FROZEN `run_s`/`queue_wait_s`/`total_s`/`terminal_at` + the true
|
|
19
|
+
* `terminal_stage` on terminal rows, and `elapsed_in_stage_s` +
|
|
20
|
+
* `last_progress_at` (with queue wait kept separate) on live ones. Nothing here
|
|
21
|
+
* derives a duration from `Date.now()` — that is the entire point.
|
|
22
|
+
*
|
|
23
|
+
* A row from an older server (or the demo fixtures) carries none of these, so
|
|
24
|
+
* every helper degrades to the honest fallback: it says AGE, and labels it age,
|
|
25
|
+
* rather than passing an age off as a runtime.
|
|
26
|
+
*/
|
|
27
|
+
import type { MediaJob } from "./useMediaJobs";
|
|
28
|
+
|
|
29
|
+
/** Seconds as "9.4s" / "3m 4s" / "1h 02m" — compact, never lossy about scale. */
|
|
30
|
+
export function fmtDuration(secs: number | null): string {
|
|
31
|
+
if (secs == null || !isFinite(secs) || secs < 0) return "";
|
|
32
|
+
if (secs < 10) return `${secs.toFixed(1)}s`;
|
|
33
|
+
const s = Math.floor(secs);
|
|
34
|
+
if (s < 60) return `${s}s`;
|
|
35
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
36
|
+
const h = Math.floor(s / 3600);
|
|
37
|
+
return `${h}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The stage label for a row. A TERMINAL row says what it ended as ("done",
|
|
42
|
+
* "failed", "cancelled") — never the stage it happened to be in when it ended.
|
|
43
|
+
* That stage is still available (`atStage`) and the expanded timeline shows it;
|
|
44
|
+
* it is simply no longer allowed to masquerade as a current stage.
|
|
45
|
+
*/
|
|
46
|
+
export function jobStageLabel(job: MediaJob): string {
|
|
47
|
+
const raw = job.terminal ? job.terminalStage ?? job.status : job.stage;
|
|
48
|
+
return raw ? raw.replace(/_/g, " ") : "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The stage a TERMINAL job was in when it ended, for the secondary "(in
|
|
53
|
+
* archiving)" note — empty when it adds nothing (a job that ended in no stage,
|
|
54
|
+
* or whose last stage is the outcome itself).
|
|
55
|
+
*/
|
|
56
|
+
export function terminalAtStage(job: MediaJob): string {
|
|
57
|
+
if (!job.terminal) return "";
|
|
58
|
+
const at = job.atStage;
|
|
59
|
+
if (!at || at === job.terminalStage || at === job.status) return "";
|
|
60
|
+
return at.replace(/_/g, " ");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The clock chips for one row, in display order.
|
|
65
|
+
*
|
|
66
|
+
* TERMINAL: exactly one FROZEN duration — how long it actually ran — plus the
|
|
67
|
+
* queue wait when there was a meaningful one. Nothing ticks.
|
|
68
|
+
*
|
|
69
|
+
* LIVE: the two clocks kept APART, which is the whole fix: time in the CURRENT
|
|
70
|
+
* stage, and the queue wait, as separate facts. A 9s render that waited 25h
|
|
71
|
+
* reads as "queued 25h" + "in generating 9s", never as "875m".
|
|
72
|
+
*/
|
|
73
|
+
export function jobClockParts(job: MediaJob): string[] {
|
|
74
|
+
const out: string[] = [];
|
|
75
|
+
if (job.terminal) {
|
|
76
|
+
if (job.runS != null) {
|
|
77
|
+
out.push(`ran ${fmtDuration(job.runS)}`);
|
|
78
|
+
// Only worth showing when the wait was a real part of the story.
|
|
79
|
+
if (job.queueWaitS != null && job.queueWaitS >= 5)
|
|
80
|
+
out.push(`queued ${fmtDuration(job.queueWaitS)}`);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
// No recorded start (a pre-k117 row whose runner wrote no stage entries):
|
|
84
|
+
// the total span is known, the queue/run split is not. Say "took", which is
|
|
85
|
+
// true of the whole span, rather than inventing a runtime.
|
|
86
|
+
if (job.totalS != null) return [`took ${fmtDuration(job.totalS)}`];
|
|
87
|
+
// Older server / demo fixture: say AGE, and say that it is age.
|
|
88
|
+
const age = ageSeconds(job.createdAt);
|
|
89
|
+
return age == null ? [] : [`${fmtDuration(age)} ago`];
|
|
90
|
+
}
|
|
91
|
+
if (job.elapsedInStageS != null && job.stage)
|
|
92
|
+
out.push(`in ${job.stage.replace(/_/g, " ")} ${fmtDuration(job.elapsedInStageS)}`);
|
|
93
|
+
if (job.queueWaitS != null && job.queueWaitS >= 1)
|
|
94
|
+
out.push(`queued ${fmtDuration(job.queueWaitS)}`);
|
|
95
|
+
if (job.runS != null && job.runS >= 1) out.push(`running ${fmtDuration(job.runS)}`);
|
|
96
|
+
if (out.length) return out;
|
|
97
|
+
const age = ageSeconds(job.createdAt);
|
|
98
|
+
return age == null ? [] : [`${fmtDuration(age)} ago`];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ageSeconds(createdAt: number | null): number | null {
|
|
102
|
+
if (createdAt == null) return null;
|
|
103
|
+
return Math.max(0, Date.now() / 1000 - createdAt);
|
|
104
|
+
}
|
|
@@ -43,11 +43,25 @@ export interface IdentityContextWire {
|
|
|
43
43
|
do_not_invent?: string[];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* k93 §C — ONE reference media the assist is given as PRETEXT. The backend
|
|
48
|
+
* samples ≤ 4 frames of a video (one description for an image), describes them
|
|
49
|
+
* with a vision model and prepends a "Reference video: …" paragraph to the
|
|
50
|
+
* assist prompt. `uri` is the library uri; `mime` "video/mp4" | "image/*".
|
|
51
|
+
*/
|
|
52
|
+
export interface AssistMediaWire {
|
|
53
|
+
uri: string;
|
|
54
|
+
mime: string;
|
|
55
|
+
label?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
/** The typed `context` object shared by detail/generate/negative/spread. */
|
|
47
59
|
export interface AssistContextWire {
|
|
48
60
|
kind?: "image" | "scene" | "movie";
|
|
49
61
|
/** FREE-FORM user text only — structured row state rides the typed fields (§1c). */
|
|
50
62
|
hint?: string;
|
|
63
|
+
/** Optional reference media described server-side as pretext (k93 §C). */
|
|
64
|
+
media?: AssistMediaWire;
|
|
51
65
|
segment?: SegmentRefWire;
|
|
52
66
|
previous_segment?: SegmentRefWire;
|
|
53
67
|
next_segment?: SegmentRefWire;
|
|
@@ -75,6 +89,10 @@ export interface SpreadSegmentResult {
|
|
|
75
89
|
continuity_note: string;
|
|
76
90
|
directions_used: unknown[];
|
|
77
91
|
warnings: string[];
|
|
92
|
+
/** k121: the knob values the coordination review APPLIED to this row
|
|
93
|
+
(joint_mode / parent_segment_id / branch_frame / seed / frames /
|
|
94
|
+
reference_images). Mechanics only — never prose (invariant 9). */
|
|
95
|
+
knobs: Record<string, unknown>;
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
/** The parsed 200 body of a spread call. */
|
|
@@ -89,6 +107,9 @@ export interface SpreadResult {
|
|
|
89
107
|
model: string | null;
|
|
90
108
|
model_requested: string | null;
|
|
91
109
|
model_resolved: string | null;
|
|
110
|
+
/** k121: the words-vs-knobs review of the whole set. Read it with
|
|
111
|
+
`readCoordinationReport` (video/coordinationReport.ts). */
|
|
112
|
+
coordination: unknown;
|
|
92
113
|
}
|
|
93
114
|
|
|
94
115
|
// ── intent routing (§1d) ────────────────────────────────────────────────────
|
|
@@ -300,6 +321,10 @@ export function readSpreadResult(value: unknown): SpreadResult | null {
|
|
|
300
321
|
continuity_note: str(r.continuity_note).trim(),
|
|
301
322
|
directions_used: Array.isArray(r.directions_used) ? r.directions_used : [],
|
|
302
323
|
warnings: strArray(r.warnings),
|
|
324
|
+
knobs:
|
|
325
|
+
r.knobs != null && typeof r.knobs === "object" && !Array.isArray(r.knobs)
|
|
326
|
+
? (r.knobs as Record<string, unknown>)
|
|
327
|
+
: {},
|
|
303
328
|
});
|
|
304
329
|
}
|
|
305
330
|
const seed = v.steering_seed;
|
|
@@ -316,6 +341,7 @@ export function readSpreadResult(value: unknown): SpreadResult | null {
|
|
|
316
341
|
model: typeof v.model === "string" ? v.model : null,
|
|
317
342
|
model_requested: typeof v.model_requested === "string" ? v.model_requested : null,
|
|
318
343
|
model_resolved: typeof v.model_resolved === "string" ? v.model_resolved : null,
|
|
344
|
+
coordination: v.coordination ?? null,
|
|
319
345
|
};
|
|
320
346
|
}
|
|
321
347
|
|
|
@@ -68,6 +68,22 @@ export interface MediaJob {
|
|
|
68
68
|
failure: JobFailure | null;
|
|
69
69
|
terminal: boolean; // done | failed | cancelled (only present with the history filter)
|
|
70
70
|
progressedAt: number | null; // last REAL movement (the stall/stale basis)
|
|
71
|
+
// ---- k117 LIFECYCLE CLOCK (video_intel/job_lifecycle.py) ----------------
|
|
72
|
+
// The feed used to render `now - created` as if it were runtime, so a 9.4s
|
|
73
|
+
// render created 946 minutes ago read as "946m · archiving". These are the
|
|
74
|
+
// server's own facts, computed ONCE at the terminal transition and therefore
|
|
75
|
+
// FROZEN: nothing below is a function of the browser's clock. Null on a row
|
|
76
|
+
// from an older server (or a demo fixture) — see video/jobClock.ts for the
|
|
77
|
+
// honest fallback.
|
|
78
|
+
terminalAt: number | null; // when it ended (epoch seconds); null while live
|
|
79
|
+
terminalStage: string | null; // the TRUE outcome: done | failed | cancelled
|
|
80
|
+
atStage: string | null; // the live stage it was in when it ended ("archiving")
|
|
81
|
+
startedAt: number | null; // when the run actually began
|
|
82
|
+
queueWaitS: number | null; // time spent waiting, kept SEPARATE from runtime
|
|
83
|
+
runS: number | null; // frozen runtime when terminal; time-so-far while running
|
|
84
|
+
totalS: number | null; // queue + run, terminal only
|
|
85
|
+
elapsedInStageS: number | null; // time in the CURRENT stage (live rows only)
|
|
86
|
+
lastProgressAt: number | null; // last reported progress (epoch seconds)
|
|
71
87
|
}
|
|
72
88
|
|
|
73
89
|
function str(v: unknown): string | null {
|
|
@@ -170,6 +186,16 @@ function parseJobs(raw: unknown, includeTerminal: boolean): MediaJob[] {
|
|
|
170
186
|
failure: parseFailure(row.failure),
|
|
171
187
|
terminal,
|
|
172
188
|
progressedAt: num(row.progressed_at) ?? num(row.last_movement_ts),
|
|
189
|
+
// k117 — read verbatim; never derived here (deriving is what lied).
|
|
190
|
+
terminalAt: num(row.terminal_at),
|
|
191
|
+
terminalStage: str(row.terminal_stage),
|
|
192
|
+
atStage: str(row.at_stage),
|
|
193
|
+
startedAt: num(row.started_at),
|
|
194
|
+
queueWaitS: num(row.queue_wait_s),
|
|
195
|
+
runS: num(row.run_s),
|
|
196
|
+
totalS: num(row.total_s),
|
|
197
|
+
elapsedInStageS: num(row.elapsed_in_stage_s),
|
|
198
|
+
lastProgressAt: num(row.last_progress_at) ?? num(row.progressed_at),
|
|
173
199
|
});
|
|
174
200
|
}
|
|
175
201
|
return out;
|
|
@@ -90,6 +90,11 @@ export function useOperatorHistory(): void {
|
|
|
90
90
|
genKind: (j.name === "generate_studio_movie"
|
|
91
91
|
? "generate_movie"
|
|
92
92
|
: j.name) as LibraryGenKind,
|
|
93
|
+
// groupId = the producing job: each historical run groups as its own
|
|
94
|
+
// work (like live pushes) instead of one giant shared "history" pile
|
|
95
|
+
// whose identical key collided ACROSS the type sections (expanding
|
|
96
|
+
// Videos/history also expanded Images/history).
|
|
97
|
+
groupId: id,
|
|
93
98
|
});
|
|
94
99
|
}
|
|
95
100
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// LIVE tok/s telemetry for the generate workbench's Toks panel. Polls TWO
|
|
2
|
+
// same-origin `/api` endpoints together every 2s, MOUNT-GATED exactly like
|
|
3
|
+
// `useMediaJobs` (mount → immediate fetch + 2s interval; unmount → interval
|
|
4
|
+
// cleared and a mounted-ref flipped so a late response never setStates). A fetch
|
|
5
|
+
// error keeps the last-good data rather than flashing empty, and every field is
|
|
6
|
+
// guarded — the server side is being built in parallel, so missing/extra fields
|
|
7
|
+
// must never throw.
|
|
8
|
+
//
|
|
9
|
+
// • GET /api/llm/toks/recent?limit=100 → { entries:[{ ts, worker_id,
|
|
10
|
+
// worker_name, model_key, tok_s, ttft_s, completion_tokens, config_key, ok }] }
|
|
11
|
+
// newest-first — the live tail + the per-worker strip.
|
|
12
|
+
// • GET /api/llm/toks/report → { groups:[{ worker_name, model_key, config_key,
|
|
13
|
+
// n, mean_tok_s, p50_tok_s, p95_tok_s }] } sorted best mean first — the
|
|
14
|
+
// best-config leaderboard.
|
|
15
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
16
|
+
import { hugpyConfig } from "../config";
|
|
17
|
+
import { request, okValue } from "../transport/client";
|
|
18
|
+
|
|
19
|
+
const POLL_MS = 2000;
|
|
20
|
+
const RECENT_LIMIT = 100;
|
|
21
|
+
|
|
22
|
+
/** One recent inference sample (omit-when-unset tolerated on the wire). */
|
|
23
|
+
export interface TokEntry {
|
|
24
|
+
ts: number | null; // epoch seconds when the sample was recorded
|
|
25
|
+
workerId: string | null;
|
|
26
|
+
workerName: string | null;
|
|
27
|
+
modelKey: string | null;
|
|
28
|
+
tokS: number | null; // tokens/second
|
|
29
|
+
ttftS: number | null; // time-to-first-token, seconds
|
|
30
|
+
completionTokens: number | null;
|
|
31
|
+
configKey: string | null;
|
|
32
|
+
ok: boolean | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One best-config rollup row from /report. */
|
|
36
|
+
export interface TokGroup {
|
|
37
|
+
workerName: string | null;
|
|
38
|
+
modelKey: string | null;
|
|
39
|
+
configKey: string | null;
|
|
40
|
+
n: number | null;
|
|
41
|
+
meanTokS: number | null;
|
|
42
|
+
p50TokS: number | null;
|
|
43
|
+
p95TokS: number | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function str(v: unknown): string | null {
|
|
47
|
+
return typeof v === "string" && v.trim() ? v : null;
|
|
48
|
+
}
|
|
49
|
+
function num(v: unknown): number | null {
|
|
50
|
+
return typeof v === "number" && isFinite(v) ? v : null;
|
|
51
|
+
}
|
|
52
|
+
function bool(v: unknown): boolean | null {
|
|
53
|
+
return typeof v === "boolean" ? v : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseEntries(raw: unknown): TokEntry[] {
|
|
57
|
+
const entries = (raw as { entries?: unknown } | null)?.entries;
|
|
58
|
+
if (!Array.isArray(entries)) return [];
|
|
59
|
+
const out: TokEntry[] = [];
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
if (!e || typeof e !== "object") continue;
|
|
62
|
+
const r = e as Record<string, unknown>;
|
|
63
|
+
out.push({
|
|
64
|
+
ts: num(r.ts),
|
|
65
|
+
workerId: str(r.worker_id),
|
|
66
|
+
workerName: str(r.worker_name),
|
|
67
|
+
modelKey: str(r.model_key),
|
|
68
|
+
tokS: num(r.tok_s),
|
|
69
|
+
ttftS: num(r.ttft_s),
|
|
70
|
+
completionTokens: num(r.completion_tokens),
|
|
71
|
+
configKey: str(r.config_key),
|
|
72
|
+
ok: bool(r.ok),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseGroups(raw: unknown): TokGroup[] {
|
|
79
|
+
const groups = (raw as { groups?: unknown } | null)?.groups;
|
|
80
|
+
if (!Array.isArray(groups)) return [];
|
|
81
|
+
const out: TokGroup[] = [];
|
|
82
|
+
for (const g of groups) {
|
|
83
|
+
if (!g || typeof g !== "object") continue;
|
|
84
|
+
const r = g as Record<string, unknown>;
|
|
85
|
+
out.push({
|
|
86
|
+
workerName: str(r.worker_name),
|
|
87
|
+
modelKey: str(r.model_key),
|
|
88
|
+
configKey: str(r.config_key),
|
|
89
|
+
n: num(r.n),
|
|
90
|
+
meanTokS: num(r.mean_tok_s),
|
|
91
|
+
p50TokS: num(r.p50_tok_s),
|
|
92
|
+
p95TokS: num(r.p95_tok_s),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The live tok/s feed: recent samples (newest-first) + the best-config report,
|
|
100
|
+
* refreshed together every 2s while a consumer is mounted. `live` reports whether
|
|
101
|
+
* the last poll cycle succeeded, so the panel can show a live/paused dot.
|
|
102
|
+
*/
|
|
103
|
+
export function useToks(): {
|
|
104
|
+
entries: TokEntry[];
|
|
105
|
+
groups: TokGroup[];
|
|
106
|
+
live: boolean;
|
|
107
|
+
} {
|
|
108
|
+
const [entries, setEntries] = useState<TokEntry[]>([]);
|
|
109
|
+
const [groups, setGroups] = useState<TokGroup[]>([]);
|
|
110
|
+
const [live, setLive] = useState(false);
|
|
111
|
+
const mounted = useRef(true);
|
|
112
|
+
|
|
113
|
+
const load = useCallback(async () => {
|
|
114
|
+
// Both endpoints in parallel; each is independent, so one failing keeps the
|
|
115
|
+
// other's last-good data instead of blanking the whole panel.
|
|
116
|
+
const [recent, report] = await Promise.all([
|
|
117
|
+
request<unknown>(`${hugpyConfig.toksRecentUrl}?limit=${RECENT_LIMIT}`, {
|
|
118
|
+
meta: { specKey: "llm", operation: "llm.toks.recent" },
|
|
119
|
+
}),
|
|
120
|
+
request<unknown>(hugpyConfig.toksReportUrl, {
|
|
121
|
+
meta: { specKey: "llm", operation: "llm.toks.report" },
|
|
122
|
+
}),
|
|
123
|
+
]);
|
|
124
|
+
if (!mounted.current) return;
|
|
125
|
+
if (recent.ok) setEntries(parseEntries(okValue(recent)));
|
|
126
|
+
if (report.ok) setGroups(parseGroups(okValue(report)));
|
|
127
|
+
setLive(recent.ok || report.ok);
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
mounted.current = true;
|
|
132
|
+
void load();
|
|
133
|
+
const id = window.setInterval(() => void load(), POLL_MS);
|
|
134
|
+
return () => {
|
|
135
|
+
mounted.current = false;
|
|
136
|
+
window.clearInterval(id);
|
|
137
|
+
};
|
|
138
|
+
}, [load]);
|
|
139
|
+
|
|
140
|
+
return { entries, groups, live };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The poll cadence, exported so the panel header can label it ("every 2s"). */
|
|
144
|
+
export const TOKS_POLL_MS = POLL_MS;
|
|
145
|
+
|
|
146
|
+
/** "12s ago" / "3m ago" from an epoch-seconds ts, "" when unknown. */
|
|
147
|
+
export function tokAgo(ts: number | null, nowMs: number): string {
|
|
148
|
+
if (ts == null) return "";
|
|
149
|
+
const secs = Math.max(0, Math.floor(nowMs / 1000 - ts));
|
|
150
|
+
if (secs < 60) return `${secs}s ago`;
|
|
151
|
+
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
|
|
152
|
+
return `${Math.floor(secs / 3600)}h ago`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Group recent entries by worker → { last tok/s, avg tok/s over the window }. */
|
|
156
|
+
export interface WorkerRollup {
|
|
157
|
+
workerId: string;
|
|
158
|
+
workerName: string;
|
|
159
|
+
modelKey: string | null;
|
|
160
|
+
lastTokS: number | null;
|
|
161
|
+
avgTokS: number | null;
|
|
162
|
+
n: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function rollupByWorker(entries: TokEntry[]): WorkerRollup[] {
|
|
166
|
+
const byWorker = new Map<string, TokEntry[]>();
|
|
167
|
+
for (const e of entries) {
|
|
168
|
+
const key = e.workerId ?? e.workerName;
|
|
169
|
+
if (!key) continue;
|
|
170
|
+
const list = byWorker.get(key);
|
|
171
|
+
if (list) list.push(e);
|
|
172
|
+
else byWorker.set(key, [e]);
|
|
173
|
+
}
|
|
174
|
+
const out: WorkerRollup[] = [];
|
|
175
|
+
for (const [key, list] of byWorker) {
|
|
176
|
+
// Entries arrive newest-first, so the first tok_s we see is the latest.
|
|
177
|
+
const samples = list.map((e) => e.tokS).filter((v): v is number => v != null);
|
|
178
|
+
const avg =
|
|
179
|
+
samples.length > 0
|
|
180
|
+
? samples.reduce((a, b) => a + b, 0) / samples.length
|
|
181
|
+
: null;
|
|
182
|
+
const first = list[0];
|
|
183
|
+
out.push({
|
|
184
|
+
workerId: key,
|
|
185
|
+
workerName: first?.workerName ?? key,
|
|
186
|
+
modelKey: first?.modelKey ?? null,
|
|
187
|
+
lastTokS: samples.length > 0 ? samples[0] : null,
|
|
188
|
+
avgTokS: avg,
|
|
189
|
+
n: list.length,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|