opencode-tokenwatch 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/sidebar.jsx CHANGED
@@ -101,11 +101,13 @@ export function TokenWatchPanel(props) {
101
101
  const key = `${msg.providerID}/${msg.modelID}`;
102
102
  let e = map.get(key);
103
103
  if (!e) {
104
- e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0 };
104
+ // Bug fix: 初始化时加入 totalReasoning 字段
105
+ e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0 };
105
106
  map.set(key, e);
106
107
  }
107
108
  e.totalInput += msg.inputTokens;
108
109
  e.totalOutput += msg.outputTokens;
110
+ e.totalReasoning += msg.reasoningTokens; // Bug fix: 聚合 reasoning
109
111
  e.cacheRead += msg.cacheRead;
110
112
  e.cacheWrite += msg.cacheWrite;
111
113
  e.totalCost += msg.cost;
@@ -114,16 +116,18 @@ export function TokenWatchPanel(props) {
114
116
  return Array.from(map.entries()).sort((a, b) => (b[1].totalInput + b[1].totalOutput) - (a[1].totalInput + a[1].totalOutput));
115
117
  });
116
118
  const sessionTotals = createMemo(() => {
117
- let i = 0, o = 0, cr = 0, cw = 0, r = 0, c = 0;
119
+ let i = 0, o = 0, ir = 0, cr = 0, cw = 0, r = 0, c = 0;
118
120
  for (const [, s] of modelStats()) {
119
121
  i += s.totalInput;
120
122
  o += s.totalOutput;
123
+ ir += s.totalReasoning;
121
124
  cr += s.cacheRead;
122
125
  cw += s.cacheWrite;
123
126
  r += s.requestCount;
124
127
  c += s.totalCost;
125
128
  }
126
- return { totalInput: i, totalOutput: o, totalCacheRead: cr, totalCacheWrite: cw, totalRequests: r, totalCost: c, totalTokens: i + o + cr + cw };
129
+ // Bug fix: totalTokens 改为 5 分量(含 reasoning),与官方一致
130
+ return { totalInput: i, totalOutput: o, totalReasoning: ir, totalCacheRead: cr, totalCacheWrite: cw, totalRequests: r, totalCost: c, totalTokens: i + o + ir + cr + cw };
127
131
  });
128
132
  const modelHitRate = createMemo(() => {
129
133
  return modelStats().map(([key, stat]) => {
@@ -230,6 +234,11 @@ export function TokenWatchPanel(props) {
230
234
  dist.toolResult = (dist.toolResult ?? 0) + estimateTokens(p.state.error);
231
235
  }
232
236
  }
237
+ else if (p.type === "text" && p.text) {
238
+ // Bug fix: assistant text part(主要 LLM 回复内容)之前未计入分布
239
+ // 此处用 estimateTokens 估算,最终 dist.output 会被下方真实 tokens.output 覆盖
240
+ dist.output = (dist.output ?? 0) + estimateTokens(p.text);
241
+ }
233
242
  else if (p.type === "reasoning") {
234
243
  dist.agent = (dist.agent ?? 0) + estimateTokens(p.text ?? "");
235
244
  }
@@ -238,10 +247,22 @@ export function TokenWatchPanel(props) {
238
247
  }
239
248
  }
240
249
  const tokens = msg.tokens;
250
+ // 真实 output token 数优先:覆盖上面的 text part 估算
241
251
  if (tokens?.output)
242
252
  dist.output = (dist.output ?? 0) + tokens.output;
243
253
  }
244
254
  }
255
+ // Bug fix: 添加 other 兜底桶(参考官方 session-context-breakdown.ts)
256
+ // 使用真实 input token 数减去各桶估算值,确保分布总和对齐
257
+ const realInput = sessionTotals().totalInput;
258
+ if (realInput > 0) {
259
+ const estimated = (dist.system ?? 0) + (dist.user ?? 0)
260
+ + (dist.agent ?? 0) + (dist.toolCall ?? 0) + (dist.toolResult ?? 0);
261
+ const other = realInput - estimated;
262
+ // 只在差值超过 50 token 时展示,避免估算误差噪音
263
+ if (other > 50)
264
+ dist.other = other;
265
+ }
245
266
  return dist;
246
267
  });
247
268
  const toggle = {
@@ -250,36 +271,47 @@ export function TokenWatchPanel(props) {
250
271
  sub: (k) => setCollapse(p => { const n = { ...p, subBlocks: { ...p.subBlocks, [k]: !p.subBlocks[k] } }; saveCollapseState(api, n); return n; }),
251
272
  };
252
273
  onMount(() => {
274
+ // Risk fix: 移除 message.updated 的重复订阅。
275
+ // tui.tsx 已通过 setSidebarRevision() 驱动 sidebar 整体重渲,
276
+ // sidebar 内部只需订阅 part.updated 来触发 tokenDistribution 重算。
253
277
  const unsubPart = api.event?.on?.("message.part.updated", () => setPartVersion(v => v + 1));
254
- const unsubMsg = api.event?.on?.("message.updated", () => setPartVersion(v => v + 1));
255
278
  onCleanup(() => { try {
256
279
  unsubPart?.();
257
- unsubMsg?.();
258
280
  }
259
281
  catch { } });
260
282
  });
261
- return (<box flexDirection="column" width={panelWidth()}>
262
- <box onMouseDown={toggle.global}>
263
- <text fg={primaryColor()}>
264
- {collapse().global ? "▶" : "▼"} {t("panelTitle")}
265
- {collapse().global ? ` ${t("cacheRead")}:${formatTokens(sessionTotals().totalCacheRead)} ${t("requests")}:${sessionTotals().totalRequests}` : ""}
266
- </text>
267
- </box>
268
-
269
- <Show when={!collapse().global}>
270
- <text fg={mutedColor()}>
271
- {t("total")}:{formatTokens(sessionTotals().totalTokens)} {t("requests")}:{sessionTotals().totalRequests}
272
- </text>
273
- <text fg={mutedColor()}>
274
- {t("input")}:{formatTokens(sessionTotals().totalInput)} {t("output")}:{formatTokens(sessionTotals().totalOutput)} {t("cacheRead")}:{formatTokens(sessionTotals().totalCacheRead)}
275
- </text>
276
- <Show when={sessionTotals().totalCost > 0}>
277
- <text fg={mutedColor()}>
278
- {t("cost")}:{formatCost(sessionTotals().totalCost)}
279
- </text>
280
- </Show>
281
-
282
- <For each={modelStats()}>
283
+ return (<box flexDirection="column" width={panelWidth()}>
284
+ <box onMouseDown={toggle.global}>
285
+ <text fg={primaryColor()}>
286
+ {collapse().global ? "▶" : "▼"} {t("panelTitle")}
287
+ {collapse().global ? ` ${t("cacheRead")}:${formatTokens(sessionTotals().totalCacheRead)} ${t("requests")}:${sessionTotals().totalRequests}` : ""}
288
+ </text>
289
+ </box>
290
+
291
+ <Show when={!collapse().global}>
292
+ <text fg={mutedColor()}>
293
+ {t("total")}:{formatTokens(sessionTotals().totalTokens)} {t("requests")}:{sessionTotals().totalRequests}
294
+ </text>
295
+ <text fg={mutedColor()}>
296
+ {t("input")}:{formatTokens(sessionTotals().totalInput)} {t("output")}:{formatTokens(sessionTotals().totalOutput)} {t("cacheRead")}:{formatTokens(sessionTotals().totalCacheRead)}
297
+ </text>
298
+ {/* 全局加权缓存命中率(按 token 加权:totalCacheRead / (totalCacheRead + totalInput) */}
299
+ <Show when={(sessionTotals().totalInput + sessionTotals().totalCacheRead) > 0}>
300
+ <text fg={mutedColor()}>
301
+ {t("cache")}Hit:{(() => {
302
+ const denom = sessionTotals().totalInput + sessionTotals().totalCacheRead;
303
+ const rate = denom > 0 ? (sessionTotals().totalCacheRead / denom) * 100 : 0;
304
+ return rate.toFixed(1) + "%";
305
+ })()}(global)
306
+ </text>
307
+ </Show>
308
+ <Show when={sessionTotals().totalCost > 0}>
309
+ <text fg={mutedColor()}>
310
+ {t("cost")}:{formatCost(sessionTotals().totalCost)}
311
+ </text>
312
+ </Show>
313
+
314
+ <For each={modelStats()}>
283
315
  {([key, stat]) => {
284
316
  const modelCollapsed = () => collapse().models[key] !== true;
285
317
  const totalInput = stat.totalInput + stat.cacheRead;
@@ -290,73 +322,74 @@ export function TokenWatchPanel(props) {
290
322
  : "";
291
323
  const title = `${stat.providerID}/${stat.modelID}`;
292
324
  const shortTitle = title.length > 32 ? title.slice(0, 30) + "…" : title;
293
- return (<box flexDirection="column">
294
- <box onMouseDown={() => toggle.model(key)}>
295
- <text fg={primaryColor()}>{modelCollapsed() ? "▼" : "▶"} {shortTitle}</text>
296
- </box>
297
- <text fg={mutedColor()}>
298
- {t("total")}:{formatTokens(stat.totalInput + stat.totalOutput + stat.cacheRead + stat.cacheWrite)} {t("requests")}:{stat.requestCount}
299
- </text>
300
- <text fg={mutedColor()}>
301
- {t("input")}:{formatTokens(stat.totalInput)} {t("output")}:{formatTokens(stat.totalOutput)}
302
- </text>
303
- <text fg={mutedColor()}>
304
- {t("cache")}:{formatTokens(stat.cacheRead + stat.cacheWrite)}(
305
- <span style={{ fg: hitRateColor(hitRate) }}>
306
- {progressFilled(hitRate, Math.max(3, Math.floor(panelWidth() / 3)))}
307
- {progressRemaining(hitRate, Math.max(3, Math.floor(panelWidth() / 3)))}
308
- {" "}{hitRate.toFixed(0)}%
309
- </span>
310
- {trendStr ? ` ${trendStr}` : ""})
311
- </text>
312
- <Show when={stat.totalCost > 0}>
313
- <text fg={mutedColor()}>
314
- {t("cost")}:{formatCost(stat.totalCost)}
315
- </text>
316
- </Show>
317
-
318
- <Show when={modelCollapsed()}>
319
- <Show when={config().sidebar.showPerformance}>
320
- <box flexDirection="column">
321
- <box onMouseDown={() => toggle.sub(`perf-${key}`)}>
322
- <text fg={textColor()}>{!collapse().subBlocks[`perf-${key}`] ? "▼" : "▶"} ── {t("performance")} ──</text>
323
- </box>
324
- <Show when={!collapse().subBlocks[`perf-${key}`]}>
325
- <text fg={mutedColor()}>
326
- {t("ttft")}:{formatDuration(perfTracker.getSessionStats().models[key]?.avgTTFT ?? null)} {t("tps")}:{perfTracker.getSessionStats().models[key]?.avgTPS?.toFixed(1) ?? "—"} {t("latency")}:{formatDuration(perfTracker.getSessionStats().models[key]?.avgLatency ?? null)}
327
- </text>
328
- </Show>
329
- </box>
330
- </Show>
331
-
332
- <Show when={config().sidebar.showPricing && stat.totalCost > 0}>
333
- <box flexDirection="column">
334
- <box onMouseDown={() => toggle.sub(`pricing-${key}`)}>
335
- <text fg={textColor()}>{!collapse().subBlocks[`pricing-${key}`] ? "▼" : "▶"} ── {t("pricing")} ──</text>
336
- </box>
337
- <Show when={!collapse().subBlocks[`pricing-${key}`]}>
338
- <text fg={mutedColor()}> {t("cost")}:{formatCost(stat.totalCost)}</text>
339
- <text fg={mutedColor()}> {t("modelLabel")}:{stat.providerID}/{stat.modelID}</text>
340
- </Show>
341
- </box>
342
- </Show>
343
- </Show>
325
+ return (<box flexDirection="column">
326
+ <box onMouseDown={() => toggle.model(key)}>
327
+ <text fg={primaryColor()}>{modelCollapsed() ? "▼" : "▶"} {shortTitle}</text>
328
+ </box>
329
+ <text fg={mutedColor()}>
330
+ {/* Bug fix: total 改为 5 分量(含 reasoning) */}
331
+ {t("total")}:{formatTokens(stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite)} {t("requests")}:{stat.requestCount}
332
+ </text>
333
+ <text fg={mutedColor()}>
334
+ {t("input")}:{formatTokens(stat.totalInput)} {t("output")}:{formatTokens(stat.totalOutput)}
335
+ </text>
336
+ <text fg={mutedColor()}>
337
+ {t("cache")}:{formatTokens(stat.cacheRead + stat.cacheWrite)}(
338
+ <span style={{ fg: hitRateColor(hitRate) }}>
339
+ {progressFilled(hitRate, Math.max(3, Math.floor(panelWidth() / 3)))}
340
+ {progressRemaining(hitRate, Math.max(3, Math.floor(panelWidth() / 3)))}
341
+ {" "}{hitRate.toFixed(0)}%
342
+ </span>
343
+ {trendStr ? ` ${trendStr}` : ""})
344
+ </text>
345
+ <Show when={stat.totalCost > 0}>
346
+ <text fg={mutedColor()}>
347
+ {t("cost")}:{formatCost(stat.totalCost)}
348
+ </text>
349
+ </Show>
350
+
351
+ <Show when={modelCollapsed()}>
352
+ <Show when={config().sidebar.showPerformance}>
353
+ <box flexDirection="column">
354
+ <box onMouseDown={() => toggle.sub(`perf-${key}`)}>
355
+ <text fg={textColor()}>{!collapse().subBlocks[`perf-${key}`] ? "▼" : "▶"} ── {t("performance")} ──</text>
356
+ </box>
357
+ <Show when={!collapse().subBlocks[`perf-${key}`]}>
358
+ <text fg={mutedColor()}>
359
+ {t("ttft")}:{formatDuration(perfTracker.getSessionStats().models[key]?.avgTTFT ?? null)} {t("tps")}:{perfTracker.getSessionStats().models[key]?.avgTPS?.toFixed(1) ?? "—"} {t("latency")}:{formatDuration(perfTracker.getSessionStats().models[key]?.avgLatency ?? null)}
360
+ </text>
361
+ </Show>
362
+ </box>
363
+ </Show>
364
+
365
+ <Show when={config().sidebar.showPricing && stat.totalCost > 0}>
366
+ <box flexDirection="column">
367
+ <box onMouseDown={() => toggle.sub(`pricing-${key}`)}>
368
+ <text fg={textColor()}>{!collapse().subBlocks[`pricing-${key}`] ? "▼" : "▶"} ── {t("pricing")} ──</text>
369
+ </box>
370
+ <Show when={!collapse().subBlocks[`pricing-${key}`]}>
371
+ <text fg={mutedColor()}> {t("cost")}:{formatCost(stat.totalCost)}</text>
372
+ <text fg={mutedColor()}> {t("modelLabel")}:{stat.providerID}/{stat.modelID}</text>
373
+ </Show>
374
+ </box>
375
+ </Show>
376
+ </Show>
344
377
  </box>);
345
- }}
346
- </For>
347
-
348
- <Show when={config().sidebar.showTokenDistribution}>
349
- <box flexDirection="column">
350
- <box onMouseDown={() => toggle.sub("token-dist")}>
351
- <text fg={primaryColor()}>{!collapse().subBlocks["token-dist"] ? "▼" : "▶"} ── {t("tokenDistribution")} ──</text>
352
- </box>
353
- <Show when={!collapse().subBlocks["token-dist"]}>
354
- <For each={Object.entries(tokenDistribution())}>
355
- {([role, tokens]) => (<text fg={mutedColor()}> {t(role)}:{formatTokens(tokens)}</text>)}
356
- </For>
357
- </Show>
358
- </box>
359
- </Show>
360
- </Show>
378
+ }}
379
+ </For>
380
+
381
+ <Show when={config().sidebar.showTokenDistribution}>
382
+ <box flexDirection="column">
383
+ <box onMouseDown={() => toggle.sub("token-dist")}>
384
+ <text fg={primaryColor()}>{!collapse().subBlocks["token-dist"] ? "▼" : "▶"} ── {t("tokenDistribution")} ──</text>
385
+ </box>
386
+ <Show when={!collapse().subBlocks["token-dist"]}>
387
+ <For each={Object.entries(tokenDistribution())}>
388
+ {([role, tokens]) => (<text fg={mutedColor()}> {t(role)}:{formatTokens(tokens)}</text>)}
389
+ </For>
390
+ </Show>
391
+ </box>
392
+ </Show>
393
+ </Show>
361
394
  </box>);
362
395
  }
package/dist/tui.jsx CHANGED
@@ -44,7 +44,10 @@ const tui = async (api) => {
44
44
  else {
45
45
  next = [...prev, msg];
46
46
  }
47
- persistToKv(currentSlotSessionID, next);
47
+ // Bug fix: 优先使用事件自带的 sessionID,而非 currentSlotSessionID
48
+ // currentSlotSessionID 在 slot 首次渲染时才更新,session 切换瞬间可能落后
49
+ const targetSessionID = info.sessionID ?? currentSlotSessionID;
50
+ persistToKv(targetSessionID, next);
48
51
  return next;
49
52
  });
50
53
  }
package/package.json CHANGED
@@ -1,63 +1,63 @@
1
- {
2
- "name": "opencode-tokenwatch",
3
- "version": "0.2.0",
4
- "description": "Real-time token usage, cache analytics & performance dashboard plugin for OpenCode CLI",
5
- "type": "module",
6
- "main": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
12
- },
13
- "./tui": {
14
- "types": "./dist/tui.d.ts",
15
- "import": "./dist/tui.jsx"
16
- },
17
- "./package.json": "./package.json"
18
- },
19
- "files": [
20
- "dist"
21
- ],
22
- "sideEffects": false,
23
- "engines": {
24
- "node": ">=18"
25
- },
26
- "scripts": {
27
- "build": "tsc",
28
- "release:check": "node ./scripts/publish-check.mjs",
29
- "prepublishOnly": "npm run build"
30
- },
31
- "keywords": [
32
- "opencode",
33
- "plugin",
34
- "opencode-plugin",
35
- "tokens",
36
- "usage",
37
- "stats",
38
- "sqlite",
39
- "analytics",
40
- "tui"
41
- ],
42
- "license": "MIT",
43
- "author": "TTWK",
44
- "repository": {
45
- "type": "git",
46
- "url": "git+https://github.com/TTWK/opencode-tokenwatch.git"
47
- },
48
- "bugs": {
49
- "url": "https://github.com/TTWK/opencode-tokenwatch/issues"
50
- },
51
- "homepage": "https://github.com/TTWK/opencode-tokenwatch#readme",
52
- "devDependencies": {
53
- "@opencode-ai/plugin": "latest",
54
- "@opentui/core": "^0.2.9",
55
- "@opentui/keymap": "^0.2.9",
56
- "@opentui/solid": "^0.2.9",
57
- "@types/node": "^22.0.0",
58
- "typescript": "^5.7.0"
59
- },
60
- "publishConfig": {
61
- "access": "public"
62
- }
63
- }
1
+ {
2
+ "name": "opencode-tokenwatch",
3
+ "version": "0.3.0",
4
+ "description": "Real-time token usage, cache analytics & performance dashboard plugin for OpenCode CLI",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./tui": {
14
+ "types": "./dist/tui.d.ts",
15
+ "import": "./dist/tui.jsx"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "sideEffects": false,
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc",
28
+ "release:check": "node ./scripts/publish-check.mjs",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "keywords": [
32
+ "opencode",
33
+ "plugin",
34
+ "opencode-plugin",
35
+ "tokens",
36
+ "usage",
37
+ "stats",
38
+ "sqlite",
39
+ "analytics",
40
+ "tui"
41
+ ],
42
+ "license": "MIT",
43
+ "author": "TTWK",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/TTWK/opencode-tokenwatch.git"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/TTWK/opencode-tokenwatch/issues"
50
+ },
51
+ "homepage": "https://github.com/TTWK/opencode-tokenwatch#readme",
52
+ "devDependencies": {
53
+ "@opencode-ai/plugin": "latest",
54
+ "@opentui/core": "^0.2.9",
55
+ "@opentui/keymap": "^0.2.9",
56
+ "@opentui/solid": "^0.2.9",
57
+ "@types/node": "^22.0.0",
58
+ "typescript": "^5.7.0"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ }
63
+ }
package/dist/commands.js DELETED
@@ -1,208 +0,0 @@
1
- import { getUsageReport } from "./queries.js";
2
- import { formatUsageReport } from "./formatter.js";
3
- import { generateUsageHtml } from "./generate-usage-html.js";
4
- import { t } from "./i18n.js";
5
- import { readLogs } from "./perf-tracker.js";
6
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
- import { join } from "node:path";
8
- import { homedir } from "node:os";
9
- import { execSync } from "node:child_process";
10
- const DEFAULT_CONFIG = {
11
- sidebar: { showPerformance: true, showPricing: true, showTokenDistribution: true, showTrend: true },
12
- language: "auto",
13
- };
14
- export async function registerCommands(api) {
15
- api.command?.register(() => [
16
- {
17
- value: "tokenwatch-html-report",
18
- title: "Generate HTML report",
19
- description: "Generate an HTML dashboard with token usage, cache efficiency, and performance charts",
20
- category: "Stats",
21
- slash: { name: "usage-html", aliases: ["usage"] },
22
- onSelect: async () => {
23
- await showHtmlReport(api);
24
- },
25
- },
26
- {
27
- value: "tokenwatch-json-export",
28
- title: "Export as JSON",
29
- description: "Export usage data as JSON file",
30
- category: "Stats",
31
- slash: { name: "usage-json" },
32
- onSelect: async () => {
33
- await showJsonExport(api);
34
- },
35
- },
36
- {
37
- value: "tokenwatch-text-report",
38
- title: "Text report (legacy)",
39
- description: "View plain text usage report in terminal",
40
- category: "Stats",
41
- slash: { name: "usage-text" },
42
- onSelect: async () => {
43
- await showTextReport(api);
44
- },
45
- },
46
- {
47
- value: "tokenwatch-settings",
48
- title: "TokenWatch Settings",
49
- description: "Configure sidebar display options",
50
- category: "Stats",
51
- slash: { name: "usage-settings", aliases: ["tokenwatch-settings"] },
52
- onSelect: async () => {
53
- await showSettingsDialog(api);
54
- },
55
- },
56
- ]);
57
- }
58
- function ensureReportDir() {
59
- const dir = join(homedir(), ".opencode", "reports");
60
- if (!existsSync(dir))
61
- mkdirSync(dir, { recursive: true });
62
- return dir;
63
- }
64
- function openInBrowser(filePath) {
65
- try {
66
- const platform = process.platform;
67
- if (platform === "win32")
68
- execSync(`start "" "${filePath}"`, { windowsHide: true, timeout: 5000 });
69
- else if (platform === "darwin")
70
- execSync(`open "${filePath}"`, { timeout: 5000 });
71
- else
72
- execSync(`xdg-open "${filePath}"`, { timeout: 5000 });
73
- }
74
- catch { /* silently fail */ }
75
- }
76
- function aggregatePerfStats(logs) {
77
- const map = new Map();
78
- for (const entry of logs) {
79
- const key = entry.model;
80
- let s = map.get(key);
81
- if (!s) {
82
- s = {
83
- model: key,
84
- providerID: entry.providerID,
85
- requestCount: 0,
86
- totalInput: 0, totalOutput: 0, totalCacheRead: 0, totalCacheWrite: 0, totalCost: 0,
87
- avgTTFT: null, maxTTFT: null, minTTFT: null,
88
- avgTPS: null, maxTPS: null, minTPS: null,
89
- avgLatency: null, maxLatency: null, minLatency: null,
90
- };
91
- map.set(key, s);
92
- }
93
- s.requestCount++;
94
- s.totalInput += entry.inputTokens;
95
- s.totalOutput += entry.outputTokens;
96
- s.totalCacheRead += entry.cacheReadTokens;
97
- s.totalCacheWrite += entry.cacheWriteTokens;
98
- s.totalCost += entry.cost;
99
- const c = s.requestCount;
100
- if (entry.ttft_ms != null) {
101
- s.avgTTFT = s.avgTTFT != null ? s.avgTTFT + (entry.ttft_ms - s.avgTTFT) / c : entry.ttft_ms;
102
- s.maxTTFT = s.maxTTFT != null ? Math.max(s.maxTTFT, entry.ttft_ms) : entry.ttft_ms;
103
- s.minTTFT = s.minTTFT != null ? Math.min(s.minTTFT, entry.ttft_ms) : entry.ttft_ms;
104
- }
105
- if (entry.tps != null) {
106
- s.avgTPS = s.avgTPS != null ? s.avgTPS + (entry.tps - s.avgTPS) / c : entry.tps;
107
- s.maxTPS = s.maxTPS != null ? Math.max(s.maxTPS, entry.tps) : entry.tps;
108
- s.minTPS = s.minTPS != null ? Math.min(s.minTPS, entry.tps) : entry.tps;
109
- }
110
- if (entry.latency_ms != null) {
111
- s.avgLatency = s.avgLatency != null ? s.avgLatency + (entry.latency_ms - s.avgLatency) / c : entry.latency_ms;
112
- s.maxLatency = s.maxLatency != null ? Math.max(s.maxLatency, entry.latency_ms) : entry.latency_ms;
113
- s.minLatency = s.minLatency != null ? Math.min(s.minLatency, entry.latency_ms) : entry.latency_ms;
114
- }
115
- }
116
- return Array.from(map.values());
117
- }
118
- async function buildCombinedData(api) {
119
- const report = await getUsageReport({});
120
- const logs = readLogs(1000);
121
- const now = new Date();
122
- const pad = (n) => String(n).padStart(2, '0');
123
- const meta = {
124
- generatedAt: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
125
- dateRange: {
126
- start: report.daily.length > 0 ? report.daily[report.daily.length - 1].day : "—",
127
- end: report.daily.length > 0 ? report.daily[0].day : "—",
128
- },
129
- };
130
- return {
131
- ...report,
132
- perfLogs: logs,
133
- perfSummary: aggregatePerfStats(logs),
134
- meta,
135
- };
136
- }
137
- async function showHtmlReport(api) {
138
- try {
139
- const data = await buildCombinedData(api);
140
- const html = generateUsageHtml(data);
141
- const dir = ensureReportDir();
142
- const dateStr = new Date().toISOString().slice(0, 10);
143
- const filePath = join(dir, `tokenwatch-${dateStr}.html`);
144
- writeFileSync(filePath, html, "utf-8");
145
- api.ui.toast?.({ message: `Report: ${filePath}`, variant: "info" });
146
- openInBrowser(filePath);
147
- }
148
- catch (err) {
149
- const msg = err instanceof Error ? err.message : String(err);
150
- api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
151
- }
152
- }
153
- async function showJsonExport(api) {
154
- try {
155
- const report = await getUsageReport({});
156
- const dir = ensureReportDir();
157
- const dateStr = new Date().toISOString().slice(0, 10);
158
- const filePath = join(dir, `tokenwatch-${dateStr}.json`);
159
- writeFileSync(filePath, JSON.stringify(report, null, 2), "utf-8");
160
- api.ui.toast?.({ message: `JSON: ${filePath}`, variant: "info" });
161
- }
162
- catch (err) {
163
- const msg = err instanceof Error ? err.message : String(err);
164
- api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
165
- }
166
- }
167
- async function showTextReport(api) {
168
- try {
169
- const report = await getUsageReport({});
170
- const formatted = formatUsageReport(report);
171
- const dir = ensureReportDir();
172
- const dateStr = new Date().toISOString().slice(0, 10);
173
- const filePath = join(dir, `tokenwatch-${dateStr}.md`);
174
- writeFileSync(filePath, formatted, "utf-8");
175
- api.ui.toast?.({ message: `Report saved to ${filePath}`, variant: "info" });
176
- }
177
- catch (err) {
178
- const msg = err instanceof Error ? err.message : String(err);
179
- api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
180
- }
181
- }
182
- async function showSettingsDialog(api) {
183
- const currentConfig = loadConfigFromStore(api);
184
- const cfg = currentConfig.sidebar;
185
- const options = [
186
- `[${cfg.showPerformance ? "x" : " "}] ${t("showPerformance")}`,
187
- `[${cfg.showPricing ? "x" : " "}] ${t("showPricing")}`,
188
- `[${cfg.showTokenDistribution ? "x" : " "}] ${t("showTokenDistribution")}`,
189
- `[${cfg.showTrend ? "x" : " "}] ${t("showTrend")}`,
190
- `---`,
191
- `${t("language")}: ${currentConfig.language}`,
192
- ].join("\n");
193
- api.ui.toast?.({ message: `TokenWatch settings:\n${options}`, variant: "info" });
194
- }
195
- function loadConfigFromStore(api) {
196
- const base = { sidebar: { ...DEFAULT_CONFIG.sidebar }, language: DEFAULT_CONFIG.language };
197
- try {
198
- const stored = api.kv?.get?.("tokenwatch-config");
199
- if (stored) {
200
- if (stored.sidebar)
201
- Object.assign(base.sidebar, stored.sidebar);
202
- if (stored.language)
203
- base.language = stored.language;
204
- }
205
- }
206
- catch { /* defaults */ }
207
- return base;
208
- }