opencode-tokenwatch 0.3.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/LICENSE +21 -21
- package/README.en.md +104 -97
- package/README.md +104 -97
- package/dist/commands.js +208 -0
- package/dist/commands.jsx +11 -2
- package/dist/generate-usage-html.js +719 -707
- package/dist/i18n.js +2 -0
- package/dist/perf-tracker.d.ts +1 -0
- package/dist/perf-tracker.js +37 -0
- package/dist/queries.js +101 -101
- package/dist/sidebar.d.ts +2 -2
- package/dist/sidebar.jsx +333 -137
- package/dist/stats-store.d.ts +23 -0
- package/dist/stats-store.js +258 -0
- package/dist/tui.jsx +80 -26
- package/package.json +63 -63
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,24 +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
|
-
// Bug fix: 初始化时加入 totalReasoning 字段
|
|
105
156
|
e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0 };
|
|
106
157
|
map.set(key, e);
|
|
107
158
|
}
|
|
108
159
|
e.totalInput += msg.inputTokens;
|
|
109
160
|
e.totalOutput += msg.outputTokens;
|
|
110
|
-
e.totalReasoning += msg.reasoningTokens;
|
|
161
|
+
e.totalReasoning += msg.reasoningTokens;
|
|
111
162
|
e.cacheRead += msg.cacheRead;
|
|
112
163
|
e.cacheWrite += msg.cacheWrite;
|
|
113
164
|
e.totalCost += msg.cost;
|
|
@@ -126,16 +177,19 @@ export function TokenWatchPanel(props) {
|
|
|
126
177
|
r += s.requestCount;
|
|
127
178
|
c += s.totalCost;
|
|
128
179
|
}
|
|
129
|
-
// Bug fix: totalTokens 改为 5 分量(含 reasoning),与官方一致
|
|
130
180
|
return { totalInput: i, totalOutput: o, totalReasoning: ir, totalCacheRead: cr, totalCacheWrite: cw, totalRequests: r, totalCost: c, totalTokens: i + o + ir + cr + cw };
|
|
131
181
|
});
|
|
182
|
+
const globalHitRate = createMemo(() => {
|
|
183
|
+
const denom = sessionTotals().totalInput + sessionTotals().totalCacheRead;
|
|
184
|
+
return denom > 0 ? (sessionTotals().totalCacheRead / denom) * 100 : -1;
|
|
185
|
+
});
|
|
132
186
|
const modelHitRate = createMemo(() => {
|
|
133
187
|
return modelStats().map(([key, stat]) => {
|
|
134
188
|
const denom = stat.totalInput + stat.cacheRead;
|
|
135
189
|
if (denom === 0)
|
|
136
190
|
return { key, rate: 0, msgs: [] };
|
|
137
191
|
const msgs = [];
|
|
138
|
-
for (const msg of props.allTokenMessages) {
|
|
192
|
+
for (const msg of props.allTokenMessages()) {
|
|
139
193
|
const pk = `${msg.providerID}/${msg.modelID}`;
|
|
140
194
|
if (pk !== key)
|
|
141
195
|
continue;
|
|
@@ -151,10 +205,8 @@ export function TokenWatchPanel(props) {
|
|
|
151
205
|
const sumSlice = (start, end) => {
|
|
152
206
|
let sumCache = 0, sumTotal = 0;
|
|
153
207
|
for (let i = start; i < end && i < msgs.length; i++) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
sumCache += cache;
|
|
157
|
-
sumTotal += input + cache;
|
|
208
|
+
sumCache += msgs[i].cacheRead;
|
|
209
|
+
sumTotal += msgs[i].inputTokens + msgs[i].cacheRead;
|
|
158
210
|
}
|
|
159
211
|
return { sumCache, sumTotal };
|
|
160
212
|
};
|
|
@@ -167,7 +219,13 @@ export function TokenWatchPanel(props) {
|
|
|
167
219
|
});
|
|
168
220
|
});
|
|
169
221
|
const [partVersion, setPartVersion] = createSignal(0);
|
|
222
|
+
const perfStats = createMemo(() => {
|
|
223
|
+
void props.allTokenMessages();
|
|
224
|
+
void partVersion();
|
|
225
|
+
return perfTracker.getSessionStats();
|
|
226
|
+
});
|
|
170
227
|
const tokenDistribution = createMemo(() => {
|
|
228
|
+
void props.allTokenMessages();
|
|
171
229
|
void partVersion();
|
|
172
230
|
const dist = {};
|
|
173
231
|
try {
|
|
@@ -213,6 +271,7 @@ export function TokenWatchPanel(props) {
|
|
|
213
271
|
catch {
|
|
214
272
|
continue;
|
|
215
273
|
}
|
|
274
|
+
let msgEstimatedOutput = 0;
|
|
216
275
|
for (const p of parts) {
|
|
217
276
|
if (p.type === "tool") {
|
|
218
277
|
let rawInput = "";
|
|
@@ -235,161 +294,298 @@ export function TokenWatchPanel(props) {
|
|
|
235
294
|
}
|
|
236
295
|
}
|
|
237
296
|
else if (p.type === "text" && p.text) {
|
|
238
|
-
|
|
239
|
-
// 此处用 estimateTokens 估算,最终 dist.output 会被下方真实 tokens.output 覆盖
|
|
240
|
-
dist.output = (dist.output ?? 0) + estimateTokens(p.text);
|
|
297
|
+
msgEstimatedOutput += estimateTokens(p.text);
|
|
241
298
|
}
|
|
242
299
|
else if (p.type === "reasoning") {
|
|
243
|
-
|
|
300
|
+
msgEstimatedOutput += estimateTokens(p.text ?? "");
|
|
244
301
|
}
|
|
245
302
|
else if (p.type === "subtask") {
|
|
246
|
-
|
|
303
|
+
msgEstimatedOutput += estimateTokens(p.prompt || p.description || "");
|
|
247
304
|
}
|
|
248
305
|
}
|
|
249
306
|
const tokens = msg.tokens;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
}
|
|
253
313
|
}
|
|
254
314
|
}
|
|
255
|
-
// Bug fix: 添加 other 兜底桶(参考官方 session-context-breakdown.ts)
|
|
256
|
-
// 使用真实 input token 数减去各桶估算值,确保分布总和对齐
|
|
257
315
|
const realInput = sessionTotals().totalInput;
|
|
258
316
|
if (realInput > 0) {
|
|
259
317
|
const estimated = (dist.system ?? 0) + (dist.user ?? 0)
|
|
260
|
-
+ (dist.
|
|
318
|
+
+ (dist.toolCall ?? 0) + (dist.toolResult ?? 0);
|
|
261
319
|
const other = realInput - estimated;
|
|
262
|
-
// 只在差值超过 50 token 时展示,避免估算误差噪音
|
|
263
320
|
if (other > 50)
|
|
264
321
|
dist.other = other;
|
|
265
322
|
}
|
|
266
323
|
return dist;
|
|
267
324
|
});
|
|
325
|
+
// ── 折叠状态 toggle ──
|
|
268
326
|
const toggle = {
|
|
269
327
|
global: () => setCollapse(p => { const n = { ...p, global: !p.global }; saveCollapseState(api, n); return n; }),
|
|
270
328
|
model: (k) => setCollapse(p => { const n = { ...p, models: { ...p.models, [k]: !p.models[k] } }; saveCollapseState(api, n); return n; }),
|
|
271
329
|
sub: (k) => setCollapse(p => { const n = { ...p, subBlocks: { ...p.subBlocks, [k]: !p.subBlocks[k] } }; saveCollapseState(api, n); return n; }),
|
|
272
330
|
};
|
|
273
331
|
onMount(() => {
|
|
274
|
-
// Risk fix: 移除 message.updated 的重复订阅。
|
|
275
|
-
// tui.tsx 已通过 setSidebarRevision() 驱动 sidebar 整体重渲,
|
|
276
|
-
// sidebar 内部只需订阅 part.updated 来触发 tokenDistribution 重算。
|
|
277
332
|
const unsubPart = api.event?.on?.("message.part.updated", () => setPartVersion(v => v + 1));
|
|
278
333
|
onCleanup(() => { try {
|
|
279
334
|
unsubPart?.();
|
|
280
335
|
}
|
|
281
336
|
catch { } });
|
|
282
337
|
});
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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}>
|
|
363
|
+
<text fg={primaryColor()}>
|
|
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>) : "")}
|
|
375
|
+
</text>
|
|
376
|
+
</box>
|
|
377
|
+
|
|
378
|
+
<Show when={!collapse().global}>
|
|
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>
|
|
419
|
+
</Show>
|
|
420
|
+
|
|
421
|
+
{/* ══════════════════════════════════════
|
|
422
|
+
各模型块:无分隔线,用 marginTop=1 隔开
|
|
423
|
+
══════════════════════════════════════ */}
|
|
424
|
+
<For each={modelStats()}>
|
|
315
425
|
{([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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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}>
|
|
490
|
+
<text fg={mutedColor()}>
|
|
491
|
+
<span style={{ fg: hitRateColor(hitRate) }}>●</span>
|
|
492
|
+
{" "}
|
|
493
|
+
<span style={{ fg: primaryColor() }}>{shortTitle}</span>
|
|
494
|
+
</text>
|
|
495
|
+
<text fg={mutedColor()}>{modelHeaderRight()}</text>
|
|
496
|
+
</box>
|
|
497
|
+
|
|
498
|
+
<Show when={isExpanded()}>
|
|
499
|
+
<box flexDirection="column" paddingX={1}>
|
|
500
|
+
|
|
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>
|
|
523
|
+
</box>
|
|
524
|
+
</box>
|
|
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>
|
|
552
|
+
</Show>
|
|
377
553
|
</box>);
|
|
378
|
-
}}
|
|
379
|
-
</For>
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
554
|
+
}}
|
|
555
|
+
</For>
|
|
556
|
+
|
|
557
|
+
{/* ══════════════════════════════════════
|
|
558
|
+
Token 分布区块:左右 space-between 对齐布局 (取消进度条)
|
|
559
|
+
══════════════════════════════════════ */}
|
|
560
|
+
<Show when={config().sidebar.showTokenDistribution}>
|
|
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>
|
|
571
|
+
</box>
|
|
572
|
+
|
|
573
|
+
<Show when={!collapse().subBlocks["token-dist"]}>
|
|
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>
|
|
585
|
+
</Show>
|
|
586
|
+
</box>
|
|
587
|
+
</Show>
|
|
588
|
+
|
|
589
|
+
</Show>
|
|
394
590
|
</box>);
|
|
395
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[];
|