opencode-tokenwatch 0.3.0 → 0.3.2
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 +21 -11
- package/README.md +21 -11
- package/dist/commands.jsx +14 -2
- package/dist/generate-usage-html.js +41 -21
- package/dist/i18n.js +2 -0
- package/dist/perf-tracker.d.ts +1 -0
- package/dist/perf-tracker.js +42 -0
- package/dist/sidebar.d.ts +2 -2
- package/dist/sidebar.jsx +323 -114
- package/dist/stats-store.d.ts +23 -0
- package/dist/stats-store.js +258 -0
- package/dist/tui.jsx +81 -26
- 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,30 +135,43 @@ 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
|
-
|
|
152
|
+
const msgs = props.allTokenMessages();
|
|
153
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
154
|
+
const msg = msgs[i];
|
|
101
155
|
const key = `${msg.providerID}/${msg.modelID}`;
|
|
102
156
|
let e = map.get(key);
|
|
103
157
|
if (!e) {
|
|
104
|
-
|
|
105
|
-
e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0 };
|
|
158
|
+
e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0, lastMessageIndex: -1 };
|
|
106
159
|
map.set(key, e);
|
|
107
160
|
}
|
|
108
161
|
e.totalInput += msg.inputTokens;
|
|
109
162
|
e.totalOutput += msg.outputTokens;
|
|
110
|
-
e.totalReasoning += msg.reasoningTokens;
|
|
163
|
+
e.totalReasoning += msg.reasoningTokens;
|
|
111
164
|
e.cacheRead += msg.cacheRead;
|
|
112
165
|
e.cacheWrite += msg.cacheWrite;
|
|
113
166
|
e.totalCost += msg.cost;
|
|
114
167
|
e.requestCount++;
|
|
168
|
+
e.lastMessageIndex = i; // 追踪该模型最后一条消息的索引(越大 = 越近调用)
|
|
115
169
|
}
|
|
116
|
-
return Array.from(map.entries())
|
|
170
|
+
return Array.from(map.entries())
|
|
171
|
+
// 过滤掉全零无效模型(如会话失败导致 token 全为 0 的记录)
|
|
172
|
+
.filter(([, s]) => s.totalInput + s.totalOutput + s.totalReasoning + s.cacheRead + s.cacheWrite > 0)
|
|
173
|
+
// 按最近调用时间降序:最后一条消息在数组中索引越大越靠前
|
|
174
|
+
.sort((a, b) => b[1].lastMessageIndex - a[1].lastMessageIndex);
|
|
117
175
|
});
|
|
118
176
|
const sessionTotals = createMemo(() => {
|
|
119
177
|
let i = 0, o = 0, ir = 0, cr = 0, cw = 0, r = 0, c = 0;
|
|
@@ -126,16 +184,19 @@ export function TokenWatchPanel(props) {
|
|
|
126
184
|
r += s.requestCount;
|
|
127
185
|
c += s.totalCost;
|
|
128
186
|
}
|
|
129
|
-
// Bug fix: totalTokens 改为 5 分量(含 reasoning),与官方一致
|
|
130
187
|
return { totalInput: i, totalOutput: o, totalReasoning: ir, totalCacheRead: cr, totalCacheWrite: cw, totalRequests: r, totalCost: c, totalTokens: i + o + ir + cr + cw };
|
|
131
188
|
});
|
|
189
|
+
const globalHitRate = createMemo(() => {
|
|
190
|
+
const denom = sessionTotals().totalInput + sessionTotals().totalCacheRead;
|
|
191
|
+
return denom > 0 ? (sessionTotals().totalCacheRead / denom) * 100 : -1;
|
|
192
|
+
});
|
|
132
193
|
const modelHitRate = createMemo(() => {
|
|
133
194
|
return modelStats().map(([key, stat]) => {
|
|
134
195
|
const denom = stat.totalInput + stat.cacheRead;
|
|
135
196
|
if (denom === 0)
|
|
136
197
|
return { key, rate: 0, msgs: [] };
|
|
137
198
|
const msgs = [];
|
|
138
|
-
for (const msg of props.allTokenMessages) {
|
|
199
|
+
for (const msg of props.allTokenMessages()) {
|
|
139
200
|
const pk = `${msg.providerID}/${msg.modelID}`;
|
|
140
201
|
if (pk !== key)
|
|
141
202
|
continue;
|
|
@@ -151,10 +212,8 @@ export function TokenWatchPanel(props) {
|
|
|
151
212
|
const sumSlice = (start, end) => {
|
|
152
213
|
let sumCache = 0, sumTotal = 0;
|
|
153
214
|
for (let i = start; i < end && i < msgs.length; i++) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
sumCache += cache;
|
|
157
|
-
sumTotal += input + cache;
|
|
215
|
+
sumCache += msgs[i].cacheRead;
|
|
216
|
+
sumTotal += msgs[i].inputTokens + msgs[i].cacheRead;
|
|
158
217
|
}
|
|
159
218
|
return { sumCache, sumTotal };
|
|
160
219
|
};
|
|
@@ -167,7 +226,13 @@ export function TokenWatchPanel(props) {
|
|
|
167
226
|
});
|
|
168
227
|
});
|
|
169
228
|
const [partVersion, setPartVersion] = createSignal(0);
|
|
229
|
+
const perfStats = createMemo(() => {
|
|
230
|
+
void props.allTokenMessages();
|
|
231
|
+
void partVersion();
|
|
232
|
+
return perfTracker.getSessionStats();
|
|
233
|
+
});
|
|
170
234
|
const tokenDistribution = createMemo(() => {
|
|
235
|
+
void props.allTokenMessages();
|
|
171
236
|
void partVersion();
|
|
172
237
|
const dist = {};
|
|
173
238
|
try {
|
|
@@ -213,6 +278,7 @@ export function TokenWatchPanel(props) {
|
|
|
213
278
|
catch {
|
|
214
279
|
continue;
|
|
215
280
|
}
|
|
281
|
+
let msgEstimatedOutput = 0;
|
|
216
282
|
for (const p of parts) {
|
|
217
283
|
if (p.type === "tool") {
|
|
218
284
|
let rawInput = "";
|
|
@@ -235,161 +301,304 @@ export function TokenWatchPanel(props) {
|
|
|
235
301
|
}
|
|
236
302
|
}
|
|
237
303
|
else if (p.type === "text" && p.text) {
|
|
238
|
-
|
|
239
|
-
// 此处用 estimateTokens 估算,最终 dist.output 会被下方真实 tokens.output 覆盖
|
|
240
|
-
dist.output = (dist.output ?? 0) + estimateTokens(p.text);
|
|
304
|
+
msgEstimatedOutput += estimateTokens(p.text);
|
|
241
305
|
}
|
|
242
306
|
else if (p.type === "reasoning") {
|
|
243
|
-
|
|
307
|
+
msgEstimatedOutput += estimateTokens(p.text ?? "");
|
|
244
308
|
}
|
|
245
309
|
else if (p.type === "subtask") {
|
|
246
|
-
|
|
310
|
+
msgEstimatedOutput += estimateTokens(p.prompt || p.description || "");
|
|
247
311
|
}
|
|
248
312
|
}
|
|
249
313
|
const tokens = msg.tokens;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
314
|
+
if (tokens?.output !== undefined || tokens?.reasoning !== undefined) {
|
|
315
|
+
dist.output = (dist.output ?? 0) + (tokens?.output ?? 0) + (tokens?.reasoning ?? 0);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
dist.output = (dist.output ?? 0) + msgEstimatedOutput;
|
|
319
|
+
}
|
|
253
320
|
}
|
|
254
321
|
}
|
|
255
|
-
// Bug fix: 添加 other 兜底桶(参考官方 session-context-breakdown.ts)
|
|
256
|
-
// 使用真实 input token 数减去各桶估算值,确保分布总和对齐
|
|
257
322
|
const realInput = sessionTotals().totalInput;
|
|
258
323
|
if (realInput > 0) {
|
|
259
324
|
const estimated = (dist.system ?? 0) + (dist.user ?? 0)
|
|
260
|
-
+ (dist.
|
|
325
|
+
+ (dist.toolCall ?? 0) + (dist.toolResult ?? 0);
|
|
261
326
|
const other = realInput - estimated;
|
|
262
|
-
// 只在差值超过 50 token 时展示,避免估算误差噪音
|
|
263
327
|
if (other > 50)
|
|
264
328
|
dist.other = other;
|
|
265
329
|
}
|
|
266
330
|
return dist;
|
|
267
331
|
});
|
|
332
|
+
// ── 折叠状态 toggle ──
|
|
268
333
|
const toggle = {
|
|
269
334
|
global: () => setCollapse(p => { const n = { ...p, global: !p.global }; saveCollapseState(api, n); return n; }),
|
|
270
335
|
model: (k) => setCollapse(p => { const n = { ...p, models: { ...p.models, [k]: !p.models[k] } }; saveCollapseState(api, n); return n; }),
|
|
271
336
|
sub: (k) => setCollapse(p => { const n = { ...p, subBlocks: { ...p.subBlocks, [k]: !p.subBlocks[k] } }; saveCollapseState(api, n); return n; }),
|
|
272
337
|
};
|
|
273
338
|
onMount(() => {
|
|
274
|
-
// Risk fix: 移除 message.updated 的重复订阅。
|
|
275
|
-
// tui.tsx 已通过 setSidebarRevision() 驱动 sidebar 整体重渲,
|
|
276
|
-
// sidebar 内部只需订阅 part.updated 来触发 tokenDistribution 重算。
|
|
277
339
|
const unsubPart = api.event?.on?.("message.part.updated", () => setPartVersion(v => v + 1));
|
|
278
340
|
onCleanup(() => { try {
|
|
279
341
|
unsubPart?.();
|
|
280
342
|
}
|
|
281
343
|
catch { } });
|
|
282
344
|
});
|
|
283
|
-
|
|
284
|
-
|
|
345
|
+
// ── 宽度派生值 ──
|
|
346
|
+
// innerWidth:边框内可用列数 = panelWidth - 2(左右边框各1格)
|
|
347
|
+
// barWidth:进度条宽 = innerWidth - paddingX(1*2) - "Cache: "(7) - " XX%"(4) - "↑X.X%"(最多6) = innerWidth - 19
|
|
348
|
+
// 分隔线:innerWidth - paddingX(1*2) = innerWidth - 2
|
|
349
|
+
const innerWidth = () => panelWidth() - 2;
|
|
350
|
+
const barWidth = () => Math.max(8, innerWidth() - 19);
|
|
351
|
+
const divider = () => {
|
|
352
|
+
const w = innerWidth();
|
|
353
|
+
if (w <= 2)
|
|
354
|
+
return "─".repeat(w);
|
|
355
|
+
return " " + "─".repeat(w - 2) + " ";
|
|
356
|
+
};
|
|
357
|
+
return (<box
|
|
358
|
+
// ── 不设置固定 width,让外层容器决定宽度 ──
|
|
359
|
+
// ref + onSizeChange:布局完成后获取真实宽度,用于内部字符宽度计算
|
|
360
|
+
ref={(el) => { outerBoxRef = el; }} onSizeChange={() => {
|
|
361
|
+
if (outerBoxRef)
|
|
362
|
+
setPanelWidth(outerBoxRef.width);
|
|
363
|
+
}} flexDirection="column" border={true} borderStyle="rounded" borderColor={borderColor()}>
|
|
364
|
+
|
|
365
|
+
{/* ══════════════════════════════════════
|
|
366
|
+
面板 Header:▾ TokenWatch 89.1% hit
|
|
367
|
+
justifyContent="space-between" 左右分布
|
|
368
|
+
══════════════════════════════════════ */}
|
|
369
|
+
<box flexDirection="row" justifyContent="space-between" onMouseDown={toggle.global} paddingX={1}>
|
|
285
370
|
<text fg={primaryColor()}>
|
|
286
|
-
{collapse().global ? "▶" : "
|
|
287
|
-
|
|
371
|
+
{collapse().global ? "▶" : "▾"} {t("panelTitle")}
|
|
372
|
+
</text>
|
|
373
|
+
<text fg={mutedColor()}>
|
|
374
|
+
{collapse().global ? (<>
|
|
375
|
+
{formatTokens(sessionTotals().totalTokens)}
|
|
376
|
+
{globalHitRate() >= 0 ? (<span style={{ fg: hitRateColor(globalHitRate()) }}>
|
|
377
|
+
{` (${globalHitRate().toFixed(1)}% hit)`}
|
|
378
|
+
</span>) : ""}
|
|
379
|
+
</>) : (globalHitRate() >= 0 ? (<span style={{ fg: hitRateColor(globalHitRate()) }}>
|
|
380
|
+
{`${globalHitRate().toFixed(1)}% hit`}
|
|
381
|
+
</span>) : "")}
|
|
288
382
|
</text>
|
|
289
383
|
</box>
|
|
290
384
|
|
|
291
385
|
<Show when={!collapse().global}>
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
</text>
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
<
|
|
300
|
-
<
|
|
301
|
-
{t("
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
386
|
+
|
|
387
|
+
{/* 标题下分隔线 */}
|
|
388
|
+
<text fg={borderColor()}>{divider()}</text>
|
|
389
|
+
|
|
390
|
+
{/* ══════════════════════════════════════
|
|
391
|
+
全局统计:Total / Req / Input / Output 均匀分行排布
|
|
392
|
+
══════════════════════════════════════ */}
|
|
393
|
+
<box flexDirection="row" paddingX={1}>
|
|
394
|
+
<For each={[
|
|
395
|
+
{ val: formatTokens(sessionTotals().totalTokens), lbl: t("total") },
|
|
396
|
+
{ val: sessionTotals().totalRequests.toString(), lbl: t("requests") },
|
|
397
|
+
{ val: formatTokens(sessionTotals().totalInput), lbl: t("input") },
|
|
398
|
+
{ val: formatTokens(sessionTotals().totalOutput), lbl: t("output") }
|
|
399
|
+
]}>
|
|
400
|
+
{(item, idx) => {
|
|
401
|
+
const colW = () => {
|
|
402
|
+
const totalW = panelWidth() - 4;
|
|
403
|
+
const base = Math.floor(totalW / 4);
|
|
404
|
+
return idx() === 3 ? totalW - base * 3 : base;
|
|
405
|
+
};
|
|
406
|
+
return (<box width={colW()} flexDirection="column">
|
|
407
|
+
<text fg={primaryColor()}>{centerAlign(item.val, colW())}</text>
|
|
408
|
+
<text fg={dimColor()}>
|
|
409
|
+
{centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW())}
|
|
410
|
+
</text>
|
|
411
|
+
</box>);
|
|
412
|
+
}}
|
|
413
|
+
</For>
|
|
414
|
+
</box>
|
|
415
|
+
|
|
416
|
+
{/* 成本展示 */}
|
|
417
|
+
<Show when={config().sidebar.showPricing && sessionTotals().totalCost > 0}>
|
|
418
|
+
<box flexDirection="row" justifyContent="center" marginTop={1}>
|
|
419
|
+
<text fg={mutedColor()}>
|
|
420
|
+
{t("cost")}:{" "}
|
|
421
|
+
<span style={{ fg: greenColor() }}>
|
|
422
|
+
{formatCost(sessionTotals().totalCost)}
|
|
423
|
+
</span>
|
|
424
|
+
</text>
|
|
425
|
+
</box>
|
|
312
426
|
</Show>
|
|
313
427
|
|
|
428
|
+
{/* ══════════════════════════════════════
|
|
429
|
+
各模型块:无分隔线,用 marginTop=1 隔开
|
|
430
|
+
══════════════════════════════════════ */}
|
|
314
431
|
<For each={modelStats()}>
|
|
315
432
|
{([key, stat]) => {
|
|
316
|
-
const
|
|
317
|
-
const
|
|
318
|
-
const hitRate =
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
433
|
+
const isExpanded = () => collapse().models[key] !== true;
|
|
434
|
+
const hitDenom = stat.totalInput + stat.cacheRead;
|
|
435
|
+
const hitRate = hitDenom > 0 ? (stat.cacheRead / hitDenom) * 100 : 0;
|
|
436
|
+
const trendStr = () => {
|
|
437
|
+
if (!config().sidebar.showTrend)
|
|
438
|
+
return "";
|
|
439
|
+
const td = modelTrend().find(h => h.key === key);
|
|
440
|
+
if (!td?.trend || td.trend === 0)
|
|
441
|
+
return "";
|
|
442
|
+
return td.trend > 0
|
|
443
|
+
? ` ${t("trendUp")}${td.trend.toFixed(1)}%`
|
|
444
|
+
: ` ${t("trendDown")}${Math.abs(td.trend).toFixed(1)}%`;
|
|
445
|
+
};
|
|
446
|
+
const trendColor = () => {
|
|
447
|
+
const td = modelTrend().find(h => h.key === key);
|
|
448
|
+
return (td?.trend ?? 0) >= 0
|
|
449
|
+
? RGBA.fromInts(63, 185, 80, 255)
|
|
450
|
+
: RGBA.fromInts(244, 67, 54, 255);
|
|
451
|
+
};
|
|
452
|
+
// 供应商名称截断:超过12字符则省略
|
|
453
|
+
const MAX_PROVIDER_LEN = 12;
|
|
454
|
+
let providerDisplay = stat.providerID;
|
|
455
|
+
if (providerDisplay.length > MAX_PROVIDER_LEN) {
|
|
456
|
+
providerDisplay = providerDisplay.slice(0, MAX_PROVIDER_LEN - 1) + "…";
|
|
457
|
+
}
|
|
458
|
+
let fullTitle = `${providerDisplay}/${stat.modelID}`;
|
|
459
|
+
// 去除中间段:格式为 厂商/模型厂商/模型名称 时只留 provider/modelname
|
|
460
|
+
if (fullTitle.length > 22) {
|
|
461
|
+
const parts = fullTitle.split("/");
|
|
462
|
+
if (parts.length >= 3) {
|
|
463
|
+
fullTitle = `${parts[0]}/${parts[parts.length - 1]}`;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// 模型名截断:内容宽 - paddingX(2) - "● "(2) - " ×NNN ▾"(最多8) = innerWidth - 12
|
|
467
|
+
const maxNameLen = Math.max(8, innerWidth() - 12);
|
|
468
|
+
const shortTitle = fullTitle.length > maxNameLen
|
|
469
|
+
? fullTitle.slice(0, maxNameLen - 1) + "…"
|
|
470
|
+
: fullTitle;
|
|
471
|
+
// 折叠:右侧显示总 token;展开:右侧显示请求数
|
|
472
|
+
const modelHeaderRight = () => {
|
|
473
|
+
if (!isExpanded()) {
|
|
474
|
+
const total = stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite;
|
|
475
|
+
return `${formatTokens(total)} ▶`;
|
|
476
|
+
}
|
|
477
|
+
return `×${stat.requestCount} ▾`;
|
|
478
|
+
};
|
|
479
|
+
// 计算模型总 tokens
|
|
480
|
+
const modelTotalTokens = stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite;
|
|
481
|
+
// 计算对齐标签 (使用 getter 以保持响应式切换)
|
|
482
|
+
const targetW = () => {
|
|
483
|
+
const cacheLabel = t("cache") + ":";
|
|
484
|
+
const costLabel = t("cost") + ":";
|
|
485
|
+
return Math.max(getVisualWidth(cacheLabel), getVisualWidth(costLabel));
|
|
486
|
+
};
|
|
487
|
+
const paddedCachePrefix = () => {
|
|
488
|
+
const label = t("cache") + ":";
|
|
489
|
+
return label + " ".repeat(targetW() - getVisualWidth(label));
|
|
490
|
+
};
|
|
491
|
+
const paddedCostPrefix = () => {
|
|
492
|
+
const label = t("cost") + ":";
|
|
493
|
+
return label + " ".repeat(targetW() - getVisualWidth(label));
|
|
494
|
+
};
|
|
495
|
+
// 缓存进度条宽度:可用宽度 panelWidth() - 4 减去前缀 targetW(),减去百分比(4),减去趋势(6)
|
|
496
|
+
const modelBarWidth = () => Math.max(8, (panelWidth() - 4) - targetW() - 11);
|
|
497
|
+
return (
|
|
498
|
+
// marginTop=1 提供模型间视觉间距(TUI最小单位为1行)
|
|
499
|
+
<box flexDirection="column" marginTop={1}>
|
|
500
|
+
|
|
501
|
+
{/* 模型 Header:左侧 ● 名称,右侧 统计+箭头 */}
|
|
502
|
+
<box flexDirection="row" justifyContent="space-between" onMouseDown={() => toggle.model(key)} paddingX={1}>
|
|
346
503
|
<text fg={mutedColor()}>
|
|
347
|
-
{
|
|
504
|
+
<span style={{ fg: hitRateColor(hitRate) }}>●</span>
|
|
505
|
+
{" "}
|
|
506
|
+
<span style={{ fg: primaryColor() }}>{shortTitle}</span>
|
|
348
507
|
</text>
|
|
349
|
-
|
|
508
|
+
<text fg={mutedColor()}>{modelHeaderRight()}</text>
|
|
509
|
+
</box>
|
|
350
510
|
|
|
351
|
-
<Show when={
|
|
352
|
-
<
|
|
353
|
-
<box flexDirection="column">
|
|
354
|
-
<box onMouseDown={() => toggle.sub(`perf-${key}`)}>
|
|
355
|
-
<text fg={textColor()}>{!collapse().subBlocks[`perf-${key}`] ? "▼" : "▶"} ── {t("performance")} ──</text>
|
|
356
|
-
</box>
|
|
357
|
-
<Show when={!collapse().subBlocks[`perf-${key}`]}>
|
|
358
|
-
<text fg={mutedColor()}>
|
|
359
|
-
{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)}
|
|
360
|
-
</text>
|
|
361
|
-
</Show>
|
|
362
|
-
</box>
|
|
363
|
-
</Show>
|
|
511
|
+
<Show when={isExpanded()}>
|
|
512
|
+
<box flexDirection="column" paddingX={1}>
|
|
364
513
|
|
|
365
|
-
|
|
366
|
-
<box flexDirection="column">
|
|
367
|
-
<box
|
|
368
|
-
<
|
|
514
|
+
{/* 模型指标三列网格,外带圆角边框 (取消上下间距) */}
|
|
515
|
+
<box flexDirection="column" border={true} borderStyle="rounded" borderColor={borderColor()}>
|
|
516
|
+
<box flexDirection="row">
|
|
517
|
+
<For each={[
|
|
518
|
+
{ val: formatTokens(modelTotalTokens), lbl: t("total") },
|
|
519
|
+
{ val: formatTokens(stat.totalInput), lbl: t("input") },
|
|
520
|
+
{ val: formatTokens(stat.totalOutput), lbl: t("output") }
|
|
521
|
+
]}>
|
|
522
|
+
{(item, idx) => {
|
|
523
|
+
const colW = () => {
|
|
524
|
+
const totalW = panelWidth() - 6; // 边框占用 2 列
|
|
525
|
+
const base = Math.floor(totalW / 3);
|
|
526
|
+
return idx() === 2 ? totalW - base * 2 : base;
|
|
527
|
+
};
|
|
528
|
+
return (<box width={colW()} flexDirection="column">
|
|
529
|
+
<text fg={primaryColor()}>{centerAlign(item.val, colW())}</text>
|
|
530
|
+
<text fg={dimColor()}>
|
|
531
|
+
{centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW())}
|
|
532
|
+
</text>
|
|
533
|
+
</box>);
|
|
534
|
+
}}
|
|
535
|
+
</For>
|
|
369
536
|
</box>
|
|
370
|
-
<Show when={!collapse().subBlocks[`pricing-${key}`]}>
|
|
371
|
-
<text fg={mutedColor()}> {t("cost")}:{formatCost(stat.totalCost)}</text>
|
|
372
|
-
<text fg={mutedColor()}> {t("modelLabel")}:{stat.providerID}/{stat.modelID}</text>
|
|
373
|
-
</Show>
|
|
374
537
|
</box>
|
|
375
|
-
|
|
538
|
+
|
|
539
|
+
{/* 缓存进度条 */}
|
|
540
|
+
<text fg={mutedColor()}>
|
|
541
|
+
{paddedCachePrefix()}
|
|
542
|
+
<span style={{ fg: hitRateColor(hitRate) }}>
|
|
543
|
+
{progressFilled(hitRate, modelBarWidth())}{progressRemaining(hitRate, modelBarWidth())}{" "}{hitRate.toFixed(0)}%
|
|
544
|
+
</span>
|
|
545
|
+
{trendStr()
|
|
546
|
+
? <span style={{ fg: trendColor() }}>{trendStr()}</span>
|
|
547
|
+
: null}
|
|
548
|
+
</text>
|
|
549
|
+
|
|
550
|
+
{/* 性能指标 */}
|
|
551
|
+
<Show when={config().sidebar.showPerformance && !!perfStats().models[key]}>
|
|
552
|
+
<text fg={mutedColor()} marginTop={1}>
|
|
553
|
+
{t("ttft")} <span style={{ fg: primaryColor() }}>{formatDuration(perfStats().models[key]?.avgTTFT ?? null)}</span>
|
|
554
|
+
{" "}{t("tps")} <span style={{ fg: primaryColor() }}>{perfStats().models[key]?.avgTPS?.toFixed(1) ?? "—"}</span>
|
|
555
|
+
{" "}{t("lat")} <span style={{ fg: primaryColor() }}>{formatDuration(perfStats().models[key]?.avgLatency ?? null)}</span>
|
|
556
|
+
</text>
|
|
557
|
+
</Show>
|
|
558
|
+
|
|
559
|
+
{/* 成本 */}
|
|
560
|
+
<Show when={config().sidebar.showPricing && stat.totalCost > 0}>
|
|
561
|
+
<text fg={mutedColor()}>{paddedCostPrefix()}{formatCost(stat.totalCost)}</text>
|
|
562
|
+
</Show>
|
|
563
|
+
|
|
564
|
+
</box>
|
|
376
565
|
</Show>
|
|
377
566
|
</box>);
|
|
378
567
|
}}
|
|
379
568
|
</For>
|
|
380
569
|
|
|
570
|
+
{/* ══════════════════════════════════════
|
|
571
|
+
Token 分布区块:左右 space-between 对齐布局 (取消进度条)
|
|
572
|
+
══════════════════════════════════════ */}
|
|
381
573
|
<Show when={config().sidebar.showTokenDistribution}>
|
|
382
|
-
<box flexDirection="column">
|
|
383
|
-
|
|
384
|
-
|
|
574
|
+
<box flexDirection="column" marginTop={1}>
|
|
575
|
+
|
|
576
|
+
{/* 分隔线 */}
|
|
577
|
+
<text fg={borderColor()}>{divider()}</text>
|
|
578
|
+
|
|
579
|
+
{/* Header */}
|
|
580
|
+
<box flexDirection="row" onMouseDown={() => toggle.sub("token-dist")} paddingX={1}>
|
|
581
|
+
<text fg={greenColor()}>
|
|
582
|
+
{!collapse().subBlocks["token-dist"] ? "▾" : "▶"} {t("tokenDistribution")}
|
|
583
|
+
</text>
|
|
385
584
|
</box>
|
|
585
|
+
|
|
386
586
|
<Show when={!collapse().subBlocks["token-dist"]}>
|
|
387
|
-
<
|
|
388
|
-
{([
|
|
389
|
-
|
|
587
|
+
<box flexDirection="column" paddingX={1} marginTop={1}>
|
|
588
|
+
<For each={Object.entries(tokenDistribution()).filter(([_, val]) => val > 0)}>
|
|
589
|
+
{([role, val]) => (<box flexDirection="row" justifyContent="space-between">
|
|
590
|
+
<box flexDirection="row">
|
|
591
|
+
<text fg={distRoleColor(role)}>█ </text>
|
|
592
|
+
<text fg={mutedColor()}>{t(role)}</text>
|
|
593
|
+
</box>
|
|
594
|
+
<text fg={mutedColor()}>{formatTokens(val)}</text>
|
|
595
|
+
</box>)}
|
|
596
|
+
</For>
|
|
597
|
+
</box>
|
|
390
598
|
</Show>
|
|
391
599
|
</box>
|
|
392
600
|
</Show>
|
|
601
|
+
|
|
393
602
|
</Show>
|
|
394
603
|
</box>);
|
|
395
604
|
}
|
|
@@ -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[];
|