opencode-tokenwatch 0.3.1 → 0.4.0
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 +9 -34
- package/README.md +10 -35
- package/dist/commands.jsx +3 -0
- package/dist/generate-usage-html.js +192 -108
- package/dist/perf-tracker.js +5 -0
- package/dist/sidebar.jsx +18 -5
- package/dist/tui.jsx +2 -1
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -9,14 +9,14 @@ Real-time token usage, cache analytics & performance dashboard plugin for OpenCo
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
11
|
- **Sidebar panel** — Session-level and per-model real-time stats (requests, tokens, cache, cost)
|
|
12
|
-
- **Cache hit rate** —
|
|
12
|
+
- **Cache hit rate** — Per-model tracking with trend indicators (↑/↓) and global weighted total
|
|
13
13
|
- **Performance metrics** — TTFT / TPS / End-to-end latency + P50/P95/P99 latency percentiles
|
|
14
|
-
- **Token distribution** —
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
14
|
+
- **Token distribution** — Per-role breakdown (system / user / tool / output)
|
|
15
|
+
- **Cost tracking** — Per-model cost display (requires provider billing data)
|
|
16
|
+
- **Error rate tracking** — Detects failed requests and computes real-time error rate
|
|
17
17
|
- **`/usage` command** — HTML Report → JSON Export → Text Report → Settings
|
|
18
|
-
- **HTML report** — Interactive ECharts dashboard: token
|
|
19
|
-
- **Persistent stats** — Performance metrics
|
|
18
|
+
- **HTML report** — Interactive ECharts dashboard: token trends, performance comparison, TPS ranking, error rate analysis — auto-opened in browser
|
|
19
|
+
- **Persistent stats** — Performance metrics accumulate forever in a dedicated file, unaffected by session resets
|
|
20
20
|
- **Multi-level collapse** — Panel, models, and sub-blocks collapsible with persisted state
|
|
21
21
|
- **Language switching** — Auto-detect or manually switch between Chinese and English
|
|
22
22
|
|
|
@@ -37,32 +37,7 @@ Add to `opencode.json` or `opencode.jsonc`:
|
|
|
37
37
|
|
|
38
38
|
## Configuration
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
{
|
|
42
|
-
"plugin": ["opencode-tokenwatch"],
|
|
43
|
-
"pluginConfig": {
|
|
44
|
-
"opencode-tokenwatch": {
|
|
45
|
-
"sidebar": {
|
|
46
|
-
"showPerformance": true,
|
|
47
|
-
"showPricing": true,
|
|
48
|
-
"showTokenDistribution": true,
|
|
49
|
-
"showTrend": true
|
|
50
|
-
},
|
|
51
|
-
"language": "auto"
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
| Option | Type | Default | Description |
|
|
58
|
-
|--------|------|---------|-------------|
|
|
59
|
-
| `sidebar.showPerformance` | boolean | `true` | Show TTFT/TPS/latency |
|
|
60
|
-
| `sidebar.showPricing` | boolean | `true` | Show request cost |
|
|
61
|
-
| `sidebar.showTokenDistribution` | boolean | `true` | Show token distribution |
|
|
62
|
-
| `sidebar.showTrend` | boolean | `true` | Show trend indicator |
|
|
63
|
-
| `language` | `"auto"` / `"zh"` / `"en"` | `"auto"` | UI language |
|
|
64
|
-
|
|
65
|
-
Settings can also be toggled via `/usage` → Settings, taking precedence over `pluginConfig`.
|
|
40
|
+
In OpenCode TUI, run `/usage` → **Settings** to interactively toggle display items and switch language. Settings are persisted automatically — no need to edit config files manually.
|
|
66
41
|
|
|
67
42
|
## Usage
|
|
68
43
|
|
|
@@ -77,8 +52,8 @@ In OpenCode TUI, run `/usage`:
|
|
|
77
52
|
|
|
78
53
|
| File | Path | Description |
|
|
79
54
|
|------|------|-------------|
|
|
80
|
-
| JSONL log | `~/.opencode/tokenwatch.jsonl` | Raw per-request log
|
|
81
|
-
| Aggregated stats | `~/.opencode/tokenwatch-stats.json` | Persistent performance stats
|
|
55
|
+
| JSONL log | `~/.opencode/tokenwatch.jsonl` | Raw per-request log |
|
|
56
|
+
| Aggregated stats | `~/.opencode/tokenwatch-stats.json` | Persistent performance stats |
|
|
82
57
|
| Report output | `~/.opencode/reports/` | HTML / JSON / Markdown reports |
|
|
83
58
|
|
|
84
59
|
## Requirements
|
package/README.md
CHANGED
|
@@ -9,14 +9,14 @@ OpenCode CLI 的实时 Token 用量统计、缓存分析与性能指标插件。
|
|
|
9
9
|
## 功能
|
|
10
10
|
|
|
11
11
|
- **侧边栏面板** — 会话级与按模型的实时统计(请求数、Token、缓存、成本)
|
|
12
|
-
- **缓存命中率** —
|
|
13
|
-
- **性能指标** — TTFT / TPS /
|
|
14
|
-
- **Token 分布** —
|
|
15
|
-
-
|
|
16
|
-
-
|
|
12
|
+
- **缓存命中率** — 按模型实时追踪,带趋势指示器(↑/↓)与全局加权总计
|
|
13
|
+
- **性能指标** — TTFT / TPS / 端到端延迟,含 P50/P95/P99 延迟分位数
|
|
14
|
+
- **Token 分布** — 按角色(system / user / tool / output)分解用量占比
|
|
15
|
+
- **成本追踪** — 按模型实时显示请求成本(需 provider 返回计费数据)
|
|
16
|
+
- **错误率统计** — 识别并统计失败请求,实时计算错误率
|
|
17
17
|
- **`/usage` 命令** — HTML 报告 → JSON 导出 → 文本报告 → 设置
|
|
18
|
-
- **HTML 报告** — 交互式 ECharts 仪表盘:Token
|
|
19
|
-
- **持久化统计** —
|
|
18
|
+
- **HTML 报告** — 交互式 ECharts 仪表盘:Token 趋势、性能对比、TPS 排名、错误率分析,自动在浏览器打开
|
|
19
|
+
- **持久化统计** — 性能指标永久累积写入独立文件,历史数据不受会话重置影响
|
|
20
20
|
- **多级折叠** — 面板、模型、子区块均可折叠,状态持久化
|
|
21
21
|
- **语言切换** — 中英双语,跟随系统或手动切换
|
|
22
22
|
|
|
@@ -37,32 +37,7 @@ npm install opencode-tokenwatch
|
|
|
37
37
|
|
|
38
38
|
## 配置
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
{
|
|
42
|
-
"plugin": ["opencode-tokenwatch"],
|
|
43
|
-
"pluginConfig": {
|
|
44
|
-
"opencode-tokenwatch": {
|
|
45
|
-
"sidebar": {
|
|
46
|
-
"showPerformance": true,
|
|
47
|
-
"showPricing": true,
|
|
48
|
-
"showTokenDistribution": true,
|
|
49
|
-
"showTrend": true
|
|
50
|
-
},
|
|
51
|
-
"language": "auto"
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
| 配置项 | 类型 | 默认值 | 说明 |
|
|
58
|
-
|--------|------|--------|------|
|
|
59
|
-
| `sidebar.showPerformance` | boolean | `true` | 显示 TTFT/TPS/延迟 |
|
|
60
|
-
| `sidebar.showPricing` | boolean | `true` | 显示请求成本 |
|
|
61
|
-
| `sidebar.showTokenDistribution` | boolean | `true` | 显示 Token 分布 |
|
|
62
|
-
| `sidebar.showTrend` | boolean | `true` | 显示趋势指示器 |
|
|
63
|
-
| `language` | `"auto"` / `"zh"` / `"en"` | `"auto"` | 界面语言 |
|
|
64
|
-
|
|
65
|
-
运行时也可通过 `/usage` → 设置 调整,优先级高于 `pluginConfig`。
|
|
40
|
+
在 OpenCode TUI 中输入 `/usage` → **设置**,可交互式开关各显示项和切换界面语言,配置自动持久化,无需手动编辑配置文件。
|
|
66
41
|
|
|
67
42
|
## 用法
|
|
68
43
|
|
|
@@ -77,8 +52,8 @@ npm install opencode-tokenwatch
|
|
|
77
52
|
|
|
78
53
|
| 文件 | 路径 | 说明 |
|
|
79
54
|
|------|------|------|
|
|
80
|
-
| JSONL 日志 | `~/.opencode/tokenwatch.jsonl` |
|
|
81
|
-
| 聚合统计 | `~/.opencode/tokenwatch-stats.json` |
|
|
55
|
+
| JSONL 日志 | `~/.opencode/tokenwatch.jsonl` | 原始请求日志 |
|
|
56
|
+
| 聚合统计 | `~/.opencode/tokenwatch-stats.json` | 持久化性能统计 |
|
|
82
57
|
| 报告输出 | `~/.opencode/reports/` | HTML / JSON / Markdown 报告 |
|
|
83
58
|
|
|
84
59
|
## 系统要求
|
package/dist/commands.jsx
CHANGED
|
@@ -61,6 +61,9 @@ function computePercentile(sortedArr, p) {
|
|
|
61
61
|
function aggregatePerfStats(logs) {
|
|
62
62
|
const map = new Map();
|
|
63
63
|
for (const entry of logs) {
|
|
64
|
+
// 过滤掉全零无效条目:token 均为 0 表示请求失败或未完成
|
|
65
|
+
if (entry.inputTokens + entry.outputTokens + entry.cacheReadTokens + entry.cacheWriteTokens === 0)
|
|
66
|
+
continue;
|
|
64
67
|
const key = entry.model;
|
|
65
68
|
let s = map.get(key);
|
|
66
69
|
if (!s) {
|
|
@@ -23,7 +23,8 @@ function cacheHitRate(input, cacheRead) {
|
|
|
23
23
|
return cacheRead / (input + cacheRead);
|
|
24
24
|
}
|
|
25
25
|
function sortModelsByUsage(models) {
|
|
26
|
-
|
|
26
|
+
// 过滤掉 totalTokens=0 的无效模型条目(如会话失败导致全为 0 的记录)
|
|
27
|
+
return [...models].filter(m => m.totalTokens > 0).sort((a, b) => b.totalTokens - a.totalTokens);
|
|
27
28
|
}
|
|
28
29
|
function renderMeta(data) {
|
|
29
30
|
const m = data.meta;
|
|
@@ -221,10 +222,13 @@ function renderModelChart(chart) {
|
|
|
221
222
|
}`;
|
|
222
223
|
}
|
|
223
224
|
function renderScatterChartInit(data) {
|
|
224
|
-
|
|
225
|
+
// 过滤掉全零无效条目:无请求或无任何 token 的模型
|
|
226
|
+
const validPerf = data.perfSummary.filter(p => p.requestCount > 0 &&
|
|
227
|
+
(p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite) > 0);
|
|
228
|
+
if (validPerf.length === 0)
|
|
225
229
|
return "";
|
|
226
230
|
// 按 TPS 降序排列(null TPS 放末尾),让最快的模型显示在最上方
|
|
227
|
-
const sorted = [...
|
|
231
|
+
const sorted = [...validPerf].sort((a, b) => {
|
|
228
232
|
if (a.avgTPS == null && b.avgTPS == null)
|
|
229
233
|
return 0;
|
|
230
234
|
if (a.avgTPS == null)
|
|
@@ -334,8 +338,12 @@ function providerBorderColor(provider) {
|
|
|
334
338
|
const colors = { opencode: "#00F593", deepseek: "#00D1FF", nvidia: "#B545FF", modelscope: "#FFB800", };
|
|
335
339
|
return colors[provider] || "#2A2A35";
|
|
336
340
|
}
|
|
341
|
+
/** 渲染 Provider 卡片,按 token 总量降序,最多显示 10 个 */
|
|
337
342
|
function renderProviderCards(data) {
|
|
338
|
-
|
|
343
|
+
const sorted = [...data.providers].sort((a, b) => b.totalTokens - a.totalTokens);
|
|
344
|
+
const top = sorted.slice(0, 10);
|
|
345
|
+
const remaining = sorted.length - 10;
|
|
346
|
+
const cards = top.map(p => {
|
|
339
347
|
const modelCount = data.models.filter(m => m.provider === p.provider).length;
|
|
340
348
|
const perfItems = data.perfSummary.filter(ps => ps.providerID === p.provider);
|
|
341
349
|
let ttftSum = 0, ttftReqs = 0;
|
|
@@ -364,11 +372,21 @@ function renderProviderCards(data) {
|
|
|
364
372
|
<div class="provider-stat"><span class="stat-label">Models</span><span>${modelCount}</span></div>
|
|
365
373
|
</div>`;
|
|
366
374
|
}).join("\n");
|
|
375
|
+
const moreHint = remaining > 0
|
|
376
|
+
? `<div class="provider-more">+ ${remaining} more provider${remaining > 1 ? 's' : ''} not shown</div>`
|
|
377
|
+
: '';
|
|
378
|
+
return cards + moreHint;
|
|
367
379
|
}
|
|
368
|
-
|
|
369
|
-
|
|
380
|
+
/**
|
|
381
|
+
* 渲染 Model Analytics 区块:三个表格合并为 Tab 切换展示,各自带分页控件。
|
|
382
|
+
* Tab 1: Usage Breakdown — Token/成本用量分解
|
|
383
|
+
* Tab 2: Latency Percentiles — TTFT/E2E 分位数
|
|
384
|
+
* Tab 3: Failed Requests — 失败请求明细(无数据时不显示此 Tab)
|
|
385
|
+
*/
|
|
386
|
+
function renderModelAnalyticsSection(data) {
|
|
387
|
+
// ─── Tab 1: Usage Breakdown ───────────────────────────────────────────────
|
|
388
|
+
const usageRows = sortModelsByUsage(data.models).map(m => {
|
|
370
389
|
const hitRate = cacheHitRate(m.inputTokens, m.cacheRead);
|
|
371
|
-
const hitRatePct = fmtPercent(hitRate);
|
|
372
390
|
const hitColor = hitRate >= 0.85 ? 'var(--cache)' : hitRate >= 0.70 ? 'var(--tps)' : 'var(--output)';
|
|
373
391
|
const perf = data.perfSummary.find(p => p.model === `${m.provider}/${m.model}`);
|
|
374
392
|
const ttft = perf?.avgTTFT != null ? perf.avgTTFT.toFixed(0) + 'ms' : '—';
|
|
@@ -382,39 +400,18 @@ function renderDataTable(data) {
|
|
|
382
400
|
<td>${fmtTokens(m.inputTokens)}</td>
|
|
383
401
|
<td>${fmtTokens(m.outputTokens)}</td>
|
|
384
402
|
<td>${fmtTokens(m.cacheRead)}</td>
|
|
385
|
-
<td style="color:${hitColor};font-weight:600">${
|
|
403
|
+
<td style="color:${hitColor};font-weight:600">${fmtPercent(hitRate)}</td>
|
|
386
404
|
<td>${ttft}</td>
|
|
387
405
|
<td style="color:var(--tps);font-size:0.85em">${p95ttft}</td>
|
|
388
406
|
<td>${tps}</td>
|
|
389
407
|
<td>${fmtCost(m.totalCost)}</td>
|
|
390
408
|
</tr>`;
|
|
391
409
|
}).join("\n");
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
<th>Model</th>
|
|
396
|
-
<th>Provider</th>
|
|
397
|
-
<th>Req</th>
|
|
398
|
-
<th>Total</th>
|
|
399
|
-
<th>Input</th>
|
|
400
|
-
<th>Output</th>
|
|
401
|
-
<th>Cache</th>
|
|
402
|
-
<th>Hit Rate</th>
|
|
403
|
-
<th>Avg TTFT</th>
|
|
404
|
-
<th>P95 TTFT</th>
|
|
405
|
-
<th>TPS</th>
|
|
406
|
-
<th>Cost</th>
|
|
407
|
-
</tr>
|
|
408
|
-
</thead>
|
|
409
|
-
<tbody>${rows}</tbody>
|
|
410
|
-
</table>`;
|
|
411
|
-
}
|
|
412
|
-
/** 渲染 P50/P95/P99 延迟分位数表格 */
|
|
413
|
-
function renderPerfPercentileTable(data) {
|
|
414
|
-
if (data.perfSummary.length === 0)
|
|
415
|
-
return '';
|
|
410
|
+
// ─── Tab 2: Latency Percentiles ───────────────────────────────────────────
|
|
411
|
+
const validPerf = data.perfSummary.filter(p => p.requestCount > 0 &&
|
|
412
|
+
(p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite) > 0);
|
|
416
413
|
const fmtMs = (v) => v != null ? v.toFixed(0) + 'ms' : '—';
|
|
417
|
-
const
|
|
414
|
+
const perfRows = validPerf.map(p => {
|
|
418
415
|
const hitColor = p.cacheHitRate != null && p.cacheHitRate >= 85 ? 'var(--cache)'
|
|
419
416
|
: p.cacheHitRate != null && p.cacheHitRate >= 70 ? 'var(--tps)' : 'var(--output)';
|
|
420
417
|
return `<tr>
|
|
@@ -431,67 +428,97 @@ function renderPerfPercentileTable(data) {
|
|
|
431
428
|
<td style="color:${hitColor};font-weight:600">${p.cacheHitRate != null ? p.cacheHitRate.toFixed(1) + '%' : '—'}</td>
|
|
432
429
|
</tr>`;
|
|
433
430
|
}).join("\n");
|
|
434
|
-
|
|
435
|
-
<section class="section">
|
|
436
|
-
<h2 class="section-title">Performance Latency Percentiles</h2>
|
|
437
|
-
<table class="data-table">
|
|
438
|
-
<thead>
|
|
439
|
-
<tr>
|
|
440
|
-
<th>Model</th>
|
|
441
|
-
<th>Req</th>
|
|
442
|
-
<th>Avg TTFT</th>
|
|
443
|
-
<th>P50 TTFT</th>
|
|
444
|
-
<th>P95 TTFT</th>
|
|
445
|
-
<th>P99 TTFT</th>
|
|
446
|
-
<th>Avg E2E</th>
|
|
447
|
-
<th>P50 E2E</th>
|
|
448
|
-
<th>P95 E2E</th>
|
|
449
|
-
<th>P99 E2E</th>
|
|
450
|
-
<th>Cache Hit</th>
|
|
451
|
-
</tr>
|
|
452
|
-
</thead>
|
|
453
|
-
<tbody>${rows}</tbody>
|
|
454
|
-
</table>
|
|
455
|
-
</section>`;
|
|
456
|
-
}
|
|
457
|
-
/** 渲染失败请求统计区域 */
|
|
458
|
-
function renderErrorStatsSection(data) {
|
|
431
|
+
// ─── Tab 3: Failed Requests ───────────────────────────────────────────────
|
|
459
432
|
const errors = data.errors;
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
.
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
<
|
|
482
|
-
|
|
483
|
-
</
|
|
484
|
-
|
|
485
|
-
<
|
|
486
|
-
<
|
|
487
|
-
<
|
|
433
|
+
const hasErrors = !!(errors && errors.failedCount > 0);
|
|
434
|
+
let errorTabBtn = '';
|
|
435
|
+
let errorTabContent = '';
|
|
436
|
+
if (hasErrors) {
|
|
437
|
+
const errorRatePct = (errors.errorRate * 100).toFixed(2) + '%';
|
|
438
|
+
const rateColor = errors.errorRate >= 0.05 ? 'var(--output)' : 'var(--tps)';
|
|
439
|
+
const cellColor = errors.errorRate >= 0.05 ? 'var(--output)' : 'var(--tps)';
|
|
440
|
+
const errorRows = errors.byModel
|
|
441
|
+
.filter(m => m.failed > 0)
|
|
442
|
+
.map(m => {
|
|
443
|
+
const modelRate = m.total > 0 ? (m.failed / m.total * 100).toFixed(1) + '%' : '—';
|
|
444
|
+
return `<tr>
|
|
445
|
+
<td>${m.provider}</td>
|
|
446
|
+
<td>${m.model}</td>
|
|
447
|
+
<td>${m.total}</td>
|
|
448
|
+
<td style="color:var(--output)">${m.failed}</td>
|
|
449
|
+
<td style="color:var(--tps)">${m.total - m.failed}</td>
|
|
450
|
+
<td style="color:${cellColor}">${modelRate}</td>
|
|
451
|
+
</tr>`;
|
|
452
|
+
}).join('\n');
|
|
453
|
+
errorTabBtn = `
|
|
454
|
+
<button class="tab-btn" data-mtab="errors" onclick="switchModelTab('errors')">
|
|
455
|
+
Failed Requests <span style="color:var(--output);margin-left:4px;font-size:0.85em">(${errors.failedCount})</span>
|
|
456
|
+
</button>`;
|
|
457
|
+
errorTabContent = `
|
|
458
|
+
<div id="model-tab-errors" class="tab-content">
|
|
459
|
+
<p style="font-size:12px;color:${rateColor};padding:8px 0 6px">
|
|
460
|
+
Overall error rate: <strong>${errorRatePct}</strong> —
|
|
461
|
+
${errors.failedCount} failed / ${errors.successCount + errors.failedCount} total
|
|
462
|
+
</p>
|
|
463
|
+
<table id="errors-table" class="data-table">
|
|
464
|
+
<thead><tr>
|
|
488
465
|
<th>Provider</th><th>Model</th><th>Total</th>
|
|
489
466
|
<th>Failed</th><th>Success</th><th>Error Rate</th>
|
|
490
|
-
</tr>
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
467
|
+
</tr></thead>
|
|
468
|
+
<tbody>${errorRows}</tbody>
|
|
469
|
+
</table>
|
|
470
|
+
<div class="pagination-ctrl" id="errors-table-ctrl">
|
|
471
|
+
<button class="page-btn" id="errors-table-prev">← Prev</button>
|
|
472
|
+
<span class="page-info" id="errors-table-info"></span>
|
|
473
|
+
<button class="page-btn" id="errors-table-next">Next →</button>
|
|
474
|
+
</div>
|
|
475
|
+
</div>`;
|
|
476
|
+
}
|
|
477
|
+
return `
|
|
478
|
+
<div class="section">
|
|
479
|
+
<div class="section-title">Model Analytics</div>
|
|
480
|
+
<div class="tab-bar">
|
|
481
|
+
<button class="tab-btn active" data-mtab="usage" onclick="switchModelTab('usage')">Usage Breakdown</button>
|
|
482
|
+
<button class="tab-btn" data-mtab="perf" onclick="switchModelTab('perf')">Latency Percentiles</button>
|
|
483
|
+
${errorTabBtn}
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
<div id="model-tab-usage" class="tab-content active">
|
|
487
|
+
<table id="usage-table" class="data-table">
|
|
488
|
+
<thead><tr>
|
|
489
|
+
<th>Model</th><th>Provider</th><th>Req</th><th>Total</th>
|
|
490
|
+
<th>Input</th><th>Output</th><th>Cache</th><th>Hit Rate</th>
|
|
491
|
+
<th>Avg TTFT</th><th>P95 TTFT</th><th>TPS</th><th>Cost</th>
|
|
492
|
+
</tr></thead>
|
|
493
|
+
<tbody>${usageRows}</tbody>
|
|
494
|
+
</table>
|
|
495
|
+
<div class="pagination-ctrl" id="usage-table-ctrl">
|
|
496
|
+
<button class="page-btn" id="usage-table-prev">← Prev</button>
|
|
497
|
+
<span class="page-info" id="usage-table-info"></span>
|
|
498
|
+
<button class="page-btn" id="usage-table-next">Next →</button>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<div id="model-tab-perf" class="tab-content">
|
|
503
|
+
${validPerf.length > 0 ? `
|
|
504
|
+
<table id="perf-table" class="data-table">
|
|
505
|
+
<thead><tr>
|
|
506
|
+
<th>Model</th><th>Req</th>
|
|
507
|
+
<th>Avg TTFT</th><th>P50 TTFT</th><th>P95 TTFT</th><th>P99 TTFT</th>
|
|
508
|
+
<th>Avg E2E</th><th>P50 E2E</th><th>P95 E2E</th><th>P99 E2E</th>
|
|
509
|
+
<th>Cache Hit</th>
|
|
510
|
+
</tr></thead>
|
|
511
|
+
<tbody>${perfRows}</tbody>
|
|
512
|
+
</table>
|
|
513
|
+
<div class="pagination-ctrl" id="perf-table-ctrl">
|
|
514
|
+
<button class="page-btn" id="perf-table-prev">← Prev</button>
|
|
515
|
+
<span class="page-info" id="perf-table-info"></span>
|
|
516
|
+
<button class="page-btn" id="perf-table-next">Next →</button>
|
|
517
|
+
</div>` : '<div class="empty-state">No performance data available for this period.</div>'}
|
|
518
|
+
</div>
|
|
519
|
+
|
|
520
|
+
${errorTabContent}
|
|
521
|
+
</div>`;
|
|
495
522
|
}
|
|
496
523
|
function renderDailyTrendInit(data) {
|
|
497
524
|
const days = data.daily.slice().reverse().map(d => d.day);
|
|
@@ -643,12 +670,11 @@ export function generateUsageHtml(data) {
|
|
|
643
670
|
const modelChartJs = modelChartVisible ? renderModelChartInit(data) : "";
|
|
644
671
|
const scatterChartJs = renderScatterChartInit(data);
|
|
645
672
|
const providerStr = renderProviderCards(data);
|
|
646
|
-
const
|
|
647
|
-
const perfPercentileStr = renderPerfPercentileTable(data);
|
|
648
|
-
const errorStatsStr = renderErrorStatsSection(data);
|
|
673
|
+
const modelAnalyticsStr = renderModelAnalyticsSection(data);
|
|
649
674
|
const dailyChartJs = renderDailyTrendInit(data);
|
|
650
675
|
const heatmapJs = renderHeatmapInit(data);
|
|
651
|
-
const hasPerf = data.perfSummary.
|
|
676
|
+
const hasPerf = data.perfSummary.some(p => p.requestCount > 0 &&
|
|
677
|
+
(p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite) > 0);
|
|
652
678
|
const jsonData = JSON.stringify(data);
|
|
653
679
|
return `<!DOCTYPE html>
|
|
654
680
|
<html lang="en">
|
|
@@ -729,6 +755,7 @@ export function generateUsageHtml(data) {
|
|
|
729
755
|
.provider-name { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--input); }
|
|
730
756
|
.provider-stat { display: flex; justify-content: space-between; font-size: 12px; padding: 2px 0; }
|
|
731
757
|
.provider-stat .stat-label { color: var(--text-dim); }
|
|
758
|
+
.provider-more { color: var(--text-dim); font-size: 11px; padding: 10px 4px 0; grid-column: 1 / -1; }
|
|
732
759
|
|
|
733
760
|
.data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
734
761
|
.data-table th {
|
|
@@ -746,6 +773,22 @@ export function generateUsageHtml(data) {
|
|
|
746
773
|
}
|
|
747
774
|
.data-table tbody tr:hover { background: rgba(42,42,53,0.4); }
|
|
748
775
|
|
|
776
|
+
.pagination-ctrl {
|
|
777
|
+
display: none; align-items: center; gap: 14px; justify-content: center;
|
|
778
|
+
padding: 14px 0 4px;
|
|
779
|
+
}
|
|
780
|
+
.page-btn {
|
|
781
|
+
background: var(--card); border: 1px solid var(--border); color: var(--text);
|
|
782
|
+
padding: 5px 16px; border-radius: 4px; cursor: pointer;
|
|
783
|
+
font-size: 12px; font-family: 'Inter', sans-serif; transition: border-color 0.15s, color 0.15s;
|
|
784
|
+
}
|
|
785
|
+
.page-btn:hover:not(:disabled) { border-color: var(--input); color: var(--input); }
|
|
786
|
+
.page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
787
|
+
.page-info {
|
|
788
|
+
color: var(--text-dim); font-size: 12px;
|
|
789
|
+
font-family: 'JetBrains Mono', monospace; min-width: 110px; text-align: center;
|
|
790
|
+
}
|
|
791
|
+
|
|
749
792
|
.empty-state {
|
|
750
793
|
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
|
|
751
794
|
padding: 40px; text-align: center; color: var(--text-dim);
|
|
@@ -778,7 +821,7 @@ export function generateUsageHtml(data) {
|
|
|
778
821
|
|
|
779
822
|
<div class="section">
|
|
780
823
|
<div class="section-title">Model Comparison Matrix</div>
|
|
781
|
-
${modelChartVisible ? '<div class="chart-box" id="model-chart"></div>' : '<div class="empty-state">No models with
|
|
824
|
+
${modelChartVisible ? '<div class="chart-box" id="model-chart"></div>' : '<div class="empty-state">No models with ≥1M tokens in this period.</div>'}
|
|
782
825
|
</div>
|
|
783
826
|
|
|
784
827
|
<div class="section">
|
|
@@ -786,14 +829,7 @@ export function generateUsageHtml(data) {
|
|
|
786
829
|
<div class="provider-row">${providerStr}</div>
|
|
787
830
|
</div>
|
|
788
831
|
|
|
789
|
-
|
|
790
|
-
<div class="section-title">Detailed Data Grid</div>
|
|
791
|
-
${tableStr}
|
|
792
|
-
</div>
|
|
793
|
-
|
|
794
|
-
${perfPercentileStr}
|
|
795
|
-
|
|
796
|
-
${errorStatsStr}
|
|
832
|
+
${modelAnalyticsStr}
|
|
797
833
|
|
|
798
834
|
<div class="section">
|
|
799
835
|
<div class="section-title">Efficiency vs Cost</div>
|
|
@@ -814,9 +850,9 @@ export function generateUsageHtml(data) {
|
|
|
814
850
|
</div>
|
|
815
851
|
</div>
|
|
816
852
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
853
|
+
<div class="footer">
|
|
854
|
+
Generated by TokenWatch · Data: SQLite + JSONL · Export: <a href="javascript:void(0)" onclick="downloadJSON()" style="color:var(--input);text-decoration:none">JSON</a> <span style="color:var(--text-dim)">(saves to browser download folder)</span>
|
|
855
|
+
</div>
|
|
820
856
|
</div>
|
|
821
857
|
|
|
822
858
|
<script id="report-data" type="application/json">${jsonData}</script>
|
|
@@ -830,9 +866,10 @@ var fmt = function(v) {
|
|
|
830
866
|
return String(v);
|
|
831
867
|
};
|
|
832
868
|
|
|
869
|
+
// Usage Timeline tab switcher
|
|
833
870
|
window.switchTab = function(name) {
|
|
834
871
|
document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
|
|
835
|
-
document.querySelectorAll('.tab-btn').forEach(function(el) { el.classList.remove('active'); });
|
|
872
|
+
document.querySelectorAll('.tab-btn[data-tab]').forEach(function(el) { el.classList.remove('active'); });
|
|
836
873
|
document.getElementById('tab-' + name).classList.add('active');
|
|
837
874
|
document.querySelector('[data-tab="' + name + '"]').classList.add('active');
|
|
838
875
|
setTimeout(function() {
|
|
@@ -841,6 +878,50 @@ window.switchTab = function(name) {
|
|
|
841
878
|
}, 50);
|
|
842
879
|
};
|
|
843
880
|
|
|
881
|
+
// Model Analytics tab switcher
|
|
882
|
+
window.switchModelTab = function(name) {
|
|
883
|
+
document.querySelectorAll('[data-mtab]').forEach(function(el) { el.classList.remove('active'); });
|
|
884
|
+
['model-tab-usage', 'model-tab-perf', 'model-tab-errors'].forEach(function(id) {
|
|
885
|
+
var el = document.getElementById(id);
|
|
886
|
+
if (el) el.classList.remove('active');
|
|
887
|
+
});
|
|
888
|
+
var activeTab = document.getElementById('model-tab-' + name);
|
|
889
|
+
if (activeTab) activeTab.classList.add('active');
|
|
890
|
+
var activeBtn = document.querySelector('[data-mtab="' + name + '"]');
|
|
891
|
+
if (activeBtn) activeBtn.classList.add('active');
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
// Generic paginator: hide/show tbody rows, show prev/next controls
|
|
895
|
+
function initPaginator(tableId, pageSize) {
|
|
896
|
+
var tbody = document.querySelector('#' + tableId + ' tbody');
|
|
897
|
+
if (!tbody) return;
|
|
898
|
+
var rows = Array.from(tbody.querySelectorAll('tr'));
|
|
899
|
+
if (rows.length <= pageSize) return; // 行数不超过一页时无需分页
|
|
900
|
+
var totalPages = Math.ceil(rows.length / pageSize);
|
|
901
|
+
var cur = 1;
|
|
902
|
+
|
|
903
|
+
function render() {
|
|
904
|
+
rows.forEach(function(r, i) {
|
|
905
|
+
r.style.display = (i >= (cur - 1) * pageSize && i < cur * pageSize) ? '' : 'none';
|
|
906
|
+
});
|
|
907
|
+
var info = document.getElementById(tableId + '-info');
|
|
908
|
+
if (info) info.textContent = 'Page ' + cur + ' / ' + totalPages + ' (' + rows.length + ' rows)';
|
|
909
|
+
var prevEl = document.getElementById(tableId + '-prev');
|
|
910
|
+
var nextEl = document.getElementById(tableId + '-next');
|
|
911
|
+
if (prevEl) prevEl.disabled = cur === 1;
|
|
912
|
+
if (nextEl) nextEl.disabled = cur === totalPages;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
var prevEl = document.getElementById(tableId + '-prev');
|
|
916
|
+
var nextEl = document.getElementById(tableId + '-next');
|
|
917
|
+
if (prevEl) prevEl.addEventListener('click', function() { if (cur > 1) { cur--; render(); } });
|
|
918
|
+
if (nextEl) nextEl.addEventListener('click', function() { if (cur < totalPages) { cur++; render(); } });
|
|
919
|
+
|
|
920
|
+
var ctrl = document.getElementById(tableId + '-ctrl');
|
|
921
|
+
if (ctrl) ctrl.style.display = 'flex';
|
|
922
|
+
render();
|
|
923
|
+
}
|
|
924
|
+
|
|
844
925
|
${modelChartJs}
|
|
845
926
|
${scatterChartJs}
|
|
846
927
|
${dailyChartJs}
|
|
@@ -864,6 +945,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
864
945
|
${hasPerf ? 'initScatterChart();' : ''}
|
|
865
946
|
initDailyChart();
|
|
866
947
|
initHeatmapChart();
|
|
948
|
+
initPaginator('usage-table', 10);
|
|
949
|
+
initPaginator('perf-table', 10);
|
|
950
|
+
initPaginator('errors-table', 10);
|
|
867
951
|
});
|
|
868
952
|
|
|
869
953
|
window.addEventListener('resize', function() {
|
package/dist/perf-tracker.js
CHANGED
|
@@ -43,6 +43,11 @@ class PerfTracker {
|
|
|
43
43
|
const cacheRead = tokens?.cache?.read ?? 0;
|
|
44
44
|
const cacheWrite = tokens?.cache?.write ?? 0;
|
|
45
45
|
const cost = info.cost ?? 0;
|
|
46
|
+
// 过滤全零 token 的失败请求,不写入日志和统计,防止污染数据
|
|
47
|
+
if (inputTokens + outputTokens + cacheRead + cacheWrite === 0) {
|
|
48
|
+
this.firstPartTimes.delete(messageID);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
46
51
|
const firstPart = this.firstPartTimes.get(messageID) ?? null;
|
|
47
52
|
const latencyMs = completed - created;
|
|
48
53
|
const ttftMs = firstPart !== null ? firstPart - created : null;
|
package/dist/sidebar.jsx
CHANGED
|
@@ -149,11 +149,13 @@ export function TokenWatchPanel(props) {
|
|
|
149
149
|
// ── 数据聚合 ──
|
|
150
150
|
const modelStats = createMemo(() => {
|
|
151
151
|
const map = new Map();
|
|
152
|
-
|
|
152
|
+
const msgs = props.allTokenMessages();
|
|
153
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
154
|
+
const msg = msgs[i];
|
|
153
155
|
const key = `${msg.providerID}/${msg.modelID}`;
|
|
154
156
|
let e = map.get(key);
|
|
155
157
|
if (!e) {
|
|
156
|
-
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 };
|
|
157
159
|
map.set(key, e);
|
|
158
160
|
}
|
|
159
161
|
e.totalInput += msg.inputTokens;
|
|
@@ -163,8 +165,13 @@ export function TokenWatchPanel(props) {
|
|
|
163
165
|
e.cacheWrite += msg.cacheWrite;
|
|
164
166
|
e.totalCost += msg.cost;
|
|
165
167
|
e.requestCount++;
|
|
168
|
+
e.lastMessageIndex = i; // 追踪该模型最后一条消息的索引(越大 = 越近调用)
|
|
166
169
|
}
|
|
167
|
-
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);
|
|
168
175
|
});
|
|
169
176
|
const sessionTotals = createMemo(() => {
|
|
170
177
|
let i = 0, o = 0, ir = 0, cr = 0, cw = 0, r = 0, c = 0;
|
|
@@ -442,8 +449,14 @@ export function TokenWatchPanel(props) {
|
|
|
442
449
|
? RGBA.fromInts(63, 185, 80, 255)
|
|
443
450
|
: RGBA.fromInts(244, 67, 54, 255);
|
|
444
451
|
};
|
|
445
|
-
//
|
|
446
|
-
|
|
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
|
|
447
460
|
if (fullTitle.length > 22) {
|
|
448
461
|
const parts = fullTitle.split("/");
|
|
449
462
|
if (parts.length >= 3) {
|
package/dist/tui.jsx
CHANGED
|
@@ -108,7 +108,8 @@ const tui = async (api) => {
|
|
|
108
108
|
if (msg.role !== "assistant")
|
|
109
109
|
continue;
|
|
110
110
|
const tokens = msg.tokens;
|
|
111
|
-
|
|
111
|
+
// 与 message.updated 处理器保持一致:过滤 total=0 的失败请求
|
|
112
|
+
if (!tokens || (tokens.total ?? 0) === 0)
|
|
112
113
|
continue;
|
|
113
114
|
const id = msg.id;
|
|
114
115
|
const idx = next.findIndex(m => m.id === id);
|