token-stats-timer 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/index.ts +102 -0
- package/package.json +20 -0
- package/run-timer.ts +181 -0
- package/token-stats.ts +1848 -0
package/token-stats.ts
ADDED
|
@@ -0,0 +1,1848 @@
|
|
|
1
|
+
// run-token-stats / token-stats 模块
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// 由 @liziy/token-stats 1.3.3 移植而来,与 @carlosgtrz/pi-run-timer 合并为一个插件。
|
|
4
|
+
// Footer 实时显示:run 计时 + 输入/输出/总量 + 缓存命中率 + 输出速率 + 上下文占用
|
|
5
|
+
// + 5h/周 套餐剩余(MiniMax / GLM / Kimi / DeepSeek 内置套餐)
|
|
6
|
+
// 每轮对话自动落 JSONL,/stats 命令按日/小时/周/月查询
|
|
7
|
+
//
|
|
8
|
+
// 配置持久化:~/.pi/agent/extensions/token-stats/(与原包兼容,历史配置直接生效)
|
|
9
|
+
// 日志输出: ~/.pi/agent/extensions/token-stats-logs/(与原包兼容,历史数据直接可用)
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
ExtensionAPI,
|
|
13
|
+
ExtensionContext,
|
|
14
|
+
Theme,
|
|
15
|
+
} from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
17
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
18
|
+
import {
|
|
19
|
+
appendFile,
|
|
20
|
+
mkdir,
|
|
21
|
+
readFile,
|
|
22
|
+
writeFile,
|
|
23
|
+
} from "node:fs/promises";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
|
|
28
|
+
// ── 共享状态(由 index.ts 注入,footer 渲染与模块解耦)──
|
|
29
|
+
|
|
30
|
+
export interface SharedState {
|
|
31
|
+
/** session 存活标志:session_shutdown 置 false,session_start 置 true */
|
|
32
|
+
sessionActive: boolean;
|
|
33
|
+
/** 由 footer 注册的渲染请求函数;footer 销毁或 session 关闭时置 null */
|
|
34
|
+
requestRender: (() => void) | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── 路径 ──────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const LOGS_DIR = join(homedir(), ".pi/agent/extensions/token-stats-logs");
|
|
40
|
+
const RAW_DIR = join(LOGS_DIR, "raw");
|
|
41
|
+
const HOURLY_DIR = join(LOGS_DIR, "hourly");
|
|
42
|
+
const DAILY_FILE = join(LOGS_DIR, "daily", "daily.jsonl");
|
|
43
|
+
|
|
44
|
+
const TOKEN_CONFIG_DIR = join(homedir(), ".pi/agent/extensions/token-stats");
|
|
45
|
+
const TOKEN_CONFIG_FILE = join(TOKEN_CONFIG_DIR, "config.json");
|
|
46
|
+
const QUOTA_CACHE_FILE = join(LOGS_DIR, "quota-cache.json");
|
|
47
|
+
const DISPLAY_CONFIG_FILE = join(TOKEN_CONFIG_DIR, "display-config.json");
|
|
48
|
+
|
|
49
|
+
// ── 常量 ──────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/** Rolling window 时长(毫秒),用于实时速率计算 */
|
|
52
|
+
const LIVE_TOKEN_SPEED_ROLLING_WINDOW_MS = 2000;
|
|
53
|
+
|
|
54
|
+
/** 速率合理范围上限 */
|
|
55
|
+
const MAX_REASONABLE_TOKEN_SPEED = 1000;
|
|
56
|
+
|
|
57
|
+
// ── 类型 ──────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
interface TurnStats {
|
|
60
|
+
input: number;
|
|
61
|
+
output: number;
|
|
62
|
+
cacheRead: number;
|
|
63
|
+
cacheWrite: number;
|
|
64
|
+
tokensPerSec: number;
|
|
65
|
+
cacheHitRate: number;
|
|
66
|
+
model: string;
|
|
67
|
+
firstTokenLatency: number; // 首 token 延迟(毫秒)
|
|
68
|
+
wordCount: number; // 输出词数(中日韩按字 + 其他按词)
|
|
69
|
+
cost: number; // 本轮花费(美元)
|
|
70
|
+
liveTokenSpeed: number | null; // 流式 rolling window 速率
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface RawRecord extends TurnStats {
|
|
74
|
+
ts: string;
|
|
75
|
+
session: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface HourlyRecord {
|
|
79
|
+
date: string;
|
|
80
|
+
hour: number;
|
|
81
|
+
count: number;
|
|
82
|
+
sumInput: number;
|
|
83
|
+
sumOutput: number;
|
|
84
|
+
sumCacheRead: number;
|
|
85
|
+
sumCacheWrite: number;
|
|
86
|
+
sumTokensPerSec: number;
|
|
87
|
+
avgCacheHitRate: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface DailyRecord {
|
|
91
|
+
date: string;
|
|
92
|
+
count: number;
|
|
93
|
+
sumInput: number;
|
|
94
|
+
sumOutput: number;
|
|
95
|
+
sumCacheRead: number;
|
|
96
|
+
sumCacheWrite: number;
|
|
97
|
+
sumTokensPerSec: number;
|
|
98
|
+
avgCacheHitRate: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── 套餐用量类型 ──────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
interface TokenPlan {
|
|
104
|
+
id: string;
|
|
105
|
+
name: string;
|
|
106
|
+
matchProviders: string[];
|
|
107
|
+
apiKeyEnv: string;
|
|
108
|
+
baseUrl: string;
|
|
109
|
+
quotaPath: string;
|
|
110
|
+
authHeader: (key: string) => Record<string, string>;
|
|
111
|
+
fetchQuota: (plan: TokenPlan, key: string) => Promise<any>;
|
|
112
|
+
format: (data: any) => { modelPrefix: string; display: string; color: 'ok' | 'warn' | 'err' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface TokenConfig {
|
|
116
|
+
providerPlans: Record<string, string | null>;
|
|
117
|
+
ttl: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface QuotaCache {
|
|
121
|
+
[planId: string]: {
|
|
122
|
+
fetchedAt: number;
|
|
123
|
+
ttl: number;
|
|
124
|
+
data: any;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type ContextStyle = "pct-window" | "used-window" | "pct" | "used" | "bar";
|
|
129
|
+
export type SpeedStyle = "t/s" | "tok/s" | "T/s" | "liveAt";
|
|
130
|
+
|
|
131
|
+
export type DisplayKey =
|
|
132
|
+
| "input" // 输入(累计输入数 ↑)
|
|
133
|
+
| "output" // 输出(累计输出数 ↓)
|
|
134
|
+
| "totalTokens" // 总token(累计输入+输出)
|
|
135
|
+
| "cacheHit" // 缓存命中率
|
|
136
|
+
| "speed" // 速度(tok/s)
|
|
137
|
+
| "context" // 容量(ctx%)
|
|
138
|
+
| "quota5h" // 5h 额度
|
|
139
|
+
| "quotaWeek" // 周额度
|
|
140
|
+
| "quotaClock" // 刷新时间(⏱)
|
|
141
|
+
| "timer"; // run 计时(⏱ run/prev/max)
|
|
142
|
+
|
|
143
|
+
export interface DisplayConfig {
|
|
144
|
+
items: Record<DisplayKey, boolean>;
|
|
145
|
+
contextStyle: ContextStyle;
|
|
146
|
+
speedStyle: SpeedStyle;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── 状态 ──────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
interface LiveTokenSample {
|
|
152
|
+
timestampMs: number;
|
|
153
|
+
tokens: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── 套餐用量状态 ─────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
interface QuotaDisplayState {
|
|
159
|
+
planId: string;
|
|
160
|
+
display: string;
|
|
161
|
+
modelPrefix: string;
|
|
162
|
+
color: "ok" | "warn" | "err" | "muted";
|
|
163
|
+
/** 该 state 对应的 provider;与当前 ctx.model.provider 不一致时视为残留 */
|
|
164
|
+
provider: string;
|
|
165
|
+
/** 数据获取时间戳;用于调试与新陈度判断 */
|
|
166
|
+
fetchedAt: number;
|
|
167
|
+
/** 错误时携带具体原因(key 缺失 / API 错误 / 网络错误 / 无数据) */
|
|
168
|
+
error?: QuotaError;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type QuotaError =
|
|
172
|
+
| { kind: "no_plan" }
|
|
173
|
+
| { kind: "key_missing"; envVar: string; provider: string }
|
|
174
|
+
| { kind: "api_error"; message: string }
|
|
175
|
+
| { kind: "network_error"; message: string }
|
|
176
|
+
| { kind: "no_data" };
|
|
177
|
+
|
|
178
|
+
// ── 工具函数 ──────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Token 格式化(对齐 @firstpick/pi-utils formatTokens)
|
|
182
|
+
*/
|
|
183
|
+
function formatTokens(count: number): string {
|
|
184
|
+
if (count < 1000) return count.toString();
|
|
185
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
186
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
187
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
188
|
+
return `${Math.round(count / 1000000)}M`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function formatTokenSpeed(tokensPerSecond: number): string {
|
|
192
|
+
if (tokensPerSecond < 100) {
|
|
193
|
+
if (tokensPerSecond >= 10) return tokensPerSecond.toFixed(1);
|
|
194
|
+
return tokensPerSecond.toFixed(2);
|
|
195
|
+
}
|
|
196
|
+
if (tokensPerSecond < 1000) return Math.round(tokensPerSecond).toString();
|
|
197
|
+
if (tokensPerSecond < 10000) return `${(tokensPerSecond / 1000).toFixed(1)}k`;
|
|
198
|
+
if (tokensPerSecond < 1000000) return `${Math.round(tokensPerSecond / 1000)}k`;
|
|
199
|
+
if (tokensPerSecond < 10000000) return `${(tokensPerSecond / 1000000).toFixed(1)}M`;
|
|
200
|
+
return `${Math.round(tokensPerSecond / 1000000)}M`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isReasonableTokenSpeed(tokensPerSecond: number): boolean {
|
|
204
|
+
return Number.isFinite(tokensPerSecond) && tokensPerSecond > 0 && tokensPerSecond <= MAX_REASONABLE_TOKEN_SPEED;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function estimateTokens(textLen: number): number {
|
|
208
|
+
return Math.round(textLen / 4);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 提取消息中的纯文本(含 thinking)
|
|
213
|
+
*/
|
|
214
|
+
function extractTextContent(content: unknown): string {
|
|
215
|
+
if (!Array.isArray(content)) return "";
|
|
216
|
+
let text = "";
|
|
217
|
+
for (const block of content) {
|
|
218
|
+
const b = block as any;
|
|
219
|
+
if (b?.type === "text" && typeof b.text === "string") {
|
|
220
|
+
text += b.text;
|
|
221
|
+
} else if (b?.type === "thinking" && typeof b.thinking === "string") {
|
|
222
|
+
text += b.thinking;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return text;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 词数统计(CJK 按字 + 其他按词)
|
|
230
|
+
* 参考 ChatBox 的 countWord 实现
|
|
231
|
+
*/
|
|
232
|
+
function countWords(text: string): number {
|
|
233
|
+
if (!text) return 0;
|
|
234
|
+
const pattern =
|
|
235
|
+
/[a-zA-Z0-9_\u0392-\u03c9\u00c0-\u00ff\u0600-\u06ff\u0400-\u04ff]+|[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3040-\u309f\uac00-\ud7af]+/g;
|
|
236
|
+
const m = text.match(pattern);
|
|
237
|
+
if (!m) return 0;
|
|
238
|
+
let count = 0;
|
|
239
|
+
for (let i = 0; i < m.length; i++) {
|
|
240
|
+
if (m[i].charCodeAt(0) >= 0x4e00) {
|
|
241
|
+
count += m[i].length;
|
|
242
|
+
} else {
|
|
243
|
+
count += 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return count;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getDateStr(ts = Date.now()): string {
|
|
250
|
+
return new Date(ts).toISOString().slice(0, 10);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function getHour(ts = Date.now()): number {
|
|
254
|
+
return new Date(ts).getHours();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function getISO(ts = Date.now()): string {
|
|
258
|
+
return new Date(ts).toISOString();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function formatUserPath(cwd: string): string {
|
|
262
|
+
const home = homedir();
|
|
263
|
+
return cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── 内置套餐定义 ─────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
function formatDuration(ms: number): string {
|
|
269
|
+
if (ms <= 0) return "";
|
|
270
|
+
if (ms >= 24 * 60 * 60 * 1000) {
|
|
271
|
+
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
|
|
272
|
+
const hours = Math.floor((ms % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
|
|
273
|
+
if (days >= 7) return `${Math.floor(days / 7)}w ${days % 7}d`;
|
|
274
|
+
return `${days}d ${hours}h`;
|
|
275
|
+
}
|
|
276
|
+
const hours = Math.floor(ms / (60 * 60 * 1000));
|
|
277
|
+
const mins = Math.floor((ms % (60 * 60 * 1000)) / (60 * 1000));
|
|
278
|
+
if (hours > 0) return `${hours}h ${mins}m`;
|
|
279
|
+
return `${mins}m`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function formatTokenPlanDisplay(intervalRemaining: number, weeklyRemaining: number, nearestResetMs?: number | null): string {
|
|
283
|
+
let display = `5h: ${Math.round(intervalRemaining)}% W: ${Math.round(weeklyRemaining)}%`;
|
|
284
|
+
if (nearestResetMs && nearestResetMs > 0) {
|
|
285
|
+
const diff = nearestResetMs - Date.now();
|
|
286
|
+
if (diff > 0 && diff < 30 * 24 * 60 * 60 * 1000) {
|
|
287
|
+
display += ` ⏱ ${formatDuration(diff)}`;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return display;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const BUILTIN_PLANS: TokenPlan[] = [
|
|
294
|
+
{
|
|
295
|
+
id: "minimax",
|
|
296
|
+
name: "MiniMax",
|
|
297
|
+
matchProviders: ["minimax_local", "minimax-cn", "minimax"],
|
|
298
|
+
apiKeyEnv: "MINIMAX_API_KEY",
|
|
299
|
+
baseUrl: "https://api.minimaxi.com",
|
|
300
|
+
quotaPath: "/v1/api/openplatform/coding_plan/remains",
|
|
301
|
+
authHeader: (key) => ({ Authorization: "Bearer " + key }),
|
|
302
|
+
fetchQuota: async (plan: TokenPlan, key: string) => {
|
|
303
|
+
const url = "https://api.minimaxi.com" + plan.quotaPath;
|
|
304
|
+
const r = await fetch(url, {
|
|
305
|
+
method: "GET",
|
|
306
|
+
headers: { Authorization: "Bearer " + key, "Content-Type": "application/json" },
|
|
307
|
+
signal: AbortSignal.timeout(5000),
|
|
308
|
+
});
|
|
309
|
+
const data = await r.json();
|
|
310
|
+
if (data.base_resp?.status_code === 0) return data;
|
|
311
|
+
throw new Error(data.base_resp?.status_msg || "MiniMax 返回错误");
|
|
312
|
+
},
|
|
313
|
+
format: (data: any) => {
|
|
314
|
+
const models = data.model_remains || [];
|
|
315
|
+
// MiniMax 官方接口 2026-07 起 model_name 改为 general / video 等语义化命名,
|
|
316
|
+
// 不再是 MiniMax-M2 / MiniMax-M3。
|
|
317
|
+
// 优先取 "general"(通用文本/编码套餐),否则取第一项
|
|
318
|
+
const m =
|
|
319
|
+
models.find((x: any) => x.model_name === "general") ||
|
|
320
|
+
models.find((x: any) => x.model_name?.includes("M2")) ||
|
|
321
|
+
models[0];
|
|
322
|
+
if (!m) return { modelPrefix: "", display: "无数据", color: "err" as const };
|
|
323
|
+
const intervalRemaining = m.current_interval_remaining_percent ?? 0;
|
|
324
|
+
const weeklyRemaining = m.current_weekly_remaining_percent ?? 0;
|
|
325
|
+
const now = Date.now();
|
|
326
|
+
const resets = [m.end_time, m.weekly_end_time].filter((t: any) => typeof t === "number" && t > now);
|
|
327
|
+
const nearestReset = resets.length > 0 ? Math.min(...resets) : null;
|
|
328
|
+
return {
|
|
329
|
+
modelPrefix: "",
|
|
330
|
+
display: formatTokenPlanDisplay(intervalRemaining, weeklyRemaining, nearestReset),
|
|
331
|
+
color: intervalRemaining < 20 || weeklyRemaining < 20 ? "err" as const : intervalRemaining < 50 || weeklyRemaining < 50 ? "warn" as const : "ok" as const,
|
|
332
|
+
};
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: "glm",
|
|
337
|
+
name: "GLM (智谱)",
|
|
338
|
+
matchProviders: ["zhipu-cn", "zhipu", "glm", "bigmodel"],
|
|
339
|
+
apiKeyEnv: "GLM_API_KEY",
|
|
340
|
+
baseUrl: "https://open.bigmodel.cn",
|
|
341
|
+
quotaPath: "/api/monitor/usage/quota/limit",
|
|
342
|
+
authHeader: (key) => ({ Authorization: key }),
|
|
343
|
+
fetchQuota: async (plan: TokenPlan, key: string) => {
|
|
344
|
+
const r = await fetch(plan.baseUrl + plan.quotaPath, {
|
|
345
|
+
method: "GET",
|
|
346
|
+
headers: { ...plan.authHeader(key), "Content-Type": "application/json" },
|
|
347
|
+
signal: AbortSignal.timeout(5000),
|
|
348
|
+
});
|
|
349
|
+
if (!r.ok) throw new Error("GLM 配额查询 HTTP " + r.status);
|
|
350
|
+
return await r.json();
|
|
351
|
+
},
|
|
352
|
+
format: (data: any) => {
|
|
353
|
+
const limits = data?.data?.limits || [];
|
|
354
|
+
const tokenLimits = limits.filter((x: any) => (x.type || "").toLowerCase() === "tokens_limit");
|
|
355
|
+
if (tokenLimits.length === 0) return { modelPrefix: "", display: "无数据", color: "err" as const };
|
|
356
|
+
let fiveHour = tokenLimits[0];
|
|
357
|
+
let weekly = tokenLimits[1];
|
|
358
|
+
if (fiveHour?.unit === 6) [fiveHour, weekly] = [weekly, fiveHour];
|
|
359
|
+
const intervalRemaining = 100 - (fiveHour?.percentage ?? 0);
|
|
360
|
+
const weeklyRemaining = 100 - (weekly?.percentage ?? 0);
|
|
361
|
+
const now = Date.now();
|
|
362
|
+
const resets = tokenLimits
|
|
363
|
+
.map((x: any) => x.nextResetTime)
|
|
364
|
+
.filter((t: any) => typeof t === "number" && t > now);
|
|
365
|
+
const nearestReset = resets.length > 0 ? Math.min(...resets) : null;
|
|
366
|
+
return {
|
|
367
|
+
modelPrefix: "",
|
|
368
|
+
display: formatTokenPlanDisplay(intervalRemaining, weeklyRemaining, nearestReset),
|
|
369
|
+
color: intervalRemaining < 20 || weeklyRemaining < 20 ? "err" as const : intervalRemaining < 50 || weeklyRemaining < 50 ? "warn" as const : "ok" as const,
|
|
370
|
+
};
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
id: "kimi",
|
|
375
|
+
name: "Kimi",
|
|
376
|
+
matchProviders: ["moonshot-cn", "moonshot", "kimi"],
|
|
377
|
+
apiKeyEnv: "MOONSHOT_API_KEY",
|
|
378
|
+
baseUrl: "https://api.kimi.com",
|
|
379
|
+
quotaPath: "/coding/v1/usages",
|
|
380
|
+
authHeader: (key) => ({ Authorization: "Bearer " + key }),
|
|
381
|
+
fetchQuota: async (plan: TokenPlan, key: string) => {
|
|
382
|
+
const r = await fetch(plan.baseUrl + plan.quotaPath, {
|
|
383
|
+
method: "GET",
|
|
384
|
+
headers: { Authorization: "Bearer " + key, "Content-Type": "application/json" },
|
|
385
|
+
signal: AbortSignal.timeout(5000),
|
|
386
|
+
});
|
|
387
|
+
if (!r.ok) throw new Error("Kimi 配额查询 HTTP " + r.status);
|
|
388
|
+
return await r.json();
|
|
389
|
+
},
|
|
390
|
+
format: (data: any) => {
|
|
391
|
+
const limits = data.limits || [];
|
|
392
|
+
let intervalRemaining = 100;
|
|
393
|
+
let nearestReset: number | null = null;
|
|
394
|
+
if (limits.length > 0) {
|
|
395
|
+
const d = limits[0].detail || {};
|
|
396
|
+
const limit = d.limit || 1;
|
|
397
|
+
const remaining = Math.max(d.remaining ?? 0, 0);
|
|
398
|
+
intervalRemaining = (remaining / limit) * 100;
|
|
399
|
+
const rt = d.resetTime;
|
|
400
|
+
if (rt) {
|
|
401
|
+
const ms = typeof rt === "string" ? new Date(rt).getTime() : rt;
|
|
402
|
+
if (ms > Date.now()) nearestReset = ms;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const usage = data.usage || {};
|
|
406
|
+
let weeklyRemaining = 100;
|
|
407
|
+
if (usage.limit) {
|
|
408
|
+
const remaining = Math.max(usage.remaining ?? 0, 0);
|
|
409
|
+
weeklyRemaining = (remaining / usage.limit) * 100;
|
|
410
|
+
const rt = usage.resetTime;
|
|
411
|
+
if (rt) {
|
|
412
|
+
const ms = typeof rt === "string" ? new Date(rt).getTime() : rt;
|
|
413
|
+
if (nearestReset === null || ms < nearestReset) nearestReset = ms;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (intervalRemaining >= 100 && weeklyRemaining >= 100) return { modelPrefix: "", display: "无数据", color: "err" as const };
|
|
417
|
+
return {
|
|
418
|
+
modelPrefix: "",
|
|
419
|
+
display: formatTokenPlanDisplay(intervalRemaining, weeklyRemaining, nearestReset),
|
|
420
|
+
color: intervalRemaining < 20 || weeklyRemaining < 20 ? "err" as const : intervalRemaining < 50 || weeklyRemaining < 50 ? "warn" as const : "ok" as const,
|
|
421
|
+
};
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
id: "deepseek",
|
|
426
|
+
name: "DeepSeek",
|
|
427
|
+
matchProviders: ["deepseek-cn", "deepseek"],
|
|
428
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
429
|
+
baseUrl: "https://api.deepseek.com",
|
|
430
|
+
quotaPath: "/user/balance",
|
|
431
|
+
authHeader: (key) => ({ Authorization: "Bearer " + key }),
|
|
432
|
+
fetchQuota: async (plan: TokenPlan, key: string) => {
|
|
433
|
+
const r = await fetch(plan.baseUrl + plan.quotaPath, {
|
|
434
|
+
method: "GET",
|
|
435
|
+
headers: { Authorization: "Bearer " + key, "Content-Type": "application/json" },
|
|
436
|
+
signal: AbortSignal.timeout(5000),
|
|
437
|
+
});
|
|
438
|
+
if (!r.ok) throw new Error("DeepSeek 配额查询 HTTP " + r.status);
|
|
439
|
+
return await r.json();
|
|
440
|
+
},
|
|
441
|
+
format: (data: any) => {
|
|
442
|
+
const infos = data?.balance_infos || [];
|
|
443
|
+
const cny = infos.find((x: any) => x.currency === "CNY") || infos[0];
|
|
444
|
+
if (!cny) return { modelPrefix: "", display: "无数据", color: "err" as const };
|
|
445
|
+
const total = parseFloat(cny.total_balance || "0");
|
|
446
|
+
return {
|
|
447
|
+
modelPrefix: "",
|
|
448
|
+
display: "¥" + total.toFixed(1),
|
|
449
|
+
color: total < 1 ? "warn" as const : "ok" as const,
|
|
450
|
+
};
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
const DEFAULT_TOKEN_CONFIG: TokenConfig = { providerPlans: {}, ttl: 60 };
|
|
456
|
+
|
|
457
|
+
const DEFAULT_DISPLAY_CONFIG: DisplayConfig = {
|
|
458
|
+
items: {
|
|
459
|
+
input: true,
|
|
460
|
+
output: true,
|
|
461
|
+
totalTokens: false,
|
|
462
|
+
cacheHit: true,
|
|
463
|
+
speed: true,
|
|
464
|
+
context: true,
|
|
465
|
+
quota5h: true,
|
|
466
|
+
quotaWeek: true,
|
|
467
|
+
quotaClock: true,
|
|
468
|
+
timer: true,
|
|
469
|
+
},
|
|
470
|
+
contextStyle: "pct-window",
|
|
471
|
+
speedStyle: "t/s",
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// ── 模块入口 ─────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
export interface TokenStatsHandle {
|
|
477
|
+
/** footer 上行指标段(不含 run 计时;计时由 index.ts 拼接) */
|
|
478
|
+
getMetricParts(theme: Theme, ctx: ExtensionContext): string[];
|
|
479
|
+
/** 是否显示 run 计时段 */
|
|
480
|
+
isTimerEnabled(): boolean;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function createTokenStats(
|
|
484
|
+
pi: ExtensionAPI,
|
|
485
|
+
shared: SharedState,
|
|
486
|
+
): TokenStatsHandle {
|
|
487
|
+
// ── 累计统计状态 ────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
const stats = {
|
|
490
|
+
// 累计会话
|
|
491
|
+
totalInput: 0,
|
|
492
|
+
totalOutput: 0,
|
|
493
|
+
totalCacheRead: 0,
|
|
494
|
+
totalCacheWrite: 0,
|
|
495
|
+
totalCost: 0,
|
|
496
|
+
turnCount: 0,
|
|
497
|
+
// 本轮计时
|
|
498
|
+
turnStartTime: 0,
|
|
499
|
+
firstTokenTime: 0,
|
|
500
|
+
streaming: false,
|
|
501
|
+
// 缓存命中率累加(用于平均值)
|
|
502
|
+
totalCacheHitRateSum: 0,
|
|
503
|
+
// 本轮最终(message_end 时写入)
|
|
504
|
+
lastInput: 0,
|
|
505
|
+
lastOutput: 0,
|
|
506
|
+
lastCacheRead: 0,
|
|
507
|
+
lastCacheWrite: 0,
|
|
508
|
+
lastCost: 0,
|
|
509
|
+
lastCacheHitRate: 0,
|
|
510
|
+
lastTokensPerSec: 0, // 平均速率(output / elapsed)
|
|
511
|
+
lastLiveTokenSpeed: null as number | null, // rolling window 速率
|
|
512
|
+
lastFirstTokenLatency: 0, // 首 token 延迟(毫秒)
|
|
513
|
+
lastWordCount: 0, // 输出词数
|
|
514
|
+
// ── 流式 rolling window 状态 ─────────────────────────
|
|
515
|
+
liveOutputChars: 0,
|
|
516
|
+
liveEstimatedTokens: 0,
|
|
517
|
+
liveUsageOutputTokens: 0,
|
|
518
|
+
liveTokenSamples: [] as LiveTokenSample[],
|
|
519
|
+
// ── 去重(防止 message_end + turn_end 重复累加)───
|
|
520
|
+
accountedUsageKeys: new Set<string>(),
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
// ── 套餐用量状态 ────────────────────────────────────
|
|
524
|
+
|
|
525
|
+
let quotaState: QuotaDisplayState | null = null;
|
|
526
|
+
let quotaTimerId: ReturnType<typeof setInterval> | null = null;
|
|
527
|
+
let tokenConfig: TokenConfig | null = null;
|
|
528
|
+
let lastQuotaProvider: string | null = null;
|
|
529
|
+
|
|
530
|
+
let displayConfig: DisplayConfig = {
|
|
531
|
+
...DEFAULT_DISPLAY_CONFIG,
|
|
532
|
+
items: { ...DEFAULT_DISPLAY_CONFIG.items },
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
// ── UI 刷新 ──────────────────────────────────────────
|
|
536
|
+
|
|
537
|
+
/** 等宽进度条:██░░░░░░ 25% */
|
|
538
|
+
function progressBar(pct: number, width = 8): string {
|
|
539
|
+
const filled = Math.round(Math.min(pct, 100) / 100 * width);
|
|
540
|
+
return `[${"█".repeat(filled)}${"░".repeat(width - filled)}]`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function getRollingLiveTokenSpeed(nowMs: number = Date.now()): number | null {
|
|
544
|
+
const cutoffMs = nowMs - LIVE_TOKEN_SPEED_ROLLING_WINDOW_MS;
|
|
545
|
+
stats.liveTokenSamples = stats.liveTokenSamples.filter(
|
|
546
|
+
(s) => s.timestampMs >= cutoffMs,
|
|
547
|
+
);
|
|
548
|
+
if (stats.liveTokenSamples.length === 0) return null;
|
|
549
|
+
|
|
550
|
+
const firstSampleMs = stats.liveTokenSamples[0]!.timestampMs;
|
|
551
|
+
const windowStartMs = Math.max(stats.turnStartTime || firstSampleMs, cutoffMs);
|
|
552
|
+
const elapsedSeconds = (nowMs - windowStartMs) / 1000;
|
|
553
|
+
if (elapsedSeconds <= 0) return null;
|
|
554
|
+
|
|
555
|
+
const tokens = stats.liveTokenSamples.reduce((sum, s) => sum + s.tokens, 0);
|
|
556
|
+
const speed = tokens / elapsedSeconds;
|
|
557
|
+
return isReasonableTokenSpeed(speed) ? speed : null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function resetLiveState() {
|
|
561
|
+
stats.liveOutputChars = 0;
|
|
562
|
+
stats.liveEstimatedTokens = 0;
|
|
563
|
+
stats.liveUsageOutputTokens = 0;
|
|
564
|
+
stats.liveTokenSamples = [];
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function getMetricParts(theme: Theme, ctx: ExtensionContext): string[] {
|
|
568
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
569
|
+
const warn = (s: string) => theme.fg("warning", s);
|
|
570
|
+
const ok = (s: string) => theme.fg("success", s);
|
|
571
|
+
const muted = (s: string) => theme.fg("muted", s);
|
|
572
|
+
|
|
573
|
+
const parts: string[] = [];
|
|
574
|
+
const cfg = displayConfig.items;
|
|
575
|
+
|
|
576
|
+
// ── 输入 / 输出 / 总token / 缓存命中 ──────────────
|
|
577
|
+
{
|
|
578
|
+
const segParts: string[] = [];
|
|
579
|
+
if (cfg.input) segParts.push(`↑${formatTokens(stats.totalInput)}`);
|
|
580
|
+
if (cfg.output) segParts.push(`↓${formatTokens(stats.totalOutput)}`);
|
|
581
|
+
if (cfg.totalTokens) {
|
|
582
|
+
const total = stats.totalInput + stats.totalOutput;
|
|
583
|
+
segParts.push(`Σ${formatTokens(total)}`);
|
|
584
|
+
}
|
|
585
|
+
if (cfg.cacheHit) {
|
|
586
|
+
const totalPrompt = stats.totalInput + stats.totalCacheRead + stats.totalCacheWrite;
|
|
587
|
+
const cumCH = totalPrompt > 0 ? (stats.totalCacheRead / totalPrompt) * 100 : 0;
|
|
588
|
+
const chColor = cumCH >= 80 ? ok
|
|
589
|
+
: cumCH >= 50 ? (s: string) => s
|
|
590
|
+
: warn;
|
|
591
|
+
segParts.push(`${dim("CH")}${chColor(`${cumCH.toFixed(0)}%`)}`);
|
|
592
|
+
}
|
|
593
|
+
if (segParts.length > 0) parts.push(segParts.join(" "));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ── 速度 ⚡ ─────────────────────────────────────────
|
|
597
|
+
if (cfg.speed) {
|
|
598
|
+
const liveSpeed = getRollingLiveTokenSpeed();
|
|
599
|
+
const displaySpeed = liveSpeed !== null ? liveSpeed : stats.lastTokensPerSec;
|
|
600
|
+
const speedNum = ok(formatTokenSpeed(displaySpeed));
|
|
601
|
+
const speedStyle = displayConfig.speedStyle ?? "t/s";
|
|
602
|
+
switch (speedStyle) {
|
|
603
|
+
case "tok/s":
|
|
604
|
+
parts.push(`⚡${speedNum} tok/s`);
|
|
605
|
+
break;
|
|
606
|
+
case "T/s":
|
|
607
|
+
parts.push(`⚡${speedNum} T/s`);
|
|
608
|
+
break;
|
|
609
|
+
case "liveAt":
|
|
610
|
+
if (stats.streaming && liveSpeed !== null) {
|
|
611
|
+
parts.push(`⚡${formatTokens(stats.liveEstimatedTokens)}@${speedNum}`);
|
|
612
|
+
} else {
|
|
613
|
+
parts.push(`⚡${speedNum} t/s`);
|
|
614
|
+
}
|
|
615
|
+
break;
|
|
616
|
+
default:
|
|
617
|
+
parts.push(`⚡${speedNum} t/s`);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ── 容量 ────────────────────────────────────────────
|
|
623
|
+
if (cfg.context) {
|
|
624
|
+
try {
|
|
625
|
+
const cu = ctx.getContextUsage();
|
|
626
|
+
const ctxWindow = cu?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
627
|
+
const ctxPercent = typeof cu?.percent === "number" ? cu.percent : null;
|
|
628
|
+
const ctxUsed = ctxPercent !== null && ctxWindow > 0 ? Math.round(ctxWindow * ctxPercent / 100) : 0;
|
|
629
|
+
const ctxStyle = displayConfig.contextStyle ?? "pct-window";
|
|
630
|
+
let ctxStr: string;
|
|
631
|
+
if (ctxWindow > 0 && ctxPercent !== null) {
|
|
632
|
+
switch (ctxStyle) {
|
|
633
|
+
case "used-window":
|
|
634
|
+
ctxStr = `${formatTokens(ctxUsed)}/${formatTokens(ctxWindow)}`;
|
|
635
|
+
break;
|
|
636
|
+
case "pct":
|
|
637
|
+
ctxStr = `${ctxPercent.toFixed(1)}%`;
|
|
638
|
+
break;
|
|
639
|
+
case "used":
|
|
640
|
+
ctxStr = formatTokens(ctxUsed);
|
|
641
|
+
break;
|
|
642
|
+
case "bar":
|
|
643
|
+
ctxStr = `${progressBar(ctxPercent)} ${ctxPercent.toFixed(1)}%`;
|
|
644
|
+
break;
|
|
645
|
+
default:
|
|
646
|
+
ctxStr = `${ctxPercent.toFixed(1)}%/${formatTokens(ctxWindow)}`;
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
} else {
|
|
650
|
+
ctxStr = ctxWindow > 0 ? `?/${formatTokens(ctxWindow)}` : `0%/0`;
|
|
651
|
+
}
|
|
652
|
+
const ctxColor = ctxPercent !== null && ctxWindow > 0
|
|
653
|
+
? ctxPercent < 50 ? ok
|
|
654
|
+
: ctxPercent < 65 ? (s: string) => theme.fg("accent", s)
|
|
655
|
+
: ctxPercent < 75 ? muted
|
|
656
|
+
: ctxPercent < 85 ? warn
|
|
657
|
+
: (s: string) => theme.fg("error", s)
|
|
658
|
+
: dim;
|
|
659
|
+
parts.push(ctxColor(ctxStr));
|
|
660
|
+
} catch { /* ignore */ }
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ── 套餐用量(最右侧):检测 provider 变化,自动隐藏/刷新 ─
|
|
664
|
+
const curProvider = ctx.model?.provider ?? null;
|
|
665
|
+
if (curProvider !== lastQuotaProvider) {
|
|
666
|
+
// 跨 provider 切换:force refresh(绕过缓存)
|
|
667
|
+
if (lastQuotaProvider !== null || curProvider !== null) {
|
|
668
|
+
setTimeout(() => {
|
|
669
|
+
if (!shared.sessionActive) return;
|
|
670
|
+
refreshQuota(ctx, true)
|
|
671
|
+
.then(() => shared.requestRender?.())
|
|
672
|
+
.catch(() => { /* ctx 已失效(session 被替换),忽略 */ });
|
|
673
|
+
}, 0);
|
|
674
|
+
}
|
|
675
|
+
lastQuotaProvider = curProvider;
|
|
676
|
+
}
|
|
677
|
+
if (quotaState && quotaState.display) {
|
|
678
|
+
const qColor = quotaState.color === "ok" ? ok
|
|
679
|
+
: quotaState.color === "warn" ? warn
|
|
680
|
+
: quotaState.color === "err" ? (s: string) => theme.fg("error", s)
|
|
681
|
+
: muted;
|
|
682
|
+
const prefix = quotaState.modelPrefix ? quotaState.modelPrefix + " " : "";
|
|
683
|
+
|
|
684
|
+
// error 状态(如 no_plan / key_missing)也显示具体原因,不再静默消失
|
|
685
|
+
if (quotaState.error) {
|
|
686
|
+
parts.push(qColor(prefix + quotaState.display));
|
|
687
|
+
} else {
|
|
688
|
+
// 正常状态:按子项过滤配额显示
|
|
689
|
+
const fullDisplay = quotaState.display;
|
|
690
|
+
const filteredParts: string[] = [];
|
|
691
|
+
if (cfg.quota5h) {
|
|
692
|
+
const m = fullDisplay.match(/\b5h:\s+\d+%/);
|
|
693
|
+
if (m) filteredParts.push(m[0]);
|
|
694
|
+
}
|
|
695
|
+
if (cfg.quotaWeek) {
|
|
696
|
+
const m = fullDisplay.match(/\bW:\s+\d+%/);
|
|
697
|
+
if (m) filteredParts.push(m[0]);
|
|
698
|
+
}
|
|
699
|
+
if (cfg.quotaClock) {
|
|
700
|
+
const m = fullDisplay.match(/⏱\s*\d+[hm]/);
|
|
701
|
+
if (m) filteredParts.push(m[0]);
|
|
702
|
+
}
|
|
703
|
+
if (filteredParts.length > 0) {
|
|
704
|
+
parts.push(qColor(prefix + filteredParts.join(" ")));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return parts;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── 日志持久化 ───────────────────────────────────────
|
|
713
|
+
|
|
714
|
+
async function ensureDir(dir: string) {
|
|
715
|
+
await mkdir(dir, { recursive: true });
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function appendRaw(record: RawRecord) {
|
|
719
|
+
await ensureDir(RAW_DIR);
|
|
720
|
+
const file = join(RAW_DIR, `${record.ts.slice(0, 10)}.jsonl`);
|
|
721
|
+
await appendFile(file, JSON.stringify(record) + "\n", "utf-8");
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function updateHourly(record: RawRecord) {
|
|
725
|
+
await ensureDir(HOURLY_DIR);
|
|
726
|
+
const date = record.ts.slice(0, 10);
|
|
727
|
+
const hour = new Date(record.ts).getHours();
|
|
728
|
+
const file = join(HOURLY_DIR, `${date}.jsonl`);
|
|
729
|
+
|
|
730
|
+
let lines: string[] = [];
|
|
731
|
+
try {
|
|
732
|
+
lines = (await readFile(file, "utf-8")).trim().split("\n").filter(Boolean);
|
|
733
|
+
} catch {
|
|
734
|
+
// 文件不存在
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const records: HourlyRecord[] = lines.map((l) => JSON.parse(l));
|
|
738
|
+
const idx = records.findIndex(
|
|
739
|
+
(r) => r.date === date && r.hour === hour,
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
if (idx >= 0) {
|
|
743
|
+
const r = records[idx];
|
|
744
|
+
const newCount = r.count + 1;
|
|
745
|
+
records[idx] = {
|
|
746
|
+
date,
|
|
747
|
+
hour,
|
|
748
|
+
count: newCount,
|
|
749
|
+
sumInput: r.sumInput + record.input,
|
|
750
|
+
sumOutput: r.sumOutput + record.output,
|
|
751
|
+
sumCacheRead: r.sumCacheRead + record.cacheRead,
|
|
752
|
+
sumCacheWrite: r.sumCacheWrite + record.cacheWrite,
|
|
753
|
+
sumTokensPerSec: r.sumTokensPerSec + record.tokensPerSec,
|
|
754
|
+
avgCacheHitRate:
|
|
755
|
+
((r.avgCacheHitRate * r.count + record.cacheHitRate) / newCount),
|
|
756
|
+
};
|
|
757
|
+
} else {
|
|
758
|
+
records.push({
|
|
759
|
+
date,
|
|
760
|
+
hour,
|
|
761
|
+
count: 1,
|
|
762
|
+
sumInput: record.input,
|
|
763
|
+
sumOutput: record.output,
|
|
764
|
+
sumCacheRead: record.cacheRead,
|
|
765
|
+
sumCacheWrite: record.cacheWrite,
|
|
766
|
+
sumTokensPerSec: record.tokensPerSec,
|
|
767
|
+
avgCacheHitRate: record.cacheHitRate,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
await writeFile(
|
|
772
|
+
file,
|
|
773
|
+
records.map((r) => JSON.stringify(r)).join("\n") + "\n",
|
|
774
|
+
"utf-8",
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function updateDaily(record: RawRecord) {
|
|
779
|
+
await ensureDir(join(LOGS_DIR, "daily"));
|
|
780
|
+
const date = record.ts.slice(0, 10);
|
|
781
|
+
|
|
782
|
+
let lines: string[] = [];
|
|
783
|
+
try {
|
|
784
|
+
lines = (await readFile(DAILY_FILE, "utf-8")).trim().split("\n")
|
|
785
|
+
.filter(Boolean);
|
|
786
|
+
} catch {
|
|
787
|
+
// 文件不存在
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const records: DailyRecord[] = lines.map((l) => JSON.parse(l));
|
|
791
|
+
const idx = records.findIndex((r) => r.date === date);
|
|
792
|
+
|
|
793
|
+
if (idx >= 0) {
|
|
794
|
+
const r = records[idx];
|
|
795
|
+
const newCount = r.count + 1;
|
|
796
|
+
records[idx] = {
|
|
797
|
+
date,
|
|
798
|
+
count: newCount,
|
|
799
|
+
sumInput: r.sumInput + record.input,
|
|
800
|
+
sumOutput: r.sumOutput + record.output,
|
|
801
|
+
sumCacheRead: r.sumCacheRead + record.cacheRead,
|
|
802
|
+
sumCacheWrite: r.sumCacheWrite + record.cacheWrite,
|
|
803
|
+
sumTokensPerSec: r.sumTokensPerSec + record.tokensPerSec,
|
|
804
|
+
avgCacheHitRate:
|
|
805
|
+
((r.avgCacheHitRate * r.count + record.cacheHitRate) / newCount),
|
|
806
|
+
};
|
|
807
|
+
} else {
|
|
808
|
+
records.push({
|
|
809
|
+
date,
|
|
810
|
+
count: 1,
|
|
811
|
+
sumInput: record.input,
|
|
812
|
+
sumOutput: record.output,
|
|
813
|
+
sumCacheRead: record.cacheRead,
|
|
814
|
+
sumCacheWrite: record.cacheWrite,
|
|
815
|
+
sumTokensPerSec: record.tokensPerSec,
|
|
816
|
+
avgCacheHitRate: record.cacheHitRate,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
await writeFile(
|
|
821
|
+
DAILY_FILE,
|
|
822
|
+
records.map((r) => JSON.stringify(r)).join("\n") + "\n",
|
|
823
|
+
"utf-8",
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async function persistTurn(record: TurnStats, sessionId: string) {
|
|
828
|
+
const raw: RawRecord = {
|
|
829
|
+
...record,
|
|
830
|
+
ts: getISO(),
|
|
831
|
+
session: sessionId,
|
|
832
|
+
};
|
|
833
|
+
await appendRaw(raw);
|
|
834
|
+
await updateHourly(raw);
|
|
835
|
+
await updateDaily(raw);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ── 会话恢复:从历史消息重建累计统计 ─────────────────
|
|
839
|
+
|
|
840
|
+
function normalizeTimestampMs(timestamp: number): number {
|
|
841
|
+
// 处理混合时间戳单位
|
|
842
|
+
if (timestamp < 1e11) return timestamp * 1000; // seconds → ms
|
|
843
|
+
if (timestamp > 1e14) return Math.floor(timestamp / 1000); // microsec → ms
|
|
844
|
+
return timestamp;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function getEntryTimestampMs(entry: {
|
|
848
|
+
type: string;
|
|
849
|
+
timestamp: string;
|
|
850
|
+
message?: { timestamp?: number };
|
|
851
|
+
}): number | null {
|
|
852
|
+
if (entry.type === "message" && typeof entry.message?.timestamp === "number") {
|
|
853
|
+
return normalizeTimestampMs(entry.message.timestamp);
|
|
854
|
+
}
|
|
855
|
+
const parsed = Date.parse(entry.timestamp);
|
|
856
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function rebuildFromHistory(ctx: ExtensionContext) {
|
|
860
|
+
const branch = ctx.sessionManager.getBranch();
|
|
861
|
+
stats.totalInput = 0;
|
|
862
|
+
stats.totalOutput = 0;
|
|
863
|
+
stats.totalCacheRead = 0;
|
|
864
|
+
stats.totalCacheWrite = 0;
|
|
865
|
+
stats.totalCost = 0;
|
|
866
|
+
stats.totalCacheHitRateSum = 0;
|
|
867
|
+
stats.turnCount = 0;
|
|
868
|
+
stats.accountedUsageKeys = new Set();
|
|
869
|
+
stats.lastTokensPerSec = 0;
|
|
870
|
+
|
|
871
|
+
// 遍历 entries 重建累计统计,同时推算历史速率
|
|
872
|
+
let latestAssistantSpeed: number | null = null;
|
|
873
|
+
|
|
874
|
+
for (const entry of branch) {
|
|
875
|
+
if (entry.type !== "message") continue;
|
|
876
|
+
const msg = (entry as any).message;
|
|
877
|
+
if (msg.role !== "assistant" || !msg.usage) continue;
|
|
878
|
+
|
|
879
|
+
stats.totalInput += msg.usage.input ?? 0;
|
|
880
|
+
stats.totalOutput += msg.usage.output ?? 0;
|
|
881
|
+
stats.totalCacheRead += msg.usage.cacheRead ?? 0;
|
|
882
|
+
stats.totalCacheWrite += msg.usage.cacheWrite ?? 0;
|
|
883
|
+
stats.totalCost += msg.usage.cost?.total ?? 0;
|
|
884
|
+
|
|
885
|
+
const promptTokens = (msg.usage.input ?? 0) + (msg.usage.cacheRead ?? 0) + (msg.usage.cacheWrite ?? 0);
|
|
886
|
+
const chRate = promptTokens > 0
|
|
887
|
+
? ((msg.usage.cacheRead ?? 0) / promptTokens) * 100
|
|
888
|
+
: 0;
|
|
889
|
+
stats.totalCacheHitRateSum += chRate;
|
|
890
|
+
stats.turnCount++;
|
|
891
|
+
|
|
892
|
+
// 推算历史速率:从上一个 user 消息到本条 assistant 的耗时
|
|
893
|
+
if ((msg.usage.output ?? 0) <= 0) continue;
|
|
894
|
+
const endMs = getEntryTimestampMs(entry);
|
|
895
|
+
if (endMs === null) continue;
|
|
896
|
+
|
|
897
|
+
for (let j = branch.indexOf(entry) - 1; j >= 0; j--) {
|
|
898
|
+
const prev = branch[j];
|
|
899
|
+
if (prev.type !== "message") continue;
|
|
900
|
+
const prevMsg = (prev as any).message;
|
|
901
|
+
if (prevMsg.role === "assistant") continue; // 跳过 assistant 之间的 delta
|
|
902
|
+
|
|
903
|
+
const startMs = getEntryTimestampMs(prev);
|
|
904
|
+
if (startMs === null || endMs <= startMs) continue;
|
|
905
|
+
|
|
906
|
+
const elapsedSeconds = (endMs - startMs) / 1000;
|
|
907
|
+
if (elapsedSeconds <= 0) continue;
|
|
908
|
+
|
|
909
|
+
const speed = (msg.usage.output ?? 0) / elapsedSeconds;
|
|
910
|
+
if (!isReasonableTokenSpeed(speed)) continue;
|
|
911
|
+
|
|
912
|
+
if (prevMsg.role === "user") {
|
|
913
|
+
latestAssistantSpeed = speed;
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
// 非 user 消息的 fallback
|
|
917
|
+
if (latestAssistantSpeed === null) latestAssistantSpeed = speed;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (latestAssistantSpeed !== null) {
|
|
922
|
+
stats.lastTokensPerSec = latestAssistantSpeed;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// ── 配置文件操作 ─────────────────────────────────────
|
|
927
|
+
|
|
928
|
+
async function loadTokenConfig(): Promise<TokenConfig> {
|
|
929
|
+
try {
|
|
930
|
+
if (existsSync(TOKEN_CONFIG_FILE)) {
|
|
931
|
+
const raw = await readFile(TOKEN_CONFIG_FILE, "utf-8");
|
|
932
|
+
return { ...DEFAULT_TOKEN_CONFIG, ...JSON.parse(raw) };
|
|
933
|
+
}
|
|
934
|
+
} catch {}
|
|
935
|
+
return { ...DEFAULT_TOKEN_CONFIG };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async function saveTokenConfig(cfg: TokenConfig) {
|
|
939
|
+
await mkdir(TOKEN_CONFIG_DIR, { recursive: true });
|
|
940
|
+
await writeFile(TOKEN_CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function isContextStyle(v: unknown): v is ContextStyle {
|
|
944
|
+
return typeof v === "string" && ["pct-window", "used-window", "pct", "used", "bar"].includes(v);
|
|
945
|
+
}
|
|
946
|
+
function isSpeedStyle(v: unknown): v is SpeedStyle {
|
|
947
|
+
return typeof v === "string" && ["t/s", "tok/s", "T/s", "liveAt"].includes(v);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async function loadDisplayConfig(): Promise<DisplayConfig> {
|
|
951
|
+
try {
|
|
952
|
+
if (existsSync(DISPLAY_CONFIG_FILE)) {
|
|
953
|
+
const raw = await readFile(DISPLAY_CONFIG_FILE, "utf-8");
|
|
954
|
+
const saved = JSON.parse(raw) as DisplayConfig;
|
|
955
|
+
// 与默认值合并,防止新增条目缺失
|
|
956
|
+
const merged: DisplayConfig = {
|
|
957
|
+
...DEFAULT_DISPLAY_CONFIG,
|
|
958
|
+
items: { ...DEFAULT_DISPLAY_CONFIG.items },
|
|
959
|
+
};
|
|
960
|
+
if (saved.items) {
|
|
961
|
+
for (const key of Object.keys(merged.items) as DisplayKey[]) {
|
|
962
|
+
if (typeof saved.items[key] === "boolean") merged.items[key] = saved.items[key];
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (isContextStyle(saved.contextStyle)) merged.contextStyle = saved.contextStyle;
|
|
966
|
+
if (isSpeedStyle(saved.speedStyle)) merged.speedStyle = saved.speedStyle;
|
|
967
|
+
return merged;
|
|
968
|
+
}
|
|
969
|
+
} catch {}
|
|
970
|
+
return { ...DEFAULT_DISPLAY_CONFIG, items: { ...DEFAULT_DISPLAY_CONFIG.items } };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function saveDisplayConfig(cfg: DisplayConfig) {
|
|
974
|
+
await mkdir(TOKEN_CONFIG_DIR, { recursive: true });
|
|
975
|
+
await writeFile(DISPLAY_CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// ── 缓存操作 ─────────────────────────────────────────
|
|
979
|
+
|
|
980
|
+
async function readQuotaCache(): Promise<QuotaCache> {
|
|
981
|
+
try {
|
|
982
|
+
if (existsSync(QUOTA_CACHE_FILE)) {
|
|
983
|
+
const raw = await readFile(QUOTA_CACHE_FILE, "utf-8");
|
|
984
|
+
return JSON.parse(raw);
|
|
985
|
+
}
|
|
986
|
+
} catch {}
|
|
987
|
+
return {};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
async function writeQuotaCache(cache: QuotaCache) {
|
|
991
|
+
await ensureDir(LOGS_DIR);
|
|
992
|
+
await writeFile(QUOTA_CACHE_FILE, JSON.stringify(cache, null, 2), "utf-8");
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// ── 匹配逻辑 ─────────────────────────────────────────
|
|
996
|
+
|
|
997
|
+
function resolveActivePlan(provider?: string): TokenPlan | null {
|
|
998
|
+
if (!tokenConfig) return null;
|
|
999
|
+
const planId = provider ? (tokenConfig.providerPlans[provider] ?? null) : null;
|
|
1000
|
+
if (!planId) return null;
|
|
1001
|
+
return BUILTIN_PLANS.find(p => p.id === planId) || null;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function resolveApiKey(plan: TokenPlan): string | null {
|
|
1005
|
+
// 1. 环境变量优先
|
|
1006
|
+
if (plan.apiKeyEnv && process.env[plan.apiKeyEnv]) {
|
|
1007
|
+
return process.env[plan.apiKeyEnv]!;
|
|
1008
|
+
}
|
|
1009
|
+
// 2. 读取 pi 的 auth.json
|
|
1010
|
+
try {
|
|
1011
|
+
const authPath = join(homedir(), ".pi/agent/auth.json");
|
|
1012
|
+
if (existsSync(authPath)) {
|
|
1013
|
+
const raw = readFileSync(authPath, "utf-8");
|
|
1014
|
+
const auth = JSON.parse(raw);
|
|
1015
|
+
for (const providerId of plan.matchProviders) {
|
|
1016
|
+
const entry = auth[providerId];
|
|
1017
|
+
if (entry?.key) return entry.key;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
} catch {}
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* 检测并处理 provider 变化。
|
|
1026
|
+
* 返回 true 表示发生了切换(供调用者决定是否要 force refresh)。
|
|
1027
|
+
*/
|
|
1028
|
+
function detectAndHandleProviderChange(ctx: ExtensionContext): boolean {
|
|
1029
|
+
const curProvider = ctx.model?.provider ?? null;
|
|
1030
|
+
if (!curProvider) {
|
|
1031
|
+
// provider 缺失:清空 quotaState,不刷新
|
|
1032
|
+
if (quotaState) quotaState = null;
|
|
1033
|
+
lastQuotaProvider = null;
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
if (curProvider === lastQuotaProvider) return false;
|
|
1037
|
+
// 切换发生:先记录新 provider,再清旧 state
|
|
1038
|
+
lastQuotaProvider = curProvider;
|
|
1039
|
+
quotaState = null;
|
|
1040
|
+
return true;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function buildErrorState(
|
|
1044
|
+
provider: string,
|
|
1045
|
+
planId: string,
|
|
1046
|
+
error: QuotaError,
|
|
1047
|
+
): QuotaDisplayState {
|
|
1048
|
+
let display = "无数据";
|
|
1049
|
+
if (error.kind === "key_missing") {
|
|
1050
|
+
display = `❌ ${error.envVar} 未设置`;
|
|
1051
|
+
} else if (error.kind === "api_error") {
|
|
1052
|
+
display = `❌ ${truncateText(error.message, 24)}`;
|
|
1053
|
+
} else if (error.kind === "network_error") {
|
|
1054
|
+
display = `❌ 网络/超时`;
|
|
1055
|
+
} else if (error.kind === "no_data") {
|
|
1056
|
+
display = "无数据";
|
|
1057
|
+
} else if (error.kind === "no_plan") {
|
|
1058
|
+
display = "未启用";
|
|
1059
|
+
}
|
|
1060
|
+
return {
|
|
1061
|
+
planId,
|
|
1062
|
+
provider,
|
|
1063
|
+
display,
|
|
1064
|
+
modelPrefix: "",
|
|
1065
|
+
color: "err",
|
|
1066
|
+
error,
|
|
1067
|
+
fetchedAt: Date.now(),
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function truncateText(s: string, max: number): string {
|
|
1072
|
+
if (s.length <= max) return s;
|
|
1073
|
+
return s.slice(0, max - 1) + "…";
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* 把 quotaState.error 格式化为人类可读提示。
|
|
1078
|
+
*/
|
|
1079
|
+
function formatQuotaError(state: QuotaDisplayState | null | undefined): string {
|
|
1080
|
+
if (!state || !state.error) return "未知错误";
|
|
1081
|
+
const e = state.error;
|
|
1082
|
+
switch (e.kind) {
|
|
1083
|
+
case "no_plan":
|
|
1084
|
+
return "该 provider 未配置套餐";
|
|
1085
|
+
case "key_missing":
|
|
1086
|
+
return `未设置环境变量 ${e.envVar} 或 ~/.pi/agent/auth.json 中 ${e.provider} 的 key 字段`;
|
|
1087
|
+
case "api_error":
|
|
1088
|
+
return `API 返回错误: ${e.message}`;
|
|
1089
|
+
case "network_error":
|
|
1090
|
+
return `网络/超时: ${e.message}`;
|
|
1091
|
+
case "no_data":
|
|
1092
|
+
return "接口返回无数据";
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* 刷新套餐用量。
|
|
1098
|
+
* force=true 时绕过缓存(用于 provider 切换 / 手动刷新 / session_start)。
|
|
1099
|
+
*/
|
|
1100
|
+
async function refreshQuota(ctx: ExtensionContext, force = false): Promise<void> {
|
|
1101
|
+
// 1. 先检测 provider 变化(可能清空 quotaState)
|
|
1102
|
+
detectAndHandleProviderChange(ctx);
|
|
1103
|
+
|
|
1104
|
+
const curProvider = ctx.model?.provider;
|
|
1105
|
+
if (!curProvider) return; // provider 缺失:不显示
|
|
1106
|
+
|
|
1107
|
+
// 2. 解析 plan
|
|
1108
|
+
const plan = resolveActivePlan(curProvider);
|
|
1109
|
+
if (!plan) {
|
|
1110
|
+
// 用户没启用套餐:静默隐藏该段
|
|
1111
|
+
quotaState = null;
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// 3. 解析 key
|
|
1116
|
+
const key = resolveApiKey(plan);
|
|
1117
|
+
if (!key) {
|
|
1118
|
+
quotaState = buildErrorState(curProvider, plan.id, {
|
|
1119
|
+
kind: "key_missing",
|
|
1120
|
+
envVar: plan.apiKeyEnv || "API_KEY",
|
|
1121
|
+
provider: curProvider,
|
|
1122
|
+
});
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// 4. 读缓存(force 时跳过)
|
|
1127
|
+
const cache = await readQuotaCache();
|
|
1128
|
+
const cached = cache[plan.id];
|
|
1129
|
+
const ttlMs = (tokenConfig?.ttl || 60) * 1000;
|
|
1130
|
+
if (!force && cached && (Date.now() - cached.fetchedAt) < cached.ttl) {
|
|
1131
|
+
const fmt = plan.format(cached.data);
|
|
1132
|
+
quotaState = {
|
|
1133
|
+
planId: plan.id,
|
|
1134
|
+
provider: curProvider,
|
|
1135
|
+
display: fmt.display,
|
|
1136
|
+
modelPrefix: fmt.modelPrefix,
|
|
1137
|
+
color: fmt.color,
|
|
1138
|
+
fetchedAt: cached.fetchedAt,
|
|
1139
|
+
};
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// 5. 调接口
|
|
1144
|
+
try {
|
|
1145
|
+
const data = await plan.fetchQuota(plan, key);
|
|
1146
|
+
cache[plan.id] = { fetchedAt: Date.now(), ttl: ttlMs, data };
|
|
1147
|
+
await writeQuotaCache(cache);
|
|
1148
|
+
const fmt = plan.format(data);
|
|
1149
|
+
// format 可能返回 "无数据" 颜色为 err
|
|
1150
|
+
if (fmt.color === "err" && fmt.display === "无数据") {
|
|
1151
|
+
quotaState = buildErrorState(curProvider, plan.id, { kind: "no_data" });
|
|
1152
|
+
quotaState.display = fmt.display;
|
|
1153
|
+
quotaState.modelPrefix = fmt.modelPrefix;
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
quotaState = {
|
|
1157
|
+
planId: plan.id,
|
|
1158
|
+
provider: curProvider,
|
|
1159
|
+
display: fmt.display,
|
|
1160
|
+
modelPrefix: fmt.modelPrefix,
|
|
1161
|
+
color: fmt.color,
|
|
1162
|
+
fetchedAt: Date.now(),
|
|
1163
|
+
};
|
|
1164
|
+
} catch (e: any) {
|
|
1165
|
+
// 区分网络错误与 API 业务错误
|
|
1166
|
+
const msg = e?.message || String(e);
|
|
1167
|
+
const isNetwork = /timeout|abort|fetch failed|network|econnreset|enotfound/i.test(msg);
|
|
1168
|
+
quotaState = buildErrorState(curProvider, plan.id, isNetwork
|
|
1169
|
+
? { kind: "network_error", message: msg }
|
|
1170
|
+
: { kind: "api_error", message: msg },
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
async function forceRefreshQuota(ctx: ExtensionContext) {
|
|
1176
|
+
await refreshQuota(ctx, true);
|
|
1177
|
+
shared.requestRender?.();
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** 清空所有套餐缓存(session_start 调,避免跨 session 复用旧数据) */
|
|
1181
|
+
async function invalidateAllQuotaCache() {
|
|
1182
|
+
try {
|
|
1183
|
+
if (existsSync(QUOTA_CACHE_FILE)) {
|
|
1184
|
+
await writeFile(QUOTA_CACHE_FILE, "{}", "utf-8");
|
|
1185
|
+
}
|
|
1186
|
+
} catch { /* ignore */ }
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// ── /stats 命令 ───────────────────────────────────────
|
|
1190
|
+
|
|
1191
|
+
function weightedCacheHitRate(d: { sumInput: number; sumCacheRead: number; sumCacheWrite: number }): number {
|
|
1192
|
+
const total = d.sumInput + d.sumCacheRead + d.sumCacheWrite;
|
|
1193
|
+
return total > 0 ? (d.sumCacheRead / total) * 100 : 0;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function renderDaySummary(daily: DailyRecord): string {
|
|
1197
|
+
const d = daily;
|
|
1198
|
+
const avgInput = d.count > 0 ? d.sumInput / d.count : 0;
|
|
1199
|
+
const avgOutput = d.count > 0 ? d.sumOutput / d.count : 0;
|
|
1200
|
+
const totalPrompt = d.sumInput + d.sumCacheRead + d.sumCacheWrite;
|
|
1201
|
+
const cacheHitRate = weightedCacheHitRate(d);
|
|
1202
|
+
|
|
1203
|
+
const lines = [
|
|
1204
|
+
`对话次数: ${d.count}`,
|
|
1205
|
+
`新增输入: ${formatTokens(d.sumInput)} (平均 ${formatTokens(avgInput)}/次,未命中缓存)`,
|
|
1206
|
+
`缓存输入: ${formatTokens(d.sumCacheRead)}`,
|
|
1207
|
+
`总输出: ${formatTokens(d.sumOutput)} (平均 ${formatTokens(avgOutput)}/次)`,
|
|
1208
|
+
`总token: ${formatTokens(totalPrompt)} (新增 + 缓存)`,
|
|
1209
|
+
`缓存命中率: ${cacheHitRate.toFixed(1)}%`,
|
|
1210
|
+
`平均速率: ${(d.sumTokensPerSec / d.count).toFixed(1)} t/s`,
|
|
1211
|
+
];
|
|
1212
|
+
return lines.join("\n");
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async function showStats(
|
|
1216
|
+
lines: string[],
|
|
1217
|
+
title: string,
|
|
1218
|
+
ctx: ExtensionContext,
|
|
1219
|
+
) {
|
|
1220
|
+
const theme = ctx.ui.theme;
|
|
1221
|
+
const text = `${theme.fg("accent", theme.bold(title))}\n${theme.fg("dim", "─".repeat(42))}\n` +
|
|
1222
|
+
lines.map((l) => theme.fg("dim", l)).join("\n");
|
|
1223
|
+
pi.sendMessage({
|
|
1224
|
+
customType: "token-stats",
|
|
1225
|
+
content: text,
|
|
1226
|
+
display: true,
|
|
1227
|
+
details: {},
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async function showDay(date: string, ctx: ExtensionContext) {
|
|
1232
|
+
let records: DailyRecord[] = [];
|
|
1233
|
+
try {
|
|
1234
|
+
records = (await readFile(DAILY_FILE, "utf-8")).trim().split("\n")
|
|
1235
|
+
.filter(Boolean)
|
|
1236
|
+
.map((l) => JSON.parse(l));
|
|
1237
|
+
} catch {
|
|
1238
|
+
// nothing
|
|
1239
|
+
}
|
|
1240
|
+
const daily = records.find((r) => r.date === date) || null;
|
|
1241
|
+
|
|
1242
|
+
if (!daily) {
|
|
1243
|
+
ctx.ui.notify(`${date} 暂无统计数据`, "info");
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
await showStats(
|
|
1248
|
+
renderDaySummary(daily).split("\n"),
|
|
1249
|
+
`Token 统计 | ${date}`,
|
|
1250
|
+
ctx,
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function showHourly(date: string, ctx: ExtensionContext) {
|
|
1255
|
+
const file = join(HOURLY_DIR, `${date}.jsonl`);
|
|
1256
|
+
let records: HourlyRecord[] = [];
|
|
1257
|
+
try {
|
|
1258
|
+
records = (await readFile(file, "utf-8")).trim().split("\n")
|
|
1259
|
+
.filter(Boolean)
|
|
1260
|
+
.map((l) => JSON.parse(l));
|
|
1261
|
+
} catch {
|
|
1262
|
+
// nothing
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (records.length === 0) {
|
|
1266
|
+
ctx.ui.notify(`${date} 暂无按小时统计`, "info");
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
records.sort((a, b) => a.hour - b.hour);
|
|
1271
|
+
|
|
1272
|
+
const lines = [
|
|
1273
|
+
"时 次数 输入 输出 命中率 速率",
|
|
1274
|
+
"─".repeat(40),
|
|
1275
|
+
...records.map((r) =>
|
|
1276
|
+
`${String(r.hour).padStart(2, "0")} ` +
|
|
1277
|
+
`${String(r.count).padStart(3)} ` +
|
|
1278
|
+
`${formatTokens(r.sumInput).padStart(7)} ` +
|
|
1279
|
+
`${formatTokens(r.sumOutput).padStart(7)} ` +
|
|
1280
|
+
`${weightedCacheHitRate(r).toFixed(1).padStart(5)}% ` +
|
|
1281
|
+
`${(r.sumTokensPerSec / r.count).toFixed(1).padStart(5)}`,
|
|
1282
|
+
),
|
|
1283
|
+
];
|
|
1284
|
+
|
|
1285
|
+
await showStats(lines, `按小时分布 | ${date}`, ctx);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async function showWeek(ctx: ExtensionContext) {
|
|
1289
|
+
let records: DailyRecord[] = [];
|
|
1290
|
+
try {
|
|
1291
|
+
records = (await readFile(DAILY_FILE, "utf-8")).trim().split("\n")
|
|
1292
|
+
.filter(Boolean)
|
|
1293
|
+
.map((l) => JSON.parse(l));
|
|
1294
|
+
} catch {
|
|
1295
|
+
// nothing
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// 最近 7 天
|
|
1299
|
+
const today = getDateStr();
|
|
1300
|
+
const sevenDaysAgo = getDateStr(
|
|
1301
|
+
Date.now() - 7 * 24 * 60 * 60 * 1000,
|
|
1302
|
+
);
|
|
1303
|
+
const weekRecords = records
|
|
1304
|
+
.filter((r) => r.date >= sevenDaysAgo && r.date <= today)
|
|
1305
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
1306
|
+
|
|
1307
|
+
if (weekRecords.length === 0) {
|
|
1308
|
+
ctx.ui.notify("本周暂无统计数据", "info");
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const lines = [
|
|
1313
|
+
"日期 次数 新增输入 缓存输入 输出 总token 命中率 速率",
|
|
1314
|
+
"─".repeat(70),
|
|
1315
|
+
...weekRecords.map((r) => {
|
|
1316
|
+
const totalPrompt = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
|
|
1317
|
+
return (
|
|
1318
|
+
`${r.date} ` +
|
|
1319
|
+
`${String(r.count).padStart(3)} ` +
|
|
1320
|
+
`${formatTokens(r.sumInput).padStart(7)} ` +
|
|
1321
|
+
`${formatTokens(r.sumCacheRead).padStart(7)} ` +
|
|
1322
|
+
`${formatTokens(r.sumOutput).padStart(7)} ` +
|
|
1323
|
+
`${formatTokens(totalPrompt).padStart(7)} ` +
|
|
1324
|
+
`${weightedCacheHitRate(r).toFixed(1).padStart(5)}% ` +
|
|
1325
|
+
`${(r.sumTokensPerSec / r.count).toFixed(1).padStart(5)}`
|
|
1326
|
+
);
|
|
1327
|
+
}),
|
|
1328
|
+
];
|
|
1329
|
+
|
|
1330
|
+
await showStats(lines, "本周每天汇总", ctx);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function getMonthStr(date: Date = new Date()): string {
|
|
1334
|
+
const y = date.getFullYear();
|
|
1335
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
1336
|
+
return `${y}-${m}`;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
async function showMonth(month: string, ctx: ExtensionContext) {
|
|
1340
|
+
let records: DailyRecord[] = [];
|
|
1341
|
+
try {
|
|
1342
|
+
records = (await readFile(DAILY_FILE, "utf-8")).trim().split("\n")
|
|
1343
|
+
.filter(Boolean)
|
|
1344
|
+
.map((l) => JSON.parse(l));
|
|
1345
|
+
} catch {
|
|
1346
|
+
// nothing
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const monthRecords = records
|
|
1350
|
+
.filter((r) => r.date.startsWith(month))
|
|
1351
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
1352
|
+
|
|
1353
|
+
if (monthRecords.length === 0) {
|
|
1354
|
+
ctx.ui.notify(`${month} 暂无统计数据`, "info");
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// 累计
|
|
1359
|
+
const total = monthRecords.reduce(
|
|
1360
|
+
(acc, r) => {
|
|
1361
|
+
acc.count += r.count;
|
|
1362
|
+
acc.sumInput += r.sumInput;
|
|
1363
|
+
acc.sumCacheRead += r.sumCacheRead;
|
|
1364
|
+
acc.sumCacheWrite += r.sumCacheWrite;
|
|
1365
|
+
acc.sumOutput += r.sumOutput;
|
|
1366
|
+
acc.sumTokensPerSec += r.sumTokensPerSec;
|
|
1367
|
+
return acc;
|
|
1368
|
+
},
|
|
1369
|
+
{ count: 0, sumInput: 0, sumCacheRead: 0, sumCacheWrite: 0, sumOutput: 0, sumTokensPerSec: 0 },
|
|
1370
|
+
);
|
|
1371
|
+
const totalPrompt = total.sumInput + total.sumCacheRead + total.sumCacheWrite;
|
|
1372
|
+
const cacheHitRate = weightedCacheHitRate(total);
|
|
1373
|
+
|
|
1374
|
+
const lines = [
|
|
1375
|
+
"日期 次数 新增输入 缓存输入 输出 总token 命中率 速率",
|
|
1376
|
+
"─".repeat(70),
|
|
1377
|
+
...monthRecords.map((r) => {
|
|
1378
|
+
const tp = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
|
|
1379
|
+
return (
|
|
1380
|
+
`${r.date} ` +
|
|
1381
|
+
`${String(r.count).padStart(3)} ` +
|
|
1382
|
+
`${formatTokens(r.sumInput).padStart(7)} ` +
|
|
1383
|
+
`${formatTokens(r.sumCacheRead).padStart(7)} ` +
|
|
1384
|
+
`${formatTokens(r.sumOutput).padStart(7)} ` +
|
|
1385
|
+
`${formatTokens(tp).padStart(7)} ` +
|
|
1386
|
+
`${weightedCacheHitRate(r).toFixed(1).padStart(5)}% ` +
|
|
1387
|
+
`${(r.sumTokensPerSec / r.count).toFixed(1).padStart(5)}`
|
|
1388
|
+
);
|
|
1389
|
+
}),
|
|
1390
|
+
"",
|
|
1391
|
+
`合计 ${String(total.count).padStart(3)} ` +
|
|
1392
|
+
`${formatTokens(total.sumInput).padStart(7)} ` +
|
|
1393
|
+
`${formatTokens(total.sumCacheRead).padStart(7)} ` +
|
|
1394
|
+
`${formatTokens(total.sumOutput).padStart(7)} ` +
|
|
1395
|
+
`${formatTokens(totalPrompt).padStart(7)} ` +
|
|
1396
|
+
`${cacheHitRate.toFixed(1).padStart(5)}% ` +
|
|
1397
|
+
`${(total.sumTokensPerSec / total.count).toFixed(1).padStart(5)}`,
|
|
1398
|
+
];
|
|
1399
|
+
|
|
1400
|
+
await showStats(lines, `${month} 月度汇总`, ctx);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// ── 事件注册 ─────────────────────────────────────────
|
|
1404
|
+
|
|
1405
|
+
// ── message renderer: 渲染 /stats 发出的消息 ─────────
|
|
1406
|
+
pi.registerMessageRenderer("token-stats", (message, _options, _theme) => {
|
|
1407
|
+
return new Text(message.content, 0, 0);
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
// ── turn_start: 记录时间 + 检测供应商切换 ──────────
|
|
1411
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
1412
|
+
stats.turnStartTime = Date.now();
|
|
1413
|
+
stats.firstTokenTime = 0;
|
|
1414
|
+
stats.streaming = false;
|
|
1415
|
+
|
|
1416
|
+
// turn_start 也能触发 provider 变化检测;切换时 force refresh
|
|
1417
|
+
if (ctx.model?.provider !== lastQuotaProvider) {
|
|
1418
|
+
lastQuotaProvider = ctx.model?.provider ?? null;
|
|
1419
|
+
quotaState = null; // 跨 provider 立即清旧 state
|
|
1420
|
+
await refreshQuota(ctx, true); // force 绕过缓存
|
|
1421
|
+
shared.requestRender?.();
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
shared.requestRender?.();
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
// ── message_update: 流式实时估算 + rolling window ────
|
|
1428
|
+
pi.on("message_update", async (event, ctx) => {
|
|
1429
|
+
if (event.message.role !== "assistant") return;
|
|
1430
|
+
const content = event.message.content;
|
|
1431
|
+
if (!Array.isArray(content)) return;
|
|
1432
|
+
|
|
1433
|
+
const streamEvent = (event as any).assistantMessageEvent;
|
|
1434
|
+
if (
|
|
1435
|
+
streamEvent?.type !== "text_delta" &&
|
|
1436
|
+
streamEvent?.type !== "thinking_delta" &&
|
|
1437
|
+
streamEvent?.type !== "toolcall_delta"
|
|
1438
|
+
) {
|
|
1439
|
+
// 非 delta 事件仍需要更新部分状态
|
|
1440
|
+
if (stats.firstTokenTime === 0) stats.firstTokenTime = Date.now();
|
|
1441
|
+
stats.streaming = true;
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// 记录首个 token 到达时间
|
|
1446
|
+
if (stats.firstTokenTime === 0) stats.firstTokenTime = Date.now();
|
|
1447
|
+
stats.streaming = true;
|
|
1448
|
+
|
|
1449
|
+
const nowMs = Date.now();
|
|
1450
|
+
stats.liveOutputChars += streamEvent.delta.length;
|
|
1451
|
+
|
|
1452
|
+
// 优先使用 pi 框架返回的 partial usage
|
|
1453
|
+
const usageOutputTokens = streamEvent.partial?.usage?.output;
|
|
1454
|
+
let newTokens = 0;
|
|
1455
|
+
if (
|
|
1456
|
+
typeof usageOutputTokens === "number" &&
|
|
1457
|
+
usageOutputTokens > stats.liveUsageOutputTokens
|
|
1458
|
+
) {
|
|
1459
|
+
newTokens = usageOutputTokens - stats.liveUsageOutputTokens;
|
|
1460
|
+
stats.liveUsageOutputTokens = usageOutputTokens;
|
|
1461
|
+
stats.liveEstimatedTokens = usageOutputTokens;
|
|
1462
|
+
} else if (stats.liveUsageOutputTokens <= 0) {
|
|
1463
|
+
// 回退到字符估算
|
|
1464
|
+
const estimated = estimateTokens(stats.liveOutputChars);
|
|
1465
|
+
newTokens = Math.max(0, estimated - stats.liveEstimatedTokens);
|
|
1466
|
+
stats.liveEstimatedTokens = estimated;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
if (newTokens > 0) {
|
|
1470
|
+
stats.liveTokenSamples.push({ timestampMs: nowMs, tokens: newTokens });
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
shared.requestRender?.();
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
// ── message_end: 精确统计 + 持久化 ──────────────────
|
|
1477
|
+
pi.on("message_end", async (event, ctx) => {
|
|
1478
|
+
if (event.message.role !== "assistant") return;
|
|
1479
|
+
const assistantMsg = event.message as AssistantMessage;
|
|
1480
|
+
const usage = assistantMsg.usage;
|
|
1481
|
+
if (!usage) return;
|
|
1482
|
+
|
|
1483
|
+
// 去重:使用 responseId 防止 message_end + turn_end 重复累加
|
|
1484
|
+
const usageKey = assistantMsg.responseId ||
|
|
1485
|
+
`${assistantMsg.timestamp}:${assistantMsg.provider}:${assistantMsg.model}:${usage.input}:${usage.output}`;
|
|
1486
|
+
if (stats.accountedUsageKeys.has(usageKey)) return;
|
|
1487
|
+
stats.accountedUsageKeys.add(usageKey);
|
|
1488
|
+
|
|
1489
|
+
// 流总耗时(秒)
|
|
1490
|
+
const totalElapsed =
|
|
1491
|
+
stats.turnStartTime > 0
|
|
1492
|
+
? (Date.now() - stats.turnStartTime) / 1000
|
|
1493
|
+
: 0;
|
|
1494
|
+
// 过滤异常:< 50ms 视为不可信
|
|
1495
|
+
const tokensPerSec =
|
|
1496
|
+
totalElapsed >= 0.05 ? usage.output / totalElapsed : 0;
|
|
1497
|
+
// rolling window 速率(优先于平均速率)
|
|
1498
|
+
const liveSpeed = getRollingLiveTokenSpeed();
|
|
1499
|
+
// 首 token 延迟(毫秒)
|
|
1500
|
+
const firstTokenLatency =
|
|
1501
|
+
stats.firstTokenTime > 0 && stats.turnStartTime > 0
|
|
1502
|
+
? stats.firstTokenTime - stats.turnStartTime
|
|
1503
|
+
: 0;
|
|
1504
|
+
// 词数
|
|
1505
|
+
const wordCount = countWords(
|
|
1506
|
+
extractTextContent(event.message.content),
|
|
1507
|
+
);
|
|
1508
|
+
// 缓存命中率(pi 内置公式)
|
|
1509
|
+
const promptTokens =
|
|
1510
|
+
usage.input + usage.cacheRead + usage.cacheWrite;
|
|
1511
|
+
const cacheHitRate =
|
|
1512
|
+
promptTokens > 0
|
|
1513
|
+
? (usage.cacheRead / promptTokens) * 100
|
|
1514
|
+
: 0;
|
|
1515
|
+
// 花费
|
|
1516
|
+
const cost = usage.cost?.total ?? 0;
|
|
1517
|
+
|
|
1518
|
+
// 更新本轮精确值
|
|
1519
|
+
stats.lastInput = usage.input;
|
|
1520
|
+
stats.lastOutput = usage.output;
|
|
1521
|
+
stats.lastCacheRead = usage.cacheRead;
|
|
1522
|
+
stats.lastCacheWrite = usage.cacheWrite;
|
|
1523
|
+
stats.lastCost = cost;
|
|
1524
|
+
stats.lastCacheHitRate = cacheHitRate;
|
|
1525
|
+
stats.lastTokensPerSec = tokensPerSec;
|
|
1526
|
+
stats.lastLiveTokenSpeed = liveSpeed;
|
|
1527
|
+
stats.lastFirstTokenLatency = firstTokenLatency;
|
|
1528
|
+
stats.lastWordCount = wordCount;
|
|
1529
|
+
stats.streaming = false;
|
|
1530
|
+
|
|
1531
|
+
// 累加到会话
|
|
1532
|
+
stats.totalInput += usage.input;
|
|
1533
|
+
stats.totalOutput += usage.output;
|
|
1534
|
+
stats.totalCacheRead += usage.cacheRead;
|
|
1535
|
+
stats.totalCacheWrite += usage.cacheWrite;
|
|
1536
|
+
stats.totalCost += cost;
|
|
1537
|
+
stats.totalCacheHitRateSum += cacheHitRate;
|
|
1538
|
+
stats.turnCount++;
|
|
1539
|
+
|
|
1540
|
+
shared.requestRender?.();
|
|
1541
|
+
|
|
1542
|
+
// 持久化
|
|
1543
|
+
const sessionId =
|
|
1544
|
+
ctx.sessionManager.getSessionId?.() ?? "unknown";
|
|
1545
|
+
const model = `${event.message.provider}/${event.message.model}`;
|
|
1546
|
+
await persistTurn({
|
|
1547
|
+
input: usage.input,
|
|
1548
|
+
output: usage.output,
|
|
1549
|
+
cacheRead: usage.cacheRead,
|
|
1550
|
+
cacheWrite: usage.cacheWrite,
|
|
1551
|
+
tokensPerSec,
|
|
1552
|
+
cacheHitRate,
|
|
1553
|
+
model,
|
|
1554
|
+
firstTokenLatency,
|
|
1555
|
+
wordCount,
|
|
1556
|
+
cost,
|
|
1557
|
+
liveTokenSpeed: liveSpeed,
|
|
1558
|
+
}, sessionId);
|
|
1559
|
+
|
|
1560
|
+
// 重置 live 状态
|
|
1561
|
+
resetLiveState();
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
// ── agent_end: 整个对话结束,确保最终状态刷新 ────────
|
|
1565
|
+
pi.on("agent_end", async (_event, _ctx) => {
|
|
1566
|
+
stats.streaming = false;
|
|
1567
|
+
resetLiveState();
|
|
1568
|
+
shared.requestRender?.();
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
// ── session_shutdown: 清理跨 session 资源(定时器 / footer 引用)───────
|
|
1572
|
+
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
1573
|
+
// session 替换(/new /resume /fork)或 /reload 时旧 ctx 会失效,
|
|
1574
|
+
// 必须在此清掉旧实例的定时器与闭包引用,否则定时器回调访问旧 ctx
|
|
1575
|
+
// 会抛 "extension ctx is stale" 导致 pi 崩溃退出。
|
|
1576
|
+
shared.sessionActive = false;
|
|
1577
|
+
if (quotaTimerId) {
|
|
1578
|
+
clearInterval(quotaTimerId);
|
|
1579
|
+
quotaTimerId = null;
|
|
1580
|
+
}
|
|
1581
|
+
shared.requestRender = null;
|
|
1582
|
+
lastQuotaProvider = null;
|
|
1583
|
+
quotaState = null;
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
// ── session_start: 恢复累计状态 + 定时刷新配额 ──────
|
|
1587
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1588
|
+
shared.sessionActive = true;
|
|
1589
|
+
rebuildFromHistory(ctx);
|
|
1590
|
+
|
|
1591
|
+
// 套餐用量:加载配置 + 定时刷新
|
|
1592
|
+
tokenConfig = await loadTokenConfig();
|
|
1593
|
+
displayConfig = await loadDisplayConfig();
|
|
1594
|
+
lastQuotaProvider = null; // 强制让 refreshQuota 检测一次
|
|
1595
|
+
quotaState = null;
|
|
1596
|
+
// 清空所有 plan 的缓存(避免跨 session 复用旧数据)
|
|
1597
|
+
await invalidateAllQuotaCache();
|
|
1598
|
+
if (quotaTimerId) clearInterval(quotaTimerId);
|
|
1599
|
+
// 第一次强制刷新(绕缓存)
|
|
1600
|
+
await refreshQuota(ctx, true);
|
|
1601
|
+
shared.requestRender?.();
|
|
1602
|
+
quotaTimerId = setInterval(async () => {
|
|
1603
|
+
if (!shared.sessionActive) return;
|
|
1604
|
+
try {
|
|
1605
|
+
// 定时器也先检测 provider 变化;变化则 force refresh
|
|
1606
|
+
if (ctx.model?.provider !== lastQuotaProvider) {
|
|
1607
|
+
await refreshQuota(ctx, true);
|
|
1608
|
+
} else {
|
|
1609
|
+
await refreshQuota(ctx, false);
|
|
1610
|
+
}
|
|
1611
|
+
} catch { /* ctx 已失效(session 被替换),忽略本次刷新 */ }
|
|
1612
|
+
shared.requestRender?.();
|
|
1613
|
+
}, (tokenConfig?.ttl || 60) * 1000);
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
// ── /stats 命令 ─────────────────────────────────────
|
|
1617
|
+
|
|
1618
|
+
pi.registerCommand("stats", {
|
|
1619
|
+
description: "Token 统计 (day | hour | week | month | config) 无参默认进入套餐配置",
|
|
1620
|
+
handler: async (args, ctx) => {
|
|
1621
|
+
const arg = args.trim();
|
|
1622
|
+
|
|
1623
|
+
// 无参 → 套餐配置
|
|
1624
|
+
if (!arg) {
|
|
1625
|
+
const provider = ctx.model?.provider;
|
|
1626
|
+
if (!provider) {
|
|
1627
|
+
ctx.ui.notify("无法获取当前供应商,请先切换对话", "warning");
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
// 套餐用量选择菜单
|
|
1631
|
+
const options = ["关闭", ...BUILTIN_PLANS.map(p => p.name)];
|
|
1632
|
+
const choice = await ctx.ui.select(
|
|
1633
|
+
"选择 " + provider + " 要显示配额的套餐(选中后退出)",
|
|
1634
|
+
options,
|
|
1635
|
+
);
|
|
1636
|
+
|
|
1637
|
+
const defaults: TokenConfig = { providerPlans: {}, ttl: 60 };
|
|
1638
|
+
|
|
1639
|
+
if (!choice || choice === "关闭") {
|
|
1640
|
+
tokenConfig = tokenConfig
|
|
1641
|
+
? { ...tokenConfig, providerPlans: { ...tokenConfig.providerPlans, [provider]: null } }
|
|
1642
|
+
: { ...defaults, providerPlans: { [provider]: null } };
|
|
1643
|
+
await saveTokenConfig(tokenConfig);
|
|
1644
|
+
lastQuotaProvider = provider;
|
|
1645
|
+
quotaState = null;
|
|
1646
|
+
if (quotaTimerId) clearInterval(quotaTimerId);
|
|
1647
|
+
quotaTimerId = setInterval(async () => {
|
|
1648
|
+
if (!shared.sessionActive) return;
|
|
1649
|
+
try {
|
|
1650
|
+
await refreshQuota(ctx);
|
|
1651
|
+
} catch { /* ctx 已失效(session 被替换),忽略 */ }
|
|
1652
|
+
shared.requestRender?.();
|
|
1653
|
+
}, (tokenConfig?.ttl || 60) * 1000);
|
|
1654
|
+
shared.requestRender?.();
|
|
1655
|
+
ctx.ui.notify(provider + " 的套餐用量已关闭", "info");
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
const plan = BUILTIN_PLANS.find(p => p.name === choice);
|
|
1659
|
+
if (plan) {
|
|
1660
|
+
tokenConfig = tokenConfig
|
|
1661
|
+
? { ...tokenConfig, providerPlans: { ...tokenConfig.providerPlans, [provider]: plan.id } }
|
|
1662
|
+
: { ...defaults, providerPlans: { [provider]: plan.id } };
|
|
1663
|
+
await saveTokenConfig(tokenConfig);
|
|
1664
|
+
lastQuotaProvider = provider;
|
|
1665
|
+
// 立即查询
|
|
1666
|
+
await forceRefreshQuota(ctx);
|
|
1667
|
+
if (quotaTimerId) clearInterval(quotaTimerId);
|
|
1668
|
+
quotaTimerId = setInterval(async () => {
|
|
1669
|
+
if (!shared.sessionActive) return;
|
|
1670
|
+
try {
|
|
1671
|
+
await refreshQuota(ctx);
|
|
1672
|
+
} catch { /* ctx 已失效(session 被替换),忽略 */ }
|
|
1673
|
+
shared.requestRender?.();
|
|
1674
|
+
}, (tokenConfig?.ttl || 60) * 1000);
|
|
1675
|
+
if (quotaState?.error) {
|
|
1676
|
+
// 仅当 quotaState 带有 error 字段时(key 缺失 / API 错误 / 网络错误 / 无数据)才提示"查询失败"
|
|
1677
|
+
const errMsg = formatQuotaError(quotaState);
|
|
1678
|
+
ctx.ui.notify(`${plan.name} 配额查询失败:${errMsg}`, "info");
|
|
1679
|
+
} else {
|
|
1680
|
+
ctx.ui.notify(plan.name + " 配额已启用", "info");
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
if (arg === "config") {
|
|
1687
|
+
const subChoice = await ctx.ui.select("配置", [
|
|
1688
|
+
"显示样式",
|
|
1689
|
+
"显示内容",
|
|
1690
|
+
"刷新时间 (当前 " + (tokenConfig?.ttl || 60) + "s)",
|
|
1691
|
+
]);
|
|
1692
|
+
if (!subChoice) return;
|
|
1693
|
+
|
|
1694
|
+
if (subChoice === "显示样式") {
|
|
1695
|
+
const catChoice = await ctx.ui.select("选择要配置的样式类别", [
|
|
1696
|
+
"上下文样式",
|
|
1697
|
+
"⚡ 速率样式",
|
|
1698
|
+
]);
|
|
1699
|
+
if (!catChoice) return;
|
|
1700
|
+
|
|
1701
|
+
if (catChoice === "上下文样式") {
|
|
1702
|
+
const items: { label: string; value: ContextStyle; preview: string }[] = [
|
|
1703
|
+
{ label: "pct-window", value: "pct-window", preview: `5.3%/1.0M` },
|
|
1704
|
+
{ label: "used-window", value: "used-window", preview: `256k/1.0M` },
|
|
1705
|
+
{ label: "pct", value: "pct", preview: `5.3%` },
|
|
1706
|
+
{ label: "used", value: "used", preview: `256k` },
|
|
1707
|
+
{ label: "bar", value: "bar", preview: `[██░░░░░░] 25%` },
|
|
1708
|
+
];
|
|
1709
|
+
const choice = await ctx.ui.select(
|
|
1710
|
+
"上下文样式(当前: " + displayConfig.contextStyle + ")",
|
|
1711
|
+
items.map(i =>
|
|
1712
|
+
(displayConfig.contextStyle === i.value ? "● " : "○ ") + i.label + " " + i.preview
|
|
1713
|
+
),
|
|
1714
|
+
);
|
|
1715
|
+
if (choice) {
|
|
1716
|
+
const idx = items.findIndex(i =>
|
|
1717
|
+
(displayConfig.contextStyle === i.value ? "● " : "○ ") + i.label + " " + i.preview === choice
|
|
1718
|
+
);
|
|
1719
|
+
if (idx >= 0) {
|
|
1720
|
+
displayConfig = { ...displayConfig, contextStyle: items[idx].value };
|
|
1721
|
+
await saveDisplayConfig(displayConfig);
|
|
1722
|
+
shared.requestRender?.();
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
} else {
|
|
1726
|
+
const items: { label: string; value: SpeedStyle; preview: string }[] = [
|
|
1727
|
+
{ label: "t/s", value: "t/s", preview: `⚡77.7 t/s` },
|
|
1728
|
+
{ label: "tok/s", value: "tok/s", preview: `⚡77.7 tok/s` },
|
|
1729
|
+
{ label: "T/s", value: "T/s", preview: `⚡77.7 T/s` },
|
|
1730
|
+
{ label: "live@速率", value: "liveAt", preview: `⚡1.2k@77.7` },
|
|
1731
|
+
];
|
|
1732
|
+
const choice = await ctx.ui.select(
|
|
1733
|
+
"⚡ 速率样式(当前: " + displayConfig.speedStyle + ")",
|
|
1734
|
+
items.map(i =>
|
|
1735
|
+
(displayConfig.speedStyle === i.value ? "● " : "○ ") + i.label + " " + i.preview
|
|
1736
|
+
),
|
|
1737
|
+
);
|
|
1738
|
+
if (choice) {
|
|
1739
|
+
const idx = items.findIndex(i =>
|
|
1740
|
+
(displayConfig.speedStyle === i.value ? "● " : "○ ") + i.label + " " + i.preview === choice
|
|
1741
|
+
);
|
|
1742
|
+
if (idx >= 0) {
|
|
1743
|
+
displayConfig = { ...displayConfig, speedStyle: items[idx].value };
|
|
1744
|
+
await saveDisplayConfig(displayConfig);
|
|
1745
|
+
shared.requestRender?.();
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
ctx.ui.notify("显示样式已保存", "info");
|
|
1750
|
+
} else if (subChoice === "显示内容") {
|
|
1751
|
+
const itemLabels: DisplayKey[] = [
|
|
1752
|
+
"input", "output", "totalTokens", "cacheHit", "speed", "context",
|
|
1753
|
+
"quota5h", "quotaWeek", "quotaClock", "timer",
|
|
1754
|
+
];
|
|
1755
|
+
const itemNames: Record<DisplayKey, string> = {
|
|
1756
|
+
timer: "计时器", input: "输入", output: "输出", totalTokens: "总token",
|
|
1757
|
+
cacheHit: "缓存命中", speed: "速度", context: "容量",
|
|
1758
|
+
quota5h: "5h额度", quotaWeek: "周额度", quotaClock: "刷新时间",
|
|
1759
|
+
};
|
|
1760
|
+
while (true) {
|
|
1761
|
+
const options = itemLabels.map(k =>
|
|
1762
|
+
`${displayConfig.items[k] ? "✅" : "⬜"} ${itemNames[k]}`,
|
|
1763
|
+
);
|
|
1764
|
+
options.push("🔙 完成");
|
|
1765
|
+
const choice = await ctx.ui.select("选择要切换显示的项目", options);
|
|
1766
|
+
if (!choice || choice === "🔙 完成") break;
|
|
1767
|
+
const idx = options.indexOf(choice);
|
|
1768
|
+
if (idx >= 0 && idx < itemLabels.length) {
|
|
1769
|
+
const key = itemLabels[idx];
|
|
1770
|
+
displayConfig = {
|
|
1771
|
+
...displayConfig,
|
|
1772
|
+
items: { ...displayConfig.items, [key]: !displayConfig.items[key] },
|
|
1773
|
+
};
|
|
1774
|
+
await saveDisplayConfig(displayConfig);
|
|
1775
|
+
shared.requestRender?.();
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
ctx.ui.notify("状态栏显示配置已保存", "info");
|
|
1779
|
+
} else if (subChoice === "刷新时间 (当前 " + (tokenConfig?.ttl || 60) + "s)") {
|
|
1780
|
+
const input = await ctx.ui.input("输入刷新间隔(秒)", String(tokenConfig?.ttl || 60));
|
|
1781
|
+
if (input) {
|
|
1782
|
+
const sec = parseInt(input, 10);
|
|
1783
|
+
if (Number.isNaN(sec) || sec < 10) {
|
|
1784
|
+
ctx.ui.notify("刷新时间必须 >= 10 秒", "warning");
|
|
1785
|
+
} else {
|
|
1786
|
+
tokenConfig = tokenConfig
|
|
1787
|
+
? { ...tokenConfig, ttl: sec }
|
|
1788
|
+
: { providerPlans: {}, ttl: sec };
|
|
1789
|
+
await saveTokenConfig(tokenConfig);
|
|
1790
|
+
// 重设定时器
|
|
1791
|
+
if (quotaTimerId) clearInterval(quotaTimerId);
|
|
1792
|
+
quotaTimerId = setInterval(async () => {
|
|
1793
|
+
if (!shared.sessionActive) return;
|
|
1794
|
+
try {
|
|
1795
|
+
await refreshQuota(ctx);
|
|
1796
|
+
} catch { /* ctx 已失效(session 被替换),忽略 */ }
|
|
1797
|
+
shared.requestRender?.();
|
|
1798
|
+
}, sec * 1000);
|
|
1799
|
+
ctx.ui.notify("刷新时间已设为 " + sec + " 秒", "info");
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
if (arg === "today" || arg === "day") {
|
|
1807
|
+
await showDay(getDateStr(), ctx);
|
|
1808
|
+
} else if (arg.startsWith("day ")) {
|
|
1809
|
+
const date = arg.slice(4).trim();
|
|
1810
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
1811
|
+
await showDay(date, ctx);
|
|
1812
|
+
} else {
|
|
1813
|
+
ctx.ui.notify("用法: /stats day YYYY-MM-DD", "warning");
|
|
1814
|
+
}
|
|
1815
|
+
} else if (arg === "hour") {
|
|
1816
|
+
await showHourly(getDateStr(), ctx);
|
|
1817
|
+
} else if (arg.startsWith("hour ")) {
|
|
1818
|
+
const date = arg.slice(5).trim();
|
|
1819
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
1820
|
+
await showHourly(date, ctx);
|
|
1821
|
+
} else {
|
|
1822
|
+
ctx.ui.notify("用法: /stats hour YYYY-MM-DD", "warning");
|
|
1823
|
+
}
|
|
1824
|
+
} else if (arg === "week") {
|
|
1825
|
+
await showWeek(ctx);
|
|
1826
|
+
} else if (arg === "month") {
|
|
1827
|
+
await showMonth(getMonthStr(), ctx);
|
|
1828
|
+
} else if (arg.startsWith("month ")) {
|
|
1829
|
+
const ms = arg.slice(6).trim();
|
|
1830
|
+
if (/^\d{4}-\d{2}$/.test(ms)) {
|
|
1831
|
+
await showMonth(ms, ctx);
|
|
1832
|
+
} else {
|
|
1833
|
+
ctx.ui.notify("用法: /stats month YYYY-MM", "warning");
|
|
1834
|
+
}
|
|
1835
|
+
} else {
|
|
1836
|
+
ctx.ui.notify(
|
|
1837
|
+
"用法: /stats [day [date] | hour [date] | week | month [YYYY-MM] | config]",
|
|
1838
|
+
"warning",
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
},
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
return {
|
|
1845
|
+
getMetricParts,
|
|
1846
|
+
isTimerEnabled: () => displayConfig.items.timer === true,
|
|
1847
|
+
};
|
|
1848
|
+
}
|