opencode-tokenwatch 0.2.0 → 0.3.1
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/README.en.md +19 -11
- package/README.md +20 -12
- package/dist/commands.jsx +69 -4
- package/dist/formatter.d.ts +30 -0
- package/dist/generate-usage-html.js +210 -71
- package/dist/i18n.js +2 -4
- package/dist/perf-tracker.d.ts +6 -0
- package/dist/perf-tracker.js +122 -13
- package/dist/queries.d.ts +3 -1
- package/dist/queries.js +65 -6
- package/dist/sidebar.d.ts +2 -2
- package/dist/sidebar.jsx +321 -92
- package/dist/stats-store.d.ts +23 -0
- package/dist/stats-store.js +258 -0
- package/dist/tui.jsx +84 -27
- package/package.json +1 -1
package/dist/sidebar.jsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createSignal, createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
|
|
2
2
|
import { RGBA } from "@opentui/core";
|
|
3
3
|
import { formatTokens, formatCost, formatDuration } from "./formatter.js";
|
|
4
|
-
import { t, setLanguage } from "./i18n.js";
|
|
4
|
+
import { t as baseT, setLanguage } from "./i18n.js";
|
|
5
5
|
const DEFAULT_CONFIG = {
|
|
6
6
|
sidebar: { showPerformance: true, showPricing: true, showTokenDistribution: true, showTrend: true },
|
|
7
7
|
language: "auto",
|
|
@@ -17,6 +17,29 @@ function progressFilled(percent, width) {
|
|
|
17
17
|
function progressRemaining(percent, width) {
|
|
18
18
|
return "░".repeat(Math.max(0, width - progressBarWidth(percent, width)));
|
|
19
19
|
}
|
|
20
|
+
function getVisualWidth(str) {
|
|
21
|
+
let w = 0;
|
|
22
|
+
for (const c of str) {
|
|
23
|
+
const code = c.codePointAt(0) ?? 0;
|
|
24
|
+
if ((code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3040 && code <= 0x30FF) ||
|
|
25
|
+
(code >= 0xAC00 && code <= 0xD7A3) || (code >= 0x1100 && code <= 0x11FF) ||
|
|
26
|
+
(code >= 0x2E80 && code <= 0x2EFF)) {
|
|
27
|
+
w += 2;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
w += 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return w;
|
|
34
|
+
}
|
|
35
|
+
function centerAlign(text, width) {
|
|
36
|
+
const visualW = getVisualWidth(text);
|
|
37
|
+
if (visualW >= width)
|
|
38
|
+
return text;
|
|
39
|
+
const left = Math.floor((width - visualW) / 2);
|
|
40
|
+
const right = width - visualW - left;
|
|
41
|
+
return " ".repeat(left) + text + " ".repeat(right);
|
|
42
|
+
}
|
|
20
43
|
function hitRateColor(rate) {
|
|
21
44
|
if (rate >= 85)
|
|
22
45
|
return RGBA.fromInts(76, 175, 80, 255);
|
|
@@ -24,6 +47,18 @@ function hitRateColor(rate) {
|
|
|
24
47
|
return RGBA.fromInts(255, 193, 7, 255);
|
|
25
48
|
return RGBA.fromInts(244, 67, 54, 255);
|
|
26
49
|
}
|
|
50
|
+
/** 各 Token 分布角色的颜色 */
|
|
51
|
+
function distRoleColor(role) {
|
|
52
|
+
const map = {
|
|
53
|
+
system: RGBA.fromInts(130, 80, 255, 255),
|
|
54
|
+
user: RGBA.fromInts(88, 166, 255, 255),
|
|
55
|
+
toolCall: RGBA.fromInts(210, 153, 34, 255),
|
|
56
|
+
toolResult: RGBA.fromInts(219, 109, 40, 255),
|
|
57
|
+
output: RGBA.fromInts(63, 185, 80, 255),
|
|
58
|
+
other: RGBA.fromInts(72, 79, 88, 255),
|
|
59
|
+
};
|
|
60
|
+
return map[role] ?? RGBA.fromInts(72, 79, 88, 255);
|
|
61
|
+
}
|
|
27
62
|
function estimateTokens(text) {
|
|
28
63
|
if (!text || text.length === 0)
|
|
29
64
|
return 0;
|
|
@@ -76,9 +111,19 @@ export function loadConfig(api) {
|
|
|
76
111
|
}
|
|
77
112
|
export function TokenWatchPanel(props) {
|
|
78
113
|
const { api, theme, perfTracker } = props;
|
|
79
|
-
const getMessages = () => props.messages;
|
|
114
|
+
const getMessages = () => props.messages();
|
|
80
115
|
const [config, setConfig] = createSignal(loadConfig(api));
|
|
116
|
+
// 同步初始化语言以防首帧渲染使用错误的 detectLanguage 默认值
|
|
117
|
+
setLanguage(config().language);
|
|
81
118
|
let knownCfgVer = api.kv?.get?.("tokenwatch-config-version");
|
|
119
|
+
// ── 响应式翻译函数 ──
|
|
120
|
+
const t = (key) => {
|
|
121
|
+
// 显式订阅 config 的变化以建立 SolidJS 追踪依赖
|
|
122
|
+
void config().language;
|
|
123
|
+
return baseT(key);
|
|
124
|
+
};
|
|
125
|
+
// 检测是否是纯英文标签,用于全大写转换
|
|
126
|
+
const isEnglish = (str) => /^[a-zA-Z\s\.\/]+$/.test(str);
|
|
82
127
|
createEffect(() => {
|
|
83
128
|
const timer = setInterval(() => {
|
|
84
129
|
const v = api.kv?.get?.("tokenwatch-config-version");
|
|
@@ -90,22 +135,30 @@ export function TokenWatchPanel(props) {
|
|
|
90
135
|
onCleanup(() => clearInterval(timer));
|
|
91
136
|
});
|
|
92
137
|
const [collapse, setCollapse] = createSignal(loadCollapseState(api));
|
|
93
|
-
|
|
138
|
+
// ── 真实面板宽度:通过 ref + onSizeChange 从渲染引擎获取 ──
|
|
139
|
+
// 初始值给一个合理默认,渲染后立即更新为实际值
|
|
140
|
+
const [panelWidth, setPanelWidth] = createSignal(38);
|
|
141
|
+
let outerBoxRef = null;
|
|
94
142
|
createEffect(() => setLanguage(config().language));
|
|
143
|
+
// ── 颜色 helpers ──
|
|
95
144
|
const primaryColor = () => theme.current.primary;
|
|
96
145
|
const mutedColor = () => theme.current.textMuted;
|
|
97
|
-
const
|
|
146
|
+
const dimColor = () => RGBA.fromInts(72, 79, 88, 255);
|
|
147
|
+
const greenColor = () => RGBA.fromInts(63, 185, 80, 255);
|
|
148
|
+
const borderColor = () => RGBA.fromInts(55, 65, 80, 255);
|
|
149
|
+
// ── 数据聚合 ──
|
|
98
150
|
const modelStats = createMemo(() => {
|
|
99
151
|
const map = new Map();
|
|
100
|
-
for (const msg of props.allTokenMessages) {
|
|
152
|
+
for (const msg of props.allTokenMessages()) {
|
|
101
153
|
const key = `${msg.providerID}/${msg.modelID}`;
|
|
102
154
|
let e = map.get(key);
|
|
103
155
|
if (!e) {
|
|
104
|
-
e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0 };
|
|
156
|
+
e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0 };
|
|
105
157
|
map.set(key, e);
|
|
106
158
|
}
|
|
107
159
|
e.totalInput += msg.inputTokens;
|
|
108
160
|
e.totalOutput += msg.outputTokens;
|
|
161
|
+
e.totalReasoning += msg.reasoningTokens;
|
|
109
162
|
e.cacheRead += msg.cacheRead;
|
|
110
163
|
e.cacheWrite += msg.cacheWrite;
|
|
111
164
|
e.totalCost += msg.cost;
|
|
@@ -114,16 +167,21 @@ export function TokenWatchPanel(props) {
|
|
|
114
167
|
return Array.from(map.entries()).sort((a, b) => (b[1].totalInput + b[1].totalOutput) - (a[1].totalInput + a[1].totalOutput));
|
|
115
168
|
});
|
|
116
169
|
const sessionTotals = createMemo(() => {
|
|
117
|
-
let i = 0, o = 0, cr = 0, cw = 0, r = 0, c = 0;
|
|
170
|
+
let i = 0, o = 0, ir = 0, cr = 0, cw = 0, r = 0, c = 0;
|
|
118
171
|
for (const [, s] of modelStats()) {
|
|
119
172
|
i += s.totalInput;
|
|
120
173
|
o += s.totalOutput;
|
|
174
|
+
ir += s.totalReasoning;
|
|
121
175
|
cr += s.cacheRead;
|
|
122
176
|
cw += s.cacheWrite;
|
|
123
177
|
r += s.requestCount;
|
|
124
178
|
c += s.totalCost;
|
|
125
179
|
}
|
|
126
|
-
return { totalInput: i, totalOutput: o, totalCacheRead: cr, totalCacheWrite: cw, totalRequests: r, totalCost: c, totalTokens: i + o + cr + cw };
|
|
180
|
+
return { totalInput: i, totalOutput: o, totalReasoning: ir, totalCacheRead: cr, totalCacheWrite: cw, totalRequests: r, totalCost: c, totalTokens: i + o + ir + cr + cw };
|
|
181
|
+
});
|
|
182
|
+
const globalHitRate = createMemo(() => {
|
|
183
|
+
const denom = sessionTotals().totalInput + sessionTotals().totalCacheRead;
|
|
184
|
+
return denom > 0 ? (sessionTotals().totalCacheRead / denom) * 100 : -1;
|
|
127
185
|
});
|
|
128
186
|
const modelHitRate = createMemo(() => {
|
|
129
187
|
return modelStats().map(([key, stat]) => {
|
|
@@ -131,7 +189,7 @@ export function TokenWatchPanel(props) {
|
|
|
131
189
|
if (denom === 0)
|
|
132
190
|
return { key, rate: 0, msgs: [] };
|
|
133
191
|
const msgs = [];
|
|
134
|
-
for (const msg of props.allTokenMessages) {
|
|
192
|
+
for (const msg of props.allTokenMessages()) {
|
|
135
193
|
const pk = `${msg.providerID}/${msg.modelID}`;
|
|
136
194
|
if (pk !== key)
|
|
137
195
|
continue;
|
|
@@ -147,10 +205,8 @@ export function TokenWatchPanel(props) {
|
|
|
147
205
|
const sumSlice = (start, end) => {
|
|
148
206
|
let sumCache = 0, sumTotal = 0;
|
|
149
207
|
for (let i = start; i < end && i < msgs.length; i++) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
sumCache += cache;
|
|
153
|
-
sumTotal += input + cache;
|
|
208
|
+
sumCache += msgs[i].cacheRead;
|
|
209
|
+
sumTotal += msgs[i].inputTokens + msgs[i].cacheRead;
|
|
154
210
|
}
|
|
155
211
|
return { sumCache, sumTotal };
|
|
156
212
|
};
|
|
@@ -163,7 +219,13 @@ export function TokenWatchPanel(props) {
|
|
|
163
219
|
});
|
|
164
220
|
});
|
|
165
221
|
const [partVersion, setPartVersion] = createSignal(0);
|
|
222
|
+
const perfStats = createMemo(() => {
|
|
223
|
+
void props.allTokenMessages();
|
|
224
|
+
void partVersion();
|
|
225
|
+
return perfTracker.getSessionStats();
|
|
226
|
+
});
|
|
166
227
|
const tokenDistribution = createMemo(() => {
|
|
228
|
+
void props.allTokenMessages();
|
|
167
229
|
void partVersion();
|
|
168
230
|
const dist = {};
|
|
169
231
|
try {
|
|
@@ -209,6 +271,7 @@ export function TokenWatchPanel(props) {
|
|
|
209
271
|
catch {
|
|
210
272
|
continue;
|
|
211
273
|
}
|
|
274
|
+
let msgEstimatedOutput = 0;
|
|
212
275
|
for (const p of parts) {
|
|
213
276
|
if (p.type === "tool") {
|
|
214
277
|
let rawInput = "";
|
|
@@ -230,20 +293,36 @@ export function TokenWatchPanel(props) {
|
|
|
230
293
|
dist.toolResult = (dist.toolResult ?? 0) + estimateTokens(p.state.error);
|
|
231
294
|
}
|
|
232
295
|
}
|
|
296
|
+
else if (p.type === "text" && p.text) {
|
|
297
|
+
msgEstimatedOutput += estimateTokens(p.text);
|
|
298
|
+
}
|
|
233
299
|
else if (p.type === "reasoning") {
|
|
234
|
-
|
|
300
|
+
msgEstimatedOutput += estimateTokens(p.text ?? "");
|
|
235
301
|
}
|
|
236
302
|
else if (p.type === "subtask") {
|
|
237
|
-
|
|
303
|
+
msgEstimatedOutput += estimateTokens(p.prompt || p.description || "");
|
|
238
304
|
}
|
|
239
305
|
}
|
|
240
306
|
const tokens = msg.tokens;
|
|
241
|
-
if (tokens?.output)
|
|
242
|
-
dist.output = (dist.output ?? 0) + tokens
|
|
307
|
+
if (tokens?.output !== undefined || tokens?.reasoning !== undefined) {
|
|
308
|
+
dist.output = (dist.output ?? 0) + (tokens?.output ?? 0) + (tokens?.reasoning ?? 0);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
dist.output = (dist.output ?? 0) + msgEstimatedOutput;
|
|
312
|
+
}
|
|
243
313
|
}
|
|
244
314
|
}
|
|
315
|
+
const realInput = sessionTotals().totalInput;
|
|
316
|
+
if (realInput > 0) {
|
|
317
|
+
const estimated = (dist.system ?? 0) + (dist.user ?? 0)
|
|
318
|
+
+ (dist.toolCall ?? 0) + (dist.toolResult ?? 0);
|
|
319
|
+
const other = realInput - estimated;
|
|
320
|
+
if (other > 50)
|
|
321
|
+
dist.other = other;
|
|
322
|
+
}
|
|
245
323
|
return dist;
|
|
246
324
|
});
|
|
325
|
+
// ── 折叠状态 toggle ──
|
|
247
326
|
const toggle = {
|
|
248
327
|
global: () => setCollapse(p => { const n = { ...p, global: !p.global }; saveCollapseState(api, n); return n; }),
|
|
249
328
|
model: (k) => setCollapse(p => { const n = { ...p, models: { ...p.models, [k]: !p.models[k] } }; saveCollapseState(api, n); return n; }),
|
|
@@ -251,112 +330,262 @@ export function TokenWatchPanel(props) {
|
|
|
251
330
|
};
|
|
252
331
|
onMount(() => {
|
|
253
332
|
const unsubPart = api.event?.on?.("message.part.updated", () => setPartVersion(v => v + 1));
|
|
254
|
-
const unsubMsg = api.event?.on?.("message.updated", () => setPartVersion(v => v + 1));
|
|
255
333
|
onCleanup(() => { try {
|
|
256
334
|
unsubPart?.();
|
|
257
|
-
unsubMsg?.();
|
|
258
335
|
}
|
|
259
336
|
catch { } });
|
|
260
337
|
});
|
|
261
|
-
|
|
262
|
-
|
|
338
|
+
// ── 宽度派生值 ──
|
|
339
|
+
// innerWidth:边框内可用列数 = panelWidth - 2(左右边框各1格)
|
|
340
|
+
// barWidth:进度条宽 = innerWidth - paddingX(1*2) - "Cache: "(7) - " XX%"(4) - "↑X.X%"(最多6) = innerWidth - 19
|
|
341
|
+
// 分隔线:innerWidth - paddingX(1*2) = innerWidth - 2
|
|
342
|
+
const innerWidth = () => panelWidth() - 2;
|
|
343
|
+
const barWidth = () => Math.max(8, innerWidth() - 19);
|
|
344
|
+
const divider = () => {
|
|
345
|
+
const w = innerWidth();
|
|
346
|
+
if (w <= 2)
|
|
347
|
+
return "─".repeat(w);
|
|
348
|
+
return " " + "─".repeat(w - 2) + " ";
|
|
349
|
+
};
|
|
350
|
+
return (<box
|
|
351
|
+
// ── 不设置固定 width,让外层容器决定宽度 ──
|
|
352
|
+
// ref + onSizeChange:布局完成后获取真实宽度,用于内部字符宽度计算
|
|
353
|
+
ref={(el) => { outerBoxRef = el; }} onSizeChange={() => {
|
|
354
|
+
if (outerBoxRef)
|
|
355
|
+
setPanelWidth(outerBoxRef.width);
|
|
356
|
+
}} flexDirection="column" border={true} borderStyle="rounded" borderColor={borderColor()}>
|
|
357
|
+
|
|
358
|
+
{/* ══════════════════════════════════════
|
|
359
|
+
面板 Header:▾ TokenWatch 89.1% hit
|
|
360
|
+
justifyContent="space-between" 左右分布
|
|
361
|
+
══════════════════════════════════════ */}
|
|
362
|
+
<box flexDirection="row" justifyContent="space-between" onMouseDown={toggle.global} paddingX={1}>
|
|
263
363
|
<text fg={primaryColor()}>
|
|
264
|
-
{collapse().global ? "▶" : "
|
|
265
|
-
|
|
364
|
+
{collapse().global ? "▶" : "▾"} {t("panelTitle")}
|
|
365
|
+
</text>
|
|
366
|
+
<text fg={mutedColor()}>
|
|
367
|
+
{collapse().global ? (<>
|
|
368
|
+
{formatTokens(sessionTotals().totalTokens)}
|
|
369
|
+
{globalHitRate() >= 0 ? (<span style={{ fg: hitRateColor(globalHitRate()) }}>
|
|
370
|
+
{` (${globalHitRate().toFixed(1)}% hit)`}
|
|
371
|
+
</span>) : ""}
|
|
372
|
+
</>) : (globalHitRate() >= 0 ? (<span style={{ fg: hitRateColor(globalHitRate()) }}>
|
|
373
|
+
{`${globalHitRate().toFixed(1)}% hit`}
|
|
374
|
+
</span>) : "")}
|
|
266
375
|
</text>
|
|
267
376
|
</box>
|
|
268
377
|
|
|
269
378
|
<Show when={!collapse().global}>
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
</text>
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
379
|
+
|
|
380
|
+
{/* 标题下分隔线 */}
|
|
381
|
+
<text fg={borderColor()}>{divider()}</text>
|
|
382
|
+
|
|
383
|
+
{/* ══════════════════════════════════════
|
|
384
|
+
全局统计:Total / Req / Input / Output 均匀分行排布
|
|
385
|
+
══════════════════════════════════════ */}
|
|
386
|
+
<box flexDirection="row" paddingX={1}>
|
|
387
|
+
<For each={[
|
|
388
|
+
{ val: formatTokens(sessionTotals().totalTokens), lbl: t("total") },
|
|
389
|
+
{ val: sessionTotals().totalRequests.toString(), lbl: t("requests") },
|
|
390
|
+
{ val: formatTokens(sessionTotals().totalInput), lbl: t("input") },
|
|
391
|
+
{ val: formatTokens(sessionTotals().totalOutput), lbl: t("output") }
|
|
392
|
+
]}>
|
|
393
|
+
{(item, idx) => {
|
|
394
|
+
const colW = () => {
|
|
395
|
+
const totalW = panelWidth() - 4;
|
|
396
|
+
const base = Math.floor(totalW / 4);
|
|
397
|
+
return idx() === 3 ? totalW - base * 3 : base;
|
|
398
|
+
};
|
|
399
|
+
return (<box width={colW()} flexDirection="column">
|
|
400
|
+
<text fg={primaryColor()}>{centerAlign(item.val, colW())}</text>
|
|
401
|
+
<text fg={dimColor()}>
|
|
402
|
+
{centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW())}
|
|
403
|
+
</text>
|
|
404
|
+
</box>);
|
|
405
|
+
}}
|
|
406
|
+
</For>
|
|
407
|
+
</box>
|
|
408
|
+
|
|
409
|
+
{/* 成本展示 */}
|
|
410
|
+
<Show when={config().sidebar.showPricing && sessionTotals().totalCost > 0}>
|
|
411
|
+
<box flexDirection="row" justifyContent="center" marginTop={1}>
|
|
412
|
+
<text fg={mutedColor()}>
|
|
413
|
+
{t("cost")}:{" "}
|
|
414
|
+
<span style={{ fg: greenColor() }}>
|
|
415
|
+
{formatCost(sessionTotals().totalCost)}
|
|
416
|
+
</span>
|
|
417
|
+
</text>
|
|
418
|
+
</box>
|
|
280
419
|
</Show>
|
|
281
420
|
|
|
421
|
+
{/* ══════════════════════════════════════
|
|
422
|
+
各模型块:无分隔线,用 marginTop=1 隔开
|
|
423
|
+
══════════════════════════════════════ */}
|
|
282
424
|
<For each={modelStats()}>
|
|
283
425
|
{([key, stat]) => {
|
|
284
|
-
const
|
|
285
|
-
const
|
|
286
|
-
const hitRate =
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
426
|
+
const isExpanded = () => collapse().models[key] !== true;
|
|
427
|
+
const hitDenom = stat.totalInput + stat.cacheRead;
|
|
428
|
+
const hitRate = hitDenom > 0 ? (stat.cacheRead / hitDenom) * 100 : 0;
|
|
429
|
+
const trendStr = () => {
|
|
430
|
+
if (!config().sidebar.showTrend)
|
|
431
|
+
return "";
|
|
432
|
+
const td = modelTrend().find(h => h.key === key);
|
|
433
|
+
if (!td?.trend || td.trend === 0)
|
|
434
|
+
return "";
|
|
435
|
+
return td.trend > 0
|
|
436
|
+
? ` ${t("trendUp")}${td.trend.toFixed(1)}%`
|
|
437
|
+
: ` ${t("trendDown")}${Math.abs(td.trend).toFixed(1)}%`;
|
|
438
|
+
};
|
|
439
|
+
const trendColor = () => {
|
|
440
|
+
const td = modelTrend().find(h => h.key === key);
|
|
441
|
+
return (td?.trend ?? 0) >= 0
|
|
442
|
+
? RGBA.fromInts(63, 185, 80, 255)
|
|
443
|
+
: RGBA.fromInts(244, 67, 54, 255);
|
|
444
|
+
};
|
|
445
|
+
// 模型名处理:假如总长超过22字符,且格式为 厂商/模型厂商/模型名称,去除中间模型厂商,只留 provider/modelname 格式
|
|
446
|
+
let fullTitle = `${stat.providerID}/${stat.modelID}`;
|
|
447
|
+
if (fullTitle.length > 22) {
|
|
448
|
+
const parts = fullTitle.split("/");
|
|
449
|
+
if (parts.length >= 3) {
|
|
450
|
+
fullTitle = `${parts[0]}/${parts[parts.length - 1]}`;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// 模型名截断:内容宽 - paddingX(2) - "● "(2) - " ×NNN ▾"(最多8) = innerWidth - 12
|
|
454
|
+
const maxNameLen = Math.max(8, innerWidth() - 12);
|
|
455
|
+
const shortTitle = fullTitle.length > maxNameLen
|
|
456
|
+
? fullTitle.slice(0, maxNameLen - 1) + "…"
|
|
457
|
+
: fullTitle;
|
|
458
|
+
// 折叠:右侧显示总 token;展开:右侧显示请求数
|
|
459
|
+
const modelHeaderRight = () => {
|
|
460
|
+
if (!isExpanded()) {
|
|
461
|
+
const total = stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite;
|
|
462
|
+
return `${formatTokens(total)} ▶`;
|
|
463
|
+
}
|
|
464
|
+
return `×${stat.requestCount} ▾`;
|
|
465
|
+
};
|
|
466
|
+
// 计算模型总 tokens
|
|
467
|
+
const modelTotalTokens = stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite;
|
|
468
|
+
// 计算对齐标签 (使用 getter 以保持响应式切换)
|
|
469
|
+
const targetW = () => {
|
|
470
|
+
const cacheLabel = t("cache") + ":";
|
|
471
|
+
const costLabel = t("cost") + ":";
|
|
472
|
+
return Math.max(getVisualWidth(cacheLabel), getVisualWidth(costLabel));
|
|
473
|
+
};
|
|
474
|
+
const paddedCachePrefix = () => {
|
|
475
|
+
const label = t("cache") + ":";
|
|
476
|
+
return label + " ".repeat(targetW() - getVisualWidth(label));
|
|
477
|
+
};
|
|
478
|
+
const paddedCostPrefix = () => {
|
|
479
|
+
const label = t("cost") + ":";
|
|
480
|
+
return label + " ".repeat(targetW() - getVisualWidth(label));
|
|
481
|
+
};
|
|
482
|
+
// 缓存进度条宽度:可用宽度 panelWidth() - 4 减去前缀 targetW(),减去百分比(4),减去趋势(6)
|
|
483
|
+
const modelBarWidth = () => Math.max(8, (panelWidth() - 4) - targetW() - 11);
|
|
484
|
+
return (
|
|
485
|
+
// marginTop=1 提供模型间视觉间距(TUI最小单位为1行)
|
|
486
|
+
<box flexDirection="column" marginTop={1}>
|
|
487
|
+
|
|
488
|
+
{/* 模型 Header:左侧 ● 名称,右侧 统计+箭头 */}
|
|
489
|
+
<box flexDirection="row" justifyContent="space-between" onMouseDown={() => toggle.model(key)} paddingX={1}>
|
|
313
490
|
<text fg={mutedColor()}>
|
|
314
|
-
{
|
|
491
|
+
<span style={{ fg: hitRateColor(hitRate) }}>●</span>
|
|
492
|
+
{" "}
|
|
493
|
+
<span style={{ fg: primaryColor() }}>{shortTitle}</span>
|
|
315
494
|
</text>
|
|
316
|
-
|
|
495
|
+
<text fg={mutedColor()}>{modelHeaderRight()}</text>
|
|
496
|
+
</box>
|
|
317
497
|
|
|
318
|
-
<Show when={
|
|
319
|
-
<
|
|
320
|
-
<box flexDirection="column">
|
|
321
|
-
<box onMouseDown={() => toggle.sub(`perf-${key}`)}>
|
|
322
|
-
<text fg={textColor()}>{!collapse().subBlocks[`perf-${key}`] ? "▼" : "▶"} ── {t("performance")} ──</text>
|
|
323
|
-
</box>
|
|
324
|
-
<Show when={!collapse().subBlocks[`perf-${key}`]}>
|
|
325
|
-
<text fg={mutedColor()}>
|
|
326
|
-
{t("ttft")}:{formatDuration(perfTracker.getSessionStats().models[key]?.avgTTFT ?? null)} {t("tps")}:{perfTracker.getSessionStats().models[key]?.avgTPS?.toFixed(1) ?? "—"} {t("latency")}:{formatDuration(perfTracker.getSessionStats().models[key]?.avgLatency ?? null)}
|
|
327
|
-
</text>
|
|
328
|
-
</Show>
|
|
329
|
-
</box>
|
|
330
|
-
</Show>
|
|
498
|
+
<Show when={isExpanded()}>
|
|
499
|
+
<box flexDirection="column" paddingX={1}>
|
|
331
500
|
|
|
332
|
-
|
|
333
|
-
<box flexDirection="column">
|
|
334
|
-
<box
|
|
335
|
-
<
|
|
501
|
+
{/* 模型指标三列网格,外带圆角边框 (取消上下间距) */}
|
|
502
|
+
<box flexDirection="column" border={true} borderStyle="rounded" borderColor={borderColor()}>
|
|
503
|
+
<box flexDirection="row">
|
|
504
|
+
<For each={[
|
|
505
|
+
{ val: formatTokens(modelTotalTokens), lbl: t("total") },
|
|
506
|
+
{ val: formatTokens(stat.totalInput), lbl: t("input") },
|
|
507
|
+
{ val: formatTokens(stat.totalOutput), lbl: t("output") }
|
|
508
|
+
]}>
|
|
509
|
+
{(item, idx) => {
|
|
510
|
+
const colW = () => {
|
|
511
|
+
const totalW = panelWidth() - 6; // 边框占用 2 列
|
|
512
|
+
const base = Math.floor(totalW / 3);
|
|
513
|
+
return idx() === 2 ? totalW - base * 2 : base;
|
|
514
|
+
};
|
|
515
|
+
return (<box width={colW()} flexDirection="column">
|
|
516
|
+
<text fg={primaryColor()}>{centerAlign(item.val, colW())}</text>
|
|
517
|
+
<text fg={dimColor()}>
|
|
518
|
+
{centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW())}
|
|
519
|
+
</text>
|
|
520
|
+
</box>);
|
|
521
|
+
}}
|
|
522
|
+
</For>
|
|
336
523
|
</box>
|
|
337
|
-
<Show when={!collapse().subBlocks[`pricing-${key}`]}>
|
|
338
|
-
<text fg={mutedColor()}> {t("cost")}:{formatCost(stat.totalCost)}</text>
|
|
339
|
-
<text fg={mutedColor()}> {t("modelLabel")}:{stat.providerID}/{stat.modelID}</text>
|
|
340
|
-
</Show>
|
|
341
524
|
</box>
|
|
342
|
-
|
|
525
|
+
|
|
526
|
+
{/* 缓存进度条 */}
|
|
527
|
+
<text fg={mutedColor()}>
|
|
528
|
+
{paddedCachePrefix()}
|
|
529
|
+
<span style={{ fg: hitRateColor(hitRate) }}>
|
|
530
|
+
{progressFilled(hitRate, modelBarWidth())}{progressRemaining(hitRate, modelBarWidth())}{" "}{hitRate.toFixed(0)}%
|
|
531
|
+
</span>
|
|
532
|
+
{trendStr()
|
|
533
|
+
? <span style={{ fg: trendColor() }}>{trendStr()}</span>
|
|
534
|
+
: null}
|
|
535
|
+
</text>
|
|
536
|
+
|
|
537
|
+
{/* 性能指标 */}
|
|
538
|
+
<Show when={config().sidebar.showPerformance && !!perfStats().models[key]}>
|
|
539
|
+
<text fg={mutedColor()} marginTop={1}>
|
|
540
|
+
{t("ttft")} <span style={{ fg: primaryColor() }}>{formatDuration(perfStats().models[key]?.avgTTFT ?? null)}</span>
|
|
541
|
+
{" "}{t("tps")} <span style={{ fg: primaryColor() }}>{perfStats().models[key]?.avgTPS?.toFixed(1) ?? "—"}</span>
|
|
542
|
+
{" "}{t("lat")} <span style={{ fg: primaryColor() }}>{formatDuration(perfStats().models[key]?.avgLatency ?? null)}</span>
|
|
543
|
+
</text>
|
|
544
|
+
</Show>
|
|
545
|
+
|
|
546
|
+
{/* 成本 */}
|
|
547
|
+
<Show when={config().sidebar.showPricing && stat.totalCost > 0}>
|
|
548
|
+
<text fg={mutedColor()}>{paddedCostPrefix()}{formatCost(stat.totalCost)}</text>
|
|
549
|
+
</Show>
|
|
550
|
+
|
|
551
|
+
</box>
|
|
343
552
|
</Show>
|
|
344
553
|
</box>);
|
|
345
554
|
}}
|
|
346
555
|
</For>
|
|
347
556
|
|
|
557
|
+
{/* ══════════════════════════════════════
|
|
558
|
+
Token 分布区块:左右 space-between 对齐布局 (取消进度条)
|
|
559
|
+
══════════════════════════════════════ */}
|
|
348
560
|
<Show when={config().sidebar.showTokenDistribution}>
|
|
349
|
-
<box flexDirection="column">
|
|
350
|
-
|
|
351
|
-
|
|
561
|
+
<box flexDirection="column" marginTop={1}>
|
|
562
|
+
|
|
563
|
+
{/* 分隔线 */}
|
|
564
|
+
<text fg={borderColor()}>{divider()}</text>
|
|
565
|
+
|
|
566
|
+
{/* Header */}
|
|
567
|
+
<box flexDirection="row" onMouseDown={() => toggle.sub("token-dist")} paddingX={1}>
|
|
568
|
+
<text fg={greenColor()}>
|
|
569
|
+
{!collapse().subBlocks["token-dist"] ? "▾" : "▶"} {t("tokenDistribution")}
|
|
570
|
+
</text>
|
|
352
571
|
</box>
|
|
572
|
+
|
|
353
573
|
<Show when={!collapse().subBlocks["token-dist"]}>
|
|
354
|
-
<
|
|
355
|
-
{([
|
|
356
|
-
|
|
574
|
+
<box flexDirection="column" paddingX={1} marginTop={1}>
|
|
575
|
+
<For each={Object.entries(tokenDistribution()).filter(([_, val]) => val > 0)}>
|
|
576
|
+
{([role, val]) => (<box flexDirection="row" justifyContent="space-between">
|
|
577
|
+
<box flexDirection="row">
|
|
578
|
+
<text fg={distRoleColor(role)}>█ </text>
|
|
579
|
+
<text fg={mutedColor()}>{t(role)}</text>
|
|
580
|
+
</box>
|
|
581
|
+
<text fg={mutedColor()}>{formatTokens(val)}</text>
|
|
582
|
+
</box>)}
|
|
583
|
+
</For>
|
|
584
|
+
</box>
|
|
357
585
|
</Show>
|
|
358
586
|
</box>
|
|
359
587
|
</Show>
|
|
588
|
+
|
|
360
589
|
</Show>
|
|
361
590
|
</box>);
|
|
362
591
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stats-store.ts — 持久化聚合统计存储
|
|
3
|
+
*
|
|
4
|
+
* 设计目标:将性能指标的"聚合统计"与"原始 JSONL 日志"彻底解耦。
|
|
5
|
+
* - 每次请求完成时,通过 updatePersistedStats() 增量写入 JSON 统计文件
|
|
6
|
+
* - 统计文件永久累积,不受 JSONL 日志轮转/窗口限制影响
|
|
7
|
+
* - 百分位数采用 Reservoir Sampling 保持有界内存占用
|
|
8
|
+
* - 首次启动时自动从现有 JSONL 日志迁移,不丢失历史数据
|
|
9
|
+
*/
|
|
10
|
+
import type { LogEntry, ModelPerfStats } from "./formatter.js";
|
|
11
|
+
/**
|
|
12
|
+
* 将一条新的日志条目增量更新到持久化统计文件。
|
|
13
|
+
* 在 perf-tracker.ts 的 appendLog() 之后调用。
|
|
14
|
+
*
|
|
15
|
+
* 设计原则:本函数只做增量更新,迁移逻辑由 readPersistedStats() 负责。
|
|
16
|
+
* 这样可以避免迁移与增量更新之间的竞态问题。
|
|
17
|
+
*/
|
|
18
|
+
export declare function updatePersistedStats(entry: LogEntry): void;
|
|
19
|
+
/**
|
|
20
|
+
* 读取所有持久化统计,返回 ModelPerfStats 数组(含分位数)。
|
|
21
|
+
* 用于 HTML 报告生成,替代 aggregatePerfStats(readLogs(N)) 的有限窗口方案。
|
|
22
|
+
*/
|
|
23
|
+
export declare function readPersistedStats(): ModelPerfStats[];
|