opencode-tokenwatch 0.4.0 → 0.5.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/dist/server.js +11 -0
- package/dist/sidebar.d.ts +1 -1
- package/dist/tui.js +3611 -0
- package/package.json +16 -7
- package/dist/commands.js +0 -208
- package/dist/commands.jsx +0 -381
- package/dist/formatter.js +0 -289
- package/dist/generate-usage-html.js +0 -962
- package/dist/i18n.js +0 -173
- package/dist/index.js +0 -7
- package/dist/perf-tracker.js +0 -299
- package/dist/queries.js +0 -393
- package/dist/sidebar.jsx +0 -604
- package/dist/stats-store.js +0 -258
- package/dist/tui.jsx +0 -178
- /package/dist/{index.d.ts → server.d.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-tokenwatch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Real-time token usage, cache analytics & performance dashboard plugin for OpenCode CLI",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./dist/
|
|
7
|
-
"types": "./dist/
|
|
6
|
+
"main": "./dist/server.js",
|
|
7
|
+
"types": "./dist/server.d.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
|
-
"types": "./dist/
|
|
11
|
-
"import": "./dist/
|
|
10
|
+
"types": "./dist/server.d.ts",
|
|
11
|
+
"import": "./dist/server.js"
|
|
12
12
|
},
|
|
13
13
|
"./tui": {
|
|
14
14
|
"types": "./dist/tui.d.ts",
|
|
15
|
-
"import": "./dist/tui.
|
|
15
|
+
"import": "./dist/tui.js",
|
|
16
|
+
"config": {
|
|
17
|
+
"enabled": true
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"./server": {
|
|
21
|
+
"types": "./dist/server.d.ts",
|
|
22
|
+
"import": "./dist/server.js"
|
|
16
23
|
},
|
|
17
24
|
"./package.json": "./package.json"
|
|
18
25
|
},
|
|
@@ -24,7 +31,7 @@
|
|
|
24
31
|
"node": ">=18"
|
|
25
32
|
},
|
|
26
33
|
"scripts": {
|
|
27
|
-
"build": "tsc",
|
|
34
|
+
"build": "tsc && node build.tui.mjs",
|
|
28
35
|
"release:check": "node ./scripts/publish-check.mjs",
|
|
29
36
|
"prepublishOnly": "npm run build"
|
|
30
37
|
},
|
|
@@ -55,6 +62,8 @@
|
|
|
55
62
|
"@opentui/keymap": "^0.2.9",
|
|
56
63
|
"@opentui/solid": "^0.2.9",
|
|
57
64
|
"@types/node": "^22.0.0",
|
|
65
|
+
"esbuild": "^0.25.0",
|
|
66
|
+
"esbuild-plugin-solid": "^0.5.0",
|
|
58
67
|
"typescript": "^5.7.0"
|
|
59
68
|
},
|
|
60
69
|
"publishConfig": {
|
package/dist/commands.js
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
import { getUsageReport } from "./queries.js";
|
|
2
|
-
import { formatUsageReport } from "./formatter.js";
|
|
3
|
-
import { generateUsageHtml } from "./generate-usage-html.js";
|
|
4
|
-
import { t } from "./i18n.js";
|
|
5
|
-
import { readLogs } from "./perf-tracker.js";
|
|
6
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
import { homedir } from "node:os";
|
|
9
|
-
import { execSync } from "node:child_process";
|
|
10
|
-
const DEFAULT_CONFIG = {
|
|
11
|
-
sidebar: { showPerformance: true, showPricing: true, showTokenDistribution: true, showTrend: true },
|
|
12
|
-
language: "auto",
|
|
13
|
-
};
|
|
14
|
-
export async function registerCommands(api) {
|
|
15
|
-
api.command?.register(() => [
|
|
16
|
-
{
|
|
17
|
-
value: "tokenwatch-html-report",
|
|
18
|
-
title: "Generate HTML report",
|
|
19
|
-
description: "Generate an HTML dashboard with token usage, cache efficiency, and performance charts",
|
|
20
|
-
category: "Stats",
|
|
21
|
-
slash: { name: "usage-html", aliases: ["usage"] },
|
|
22
|
-
onSelect: async () => {
|
|
23
|
-
await showHtmlReport(api);
|
|
24
|
-
},
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
value: "tokenwatch-json-export",
|
|
28
|
-
title: "Export as JSON",
|
|
29
|
-
description: "Export usage data as JSON file",
|
|
30
|
-
category: "Stats",
|
|
31
|
-
slash: { name: "usage-json" },
|
|
32
|
-
onSelect: async () => {
|
|
33
|
-
await showJsonExport(api);
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
value: "tokenwatch-text-report",
|
|
38
|
-
title: "Text report (legacy)",
|
|
39
|
-
description: "View plain text usage report in terminal",
|
|
40
|
-
category: "Stats",
|
|
41
|
-
slash: { name: "usage-text" },
|
|
42
|
-
onSelect: async () => {
|
|
43
|
-
await showTextReport(api);
|
|
44
|
-
},
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
value: "tokenwatch-settings",
|
|
48
|
-
title: "TokenWatch Settings",
|
|
49
|
-
description: "Configure sidebar display options",
|
|
50
|
-
category: "Stats",
|
|
51
|
-
slash: { name: "usage-settings", aliases: ["tokenwatch-settings"] },
|
|
52
|
-
onSelect: async () => {
|
|
53
|
-
await showSettingsDialog(api);
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
]);
|
|
57
|
-
}
|
|
58
|
-
function ensureReportDir() {
|
|
59
|
-
const dir = join(homedir(), ".opencode", "reports");
|
|
60
|
-
if (!existsSync(dir))
|
|
61
|
-
mkdirSync(dir, { recursive: true });
|
|
62
|
-
return dir;
|
|
63
|
-
}
|
|
64
|
-
function openInBrowser(filePath) {
|
|
65
|
-
try {
|
|
66
|
-
const platform = process.platform;
|
|
67
|
-
if (platform === "win32")
|
|
68
|
-
execSync(`start "" "${filePath}"`, { windowsHide: true, timeout: 5000 });
|
|
69
|
-
else if (platform === "darwin")
|
|
70
|
-
execSync(`open "${filePath}"`, { timeout: 5000 });
|
|
71
|
-
else
|
|
72
|
-
execSync(`xdg-open "${filePath}"`, { timeout: 5000 });
|
|
73
|
-
}
|
|
74
|
-
catch { /* silently fail */ }
|
|
75
|
-
}
|
|
76
|
-
function aggregatePerfStats(logs) {
|
|
77
|
-
const map = new Map();
|
|
78
|
-
for (const entry of logs) {
|
|
79
|
-
const key = entry.model;
|
|
80
|
-
let s = map.get(key);
|
|
81
|
-
if (!s) {
|
|
82
|
-
s = {
|
|
83
|
-
model: key,
|
|
84
|
-
providerID: entry.providerID,
|
|
85
|
-
requestCount: 0,
|
|
86
|
-
totalInput: 0, totalOutput: 0, totalCacheRead: 0, totalCacheWrite: 0, totalCost: 0,
|
|
87
|
-
avgTTFT: null, maxTTFT: null, minTTFT: null,
|
|
88
|
-
avgTPS: null, maxTPS: null, minTPS: null,
|
|
89
|
-
avgLatency: null, maxLatency: null, minLatency: null,
|
|
90
|
-
};
|
|
91
|
-
map.set(key, s);
|
|
92
|
-
}
|
|
93
|
-
s.requestCount++;
|
|
94
|
-
s.totalInput += entry.inputTokens;
|
|
95
|
-
s.totalOutput += entry.outputTokens;
|
|
96
|
-
s.totalCacheRead += entry.cacheReadTokens;
|
|
97
|
-
s.totalCacheWrite += entry.cacheWriteTokens;
|
|
98
|
-
s.totalCost += entry.cost;
|
|
99
|
-
const c = s.requestCount;
|
|
100
|
-
if (entry.ttft_ms != null) {
|
|
101
|
-
s.avgTTFT = s.avgTTFT != null ? s.avgTTFT + (entry.ttft_ms - s.avgTTFT) / c : entry.ttft_ms;
|
|
102
|
-
s.maxTTFT = s.maxTTFT != null ? Math.max(s.maxTTFT, entry.ttft_ms) : entry.ttft_ms;
|
|
103
|
-
s.minTTFT = s.minTTFT != null ? Math.min(s.minTTFT, entry.ttft_ms) : entry.ttft_ms;
|
|
104
|
-
}
|
|
105
|
-
if (entry.tps != null) {
|
|
106
|
-
s.avgTPS = s.avgTPS != null ? s.avgTPS + (entry.tps - s.avgTPS) / c : entry.tps;
|
|
107
|
-
s.maxTPS = s.maxTPS != null ? Math.max(s.maxTPS, entry.tps) : entry.tps;
|
|
108
|
-
s.minTPS = s.minTPS != null ? Math.min(s.minTPS, entry.tps) : entry.tps;
|
|
109
|
-
}
|
|
110
|
-
if (entry.latency_ms != null) {
|
|
111
|
-
s.avgLatency = s.avgLatency != null ? s.avgLatency + (entry.latency_ms - s.avgLatency) / c : entry.latency_ms;
|
|
112
|
-
s.maxLatency = s.maxLatency != null ? Math.max(s.maxLatency, entry.latency_ms) : entry.latency_ms;
|
|
113
|
-
s.minLatency = s.minLatency != null ? Math.min(s.minLatency, entry.latency_ms) : entry.latency_ms;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return Array.from(map.values());
|
|
117
|
-
}
|
|
118
|
-
async function buildCombinedData(api) {
|
|
119
|
-
const report = await getUsageReport({});
|
|
120
|
-
const logs = readLogs(1000);
|
|
121
|
-
const now = new Date();
|
|
122
|
-
const pad = (n) => String(n).padStart(2, '0');
|
|
123
|
-
const meta = {
|
|
124
|
-
generatedAt: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
|
|
125
|
-
dateRange: {
|
|
126
|
-
start: report.daily.length > 0 ? report.daily[report.daily.length - 1].day : "—",
|
|
127
|
-
end: report.daily.length > 0 ? report.daily[0].day : "—",
|
|
128
|
-
},
|
|
129
|
-
};
|
|
130
|
-
return {
|
|
131
|
-
...report,
|
|
132
|
-
perfLogs: logs,
|
|
133
|
-
perfSummary: aggregatePerfStats(logs),
|
|
134
|
-
meta,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
async function showHtmlReport(api) {
|
|
138
|
-
try {
|
|
139
|
-
const data = await buildCombinedData(api);
|
|
140
|
-
const html = generateUsageHtml(data);
|
|
141
|
-
const dir = ensureReportDir();
|
|
142
|
-
const dateStr = new Date().toISOString().slice(0, 10);
|
|
143
|
-
const filePath = join(dir, `tokenwatch-${dateStr}.html`);
|
|
144
|
-
writeFileSync(filePath, html, "utf-8");
|
|
145
|
-
api.ui.toast?.({ message: `Report: ${filePath}`, variant: "info" });
|
|
146
|
-
openInBrowser(filePath);
|
|
147
|
-
}
|
|
148
|
-
catch (err) {
|
|
149
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
150
|
-
api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
async function showJsonExport(api) {
|
|
154
|
-
try {
|
|
155
|
-
const report = await getUsageReport({});
|
|
156
|
-
const dir = ensureReportDir();
|
|
157
|
-
const dateStr = new Date().toISOString().slice(0, 10);
|
|
158
|
-
const filePath = join(dir, `tokenwatch-${dateStr}.json`);
|
|
159
|
-
writeFileSync(filePath, JSON.stringify(report, null, 2), "utf-8");
|
|
160
|
-
api.ui.toast?.({ message: `JSON: ${filePath}`, variant: "info" });
|
|
161
|
-
}
|
|
162
|
-
catch (err) {
|
|
163
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
164
|
-
api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
async function showTextReport(api) {
|
|
168
|
-
try {
|
|
169
|
-
const report = await getUsageReport({});
|
|
170
|
-
const formatted = formatUsageReport(report);
|
|
171
|
-
const dir = ensureReportDir();
|
|
172
|
-
const dateStr = new Date().toISOString().slice(0, 10);
|
|
173
|
-
const filePath = join(dir, `tokenwatch-${dateStr}.md`);
|
|
174
|
-
writeFileSync(filePath, formatted, "utf-8");
|
|
175
|
-
api.ui.toast?.({ message: `Report saved to ${filePath}`, variant: "info" });
|
|
176
|
-
}
|
|
177
|
-
catch (err) {
|
|
178
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
179
|
-
api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
async function showSettingsDialog(api) {
|
|
183
|
-
const currentConfig = loadConfigFromStore(api);
|
|
184
|
-
const cfg = currentConfig.sidebar;
|
|
185
|
-
const options = [
|
|
186
|
-
`[${cfg.showPerformance ? "x" : " "}] ${t("showPerformance")}`,
|
|
187
|
-
`[${cfg.showPricing ? "x" : " "}] ${t("showPricing")}`,
|
|
188
|
-
`[${cfg.showTokenDistribution ? "x" : " "}] ${t("showTokenDistribution")}`,
|
|
189
|
-
`[${cfg.showTrend ? "x" : " "}] ${t("showTrend")}`,
|
|
190
|
-
`---`,
|
|
191
|
-
`${t("language")}: ${currentConfig.language}`,
|
|
192
|
-
].join("\n");
|
|
193
|
-
api.ui.toast?.({ message: `TokenWatch settings:\n${options}`, variant: "info" });
|
|
194
|
-
}
|
|
195
|
-
function loadConfigFromStore(api) {
|
|
196
|
-
const base = { sidebar: { ...DEFAULT_CONFIG.sidebar }, language: DEFAULT_CONFIG.language };
|
|
197
|
-
try {
|
|
198
|
-
const stored = api.kv?.get?.("tokenwatch-config");
|
|
199
|
-
if (stored) {
|
|
200
|
-
if (stored.sidebar)
|
|
201
|
-
Object.assign(base.sidebar, stored.sidebar);
|
|
202
|
-
if (stored.language)
|
|
203
|
-
base.language = stored.language;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
catch { /* defaults */ }
|
|
207
|
-
return base;
|
|
208
|
-
}
|
package/dist/commands.jsx
DELETED
|
@@ -1,381 +0,0 @@
|
|
|
1
|
-
import { getUsageReport, getPresetRange } from "./queries.js";
|
|
2
|
-
import { formatUsageReport } from "./formatter.js";
|
|
3
|
-
import { generateUsageHtml } from "./generate-usage-html.js";
|
|
4
|
-
import { t, setLanguage } from "./i18n.js";
|
|
5
|
-
import { readLogs } from "./perf-tracker.js";
|
|
6
|
-
import { readPersistedStats } from "./stats-store.js";
|
|
7
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
import { homedir } from "node:os";
|
|
10
|
-
import { execSync } from "node:child_process";
|
|
11
|
-
const DEFAULT_CONFIG = {
|
|
12
|
-
sidebar: { showPerformance: true, showPricing: true, showTokenDistribution: true, showTrend: true },
|
|
13
|
-
language: "auto",
|
|
14
|
-
};
|
|
15
|
-
export async function registerCommands(api) {
|
|
16
|
-
api.command?.register(() => [
|
|
17
|
-
{
|
|
18
|
-
value: "tokenwatch-usage",
|
|
19
|
-
title: "TokenWatch",
|
|
20
|
-
description: "Token usage reports, export, and settings",
|
|
21
|
-
category: "Stats",
|
|
22
|
-
slash: { name: "usage" },
|
|
23
|
-
onSelect: async (dialog) => {
|
|
24
|
-
if (dialog)
|
|
25
|
-
showUsageMenu(api, dialog);
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
]);
|
|
29
|
-
}
|
|
30
|
-
function ensureReportDir() {
|
|
31
|
-
const dir = join(homedir(), ".opencode", "reports");
|
|
32
|
-
if (!existsSync(dir))
|
|
33
|
-
mkdirSync(dir, { recursive: true });
|
|
34
|
-
return dir;
|
|
35
|
-
}
|
|
36
|
-
function openInBrowser(filePath) {
|
|
37
|
-
try {
|
|
38
|
-
const platform = process.platform;
|
|
39
|
-
if (platform === "win32")
|
|
40
|
-
execSync(`start "" "${filePath}"`, { windowsHide: true, timeout: 5000 });
|
|
41
|
-
else if (platform === "darwin")
|
|
42
|
-
execSync(`open "${filePath}"`, { timeout: 5000 });
|
|
43
|
-
else
|
|
44
|
-
execSync(`xdg-open "${filePath}"`, { timeout: 5000 });
|
|
45
|
-
}
|
|
46
|
-
catch { /* silently fail */ }
|
|
47
|
-
}
|
|
48
|
-
/** 线性插值百分位数,输入须为有序数组 */
|
|
49
|
-
function computePercentile(sortedArr, p) {
|
|
50
|
-
if (sortedArr.length === 0)
|
|
51
|
-
return null;
|
|
52
|
-
if (sortedArr.length === 1)
|
|
53
|
-
return sortedArr[0];
|
|
54
|
-
const idx = (p / 100) * (sortedArr.length - 1);
|
|
55
|
-
const lo = Math.floor(idx);
|
|
56
|
-
const hi = Math.ceil(idx);
|
|
57
|
-
if (lo === hi)
|
|
58
|
-
return sortedArr[lo];
|
|
59
|
-
return sortedArr[lo] + (sortedArr[hi] - sortedArr[lo]) * (idx - lo);
|
|
60
|
-
}
|
|
61
|
-
function aggregatePerfStats(logs) {
|
|
62
|
-
const map = new Map();
|
|
63
|
-
for (const entry of logs) {
|
|
64
|
-
// 过滤掉全零无效条目:token 均为 0 表示请求失败或未完成
|
|
65
|
-
if (entry.inputTokens + entry.outputTokens + entry.cacheReadTokens + entry.cacheWriteTokens === 0)
|
|
66
|
-
continue;
|
|
67
|
-
const key = entry.model;
|
|
68
|
-
let s = map.get(key);
|
|
69
|
-
if (!s) {
|
|
70
|
-
s = {
|
|
71
|
-
model: key,
|
|
72
|
-
providerID: entry.providerID,
|
|
73
|
-
requestCount: 0,
|
|
74
|
-
ttftCount: 0, // Bug fix: 独立维护有效样本计数
|
|
75
|
-
tpsCount: 0,
|
|
76
|
-
latencyCount: 0,
|
|
77
|
-
totalInput: 0, totalOutput: 0, totalCacheRead: 0, totalCacheWrite: 0, totalCost: 0,
|
|
78
|
-
avgTTFT: null, maxTTFT: null, minTTFT: null,
|
|
79
|
-
p50TTFT: null, p95TTFT: null, p99TTFT: null,
|
|
80
|
-
avgTPS: null, maxTPS: null, minTPS: null,
|
|
81
|
-
avgLatency: null, maxLatency: null, minLatency: null,
|
|
82
|
-
p50Latency: null, p95Latency: null, p99Latency: null,
|
|
83
|
-
cacheHitRate: null,
|
|
84
|
-
};
|
|
85
|
-
map.set(key, s);
|
|
86
|
-
}
|
|
87
|
-
s.requestCount++;
|
|
88
|
-
s.totalInput += entry.inputTokens;
|
|
89
|
-
s.totalOutput += entry.outputTokens;
|
|
90
|
-
s.totalCacheRead += entry.cacheReadTokens;
|
|
91
|
-
s.totalCacheWrite += entry.cacheWriteTokens;
|
|
92
|
-
s.totalCost += entry.cost;
|
|
93
|
-
if (entry.ttft_ms != null) {
|
|
94
|
-
// Bug fix: 分母使用 ttftCount(有效样本数),而非 requestCount(总请求数)
|
|
95
|
-
s.ttftCount++;
|
|
96
|
-
const c = s.ttftCount;
|
|
97
|
-
s.avgTTFT = s.avgTTFT != null ? s.avgTTFT + (entry.ttft_ms - s.avgTTFT) / c : entry.ttft_ms;
|
|
98
|
-
s.maxTTFT = s.maxTTFT != null ? Math.max(s.maxTTFT, entry.ttft_ms) : entry.ttft_ms;
|
|
99
|
-
s.minTTFT = s.minTTFT != null ? Math.min(s.minTTFT, entry.ttft_ms) : entry.ttft_ms;
|
|
100
|
-
}
|
|
101
|
-
if (entry.tps != null) {
|
|
102
|
-
// Bug fix: 分母使用 tpsCount(有效样本数)
|
|
103
|
-
s.tpsCount++;
|
|
104
|
-
const c = s.tpsCount;
|
|
105
|
-
s.avgTPS = s.avgTPS != null ? s.avgTPS + (entry.tps - s.avgTPS) / c : entry.tps;
|
|
106
|
-
s.maxTPS = s.maxTPS != null ? Math.max(s.maxTPS, entry.tps) : entry.tps;
|
|
107
|
-
s.minTPS = s.minTPS != null ? Math.min(s.minTPS, entry.tps) : entry.tps;
|
|
108
|
-
}
|
|
109
|
-
if (entry.latency_ms != null) {
|
|
110
|
-
s.latencyCount++;
|
|
111
|
-
const c = s.latencyCount;
|
|
112
|
-
s.avgLatency = s.avgLatency != null ? s.avgLatency + (entry.latency_ms - s.avgLatency) / c : entry.latency_ms;
|
|
113
|
-
s.maxLatency = s.maxLatency != null ? Math.max(s.maxLatency, entry.latency_ms) : entry.latency_ms;
|
|
114
|
-
s.minLatency = s.minLatency != null ? Math.min(s.minLatency, entry.latency_ms) : entry.latency_ms;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
// 分位数后处理:需要收集每个模型的所有样本然后计算
|
|
118
|
-
// 注: 此处采用单次遍历日志重新收集分数据,需要两次遍历
|
|
119
|
-
const ttftBuckets = new Map();
|
|
120
|
-
const latBuckets = new Map();
|
|
121
|
-
for (const entry of logs) {
|
|
122
|
-
const key = entry.model;
|
|
123
|
-
if (entry.ttft_ms != null) {
|
|
124
|
-
const arr = ttftBuckets.get(key) ?? [];
|
|
125
|
-
arr.push(entry.ttft_ms);
|
|
126
|
-
ttftBuckets.set(key, arr);
|
|
127
|
-
}
|
|
128
|
-
if (entry.latency_ms != null) {
|
|
129
|
-
const arr = latBuckets.get(key) ?? [];
|
|
130
|
-
arr.push(entry.latency_ms);
|
|
131
|
-
latBuckets.set(key, arr);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
const result = Array.from(map.values());
|
|
135
|
-
for (const s of result) {
|
|
136
|
-
const ttftArr = [...(ttftBuckets.get(s.model) ?? [])].sort((a, b) => a - b);
|
|
137
|
-
s.p50TTFT = computePercentile(ttftArr, 50);
|
|
138
|
-
s.p95TTFT = computePercentile(ttftArr, 95);
|
|
139
|
-
s.p99TTFT = computePercentile(ttftArr, 99);
|
|
140
|
-
const latArr = [...(latBuckets.get(s.model) ?? [])].sort((a, b) => a - b);
|
|
141
|
-
s.p50Latency = computePercentile(latArr, 50);
|
|
142
|
-
s.p95Latency = computePercentile(latArr, 95);
|
|
143
|
-
s.p99Latency = computePercentile(latArr, 99);
|
|
144
|
-
const denom = s.totalInput + s.totalCacheRead;
|
|
145
|
-
s.cacheHitRate = denom > 0 ? (s.totalCacheRead / denom) * 100 : null;
|
|
146
|
-
}
|
|
147
|
-
return result;
|
|
148
|
-
}
|
|
149
|
-
async function buildCombinedData(api, filters = {}) {
|
|
150
|
-
const report = await getUsageReport(filters);
|
|
151
|
-
// perfLogs: 仅用于 JSON 导出参考,保持适当窗口即可
|
|
152
|
-
const logs = readLogs(200);
|
|
153
|
-
// perfSummary: 使用持久化聚合统计,包含自插件安装以来的全量历史数据
|
|
154
|
-
// 不再受 readLogs 窗口限制,即使 JSONL 被轮转,历史指标也不会丢失
|
|
155
|
-
const perfSummary = readPersistedStats();
|
|
156
|
-
const now = new Date();
|
|
157
|
-
const pad = (n) => String(n).padStart(2, '0');
|
|
158
|
-
const meta = {
|
|
159
|
-
generatedAt: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
|
|
160
|
-
dateRange: {
|
|
161
|
-
start: report.daily.length > 0 ? report.daily[report.daily.length - 1].day : "—",
|
|
162
|
-
end: report.daily.length > 0 ? report.daily[0].day : "—",
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
return {
|
|
166
|
-
...report,
|
|
167
|
-
perfLogs: logs,
|
|
168
|
-
perfSummary,
|
|
169
|
-
meta,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
async function showHtmlReport(api, filters = {}) {
|
|
173
|
-
try {
|
|
174
|
-
const data = await buildCombinedData(api, filters);
|
|
175
|
-
const html = generateUsageHtml(data);
|
|
176
|
-
const dir = ensureReportDir();
|
|
177
|
-
const dateStr = new Date().toISOString().slice(0, 10);
|
|
178
|
-
const filePath = join(dir, `tokenwatch-${dateStr}.html`);
|
|
179
|
-
writeFileSync(filePath, html, "utf-8");
|
|
180
|
-
api.ui.toast?.({ message: `Report: ${filePath}`, variant: "info" });
|
|
181
|
-
openInBrowser(filePath);
|
|
182
|
-
}
|
|
183
|
-
catch (err) {
|
|
184
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
185
|
-
api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
function showHtmlReportRangeMenu(api, dialog) {
|
|
189
|
-
dialog.replace(() => (<api.ui.DialogSelect title={t("cmdTitleHtml")} placeholder="Select date range..." options={[
|
|
190
|
-
{
|
|
191
|
-
title: t("menuToday"),
|
|
192
|
-
value: "today",
|
|
193
|
-
onSelect: () => {
|
|
194
|
-
dialog.clear();
|
|
195
|
-
const d = new Date();
|
|
196
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
197
|
-
const s = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
198
|
-
showHtmlReport(api, { startDate: s, endDate: s });
|
|
199
|
-
},
|
|
200
|
-
},
|
|
201
|
-
{
|
|
202
|
-
title: t("menu7d"),
|
|
203
|
-
value: "7d",
|
|
204
|
-
onSelect: () => { dialog.clear(); showHtmlReport(api, getPresetRange("7d")); },
|
|
205
|
-
},
|
|
206
|
-
{
|
|
207
|
-
title: t("menu30d"),
|
|
208
|
-
value: "30d",
|
|
209
|
-
onSelect: () => { dialog.clear(); showHtmlReport(api, getPresetRange("30d")); },
|
|
210
|
-
},
|
|
211
|
-
{
|
|
212
|
-
title: t("menuAll"),
|
|
213
|
-
value: "all",
|
|
214
|
-
onSelect: () => { dialog.clear(); showHtmlReport(api, getPresetRange("all")); },
|
|
215
|
-
},
|
|
216
|
-
]} flat={true}/>));
|
|
217
|
-
}
|
|
218
|
-
function showUsageMenu(api, dialog) {
|
|
219
|
-
try {
|
|
220
|
-
setLanguage(loadConfigFromStore(api).language);
|
|
221
|
-
}
|
|
222
|
-
catch { }
|
|
223
|
-
dialog.replace(() => (<api.ui.DialogSelect title={t("panelTitle")} placeholder="Select an action..." options={[
|
|
224
|
-
{
|
|
225
|
-
title: `${t("cmdTitleHtml")} ▸`,
|
|
226
|
-
value: "html",
|
|
227
|
-
description: t("cmdDescHtml"),
|
|
228
|
-
onSelect: () => showHtmlReportRangeMenu(api, dialog),
|
|
229
|
-
},
|
|
230
|
-
{
|
|
231
|
-
title: t("cmdTitleJson"),
|
|
232
|
-
value: "json",
|
|
233
|
-
description: t("cmdDescJson"),
|
|
234
|
-
onSelect: () => { dialog.clear(); showJsonExport(api); },
|
|
235
|
-
},
|
|
236
|
-
{
|
|
237
|
-
title: t("cmdTitleText"),
|
|
238
|
-
value: "text",
|
|
239
|
-
description: t("cmdDescText"),
|
|
240
|
-
onSelect: () => { dialog.clear(); showTextReport(api); },
|
|
241
|
-
},
|
|
242
|
-
{
|
|
243
|
-
title: `${t("cmdTitleSettings")} ▸`,
|
|
244
|
-
value: "settings",
|
|
245
|
-
description: t("cmdDescSettings"),
|
|
246
|
-
onSelect: () => showSettingsDialog(api, dialog),
|
|
247
|
-
},
|
|
248
|
-
]} flat={true}/>));
|
|
249
|
-
}
|
|
250
|
-
async function showJsonExport(api) {
|
|
251
|
-
try {
|
|
252
|
-
const report = await getUsageReport({});
|
|
253
|
-
const dir = ensureReportDir();
|
|
254
|
-
const dateStr = new Date().toISOString().slice(0, 10);
|
|
255
|
-
const filePath = join(dir, `tokenwatch-${dateStr}.json`);
|
|
256
|
-
writeFileSync(filePath, JSON.stringify(report, null, 2), "utf-8");
|
|
257
|
-
api.ui.toast?.({ message: `JSON: ${filePath}`, variant: "info" });
|
|
258
|
-
}
|
|
259
|
-
catch (err) {
|
|
260
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
261
|
-
api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
async function showTextReport(api) {
|
|
265
|
-
try {
|
|
266
|
-
const report = await getUsageReport({});
|
|
267
|
-
const formatted = formatUsageReport(report);
|
|
268
|
-
const dir = ensureReportDir();
|
|
269
|
-
const dateStr = new Date().toISOString().slice(0, 10);
|
|
270
|
-
const filePath = join(dir, `tokenwatch-${dateStr}.md`);
|
|
271
|
-
writeFileSync(filePath, formatted, "utf-8");
|
|
272
|
-
api.ui.toast?.({ message: `Report saved to ${filePath}`, variant: "info" });
|
|
273
|
-
}
|
|
274
|
-
catch (err) {
|
|
275
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
276
|
-
api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
function saveConfigToStore(api, cfg) {
|
|
280
|
-
api.kv?.set?.("tokenwatch-config", cfg);
|
|
281
|
-
}
|
|
282
|
-
let lastSelectedSetting;
|
|
283
|
-
function showSettingsDialog(api, dialog) {
|
|
284
|
-
if (!dialog)
|
|
285
|
-
return;
|
|
286
|
-
const reopen = (value) => {
|
|
287
|
-
lastSelectedSetting = value;
|
|
288
|
-
setTimeout(() => showSettingsDialog(api, dialog), 0);
|
|
289
|
-
};
|
|
290
|
-
const cfg = loadConfigFromStore(api).sidebar;
|
|
291
|
-
dialog.replace(() => (<api.ui.DialogSelect title={t("settingsTitle")} placeholder={t("settingsPlaceholder")} options={[
|
|
292
|
-
{
|
|
293
|
-
title: `${cfg.showPerformance ? "✓ " : " "}${t("showPerformance")}`,
|
|
294
|
-
value: "showPerformance",
|
|
295
|
-
description: t("descShowPerformance"),
|
|
296
|
-
onSelect: () => { toggleSidebarSetting(api, "showPerformance"); reopen("showPerformance"); },
|
|
297
|
-
},
|
|
298
|
-
{
|
|
299
|
-
title: `${cfg.showPricing ? "✓ " : " "}${t("showPricing")}`,
|
|
300
|
-
value: "showPricing",
|
|
301
|
-
description: t("descShowPricing"),
|
|
302
|
-
onSelect: () => { toggleSidebarSetting(api, "showPricing"); reopen("showPricing"); },
|
|
303
|
-
},
|
|
304
|
-
{
|
|
305
|
-
title: `${cfg.showTokenDistribution ? "✓ " : " "}${t("showTokenDistribution")}`,
|
|
306
|
-
value: "showTokenDistribution",
|
|
307
|
-
description: t("descShowTokenDistribution"),
|
|
308
|
-
onSelect: () => { toggleSidebarSetting(api, "showTokenDistribution"); reopen("showTokenDistribution"); },
|
|
309
|
-
},
|
|
310
|
-
{
|
|
311
|
-
title: `${cfg.showTrend ? "✓ " : " "}${t("showTrend")}`,
|
|
312
|
-
value: "showTrend",
|
|
313
|
-
description: t("descShowTrend"),
|
|
314
|
-
onSelect: () => { toggleSidebarSetting(api, "showTrend"); reopen("showTrend"); },
|
|
315
|
-
},
|
|
316
|
-
{
|
|
317
|
-
title: `${t("settingsLanguage")} ▸`,
|
|
318
|
-
value: "language",
|
|
319
|
-
description: t("descSettingsLanguage"),
|
|
320
|
-
onSelect: () => showLanguageMenu(api, dialog),
|
|
321
|
-
},
|
|
322
|
-
{
|
|
323
|
-
title: t("done"),
|
|
324
|
-
value: "done",
|
|
325
|
-
description: t("closeSettings"),
|
|
326
|
-
onSelect: () => { lastSelectedSetting = undefined; dialog.clear(); },
|
|
327
|
-
},
|
|
328
|
-
]} flat={true} current={lastSelectedSetting}/>));
|
|
329
|
-
}
|
|
330
|
-
function showLanguageMenu(api, dialog) {
|
|
331
|
-
const current = api.kv?.get?.("tokenwatch-config")?.language ?? "auto";
|
|
332
|
-
dialog.replace(() => (<api.ui.DialogSelect title={t("settingsLanguage")} placeholder={t("settingsLanguage")} options={[
|
|
333
|
-
{
|
|
334
|
-
title: `${current === "auto" ? "✓ " : " "}${t("langAuto")}`,
|
|
335
|
-
value: "auto",
|
|
336
|
-
description: "自动检测 / Auto-detect",
|
|
337
|
-
onSelect: () => { setLanguageSetting(api, "auto"); lastSelectedSetting = "language"; dialog.clear(); showSettingsDialog(api, dialog); },
|
|
338
|
-
},
|
|
339
|
-
{
|
|
340
|
-
title: `${current === "zh" ? "✓ " : " "}中文`,
|
|
341
|
-
value: "zh",
|
|
342
|
-
description: "简体中文",
|
|
343
|
-
onSelect: () => { setLanguageSetting(api, "zh"); lastSelectedSetting = "language"; dialog.clear(); showSettingsDialog(api, dialog); },
|
|
344
|
-
},
|
|
345
|
-
{
|
|
346
|
-
title: `${current === "en" ? "✓ " : " "}English`,
|
|
347
|
-
value: "en",
|
|
348
|
-
description: "English",
|
|
349
|
-
onSelect: () => { setLanguageSetting(api, "en"); lastSelectedSetting = "language"; dialog.clear(); showSettingsDialog(api, dialog); },
|
|
350
|
-
},
|
|
351
|
-
]} flat={true}/>));
|
|
352
|
-
}
|
|
353
|
-
function setLanguageSetting(api, lang) {
|
|
354
|
-
setLanguage(lang);
|
|
355
|
-
const current = loadConfigFromStore(api);
|
|
356
|
-
current.language = lang;
|
|
357
|
-
saveConfigToStore(api, current);
|
|
358
|
-
const v = (api.kv?.get?.("tokenwatch-config-version") ?? 0) + 1;
|
|
359
|
-
api.kv?.set?.("tokenwatch-config-version", v);
|
|
360
|
-
}
|
|
361
|
-
function toggleSidebarSetting(api, key) {
|
|
362
|
-
const current = loadConfigFromStore(api);
|
|
363
|
-
current.sidebar[key] = !current.sidebar[key];
|
|
364
|
-
saveConfigToStore(api, current);
|
|
365
|
-
const v = (api.kv?.get?.("tokenwatch-config-version") ?? 0) + 1;
|
|
366
|
-
api.kv?.set?.("tokenwatch-config-version", v);
|
|
367
|
-
}
|
|
368
|
-
function loadConfigFromStore(api) {
|
|
369
|
-
const base = { sidebar: { ...DEFAULT_CONFIG.sidebar }, language: DEFAULT_CONFIG.language };
|
|
370
|
-
try {
|
|
371
|
-
const stored = api.kv?.get?.("tokenwatch-config");
|
|
372
|
-
if (stored) {
|
|
373
|
-
if (stored.sidebar)
|
|
374
|
-
Object.assign(base.sidebar, stored.sidebar);
|
|
375
|
-
if (stored.language)
|
|
376
|
-
base.language = stored.language;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
catch { /* defaults */ }
|
|
380
|
-
return base;
|
|
381
|
-
}
|