@runtypelabs/persona 3.22.0 → 3.23.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/dist/animations/glyph-cycle.cjs +2 -262
- package/dist/animations/glyph-cycle.js +2 -235
- package/dist/animations/wipe.cjs +2 -72
- package/dist/animations/wipe.js +2 -45
- package/dist/index.cjs +50 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.global.js +83 -83
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +49 -49
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.cjs +22 -1874
- package/dist/smart-dom-reader.js +22 -1847
- package/dist/testing.cjs +3 -84
- package/dist/testing.js +3 -55
- package/dist/theme-editor.cjs +54 -24695
- package/dist/theme-editor.js +54 -24682
- package/package.json +9 -6
- package/src/components/event-stream-view.ts +122 -1
- package/src/ui.ts +24 -3
- package/src/utils/throughput-tracker.test.ts +366 -0
- package/src/utils/throughput-tracker.ts +427 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Output Throughput Tracker
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Derives an output tokens-per-second metric from the widget's existing SSE
|
|
6
|
+
// event stream, for display in the Events diagnostics screen. This is a passive
|
|
7
|
+
// consumer: it never mutates dispatch payloads, never forces debug mode, and
|
|
8
|
+
// never changes the wire contract — it only inspects the `(type, payload)`
|
|
9
|
+
// events that already flow through the SSE tap.
|
|
10
|
+
//
|
|
11
|
+
// Throughput is estimated live from visible text deltas while a run streams,
|
|
12
|
+
// then prefers exact provider usage (output tokens) when terminal events carry
|
|
13
|
+
// it. A run starts when the stream starts (or lazily on the first visible
|
|
14
|
+
// delta), stays "running" across intermediate step/turn completions, and only
|
|
15
|
+
// finalizes on terminal `flow_complete` / `agent_complete`. Stream errors mark
|
|
16
|
+
// the metric unavailable rather than leaving it stuck "running".
|
|
17
|
+
|
|
18
|
+
export type ThroughputMetricStatus = "idle" | "running" | "complete" | "error";
|
|
19
|
+
|
|
20
|
+
export type ThroughputMetricSource = "usage" | "estimate";
|
|
21
|
+
|
|
22
|
+
export interface ThroughputMetric {
|
|
23
|
+
status: ThroughputMetricStatus;
|
|
24
|
+
/** Output tokens per second, when computable. */
|
|
25
|
+
tokensPerSecond?: number;
|
|
26
|
+
/** Output tokens counted/estimated for the run. */
|
|
27
|
+
outputTokens?: number;
|
|
28
|
+
/** Duration window the rate was computed over (ms). */
|
|
29
|
+
durationMs?: number;
|
|
30
|
+
/** Whether `outputTokens` came from provider usage or a text estimate. */
|
|
31
|
+
source?: ThroughputMetricSource;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ThroughputRunStats {
|
|
35
|
+
startedAt: number;
|
|
36
|
+
firstDeltaAt?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Running character count of accumulated visible text, estimated via the
|
|
39
|
+
* ~4 chars/token heuristic. Tracked as a counter (not the concatenated
|
|
40
|
+
* string) so the estimate stays O(1) per delta over a long stream.
|
|
41
|
+
*/
|
|
42
|
+
visibleCharCount: number;
|
|
43
|
+
exactOutputTokens: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Below this streamed window we don't trust the rate; fall back to provider
|
|
47
|
+
// execution time or whole-request duration instead.
|
|
48
|
+
const THROUGHPUT_MIN_DURATION_MS = 250;
|
|
49
|
+
|
|
50
|
+
// Request-level lifecycle events: each marks the beginning of a NEW request.
|
|
51
|
+
// The SSE tap fires for every payload type regardless of whether the client has
|
|
52
|
+
// a handler for it, so any of these that the server emits starts the run with
|
|
53
|
+
// an accurate `startedAt` (capturing time-to-first-token). These RESET any run
|
|
54
|
+
// already in progress, so a prior stream that ended without a terminal/error
|
|
55
|
+
// frame (e.g. `session.cancel()`) doesn't bleed its tokens into the next one.
|
|
56
|
+
const REQUEST_START_EVENTS = new Set([
|
|
57
|
+
"flow_start",
|
|
58
|
+
"flow_run_start",
|
|
59
|
+
"agent_start",
|
|
60
|
+
"dispatch_start",
|
|
61
|
+
"run_start",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
// Per-step markers that fire repeatedly WITHIN a single request (a flow emits
|
|
65
|
+
// one per step). These only lazily begin a run — they must never reset, or a
|
|
66
|
+
// multi-step response would restart the metric between steps. If no request- or
|
|
67
|
+
// step-start event is emitted, the first visible delta lazily starts the run.
|
|
68
|
+
const STEP_START_EVENTS = new Set(["step_start", "execution_start"]);
|
|
69
|
+
|
|
70
|
+
const VISIBLE_DELTA_EVENTS = new Set([
|
|
71
|
+
"step_delta",
|
|
72
|
+
"step_chunk",
|
|
73
|
+
"chunk",
|
|
74
|
+
"agent_turn_delta",
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const INTERMEDIATE_COMPLETE_EVENTS = new Set([
|
|
78
|
+
"step_complete",
|
|
79
|
+
"agent_turn_complete",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const TERMINAL_COMPLETE_EVENTS = new Set(["flow_complete", "agent_complete"]);
|
|
83
|
+
|
|
84
|
+
const ERROR_EVENTS = new Set([
|
|
85
|
+
"step_error",
|
|
86
|
+
"flow_error",
|
|
87
|
+
"agent_error",
|
|
88
|
+
"dispatch_error",
|
|
89
|
+
"error",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
93
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
94
|
+
|
|
95
|
+
const toFiniteNumber = (value: unknown): number | undefined =>
|
|
96
|
+
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
97
|
+
|
|
98
|
+
const getRecord = (
|
|
99
|
+
value: Record<string, unknown>,
|
|
100
|
+
key: string
|
|
101
|
+
): Record<string, unknown> | undefined => {
|
|
102
|
+
const nested = value[key];
|
|
103
|
+
return isRecord(nested) ? nested : undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Token estimate from a character count: ~4 chars/token, floor of 1. */
|
|
107
|
+
function estimateTokensFromCharCount(charCount: number): number {
|
|
108
|
+
return charCount > 0 ? Math.max(1, Math.ceil(charCount / 4)) : 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Simple token estimate matching the dashboard heuristic: ~4 chars/token. */
|
|
112
|
+
export function estimateOutputTokens(text: string): number {
|
|
113
|
+
return estimateTokensFromCharCount(text.trim().length);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function calculateTokensPerSecond(
|
|
117
|
+
outputTokens: number,
|
|
118
|
+
durationMs: number | undefined
|
|
119
|
+
): number | undefined {
|
|
120
|
+
if (
|
|
121
|
+
outputTokens <= 0 ||
|
|
122
|
+
durationMs === undefined ||
|
|
123
|
+
durationMs < THROUGHPUT_MIN_DURATION_MS
|
|
124
|
+
) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
return outputTokens / (durationMs / 1000);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveEventType(
|
|
131
|
+
eventType: string,
|
|
132
|
+
payload: Record<string, unknown>
|
|
133
|
+
): string {
|
|
134
|
+
return typeof payload.type === "string" ? payload.type : eventType;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getTextDelta(payload: Record<string, unknown>): string {
|
|
138
|
+
if (typeof payload.text === "string") return payload.text;
|
|
139
|
+
if (typeof payload.delta === "string") return payload.delta;
|
|
140
|
+
if (typeof payload.content === "string") return payload.content;
|
|
141
|
+
if (typeof payload.chunk === "string") return payload.chunk;
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Only count visible model output.
|
|
147
|
+
*
|
|
148
|
+
* For `agent_turn_delta`, count only a contentType of exactly `text` as
|
|
149
|
+
* visible, matching the client renderer (which appends streaming assistant
|
|
150
|
+
* text only when `contentType === "text"`); `thinking`, `tool_input`, any
|
|
151
|
+
* other value, and a missing contentType are ignored so throughput never
|
|
152
|
+
* includes deltas the chat UI doesn't render.
|
|
153
|
+
*
|
|
154
|
+
* For `step_delta` / `step_chunk`, skip tool and context steps — those carry
|
|
155
|
+
* tool I/O, not model-visible text — mirroring the widget's own renderer.
|
|
156
|
+
*/
|
|
157
|
+
function isVisibleTextDelta(
|
|
158
|
+
type: string,
|
|
159
|
+
payload: Record<string, unknown>
|
|
160
|
+
): boolean {
|
|
161
|
+
if (type === "step_delta" || type === "step_chunk") {
|
|
162
|
+
return payload.stepType !== "tool" && payload.executionType !== "context";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (type !== "agent_turn_delta") return true;
|
|
166
|
+
|
|
167
|
+
const contentType =
|
|
168
|
+
typeof payload.contentType === "string"
|
|
169
|
+
? payload.contentType
|
|
170
|
+
: typeof payload.content_type === "string"
|
|
171
|
+
? payload.content_type
|
|
172
|
+
: undefined;
|
|
173
|
+
|
|
174
|
+
return contentType === "text";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Extract exact output tokens from a variety of usage payload shapes. */
|
|
178
|
+
function getOutputTokens(payload: Record<string, unknown>): number | undefined {
|
|
179
|
+
const result = getRecord(payload, "result");
|
|
180
|
+
const candidates = [
|
|
181
|
+
getRecord(payload, "tokens"),
|
|
182
|
+
getRecord(payload, "totalTokens"),
|
|
183
|
+
result ? getRecord(result, "tokens") : undefined,
|
|
184
|
+
getRecord(payload, "usage"),
|
|
185
|
+
result ? getRecord(result, "usage") : undefined,
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
for (const candidate of candidates) {
|
|
189
|
+
if (!candidate) continue;
|
|
190
|
+
const outputTokens =
|
|
191
|
+
toFiniteNumber(candidate.output) ??
|
|
192
|
+
toFiniteNumber(candidate.outputTokens) ??
|
|
193
|
+
toFiniteNumber(candidate.completionTokens);
|
|
194
|
+
if (outputTokens !== undefined) return outputTokens;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
toFiniteNumber(payload.outputTokens) ??
|
|
199
|
+
toFiniteNumber(payload.completionTokens) ??
|
|
200
|
+
(result
|
|
201
|
+
? (toFiniteNumber(result.outputTokens) ??
|
|
202
|
+
toFiniteNumber(result.completionTokens))
|
|
203
|
+
: undefined)
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Extract provider execution time (ms) from a variety of payload shapes. */
|
|
208
|
+
function getExecutionTimeMs(
|
|
209
|
+
payload: Record<string, unknown>
|
|
210
|
+
): number | undefined {
|
|
211
|
+
const result = getRecord(payload, "result");
|
|
212
|
+
return (
|
|
213
|
+
toFiniteNumber(payload.executionTime) ??
|
|
214
|
+
toFiniteNumber(payload.executionTimeMs) ??
|
|
215
|
+
toFiniteNumber(payload.execution_time) ??
|
|
216
|
+
toFiniteNumber(payload.duration) ??
|
|
217
|
+
(result
|
|
218
|
+
? (toFiniteNumber(result.executionTime) ??
|
|
219
|
+
toFiniteNumber(result.executionTimeMs))
|
|
220
|
+
: undefined)
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function defaultClock(): number {
|
|
225
|
+
if (
|
|
226
|
+
typeof performance !== "undefined" &&
|
|
227
|
+
typeof performance.now === "function"
|
|
228
|
+
) {
|
|
229
|
+
return performance.now();
|
|
230
|
+
}
|
|
231
|
+
return Date.now();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Tracks output throughput across one streamed run at a time. Feed it every SSE
|
|
236
|
+
* event via {@link processEvent}; read the current state via {@link getMetric}.
|
|
237
|
+
*/
|
|
238
|
+
export class ThroughputTracker {
|
|
239
|
+
private metric: ThroughputMetric = { status: "idle" };
|
|
240
|
+
private run: ThroughputRunStats | null = null;
|
|
241
|
+
private readonly now: () => number;
|
|
242
|
+
|
|
243
|
+
constructor(now: () => number = defaultClock) {
|
|
244
|
+
this.now = now;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
getMetric(): ThroughputMetric {
|
|
248
|
+
// While a run is streaming, recompute the elapsed window (and rate) from the
|
|
249
|
+
// clock on each read. The view polls this every ~200ms, so without this a
|
|
250
|
+
// pause between deltas would keep showing the stale rate from the last
|
|
251
|
+
// event; recomputing lets the displayed tok/s decay as time passes.
|
|
252
|
+
const run = this.run;
|
|
253
|
+
if (
|
|
254
|
+
run &&
|
|
255
|
+
this.metric.status === "running" &&
|
|
256
|
+
run.firstDeltaAt !== undefined &&
|
|
257
|
+
this.metric.outputTokens !== undefined
|
|
258
|
+
) {
|
|
259
|
+
const durationMs = this.now() - run.firstDeltaAt;
|
|
260
|
+
return {
|
|
261
|
+
...this.metric,
|
|
262
|
+
durationMs,
|
|
263
|
+
tokensPerSecond: calculateTokensPerSecond(
|
|
264
|
+
this.metric.outputTokens,
|
|
265
|
+
durationMs
|
|
266
|
+
),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return this.metric;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Reset back to idle (e.g. when the chat is cleared). */
|
|
273
|
+
reset(): void {
|
|
274
|
+
this.run = null;
|
|
275
|
+
this.metric = { status: "idle" };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private startRun(now: number): void {
|
|
279
|
+
this.run = {
|
|
280
|
+
startedAt: now,
|
|
281
|
+
visibleCharCount: 0,
|
|
282
|
+
exactOutputTokens: 0,
|
|
283
|
+
};
|
|
284
|
+
this.metric = { status: "running" };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
processEvent(eventType: string, payload: unknown): void {
|
|
288
|
+
if (!isRecord(payload)) {
|
|
289
|
+
// Non-object payloads can still signal lifecycle (e.g. bare "error").
|
|
290
|
+
if (ERROR_EVENTS.has(eventType) && this.run) {
|
|
291
|
+
this.run = null;
|
|
292
|
+
this.metric = { status: "error" };
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const type = resolveEventType(eventType, payload);
|
|
298
|
+
const now = this.now();
|
|
299
|
+
|
|
300
|
+
if (REQUEST_START_EVENTS.has(type)) {
|
|
301
|
+
// New request — start fresh, discarding any incomplete prior run.
|
|
302
|
+
this.startRun(now);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (STEP_START_EVENTS.has(type)) {
|
|
307
|
+
// Mid-request step marker — only begin a run if none is active.
|
|
308
|
+
if (!this.run) this.startRun(now);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (VISIBLE_DELTA_EVENTS.has(type)) {
|
|
313
|
+
if (!isVisibleTextDelta(type, payload)) return;
|
|
314
|
+
const text = getTextDelta(payload);
|
|
315
|
+
if (!text) return;
|
|
316
|
+
|
|
317
|
+
// Lazily start a run if the stream began without a recognized start event.
|
|
318
|
+
if (!this.run) this.startRun(now);
|
|
319
|
+
const stats = this.run!;
|
|
320
|
+
|
|
321
|
+
stats.firstDeltaAt ??= now;
|
|
322
|
+
stats.visibleCharCount += text.length;
|
|
323
|
+
|
|
324
|
+
// Add the live char estimate of the CURRENT (not-yet-completed) step on
|
|
325
|
+
// top of any exact usage already booked from completed steps, so the
|
|
326
|
+
// count only grows — it never drops back to a bare estimate mid-run.
|
|
327
|
+
const outputTokens =
|
|
328
|
+
stats.exactOutputTokens +
|
|
329
|
+
estimateTokensFromCharCount(stats.visibleCharCount);
|
|
330
|
+
const durationMs = now - stats.firstDeltaAt;
|
|
331
|
+
this.metric = {
|
|
332
|
+
status: "running",
|
|
333
|
+
tokensPerSecond: calculateTokensPerSecond(outputTokens, durationMs),
|
|
334
|
+
outputTokens,
|
|
335
|
+
durationMs,
|
|
336
|
+
source: stats.exactOutputTokens > 0 ? "usage" : "estimate",
|
|
337
|
+
};
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (INTERMEDIATE_COMPLETE_EVENTS.has(type)) {
|
|
342
|
+
// Accumulate exact usage but keep the run going — these fire per
|
|
343
|
+
// step/turn, not at the end of the whole run.
|
|
344
|
+
if (!this.run) return;
|
|
345
|
+
const stats = this.run;
|
|
346
|
+
const exact = getOutputTokens(payload);
|
|
347
|
+
if (exact !== undefined) {
|
|
348
|
+
stats.exactOutputTokens += exact;
|
|
349
|
+
// This step's visible text is now represented exactly by provider
|
|
350
|
+
// usage — drop it from the running char estimate so the two don't
|
|
351
|
+
// double-count once the next step starts streaming.
|
|
352
|
+
stats.visibleCharCount = 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const usingExact = stats.exactOutputTokens > 0;
|
|
356
|
+
const outputTokens =
|
|
357
|
+
stats.exactOutputTokens +
|
|
358
|
+
estimateTokensFromCharCount(stats.visibleCharCount);
|
|
359
|
+
const durationMs = this.resolveDuration(stats, payload, now);
|
|
360
|
+
this.metric = {
|
|
361
|
+
status: "running",
|
|
362
|
+
tokensPerSecond: calculateTokensPerSecond(outputTokens, durationMs),
|
|
363
|
+
outputTokens,
|
|
364
|
+
durationMs,
|
|
365
|
+
source: usingExact ? "usage" : "estimate",
|
|
366
|
+
};
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (TERMINAL_COMPLETE_EVENTS.has(type)) {
|
|
371
|
+
if (!this.run) return;
|
|
372
|
+
const stats = this.run;
|
|
373
|
+
// Prefer exact output tokens from this terminal event, else accumulated
|
|
374
|
+
// usage from intermediate completes, else the text estimate.
|
|
375
|
+
const terminalExact = getOutputTokens(payload);
|
|
376
|
+
// Prefer a total from the terminal event; otherwise sum exact usage booked
|
|
377
|
+
// from intermediate completes plus the estimate of any still-streamed text
|
|
378
|
+
// not yet covered by a usage report.
|
|
379
|
+
const outputTokens =
|
|
380
|
+
terminalExact ??
|
|
381
|
+
stats.exactOutputTokens +
|
|
382
|
+
estimateTokensFromCharCount(stats.visibleCharCount);
|
|
383
|
+
const source: ThroughputMetricSource =
|
|
384
|
+
terminalExact !== undefined || stats.exactOutputTokens > 0
|
|
385
|
+
? "usage"
|
|
386
|
+
: "estimate";
|
|
387
|
+
const durationMs = this.resolveDuration(stats, payload, now);
|
|
388
|
+
this.metric = {
|
|
389
|
+
status: "complete",
|
|
390
|
+
tokensPerSecond: calculateTokensPerSecond(outputTokens, durationMs),
|
|
391
|
+
outputTokens,
|
|
392
|
+
durationMs,
|
|
393
|
+
source,
|
|
394
|
+
};
|
|
395
|
+
this.run = null;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (ERROR_EVENTS.has(type)) {
|
|
400
|
+
if (!this.run) return;
|
|
401
|
+
this.run = null;
|
|
402
|
+
this.metric = { status: "error" };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Prefer the streamed visible-output window when it clears the minimum
|
|
408
|
+
* threshold; otherwise fall back to provider execution time, then to the
|
|
409
|
+
* whole-request duration.
|
|
410
|
+
*/
|
|
411
|
+
private resolveDuration(
|
|
412
|
+
stats: ThroughputRunStats,
|
|
413
|
+
payload: Record<string, unknown>,
|
|
414
|
+
now: number
|
|
415
|
+
): number {
|
|
416
|
+
const streamedDurationMs =
|
|
417
|
+
stats.firstDeltaAt !== undefined ? now - stats.firstDeltaAt : undefined;
|
|
418
|
+
if (
|
|
419
|
+
streamedDurationMs !== undefined &&
|
|
420
|
+
streamedDurationMs >= THROUGHPUT_MIN_DURATION_MS
|
|
421
|
+
) {
|
|
422
|
+
return streamedDurationMs;
|
|
423
|
+
}
|
|
424
|
+
const executionTimeMs = getExecutionTimeMs(payload);
|
|
425
|
+
return executionTimeMs ?? now - stats.startedAt;
|
|
426
|
+
}
|
|
427
|
+
}
|