返回 DeepSeek-Reasonix
turnMetrics.ts
根目录 / desktop / frontend / src / lib / turnMetrics.ts
1 export interface LiveOutputBuffers {
2 readonly text: string;
3 readonly reasoning: string;
4 }
5
6 /** A snapshot has output but no provider timings. Measure only new output. */
7 export interface TurnRateSample {
8 outputQuarters: number;
9 requestStartQuarters: number;
10 requestStartModelMs: number;
11 argChars?: number;
12 }
13
14 export interface TurnMetricInput {
15 now: number;
16 turnStartAt: number | undefined;
17 turnDoneAt: number | undefined;
18 running: boolean;
19 /** Controller wait plus any composer-local pause, in ms. */
20 waitAccumMs: number;
21 lastTurnWaitAccumMs?: number;
22 turnTokens?: number;
23 turnOutputTokens?: number;
24 lastTurnOutputTokens?: number;
25 turnOutputCharsAtUsage?: number;
26 turnArgChars?: number;
27 turnModelActiveMs: number;
28 turnRateOutputQuarters?: number;
29 turnModelActiveAt?: number;
30 liveModelActiveAt?: number;
31 live?: LiveOutputBuffers;
32 turnOutputEstimated?: boolean;
33 lastTurnOutputEstimated?: boolean;
34 }
35
36 export interface TurnMetrics {
37 elapsedMs: number;
38 tokens: number;
39 outputTokens: number;
40 tps: number | null;
41 estimated: boolean;
42 }
43
44 /**
45 * UTF-8 byte weight of `s` from `from`, in quarter-tokens. Matches the backend
46 * model in internal/agent/agent.go estimateTokensFromBytes, which is CJK-aware
47 * by construction: ASCII counts 1, Han/kana/hangul 3.
48 */
49 export function outputQuarters(s: string, from = 0): number {
50 let quarters = 0;
51 for (let i = from; i < s.length; i++) {
52 const c = s.charCodeAt(i);
53 if (c < 0x80) quarters += 1;
54 else if (c < 0x800) quarters += 2;
55 else if (c >= 0xd800 && c <= 0xdbff) {
56 quarters += 4;
57 i += 1;
58 } else quarters += 3;
59 }
60 return quarters;
61 }
62
63 /**
64 * `Math.round(quarters / 4)` is the previous flat `Math.round(chars / 4)`
65 * expression verbatim: pure ASCII sums one quarter per char, so `quarters`
66 * equals the char count and every existing value is reproduced exactly.
67 */
68 export function tokensFromQuarters(quarters: number): number {
69 return quarters > 0 ? Math.round(quarters / 4) : 0;
70 }
71
72 /**
73 * Output tokens streamed since the last billed usage event.
74 *
75 * `billedChars` is a SUM of both buffer lengths, so the billed/unbilled
76 * boundary cannot be located inside either buffer. The newest characters are
77 * attributed in stream order (all of `text`, then the tail of `reasoning`) —
78 * exact when the stream ended in text, locally bounded otherwise. Streaming
79 * tool-call arguments arrive as a bare count, so they are charged as ASCII.
80 */
81 export function unbilledOutputTokens(
82 buffers: LiveOutputBuffers | undefined,
83 billedChars: number,
84 argChars: number,
85 ): number {
86 const textChars = buffers?.text.length ?? 0;
87 const reasoningChars = buffers?.reasoning.length ?? 0;
88 const window = Math.max(0, textChars + reasoningChars - Math.max(0, billedChars || 0));
89 const extra = Math.max(0, argChars);
90 if (buffers === undefined || window === 0) return tokensFromQuarters(window + extra);
91 let quarters = extra;
92 if (window >= textChars) quarters += outputQuarters(buffers.text);
93 else quarters += outputQuarters(buffers.text, textChars - window);
94 const fromReasoning = window - textChars;
95 if (fromReasoning > 0) {
96 quarters += outputQuarters(buffers.reasoning, Math.max(0, reasoningChars - fromReasoning));
97 }
98 return tokensFromQuarters(quarters);
99 }
100
101 /** Locale-free elapsed label shared by every surface showing a turn clock. */
102 export function formatElapsedMs(ms: number): string {
103 const s = Math.floor(ms / 1000);
104 if (s < 60) return `${s}s`;
105 return `${Math.floor(s / 60)}m ${s % 60}s`;
106 }
107
108 /**
109 * The single definition of the turn's elapsed, token and throughput readings.
110 * Returns null when no turn is open or settled, matching the run-strip gate.
111 */
112 export function turnMetrics(input: TurnMetricInput): TurnMetrics | null {
113 if (!input.turnStartAt || (!input.running && !input.turnDoneAt)) return null;
114 const metricsNow = input.turnDoneAt || input.now;
115 const waitMs = input.turnDoneAt
116 ? input.lastTurnWaitAccumMs ?? input.waitAccumMs
117 : input.waitAccumMs;
118 const elapsedMs = Math.max(0, metricsNow - input.turnStartAt - waitMs);
119 const estimatedTokens = input.turnDoneAt
120 ? Math.max(0, (input.lastTurnOutputTokens ?? input.turnOutputTokens ?? 0) - (input.turnOutputTokens ?? 0))
121 : unbilledOutputTokens(input.live, input.turnOutputCharsAtUsage ?? 0, input.turnArgChars ?? 0);
122 const outputTokens = (input.turnOutputTokens ?? 0) + estimatedTokens;
123 const modelActiveAt = input.liveModelActiveAt ?? input.turnModelActiveAt;
124 const modelElapsedMs = Math.max(0, input.turnModelActiveMs
125 + (modelActiveAt && modelActiveAt > 0 ? Math.max(0, metricsNow - modelActiveAt) : 0));
126 // Below the gate the reading is too noisy to be worth a number.
127 const rateTokens = input.turnRateOutputQuarters === undefined
128 ? outputTokens : tokensFromQuarters(input.turnRateOutputQuarters);
129 const tps = rateTokens > 0 && modelElapsedMs >= 500
130 ? Math.round(rateTokens / (modelElapsedMs / 1000))
131 : null;
132 const estimated = input.turnDoneAt
133 ? input.lastTurnOutputEstimated === true
134 : estimatedTokens > 0 || input.turnOutputEstimated === true;
135 return { elapsedMs, tokens: (input.turnTokens ?? 0) + estimatedTokens, outputTokens, tps, estimated };
136 }
137
137 lines TYPESCRIPT