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