opencode-tokenwatch 0.3.2 → 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/LICENSE +21 -21
- package/README.en.md +79 -107
- package/README.md +79 -107
- package/dist/server.js +11 -0
- package/dist/sidebar.d.ts +1 -1
- package/dist/tui.js +3611 -0
- package/package.json +72 -63
- package/dist/commands.jsx +0 -381
- package/dist/formatter.js +0 -289
- package/dist/generate-usage-html.js +0 -886
- 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/dist/tui.js
ADDED
|
@@ -0,0 +1,3611 @@
|
|
|
1
|
+
// src/tui.tsx
|
|
2
|
+
import { createComponent as _$createComponent3 } from "@opentui/solid";
|
|
3
|
+
import { createSignal as createSignal2, createEffect as createEffect2, onCleanup as onCleanup2 } from "solid-js";
|
|
4
|
+
|
|
5
|
+
// src/commands.tsx
|
|
6
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
7
|
+
|
|
8
|
+
// src/queries.ts
|
|
9
|
+
import { exec } from "node:child_process";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
function buildEnvWithOpencodePath() {
|
|
12
|
+
const nodeDir = dirname(process.execPath);
|
|
13
|
+
const candidateDirs = [
|
|
14
|
+
// nvm on Windows: opencode-ai 包的 bin 目录
|
|
15
|
+
join(nodeDir, "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64", "bin"),
|
|
16
|
+
// npm global install: opencode 直接在 node_modules/.bin
|
|
17
|
+
join(nodeDir, "node_modules", ".bin"),
|
|
18
|
+
// 也加入 node.exe 所在目录本身(nvm 可能在此放 shim)
|
|
19
|
+
nodeDir
|
|
20
|
+
];
|
|
21
|
+
const pathSep = process.platform === "win32" ? ";" : ":";
|
|
22
|
+
const extraPath = candidateDirs.join(pathSep);
|
|
23
|
+
return {
|
|
24
|
+
...process.env,
|
|
25
|
+
PATH: `${extraPath}${pathSep}${process.env.PATH ?? ""}`
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
var OPENCODE_ENV = buildEnvWithOpencodePath();
|
|
29
|
+
function execAsync(cmd) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
exec(cmd, { windowsHide: true, timeout: 3e4, env: OPENCODE_ENV }, (error, stdout, stderr) => {
|
|
32
|
+
if (error) reject(Object.assign(error, { stderr }));
|
|
33
|
+
else resolve({ stdout, stderr });
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async function queryDb(sql) {
|
|
38
|
+
const flatSql = sql.replace(/\s+/g, " ").trim();
|
|
39
|
+
const { stdout, stderr } = await execAsync(`opencode db ${JSON.stringify(flatSql)} --format json`);
|
|
40
|
+
if (stderr) throw new Error(stderr.trim());
|
|
41
|
+
const parsed = JSON.parse(stdout.trim());
|
|
42
|
+
return Array.isArray(parsed) ? parsed : parsed.data ?? [];
|
|
43
|
+
}
|
|
44
|
+
function escapeSql(value) {
|
|
45
|
+
return value.replace(/'/g, "''");
|
|
46
|
+
}
|
|
47
|
+
function isValidDate(s) {
|
|
48
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(s);
|
|
49
|
+
}
|
|
50
|
+
function messageWhere(filters) {
|
|
51
|
+
const where = [
|
|
52
|
+
"json_extract(m.data, '$.role') = 'assistant'",
|
|
53
|
+
"coalesce(json_extract(m.data, '$.tokens.total'), 0) > 0"
|
|
54
|
+
];
|
|
55
|
+
if (filters.sessionId) where.push(`m.session_id = '${escapeSql(filters.sessionId)}'`);
|
|
56
|
+
if (filters.provider) where.push(`coalesce(json_extract(m.data, '$.providerID'), '') = '${escapeSql(filters.provider)}'`);
|
|
57
|
+
if (filters.model) where.push(`coalesce(json_extract(m.data, '$.modelID'), '') = '${escapeSql(filters.model)}'`);
|
|
58
|
+
if (filters.startDate && isValidDate(filters.startDate)) {
|
|
59
|
+
where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') >= '${filters.startDate}'`);
|
|
60
|
+
}
|
|
61
|
+
if (filters.endDate && isValidDate(filters.endDate)) {
|
|
62
|
+
where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') <= '${filters.endDate}'`);
|
|
63
|
+
}
|
|
64
|
+
return where.join(" AND ");
|
|
65
|
+
}
|
|
66
|
+
function parseList(value) {
|
|
67
|
+
if (!value) return [];
|
|
68
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
function toSessionTokenData(row) {
|
|
71
|
+
const models = parseList(row?.models_used);
|
|
72
|
+
const providers = parseList(row?.providers_used);
|
|
73
|
+
return {
|
|
74
|
+
model: models.length === 1 ? models[0] : "",
|
|
75
|
+
provider: providers.length === 1 ? providers[0] : "",
|
|
76
|
+
modelsUsed: models,
|
|
77
|
+
totalTokens: row?.total_tokens ?? 0,
|
|
78
|
+
inputTokens: row?.input_tokens ?? 0,
|
|
79
|
+
outputTokens: row?.output_tokens ?? 0,
|
|
80
|
+
reasoningTokens: row?.reasoning_tokens ?? 0,
|
|
81
|
+
cacheRead: row?.cache_read ?? 0,
|
|
82
|
+
cacheWrite: row?.cache_write ?? 0,
|
|
83
|
+
totalCost: row?.total_cost ?? 0,
|
|
84
|
+
requestCount: row?.request_count ?? 0
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function getPresetRange(preset) {
|
|
88
|
+
if (preset === "all") return {};
|
|
89
|
+
const end = /* @__PURE__ */ new Date();
|
|
90
|
+
const start = new Date(end);
|
|
91
|
+
if (preset === "7d") start.setDate(end.getDate() - 6);
|
|
92
|
+
if (preset === "30d") start.setDate(end.getDate() - 29);
|
|
93
|
+
if (preset === "month") start.setDate(1);
|
|
94
|
+
const format = (date) => {
|
|
95
|
+
const year = date.getFullYear();
|
|
96
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
97
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
98
|
+
return `${year}-${month}-${day}`;
|
|
99
|
+
};
|
|
100
|
+
return { startDate: format(start), endDate: format(end) };
|
|
101
|
+
}
|
|
102
|
+
async function getSummary(filters = {}) {
|
|
103
|
+
const sql = `
|
|
104
|
+
SELECT
|
|
105
|
+
group_concat(distinct coalesce(json_extract(m.data, '$.modelID'), 'unknown')) as models_used,
|
|
106
|
+
group_concat(distinct coalesce(json_extract(m.data, '$.providerID'), 'unknown')) as providers_used,
|
|
107
|
+
count(*) as request_count,
|
|
108
|
+
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
109
|
+
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
110
|
+
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
111
|
+
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
112
|
+
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
113
|
+
sum(coalesce(json_extract(m.data, '$.tokens.cache.write'), 0)) as cache_write,
|
|
114
|
+
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
|
|
115
|
+
FROM message m
|
|
116
|
+
WHERE ${messageWhere(filters)}
|
|
117
|
+
`.trim();
|
|
118
|
+
const rows = await queryDb(sql);
|
|
119
|
+
return toSessionTokenData(rows[0]);
|
|
120
|
+
}
|
|
121
|
+
async function getModelBreakdown(filters = {}) {
|
|
122
|
+
const sql = `
|
|
123
|
+
SELECT
|
|
124
|
+
coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
|
|
125
|
+
coalesce(json_extract(m.data, '$.modelID'), 'unknown') as model,
|
|
126
|
+
count(*) as requests,
|
|
127
|
+
count(distinct m.session_id) as sessions,
|
|
128
|
+
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
129
|
+
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
130
|
+
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
131
|
+
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
132
|
+
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
133
|
+
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
|
|
134
|
+
FROM message m
|
|
135
|
+
WHERE ${messageWhere(filters)}
|
|
136
|
+
GROUP BY provider, model
|
|
137
|
+
ORDER BY total_tokens DESC
|
|
138
|
+
`.trim();
|
|
139
|
+
const rows = await queryDb(sql);
|
|
140
|
+
return rows.map((row) => ({
|
|
141
|
+
provider: row.provider ?? "unknown",
|
|
142
|
+
model: row.model ?? "unknown",
|
|
143
|
+
requests: row.requests ?? 0,
|
|
144
|
+
sessions: row.sessions ?? 0,
|
|
145
|
+
totalTokens: row.total_tokens ?? 0,
|
|
146
|
+
inputTokens: row.input_tokens ?? 0,
|
|
147
|
+
outputTokens: row.output_tokens ?? 0,
|
|
148
|
+
reasoningTokens: row.reasoning_tokens ?? 0,
|
|
149
|
+
cacheRead: row.cache_read ?? 0,
|
|
150
|
+
totalCost: row.total_cost ?? 0
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
async function getProviderBreakdown(filters = {}) {
|
|
154
|
+
const sql = `
|
|
155
|
+
SELECT
|
|
156
|
+
coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
|
|
157
|
+
count(*) as requests,
|
|
158
|
+
count(distinct m.session_id) as sessions,
|
|
159
|
+
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
160
|
+
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
161
|
+
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
162
|
+
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
163
|
+
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
164
|
+
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
|
|
165
|
+
FROM message m
|
|
166
|
+
WHERE ${messageWhere(filters)}
|
|
167
|
+
GROUP BY provider
|
|
168
|
+
ORDER BY total_tokens DESC
|
|
169
|
+
`.trim();
|
|
170
|
+
const rows = await queryDb(sql);
|
|
171
|
+
return rows.map((row) => ({
|
|
172
|
+
provider: row.provider ?? "unknown",
|
|
173
|
+
requests: row.requests ?? 0,
|
|
174
|
+
sessions: row.sessions ?? 0,
|
|
175
|
+
totalTokens: row.total_tokens ?? 0,
|
|
176
|
+
inputTokens: row.input_tokens ?? 0,
|
|
177
|
+
outputTokens: row.output_tokens ?? 0,
|
|
178
|
+
reasoningTokens: row.reasoning_tokens ?? 0,
|
|
179
|
+
cacheRead: row.cache_read ?? 0,
|
|
180
|
+
totalCost: row.total_cost ?? 0
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
async function getDailyBreakdown(filters = {}) {
|
|
184
|
+
const limit = filters.limit ?? 30;
|
|
185
|
+
const sql = `
|
|
186
|
+
SELECT
|
|
187
|
+
date(m.time_created / 1000, 'unixepoch', 'localtime') as day,
|
|
188
|
+
count(*) as requests,
|
|
189
|
+
count(distinct m.session_id) as sessions,
|
|
190
|
+
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
191
|
+
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
192
|
+
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
193
|
+
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
194
|
+
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
195
|
+
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
|
|
196
|
+
FROM message m
|
|
197
|
+
WHERE ${messageWhere(filters)}
|
|
198
|
+
GROUP BY day
|
|
199
|
+
ORDER BY day DESC
|
|
200
|
+
LIMIT ${Math.max(1, limit)}
|
|
201
|
+
`.trim();
|
|
202
|
+
const rows = await queryDb(sql);
|
|
203
|
+
return rows.map((row) => ({
|
|
204
|
+
day: row.day ?? "",
|
|
205
|
+
requests: row.requests ?? 0,
|
|
206
|
+
sessions: row.sessions ?? 0,
|
|
207
|
+
totalTokens: row.total_tokens ?? 0,
|
|
208
|
+
inputTokens: row.input_tokens ?? 0,
|
|
209
|
+
outputTokens: row.output_tokens ?? 0,
|
|
210
|
+
reasoningTokens: row.reasoning_tokens ?? 0,
|
|
211
|
+
cacheRead: row.cache_read ?? 0,
|
|
212
|
+
totalCost: row.total_cost ?? 0
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
async function getSessionBreakdown(filters = {}) {
|
|
216
|
+
const limit = filters.limit ?? 15;
|
|
217
|
+
const sql = `
|
|
218
|
+
SELECT
|
|
219
|
+
s.id as session_id,
|
|
220
|
+
s.title as title,
|
|
221
|
+
coalesce(json_extract(m.data, '$.providerID'), json_extract(s.model, '$.providerID'), 'unknown') as provider,
|
|
222
|
+
coalesce(json_extract(m.data, '$.modelID'), json_extract(s.model, '$.id'), 'unknown') as model,
|
|
223
|
+
count(*) as requests,
|
|
224
|
+
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
225
|
+
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
226
|
+
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
227
|
+
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
228
|
+
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
229
|
+
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost,
|
|
230
|
+
date(max(m.time_created) / 1000, 'unixepoch', 'localtime') as day
|
|
231
|
+
FROM message m
|
|
232
|
+
JOIN session s ON s.id = m.session_id
|
|
233
|
+
WHERE ${messageWhere(filters)}
|
|
234
|
+
GROUP BY s.id, s.title, provider, model
|
|
235
|
+
ORDER BY max(m.time_created) DESC
|
|
236
|
+
LIMIT ${Math.max(1, limit)}
|
|
237
|
+
`.trim();
|
|
238
|
+
const rows = await queryDb(sql);
|
|
239
|
+
return rows.map((row) => ({
|
|
240
|
+
sessionId: row.session_id ?? "",
|
|
241
|
+
title: row.title ?? "(untitled)",
|
|
242
|
+
provider: row.provider ?? "unknown",
|
|
243
|
+
model: row.model ?? "unknown",
|
|
244
|
+
requests: row.requests ?? 0,
|
|
245
|
+
totalTokens: row.total_tokens ?? 0,
|
|
246
|
+
inputTokens: row.input_tokens ?? 0,
|
|
247
|
+
outputTokens: row.output_tokens ?? 0,
|
|
248
|
+
reasoningTokens: row.reasoning_tokens ?? 0,
|
|
249
|
+
cacheRead: row.cache_read ?? 0,
|
|
250
|
+
totalCost: row.total_cost ?? 0,
|
|
251
|
+
day: row.day ?? ""
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
async function getErrorStats(filters = {}) {
|
|
255
|
+
const baseConds = [
|
|
256
|
+
"json_extract(m.data, '$.role') = 'assistant'"
|
|
257
|
+
];
|
|
258
|
+
if (filters.sessionId) baseConds.push(`m.session_id = '${escapeSql(filters.sessionId)}'`);
|
|
259
|
+
if (filters.provider) baseConds.push(`coalesce(json_extract(m.data, '$.providerID'), '') = '${escapeSql(filters.provider)}'`);
|
|
260
|
+
if (filters.model) baseConds.push(`coalesce(json_extract(m.data, '$.modelID'), '') = '${escapeSql(filters.model)}'`);
|
|
261
|
+
if (filters.startDate && isValidDate(filters.startDate)) {
|
|
262
|
+
baseConds.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') >= '${filters.startDate}'`);
|
|
263
|
+
}
|
|
264
|
+
if (filters.endDate && isValidDate(filters.endDate)) {
|
|
265
|
+
baseConds.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') <= '${filters.endDate}'`);
|
|
266
|
+
}
|
|
267
|
+
const baseWhere = baseConds.join(" AND ");
|
|
268
|
+
const sql = `
|
|
269
|
+
SELECT
|
|
270
|
+
coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
|
|
271
|
+
coalesce(json_extract(m.data, '$.modelID'), 'unknown') as model,
|
|
272
|
+
count(*) as total,
|
|
273
|
+
sum(CASE WHEN coalesce(json_extract(m.data, '$.tokens.total'), 0) = 0 THEN 1 ELSE 0 END) as failed
|
|
274
|
+
FROM message m
|
|
275
|
+
WHERE ${baseWhere}
|
|
276
|
+
GROUP BY provider, model
|
|
277
|
+
ORDER BY failed DESC
|
|
278
|
+
`.trim();
|
|
279
|
+
try {
|
|
280
|
+
const rows = await queryDb(sql);
|
|
281
|
+
let successCount = 0, failedCount = 0;
|
|
282
|
+
const byModel = rows.map((r) => {
|
|
283
|
+
const total = r.total ?? 0;
|
|
284
|
+
const failed = r.failed ?? 0;
|
|
285
|
+
const success = total - failed;
|
|
286
|
+
successCount += success;
|
|
287
|
+
failedCount += failed;
|
|
288
|
+
return { provider: r.provider ?? "unknown", model: r.model ?? "unknown", failed, total };
|
|
289
|
+
});
|
|
290
|
+
const errorRate = successCount + failedCount > 0 ? failedCount / (successCount + failedCount) : 0;
|
|
291
|
+
return { successCount, failedCount, errorRate, byModel };
|
|
292
|
+
} catch {
|
|
293
|
+
return { successCount: 0, failedCount: 0, errorRate: 0, byModel: [] };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async function getUsageReport(filters = {}) {
|
|
297
|
+
const [summary, models, providers, daily, sessions, errors] = await Promise.all([
|
|
298
|
+
getSummary(filters),
|
|
299
|
+
getModelBreakdown(filters),
|
|
300
|
+
getProviderBreakdown(filters),
|
|
301
|
+
getDailyBreakdown(filters),
|
|
302
|
+
getSessionBreakdown(filters),
|
|
303
|
+
getErrorStats(filters)
|
|
304
|
+
]);
|
|
305
|
+
return { filters, summary, models, providers, daily, sessions, errors };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/formatter.ts
|
|
309
|
+
function formatTokens(n) {
|
|
310
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
|
|
311
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
312
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
|
313
|
+
return String(n);
|
|
314
|
+
}
|
|
315
|
+
function formatCost(n) {
|
|
316
|
+
if (n === 0) return "$0.00";
|
|
317
|
+
if (n < 0.01) return `$${n.toFixed(4)}`;
|
|
318
|
+
return `$${n.toFixed(2)}`;
|
|
319
|
+
}
|
|
320
|
+
function formatDuration(ms) {
|
|
321
|
+
if (ms === null) return "\u2014";
|
|
322
|
+
if (ms < 1e3) return `${ms.toFixed(0)}ms`;
|
|
323
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
|
|
324
|
+
const m = Math.floor(ms / 6e4);
|
|
325
|
+
const s = Math.floor(ms % 6e4 / 1e3);
|
|
326
|
+
return `${m}m ${s}s`;
|
|
327
|
+
}
|
|
328
|
+
function formatFilters(filters) {
|
|
329
|
+
const parts = [];
|
|
330
|
+
if (filters.sessionId) parts.push(`session=${filters.sessionId}`);
|
|
331
|
+
if (filters.provider) parts.push(`provider=${filters.provider}`);
|
|
332
|
+
if (filters.model) parts.push(`model=${filters.model}`);
|
|
333
|
+
if (filters.startDate || filters.endDate) {
|
|
334
|
+
parts.push(`date=${filters.startDate ?? "..."}..${filters.endDate ?? "..."}`);
|
|
335
|
+
}
|
|
336
|
+
return parts.length ? parts.join(" | ") : "scope=all local sessions";
|
|
337
|
+
}
|
|
338
|
+
function getVisualWidth(str) {
|
|
339
|
+
let width = 0;
|
|
340
|
+
for (let i = 0; i < str.length; i++) {
|
|
341
|
+
width += str.charCodeAt(i) > 255 ? 2 : 1;
|
|
342
|
+
}
|
|
343
|
+
return width;
|
|
344
|
+
}
|
|
345
|
+
function truncateByWidth(str, maxWidth) {
|
|
346
|
+
if (getVisualWidth(str) <= maxWidth) return str;
|
|
347
|
+
let width = 0;
|
|
348
|
+
let res = "";
|
|
349
|
+
for (let i = 0; i < str.length; i++) {
|
|
350
|
+
const charWidth = str.charCodeAt(i) > 255 ? 2 : 1;
|
|
351
|
+
if (width + charWidth > maxWidth - 3) {
|
|
352
|
+
return res + "...";
|
|
353
|
+
}
|
|
354
|
+
width += charWidth;
|
|
355
|
+
res += str[i];
|
|
356
|
+
}
|
|
357
|
+
return res;
|
|
358
|
+
}
|
|
359
|
+
function table(columns, rows, totalRow) {
|
|
360
|
+
const widths = columns.map((col, i) => {
|
|
361
|
+
const rowWidths = rows.map((row) => getVisualWidth(row[i] ?? ""));
|
|
362
|
+
const maxRow = rowWidths.length ? Math.max(...rowWidths) : 0;
|
|
363
|
+
const totalWidth = totalRow ? getVisualWidth(totalRow[i] ?? "") : 0;
|
|
364
|
+
return Math.max(getVisualWidth(col.label), maxRow, totalWidth);
|
|
365
|
+
});
|
|
366
|
+
const renderRow = (cells) => "\u2551" + cells.map((cell, i) => {
|
|
367
|
+
const value = cell ?? "";
|
|
368
|
+
const vWidth = getVisualWidth(value);
|
|
369
|
+
const padding = " ".repeat(widths[i] - vWidth);
|
|
370
|
+
const content = columns[i].align === "right" ? padding + value : value + padding;
|
|
371
|
+
return ` ${content} \u2551`;
|
|
372
|
+
}).join("");
|
|
373
|
+
const sep = (left, mid, right, fill) => left + widths.map((w) => fill.repeat(w + 2)).join(mid) + right;
|
|
374
|
+
const lines = [
|
|
375
|
+
sep("\u2554", "\u2566", "\u2557", "\u2550"),
|
|
376
|
+
renderRow(columns.map((col) => col.label)),
|
|
377
|
+
sep("\u2560", "\u256C", "\u2563", "\u2550"),
|
|
378
|
+
...rows.map(renderRow)
|
|
379
|
+
];
|
|
380
|
+
if (totalRow) {
|
|
381
|
+
lines.push(sep("\u2560", "\u256C", "\u2563", "\u2550"));
|
|
382
|
+
lines.push(renderRow(totalRow));
|
|
383
|
+
}
|
|
384
|
+
lines.push(sep("\u255A", "\u2569", "\u255D", "\u2550"));
|
|
385
|
+
return lines.join("\n");
|
|
386
|
+
}
|
|
387
|
+
function formatSessionSummary(data, title = "Current Session") {
|
|
388
|
+
const modelLabel = data.modelsUsed.length > 1 ? `${data.modelsUsed.length} models` : data.model || "(unknown)";
|
|
389
|
+
return [
|
|
390
|
+
`\u2550\u2550\u2550 ${title} \u2550\u2550\u2550`,
|
|
391
|
+
`Models: ${modelLabel}`,
|
|
392
|
+
`Provider: ${data.provider || "(mixed)"}`,
|
|
393
|
+
`Req: ${data.requestCount}`,
|
|
394
|
+
`Total Tokens: ${formatTokens(data.totalTokens)}`,
|
|
395
|
+
` Input: ${formatTokens(data.inputTokens)}`,
|
|
396
|
+
` Output: ${formatTokens(data.outputTokens)}`,
|
|
397
|
+
` Reasoning: ${formatTokens(data.reasoningTokens)}`,
|
|
398
|
+
` C.Read: ${formatTokens(data.cacheRead)}`,
|
|
399
|
+
` C.Write: ${formatTokens(data.cacheWrite)}`,
|
|
400
|
+
` Cost: ${formatCost(data.totalCost)}`
|
|
401
|
+
].join("\n");
|
|
402
|
+
}
|
|
403
|
+
function formatModelBreakdown(items) {
|
|
404
|
+
if (items.length === 0) return "\u2550\u2550\u2550 Model Breakdown \u2550\u2550\u2550\n(no data)";
|
|
405
|
+
const total = items.reduce((acc, item) => ({
|
|
406
|
+
requests: acc.requests + item.requests,
|
|
407
|
+
totalTokens: acc.totalTokens + item.totalTokens,
|
|
408
|
+
inputTokens: acc.inputTokens + item.inputTokens,
|
|
409
|
+
outputTokens: acc.outputTokens + item.outputTokens,
|
|
410
|
+
cacheRead: acc.cacheRead + item.cacheRead
|
|
411
|
+
}), {
|
|
412
|
+
requests: 0,
|
|
413
|
+
totalTokens: 0,
|
|
414
|
+
inputTokens: 0,
|
|
415
|
+
outputTokens: 0,
|
|
416
|
+
cacheRead: 0
|
|
417
|
+
});
|
|
418
|
+
const rows = items.map((item) => [
|
|
419
|
+
item.provider || "-",
|
|
420
|
+
truncateByWidth(item.model, 20),
|
|
421
|
+
String(item.requests),
|
|
422
|
+
formatTokens(item.totalTokens),
|
|
423
|
+
formatTokens(item.inputTokens),
|
|
424
|
+
formatTokens(item.outputTokens),
|
|
425
|
+
formatTokens(item.cacheRead)
|
|
426
|
+
]);
|
|
427
|
+
return [
|
|
428
|
+
"\u2550\u2550\u2550 Model Breakdown \u2550\u2550\u2550",
|
|
429
|
+
table([
|
|
430
|
+
{ label: "Provider", align: "left" },
|
|
431
|
+
{ label: "Model", align: "left" },
|
|
432
|
+
{ label: "Req", align: "right" },
|
|
433
|
+
{ label: "Total", align: "right" },
|
|
434
|
+
{ label: "In", align: "right" },
|
|
435
|
+
{ label: "Out", align: "right" },
|
|
436
|
+
{ label: "Cache", align: "right" }
|
|
437
|
+
], rows, [
|
|
438
|
+
"TOTAL",
|
|
439
|
+
"",
|
|
440
|
+
String(total.requests),
|
|
441
|
+
formatTokens(total.totalTokens),
|
|
442
|
+
formatTokens(total.inputTokens),
|
|
443
|
+
formatTokens(total.outputTokens),
|
|
444
|
+
formatTokens(total.cacheRead)
|
|
445
|
+
])
|
|
446
|
+
].join("\n");
|
|
447
|
+
}
|
|
448
|
+
function formatProviderBreakdown(items) {
|
|
449
|
+
if (items.length === 0) return "\u2550\u2550\u2550 Provider Breakdown \u2550\u2550\u2550\n(no data)";
|
|
450
|
+
const total = items.reduce((acc, item) => ({
|
|
451
|
+
requests: acc.requests + item.requests,
|
|
452
|
+
totalTokens: acc.totalTokens + item.totalTokens,
|
|
453
|
+
inputTokens: acc.inputTokens + item.inputTokens,
|
|
454
|
+
outputTokens: acc.outputTokens + item.outputTokens,
|
|
455
|
+
cacheRead: acc.cacheRead + item.cacheRead
|
|
456
|
+
}), {
|
|
457
|
+
requests: 0,
|
|
458
|
+
totalTokens: 0,
|
|
459
|
+
inputTokens: 0,
|
|
460
|
+
outputTokens: 0,
|
|
461
|
+
cacheRead: 0
|
|
462
|
+
});
|
|
463
|
+
const rows = items.map((item) => [
|
|
464
|
+
item.provider || "-",
|
|
465
|
+
String(item.requests),
|
|
466
|
+
formatTokens(item.totalTokens),
|
|
467
|
+
formatTokens(item.inputTokens),
|
|
468
|
+
formatTokens(item.outputTokens),
|
|
469
|
+
formatTokens(item.cacheRead)
|
|
470
|
+
]);
|
|
471
|
+
return [
|
|
472
|
+
"\u2550\u2550\u2550 Provider Breakdown \u2550\u2550\u2550",
|
|
473
|
+
table([
|
|
474
|
+
{ label: "Provider", align: "left" },
|
|
475
|
+
{ label: "Req", align: "right" },
|
|
476
|
+
{ label: "Total", align: "right" },
|
|
477
|
+
{ label: "In", align: "right" },
|
|
478
|
+
{ label: "Out", align: "right" },
|
|
479
|
+
{ label: "Cache", align: "right" }
|
|
480
|
+
], rows, [
|
|
481
|
+
"TOTAL",
|
|
482
|
+
String(total.requests),
|
|
483
|
+
formatTokens(total.totalTokens),
|
|
484
|
+
formatTokens(total.inputTokens),
|
|
485
|
+
formatTokens(total.outputTokens),
|
|
486
|
+
formatTokens(total.cacheRead)
|
|
487
|
+
])
|
|
488
|
+
].join("\n");
|
|
489
|
+
}
|
|
490
|
+
function formatDailyBreakdown(items) {
|
|
491
|
+
if (items.length === 0) return "\u2550\u2550\u2550 Daily Breakdown \u2550\u2550\u2550\n(no data)";
|
|
492
|
+
const total = items.reduce((acc, item) => ({
|
|
493
|
+
requests: acc.requests + item.requests,
|
|
494
|
+
totalTokens: acc.totalTokens + item.totalTokens,
|
|
495
|
+
inputTokens: acc.inputTokens + item.inputTokens,
|
|
496
|
+
outputTokens: acc.outputTokens + item.outputTokens,
|
|
497
|
+
cacheRead: acc.cacheRead + item.cacheRead
|
|
498
|
+
}), {
|
|
499
|
+
requests: 0,
|
|
500
|
+
totalTokens: 0,
|
|
501
|
+
inputTokens: 0,
|
|
502
|
+
outputTokens: 0,
|
|
503
|
+
cacheRead: 0
|
|
504
|
+
});
|
|
505
|
+
const rows = items.map((item) => [
|
|
506
|
+
item.day,
|
|
507
|
+
String(item.requests),
|
|
508
|
+
formatTokens(item.totalTokens),
|
|
509
|
+
formatTokens(item.inputTokens),
|
|
510
|
+
formatTokens(item.outputTokens),
|
|
511
|
+
formatTokens(item.cacheRead)
|
|
512
|
+
]);
|
|
513
|
+
return [
|
|
514
|
+
"\u2550\u2550\u2550 Daily Breakdown \u2550\u2550\u2550",
|
|
515
|
+
table([
|
|
516
|
+
{ label: "Day", align: "left" },
|
|
517
|
+
{ label: "Req", align: "right" },
|
|
518
|
+
{ label: "Total", align: "right" },
|
|
519
|
+
{ label: "In", align: "right" },
|
|
520
|
+
{ label: "Out", align: "right" },
|
|
521
|
+
{ label: "Cache", align: "right" }
|
|
522
|
+
], rows, [
|
|
523
|
+
"TOTAL",
|
|
524
|
+
String(total.requests),
|
|
525
|
+
formatTokens(total.totalTokens),
|
|
526
|
+
formatTokens(total.inputTokens),
|
|
527
|
+
formatTokens(total.outputTokens),
|
|
528
|
+
formatTokens(total.cacheRead)
|
|
529
|
+
])
|
|
530
|
+
].join("\n");
|
|
531
|
+
}
|
|
532
|
+
function formatSessionBreakdown(items) {
|
|
533
|
+
if (items.length === 0) return "\u2550\u2550\u2550 Session Breakdown \u2550\u2550\u2550\n(no data)";
|
|
534
|
+
const rows = items.map((item) => {
|
|
535
|
+
const title = truncateByWidth(item.title, 40);
|
|
536
|
+
return [
|
|
537
|
+
item.day,
|
|
538
|
+
item.provider || "-",
|
|
539
|
+
truncateByWidth(item.model, 20),
|
|
540
|
+
String(item.requests),
|
|
541
|
+
formatTokens(item.totalTokens),
|
|
542
|
+
formatTokens(item.cacheRead),
|
|
543
|
+
title
|
|
544
|
+
];
|
|
545
|
+
});
|
|
546
|
+
return [
|
|
547
|
+
"\u2550\u2550\u2550 Session Breakdown \u2550\u2550\u2550",
|
|
548
|
+
table([
|
|
549
|
+
{ label: "Day", align: "left" },
|
|
550
|
+
{ label: "Provider", align: "left" },
|
|
551
|
+
{ label: "Model", align: "left" },
|
|
552
|
+
{ label: "Req", align: "right" },
|
|
553
|
+
{ label: "Total", align: "right" },
|
|
554
|
+
{ label: "Cache", align: "right" },
|
|
555
|
+
{ label: "Title", align: "left" }
|
|
556
|
+
], rows)
|
|
557
|
+
].join("\n");
|
|
558
|
+
}
|
|
559
|
+
function formatUsageReport(report) {
|
|
560
|
+
return [
|
|
561
|
+
`Filters: ${formatFilters(report.filters)}`,
|
|
562
|
+
"",
|
|
563
|
+
formatSessionSummary(report.summary, "Usage Summary"),
|
|
564
|
+
"",
|
|
565
|
+
formatModelBreakdown(report.models),
|
|
566
|
+
"",
|
|
567
|
+
formatProviderBreakdown(report.providers),
|
|
568
|
+
"",
|
|
569
|
+
formatDailyBreakdown(report.daily),
|
|
570
|
+
"",
|
|
571
|
+
formatSessionBreakdown(report.sessions)
|
|
572
|
+
].join("\n");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// src/generate-usage-html.ts
|
|
576
|
+
function fmtTokens(n) {
|
|
577
|
+
if (n >= 1e9) return (n / 1e9).toFixed(1) + "B";
|
|
578
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
|
579
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
|
|
580
|
+
return String(n);
|
|
581
|
+
}
|
|
582
|
+
function fmtCost(n) {
|
|
583
|
+
if (n === 0) return "$0.00";
|
|
584
|
+
if (n < 0.01) return "$" + n.toFixed(6);
|
|
585
|
+
return "$" + n.toFixed(2);
|
|
586
|
+
}
|
|
587
|
+
function fmtPercent(n) {
|
|
588
|
+
return (n * 100).toFixed(1) + "%";
|
|
589
|
+
}
|
|
590
|
+
function cacheHitRate(input, cacheRead) {
|
|
591
|
+
if (input + cacheRead === 0) return 0;
|
|
592
|
+
return cacheRead / (input + cacheRead);
|
|
593
|
+
}
|
|
594
|
+
function sortModelsByUsage(models) {
|
|
595
|
+
return [...models].filter((m) => m.totalTokens > 0).sort((a, b) => b.totalTokens - a.totalTokens);
|
|
596
|
+
}
|
|
597
|
+
function renderMeta(data) {
|
|
598
|
+
const m = data.meta;
|
|
599
|
+
return `TokenWatch Usage Report \xB7 ${m.dateRange.start} \u2192 ${m.dateRange.end} \xB7 generated ${m.generatedAt}`;
|
|
600
|
+
}
|
|
601
|
+
function renderKpiCards(data) {
|
|
602
|
+
const s = data.summary;
|
|
603
|
+
const hitRate = cacheHitRate(s.inputTokens, s.cacheRead);
|
|
604
|
+
const hitRatePct = fmtPercent(hitRate);
|
|
605
|
+
let tpsSum = 0, tpsReqs = 0;
|
|
606
|
+
for (const p of data.perfSummary) {
|
|
607
|
+
if (p.avgTPS != null && p.avgTPS > 0) {
|
|
608
|
+
tpsSum += p.avgTPS * p.requestCount;
|
|
609
|
+
tpsReqs += p.requestCount;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const avgTpsRaw = tpsReqs > 0 ? tpsSum / tpsReqs : 0;
|
|
613
|
+
const avgTps = tpsReqs > 0 ? avgTpsRaw.toFixed(1) : "\u2014";
|
|
614
|
+
const isHighCache = hitRate >= 0.5;
|
|
615
|
+
const errors = data.errors;
|
|
616
|
+
const errorRatePct = errors ? (errors.errorRate * 100).toFixed(1) + "%" : "\u2014";
|
|
617
|
+
const errorColor = errors && errors.errorRate >= 0.05 ? "var(--output)" : errors && errors.errorRate > 0 ? "var(--tps)" : "var(--cache)";
|
|
618
|
+
return `
|
|
619
|
+
<div class="kpi-row">
|
|
620
|
+
<div class="kpi-card">
|
|
621
|
+
<div class="kpi-label">Total Tokens</div>
|
|
622
|
+
<div class="kpi-value">${fmtTokens(s.totalTokens)}</div>
|
|
623
|
+
</div>
|
|
624
|
+
<div class="kpi-card${isHighCache ? " kpi-glow" : ""}">
|
|
625
|
+
<div class="kpi-label">Cache Hit Rate</div>
|
|
626
|
+
<div class="kpi-value" style="color:var(--cache)">${hitRatePct}</div>
|
|
627
|
+
</div>
|
|
628
|
+
<div class="kpi-card">
|
|
629
|
+
<div class="kpi-label">Avg TPS</div>
|
|
630
|
+
<div class="kpi-value" style="color:var(--tps)">${avgTps}</div>
|
|
631
|
+
</div>
|
|
632
|
+
<div class="kpi-card">
|
|
633
|
+
<div class="kpi-label">Requests</div>
|
|
634
|
+
<div class="kpi-value">${s.requestCount}</div>
|
|
635
|
+
</div>
|
|
636
|
+
<div class="kpi-card">
|
|
637
|
+
<div class="kpi-label">Total Cost</div>
|
|
638
|
+
<div class="kpi-value" style="color:var(--tps)">${fmtCost(s.totalCost)}</div>
|
|
639
|
+
</div>
|
|
640
|
+
<div class="kpi-card">
|
|
641
|
+
<div class="kpi-label">Error Rate</div>
|
|
642
|
+
<div class="kpi-value" style="color:${errorColor}">${errorRatePct}</div>
|
|
643
|
+
</div>
|
|
644
|
+
</div>`;
|
|
645
|
+
}
|
|
646
|
+
function renderModelChartInit(data) {
|
|
647
|
+
const models = sortModelsByUsage(data.models).filter((m) => m.totalTokens >= 1e6);
|
|
648
|
+
const names = models.map((m) => m.model);
|
|
649
|
+
const inputData = models.map((m) => m.inputTokens);
|
|
650
|
+
const outputData = models.map((m) => m.outputTokens);
|
|
651
|
+
const cacheData = models.map((m) => m.cacheRead);
|
|
652
|
+
const tpsData = models.map((m) => {
|
|
653
|
+
const perf = data.perfSummary.find((p) => p.model === `${m.provider}/${m.model}`);
|
|
654
|
+
return perf?.avgTPS ?? null;
|
|
655
|
+
});
|
|
656
|
+
return `var modelNames = ${JSON.stringify(names)};
|
|
657
|
+
var modelInput = ${JSON.stringify(inputData)};
|
|
658
|
+
var modelOutput = ${JSON.stringify(outputData)};
|
|
659
|
+
var modelCache = ${JSON.stringify(cacheData)};
|
|
660
|
+
var modelTps = ${JSON.stringify(tpsData)};
|
|
661
|
+
|
|
662
|
+
function initModelChart() {
|
|
663
|
+
var el = document.getElementById('model-chart');
|
|
664
|
+
if (!el) return;
|
|
665
|
+
var chart = echarts.init(el);
|
|
666
|
+
window.modelChart = chart;
|
|
667
|
+
renderModelChart(chart);
|
|
668
|
+
return chart;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function renderModelChart(chart) {
|
|
672
|
+
var totals = modelInput.map(function(v, i) { return v + modelOutput[i] + modelCache[i]; });
|
|
673
|
+
var option = {
|
|
674
|
+
tooltip: {
|
|
675
|
+
trigger: 'axis',
|
|
676
|
+
axisPointer: { type: 'shadow' },
|
|
677
|
+
formatter: function(params) {
|
|
678
|
+
var html = '<b>' + params[0].axisValue + '</b><br/>';
|
|
679
|
+
var total = 0;
|
|
680
|
+
params.forEach(function(p) {
|
|
681
|
+
if (p.seriesName !== 'TPS') {
|
|
682
|
+
html += p.marker + ' ' + p.seriesName + ': ' + fmt(p.value) + '<br/>';
|
|
683
|
+
total += p.value;
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
html += 'Total: ' + fmt(total) + '<br/>';
|
|
687
|
+
var tpsParam = params.find(function(p) { return p.seriesName === 'TPS'; });
|
|
688
|
+
if (tpsParam && tpsParam.value != null) {
|
|
689
|
+
html += tpsParam.marker + ' TPS: ' + tpsParam.value.toFixed(1) + '<br/>';
|
|
690
|
+
}
|
|
691
|
+
return html;
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
legend: {
|
|
695
|
+
data: ['Input', 'Output', 'Cache', 'TPS'],
|
|
696
|
+
textStyle: { color: '#B0B0C0' },
|
|
697
|
+
top: 5
|
|
698
|
+
},
|
|
699
|
+
grid: { left: 60, right: 60, bottom: 100, top: 50 },
|
|
700
|
+
xAxis: {
|
|
701
|
+
type: 'category',
|
|
702
|
+
data: modelNames,
|
|
703
|
+
axisLabel: { color: '#B0B0C0', rotate: 45, interval: 0, fontSize: 10 },
|
|
704
|
+
axisLine: { lineStyle: { color: '#2A2A35' } }
|
|
705
|
+
},
|
|
706
|
+
yAxis: [
|
|
707
|
+
{
|
|
708
|
+
type: 'value',
|
|
709
|
+
name: 'Tokens',
|
|
710
|
+
nameTextStyle: { color: '#B0B0C0' },
|
|
711
|
+
axisLabel: {
|
|
712
|
+
color: '#B0B0C0',
|
|
713
|
+
formatter: fmt
|
|
714
|
+
},
|
|
715
|
+
splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
type: 'value',
|
|
719
|
+
name: 'TPS',
|
|
720
|
+
nameTextStyle: { color: '#FFB800' },
|
|
721
|
+
axisLabel: { color: '#FFB800', formatter: function(v) { return v.toFixed(1); } },
|
|
722
|
+
splitLine: { show: false }
|
|
723
|
+
}
|
|
724
|
+
],
|
|
725
|
+
series: [
|
|
726
|
+
{
|
|
727
|
+
name: 'Input',
|
|
728
|
+
type: 'bar',
|
|
729
|
+
stack: 'tokens',
|
|
730
|
+
data: modelInput,
|
|
731
|
+
itemStyle: { color: '#00D1FF' },
|
|
732
|
+
barMaxWidth: 40
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
name: 'Cache',
|
|
736
|
+
type: 'bar',
|
|
737
|
+
stack: 'tokens',
|
|
738
|
+
data: modelCache,
|
|
739
|
+
itemStyle: { color: '#00F593' },
|
|
740
|
+
barMaxWidth: 40,
|
|
741
|
+
label: {
|
|
742
|
+
show: true,
|
|
743
|
+
position: 'inside',
|
|
744
|
+
formatter: function(p) {
|
|
745
|
+
var cache = modelCache[p.dataIndex];
|
|
746
|
+
var input = modelInput[p.dataIndex];
|
|
747
|
+
if (cache === 0) return '';
|
|
748
|
+
return (cache / (input + cache) * 100).toFixed(0) + '%';
|
|
749
|
+
},
|
|
750
|
+
color: '#fff', fontSize: 10, fontWeight: 'bold'
|
|
751
|
+
}
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
name: 'Output',
|
|
755
|
+
type: 'bar',
|
|
756
|
+
stack: 'tokens',
|
|
757
|
+
data: modelOutput,
|
|
758
|
+
itemStyle: { color: '#B545FF' },
|
|
759
|
+
barMaxWidth: 40,
|
|
760
|
+
label: {
|
|
761
|
+
show: true,
|
|
762
|
+
position: 'top',
|
|
763
|
+
formatter: function(p) {
|
|
764
|
+
var total = modelInput[p.dataIndex] + modelOutput[p.dataIndex] + modelCache[p.dataIndex];
|
|
765
|
+
return total > 0 ? fmt(total) : '';
|
|
766
|
+
},
|
|
767
|
+
color: '#fff', fontSize: 10, fontWeight: 'bold'
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
name: 'TPS',
|
|
772
|
+
type: 'scatter',
|
|
773
|
+
yAxisIndex: 1,
|
|
774
|
+
data: modelTps,
|
|
775
|
+
symbol: 'diamond',
|
|
776
|
+
symbolSize: function(val) { return val != null && val > 0 ? 13 : 0; },
|
|
777
|
+
itemStyle: { color: '#FFB800' },
|
|
778
|
+
label: {
|
|
779
|
+
show: true,
|
|
780
|
+
position: 'right',
|
|
781
|
+
formatter: function(p) { return p.value != null && p.value > 0 ? p.value.toFixed(1) : ''; },
|
|
782
|
+
color: '#FFB800', fontSize: 10
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
]
|
|
786
|
+
};
|
|
787
|
+
chart.setOption(option);
|
|
788
|
+
chart.resize();
|
|
789
|
+
}`;
|
|
790
|
+
}
|
|
791
|
+
function renderScatterChartInit(data) {
|
|
792
|
+
const validPerf = data.perfSummary.filter(
|
|
793
|
+
(p) => p.requestCount > 0 && p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite > 0
|
|
794
|
+
);
|
|
795
|
+
if (validPerf.length === 0) return "";
|
|
796
|
+
const sorted = [...validPerf].sort((a, b) => {
|
|
797
|
+
if (a.avgTPS == null && b.avgTPS == null) return 0;
|
|
798
|
+
if (a.avgTPS == null) return 1;
|
|
799
|
+
if (b.avgTPS == null) return -1;
|
|
800
|
+
return b.avgTPS - a.avgTPS;
|
|
801
|
+
});
|
|
802
|
+
const names = sorted.map((p) => p.model);
|
|
803
|
+
const tpsValues = sorted.map((p) => p.avgTPS ?? 0);
|
|
804
|
+
const ttftValues = sorted.map((p) => p.avgTTFT ?? 0);
|
|
805
|
+
const costValues = sorted.map((p) => {
|
|
806
|
+
const billable = p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite;
|
|
807
|
+
return billable > 0 ? p.totalCost / billable * 1e3 : 0;
|
|
808
|
+
});
|
|
809
|
+
const hitRates = sorted.map((p) => p.cacheHitRate ?? 0);
|
|
810
|
+
const reqCounts = sorted.map((p) => p.requestCount);
|
|
811
|
+
return `
|
|
812
|
+
var effNames = ${JSON.stringify(names)};
|
|
813
|
+
var effTps = ${JSON.stringify(tpsValues)};
|
|
814
|
+
var effTtft = ${JSON.stringify(ttftValues)};
|
|
815
|
+
var effCost = ${JSON.stringify(costValues)};
|
|
816
|
+
var effHit = ${JSON.stringify(hitRates)};
|
|
817
|
+
var effReq = ${JSON.stringify(reqCounts)};
|
|
818
|
+
|
|
819
|
+
function initScatterChart() {
|
|
820
|
+
var el = document.getElementById('scatter-chart');
|
|
821
|
+
if (!el) return;
|
|
822
|
+
var chart = echarts.init(el);
|
|
823
|
+
window.scatterChart = chart;
|
|
824
|
+
|
|
825
|
+
// TPS \u8D8A\u9AD8\u8D8A\u7EFF\uFF0C\u8D8A\u4F4E\u8D8A\u7D2B\uFF0C\u65E0\u6570\u636E\u4E3A\u7070
|
|
826
|
+
var maxTps = Math.max.apply(null, effTps.filter(function(v){ return v > 0; })) || 1;
|
|
827
|
+
var barColors = effTps.map(function(v) {
|
|
828
|
+
if (v <= 0) return '#444455';
|
|
829
|
+
var r = v / maxTps;
|
|
830
|
+
if (r >= 0.8) return '#00F593';
|
|
831
|
+
if (r >= 0.5) return '#FFB800';
|
|
832
|
+
return '#B545FF';
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
var option = {
|
|
836
|
+
tooltip: {
|
|
837
|
+
trigger: 'axis',
|
|
838
|
+
axisPointer: { type: 'none' },
|
|
839
|
+
formatter: function(params) {
|
|
840
|
+
var i = params[0].dataIndex;
|
|
841
|
+
var tps = effTps[i] > 0 ? effTps[i].toFixed(1) + ' tok/s' : '\u2014';
|
|
842
|
+
var ttft = effTtft[i] > 0 ? effTtft[i].toFixed(0) + ' ms' : '\u2014';
|
|
843
|
+
var cost = effCost[i] > 0 ? '$' + effCost[i].toFixed(4) + '/1K' : '\u2014';
|
|
844
|
+
var hit = effHit[i] > 0 ? effHit[i].toFixed(1) + '%' : '\u2014';
|
|
845
|
+
return '<b>' + effNames[i] + '</b><br/>' +
|
|
846
|
+
'\u25B6 TPS: ' + tps + '<br/>' +
|
|
847
|
+
'\u23F1 TTFT: ' + ttft + '<br/>' +
|
|
848
|
+
'\u{1F4B0} Cost/1K: ' + cost + '<br/>' +
|
|
849
|
+
'\u{1F4BE} Cache Hit: ' + hit + '<br/>' +
|
|
850
|
+
'Requests: ' + effReq[i];
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
grid: { left: 20, right: 280, bottom: 30, top: 20, containLabel: true },
|
|
854
|
+
xAxis: {
|
|
855
|
+
type: 'value',
|
|
856
|
+
name: 'Avg TPS (tokens / sec)',
|
|
857
|
+
nameTextStyle: { color: '#B0B0C0', fontSize: 11 },
|
|
858
|
+
axisLabel: { color: '#B0B0C0', formatter: function(v) { return v > 0 ? v.toFixed(0) : '0'; } },
|
|
859
|
+
splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
|
|
860
|
+
},
|
|
861
|
+
yAxis: {
|
|
862
|
+
type: 'category',
|
|
863
|
+
data: effNames,
|
|
864
|
+
inverse: true,
|
|
865
|
+
axisLabel: {
|
|
866
|
+
color: '#E0E0F0',
|
|
867
|
+
fontSize: 11,
|
|
868
|
+
formatter: function(v) { return v.length > 50 ? v.slice(0, 48) + '\u2026' : v; }
|
|
869
|
+
},
|
|
870
|
+
axisLine: { show: false },
|
|
871
|
+
axisTick: { show: false }
|
|
872
|
+
},
|
|
873
|
+
series: [{
|
|
874
|
+
type: 'bar',
|
|
875
|
+
data: effTps.map(function(v, i) {
|
|
876
|
+
return { value: v > 0 ? v : 0.001, itemStyle: { color: barColors[i], borderRadius: [0, 4, 4, 0] } };
|
|
877
|
+
}),
|
|
878
|
+
barMaxWidth: 22,
|
|
879
|
+
label: {
|
|
880
|
+
show: true,
|
|
881
|
+
position: 'right',
|
|
882
|
+
color: '#E0E0F0',
|
|
883
|
+
fontSize: 10,
|
|
884
|
+
formatter: function(p) {
|
|
885
|
+
var i = p.dataIndex;
|
|
886
|
+
var parts = [effTps[i] > 0 ? effTps[i].toFixed(1) + ' t/s' : '\u2014'];
|
|
887
|
+
if (effTtft[i] > 0) parts.push('TTFT ' + effTtft[i].toFixed(0) + 'ms');
|
|
888
|
+
if (effCost[i] > 0) parts.push('$' + effCost[i].toFixed(4) + '/1K');
|
|
889
|
+
return parts.join(' ');
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}]
|
|
893
|
+
};
|
|
894
|
+
chart.setOption(option);
|
|
895
|
+
chart.resize();
|
|
896
|
+
}
|
|
897
|
+
`;
|
|
898
|
+
}
|
|
899
|
+
function providerBorderColor(provider) {
|
|
900
|
+
const colors = { opencode: "#00F593", deepseek: "#00D1FF", nvidia: "#B545FF", modelscope: "#FFB800" };
|
|
901
|
+
return colors[provider] || "#2A2A35";
|
|
902
|
+
}
|
|
903
|
+
function renderProviderCards(data) {
|
|
904
|
+
const sorted = [...data.providers].sort((a, b) => b.totalTokens - a.totalTokens);
|
|
905
|
+
const top = sorted.slice(0, 10);
|
|
906
|
+
const remaining = sorted.length - 10;
|
|
907
|
+
const cards = top.map((p) => {
|
|
908
|
+
const modelCount = data.models.filter((m) => m.provider === p.provider).length;
|
|
909
|
+
const perfItems = data.perfSummary.filter((ps) => ps.providerID === p.provider);
|
|
910
|
+
let ttftSum = 0, ttftReqs = 0;
|
|
911
|
+
for (const x of perfItems) {
|
|
912
|
+
if (x.avgTTFT != null && x.avgTTFT > 0) {
|
|
913
|
+
ttftSum += x.avgTTFT * x.requestCount;
|
|
914
|
+
ttftReqs += x.requestCount;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
const avgTtft = ttftReqs > 0 ? ttftSum / ttftReqs : null;
|
|
918
|
+
let tpsSum = 0, tpsReqs = 0;
|
|
919
|
+
for (const x of perfItems) {
|
|
920
|
+
if (x.avgTPS != null && x.avgTPS > 0) {
|
|
921
|
+
tpsSum += x.avgTPS * x.requestCount;
|
|
922
|
+
tpsReqs += x.requestCount;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
const avgTps = tpsReqs > 0 ? tpsSum / tpsReqs : null;
|
|
926
|
+
return `
|
|
927
|
+
<div class="provider-card" style="border-color:${providerBorderColor(p.provider)}">
|
|
928
|
+
<div class="provider-name">${p.provider}</div>
|
|
929
|
+
<div class="provider-stat"><span class="stat-label">Tokens</span><span>${fmtTokens(p.totalTokens)}</span></div>
|
|
930
|
+
<div class="provider-stat"><span class="stat-label">Cost</span><span>${fmtCost(p.totalCost)}</span></div>
|
|
931
|
+
<div class="provider-stat"><span class="stat-label">Avg TTFT</span><span>${avgTtft != null ? avgTtft.toFixed(0) + "ms" : "\u2014"}</span></div>
|
|
932
|
+
<div class="provider-stat"><span class="stat-label">Avg TPS</span><span>${avgTps != null ? avgTps.toFixed(1) : "\u2014"}</span></div>
|
|
933
|
+
<div class="provider-stat"><span class="stat-label">Models</span><span>${modelCount}</span></div>
|
|
934
|
+
</div>`;
|
|
935
|
+
}).join("\n");
|
|
936
|
+
const moreHint = remaining > 0 ? `<div class="provider-more">+ ${remaining} more provider${remaining > 1 ? "s" : ""} not shown</div>` : "";
|
|
937
|
+
return cards + moreHint;
|
|
938
|
+
}
|
|
939
|
+
function renderModelAnalyticsSection(data) {
|
|
940
|
+
const usageRows = sortModelsByUsage(data.models).map((m) => {
|
|
941
|
+
const hitRate = cacheHitRate(m.inputTokens, m.cacheRead);
|
|
942
|
+
const hitColor = hitRate >= 0.85 ? "var(--cache)" : hitRate >= 0.7 ? "var(--tps)" : "var(--output)";
|
|
943
|
+
const perf = data.perfSummary.find((p) => p.model === `${m.provider}/${m.model}`);
|
|
944
|
+
const ttft = perf?.avgTTFT != null ? perf.avgTTFT.toFixed(0) + "ms" : "\u2014";
|
|
945
|
+
const p95ttft = perf?.p95TTFT != null ? perf.p95TTFT.toFixed(0) + "ms" : "\u2014";
|
|
946
|
+
const tps = perf?.avgTPS != null ? perf.avgTPS.toFixed(1) : "\u2014";
|
|
947
|
+
return `<tr>
|
|
948
|
+
<td>${m.model}</td>
|
|
949
|
+
<td>${m.provider}</td>
|
|
950
|
+
<td>${m.requests}</td>
|
|
951
|
+
<td>${fmtTokens(m.totalTokens)}</td>
|
|
952
|
+
<td>${fmtTokens(m.inputTokens)}</td>
|
|
953
|
+
<td>${fmtTokens(m.outputTokens)}</td>
|
|
954
|
+
<td>${fmtTokens(m.cacheRead)}</td>
|
|
955
|
+
<td style="color:${hitColor};font-weight:600">${fmtPercent(hitRate)}</td>
|
|
956
|
+
<td>${ttft}</td>
|
|
957
|
+
<td style="color:var(--tps);font-size:0.85em">${p95ttft}</td>
|
|
958
|
+
<td>${tps}</td>
|
|
959
|
+
<td>${fmtCost(m.totalCost)}</td>
|
|
960
|
+
</tr>`;
|
|
961
|
+
}).join("\n");
|
|
962
|
+
const validPerf = data.perfSummary.filter(
|
|
963
|
+
(p) => p.requestCount > 0 && p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite > 0
|
|
964
|
+
);
|
|
965
|
+
const fmtMs = (v) => v != null ? v.toFixed(0) + "ms" : "\u2014";
|
|
966
|
+
const perfRows = validPerf.map((p) => {
|
|
967
|
+
const hitColor = p.cacheHitRate != null && p.cacheHitRate >= 85 ? "var(--cache)" : p.cacheHitRate != null && p.cacheHitRate >= 70 ? "var(--tps)" : "var(--output)";
|
|
968
|
+
return `<tr>
|
|
969
|
+
<td>${p.model}</td>
|
|
970
|
+
<td>${p.requestCount}</td>
|
|
971
|
+
<td>${fmtMs(p.avgTTFT)}</td>
|
|
972
|
+
<td>${fmtMs(p.p50TTFT)}</td>
|
|
973
|
+
<td>${fmtMs(p.p95TTFT)}</td>
|
|
974
|
+
<td>${fmtMs(p.p99TTFT)}</td>
|
|
975
|
+
<td>${fmtMs(p.avgLatency)}</td>
|
|
976
|
+
<td>${fmtMs(p.p50Latency)}</td>
|
|
977
|
+
<td>${fmtMs(p.p95Latency)}</td>
|
|
978
|
+
<td>${fmtMs(p.p99Latency)}</td>
|
|
979
|
+
<td style="color:${hitColor};font-weight:600">${p.cacheHitRate != null ? p.cacheHitRate.toFixed(1) + "%" : "\u2014"}</td>
|
|
980
|
+
</tr>`;
|
|
981
|
+
}).join("\n");
|
|
982
|
+
const errors = data.errors;
|
|
983
|
+
const hasErrors = !!(errors && errors.failedCount > 0);
|
|
984
|
+
let errorTabBtn = "";
|
|
985
|
+
let errorTabContent = "";
|
|
986
|
+
if (hasErrors) {
|
|
987
|
+
const errorRatePct = (errors.errorRate * 100).toFixed(2) + "%";
|
|
988
|
+
const rateColor = errors.errorRate >= 0.05 ? "var(--output)" : "var(--tps)";
|
|
989
|
+
const cellColor = errors.errorRate >= 0.05 ? "var(--output)" : "var(--tps)";
|
|
990
|
+
const errorRows = errors.byModel.filter((m) => m.failed > 0).map((m) => {
|
|
991
|
+
const modelRate = m.total > 0 ? (m.failed / m.total * 100).toFixed(1) + "%" : "\u2014";
|
|
992
|
+
return `<tr>
|
|
993
|
+
<td>${m.provider}</td>
|
|
994
|
+
<td>${m.model}</td>
|
|
995
|
+
<td>${m.total}</td>
|
|
996
|
+
<td style="color:var(--output)">${m.failed}</td>
|
|
997
|
+
<td style="color:var(--tps)">${m.total - m.failed}</td>
|
|
998
|
+
<td style="color:${cellColor}">${modelRate}</td>
|
|
999
|
+
</tr>`;
|
|
1000
|
+
}).join("\n");
|
|
1001
|
+
errorTabBtn = `
|
|
1002
|
+
<button class="tab-btn" data-mtab="errors" onclick="switchModelTab('errors')">
|
|
1003
|
+
Failed Requests <span style="color:var(--output);margin-left:4px;font-size:0.85em">(${errors.failedCount})</span>
|
|
1004
|
+
</button>`;
|
|
1005
|
+
errorTabContent = `
|
|
1006
|
+
<div id="model-tab-errors" class="tab-content">
|
|
1007
|
+
<p style="font-size:12px;color:${rateColor};padding:8px 0 6px">
|
|
1008
|
+
Overall error rate: <strong>${errorRatePct}</strong> —
|
|
1009
|
+
${errors.failedCount} failed / ${errors.successCount + errors.failedCount} total
|
|
1010
|
+
</p>
|
|
1011
|
+
<table id="errors-table" class="data-table">
|
|
1012
|
+
<thead><tr>
|
|
1013
|
+
<th>Provider</th><th>Model</th><th>Total</th>
|
|
1014
|
+
<th>Failed</th><th>Success</th><th>Error Rate</th>
|
|
1015
|
+
</tr></thead>
|
|
1016
|
+
<tbody>${errorRows}</tbody>
|
|
1017
|
+
</table>
|
|
1018
|
+
<div class="pagination-ctrl" id="errors-table-ctrl">
|
|
1019
|
+
<button class="page-btn" id="errors-table-prev">\u2190 Prev</button>
|
|
1020
|
+
<span class="page-info" id="errors-table-info"></span>
|
|
1021
|
+
<button class="page-btn" id="errors-table-next">Next \u2192</button>
|
|
1022
|
+
</div>
|
|
1023
|
+
</div>`;
|
|
1024
|
+
}
|
|
1025
|
+
return `
|
|
1026
|
+
<div class="section">
|
|
1027
|
+
<div class="section-title">Model Analytics</div>
|
|
1028
|
+
<div class="tab-bar">
|
|
1029
|
+
<button class="tab-btn active" data-mtab="usage" onclick="switchModelTab('usage')">Usage Breakdown</button>
|
|
1030
|
+
<button class="tab-btn" data-mtab="perf" onclick="switchModelTab('perf')">Latency Percentiles</button>
|
|
1031
|
+
${errorTabBtn}
|
|
1032
|
+
</div>
|
|
1033
|
+
|
|
1034
|
+
<div id="model-tab-usage" class="tab-content active">
|
|
1035
|
+
<table id="usage-table" class="data-table">
|
|
1036
|
+
<thead><tr>
|
|
1037
|
+
<th>Model</th><th>Provider</th><th>Req</th><th>Total</th>
|
|
1038
|
+
<th>Input</th><th>Output</th><th>Cache</th><th>Hit Rate</th>
|
|
1039
|
+
<th>Avg TTFT</th><th>P95 TTFT</th><th>TPS</th><th>Cost</th>
|
|
1040
|
+
</tr></thead>
|
|
1041
|
+
<tbody>${usageRows}</tbody>
|
|
1042
|
+
</table>
|
|
1043
|
+
<div class="pagination-ctrl" id="usage-table-ctrl">
|
|
1044
|
+
<button class="page-btn" id="usage-table-prev">\u2190 Prev</button>
|
|
1045
|
+
<span class="page-info" id="usage-table-info"></span>
|
|
1046
|
+
<button class="page-btn" id="usage-table-next">Next \u2192</button>
|
|
1047
|
+
</div>
|
|
1048
|
+
</div>
|
|
1049
|
+
|
|
1050
|
+
<div id="model-tab-perf" class="tab-content">
|
|
1051
|
+
${validPerf.length > 0 ? `
|
|
1052
|
+
<table id="perf-table" class="data-table">
|
|
1053
|
+
<thead><tr>
|
|
1054
|
+
<th>Model</th><th>Req</th>
|
|
1055
|
+
<th>Avg TTFT</th><th>P50 TTFT</th><th>P95 TTFT</th><th>P99 TTFT</th>
|
|
1056
|
+
<th>Avg E2E</th><th>P50 E2E</th><th>P95 E2E</th><th>P99 E2E</th>
|
|
1057
|
+
<th>Cache Hit</th>
|
|
1058
|
+
</tr></thead>
|
|
1059
|
+
<tbody>${perfRows}</tbody>
|
|
1060
|
+
</table>
|
|
1061
|
+
<div class="pagination-ctrl" id="perf-table-ctrl">
|
|
1062
|
+
<button class="page-btn" id="perf-table-prev">\u2190 Prev</button>
|
|
1063
|
+
<span class="page-info" id="perf-table-info"></span>
|
|
1064
|
+
<button class="page-btn" id="perf-table-next">Next \u2192</button>
|
|
1065
|
+
</div>` : '<div class="empty-state">No performance data available for this period.</div>'}
|
|
1066
|
+
</div>
|
|
1067
|
+
|
|
1068
|
+
${errorTabContent}
|
|
1069
|
+
</div>`;
|
|
1070
|
+
}
|
|
1071
|
+
function renderDailyTrendInit(data) {
|
|
1072
|
+
const days = data.daily.slice().reverse().map((d) => d.day);
|
|
1073
|
+
const tokens = data.daily.slice().reverse().map((d) => d.totalTokens);
|
|
1074
|
+
const costs = data.daily.slice().reverse().map((d) => d.totalCost);
|
|
1075
|
+
return `
|
|
1076
|
+
var dailyDays = ${JSON.stringify(days)};
|
|
1077
|
+
var dailyTokens = ${JSON.stringify(tokens)};
|
|
1078
|
+
var dailyCosts = ${JSON.stringify(costs)};
|
|
1079
|
+
|
|
1080
|
+
function initDailyChart() {
|
|
1081
|
+
var el = document.getElementById('daily-chart');
|
|
1082
|
+
if (!el) return;
|
|
1083
|
+
var chart = echarts.init(el);
|
|
1084
|
+
window.dailyChart = chart;
|
|
1085
|
+
var option = {
|
|
1086
|
+
tooltip: {
|
|
1087
|
+
trigger: 'axis',
|
|
1088
|
+
formatter: function(params) {
|
|
1089
|
+
var html = '<b>' + params[0].axisValue + '</b><br/>';
|
|
1090
|
+
params.forEach(function(p) {
|
|
1091
|
+
html += p.marker + ' ' + p.seriesName + ': ' + (p.seriesName === 'Cost' ? '$' + p.value.toFixed(4) : fmt(p.value)) + '<br/>';
|
|
1092
|
+
});
|
|
1093
|
+
return html;
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1096
|
+
legend: {
|
|
1097
|
+
data: ['Tokens', 'Cost'],
|
|
1098
|
+
textStyle: { color: '#B0B0C0' },
|
|
1099
|
+
top: 5
|
|
1100
|
+
},
|
|
1101
|
+
grid: { left: 60, right: 60, bottom: 80, top: 40 },
|
|
1102
|
+
xAxis: {
|
|
1103
|
+
type: 'category',
|
|
1104
|
+
data: dailyDays,
|
|
1105
|
+
axisLabel: { color: '#B0B0C0', rotate: 45, interval: 0, fontSize: 10 },
|
|
1106
|
+
axisLine: { lineStyle: { color: '#2A2A35' } }
|
|
1107
|
+
},
|
|
1108
|
+
yAxis: [
|
|
1109
|
+
{
|
|
1110
|
+
type: 'value',
|
|
1111
|
+
name: 'Tokens',
|
|
1112
|
+
nameTextStyle: { color: '#B0B0C0' },
|
|
1113
|
+
axisLabel: { color: '#B0B0C0', formatter: fmt },
|
|
1114
|
+
splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
type: 'value',
|
|
1118
|
+
name: 'Cost',
|
|
1119
|
+
nameTextStyle: { color: '#FFB800' },
|
|
1120
|
+
axisLabel: { color: '#FFB800', formatter: function(v) { return '$' + v.toFixed(4); } },
|
|
1121
|
+
splitLine: { show: false }
|
|
1122
|
+
}
|
|
1123
|
+
],
|
|
1124
|
+
dataZoom: [{
|
|
1125
|
+
type: 'slider',
|
|
1126
|
+
bottom: 5,
|
|
1127
|
+
height: 20,
|
|
1128
|
+
borderColor: '#2A2A35',
|
|
1129
|
+
fillerColor: 'rgba(0,213,255,0.1)',
|
|
1130
|
+
handleStyle: { color: '#00D1FF' },
|
|
1131
|
+
textStyle: { color: '#B0B0C0' }
|
|
1132
|
+
}],
|
|
1133
|
+
series: [
|
|
1134
|
+
{
|
|
1135
|
+
name: 'Tokens',
|
|
1136
|
+
type: 'line',
|
|
1137
|
+
data: dailyTokens,
|
|
1138
|
+
smooth: true,
|
|
1139
|
+
symbol: 'none',
|
|
1140
|
+
lineStyle: { color: '#00D1FF', width: 2 },
|
|
1141
|
+
areaStyle: { color: 'rgba(0,209,255,0.15)' }
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
name: 'Cost',
|
|
1145
|
+
type: 'line',
|
|
1146
|
+
yAxisIndex: 1,
|
|
1147
|
+
data: dailyCosts,
|
|
1148
|
+
smooth: true,
|
|
1149
|
+
symbol: 'none',
|
|
1150
|
+
lineStyle: { color: '#FFB800', width: 2 },
|
|
1151
|
+
areaStyle: { color: 'rgba(255,184,0,0.1)' }
|
|
1152
|
+
}
|
|
1153
|
+
]
|
|
1154
|
+
};
|
|
1155
|
+
chart.setOption(option);
|
|
1156
|
+
chart.resize();
|
|
1157
|
+
}`;
|
|
1158
|
+
}
|
|
1159
|
+
function renderHeatmapInit(data) {
|
|
1160
|
+
const days = data.daily.slice().reverse();
|
|
1161
|
+
const heatData = days.map((d) => [d.day, Math.log10(d.totalTokens + 1)]);
|
|
1162
|
+
const minDate = days.length > 0 ? days[0].day : "";
|
|
1163
|
+
const maxDate = days.length > 0 ? days[days.length - 1].day : "";
|
|
1164
|
+
return `
|
|
1165
|
+
var heatData = ${JSON.stringify(heatData)};
|
|
1166
|
+
|
|
1167
|
+
function initHeatmapChart() {
|
|
1168
|
+
var el = document.getElementById('heatmap-chart');
|
|
1169
|
+
if (!el) return;
|
|
1170
|
+
var chart = echarts.init(el);
|
|
1171
|
+
window.heatmapChart = chart;
|
|
1172
|
+
var option = {
|
|
1173
|
+
tooltip: {
|
|
1174
|
+
formatter: function(params) {
|
|
1175
|
+
var val = params.value;
|
|
1176
|
+
var rawTokens = Math.pow(10, val[1]) - 1;
|
|
1177
|
+
return '<b>' + val[0] + '</b><br/>Tokens: ' + fmt(Math.round(rawTokens));
|
|
1178
|
+
}
|
|
1179
|
+
},
|
|
1180
|
+
visualMap: {
|
|
1181
|
+
min: 0,
|
|
1182
|
+
max: Math.max.apply(null, heatData.map(function(d) { return d[1]; })) || 5,
|
|
1183
|
+
calculable: true,
|
|
1184
|
+
orient: 'horizontal',
|
|
1185
|
+
left: 'center',
|
|
1186
|
+
bottom: 10,
|
|
1187
|
+
textStyle: { color: '#B0B0C0' },
|
|
1188
|
+
inRange: {
|
|
1189
|
+
color: ['#0C0C0E', '#1a3a2a', '#00F593', '#00D1FF', '#B545FF']
|
|
1190
|
+
}
|
|
1191
|
+
},
|
|
1192
|
+
calendar: {
|
|
1193
|
+
left: 30,
|
|
1194
|
+
right: 30,
|
|
1195
|
+
top: 20,
|
|
1196
|
+
bottom: 60,
|
|
1197
|
+
range: ['${minDate}', '${maxDate}'],
|
|
1198
|
+
splitLine: { lineStyle: { color: '#2A2A35' } },
|
|
1199
|
+
dayLabel: { color: '#B0B0C0' },
|
|
1200
|
+
monthLabel: { color: '#B0B0C0' },
|
|
1201
|
+
yearLabel: { color: '#B0B0C0' },
|
|
1202
|
+
itemStyle: { color: '#16161A', borderColor: '#0C0C0E', borderWidth: 2 }
|
|
1203
|
+
},
|
|
1204
|
+
series: [{
|
|
1205
|
+
type: 'heatmap',
|
|
1206
|
+
coordinateSystem: 'calendar',
|
|
1207
|
+
data: heatData
|
|
1208
|
+
}]
|
|
1209
|
+
};
|
|
1210
|
+
chart.setOption(option);
|
|
1211
|
+
chart.resize();
|
|
1212
|
+
}`;
|
|
1213
|
+
}
|
|
1214
|
+
function generateUsageHtml(data) {
|
|
1215
|
+
const metaStr = renderMeta(data);
|
|
1216
|
+
const kpiStr = renderKpiCards(data);
|
|
1217
|
+
const modelChartVisible = data.models.filter((m) => m.totalTokens >= 1e6).length > 0;
|
|
1218
|
+
const modelChartJs = modelChartVisible ? renderModelChartInit(data) : "";
|
|
1219
|
+
const scatterChartJs = renderScatterChartInit(data);
|
|
1220
|
+
const providerStr = renderProviderCards(data);
|
|
1221
|
+
const modelAnalyticsStr = renderModelAnalyticsSection(data);
|
|
1222
|
+
const dailyChartJs = renderDailyTrendInit(data);
|
|
1223
|
+
const heatmapJs = renderHeatmapInit(data);
|
|
1224
|
+
const hasPerf = data.perfSummary.some(
|
|
1225
|
+
(p) => p.requestCount > 0 && p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite > 0
|
|
1226
|
+
);
|
|
1227
|
+
const jsonData = JSON.stringify(data);
|
|
1228
|
+
return `<!DOCTYPE html>
|
|
1229
|
+
<html lang="en">
|
|
1230
|
+
<head>
|
|
1231
|
+
<meta charset="UTF-8">
|
|
1232
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1233
|
+
<title>TokenWatch Usage Report</title>
|
|
1234
|
+
<style>
|
|
1235
|
+
/* \u4F7F\u7528\u7CFB\u7EDF\u5B57\u4F53 stack\uFF0C\u65E0\u9700\u52A0\u8F7D\u5916\u90E8\u5B57\u4F53 */
|
|
1236
|
+
:root {
|
|
1237
|
+
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
1238
|
+
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', 'Monaco', monospace;
|
|
1239
|
+
}
|
|
1240
|
+
</style>
|
|
1241
|
+
<!-- ECharts: \u591A CDN fallback (staticfile -> bootcdn -> cdnjs -> unpkg -> jsDelivr) -->
|
|
1242
|
+
<script>
|
|
1243
|
+
(function() {
|
|
1244
|
+
var cdns = [
|
|
1245
|
+
'https://cdn.staticfile.org/echarts/5.5.0/echarts.min.js',
|
|
1246
|
+
'https://cdn.bootcdn.net/ajax/libs/echarts/5.5.0/echarts.min.js',
|
|
1247
|
+
'https://cdnjs.cloudflare.com/ajax/libs/echarts/5.5.0/echarts.min.js',
|
|
1248
|
+
'https://unpkg.com/echarts@5.5.0/dist/echarts.min.js',
|
|
1249
|
+
'https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js'
|
|
1250
|
+
];
|
|
1251
|
+
var idx = 0;
|
|
1252
|
+
function loadNext() {
|
|
1253
|
+
if (idx >= cdns.length) {
|
|
1254
|
+
var err = document.getElementById('echarts-error');
|
|
1255
|
+
if (err) err.style.display = 'block';
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
var s = document.createElement('script');
|
|
1259
|
+
s.src = cdns[idx++];
|
|
1260
|
+
s.onload = function() {
|
|
1261
|
+
if (typeof window.tryInitCharts === 'function') {
|
|
1262
|
+
window.tryInitCharts();
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
s.onerror = loadNext;
|
|
1266
|
+
document.head.appendChild(s);
|
|
1267
|
+
}
|
|
1268
|
+
loadNext();
|
|
1269
|
+
})();
|
|
1270
|
+
</script>
|
|
1271
|
+
<style>
|
|
1272
|
+
:root {
|
|
1273
|
+
--bg: #0C0C0E;
|
|
1274
|
+
--card: #16161A;
|
|
1275
|
+
--border: #2A2A35;
|
|
1276
|
+
--text: #E0E0F0;
|
|
1277
|
+
--text-dim: #B0B0C0;
|
|
1278
|
+
--cache: #00F593;
|
|
1279
|
+
--input: #00D1FF;
|
|
1280
|
+
--output: #B545FF;
|
|
1281
|
+
--tps: #FFB800;
|
|
1282
|
+
--radius: 8px;
|
|
1283
|
+
}
|
|
1284
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1285
|
+
body {
|
|
1286
|
+
background: var(--bg);
|
|
1287
|
+
color: var(--text);
|
|
1288
|
+
font-family: var(--font-sans);
|
|
1289
|
+
font-size: 14px;
|
|
1290
|
+
line-height: 1.5;
|
|
1291
|
+
min-height: 100vh;
|
|
1292
|
+
}
|
|
1293
|
+
.container { max-width: 1400px; margin: 0 auto; padding: 24px 20px; }
|
|
1294
|
+
.header {
|
|
1295
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
1296
|
+
padding: 16px 0; border-bottom: 1px solid var(--border); margin-bottom: 24px;
|
|
1297
|
+
}
|
|
1298
|
+
.header h1 { font-size: 22px; font-weight: 600; color: var(--text); }
|
|
1299
|
+
.header h1 span { color: var(--input); }
|
|
1300
|
+
.header .meta { font-size: 12px; color: var(--text-dim); font-family: var(--font-mono); }
|
|
1301
|
+
|
|
1302
|
+
.kpi-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 24px; }
|
|
1303
|
+
.kpi-card {
|
|
1304
|
+
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
|
|
1305
|
+
padding: 16px; text-align: center;
|
|
1306
|
+
}
|
|
1307
|
+
.kpi-card.kpi-glow { box-shadow: 0 0 20px rgba(0,245,147,0.15); border-color: var(--cache); }
|
|
1308
|
+
.kpi-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
|
1309
|
+
.kpi-value { font-size: 26px; font-weight: 700; font-family: var(--font-mono); color: var(--text); }
|
|
1310
|
+
|
|
1311
|
+
.section { margin-bottom: 28px; }
|
|
1312
|
+
.section-title {
|
|
1313
|
+
font-size: 16px; font-weight: 600; margin-bottom: 12px;
|
|
1314
|
+
padding-bottom: 6px; border-bottom: 1px solid var(--border);
|
|
1315
|
+
}
|
|
1316
|
+
.chart-box {
|
|
1317
|
+
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
|
|
1318
|
+
padding: 12px; height: 400px;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
.tab-bar { display: flex; gap: 4px; margin-bottom: 12px; }
|
|
1322
|
+
.tab-btn {
|
|
1323
|
+
background: var(--card); border: 1px solid var(--border); color: var(--text-dim);
|
|
1324
|
+
padding: 6px 18px; border-radius: 4px 4px 0 0; cursor: pointer; font-size: 13px; font-family: var(--font-sans);
|
|
1325
|
+
}
|
|
1326
|
+
.tab-btn:hover { border-color: var(--input); color: var(--text); }
|
|
1327
|
+
.tab-btn.active {
|
|
1328
|
+
background: var(--border); color: var(--text); border-bottom-color: var(--border);
|
|
1329
|
+
}
|
|
1330
|
+
.tab-content { display: none; }
|
|
1331
|
+
.tab-content.active { display: block; }
|
|
1332
|
+
|
|
1333
|
+
.provider-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
|
1334
|
+
.provider-card {
|
|
1335
|
+
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
|
|
1336
|
+
padding: 14px;
|
|
1337
|
+
}
|
|
1338
|
+
.provider-name { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--input); }
|
|
1339
|
+
.provider-stat { display: flex; justify-content: space-between; font-size: 12px; padding: 2px 0; }
|
|
1340
|
+
.provider-stat .stat-label { color: var(--text-dim); }
|
|
1341
|
+
.provider-more { color: var(--text-dim); font-size: 11px; padding: 10px 4px 0; grid-column: 1 / -1; }
|
|
1342
|
+
|
|
1343
|
+
.data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
1344
|
+
.data-table th {
|
|
1345
|
+
background: var(--card); color: var(--text-dim); padding: 8px 10px;
|
|
1346
|
+
text-align: right; border-bottom: 1px solid var(--border); font-weight: 500;
|
|
1347
|
+
white-space: nowrap;
|
|
1348
|
+
}
|
|
1349
|
+
.data-table th:first-child { text-align: left; }
|
|
1350
|
+
.data-table td {
|
|
1351
|
+
padding: 6px 10px; text-align: right; border-bottom: 1px solid var(--border);
|
|
1352
|
+
font-family: var(--font-mono);
|
|
1353
|
+
}
|
|
1354
|
+
.data-table td:first-child {
|
|
1355
|
+
text-align: left; color: var(--text); font-family: var(--font-sans);
|
|
1356
|
+
}
|
|
1357
|
+
.data-table tbody tr:hover { background: rgba(42,42,53,0.4); }
|
|
1358
|
+
|
|
1359
|
+
.pagination-ctrl {
|
|
1360
|
+
display: none; align-items: center; gap: 14px; justify-content: center;
|
|
1361
|
+
padding: 14px 0 4px;
|
|
1362
|
+
}
|
|
1363
|
+
.page-btn {
|
|
1364
|
+
background: var(--card); border: 1px solid var(--border); color: var(--text);
|
|
1365
|
+
padding: 5px 16px; border-radius: 4px; cursor: pointer;
|
|
1366
|
+
font-size: 12px; font-family: var(--font-sans); transition: border-color 0.15s, color 0.15s;
|
|
1367
|
+
}
|
|
1368
|
+
.page-btn:hover:not(:disabled) { border-color: var(--input); color: var(--input); }
|
|
1369
|
+
.page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
1370
|
+
.page-info {
|
|
1371
|
+
color: var(--text-dim); font-size: 12px;
|
|
1372
|
+
font-family: var(--font-mono); min-width: 110px; text-align: center;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
.empty-state {
|
|
1376
|
+
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
|
|
1377
|
+
padding: 40px; text-align: center; color: var(--text-dim);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
.footer {
|
|
1381
|
+
margin-top: 40px; padding: 16px 0; border-top: 1px solid var(--border);
|
|
1382
|
+
text-align: center; font-size: 11px; color: var(--text-dim);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
@media (max-width: 768px) {
|
|
1386
|
+
.kpi-row { grid-template-columns: repeat(2, 1fr); }
|
|
1387
|
+
.provider-row { grid-template-columns: 1fr; }
|
|
1388
|
+
.container { padding: 12px 10px; }
|
|
1389
|
+
.header { flex-direction: column; gap: 6px; align-items: flex-start; }
|
|
1390
|
+
.chart-box { height: 300px; }
|
|
1391
|
+
.data-table { font-size: 11px; }
|
|
1392
|
+
.data-table th, .data-table td { padding: 4px 6px; }
|
|
1393
|
+
}
|
|
1394
|
+
</style>
|
|
1395
|
+
</head>
|
|
1396
|
+
<body>
|
|
1397
|
+
<div id="echarts-error" style="display:none;position:fixed;top:0;left:0;right:0;padding:14px 24px;background:#7f1d1d;color:#fca5a5;font-size:13px;z-index:9999;text-align:center">
|
|
1398
|
+
\u26A0\uFE0F \u56FE\u8868\u5E93\u52A0\u8F7D\u5931\u8D25\uFF08ECharts CDN \u5747\u65E0\u6CD5\u8BBF\u95EE\uFF09\u3002\u8BF7\u68C0\u67E5\u7F51\u7EDC\u8FDE\u63A5\u540E\u5237\u65B0\u9875\u9762\uFF0C\u6216\u5728\u79BB\u7EBF\u73AF\u5883\u4E0B\u5C06 ECharts JS \u6587\u4EF6\u4E0B\u8F7D\u5230\u672C\u5730\u540E\u624B\u52A8\u5F15\u5165\u3002
|
|
1399
|
+
</div>
|
|
1400
|
+
<div class="container">
|
|
1401
|
+
<div class="header">
|
|
1402
|
+
<h1><span>TokenWatch</span> Usage Report</h1>
|
|
1403
|
+
<div class="meta">${metaStr}</div>
|
|
1404
|
+
</div>
|
|
1405
|
+
|
|
1406
|
+
${kpiStr}
|
|
1407
|
+
|
|
1408
|
+
<div class="section">
|
|
1409
|
+
<div class="section-title">Model Comparison Matrix</div>
|
|
1410
|
+
${modelChartVisible ? '<div class="chart-box" id="model-chart"></div>' : '<div class="empty-state">No models with ≥1M tokens in this period.</div>'}
|
|
1411
|
+
</div>
|
|
1412
|
+
|
|
1413
|
+
<div class="section">
|
|
1414
|
+
<div class="section-title">Provider Summary</div>
|
|
1415
|
+
<div class="provider-row">${providerStr}</div>
|
|
1416
|
+
</div>
|
|
1417
|
+
|
|
1418
|
+
${modelAnalyticsStr}
|
|
1419
|
+
|
|
1420
|
+
<div class="section">
|
|
1421
|
+
<div class="section-title">Efficiency vs Cost</div>
|
|
1422
|
+
${hasPerf ? '<div class="chart-box" id="scatter-chart"></div>' : '<div class="empty-state">No performance data available for this period.</div>'}
|
|
1423
|
+
</div>
|
|
1424
|
+
|
|
1425
|
+
<div class="section">
|
|
1426
|
+
<div class="section-title">Usage Timeline</div>
|
|
1427
|
+
<div class="tab-bar">
|
|
1428
|
+
<button class="tab-btn active" data-tab="daily" onclick="switchTab('daily')">Daily Trend</button>
|
|
1429
|
+
<button class="tab-btn" data-tab="heatmap" onclick="switchTab('heatmap')">Heatmap</button>
|
|
1430
|
+
</div>
|
|
1431
|
+
<div id="tab-daily" class="tab-content active">
|
|
1432
|
+
<div class="chart-box" id="daily-chart"></div>
|
|
1433
|
+
</div>
|
|
1434
|
+
<div id="tab-heatmap" class="tab-content">
|
|
1435
|
+
<div class="chart-box" id="heatmap-chart"></div>
|
|
1436
|
+
</div>
|
|
1437
|
+
</div>
|
|
1438
|
+
|
|
1439
|
+
<div class="footer">
|
|
1440
|
+
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>
|
|
1441
|
+
</div>
|
|
1442
|
+
</div>
|
|
1443
|
+
|
|
1444
|
+
<script id="report-data" type="application/json">${jsonData}</script>
|
|
1445
|
+
|
|
1446
|
+
<script>
|
|
1447
|
+
var fmt = function(v) {
|
|
1448
|
+
if (v == null) return '\u2014';
|
|
1449
|
+
if (v >= 1000000000) return (v/1000000000).toFixed(1)+'B';
|
|
1450
|
+
if (v >= 1000000) return (v/1000000).toFixed(1)+'M';
|
|
1451
|
+
if (v >= 1000) return (v/1000).toFixed(1)+'K';
|
|
1452
|
+
return String(v);
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1455
|
+
// Usage Timeline tab switcher
|
|
1456
|
+
window.switchTab = function(name) {
|
|
1457
|
+
document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
|
|
1458
|
+
document.querySelectorAll('.tab-btn[data-tab]').forEach(function(el) { el.classList.remove('active'); });
|
|
1459
|
+
document.getElementById('tab-' + name).classList.add('active');
|
|
1460
|
+
document.querySelector('[data-tab="' + name + '"]').classList.add('active');
|
|
1461
|
+
setTimeout(function() {
|
|
1462
|
+
if (name === 'daily' && window.dailyChart) window.dailyChart.resize();
|
|
1463
|
+
if (name === 'heatmap' && window.heatmapChart) window.heatmapChart.resize();
|
|
1464
|
+
}, 50);
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
// Model Analytics tab switcher
|
|
1468
|
+
window.switchModelTab = function(name) {
|
|
1469
|
+
document.querySelectorAll('[data-mtab]').forEach(function(el) { el.classList.remove('active'); });
|
|
1470
|
+
['model-tab-usage', 'model-tab-perf', 'model-tab-errors'].forEach(function(id) {
|
|
1471
|
+
var el = document.getElementById(id);
|
|
1472
|
+
if (el) el.classList.remove('active');
|
|
1473
|
+
});
|
|
1474
|
+
var activeTab = document.getElementById('model-tab-' + name);
|
|
1475
|
+
if (activeTab) activeTab.classList.add('active');
|
|
1476
|
+
var activeBtn = document.querySelector('[data-mtab="' + name + '"]');
|
|
1477
|
+
if (activeBtn) activeBtn.classList.add('active');
|
|
1478
|
+
};
|
|
1479
|
+
|
|
1480
|
+
// Generic paginator: hide/show tbody rows, show prev/next controls
|
|
1481
|
+
function initPaginator(tableId, pageSize) {
|
|
1482
|
+
var tbody = document.querySelector('#' + tableId + ' tbody');
|
|
1483
|
+
if (!tbody) return;
|
|
1484
|
+
var rows = Array.from(tbody.querySelectorAll('tr'));
|
|
1485
|
+
if (rows.length <= pageSize) return; // \u884C\u6570\u4E0D\u8D85\u8FC7\u4E00\u9875\u65F6\u65E0\u9700\u5206\u9875
|
|
1486
|
+
var totalPages = Math.ceil(rows.length / pageSize);
|
|
1487
|
+
var cur = 1;
|
|
1488
|
+
|
|
1489
|
+
function render() {
|
|
1490
|
+
rows.forEach(function(r, i) {
|
|
1491
|
+
r.style.display = (i >= (cur - 1) * pageSize && i < cur * pageSize) ? '' : 'none';
|
|
1492
|
+
});
|
|
1493
|
+
var info = document.getElementById(tableId + '-info');
|
|
1494
|
+
if (info) info.textContent = 'Page ' + cur + ' / ' + totalPages + ' (' + rows.length + ' rows)';
|
|
1495
|
+
var prevEl = document.getElementById(tableId + '-prev');
|
|
1496
|
+
var nextEl = document.getElementById(tableId + '-next');
|
|
1497
|
+
if (prevEl) prevEl.disabled = cur === 1;
|
|
1498
|
+
if (nextEl) nextEl.disabled = cur === totalPages;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
var prevEl = document.getElementById(tableId + '-prev');
|
|
1502
|
+
var nextEl = document.getElementById(tableId + '-next');
|
|
1503
|
+
if (prevEl) prevEl.addEventListener('click', function() { if (cur > 1) { cur--; render(); } });
|
|
1504
|
+
if (nextEl) nextEl.addEventListener('click', function() { if (cur < totalPages) { cur++; render(); } });
|
|
1505
|
+
|
|
1506
|
+
var ctrl = document.getElementById(tableId + '-ctrl');
|
|
1507
|
+
if (ctrl) ctrl.style.display = 'flex';
|
|
1508
|
+
render();
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
${modelChartJs}
|
|
1512
|
+
${scatterChartJs}
|
|
1513
|
+
${dailyChartJs}
|
|
1514
|
+
${heatmapJs}
|
|
1515
|
+
|
|
1516
|
+
window.downloadJSON = function() {
|
|
1517
|
+
var d = document.getElementById('report-data');
|
|
1518
|
+
if (!d) return;
|
|
1519
|
+
var b = new Blob([d.textContent], { type: 'application/json' });
|
|
1520
|
+
var a = document.createElement('a');
|
|
1521
|
+
a.href = URL.createObjectURL(b);
|
|
1522
|
+
a.download = 'tokenwatch-data.json';
|
|
1523
|
+
document.body.appendChild(a);
|
|
1524
|
+
a.click();
|
|
1525
|
+
document.body.removeChild(a);
|
|
1526
|
+
setTimeout(function() { URL.revokeObjectURL(a.href); }, 100);
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
window.tryInitCharts = function() {
|
|
1530
|
+
if (typeof echarts === 'undefined' || window.__chartsInitialized) return;
|
|
1531
|
+
window.__chartsInitialized = true;
|
|
1532
|
+
${modelChartVisible ? "initModelChart();" : ""}
|
|
1533
|
+
${hasPerf ? "initScatterChart();" : ""}
|
|
1534
|
+
initDailyChart();
|
|
1535
|
+
initHeatmapChart();
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
1539
|
+
initPaginator('usage-table', 10);
|
|
1540
|
+
initPaginator('perf-table', 10);
|
|
1541
|
+
initPaginator('errors-table', 10);
|
|
1542
|
+
window.tryInitCharts();
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
window.addEventListener('resize', function() {
|
|
1546
|
+
${modelChartVisible ? "if (window.modelChart) window.modelChart.resize();" : ""}
|
|
1547
|
+
if (window.scatterChart) window.scatterChart.resize();
|
|
1548
|
+
if (window.dailyChart) window.dailyChart.resize();
|
|
1549
|
+
if (window.heatmapChart) window.heatmapChart.resize();
|
|
1550
|
+
});
|
|
1551
|
+
</script>
|
|
1552
|
+
</body>
|
|
1553
|
+
</html>`;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// src/i18n.ts
|
|
1557
|
+
var zh = {
|
|
1558
|
+
panelTitle: "TokenWatch",
|
|
1559
|
+
collapse: "\u6298\u53E0",
|
|
1560
|
+
expand: "\u5C55\u5F00",
|
|
1561
|
+
sessionSummary: "\u4F1A\u8BDD\u7D2F\u8BA1",
|
|
1562
|
+
input: "\u8F93\u5165",
|
|
1563
|
+
output: "\u8F93\u51FA",
|
|
1564
|
+
cacheRead: "\u7F13\u5B58",
|
|
1565
|
+
cacheWrite: "\u7F13\u5B58\u5199",
|
|
1566
|
+
cacheMiss: "\u672A\u547D\u4E2D",
|
|
1567
|
+
hitRate: "\u547D\u4E2D\u7387",
|
|
1568
|
+
requests: "\u8BF7\u6C42",
|
|
1569
|
+
cost: "\u6210\u672C",
|
|
1570
|
+
trendUp: "\u2191",
|
|
1571
|
+
trendDown: "\u2193",
|
|
1572
|
+
cache: "\u7F13\u5B58",
|
|
1573
|
+
lat: "\u5EF6\u8FDF",
|
|
1574
|
+
performance: "\u6027\u80FD",
|
|
1575
|
+
pricing: "Pricing",
|
|
1576
|
+
tokenDistribution: "Token\u5206\u5E03",
|
|
1577
|
+
modelLabel: "\u6A21\u578B",
|
|
1578
|
+
provider: "\u63D0\u4F9B\u5546",
|
|
1579
|
+
ttft: "TTFT",
|
|
1580
|
+
tps: "TPS",
|
|
1581
|
+
latency: "\u5EF6\u8FDF",
|
|
1582
|
+
avg: "\u5E73\u5747",
|
|
1583
|
+
max: "\u6700\u5927",
|
|
1584
|
+
min: "\u6700\u5C0F",
|
|
1585
|
+
read: "\u8BFB",
|
|
1586
|
+
write: "\u5199",
|
|
1587
|
+
sessionAccumulated: "Session\u7D2F\u8BA1",
|
|
1588
|
+
saving: "\u8282\u7701",
|
|
1589
|
+
priceInput: "\u8F93\u5165",
|
|
1590
|
+
priceCacheRead: "\u7F13\u5B58\u8BFB",
|
|
1591
|
+
priceCacheWrite: "\u7F13\u5B58\u5199",
|
|
1592
|
+
priceOutput: "\u8F93\u51FA",
|
|
1593
|
+
total: "\u603B\u8BA1",
|
|
1594
|
+
system: "\u7CFB\u7EDF\u63D0\u793A",
|
|
1595
|
+
user: "\u7528\u6237",
|
|
1596
|
+
agent: "Agent\u6307\u4EE4",
|
|
1597
|
+
toolCall: "Tool\u8C03\u7528",
|
|
1598
|
+
toolResult: "Tool\u7ED3\u679C",
|
|
1599
|
+
outputTokens: "\u8F93\u51FA",
|
|
1600
|
+
showPerformance: "\u663E\u793A\u6027\u80FD\u6307\u6807",
|
|
1601
|
+
showPricing: "\u663E\u793A\u6A21\u578B\u5B9A\u4EF7",
|
|
1602
|
+
showTokenDistribution: "\u663E\u793AToken\u5206\u5E03",
|
|
1603
|
+
showTrend: "\u663E\u793A\u8D8B\u52BF\u6307\u793A\u5668",
|
|
1604
|
+
language: "\u8BED\u8A00",
|
|
1605
|
+
auto: "\u81EA\u52A8",
|
|
1606
|
+
cmdTitleHtml: "HTML\u62A5\u544A",
|
|
1607
|
+
cmdDescHtml: "\u751F\u6210\u4EA4\u4E92\u5F0FHTML\u4EEA\u8868\u76D8\uFF0C\u5C55\u793AToken\u7528\u91CF\u3001\u7F13\u5B58\u548C\u6027\u80FD\u56FE\u8868",
|
|
1608
|
+
cmdTitleJson: "JSON\u5BFC\u51FA",
|
|
1609
|
+
cmdDescJson: "\u5BFC\u51FA\u539F\u59CB\u7528\u91CF\u6570\u636E\u4E3AJSON\u6587\u4EF6",
|
|
1610
|
+
cmdTitleText: "\u6587\u672C\u62A5\u544A",
|
|
1611
|
+
cmdDescText: "\u751F\u6210\u7EAF\u6587\u672C\u62A5\u544A\u6587\u4EF6",
|
|
1612
|
+
cmdTitleSettings: "\u8BBE\u7F6E",
|
|
1613
|
+
cmdDescSettings: "\u914D\u7F6E\u4FA7\u8FB9\u680F\u663E\u793A\u9009\u9879",
|
|
1614
|
+
descShowPerformance: "\u5728\u4FA7\u8FB9\u680F\u663E\u793ATPS\u3001TTFT\u3001\u5EF6\u8FDF\u7B49\u6307\u6807",
|
|
1615
|
+
descShowPricing: "\u5728\u4FA7\u8FB9\u680F\u663E\u793A\u6210\u672C\u4F30\u7B97",
|
|
1616
|
+
descShowTokenDistribution: "\u5728\u4FA7\u8FB9\u680F\u663E\u793A\u8F93\u5165/\u8F93\u51FA/\u63A8\u7406Token\u7EC6\u5206",
|
|
1617
|
+
descShowTrend: "\u5728\u4FA7\u8FB9\u680F\u663E\u793AToken\u7528\u91CF\u8D8B\u52BF",
|
|
1618
|
+
settingsLanguage: "\u8BED\u8A00",
|
|
1619
|
+
descSettingsLanguage: "\u5207\u6362\u663E\u793A\u8BED\u8A00",
|
|
1620
|
+
settingsTitle: "TokenWatch\u8BBE\u7F6E",
|
|
1621
|
+
settingsPlaceholder: "\u5207\u6362\u8BBE\u7F6E\u9879...",
|
|
1622
|
+
langAuto: "\u81EA\u52A8",
|
|
1623
|
+
done: "\u5B8C\u6210",
|
|
1624
|
+
closeSettings: "\u5173\u95ED\u8BBE\u7F6E",
|
|
1625
|
+
menuToday: "\u4ECA\u5929",
|
|
1626
|
+
menu7d: "\u6700\u8FD1 7 \u5929",
|
|
1627
|
+
menu30d: "\u6700\u8FD1 30 \u5929",
|
|
1628
|
+
menuAll: "\u5168\u90E8\u65F6\u95F4"
|
|
1629
|
+
};
|
|
1630
|
+
var en = {
|
|
1631
|
+
panelTitle: "TokenWatch",
|
|
1632
|
+
collapse: "Collapse",
|
|
1633
|
+
expand: "Expand",
|
|
1634
|
+
sessionSummary: "Session",
|
|
1635
|
+
input: "Input",
|
|
1636
|
+
output: "Output",
|
|
1637
|
+
cacheRead: "Cache",
|
|
1638
|
+
cacheWrite: "C.Write",
|
|
1639
|
+
cacheMiss: "Cache Miss",
|
|
1640
|
+
hitRate: "Hit Rate",
|
|
1641
|
+
requests: "Req",
|
|
1642
|
+
cost: "Cost",
|
|
1643
|
+
trendUp: "\u2191",
|
|
1644
|
+
trendDown: "\u2193",
|
|
1645
|
+
cache: "Cache",
|
|
1646
|
+
lat: "Lat",
|
|
1647
|
+
performance: "Performance",
|
|
1648
|
+
pricing: "Pricing",
|
|
1649
|
+
tokenDistribution: "Token Distribution",
|
|
1650
|
+
modelLabel: "Model",
|
|
1651
|
+
provider: "Provider",
|
|
1652
|
+
ttft: "TTFT",
|
|
1653
|
+
tps: "TPS",
|
|
1654
|
+
latency: "Latency",
|
|
1655
|
+
avg: "Avg",
|
|
1656
|
+
max: "Max",
|
|
1657
|
+
min: "Min",
|
|
1658
|
+
read: "Read",
|
|
1659
|
+
write: "Write",
|
|
1660
|
+
sessionAccumulated: "Session Accumulated",
|
|
1661
|
+
saving: "Saving",
|
|
1662
|
+
priceInput: "Input",
|
|
1663
|
+
priceCacheRead: "Cache Read",
|
|
1664
|
+
priceCacheWrite: "Cache Write",
|
|
1665
|
+
priceOutput: "Output",
|
|
1666
|
+
total: "Total",
|
|
1667
|
+
system: "System",
|
|
1668
|
+
user: "User",
|
|
1669
|
+
agent: "Agent",
|
|
1670
|
+
toolCall: "Tool Call",
|
|
1671
|
+
toolResult: "Tool Result",
|
|
1672
|
+
outputTokens: "Output",
|
|
1673
|
+
showPerformance: "Show Performance",
|
|
1674
|
+
showPricing: "Show Pricing",
|
|
1675
|
+
showTokenDistribution: "Show Token Distribution",
|
|
1676
|
+
showTrend: "Show Trend",
|
|
1677
|
+
language: "Language",
|
|
1678
|
+
auto: "Auto",
|
|
1679
|
+
cmdTitleHtml: "HTML Report",
|
|
1680
|
+
cmdDescHtml: "Generate interactive HTML dashboard with token usage, cache, and performance charts",
|
|
1681
|
+
cmdTitleJson: "JSON Export",
|
|
1682
|
+
cmdDescJson: "Export raw usage data as JSON file",
|
|
1683
|
+
cmdTitleText: "Text Report",
|
|
1684
|
+
cmdDescText: "Generate plain text report file",
|
|
1685
|
+
cmdTitleSettings: "Settings",
|
|
1686
|
+
cmdDescSettings: "Configure sidebar display options",
|
|
1687
|
+
descShowPerformance: "Display TPS, TTFT, latency metrics in sidebar",
|
|
1688
|
+
descShowPricing: "Display cost estimates in sidebar",
|
|
1689
|
+
descShowTokenDistribution: "Display input/output/reasoning token breakdown in sidebar",
|
|
1690
|
+
descShowTrend: "Display token usage trend in sidebar",
|
|
1691
|
+
settingsLanguage: "Language",
|
|
1692
|
+
descSettingsLanguage: "Switch display language",
|
|
1693
|
+
settingsTitle: "TokenWatch Settings",
|
|
1694
|
+
settingsPlaceholder: "Toggle settings...",
|
|
1695
|
+
langAuto: "Auto",
|
|
1696
|
+
done: "Done",
|
|
1697
|
+
closeSettings: "Close settings",
|
|
1698
|
+
menuToday: "Today",
|
|
1699
|
+
menu7d: "Last 7 Days",
|
|
1700
|
+
menu30d: "Last 30 Days",
|
|
1701
|
+
menuAll: "All Time"
|
|
1702
|
+
};
|
|
1703
|
+
var currentLang = detectLanguage();
|
|
1704
|
+
function detectLanguage() {
|
|
1705
|
+
try {
|
|
1706
|
+
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
1707
|
+
if (locale.startsWith("zh")) return "zh";
|
|
1708
|
+
} catch {
|
|
1709
|
+
}
|
|
1710
|
+
return "en";
|
|
1711
|
+
}
|
|
1712
|
+
function setLanguage(lang) {
|
|
1713
|
+
if (lang === "auto") {
|
|
1714
|
+
currentLang = detectLanguage();
|
|
1715
|
+
} else {
|
|
1716
|
+
currentLang = lang;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
function t(key) {
|
|
1720
|
+
const table2 = currentLang === "zh" ? zh : en;
|
|
1721
|
+
return table2[key] ?? key;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// src/perf-tracker.ts
|
|
1725
|
+
import { appendFileSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1726
|
+
import { join as join3 } from "node:path";
|
|
1727
|
+
import { homedir as homedir2 } from "node:os";
|
|
1728
|
+
import { existsSync as existsSync2, statSync } from "node:fs";
|
|
1729
|
+
|
|
1730
|
+
// src/stats-store.ts
|
|
1731
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
1732
|
+
import { join as join2 } from "node:path";
|
|
1733
|
+
import { homedir } from "node:os";
|
|
1734
|
+
var STATS_PATH = join2(homedir(), ".opencode", "tokenwatch-stats.json");
|
|
1735
|
+
var LOG_PATH = join2(homedir(), ".opencode", "tokenwatch.jsonl");
|
|
1736
|
+
var RESERVOIR_SIZE = 500;
|
|
1737
|
+
var CURRENT_VERSION = 1;
|
|
1738
|
+
function loadStatsFile() {
|
|
1739
|
+
try {
|
|
1740
|
+
if (!existsSync(STATS_PATH)) {
|
|
1741
|
+
return { version: CURRENT_VERSION, updatedAt: "", migratedFromLogs: false, models: {} };
|
|
1742
|
+
}
|
|
1743
|
+
const content = readFileSync(STATS_PATH, "utf-8");
|
|
1744
|
+
const parsed = JSON.parse(content);
|
|
1745
|
+
if (parsed?.version === CURRENT_VERSION && parsed.models) return parsed;
|
|
1746
|
+
} catch {
|
|
1747
|
+
}
|
|
1748
|
+
return { version: CURRENT_VERSION, updatedAt: "", migratedFromLogs: false, models: {} };
|
|
1749
|
+
}
|
|
1750
|
+
function saveStatsFile(file) {
|
|
1751
|
+
try {
|
|
1752
|
+
file.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1753
|
+
writeFileSync(STATS_PATH, JSON.stringify(file), "utf-8");
|
|
1754
|
+
} catch {
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
function reservoirAdd(reservoir, value, totalCount) {
|
|
1758
|
+
if (reservoir.length < RESERVOIR_SIZE) {
|
|
1759
|
+
return [...reservoir, value];
|
|
1760
|
+
}
|
|
1761
|
+
const j = Math.floor(Math.random() * totalCount);
|
|
1762
|
+
if (j < RESERVOIR_SIZE) {
|
|
1763
|
+
const next = [...reservoir];
|
|
1764
|
+
next[j] = value;
|
|
1765
|
+
return next;
|
|
1766
|
+
}
|
|
1767
|
+
return reservoir;
|
|
1768
|
+
}
|
|
1769
|
+
function applyEntryToModels(models, entry) {
|
|
1770
|
+
const key = entry.model;
|
|
1771
|
+
let s = models[key];
|
|
1772
|
+
if (!s) {
|
|
1773
|
+
s = {
|
|
1774
|
+
model: entry.model,
|
|
1775
|
+
providerID: entry.providerID,
|
|
1776
|
+
requestCount: 0,
|
|
1777
|
+
ttftCount: 0,
|
|
1778
|
+
tpsCount: 0,
|
|
1779
|
+
latencyCount: 0,
|
|
1780
|
+
totalInput: 0,
|
|
1781
|
+
totalOutput: 0,
|
|
1782
|
+
totalCacheRead: 0,
|
|
1783
|
+
totalCacheWrite: 0,
|
|
1784
|
+
totalCost: 0,
|
|
1785
|
+
avgTTFT: null,
|
|
1786
|
+
maxTTFT: null,
|
|
1787
|
+
minTTFT: null,
|
|
1788
|
+
avgTPS: null,
|
|
1789
|
+
maxTPS: null,
|
|
1790
|
+
minTPS: null,
|
|
1791
|
+
avgLatency: null,
|
|
1792
|
+
maxLatency: null,
|
|
1793
|
+
minLatency: null,
|
|
1794
|
+
ttftReservoir: [],
|
|
1795
|
+
latencyReservoir: []
|
|
1796
|
+
};
|
|
1797
|
+
models[key] = s;
|
|
1798
|
+
}
|
|
1799
|
+
s.requestCount++;
|
|
1800
|
+
s.totalInput += entry.inputTokens;
|
|
1801
|
+
s.totalOutput += entry.outputTokens;
|
|
1802
|
+
s.totalCacheRead += entry.cacheReadTokens;
|
|
1803
|
+
s.totalCacheWrite += entry.cacheWriteTokens;
|
|
1804
|
+
s.totalCost += entry.cost;
|
|
1805
|
+
if (entry.ttft_ms != null) {
|
|
1806
|
+
s.ttftCount++;
|
|
1807
|
+
const c = s.ttftCount;
|
|
1808
|
+
s.avgTTFT = s.avgTTFT != null ? s.avgTTFT + (entry.ttft_ms - s.avgTTFT) / c : entry.ttft_ms;
|
|
1809
|
+
s.maxTTFT = s.maxTTFT != null ? Math.max(s.maxTTFT, entry.ttft_ms) : entry.ttft_ms;
|
|
1810
|
+
s.minTTFT = s.minTTFT != null ? Math.min(s.minTTFT, entry.ttft_ms) : entry.ttft_ms;
|
|
1811
|
+
s.ttftReservoir = reservoirAdd(s.ttftReservoir, entry.ttft_ms, s.ttftCount);
|
|
1812
|
+
}
|
|
1813
|
+
if (entry.tps != null) {
|
|
1814
|
+
s.tpsCount++;
|
|
1815
|
+
const c = s.tpsCount;
|
|
1816
|
+
s.avgTPS = s.avgTPS != null ? s.avgTPS + (entry.tps - s.avgTPS) / c : entry.tps;
|
|
1817
|
+
s.maxTPS = s.maxTPS != null ? Math.max(s.maxTPS, entry.tps) : entry.tps;
|
|
1818
|
+
s.minTPS = s.minTPS != null ? Math.min(s.minTPS, entry.tps) : entry.tps;
|
|
1819
|
+
}
|
|
1820
|
+
if (entry.latency_ms != null) {
|
|
1821
|
+
s.latencyCount++;
|
|
1822
|
+
const c = s.latencyCount;
|
|
1823
|
+
s.avgLatency = s.avgLatency != null ? s.avgLatency + (entry.latency_ms - s.avgLatency) / c : entry.latency_ms;
|
|
1824
|
+
s.maxLatency = s.maxLatency != null ? Math.max(s.maxLatency, entry.latency_ms) : entry.latency_ms;
|
|
1825
|
+
s.minLatency = s.minLatency != null ? Math.min(s.minLatency, entry.latency_ms) : entry.latency_ms;
|
|
1826
|
+
s.latencyReservoir = reservoirAdd(s.latencyReservoir, entry.latency_ms, s.latencyCount);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
function migrateFromLogsIfNeeded(file) {
|
|
1830
|
+
if (file.migratedFromLogs) return false;
|
|
1831
|
+
if (!existsSync(LOG_PATH)) {
|
|
1832
|
+
file.migratedFromLogs = true;
|
|
1833
|
+
return true;
|
|
1834
|
+
}
|
|
1835
|
+
try {
|
|
1836
|
+
const content = readFileSync(LOG_PATH, "utf-8").trim();
|
|
1837
|
+
if (!content) {
|
|
1838
|
+
file.migratedFromLogs = true;
|
|
1839
|
+
return true;
|
|
1840
|
+
}
|
|
1841
|
+
let migrated = 0;
|
|
1842
|
+
for (const line of content.split("\n")) {
|
|
1843
|
+
if (!line) continue;
|
|
1844
|
+
try {
|
|
1845
|
+
const entry = JSON.parse(line);
|
|
1846
|
+
if (entry.model && entry.ts) {
|
|
1847
|
+
applyEntryToModels(file.models, entry);
|
|
1848
|
+
migrated++;
|
|
1849
|
+
}
|
|
1850
|
+
} catch {
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
file.migratedFromLogs = true;
|
|
1854
|
+
if (migrated > 0) {
|
|
1855
|
+
;
|
|
1856
|
+
file._migratedFrom = `${LOG_PATH} (${migrated} entries)`;
|
|
1857
|
+
}
|
|
1858
|
+
return true;
|
|
1859
|
+
} catch {
|
|
1860
|
+
file.migratedFromLogs = true;
|
|
1861
|
+
return true;
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
function percentile(arr, p) {
|
|
1865
|
+
if (arr.length === 0) return null;
|
|
1866
|
+
if (arr.length === 1) return arr[0];
|
|
1867
|
+
const idx = p / 100 * (arr.length - 1);
|
|
1868
|
+
const lo = Math.floor(idx);
|
|
1869
|
+
const hi = Math.ceil(idx);
|
|
1870
|
+
if (lo === hi) return arr[lo];
|
|
1871
|
+
return arr[lo] + (arr[hi] - arr[lo]) * (idx - lo);
|
|
1872
|
+
}
|
|
1873
|
+
function updatePersistedStats(entry) {
|
|
1874
|
+
try {
|
|
1875
|
+
const file = loadStatsFile();
|
|
1876
|
+
applyEntryToModels(file.models, entry);
|
|
1877
|
+
saveStatsFile(file);
|
|
1878
|
+
} catch {
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
function readPersistedStats() {
|
|
1882
|
+
try {
|
|
1883
|
+
const file = loadStatsFile();
|
|
1884
|
+
if (!file.migratedFromLogs) {
|
|
1885
|
+
file.models = {};
|
|
1886
|
+
migrateFromLogsIfNeeded(file);
|
|
1887
|
+
saveStatsFile(file);
|
|
1888
|
+
}
|
|
1889
|
+
return Object.values(file.models).map((s) => {
|
|
1890
|
+
const ttftArr = [...s.ttftReservoir].sort((a, b) => a - b);
|
|
1891
|
+
const latArr = [...s.latencyReservoir].sort((a, b) => a - b);
|
|
1892
|
+
const denom = s.totalInput + s.totalCacheRead;
|
|
1893
|
+
return {
|
|
1894
|
+
model: s.model,
|
|
1895
|
+
providerID: s.providerID,
|
|
1896
|
+
requestCount: s.requestCount,
|
|
1897
|
+
ttftCount: s.ttftCount,
|
|
1898
|
+
tpsCount: s.tpsCount,
|
|
1899
|
+
latencyCount: s.latencyCount,
|
|
1900
|
+
totalInput: s.totalInput,
|
|
1901
|
+
totalOutput: s.totalOutput,
|
|
1902
|
+
totalCacheRead: s.totalCacheRead,
|
|
1903
|
+
totalCacheWrite: s.totalCacheWrite,
|
|
1904
|
+
totalCost: s.totalCost,
|
|
1905
|
+
avgTTFT: s.avgTTFT,
|
|
1906
|
+
maxTTFT: s.maxTTFT,
|
|
1907
|
+
minTTFT: s.minTTFT,
|
|
1908
|
+
p50TTFT: percentile(ttftArr, 50),
|
|
1909
|
+
p95TTFT: percentile(ttftArr, 95),
|
|
1910
|
+
p99TTFT: percentile(ttftArr, 99),
|
|
1911
|
+
avgTPS: s.avgTPS,
|
|
1912
|
+
maxTPS: s.maxTPS,
|
|
1913
|
+
minTPS: s.minTPS,
|
|
1914
|
+
avgLatency: s.avgLatency,
|
|
1915
|
+
maxLatency: s.maxLatency,
|
|
1916
|
+
minLatency: s.minLatency,
|
|
1917
|
+
p50Latency: percentile(latArr, 50),
|
|
1918
|
+
p95Latency: percentile(latArr, 95),
|
|
1919
|
+
p99Latency: percentile(latArr, 99),
|
|
1920
|
+
cacheHitRate: denom > 0 ? s.totalCacheRead / denom * 100 : null
|
|
1921
|
+
};
|
|
1922
|
+
});
|
|
1923
|
+
} catch {
|
|
1924
|
+
return [];
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// src/perf-tracker.ts
|
|
1929
|
+
var LOG_PATH2 = join3(homedir2(), ".opencode", "tokenwatch.jsonl");
|
|
1930
|
+
var PerfTracker = class {
|
|
1931
|
+
firstPartTimes = /* @__PURE__ */ new Map();
|
|
1932
|
+
statsMap = /* @__PURE__ */ new Map();
|
|
1933
|
+
/** 原始样本串,用于分位数计算,不持久化 */
|
|
1934
|
+
ttftSamples = /* @__PURE__ */ new Map();
|
|
1935
|
+
latencySamples = /* @__PURE__ */ new Map();
|
|
1936
|
+
handlePartUpdated(event) {
|
|
1937
|
+
if (!event.time?.start || !event.message_id) return;
|
|
1938
|
+
const cur = this.firstPartTimes.get(event.message_id) ?? Number.POSITIVE_INFINITY;
|
|
1939
|
+
if (event.time.start < cur) {
|
|
1940
|
+
this.firstPartTimes.set(event.message_id, event.time.start);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
handleMessageUpdated(event) {
|
|
1944
|
+
const info = event.properties?.info;
|
|
1945
|
+
if (!info || info.role !== "assistant") return;
|
|
1946
|
+
if (!info.time?.completed) return;
|
|
1947
|
+
const messageID = info.id ?? "";
|
|
1948
|
+
const created = info.time.created;
|
|
1949
|
+
const completed = info.time.completed;
|
|
1950
|
+
if (!created || !completed) {
|
|
1951
|
+
this.firstPartTimes.delete(messageID);
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
const sessionID = info.sessionID ?? "";
|
|
1955
|
+
const providerID = info.providerID ?? "unknown";
|
|
1956
|
+
const modelID = info.modelID ?? "unknown";
|
|
1957
|
+
const model = `${providerID}/${modelID}`;
|
|
1958
|
+
const tokens = info.tokens;
|
|
1959
|
+
const inputTokens = tokens?.input ?? 0;
|
|
1960
|
+
const outputTokens = tokens?.output ?? 0;
|
|
1961
|
+
const reasoningTokens = tokens?.reasoning ?? 0;
|
|
1962
|
+
const cacheRead = tokens?.cache?.read ?? 0;
|
|
1963
|
+
const cacheWrite = tokens?.cache?.write ?? 0;
|
|
1964
|
+
const cost = info.cost ?? 0;
|
|
1965
|
+
if (inputTokens + outputTokens + cacheRead + cacheWrite === 0) {
|
|
1966
|
+
this.firstPartTimes.delete(messageID);
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
const firstPart = this.firstPartTimes.get(messageID) ?? null;
|
|
1970
|
+
const latencyMs = completed - created;
|
|
1971
|
+
const ttftMs = firstPart !== null ? firstPart - created : null;
|
|
1972
|
+
const genMs = firstPart !== null ? completed - firstPart : null;
|
|
1973
|
+
const tps = genMs !== null && genMs > 0 && outputTokens > 0 ? outputTokens / genMs * 1e3 : null;
|
|
1974
|
+
this.firstPartTimes.delete(messageID);
|
|
1975
|
+
const entry = {
|
|
1976
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1977
|
+
model,
|
|
1978
|
+
providerID,
|
|
1979
|
+
modelID,
|
|
1980
|
+
sessionID,
|
|
1981
|
+
ttft_ms: ttftMs,
|
|
1982
|
+
tps,
|
|
1983
|
+
// 只在有可靠 genMs 时才有值
|
|
1984
|
+
latency_ms: latencyMs,
|
|
1985
|
+
inputTokens,
|
|
1986
|
+
outputTokens,
|
|
1987
|
+
reasoningTokens,
|
|
1988
|
+
cacheReadTokens: cacheRead,
|
|
1989
|
+
cacheWriteTokens: cacheWrite,
|
|
1990
|
+
cost
|
|
1991
|
+
};
|
|
1992
|
+
this.appendLog(entry);
|
|
1993
|
+
this.updateStats(model, entry);
|
|
1994
|
+
}
|
|
1995
|
+
appendLog(entry) {
|
|
1996
|
+
try {
|
|
1997
|
+
const MAX_SIZE = 5 * 1024 * 1024;
|
|
1998
|
+
const KEEP_LINES = 2e3;
|
|
1999
|
+
if (existsSync2(LOG_PATH2) && statSync(LOG_PATH2).size > MAX_SIZE) {
|
|
2000
|
+
const lines = readFileSync2(LOG_PATH2, "utf-8").trim().split("\n");
|
|
2001
|
+
writeFileSync2(LOG_PATH2, lines.slice(-KEEP_LINES).join("\n") + "\n");
|
|
2002
|
+
}
|
|
2003
|
+
appendFileSync(LOG_PATH2, JSON.stringify(entry) + "\n");
|
|
2004
|
+
} catch {
|
|
2005
|
+
}
|
|
2006
|
+
updatePersistedStats(entry);
|
|
2007
|
+
}
|
|
2008
|
+
handleMessageRemoved(event) {
|
|
2009
|
+
const mid = event.properties?.messageID ?? "";
|
|
2010
|
+
if (mid) {
|
|
2011
|
+
this.firstPartTimes.delete(mid);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
updateStats(model, entry) {
|
|
2015
|
+
let stats = this.statsMap.get(model);
|
|
2016
|
+
if (!stats) {
|
|
2017
|
+
stats = {
|
|
2018
|
+
model,
|
|
2019
|
+
providerID: entry.providerID,
|
|
2020
|
+
requestCount: 0,
|
|
2021
|
+
ttftCount: 0,
|
|
2022
|
+
// Bug fix: 独立维护有效样本计数
|
|
2023
|
+
tpsCount: 0,
|
|
2024
|
+
latencyCount: 0,
|
|
2025
|
+
totalInput: 0,
|
|
2026
|
+
totalOutput: 0,
|
|
2027
|
+
totalCacheRead: 0,
|
|
2028
|
+
totalCacheWrite: 0,
|
|
2029
|
+
totalCost: 0,
|
|
2030
|
+
avgTTFT: null,
|
|
2031
|
+
maxTTFT: null,
|
|
2032
|
+
minTTFT: null,
|
|
2033
|
+
p50TTFT: null,
|
|
2034
|
+
p95TTFT: null,
|
|
2035
|
+
p99TTFT: null,
|
|
2036
|
+
avgTPS: null,
|
|
2037
|
+
maxTPS: null,
|
|
2038
|
+
minTPS: null,
|
|
2039
|
+
avgLatency: null,
|
|
2040
|
+
maxLatency: null,
|
|
2041
|
+
minLatency: null,
|
|
2042
|
+
p50Latency: null,
|
|
2043
|
+
p95Latency: null,
|
|
2044
|
+
p99Latency: null,
|
|
2045
|
+
cacheHitRate: null
|
|
2046
|
+
};
|
|
2047
|
+
this.statsMap.set(model, stats);
|
|
2048
|
+
}
|
|
2049
|
+
stats.requestCount++;
|
|
2050
|
+
stats.totalInput += entry.inputTokens;
|
|
2051
|
+
stats.totalOutput += entry.outputTokens;
|
|
2052
|
+
stats.totalCacheRead += entry.cacheReadTokens;
|
|
2053
|
+
stats.totalCacheWrite += entry.cacheWriteTokens;
|
|
2054
|
+
stats.totalCost += entry.cost;
|
|
2055
|
+
if (entry.ttft_ms !== null) {
|
|
2056
|
+
stats.ttftCount++;
|
|
2057
|
+
const c = stats.ttftCount;
|
|
2058
|
+
const prev = stats.avgTTFT;
|
|
2059
|
+
stats.avgTTFT = prev !== null ? prev + (entry.ttft_ms - prev) / c : entry.ttft_ms;
|
|
2060
|
+
stats.maxTTFT = stats.maxTTFT !== null ? Math.max(stats.maxTTFT, entry.ttft_ms) : entry.ttft_ms;
|
|
2061
|
+
stats.minTTFT = stats.minTTFT !== null ? Math.min(stats.minTTFT, entry.ttft_ms) : entry.ttft_ms;
|
|
2062
|
+
const ttftArr = this.ttftSamples.get(model) ?? [];
|
|
2063
|
+
ttftArr.push(entry.ttft_ms);
|
|
2064
|
+
this.ttftSamples.set(model, ttftArr);
|
|
2065
|
+
}
|
|
2066
|
+
if (entry.tps !== null) {
|
|
2067
|
+
stats.tpsCount++;
|
|
2068
|
+
const c = stats.tpsCount;
|
|
2069
|
+
const prev = stats.avgTPS;
|
|
2070
|
+
stats.avgTPS = prev !== null ? prev + (entry.tps - prev) / c : entry.tps;
|
|
2071
|
+
stats.maxTPS = stats.maxTPS !== null ? Math.max(stats.maxTPS, entry.tps) : entry.tps;
|
|
2072
|
+
stats.minTPS = stats.minTPS !== null ? Math.min(stats.minTPS, entry.tps) : entry.tps;
|
|
2073
|
+
}
|
|
2074
|
+
if (entry.latency_ms !== null) {
|
|
2075
|
+
stats.latencyCount++;
|
|
2076
|
+
const c = stats.latencyCount;
|
|
2077
|
+
const prev = stats.avgLatency;
|
|
2078
|
+
stats.avgLatency = prev !== null ? prev + (entry.latency_ms - prev) / c : entry.latency_ms;
|
|
2079
|
+
stats.maxLatency = stats.maxLatency !== null ? Math.max(stats.maxLatency, entry.latency_ms) : entry.latency_ms;
|
|
2080
|
+
stats.minLatency = stats.minLatency !== null ? Math.min(stats.minLatency, entry.latency_ms) : entry.latency_ms;
|
|
2081
|
+
const latArr = this.latencySamples.get(model) ?? [];
|
|
2082
|
+
latArr.push(entry.latency_ms);
|
|
2083
|
+
this.latencySamples.set(model, latArr);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
/** 计算有序数组的指定百分位数(线性插值法) */
|
|
2087
|
+
percentile(sortedArr, p) {
|
|
2088
|
+
if (sortedArr.length === 0) return null;
|
|
2089
|
+
if (sortedArr.length === 1) return sortedArr[0];
|
|
2090
|
+
const idx = p / 100 * (sortedArr.length - 1);
|
|
2091
|
+
const lo = Math.floor(idx);
|
|
2092
|
+
const hi = Math.ceil(idx);
|
|
2093
|
+
if (lo === hi) return sortedArr[lo];
|
|
2094
|
+
return sortedArr[lo] + (sortedArr[hi] - sortedArr[lo]) * (idx - lo);
|
|
2095
|
+
}
|
|
2096
|
+
getSessionStats() {
|
|
2097
|
+
let totalInput = 0, totalOutput = 0, totalCacheRead = 0, totalCacheWrite = 0;
|
|
2098
|
+
let totalRequests = 0, totalCost = 0;
|
|
2099
|
+
let weightedHitSum = 0, totalReqForHit = 0;
|
|
2100
|
+
for (const [model, s] of this.statsMap) {
|
|
2101
|
+
totalInput += s.totalInput;
|
|
2102
|
+
totalOutput += s.totalOutput;
|
|
2103
|
+
totalCacheRead += s.totalCacheRead;
|
|
2104
|
+
totalCacheWrite += s.totalCacheWrite;
|
|
2105
|
+
totalRequests += s.requestCount;
|
|
2106
|
+
totalCost += s.totalCost;
|
|
2107
|
+
const ttftArr = [...this.ttftSamples.get(model) ?? []].sort((a, b) => a - b);
|
|
2108
|
+
s.p50TTFT = this.percentile(ttftArr, 50);
|
|
2109
|
+
s.p95TTFT = this.percentile(ttftArr, 95);
|
|
2110
|
+
s.p99TTFT = this.percentile(ttftArr, 99);
|
|
2111
|
+
const latArr = [...this.latencySamples.get(model) ?? []].sort((a, b) => a - b);
|
|
2112
|
+
s.p50Latency = this.percentile(latArr, 50);
|
|
2113
|
+
s.p95Latency = this.percentile(latArr, 95);
|
|
2114
|
+
s.p99Latency = this.percentile(latArr, 99);
|
|
2115
|
+
const denom = s.totalInput + s.totalCacheRead;
|
|
2116
|
+
s.cacheHitRate = denom > 0 ? s.totalCacheRead / denom * 100 : null;
|
|
2117
|
+
if (s.cacheHitRate !== null) {
|
|
2118
|
+
weightedHitSum += s.cacheHitRate * s.requestCount;
|
|
2119
|
+
totalReqForHit += s.requestCount;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
const weightedCacheHitRate = totalReqForHit > 0 ? weightedHitSum / totalReqForHit : null;
|
|
2123
|
+
return {
|
|
2124
|
+
models: Object.fromEntries(this.statsMap),
|
|
2125
|
+
totals: { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalRequests, totalCost, weightedCacheHitRate }
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
readLogs(last = 50) {
|
|
2129
|
+
try {
|
|
2130
|
+
if (!existsSync2(LOG_PATH2)) return [];
|
|
2131
|
+
const content = readFileSync2(LOG_PATH2, "utf-8").trim();
|
|
2132
|
+
if (!content) return [];
|
|
2133
|
+
const lines = content.split("\n");
|
|
2134
|
+
const entries = [];
|
|
2135
|
+
for (let i = Math.max(0, lines.length - last); i < lines.length; i++) {
|
|
2136
|
+
try {
|
|
2137
|
+
entries.push(JSON.parse(lines[i]));
|
|
2138
|
+
} catch {
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
return entries;
|
|
2142
|
+
} catch {
|
|
2143
|
+
return [];
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
reset() {
|
|
2147
|
+
this.firstPartTimes.clear();
|
|
2148
|
+
this.statsMap.clear();
|
|
2149
|
+
this.ttftSamples.clear();
|
|
2150
|
+
this.latencySamples.clear();
|
|
2151
|
+
}
|
|
2152
|
+
loadSession(sessionID) {
|
|
2153
|
+
this.firstPartTimes.clear();
|
|
2154
|
+
this.statsMap.clear();
|
|
2155
|
+
this.ttftSamples.clear();
|
|
2156
|
+
this.latencySamples.clear();
|
|
2157
|
+
if (!sessionID) return;
|
|
2158
|
+
try {
|
|
2159
|
+
if (!existsSync2(LOG_PATH2)) return;
|
|
2160
|
+
const content = readFileSync2(LOG_PATH2, "utf-8").trim();
|
|
2161
|
+
if (!content) return;
|
|
2162
|
+
const lines = content.split("\n");
|
|
2163
|
+
for (const line of lines) {
|
|
2164
|
+
if (!line) continue;
|
|
2165
|
+
try {
|
|
2166
|
+
const entry = JSON.parse(line);
|
|
2167
|
+
if (entry.sessionID === sessionID) {
|
|
2168
|
+
this.updateStats(entry.model, entry);
|
|
2169
|
+
}
|
|
2170
|
+
} catch {
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
} catch {
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
};
|
|
2177
|
+
function createPerfTracker() {
|
|
2178
|
+
return new PerfTracker();
|
|
2179
|
+
}
|
|
2180
|
+
function readLogs(last = 50) {
|
|
2181
|
+
const tracker = new PerfTracker();
|
|
2182
|
+
return tracker.readLogs(last);
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// src/commands.tsx
|
|
2186
|
+
import { existsSync as existsSync3, mkdirSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2187
|
+
import { join as join4 } from "node:path";
|
|
2188
|
+
import { homedir as homedir3 } from "node:os";
|
|
2189
|
+
import { execSync } from "node:child_process";
|
|
2190
|
+
var DEFAULT_CONFIG = {
|
|
2191
|
+
sidebar: {
|
|
2192
|
+
showPerformance: true,
|
|
2193
|
+
showPricing: true,
|
|
2194
|
+
showTokenDistribution: true,
|
|
2195
|
+
showTrend: true
|
|
2196
|
+
},
|
|
2197
|
+
language: "auto"
|
|
2198
|
+
};
|
|
2199
|
+
async function registerCommands(api) {
|
|
2200
|
+
api.command?.register(() => [{
|
|
2201
|
+
value: "tokenwatch-usage",
|
|
2202
|
+
title: "TokenWatch",
|
|
2203
|
+
description: "Token usage reports, export, and settings",
|
|
2204
|
+
category: "Stats",
|
|
2205
|
+
slash: {
|
|
2206
|
+
name: "usage"
|
|
2207
|
+
},
|
|
2208
|
+
onSelect: async (dialog) => {
|
|
2209
|
+
if (dialog) showUsageMenu(api, dialog);
|
|
2210
|
+
}
|
|
2211
|
+
}]);
|
|
2212
|
+
}
|
|
2213
|
+
function ensureReportDir() {
|
|
2214
|
+
const dir = join4(homedir3(), ".opencode", "reports");
|
|
2215
|
+
if (!existsSync3(dir)) mkdirSync(dir, {
|
|
2216
|
+
recursive: true
|
|
2217
|
+
});
|
|
2218
|
+
return dir;
|
|
2219
|
+
}
|
|
2220
|
+
function openInBrowser(filePath) {
|
|
2221
|
+
try {
|
|
2222
|
+
const platform = process.platform;
|
|
2223
|
+
if (platform === "win32") execSync(`start "" "${filePath}"`, {
|
|
2224
|
+
windowsHide: true,
|
|
2225
|
+
timeout: 5e3
|
|
2226
|
+
});
|
|
2227
|
+
else if (platform === "darwin") execSync(`open "${filePath}"`, {
|
|
2228
|
+
timeout: 5e3
|
|
2229
|
+
});
|
|
2230
|
+
else execSync(`xdg-open "${filePath}"`, {
|
|
2231
|
+
timeout: 5e3
|
|
2232
|
+
});
|
|
2233
|
+
} catch {
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
async function buildCombinedData(api, filters = {}) {
|
|
2237
|
+
const report = await getUsageReport(filters);
|
|
2238
|
+
const logs = readLogs(200);
|
|
2239
|
+
const perfSummary = readPersistedStats();
|
|
2240
|
+
const now = /* @__PURE__ */ new Date();
|
|
2241
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
2242
|
+
const meta = {
|
|
2243
|
+
generatedAt: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
|
|
2244
|
+
dateRange: {
|
|
2245
|
+
start: report.daily.length > 0 ? report.daily[report.daily.length - 1].day : "\u2014",
|
|
2246
|
+
end: report.daily.length > 0 ? report.daily[0].day : "\u2014"
|
|
2247
|
+
}
|
|
2248
|
+
};
|
|
2249
|
+
return {
|
|
2250
|
+
...report,
|
|
2251
|
+
perfLogs: logs,
|
|
2252
|
+
perfSummary,
|
|
2253
|
+
meta
|
|
2254
|
+
};
|
|
2255
|
+
}
|
|
2256
|
+
function getRangeSlug(filters, presetTag) {
|
|
2257
|
+
if (presetTag) return presetTag;
|
|
2258
|
+
if (!filters.startDate && !filters.endDate) return "all";
|
|
2259
|
+
if (filters.startDate && filters.endDate) {
|
|
2260
|
+
if (filters.startDate === filters.endDate) {
|
|
2261
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2262
|
+
if (filters.startDate === today) return "today";
|
|
2263
|
+
return filters.startDate;
|
|
2264
|
+
}
|
|
2265
|
+
return `${filters.startDate.replace(/-/g, "")}_${filters.endDate.replace(/-/g, "")}`;
|
|
2266
|
+
}
|
|
2267
|
+
if (filters.startDate) return `from_${filters.startDate.replace(/-/g, "")}`;
|
|
2268
|
+
if (filters.endDate) return `until_${filters.endDate.replace(/-/g, "")}`;
|
|
2269
|
+
return "custom";
|
|
2270
|
+
}
|
|
2271
|
+
function generateUniqueReportPath(dir, rangeSlug) {
|
|
2272
|
+
const dateStr = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2273
|
+
const baseName = `tokenwatch-${rangeSlug}-${dateStr}`;
|
|
2274
|
+
let targetPath = join4(dir, `${baseName}.html`);
|
|
2275
|
+
if (!existsSync3(targetPath)) {
|
|
2276
|
+
return targetPath;
|
|
2277
|
+
}
|
|
2278
|
+
const now = /* @__PURE__ */ new Date();
|
|
2279
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
2280
|
+
const timeSuffix = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
2281
|
+
targetPath = join4(dir, `${baseName}_${timeSuffix}.html`);
|
|
2282
|
+
let counter = 1;
|
|
2283
|
+
while (existsSync3(targetPath)) {
|
|
2284
|
+
targetPath = join4(dir, `${baseName}_${timeSuffix}_${counter}.html`);
|
|
2285
|
+
counter++;
|
|
2286
|
+
}
|
|
2287
|
+
return targetPath;
|
|
2288
|
+
}
|
|
2289
|
+
async function showHtmlReport(api, filters = {}, presetTag) {
|
|
2290
|
+
try {
|
|
2291
|
+
const data = await buildCombinedData(api, filters);
|
|
2292
|
+
const html = generateUsageHtml(data);
|
|
2293
|
+
const dir = ensureReportDir();
|
|
2294
|
+
const rangeSlug = getRangeSlug(filters, presetTag);
|
|
2295
|
+
const filePath = generateUniqueReportPath(dir, rangeSlug);
|
|
2296
|
+
writeFileSync3(filePath, html, "utf-8");
|
|
2297
|
+
api.ui.toast?.({
|
|
2298
|
+
message: `Report: ${filePath}`,
|
|
2299
|
+
variant: "info"
|
|
2300
|
+
});
|
|
2301
|
+
openInBrowser(filePath);
|
|
2302
|
+
} catch (err) {
|
|
2303
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2304
|
+
api.ui.toast?.({
|
|
2305
|
+
message: `Error: ${msg}`,
|
|
2306
|
+
variant: "error"
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
function showHtmlReportRangeMenu(api, dialog) {
|
|
2311
|
+
dialog.replace(() => _$createComponent(api.ui.DialogSelect, {
|
|
2312
|
+
get title() {
|
|
2313
|
+
return t("cmdTitleHtml");
|
|
2314
|
+
},
|
|
2315
|
+
placeholder: "Select date range...",
|
|
2316
|
+
get options() {
|
|
2317
|
+
return [{
|
|
2318
|
+
title: t("menuToday"),
|
|
2319
|
+
value: "today",
|
|
2320
|
+
onSelect: () => {
|
|
2321
|
+
dialog.clear();
|
|
2322
|
+
const d = /* @__PURE__ */ new Date();
|
|
2323
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
2324
|
+
const s = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
2325
|
+
showHtmlReport(api, {
|
|
2326
|
+
startDate: s,
|
|
2327
|
+
endDate: s
|
|
2328
|
+
}, "today");
|
|
2329
|
+
}
|
|
2330
|
+
}, {
|
|
2331
|
+
title: t("menu7d"),
|
|
2332
|
+
value: "7d",
|
|
2333
|
+
onSelect: () => {
|
|
2334
|
+
dialog.clear();
|
|
2335
|
+
showHtmlReport(api, getPresetRange("7d"), "7d");
|
|
2336
|
+
}
|
|
2337
|
+
}, {
|
|
2338
|
+
title: t("menu30d"),
|
|
2339
|
+
value: "30d",
|
|
2340
|
+
onSelect: () => {
|
|
2341
|
+
dialog.clear();
|
|
2342
|
+
showHtmlReport(api, getPresetRange("30d"), "30d");
|
|
2343
|
+
}
|
|
2344
|
+
}, {
|
|
2345
|
+
title: t("menuAll"),
|
|
2346
|
+
value: "all",
|
|
2347
|
+
onSelect: () => {
|
|
2348
|
+
dialog.clear();
|
|
2349
|
+
showHtmlReport(api, getPresetRange("all"), "all");
|
|
2350
|
+
}
|
|
2351
|
+
}];
|
|
2352
|
+
},
|
|
2353
|
+
flat: true
|
|
2354
|
+
}));
|
|
2355
|
+
}
|
|
2356
|
+
function showUsageMenu(api, dialog) {
|
|
2357
|
+
try {
|
|
2358
|
+
setLanguage(loadConfigFromStore(api).language);
|
|
2359
|
+
} catch {
|
|
2360
|
+
}
|
|
2361
|
+
dialog.replace(() => _$createComponent(api.ui.DialogSelect, {
|
|
2362
|
+
get title() {
|
|
2363
|
+
return t("panelTitle");
|
|
2364
|
+
},
|
|
2365
|
+
placeholder: "Select an action...",
|
|
2366
|
+
get options() {
|
|
2367
|
+
return [{
|
|
2368
|
+
title: `${t("cmdTitleHtml")} \u25B8`,
|
|
2369
|
+
value: "html",
|
|
2370
|
+
description: t("cmdDescHtml"),
|
|
2371
|
+
onSelect: () => showHtmlReportRangeMenu(api, dialog)
|
|
2372
|
+
}, {
|
|
2373
|
+
title: t("cmdTitleJson"),
|
|
2374
|
+
value: "json",
|
|
2375
|
+
description: t("cmdDescJson"),
|
|
2376
|
+
onSelect: () => {
|
|
2377
|
+
dialog.clear();
|
|
2378
|
+
showJsonExport(api);
|
|
2379
|
+
}
|
|
2380
|
+
}, {
|
|
2381
|
+
title: t("cmdTitleText"),
|
|
2382
|
+
value: "text",
|
|
2383
|
+
description: t("cmdDescText"),
|
|
2384
|
+
onSelect: () => {
|
|
2385
|
+
dialog.clear();
|
|
2386
|
+
showTextReport(api);
|
|
2387
|
+
}
|
|
2388
|
+
}, {
|
|
2389
|
+
title: `${t("cmdTitleSettings")} \u25B8`,
|
|
2390
|
+
value: "settings",
|
|
2391
|
+
description: t("cmdDescSettings"),
|
|
2392
|
+
onSelect: () => showSettingsDialog(api, dialog)
|
|
2393
|
+
}];
|
|
2394
|
+
},
|
|
2395
|
+
flat: true
|
|
2396
|
+
}));
|
|
2397
|
+
}
|
|
2398
|
+
async function showJsonExport(api) {
|
|
2399
|
+
try {
|
|
2400
|
+
const report = await getUsageReport({});
|
|
2401
|
+
const dir = ensureReportDir();
|
|
2402
|
+
const dateStr = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2403
|
+
const filePath = join4(dir, `tokenwatch-${dateStr}.json`);
|
|
2404
|
+
writeFileSync3(filePath, JSON.stringify(report, null, 2), "utf-8");
|
|
2405
|
+
api.ui.toast?.({
|
|
2406
|
+
message: `JSON: ${filePath}`,
|
|
2407
|
+
variant: "info"
|
|
2408
|
+
});
|
|
2409
|
+
} catch (err) {
|
|
2410
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2411
|
+
api.ui.toast?.({
|
|
2412
|
+
message: `Error: ${msg}`,
|
|
2413
|
+
variant: "error"
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
async function showTextReport(api) {
|
|
2418
|
+
try {
|
|
2419
|
+
const report = await getUsageReport({});
|
|
2420
|
+
const formatted = formatUsageReport(report);
|
|
2421
|
+
const dir = ensureReportDir();
|
|
2422
|
+
const dateStr = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2423
|
+
const filePath = join4(dir, `tokenwatch-${dateStr}.md`);
|
|
2424
|
+
writeFileSync3(filePath, formatted, "utf-8");
|
|
2425
|
+
api.ui.toast?.({
|
|
2426
|
+
message: `Report saved to ${filePath}`,
|
|
2427
|
+
variant: "info"
|
|
2428
|
+
});
|
|
2429
|
+
} catch (err) {
|
|
2430
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2431
|
+
api.ui.toast?.({
|
|
2432
|
+
message: `Error: ${msg}`,
|
|
2433
|
+
variant: "error"
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
function saveConfigToStore(api, cfg) {
|
|
2438
|
+
api.kv?.set?.("tokenwatch-config", cfg);
|
|
2439
|
+
}
|
|
2440
|
+
var lastSelectedSetting;
|
|
2441
|
+
function showSettingsDialog(api, dialog) {
|
|
2442
|
+
if (!dialog) return;
|
|
2443
|
+
const reopen = (value) => {
|
|
2444
|
+
lastSelectedSetting = value;
|
|
2445
|
+
setTimeout(() => showSettingsDialog(api, dialog), 0);
|
|
2446
|
+
};
|
|
2447
|
+
const cfg = loadConfigFromStore(api).sidebar;
|
|
2448
|
+
dialog.replace(() => _$createComponent(api.ui.DialogSelect, {
|
|
2449
|
+
get title() {
|
|
2450
|
+
return t("settingsTitle");
|
|
2451
|
+
},
|
|
2452
|
+
get placeholder() {
|
|
2453
|
+
return t("settingsPlaceholder");
|
|
2454
|
+
},
|
|
2455
|
+
get options() {
|
|
2456
|
+
return [{
|
|
2457
|
+
title: `${cfg.showPerformance ? "\u2713 " : " "}${t("showPerformance")}`,
|
|
2458
|
+
value: "showPerformance",
|
|
2459
|
+
description: t("descShowPerformance"),
|
|
2460
|
+
onSelect: () => {
|
|
2461
|
+
toggleSidebarSetting(api, "showPerformance");
|
|
2462
|
+
reopen("showPerformance");
|
|
2463
|
+
}
|
|
2464
|
+
}, {
|
|
2465
|
+
title: `${cfg.showPricing ? "\u2713 " : " "}${t("showPricing")}`,
|
|
2466
|
+
value: "showPricing",
|
|
2467
|
+
description: t("descShowPricing"),
|
|
2468
|
+
onSelect: () => {
|
|
2469
|
+
toggleSidebarSetting(api, "showPricing");
|
|
2470
|
+
reopen("showPricing");
|
|
2471
|
+
}
|
|
2472
|
+
}, {
|
|
2473
|
+
title: `${cfg.showTokenDistribution ? "\u2713 " : " "}${t("showTokenDistribution")}`,
|
|
2474
|
+
value: "showTokenDistribution",
|
|
2475
|
+
description: t("descShowTokenDistribution"),
|
|
2476
|
+
onSelect: () => {
|
|
2477
|
+
toggleSidebarSetting(api, "showTokenDistribution");
|
|
2478
|
+
reopen("showTokenDistribution");
|
|
2479
|
+
}
|
|
2480
|
+
}, {
|
|
2481
|
+
title: `${cfg.showTrend ? "\u2713 " : " "}${t("showTrend")}`,
|
|
2482
|
+
value: "showTrend",
|
|
2483
|
+
description: t("descShowTrend"),
|
|
2484
|
+
onSelect: () => {
|
|
2485
|
+
toggleSidebarSetting(api, "showTrend");
|
|
2486
|
+
reopen("showTrend");
|
|
2487
|
+
}
|
|
2488
|
+
}, {
|
|
2489
|
+
title: `${t("settingsLanguage")} \u25B8`,
|
|
2490
|
+
value: "language",
|
|
2491
|
+
description: t("descSettingsLanguage"),
|
|
2492
|
+
onSelect: () => showLanguageMenu(api, dialog)
|
|
2493
|
+
}, {
|
|
2494
|
+
title: t("done"),
|
|
2495
|
+
value: "done",
|
|
2496
|
+
description: t("closeSettings"),
|
|
2497
|
+
onSelect: () => {
|
|
2498
|
+
lastSelectedSetting = void 0;
|
|
2499
|
+
dialog.clear();
|
|
2500
|
+
}
|
|
2501
|
+
}];
|
|
2502
|
+
},
|
|
2503
|
+
flat: true,
|
|
2504
|
+
current: lastSelectedSetting
|
|
2505
|
+
}));
|
|
2506
|
+
}
|
|
2507
|
+
function showLanguageMenu(api, dialog) {
|
|
2508
|
+
const current = api.kv?.get?.("tokenwatch-config")?.language ?? "auto";
|
|
2509
|
+
dialog.replace(() => _$createComponent(api.ui.DialogSelect, {
|
|
2510
|
+
get title() {
|
|
2511
|
+
return t("settingsLanguage");
|
|
2512
|
+
},
|
|
2513
|
+
get placeholder() {
|
|
2514
|
+
return t("settingsLanguage");
|
|
2515
|
+
},
|
|
2516
|
+
get options() {
|
|
2517
|
+
return [{
|
|
2518
|
+
title: `${current === "auto" ? "\u2713 " : " "}${t("langAuto")}`,
|
|
2519
|
+
value: "auto",
|
|
2520
|
+
description: "\u81EA\u52A8\u68C0\u6D4B / Auto-detect",
|
|
2521
|
+
onSelect: () => {
|
|
2522
|
+
setLanguageSetting(api, "auto");
|
|
2523
|
+
lastSelectedSetting = "language";
|
|
2524
|
+
dialog.clear();
|
|
2525
|
+
showSettingsDialog(api, dialog);
|
|
2526
|
+
}
|
|
2527
|
+
}, {
|
|
2528
|
+
title: `${current === "zh" ? "\u2713 " : " "}\u4E2D\u6587`,
|
|
2529
|
+
value: "zh",
|
|
2530
|
+
description: "\u7B80\u4F53\u4E2D\u6587",
|
|
2531
|
+
onSelect: () => {
|
|
2532
|
+
setLanguageSetting(api, "zh");
|
|
2533
|
+
lastSelectedSetting = "language";
|
|
2534
|
+
dialog.clear();
|
|
2535
|
+
showSettingsDialog(api, dialog);
|
|
2536
|
+
}
|
|
2537
|
+
}, {
|
|
2538
|
+
title: `${current === "en" ? "\u2713 " : " "}English`,
|
|
2539
|
+
value: "en",
|
|
2540
|
+
description: "English",
|
|
2541
|
+
onSelect: () => {
|
|
2542
|
+
setLanguageSetting(api, "en");
|
|
2543
|
+
lastSelectedSetting = "language";
|
|
2544
|
+
dialog.clear();
|
|
2545
|
+
showSettingsDialog(api, dialog);
|
|
2546
|
+
}
|
|
2547
|
+
}];
|
|
2548
|
+
},
|
|
2549
|
+
flat: true
|
|
2550
|
+
}));
|
|
2551
|
+
}
|
|
2552
|
+
function setLanguageSetting(api, lang) {
|
|
2553
|
+
setLanguage(lang);
|
|
2554
|
+
const current = loadConfigFromStore(api);
|
|
2555
|
+
current.language = lang;
|
|
2556
|
+
saveConfigToStore(api, current);
|
|
2557
|
+
const v = (api.kv?.get?.("tokenwatch-config-version") ?? 0) + 1;
|
|
2558
|
+
api.kv?.set?.("tokenwatch-config-version", v);
|
|
2559
|
+
}
|
|
2560
|
+
function toggleSidebarSetting(api, key) {
|
|
2561
|
+
const current = loadConfigFromStore(api);
|
|
2562
|
+
current.sidebar[key] = !current.sidebar[key];
|
|
2563
|
+
saveConfigToStore(api, current);
|
|
2564
|
+
const v = (api.kv?.get?.("tokenwatch-config-version") ?? 0) + 1;
|
|
2565
|
+
api.kv?.set?.("tokenwatch-config-version", v);
|
|
2566
|
+
}
|
|
2567
|
+
function loadConfigFromStore(api) {
|
|
2568
|
+
const base = {
|
|
2569
|
+
sidebar: {
|
|
2570
|
+
...DEFAULT_CONFIG.sidebar
|
|
2571
|
+
},
|
|
2572
|
+
language: DEFAULT_CONFIG.language
|
|
2573
|
+
};
|
|
2574
|
+
try {
|
|
2575
|
+
const stored = api.kv?.get?.("tokenwatch-config");
|
|
2576
|
+
if (stored) {
|
|
2577
|
+
if (stored.sidebar) Object.assign(base.sidebar, stored.sidebar);
|
|
2578
|
+
if (stored.language) base.language = stored.language;
|
|
2579
|
+
}
|
|
2580
|
+
} catch {
|
|
2581
|
+
}
|
|
2582
|
+
return base;
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
// src/sidebar.tsx
|
|
2586
|
+
import { createComponent as _$createComponent2 } from "@opentui/solid";
|
|
2587
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
2588
|
+
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
2589
|
+
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
2590
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
2591
|
+
import { memo as _$memo } from "@opentui/solid";
|
|
2592
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
2593
|
+
import { use as _$use } from "@opentui/solid";
|
|
2594
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
2595
|
+
import { createSignal, createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
|
|
2596
|
+
import { RGBA } from "@opentui/core";
|
|
2597
|
+
var DEFAULT_CONFIG2 = {
|
|
2598
|
+
sidebar: {
|
|
2599
|
+
showPerformance: true,
|
|
2600
|
+
showPricing: true,
|
|
2601
|
+
showTokenDistribution: true,
|
|
2602
|
+
showTrend: true
|
|
2603
|
+
},
|
|
2604
|
+
language: "auto"
|
|
2605
|
+
};
|
|
2606
|
+
function progressBarWidth(percent, width) {
|
|
2607
|
+
if (percent >= 100) return width;
|
|
2608
|
+
return Math.floor(percent / 100 * width);
|
|
2609
|
+
}
|
|
2610
|
+
function progressFilled(percent, width) {
|
|
2611
|
+
return "\u2588".repeat(Math.max(0, progressBarWidth(percent, width)));
|
|
2612
|
+
}
|
|
2613
|
+
function progressRemaining(percent, width) {
|
|
2614
|
+
return "\u2591".repeat(Math.max(0, width - progressBarWidth(percent, width)));
|
|
2615
|
+
}
|
|
2616
|
+
function getVisualWidth2(str) {
|
|
2617
|
+
let w = 0;
|
|
2618
|
+
for (const c of str) {
|
|
2619
|
+
const code = c.codePointAt(0) ?? 0;
|
|
2620
|
+
if (code >= 19968 && code <= 40959 || code >= 12352 && code <= 12543 || code >= 44032 && code <= 55203 || code >= 4352 && code <= 4607 || code >= 11904 && code <= 12031) {
|
|
2621
|
+
w += 2;
|
|
2622
|
+
} else {
|
|
2623
|
+
w += 1;
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
return w;
|
|
2627
|
+
}
|
|
2628
|
+
function centerAlign(text, width) {
|
|
2629
|
+
const visualW = getVisualWidth2(text);
|
|
2630
|
+
if (visualW >= width) return text;
|
|
2631
|
+
const left = Math.floor((width - visualW) / 2);
|
|
2632
|
+
const right = width - visualW - left;
|
|
2633
|
+
return " ".repeat(left) + text + " ".repeat(right);
|
|
2634
|
+
}
|
|
2635
|
+
function hitRateColor(rate) {
|
|
2636
|
+
if (rate >= 85) return RGBA.fromInts(76, 175, 80, 255);
|
|
2637
|
+
if (rate >= 70) return RGBA.fromInts(255, 193, 7, 255);
|
|
2638
|
+
return RGBA.fromInts(244, 67, 54, 255);
|
|
2639
|
+
}
|
|
2640
|
+
function distRoleColor(role) {
|
|
2641
|
+
const map = {
|
|
2642
|
+
system: RGBA.fromInts(130, 80, 255, 255),
|
|
2643
|
+
user: RGBA.fromInts(88, 166, 255, 255),
|
|
2644
|
+
toolCall: RGBA.fromInts(210, 153, 34, 255),
|
|
2645
|
+
toolResult: RGBA.fromInts(219, 109, 40, 255),
|
|
2646
|
+
output: RGBA.fromInts(63, 185, 80, 255),
|
|
2647
|
+
other: RGBA.fromInts(72, 79, 88, 255)
|
|
2648
|
+
};
|
|
2649
|
+
return map[role] ?? RGBA.fromInts(72, 79, 88, 255);
|
|
2650
|
+
}
|
|
2651
|
+
function estimateTokens(text) {
|
|
2652
|
+
if (!text || text.length === 0) return 0;
|
|
2653
|
+
let ascii = 0, cjk = 0;
|
|
2654
|
+
for (const c of text) {
|
|
2655
|
+
const code = c.codePointAt(0) ?? 0;
|
|
2656
|
+
if (code >= 19968 && code <= 40959 || code >= 12352 && code <= 12543 || code >= 44032 && code <= 55203 || code >= 4352 && code <= 4607 || code >= 11904 && code <= 12031) cjk++;
|
|
2657
|
+
else ascii++;
|
|
2658
|
+
}
|
|
2659
|
+
const trimmed = text.trimStart();
|
|
2660
|
+
const jsonLike = (trimmed.startsWith("{") || trimmed.startsWith("[")) && /"[^"]+"\s*:/.test(text);
|
|
2661
|
+
const codeLike = !jsonLike && /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text);
|
|
2662
|
+
const asciiPerToken = jsonLike ? 2 : codeLike ? 2.5 : 4;
|
|
2663
|
+
return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.5));
|
|
2664
|
+
}
|
|
2665
|
+
function loadCollapseState(api) {
|
|
2666
|
+
try {
|
|
2667
|
+
return api.kv?.get?.("tokenwatch-collapse") ?? {
|
|
2668
|
+
global: false,
|
|
2669
|
+
models: {},
|
|
2670
|
+
subBlocks: {}
|
|
2671
|
+
};
|
|
2672
|
+
} catch {
|
|
2673
|
+
return {
|
|
2674
|
+
global: false,
|
|
2675
|
+
models: {},
|
|
2676
|
+
subBlocks: {}
|
|
2677
|
+
};
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
function saveCollapseState(api, state) {
|
|
2681
|
+
try {
|
|
2682
|
+
api.kv?.set?.("tokenwatch-collapse", state);
|
|
2683
|
+
} catch {
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
function loadConfig(api) {
|
|
2687
|
+
const base = {
|
|
2688
|
+
sidebar: {
|
|
2689
|
+
...DEFAULT_CONFIG2.sidebar
|
|
2690
|
+
},
|
|
2691
|
+
language: DEFAULT_CONFIG2.language
|
|
2692
|
+
};
|
|
2693
|
+
try {
|
|
2694
|
+
const pluginCfg = api.config?.pluginConfig?.["opencode-tokenwatch"];
|
|
2695
|
+
if (pluginCfg?.sidebar) Object.assign(base.sidebar, pluginCfg.sidebar);
|
|
2696
|
+
if (pluginCfg?.language) base.language = pluginCfg.language;
|
|
2697
|
+
const overrides = api.kv?.get?.("tokenwatch-config");
|
|
2698
|
+
if (overrides?.sidebar) Object.assign(base.sidebar, overrides.sidebar);
|
|
2699
|
+
if (overrides?.language) base.language = overrides.language;
|
|
2700
|
+
} catch {
|
|
2701
|
+
}
|
|
2702
|
+
return base;
|
|
2703
|
+
}
|
|
2704
|
+
function TokenWatchPanel(props) {
|
|
2705
|
+
const {
|
|
2706
|
+
api,
|
|
2707
|
+
theme,
|
|
2708
|
+
perfTracker
|
|
2709
|
+
} = props;
|
|
2710
|
+
const getMessages = () => props.messages();
|
|
2711
|
+
const [config, setConfig] = createSignal(loadConfig(api));
|
|
2712
|
+
setLanguage(config().language);
|
|
2713
|
+
let knownCfgVer = api.kv?.get?.("tokenwatch-config-version");
|
|
2714
|
+
const t2 = (key) => {
|
|
2715
|
+
void config().language;
|
|
2716
|
+
return t(key);
|
|
2717
|
+
};
|
|
2718
|
+
const isEnglish = (str) => /^[a-zA-Z\s\.\/]+$/.test(str);
|
|
2719
|
+
createEffect(() => {
|
|
2720
|
+
const timer = setInterval(() => {
|
|
2721
|
+
const v = api.kv?.get?.("tokenwatch-config-version");
|
|
2722
|
+
if (v != null && v !== knownCfgVer) {
|
|
2723
|
+
knownCfgVer = v;
|
|
2724
|
+
setConfig(loadConfig(api));
|
|
2725
|
+
}
|
|
2726
|
+
}, 500);
|
|
2727
|
+
onCleanup(() => clearInterval(timer));
|
|
2728
|
+
});
|
|
2729
|
+
const [collapse, setCollapse] = createSignal(loadCollapseState(api));
|
|
2730
|
+
const [panelWidth, setPanelWidth] = createSignal(38);
|
|
2731
|
+
let outerBoxRef = null;
|
|
2732
|
+
createEffect(() => setLanguage(config().language));
|
|
2733
|
+
const primaryColor = () => theme.current.primary;
|
|
2734
|
+
const mutedColor = () => theme.current.textMuted;
|
|
2735
|
+
const dimColor = () => RGBA.fromInts(72, 79, 88, 255);
|
|
2736
|
+
const greenColor = () => RGBA.fromInts(63, 185, 80, 255);
|
|
2737
|
+
const borderColor = () => RGBA.fromInts(55, 65, 80, 255);
|
|
2738
|
+
const modelStats = createMemo(() => {
|
|
2739
|
+
const map = /* @__PURE__ */ new Map();
|
|
2740
|
+
const msgs = props.allTokenMessages();
|
|
2741
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
2742
|
+
const msg = msgs[i];
|
|
2743
|
+
const key = `${msg.providerID}/${msg.modelID}`;
|
|
2744
|
+
let e = map.get(key);
|
|
2745
|
+
if (!e) {
|
|
2746
|
+
e = {
|
|
2747
|
+
providerID: msg.providerID,
|
|
2748
|
+
modelID: msg.modelID,
|
|
2749
|
+
totalInput: 0,
|
|
2750
|
+
totalOutput: 0,
|
|
2751
|
+
totalReasoning: 0,
|
|
2752
|
+
cacheRead: 0,
|
|
2753
|
+
cacheWrite: 0,
|
|
2754
|
+
totalCost: 0,
|
|
2755
|
+
requestCount: 0,
|
|
2756
|
+
lastMessageIndex: -1
|
|
2757
|
+
};
|
|
2758
|
+
map.set(key, e);
|
|
2759
|
+
}
|
|
2760
|
+
e.totalInput += msg.inputTokens;
|
|
2761
|
+
e.totalOutput += msg.outputTokens;
|
|
2762
|
+
e.totalReasoning += msg.reasoningTokens;
|
|
2763
|
+
e.cacheRead += msg.cacheRead;
|
|
2764
|
+
e.cacheWrite += msg.cacheWrite;
|
|
2765
|
+
e.totalCost += msg.cost;
|
|
2766
|
+
e.requestCount++;
|
|
2767
|
+
e.lastMessageIndex = i;
|
|
2768
|
+
}
|
|
2769
|
+
return Array.from(map.entries()).filter(([, s]) => s.totalInput + s.totalOutput + s.totalReasoning + s.cacheRead + s.cacheWrite > 0).sort((a, b) => b[1].lastMessageIndex - a[1].lastMessageIndex);
|
|
2770
|
+
});
|
|
2771
|
+
const sessionTotals = createMemo(() => {
|
|
2772
|
+
let i = 0, o = 0, ir = 0, cr = 0, cw = 0, r = 0, c = 0;
|
|
2773
|
+
for (const [, s] of modelStats()) {
|
|
2774
|
+
i += s.totalInput;
|
|
2775
|
+
o += s.totalOutput;
|
|
2776
|
+
ir += s.totalReasoning;
|
|
2777
|
+
cr += s.cacheRead;
|
|
2778
|
+
cw += s.cacheWrite;
|
|
2779
|
+
r += s.requestCount;
|
|
2780
|
+
c += s.totalCost;
|
|
2781
|
+
}
|
|
2782
|
+
return {
|
|
2783
|
+
totalInput: i,
|
|
2784
|
+
totalOutput: o,
|
|
2785
|
+
totalReasoning: ir,
|
|
2786
|
+
totalCacheRead: cr,
|
|
2787
|
+
totalCacheWrite: cw,
|
|
2788
|
+
totalRequests: r,
|
|
2789
|
+
totalCost: c,
|
|
2790
|
+
totalTokens: i + o + ir + cr + cw
|
|
2791
|
+
};
|
|
2792
|
+
});
|
|
2793
|
+
const globalHitRate = createMemo(() => {
|
|
2794
|
+
const denom = sessionTotals().totalInput + sessionTotals().totalCacheRead;
|
|
2795
|
+
return denom > 0 ? sessionTotals().totalCacheRead / denom * 100 : -1;
|
|
2796
|
+
});
|
|
2797
|
+
const modelHitRate = createMemo(() => {
|
|
2798
|
+
return modelStats().map(([key, stat]) => {
|
|
2799
|
+
const denom = stat.totalInput + stat.cacheRead;
|
|
2800
|
+
if (denom === 0) return {
|
|
2801
|
+
key,
|
|
2802
|
+
rate: 0,
|
|
2803
|
+
msgs: []
|
|
2804
|
+
};
|
|
2805
|
+
const msgs = [];
|
|
2806
|
+
for (const msg of props.allTokenMessages()) {
|
|
2807
|
+
const pk = `${msg.providerID}/${msg.modelID}`;
|
|
2808
|
+
if (pk !== key) continue;
|
|
2809
|
+
msgs.push(msg);
|
|
2810
|
+
}
|
|
2811
|
+
return {
|
|
2812
|
+
key,
|
|
2813
|
+
rate: stat.cacheRead / denom * 100,
|
|
2814
|
+
msgs
|
|
2815
|
+
};
|
|
2816
|
+
});
|
|
2817
|
+
});
|
|
2818
|
+
const modelTrend = createMemo(() => {
|
|
2819
|
+
return modelHitRate().map(({
|
|
2820
|
+
key,
|
|
2821
|
+
msgs
|
|
2822
|
+
}) => {
|
|
2823
|
+
if (msgs.length < 6) return {
|
|
2824
|
+
key,
|
|
2825
|
+
trend: null
|
|
2826
|
+
};
|
|
2827
|
+
const sumSlice = (start, end) => {
|
|
2828
|
+
let sumCache = 0, sumTotal = 0;
|
|
2829
|
+
for (let i = start; i < end && i < msgs.length; i++) {
|
|
2830
|
+
sumCache += msgs[i].cacheRead;
|
|
2831
|
+
sumTotal += msgs[i].inputTokens + msgs[i].cacheRead;
|
|
2832
|
+
}
|
|
2833
|
+
return {
|
|
2834
|
+
sumCache,
|
|
2835
|
+
sumTotal
|
|
2836
|
+
};
|
|
2837
|
+
};
|
|
2838
|
+
const n = msgs.length;
|
|
2839
|
+
const recent = sumSlice(n - 3, n);
|
|
2840
|
+
const prev = sumSlice(n - 6, n - 3);
|
|
2841
|
+
const rateRecent = recent.sumTotal > 0 ? recent.sumCache / recent.sumTotal * 100 : 0;
|
|
2842
|
+
const ratePrev = prev.sumTotal > 0 ? prev.sumCache / prev.sumTotal * 100 : 0;
|
|
2843
|
+
return {
|
|
2844
|
+
key,
|
|
2845
|
+
trend: rateRecent - ratePrev
|
|
2846
|
+
};
|
|
2847
|
+
});
|
|
2848
|
+
});
|
|
2849
|
+
const [partVersion, setPartVersion] = createSignal(0);
|
|
2850
|
+
const perfStats = createMemo(() => {
|
|
2851
|
+
void props.allTokenMessages();
|
|
2852
|
+
void partVersion();
|
|
2853
|
+
return perfTracker.getSessionStats();
|
|
2854
|
+
});
|
|
2855
|
+
const tokenDistribution = createMemo(() => {
|
|
2856
|
+
void props.allTokenMessages();
|
|
2857
|
+
void partVersion();
|
|
2858
|
+
const dist = {};
|
|
2859
|
+
try {
|
|
2860
|
+
const cfg = api.state.config;
|
|
2861
|
+
const agents = cfg?.agent;
|
|
2862
|
+
if (agents) {
|
|
2863
|
+
for (const ac of Object.values(agents)) {
|
|
2864
|
+
const a = ac;
|
|
2865
|
+
if (typeof a?.prompt === "string" && a.prompt) {
|
|
2866
|
+
dist.system = estimateTokens(a.prompt);
|
|
2867
|
+
break;
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
} catch {
|
|
2872
|
+
}
|
|
2873
|
+
for (const msg of getMessages()) {
|
|
2874
|
+
const role = msg.role;
|
|
2875
|
+
if (role === "user") {
|
|
2876
|
+
if (msg.system) dist.system = (dist.system ?? 0) + estimateTokens(msg.system);
|
|
2877
|
+
let parts = [];
|
|
2878
|
+
try {
|
|
2879
|
+
parts = api.state.part(msg.id);
|
|
2880
|
+
} catch {
|
|
2881
|
+
continue;
|
|
2882
|
+
}
|
|
2883
|
+
for (const p of parts) {
|
|
2884
|
+
if (p.type === "text" && !p.synthetic && !p.ignored) {
|
|
2885
|
+
dist.user = (dist.user ?? 0) + estimateTokens(p.text ?? "");
|
|
2886
|
+
} else if (p.type === "file" && p.source?.text?.value) {
|
|
2887
|
+
dist.user = (dist.user ?? 0) + estimateTokens(p.source.text.value);
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
} else if (role === "assistant") {
|
|
2891
|
+
let parts = [];
|
|
2892
|
+
try {
|
|
2893
|
+
parts = api.state.part(msg.id);
|
|
2894
|
+
} catch {
|
|
2895
|
+
continue;
|
|
2896
|
+
}
|
|
2897
|
+
let msgEstimatedOutput = 0;
|
|
2898
|
+
for (const p of parts) {
|
|
2899
|
+
if (p.type === "tool") {
|
|
2900
|
+
let rawInput = "";
|
|
2901
|
+
try {
|
|
2902
|
+
rawInput = p.state?.raw ?? JSON.stringify(p.state?.input);
|
|
2903
|
+
} catch {
|
|
2904
|
+
try {
|
|
2905
|
+
rawInput = JSON.stringify(p.state);
|
|
2906
|
+
} catch {
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
if (rawInput) dist.toolCall = (dist.toolCall ?? 0) + estimateTokens(rawInput);
|
|
2910
|
+
if (p.state?.status === "completed" && p.state?.output) {
|
|
2911
|
+
dist.toolResult = (dist.toolResult ?? 0) + estimateTokens(p.state.output);
|
|
2912
|
+
} else if (p.state?.status === "error" && p.state?.error) {
|
|
2913
|
+
dist.toolResult = (dist.toolResult ?? 0) + estimateTokens(p.state.error);
|
|
2914
|
+
}
|
|
2915
|
+
} else if (p.type === "text" && p.text) {
|
|
2916
|
+
msgEstimatedOutput += estimateTokens(p.text);
|
|
2917
|
+
} else if (p.type === "reasoning") {
|
|
2918
|
+
msgEstimatedOutput += estimateTokens(p.text ?? "");
|
|
2919
|
+
} else if (p.type === "subtask") {
|
|
2920
|
+
msgEstimatedOutput += estimateTokens(p.prompt || p.description || "");
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
const tokens = msg.tokens;
|
|
2924
|
+
if (tokens?.output !== void 0 || tokens?.reasoning !== void 0) {
|
|
2925
|
+
dist.output = (dist.output ?? 0) + (tokens?.output ?? 0) + (tokens?.reasoning ?? 0);
|
|
2926
|
+
} else {
|
|
2927
|
+
dist.output = (dist.output ?? 0) + msgEstimatedOutput;
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
const realInput = sessionTotals().totalInput;
|
|
2932
|
+
if (realInput > 0) {
|
|
2933
|
+
const estimated = (dist.system ?? 0) + (dist.user ?? 0) + (dist.toolCall ?? 0) + (dist.toolResult ?? 0);
|
|
2934
|
+
const other = realInput - estimated;
|
|
2935
|
+
if (other > 50) dist.other = other;
|
|
2936
|
+
}
|
|
2937
|
+
return dist;
|
|
2938
|
+
});
|
|
2939
|
+
const toggle = {
|
|
2940
|
+
global: () => setCollapse((p) => {
|
|
2941
|
+
const n = {
|
|
2942
|
+
...p,
|
|
2943
|
+
global: !p.global
|
|
2944
|
+
};
|
|
2945
|
+
saveCollapseState(api, n);
|
|
2946
|
+
return n;
|
|
2947
|
+
}),
|
|
2948
|
+
model: (k) => setCollapse((p) => {
|
|
2949
|
+
const n = {
|
|
2950
|
+
...p,
|
|
2951
|
+
models: {
|
|
2952
|
+
...p.models,
|
|
2953
|
+
[k]: !p.models[k]
|
|
2954
|
+
}
|
|
2955
|
+
};
|
|
2956
|
+
saveCollapseState(api, n);
|
|
2957
|
+
return n;
|
|
2958
|
+
}),
|
|
2959
|
+
sub: (k) => setCollapse((p) => {
|
|
2960
|
+
const n = {
|
|
2961
|
+
...p,
|
|
2962
|
+
subBlocks: {
|
|
2963
|
+
...p.subBlocks,
|
|
2964
|
+
[k]: !p.subBlocks[k]
|
|
2965
|
+
}
|
|
2966
|
+
};
|
|
2967
|
+
saveCollapseState(api, n);
|
|
2968
|
+
return n;
|
|
2969
|
+
})
|
|
2970
|
+
};
|
|
2971
|
+
onMount(() => {
|
|
2972
|
+
const unsubPart = api.event?.on?.("message.part.updated", () => setPartVersion((v) => v + 1));
|
|
2973
|
+
onCleanup(() => {
|
|
2974
|
+
try {
|
|
2975
|
+
unsubPart?.();
|
|
2976
|
+
} catch {
|
|
2977
|
+
}
|
|
2978
|
+
});
|
|
2979
|
+
});
|
|
2980
|
+
const innerWidth = () => panelWidth() - 2;
|
|
2981
|
+
const barWidth = () => Math.max(8, innerWidth() - 19);
|
|
2982
|
+
const divider = () => {
|
|
2983
|
+
const w = innerWidth();
|
|
2984
|
+
if (w <= 2) return "\u2500".repeat(w);
|
|
2985
|
+
return " " + "\u2500".repeat(w - 2) + " ";
|
|
2986
|
+
};
|
|
2987
|
+
return (() => {
|
|
2988
|
+
var _el$ = _$createElement("box"), _el$2 = _$createElement("box"), _el$3 = _$createElement("text"), _el$4 = _$createTextNode(` `), _el$5 = _$createElement("text");
|
|
2989
|
+
_$insertNode(_el$, _el$2);
|
|
2990
|
+
_$use((el) => {
|
|
2991
|
+
outerBoxRef = el;
|
|
2992
|
+
}, _el$);
|
|
2993
|
+
_$setProp(_el$, "onSizeChange", () => {
|
|
2994
|
+
if (outerBoxRef) setPanelWidth(outerBoxRef.width);
|
|
2995
|
+
});
|
|
2996
|
+
_$setProp(_el$, "flexDirection", "column");
|
|
2997
|
+
_$setProp(_el$, "border", true);
|
|
2998
|
+
_$setProp(_el$, "borderStyle", "rounded");
|
|
2999
|
+
_$insertNode(_el$2, _el$3);
|
|
3000
|
+
_$insertNode(_el$2, _el$5);
|
|
3001
|
+
_$setProp(_el$2, "flexDirection", "row");
|
|
3002
|
+
_$setProp(_el$2, "justifyContent", "space-between");
|
|
3003
|
+
_$setProp(_el$2, "paddingX", 1);
|
|
3004
|
+
_$insertNode(_el$3, _el$4);
|
|
3005
|
+
_$insert(_el$3, () => collapse().global ? "\u25B6" : "\u25BE", _el$4);
|
|
3006
|
+
_$insert(_el$3, () => t2("panelTitle"), null);
|
|
3007
|
+
_$insert(_el$5, (() => {
|
|
3008
|
+
var _c$ = _$memo(() => !!collapse().global);
|
|
3009
|
+
return () => _c$() ? [_$memo(() => formatTokens(sessionTotals().totalTokens)), _$memo(() => _$memo(() => globalHitRate() >= 0)() ? (() => {
|
|
3010
|
+
var _el$17 = _$createElement("span");
|
|
3011
|
+
_$insert(_el$17, () => ` (${globalHitRate().toFixed(1)}% hit)`);
|
|
3012
|
+
_$effect((_$p) => _$setProp(_el$17, "style", {
|
|
3013
|
+
fg: hitRateColor(globalHitRate())
|
|
3014
|
+
}, _$p));
|
|
3015
|
+
return _el$17;
|
|
3016
|
+
})() : "")] : _$memo(() => globalHitRate() >= 0)() ? (() => {
|
|
3017
|
+
var _el$18 = _$createElement("span");
|
|
3018
|
+
_$insert(_el$18, () => `${globalHitRate().toFixed(1)}% hit`);
|
|
3019
|
+
_$effect((_$p) => _$setProp(_el$18, "style", {
|
|
3020
|
+
fg: hitRateColor(globalHitRate())
|
|
3021
|
+
}, _$p));
|
|
3022
|
+
return _el$18;
|
|
3023
|
+
})() : "";
|
|
3024
|
+
})());
|
|
3025
|
+
_$insert(_el$, _$createComponent2(Show, {
|
|
3026
|
+
get when() {
|
|
3027
|
+
return !collapse().global;
|
|
3028
|
+
},
|
|
3029
|
+
get children() {
|
|
3030
|
+
return [(() => {
|
|
3031
|
+
var _el$6 = _$createElement("text");
|
|
3032
|
+
_$insert(_el$6, divider);
|
|
3033
|
+
_$effect((_$p) => _$setProp(_el$6, "fg", borderColor(), _$p));
|
|
3034
|
+
return _el$6;
|
|
3035
|
+
})(), (() => {
|
|
3036
|
+
var _el$7 = _$createElement("box");
|
|
3037
|
+
_$setProp(_el$7, "flexDirection", "row");
|
|
3038
|
+
_$setProp(_el$7, "paddingX", 1);
|
|
3039
|
+
_$insert(_el$7, _$createComponent2(For, {
|
|
3040
|
+
get each() {
|
|
3041
|
+
return [{
|
|
3042
|
+
val: formatTokens(sessionTotals().totalTokens),
|
|
3043
|
+
lbl: t2("total")
|
|
3044
|
+
}, {
|
|
3045
|
+
val: sessionTotals().totalRequests.toString(),
|
|
3046
|
+
lbl: t2("requests")
|
|
3047
|
+
}, {
|
|
3048
|
+
val: formatTokens(sessionTotals().totalInput),
|
|
3049
|
+
lbl: t2("input")
|
|
3050
|
+
}, {
|
|
3051
|
+
val: formatTokens(sessionTotals().totalOutput),
|
|
3052
|
+
lbl: t2("output")
|
|
3053
|
+
}];
|
|
3054
|
+
},
|
|
3055
|
+
children: (item, idx) => {
|
|
3056
|
+
const colW = () => {
|
|
3057
|
+
const totalW = panelWidth() - 4;
|
|
3058
|
+
const base = Math.floor(totalW / 4);
|
|
3059
|
+
return idx() === 3 ? totalW - base * 3 : base;
|
|
3060
|
+
};
|
|
3061
|
+
return (() => {
|
|
3062
|
+
var _el$19 = _$createElement("box"), _el$20 = _$createElement("text"), _el$21 = _$createElement("text");
|
|
3063
|
+
_$insertNode(_el$19, _el$20);
|
|
3064
|
+
_$insertNode(_el$19, _el$21);
|
|
3065
|
+
_$setProp(_el$19, "flexDirection", "column");
|
|
3066
|
+
_$insert(_el$20, () => centerAlign(item.val, colW()));
|
|
3067
|
+
_$insert(_el$21, () => centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW()));
|
|
3068
|
+
_$effect((_p$) => {
|
|
3069
|
+
var _v$9 = colW(), _v$0 = primaryColor(), _v$1 = dimColor();
|
|
3070
|
+
_v$9 !== _p$.e && (_p$.e = _$setProp(_el$19, "width", _v$9, _p$.e));
|
|
3071
|
+
_v$0 !== _p$.t && (_p$.t = _$setProp(_el$20, "fg", _v$0, _p$.t));
|
|
3072
|
+
_v$1 !== _p$.a && (_p$.a = _$setProp(_el$21, "fg", _v$1, _p$.a));
|
|
3073
|
+
return _p$;
|
|
3074
|
+
}, {
|
|
3075
|
+
e: void 0,
|
|
3076
|
+
t: void 0,
|
|
3077
|
+
a: void 0
|
|
3078
|
+
});
|
|
3079
|
+
return _el$19;
|
|
3080
|
+
})();
|
|
3081
|
+
}
|
|
3082
|
+
}));
|
|
3083
|
+
return _el$7;
|
|
3084
|
+
})(), _$createComponent2(Show, {
|
|
3085
|
+
get when() {
|
|
3086
|
+
return _$memo(() => !!config().sidebar.showPricing)() && sessionTotals().totalCost > 0;
|
|
3087
|
+
},
|
|
3088
|
+
get children() {
|
|
3089
|
+
var _el$8 = _$createElement("box"), _el$9 = _$createElement("text"), _el$0 = _$createTextNode(`: `), _el$10 = _$createElement("span");
|
|
3090
|
+
_$insertNode(_el$8, _el$9);
|
|
3091
|
+
_$setProp(_el$8, "flexDirection", "row");
|
|
3092
|
+
_$setProp(_el$8, "justifyContent", "center");
|
|
3093
|
+
_$setProp(_el$8, "marginTop", 1);
|
|
3094
|
+
_$insertNode(_el$9, _el$0);
|
|
3095
|
+
_$insertNode(_el$9, _el$10);
|
|
3096
|
+
_$insert(_el$9, () => t2("cost"), _el$0);
|
|
3097
|
+
_$insert(_el$10, () => formatCost(sessionTotals().totalCost));
|
|
3098
|
+
_$effect((_p$) => {
|
|
3099
|
+
var _v$ = mutedColor(), _v$2 = {
|
|
3100
|
+
fg: greenColor()
|
|
3101
|
+
};
|
|
3102
|
+
_v$ !== _p$.e && (_p$.e = _$setProp(_el$9, "fg", _v$, _p$.e));
|
|
3103
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$10, "style", _v$2, _p$.t));
|
|
3104
|
+
return _p$;
|
|
3105
|
+
}, {
|
|
3106
|
+
e: void 0,
|
|
3107
|
+
t: void 0
|
|
3108
|
+
});
|
|
3109
|
+
return _el$8;
|
|
3110
|
+
}
|
|
3111
|
+
}), _$createComponent2(For, {
|
|
3112
|
+
get each() {
|
|
3113
|
+
return modelStats();
|
|
3114
|
+
},
|
|
3115
|
+
children: ([key, stat]) => {
|
|
3116
|
+
const isExpanded = () => collapse().models[key] !== true;
|
|
3117
|
+
const hitDenom = stat.totalInput + stat.cacheRead;
|
|
3118
|
+
const hitRate = hitDenom > 0 ? stat.cacheRead / hitDenom * 100 : 0;
|
|
3119
|
+
const trendStr = () => {
|
|
3120
|
+
if (!config().sidebar.showTrend) return "";
|
|
3121
|
+
const td = modelTrend().find((h) => h.key === key);
|
|
3122
|
+
if (!td?.trend || td.trend === 0) return "";
|
|
3123
|
+
return td.trend > 0 ? ` ${t2("trendUp")}${td.trend.toFixed(1)}%` : ` ${t2("trendDown")}${Math.abs(td.trend).toFixed(1)}%`;
|
|
3124
|
+
};
|
|
3125
|
+
const trendColor = () => {
|
|
3126
|
+
const td = modelTrend().find((h) => h.key === key);
|
|
3127
|
+
return (td?.trend ?? 0) >= 0 ? RGBA.fromInts(63, 185, 80, 255) : RGBA.fromInts(244, 67, 54, 255);
|
|
3128
|
+
};
|
|
3129
|
+
const MAX_PROVIDER_LEN = 12;
|
|
3130
|
+
let providerDisplay = stat.providerID;
|
|
3131
|
+
if (providerDisplay.length > MAX_PROVIDER_LEN) {
|
|
3132
|
+
providerDisplay = providerDisplay.slice(0, MAX_PROVIDER_LEN - 1) + "\u2026";
|
|
3133
|
+
}
|
|
3134
|
+
let fullTitle = `${providerDisplay}/${stat.modelID}`;
|
|
3135
|
+
if (fullTitle.length > 22) {
|
|
3136
|
+
const parts = fullTitle.split("/");
|
|
3137
|
+
if (parts.length >= 3) {
|
|
3138
|
+
fullTitle = `${parts[0]}/${parts[parts.length - 1]}`;
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
const maxNameLen = Math.max(8, innerWidth() - 12);
|
|
3142
|
+
const shortTitle = fullTitle.length > maxNameLen ? fullTitle.slice(0, maxNameLen - 1) + "\u2026" : fullTitle;
|
|
3143
|
+
const modelHeaderRight = () => {
|
|
3144
|
+
if (!isExpanded()) {
|
|
3145
|
+
const total = stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite;
|
|
3146
|
+
return `${formatTokens(total)} \u25B6`;
|
|
3147
|
+
}
|
|
3148
|
+
return `\xD7${stat.requestCount} \u25BE`;
|
|
3149
|
+
};
|
|
3150
|
+
const modelTotalTokens = stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite;
|
|
3151
|
+
const targetW = () => {
|
|
3152
|
+
const cacheLabel = t2("cache") + ":";
|
|
3153
|
+
const costLabel = t2("cost") + ":";
|
|
3154
|
+
return Math.max(getVisualWidth2(cacheLabel), getVisualWidth2(costLabel));
|
|
3155
|
+
};
|
|
3156
|
+
const paddedCachePrefix = () => {
|
|
3157
|
+
const label = t2("cache") + ":";
|
|
3158
|
+
return label + " ".repeat(targetW() - getVisualWidth2(label));
|
|
3159
|
+
};
|
|
3160
|
+
const paddedCostPrefix = () => {
|
|
3161
|
+
const label = t2("cost") + ":";
|
|
3162
|
+
return label + " ".repeat(targetW() - getVisualWidth2(label));
|
|
3163
|
+
};
|
|
3164
|
+
const modelBarWidth = () => Math.max(8, panelWidth() - 4 - targetW() - 11);
|
|
3165
|
+
return (
|
|
3166
|
+
// marginTop=1 提供模型间视觉间距(TUI最小单位为1行)
|
|
3167
|
+
(() => {
|
|
3168
|
+
var _el$22 = _$createElement("box"), _el$23 = _$createElement("box"), _el$24 = _$createElement("text"), _el$25 = _$createElement("span"), _el$27 = _$createTextNode(` `), _el$28 = _$createElement("span"), _el$29 = _$createElement("text");
|
|
3169
|
+
_$insertNode(_el$22, _el$23);
|
|
3170
|
+
_$setProp(_el$22, "flexDirection", "column");
|
|
3171
|
+
_$setProp(_el$22, "marginTop", 1);
|
|
3172
|
+
_$insertNode(_el$23, _el$24);
|
|
3173
|
+
_$insertNode(_el$23, _el$29);
|
|
3174
|
+
_$setProp(_el$23, "flexDirection", "row");
|
|
3175
|
+
_$setProp(_el$23, "justifyContent", "space-between");
|
|
3176
|
+
_$setProp(_el$23, "onMouseDown", () => toggle.model(key));
|
|
3177
|
+
_$setProp(_el$23, "paddingX", 1);
|
|
3178
|
+
_$insertNode(_el$24, _el$25);
|
|
3179
|
+
_$insertNode(_el$24, _el$27);
|
|
3180
|
+
_$insertNode(_el$24, _el$28);
|
|
3181
|
+
_$insertNode(_el$25, _$createTextNode(`\u25CF`));
|
|
3182
|
+
_$insert(_el$28, shortTitle);
|
|
3183
|
+
_$insert(_el$29, modelHeaderRight);
|
|
3184
|
+
_$insert(_el$22, _$createComponent2(Show, {
|
|
3185
|
+
get when() {
|
|
3186
|
+
return isExpanded();
|
|
3187
|
+
},
|
|
3188
|
+
get children() {
|
|
3189
|
+
var _el$30 = _$createElement("box"), _el$31 = _$createElement("box"), _el$32 = _$createElement("box"), _el$33 = _$createElement("text"), _el$34 = _$createElement("span"), _el$35 = _$createTextNode(` `), _el$36 = _$createTextNode(`%`);
|
|
3190
|
+
_$insertNode(_el$30, _el$31);
|
|
3191
|
+
_$insertNode(_el$30, _el$33);
|
|
3192
|
+
_$setProp(_el$30, "flexDirection", "column");
|
|
3193
|
+
_$setProp(_el$30, "paddingX", 1);
|
|
3194
|
+
_$insertNode(_el$31, _el$32);
|
|
3195
|
+
_$setProp(_el$31, "flexDirection", "column");
|
|
3196
|
+
_$setProp(_el$31, "border", true);
|
|
3197
|
+
_$setProp(_el$31, "borderStyle", "rounded");
|
|
3198
|
+
_$setProp(_el$32, "flexDirection", "row");
|
|
3199
|
+
_$insert(_el$32, _$createComponent2(For, {
|
|
3200
|
+
get each() {
|
|
3201
|
+
return [{
|
|
3202
|
+
val: formatTokens(modelTotalTokens),
|
|
3203
|
+
lbl: t2("total")
|
|
3204
|
+
}, {
|
|
3205
|
+
val: formatTokens(stat.totalInput),
|
|
3206
|
+
lbl: t2("input")
|
|
3207
|
+
}, {
|
|
3208
|
+
val: formatTokens(stat.totalOutput),
|
|
3209
|
+
lbl: t2("output")
|
|
3210
|
+
}];
|
|
3211
|
+
},
|
|
3212
|
+
children: (item, idx) => {
|
|
3213
|
+
const colW = () => {
|
|
3214
|
+
const totalW = panelWidth() - 6;
|
|
3215
|
+
const base = Math.floor(totalW / 3);
|
|
3216
|
+
return idx() === 2 ? totalW - base * 2 : base;
|
|
3217
|
+
};
|
|
3218
|
+
return (() => {
|
|
3219
|
+
var _el$47 = _$createElement("box"), _el$48 = _$createElement("text"), _el$49 = _$createElement("text");
|
|
3220
|
+
_$insertNode(_el$47, _el$48);
|
|
3221
|
+
_$insertNode(_el$47, _el$49);
|
|
3222
|
+
_$setProp(_el$47, "flexDirection", "column");
|
|
3223
|
+
_$insert(_el$48, () => centerAlign(item.val, colW()));
|
|
3224
|
+
_$insert(_el$49, () => centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW()));
|
|
3225
|
+
_$effect((_p$) => {
|
|
3226
|
+
var _v$21 = colW(), _v$22 = primaryColor(), _v$23 = dimColor();
|
|
3227
|
+
_v$21 !== _p$.e && (_p$.e = _$setProp(_el$47, "width", _v$21, _p$.e));
|
|
3228
|
+
_v$22 !== _p$.t && (_p$.t = _$setProp(_el$48, "fg", _v$22, _p$.t));
|
|
3229
|
+
_v$23 !== _p$.a && (_p$.a = _$setProp(_el$49, "fg", _v$23, _p$.a));
|
|
3230
|
+
return _p$;
|
|
3231
|
+
}, {
|
|
3232
|
+
e: void 0,
|
|
3233
|
+
t: void 0,
|
|
3234
|
+
a: void 0
|
|
3235
|
+
});
|
|
3236
|
+
return _el$47;
|
|
3237
|
+
})();
|
|
3238
|
+
}
|
|
3239
|
+
}));
|
|
3240
|
+
_$insertNode(_el$33, _el$34);
|
|
3241
|
+
_$insert(_el$33, paddedCachePrefix, _el$34);
|
|
3242
|
+
_$insertNode(_el$34, _el$35);
|
|
3243
|
+
_$insertNode(_el$34, _el$36);
|
|
3244
|
+
_$insert(_el$34, () => progressFilled(hitRate, modelBarWidth()), _el$35);
|
|
3245
|
+
_$insert(_el$34, () => progressRemaining(hitRate, modelBarWidth()), _el$35);
|
|
3246
|
+
_$insert(_el$34, () => hitRate.toFixed(0), _el$36);
|
|
3247
|
+
_$insert(_el$33, (() => {
|
|
3248
|
+
var _c$2 = _$memo(() => !!trendStr());
|
|
3249
|
+
return () => _c$2() ? (() => {
|
|
3250
|
+
var _el$50 = _$createElement("span");
|
|
3251
|
+
_$insert(_el$50, trendStr);
|
|
3252
|
+
_$effect((_$p) => _$setProp(_el$50, "style", {
|
|
3253
|
+
fg: trendColor()
|
|
3254
|
+
}, _$p));
|
|
3255
|
+
return _el$50;
|
|
3256
|
+
})() : null;
|
|
3257
|
+
})(), null);
|
|
3258
|
+
_$insert(_el$30, _$createComponent2(Show, {
|
|
3259
|
+
get when() {
|
|
3260
|
+
return _$memo(() => !!config().sidebar.showPerformance)() && !!perfStats().models[key];
|
|
3261
|
+
},
|
|
3262
|
+
get children() {
|
|
3263
|
+
var _el$37 = _$createElement("text"), _el$38 = _$createTextNode(` `), _el$39 = _$createElement("span"), _el$40 = _$createTextNode(` `), _el$41 = _$createTextNode(` `), _el$42 = _$createElement("span"), _el$43 = _$createTextNode(` `), _el$44 = _$createTextNode(` `), _el$45 = _$createElement("span");
|
|
3264
|
+
_$insertNode(_el$37, _el$38);
|
|
3265
|
+
_$insertNode(_el$37, _el$39);
|
|
3266
|
+
_$insertNode(_el$37, _el$40);
|
|
3267
|
+
_$insertNode(_el$37, _el$41);
|
|
3268
|
+
_$insertNode(_el$37, _el$42);
|
|
3269
|
+
_$insertNode(_el$37, _el$43);
|
|
3270
|
+
_$insertNode(_el$37, _el$44);
|
|
3271
|
+
_$insertNode(_el$37, _el$45);
|
|
3272
|
+
_$setProp(_el$37, "marginTop", 1);
|
|
3273
|
+
_$insert(_el$37, () => t2("ttft"), _el$38);
|
|
3274
|
+
_$insert(_el$39, () => formatDuration(perfStats().models[key]?.avgTTFT ?? null));
|
|
3275
|
+
_$insert(_el$37, () => t2("tps"), _el$41);
|
|
3276
|
+
_$insert(_el$42, () => perfStats().models[key]?.avgTPS?.toFixed(1) ?? "\u2014");
|
|
3277
|
+
_$insert(_el$37, () => t2("lat"), _el$44);
|
|
3278
|
+
_$insert(_el$45, () => formatDuration(perfStats().models[key]?.avgLatency ?? null));
|
|
3279
|
+
_$effect((_p$) => {
|
|
3280
|
+
var _v$10 = mutedColor(), _v$11 = {
|
|
3281
|
+
fg: primaryColor()
|
|
3282
|
+
}, _v$12 = {
|
|
3283
|
+
fg: primaryColor()
|
|
3284
|
+
}, _v$13 = {
|
|
3285
|
+
fg: primaryColor()
|
|
3286
|
+
};
|
|
3287
|
+
_v$10 !== _p$.e && (_p$.e = _$setProp(_el$37, "fg", _v$10, _p$.e));
|
|
3288
|
+
_v$11 !== _p$.t && (_p$.t = _$setProp(_el$39, "style", _v$11, _p$.t));
|
|
3289
|
+
_v$12 !== _p$.a && (_p$.a = _$setProp(_el$42, "style", _v$12, _p$.a));
|
|
3290
|
+
_v$13 !== _p$.o && (_p$.o = _$setProp(_el$45, "style", _v$13, _p$.o));
|
|
3291
|
+
return _p$;
|
|
3292
|
+
}, {
|
|
3293
|
+
e: void 0,
|
|
3294
|
+
t: void 0,
|
|
3295
|
+
a: void 0,
|
|
3296
|
+
o: void 0
|
|
3297
|
+
});
|
|
3298
|
+
return _el$37;
|
|
3299
|
+
}
|
|
3300
|
+
}), null);
|
|
3301
|
+
_$insert(_el$30, _$createComponent2(Show, {
|
|
3302
|
+
get when() {
|
|
3303
|
+
return _$memo(() => !!config().sidebar.showPricing)() && stat.totalCost > 0;
|
|
3304
|
+
},
|
|
3305
|
+
get children() {
|
|
3306
|
+
var _el$46 = _$createElement("text");
|
|
3307
|
+
_$insert(_el$46, paddedCostPrefix, null);
|
|
3308
|
+
_$insert(_el$46, () => formatCost(stat.totalCost), null);
|
|
3309
|
+
_$effect((_$p) => _$setProp(_el$46, "fg", mutedColor(), _$p));
|
|
3310
|
+
return _el$46;
|
|
3311
|
+
}
|
|
3312
|
+
}), null);
|
|
3313
|
+
_$effect((_p$) => {
|
|
3314
|
+
var _v$14 = borderColor(), _v$15 = mutedColor(), _v$16 = {
|
|
3315
|
+
fg: hitRateColor(hitRate)
|
|
3316
|
+
};
|
|
3317
|
+
_v$14 !== _p$.e && (_p$.e = _$setProp(_el$31, "borderColor", _v$14, _p$.e));
|
|
3318
|
+
_v$15 !== _p$.t && (_p$.t = _$setProp(_el$33, "fg", _v$15, _p$.t));
|
|
3319
|
+
_v$16 !== _p$.a && (_p$.a = _$setProp(_el$34, "style", _v$16, _p$.a));
|
|
3320
|
+
return _p$;
|
|
3321
|
+
}, {
|
|
3322
|
+
e: void 0,
|
|
3323
|
+
t: void 0,
|
|
3324
|
+
a: void 0
|
|
3325
|
+
});
|
|
3326
|
+
return _el$30;
|
|
3327
|
+
}
|
|
3328
|
+
}), null);
|
|
3329
|
+
_$effect((_p$) => {
|
|
3330
|
+
var _v$17 = mutedColor(), _v$18 = {
|
|
3331
|
+
fg: hitRateColor(hitRate)
|
|
3332
|
+
}, _v$19 = {
|
|
3333
|
+
fg: primaryColor()
|
|
3334
|
+
}, _v$20 = mutedColor();
|
|
3335
|
+
_v$17 !== _p$.e && (_p$.e = _$setProp(_el$24, "fg", _v$17, _p$.e));
|
|
3336
|
+
_v$18 !== _p$.t && (_p$.t = _$setProp(_el$25, "style", _v$18, _p$.t));
|
|
3337
|
+
_v$19 !== _p$.a && (_p$.a = _$setProp(_el$28, "style", _v$19, _p$.a));
|
|
3338
|
+
_v$20 !== _p$.o && (_p$.o = _$setProp(_el$29, "fg", _v$20, _p$.o));
|
|
3339
|
+
return _p$;
|
|
3340
|
+
}, {
|
|
3341
|
+
e: void 0,
|
|
3342
|
+
t: void 0,
|
|
3343
|
+
a: void 0,
|
|
3344
|
+
o: void 0
|
|
3345
|
+
});
|
|
3346
|
+
return _el$22;
|
|
3347
|
+
})()
|
|
3348
|
+
);
|
|
3349
|
+
}
|
|
3350
|
+
}), _$createComponent2(Show, {
|
|
3351
|
+
get when() {
|
|
3352
|
+
return config().sidebar.showTokenDistribution;
|
|
3353
|
+
},
|
|
3354
|
+
get children() {
|
|
3355
|
+
var _el$11 = _$createElement("box"), _el$12 = _$createElement("text"), _el$13 = _$createElement("box"), _el$14 = _$createElement("text"), _el$15 = _$createTextNode(` `);
|
|
3356
|
+
_$insertNode(_el$11, _el$12);
|
|
3357
|
+
_$insertNode(_el$11, _el$13);
|
|
3358
|
+
_$setProp(_el$11, "flexDirection", "column");
|
|
3359
|
+
_$setProp(_el$11, "marginTop", 1);
|
|
3360
|
+
_$insert(_el$12, divider);
|
|
3361
|
+
_$insertNode(_el$13, _el$14);
|
|
3362
|
+
_$setProp(_el$13, "flexDirection", "row");
|
|
3363
|
+
_$setProp(_el$13, "onMouseDown", () => toggle.sub("token-dist"));
|
|
3364
|
+
_$setProp(_el$13, "paddingX", 1);
|
|
3365
|
+
_$insertNode(_el$14, _el$15);
|
|
3366
|
+
_$insert(_el$14, () => !collapse().subBlocks["token-dist"] ? "\u25BE" : "\u25B6", _el$15);
|
|
3367
|
+
_$insert(_el$14, () => t2("tokenDistribution"), null);
|
|
3368
|
+
_$insert(_el$11, _$createComponent2(Show, {
|
|
3369
|
+
get when() {
|
|
3370
|
+
return !collapse().subBlocks["token-dist"];
|
|
3371
|
+
},
|
|
3372
|
+
get children() {
|
|
3373
|
+
var _el$16 = _$createElement("box");
|
|
3374
|
+
_$setProp(_el$16, "flexDirection", "column");
|
|
3375
|
+
_$setProp(_el$16, "paddingX", 1);
|
|
3376
|
+
_$setProp(_el$16, "marginTop", 1);
|
|
3377
|
+
_$insert(_el$16, _$createComponent2(For, {
|
|
3378
|
+
get each() {
|
|
3379
|
+
return Object.entries(tokenDistribution()).filter(([_, val]) => val > 0);
|
|
3380
|
+
},
|
|
3381
|
+
children: ([role, val]) => (() => {
|
|
3382
|
+
var _el$51 = _$createElement("box"), _el$52 = _$createElement("box"), _el$53 = _$createElement("text"), _el$55 = _$createElement("text"), _el$56 = _$createElement("text");
|
|
3383
|
+
_$insertNode(_el$51, _el$52);
|
|
3384
|
+
_$insertNode(_el$51, _el$56);
|
|
3385
|
+
_$setProp(_el$51, "flexDirection", "row");
|
|
3386
|
+
_$setProp(_el$51, "justifyContent", "space-between");
|
|
3387
|
+
_$insertNode(_el$52, _el$53);
|
|
3388
|
+
_$insertNode(_el$52, _el$55);
|
|
3389
|
+
_$setProp(_el$52, "flexDirection", "row");
|
|
3390
|
+
_$insertNode(_el$53, _$createTextNode(`\u2588 `));
|
|
3391
|
+
_$insert(_el$55, () => t2(role));
|
|
3392
|
+
_$insert(_el$56, () => formatTokens(val));
|
|
3393
|
+
_$effect((_p$) => {
|
|
3394
|
+
var _v$24 = distRoleColor(role), _v$25 = mutedColor(), _v$26 = mutedColor();
|
|
3395
|
+
_v$24 !== _p$.e && (_p$.e = _$setProp(_el$53, "fg", _v$24, _p$.e));
|
|
3396
|
+
_v$25 !== _p$.t && (_p$.t = _$setProp(_el$55, "fg", _v$25, _p$.t));
|
|
3397
|
+
_v$26 !== _p$.a && (_p$.a = _$setProp(_el$56, "fg", _v$26, _p$.a));
|
|
3398
|
+
return _p$;
|
|
3399
|
+
}, {
|
|
3400
|
+
e: void 0,
|
|
3401
|
+
t: void 0,
|
|
3402
|
+
a: void 0
|
|
3403
|
+
});
|
|
3404
|
+
return _el$51;
|
|
3405
|
+
})()
|
|
3406
|
+
}));
|
|
3407
|
+
return _el$16;
|
|
3408
|
+
}
|
|
3409
|
+
}), null);
|
|
3410
|
+
_$effect((_p$) => {
|
|
3411
|
+
var _v$3 = borderColor(), _v$4 = greenColor();
|
|
3412
|
+
_v$3 !== _p$.e && (_p$.e = _$setProp(_el$12, "fg", _v$3, _p$.e));
|
|
3413
|
+
_v$4 !== _p$.t && (_p$.t = _$setProp(_el$14, "fg", _v$4, _p$.t));
|
|
3414
|
+
return _p$;
|
|
3415
|
+
}, {
|
|
3416
|
+
e: void 0,
|
|
3417
|
+
t: void 0
|
|
3418
|
+
});
|
|
3419
|
+
return _el$11;
|
|
3420
|
+
}
|
|
3421
|
+
})];
|
|
3422
|
+
}
|
|
3423
|
+
}), null);
|
|
3424
|
+
_$effect((_p$) => {
|
|
3425
|
+
var _v$5 = borderColor(), _v$6 = toggle.global, _v$7 = primaryColor(), _v$8 = mutedColor();
|
|
3426
|
+
_v$5 !== _p$.e && (_p$.e = _$setProp(_el$, "borderColor", _v$5, _p$.e));
|
|
3427
|
+
_v$6 !== _p$.t && (_p$.t = _$setProp(_el$2, "onMouseDown", _v$6, _p$.t));
|
|
3428
|
+
_v$7 !== _p$.a && (_p$.a = _$setProp(_el$3, "fg", _v$7, _p$.a));
|
|
3429
|
+
_v$8 !== _p$.o && (_p$.o = _$setProp(_el$5, "fg", _v$8, _p$.o));
|
|
3430
|
+
return _p$;
|
|
3431
|
+
}, {
|
|
3432
|
+
e: void 0,
|
|
3433
|
+
t: void 0,
|
|
3434
|
+
a: void 0,
|
|
3435
|
+
o: void 0
|
|
3436
|
+
});
|
|
3437
|
+
return _el$;
|
|
3438
|
+
})();
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
// src/tui.tsx
|
|
3442
|
+
function kvKey(sessionID) {
|
|
3443
|
+
return "tokenwatch-msgs-" + sessionID;
|
|
3444
|
+
}
|
|
3445
|
+
var tui = async (api) => {
|
|
3446
|
+
const perfTracker = createPerfTracker();
|
|
3447
|
+
const [sidebarRevision, setSidebarRevision] = createSignal2(0);
|
|
3448
|
+
const [allTokenMessages, setAllTokenMessages] = createSignal2([]);
|
|
3449
|
+
let currentSlotSessionID = "";
|
|
3450
|
+
const cleanups = [];
|
|
3451
|
+
registerCommands(api);
|
|
3452
|
+
function persistToKv(sessionID, msgs) {
|
|
3453
|
+
try {
|
|
3454
|
+
api.kv?.set?.(kvKey(sessionID), msgs);
|
|
3455
|
+
} catch {
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
const unsubMsgUpdated = api.event.on("message.updated", (event) => {
|
|
3459
|
+
const info = event.properties?.info;
|
|
3460
|
+
perfTracker.handleMessageUpdated(event);
|
|
3461
|
+
if (info?.role === "assistant" && info?.tokens?.total > 0) {
|
|
3462
|
+
setAllTokenMessages((prev) => {
|
|
3463
|
+
const msg = {
|
|
3464
|
+
id: info.id,
|
|
3465
|
+
sessionID: info.sessionID ?? "",
|
|
3466
|
+
providerID: info.providerID ?? "unknown",
|
|
3467
|
+
modelID: info.modelID ?? "unknown",
|
|
3468
|
+
inputTokens: info.tokens?.input ?? 0,
|
|
3469
|
+
outputTokens: info.tokens?.output ?? 0,
|
|
3470
|
+
reasoningTokens: info.tokens?.reasoning ?? 0,
|
|
3471
|
+
cacheRead: info.tokens?.cache?.read ?? 0,
|
|
3472
|
+
cacheWrite: info.tokens?.cache?.write ?? 0,
|
|
3473
|
+
cost: info.cost ?? 0
|
|
3474
|
+
};
|
|
3475
|
+
const idx = prev.findIndex((m) => m.id === msg.id);
|
|
3476
|
+
let next;
|
|
3477
|
+
if (idx >= 0) {
|
|
3478
|
+
next = [...prev];
|
|
3479
|
+
next[idx] = msg;
|
|
3480
|
+
} else {
|
|
3481
|
+
next = [...prev, msg];
|
|
3482
|
+
}
|
|
3483
|
+
const targetSessionID = info.sessionID ?? currentSlotSessionID;
|
|
3484
|
+
persistToKv(targetSessionID, next);
|
|
3485
|
+
return next;
|
|
3486
|
+
});
|
|
3487
|
+
}
|
|
3488
|
+
setSidebarRevision((v) => v + 1);
|
|
3489
|
+
});
|
|
3490
|
+
cleanups.push(unsubMsgUpdated);
|
|
3491
|
+
const unsubPartUpdated = api.event.on("message.part.updated", (event) => {
|
|
3492
|
+
perfTracker.handlePartUpdated({
|
|
3493
|
+
message_id: event.properties?.part?.messageID,
|
|
3494
|
+
type: event.properties?.part?.type,
|
|
3495
|
+
text: event.properties?.part?.type === "text" ? event.properties?.part?.text : void 0,
|
|
3496
|
+
time: {
|
|
3497
|
+
start: event.properties?.part?.time?.start
|
|
3498
|
+
}
|
|
3499
|
+
});
|
|
3500
|
+
});
|
|
3501
|
+
cleanups.push(unsubPartUpdated);
|
|
3502
|
+
const unsubRemoved = api.event.on("message.removed", () => {
|
|
3503
|
+
setSidebarRevision((v) => v + 1);
|
|
3504
|
+
});
|
|
3505
|
+
cleanups.push(unsubRemoved);
|
|
3506
|
+
api.lifecycle?.onDispose?.(() => {
|
|
3507
|
+
for (const cleanup of cleanups) cleanup();
|
|
3508
|
+
});
|
|
3509
|
+
api.slots.register({
|
|
3510
|
+
order: 50,
|
|
3511
|
+
slots: {
|
|
3512
|
+
sidebar_content: (_ctx, {
|
|
3513
|
+
session_id
|
|
3514
|
+
}) => {
|
|
3515
|
+
sidebarRevision();
|
|
3516
|
+
if (session_id && session_id !== currentSlotSessionID) {
|
|
3517
|
+
currentSlotSessionID = session_id;
|
|
3518
|
+
perfTracker.loadSession(session_id);
|
|
3519
|
+
let loaded = [];
|
|
3520
|
+
try {
|
|
3521
|
+
const saved = api.kv?.get?.(kvKey(session_id));
|
|
3522
|
+
if (saved && saved.length > 0) loaded = saved;
|
|
3523
|
+
} catch {
|
|
3524
|
+
}
|
|
3525
|
+
setAllTokenMessages(loaded);
|
|
3526
|
+
}
|
|
3527
|
+
createEffect2(() => {
|
|
3528
|
+
if (!session_id) return;
|
|
3529
|
+
let timer = null;
|
|
3530
|
+
let pollCount = 0;
|
|
3531
|
+
const maxPolls = 50;
|
|
3532
|
+
const checkAndPopulate = () => {
|
|
3533
|
+
const existing = api.state.session.messages(session_id);
|
|
3534
|
+
if (!existing || existing.length === 0) return false;
|
|
3535
|
+
setAllTokenMessages((prev) => {
|
|
3536
|
+
let changed = false;
|
|
3537
|
+
const next = [...prev];
|
|
3538
|
+
for (const msg of existing) {
|
|
3539
|
+
if (msg.role !== "assistant") continue;
|
|
3540
|
+
const tokens = msg.tokens;
|
|
3541
|
+
if (!tokens || (tokens.total ?? 0) === 0) continue;
|
|
3542
|
+
const id = msg.id;
|
|
3543
|
+
const idx = next.findIndex((m) => m.id === id);
|
|
3544
|
+
const tokenMsg = {
|
|
3545
|
+
id,
|
|
3546
|
+
sessionID: session_id,
|
|
3547
|
+
providerID: msg.providerID ?? "unknown",
|
|
3548
|
+
modelID: msg.modelID ?? "unknown",
|
|
3549
|
+
inputTokens: tokens?.input ?? 0,
|
|
3550
|
+
outputTokens: tokens?.output ?? 0,
|
|
3551
|
+
reasoningTokens: tokens?.reasoning ?? 0,
|
|
3552
|
+
cacheRead: tokens?.cache?.read ?? 0,
|
|
3553
|
+
cacheWrite: tokens?.cache?.write ?? 0,
|
|
3554
|
+
cost: msg.cost ?? 0
|
|
3555
|
+
};
|
|
3556
|
+
if (idx >= 0) {
|
|
3557
|
+
const cur = next[idx];
|
|
3558
|
+
if (cur.inputTokens !== tokenMsg.inputTokens || cur.outputTokens !== tokenMsg.outputTokens || cur.reasoningTokens !== tokenMsg.reasoningTokens || cur.cacheRead !== tokenMsg.cacheRead || cur.cacheWrite !== tokenMsg.cacheWrite || cur.cost !== tokenMsg.cost) {
|
|
3559
|
+
next[idx] = tokenMsg;
|
|
3560
|
+
changed = true;
|
|
3561
|
+
}
|
|
3562
|
+
} else {
|
|
3563
|
+
next.push(tokenMsg);
|
|
3564
|
+
changed = true;
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
if (changed) {
|
|
3568
|
+
persistToKv(session_id, next);
|
|
3569
|
+
return next;
|
|
3570
|
+
}
|
|
3571
|
+
return prev;
|
|
3572
|
+
});
|
|
3573
|
+
return true;
|
|
3574
|
+
};
|
|
3575
|
+
const hasMessages = checkAndPopulate();
|
|
3576
|
+
if (!hasMessages) {
|
|
3577
|
+
timer = setInterval(() => {
|
|
3578
|
+
pollCount++;
|
|
3579
|
+
if (checkAndPopulate() || pollCount >= maxPolls) {
|
|
3580
|
+
clearInterval(timer);
|
|
3581
|
+
timer = null;
|
|
3582
|
+
}
|
|
3583
|
+
}, 200);
|
|
3584
|
+
}
|
|
3585
|
+
onCleanup2(() => {
|
|
3586
|
+
if (timer) {
|
|
3587
|
+
clearInterval(timer);
|
|
3588
|
+
}
|
|
3589
|
+
});
|
|
3590
|
+
});
|
|
3591
|
+
return _$createComponent3(TokenWatchPanel, {
|
|
3592
|
+
api,
|
|
3593
|
+
get theme() {
|
|
3594
|
+
return api.theme;
|
|
3595
|
+
},
|
|
3596
|
+
perfTracker,
|
|
3597
|
+
messages: () => api.state.session.messages(session_id),
|
|
3598
|
+
allTokenMessages
|
|
3599
|
+
});
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
});
|
|
3603
|
+
};
|
|
3604
|
+
var plugin = {
|
|
3605
|
+
id: "opencode-tokenwatch",
|
|
3606
|
+
tui
|
|
3607
|
+
};
|
|
3608
|
+
var tui_default = plugin;
|
|
3609
|
+
export {
|
|
3610
|
+
tui_default as default
|
|
3611
|
+
};
|