balance-dsh 2.0.8 → 2.1.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/lib/client.js +2725 -376
- package/lib/index.js +1322 -159
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// dsh 插件:余额与当前会话消耗
|
|
2
2
|
// 服务端:注册 /balance 命令 + /balance-api HTTP 接口(供 Web UI 插件直接渲染)
|
|
3
|
+
// 只引 Node 标准库(不是 @deepseek-ai/dsh-*),用于读取磁盘上实际安装的版本号。
|
|
4
|
+
import { statSync, readFileSync } from "node:fs";
|
|
5
|
+
|
|
3
6
|
export const name = "balance-dsh";
|
|
4
7
|
|
|
5
8
|
let legacySessionInspector;
|
|
@@ -50,6 +53,158 @@ async function inspectPersistedSession(ctx, sessionId) {
|
|
|
50
53
|
throw new Error("当前 DeepSeek Harness 未提供兼容的持久化会话读取接口");
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
// ── 持久化读取缓存(掉帧与"加载慢"的根治点)──────────────────────────────────
|
|
57
|
+
// 一次 inspect 就是把整个 zstd 文件**逐帧解压 + 逐行 JSON.parse**。本机实测:
|
|
58
|
+
// 35.8 MB 的会话 = 19331 帧、解压后 134 MB、单次 **723 ms**(另外两个 689 / 735 ms)。
|
|
59
|
+
// 这是**同步 CPU**,而插件与 DSH 同进程 —— 它一跑,整个界面就掉帧;/balance-api
|
|
60
|
+
// 撞上它,插件就是"加载慢"。更糟的是每次轮询都会重放(主会话回读 + 每个子代理各一次)。
|
|
61
|
+
//
|
|
62
|
+
// 所以:**会话内容没变,就绝不解第二遍**。指纹取 sessionController.list() 的 updatedAt
|
|
63
|
+
// (只读 header,很便宜),变了才重读。轮询期因此是 0 磁盘开销。
|
|
64
|
+
const PERSIST_CACHE_MAX = 240; // 最多缓存多少个会话
|
|
65
|
+
// ⚠️ 只管条数是不够的(2026-10-31 全量审查):单个大会话解压后就有 134 MB
|
|
66
|
+
// (实测 35.8 MB zstd → 19331 帧),240 个就是几十 GB。插件和 DSH 同进程,
|
|
67
|
+
// 真撞上就是整个应用 OOM —— 不是掉帧、不是慢,是直接崩。
|
|
68
|
+
// 所以再加一道字节预算,两条谁先到就淘汰谁;Map 插入序 + 命中者挪尾 = LRU。
|
|
69
|
+
const PERSIST_CACHE_BYTES = 512 * 1024 * 1024;
|
|
70
|
+
const PERSIST_CACHE_TTL_MS = 30 * 60 * 1000; // 指纹可用时:半小时没再访问就丢,别无限长
|
|
71
|
+
// 指纹拿不到(旧版 DSH 没有 sessionController.list)时只能用时间兜底 —— 此时必须
|
|
72
|
+
// 给一个很短的保质期,否则内容更新了也要等半小时才看见。代价是这种情况下仍会
|
|
73
|
+
// 每 5 秒重解压一次,但这已经是"能用"的下限了。
|
|
74
|
+
const PERSIST_STAMPLESS_TTL_MS = 5 * 1000;
|
|
75
|
+
const SESSION_STAMP_TTL_MS = 5 * 1000; // 指纹来源(会话列表)缓存 5 秒
|
|
76
|
+
// `?session=` 走持久化回退时的最短重解压间隔。对话进行中会话每轮都在写,
|
|
77
|
+
// 指纹必然每次都变;不节流就是「每个 chunk 一轮 800 ms 同步解压」。
|
|
78
|
+
// 4 秒的滞后在这个场景里感知不到(余额轮询本身 60 秒一轮)。
|
|
79
|
+
const SESSION_REREAD_MIN_MS = 4 * 1000;
|
|
80
|
+
|
|
81
|
+
const persistCache = new Map(); // sessionId -> { stamp, at, value, size }(插入序 = LRU 序)
|
|
82
|
+
const persistInFlight = new Map(); // sessionId -> Promise(同会话并发单飞)
|
|
83
|
+
// 当前缓存里所有 events 的估算总字节(estimateBytes 的累加)。
|
|
84
|
+
// 维护成一个变量而不是每次遍历求和:淘汰循环里会反复用到,O(n) 不可接受。
|
|
85
|
+
let persistCacheBytes = 0;
|
|
86
|
+
let sessionStampCache = { at: 0, map: new Map() };
|
|
87
|
+
|
|
88
|
+
/** sessionId -> updatedAt 指纹表。列表很便宜(不读事件),但仍缓存 5 秒挡掉连打。 */
|
|
89
|
+
async function sessionStampMap(ctx) {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
if (now - sessionStampCache.at < SESSION_STAMP_TTL_MS) return sessionStampCache.map;
|
|
92
|
+
const map = new Map();
|
|
93
|
+
const collect = (res) => {
|
|
94
|
+
const items = Array.isArray(res?.items) ? res.items : Array.isArray(res) ? res : [];
|
|
95
|
+
for (const it of items) {
|
|
96
|
+
const id = it?.sessionId ?? it?.id;
|
|
97
|
+
if (typeof id !== "string" || id === "") continue;
|
|
98
|
+
// 新版是 updatedAt,旧版是 createdAt;都没有就留空串(等于"可能变了")
|
|
99
|
+
map.set(id, String(it?.updatedAt ?? it?.createdAt ?? ""));
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
const getService = typeof ctx?.get === "function" ? ctx.get.bind(ctx) : null;
|
|
104
|
+
const controller = getService?.("sessionController");
|
|
105
|
+
if (typeof controller?.list === "function") {
|
|
106
|
+
collect(await controller.list());
|
|
107
|
+
} else {
|
|
108
|
+
const persistence = getService?.("sessionPersistence");
|
|
109
|
+
if (typeof persistence?.list === "function") collect(await persistence.list());
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// 列表拿不到:指纹退化成空串,缓存仍有效(只是内容变了也会命中,见下方注释)
|
|
113
|
+
}
|
|
114
|
+
sessionStampCache = { at: now, map };
|
|
115
|
+
return map;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 带指纹缓存的持久化读取。所有需要「读某个会话全部事件」的地方都必须走这里。
|
|
119
|
+
*
|
|
120
|
+
* `minIntervalMs`:同一会话两次**真正解压**之间的最短间隔。默认 0 = 只看指纹。
|
|
121
|
+
* 跑 `?session=` 时必须传一个正值 —— 正在对话的会话每轮都在写,`updatedAt` 指纹
|
|
122
|
+
* 每次都变,只看指纹等于「每来一次请求就把 46 MB zstd 重新解压一遍」(实测单次
|
|
123
|
+
* 约 800 ms 同步 CPU,插件与 DSH 同进程,界面直接掉帧)。传 4 秒后最多滞后 4 秒
|
|
124
|
+
* 拿到最新数字,而余额/今日本来就是几十秒一轮的节奏,感知不到。
|
|
125
|
+
*/
|
|
126
|
+
const persistReadAt = new Map(); // sessionId -> 上次真正解压的时刻
|
|
127
|
+
async function inspectPersistedCached(ctx, sessionId, minIntervalMs = 0) {
|
|
128
|
+
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
129
|
+
// 每帧按 4 KB 估算占用:实测 35.8 MB 的 zstd 会话 = 19331 帧 / 解压后 134 MB
|
|
130
|
+
// (约 6.9 KB/帧)。这个数只用来做淘汰决策,不参与任何计费。
|
|
131
|
+
const estimateBytes = (value) => {
|
|
132
|
+
const events = value?.events;
|
|
133
|
+
return Array.isArray(events) ? events.length * 4096 : 0;
|
|
134
|
+
};
|
|
135
|
+
const dropEntry = (key) => {
|
|
136
|
+
const rec = persistCache.get(key);
|
|
137
|
+
if (rec !== undefined) {
|
|
138
|
+
persistCache.delete(key);
|
|
139
|
+
persistCacheBytes -= rec.size ?? 0;
|
|
140
|
+
}
|
|
141
|
+
// persistReadAt 也必须跟着删:否则跨月累积上千个会话 id 永远不会回收。
|
|
142
|
+
persistReadAt.delete(key);
|
|
143
|
+
};
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
const stamps = await sessionStampMap(ctx);
|
|
146
|
+
const hasStamp = stamps.has(sessionId);
|
|
147
|
+
const stamp = hasStamp ? stamps.get(sessionId) : "";
|
|
148
|
+
|
|
149
|
+
const hit = persistCache.get(sessionId);
|
|
150
|
+
// 命中条件:指纹相同 + 未过期。指纹可用时给 30 分钟长效(内容一变指纹就不同,
|
|
151
|
+
// 立刻失效);拿不到指纹时只有 5 秒保质期 —— 否则内容更新了也要等半小时才看见。
|
|
152
|
+
const ttl = hasStamp ? PERSIST_CACHE_TTL_MS : PERSIST_STAMPLESS_TTL_MS;
|
|
153
|
+
if (hit !== undefined && hit.stamp === stamp && now - hit.at < ttl) {
|
|
154
|
+
// 命中要把这一项挪到 Map 末尾 —— Map 保持插入序,淘汰取第一个键,
|
|
155
|
+
// 不挪就等于 FIFO:正在用的那个大会话会被后面进来的小会话挤掉,
|
|
156
|
+
// 下一轮又得重解压一遍 134 MB。
|
|
157
|
+
persistCache.delete(sessionId);
|
|
158
|
+
persistCache.set(sessionId, hit);
|
|
159
|
+
return hit.value;
|
|
160
|
+
}
|
|
161
|
+
// 指纹变了但还没到最短重读间隔:先把上一份给出去,别为了几秒的新鲜度去解压 46 MB。
|
|
162
|
+
if (minIntervalMs > 0 && hit !== undefined) {
|
|
163
|
+
const lastAt = persistReadAt.get(sessionId) ?? 0;
|
|
164
|
+
if (now - lastAt < minIntervalMs) return hit.value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const running = persistInFlight.get(sessionId);
|
|
168
|
+
if (running !== undefined) return running;
|
|
169
|
+
|
|
170
|
+
const task = (async () => {
|
|
171
|
+
try {
|
|
172
|
+
const value = await inspectPersistedSession(ctx, sessionId);
|
|
173
|
+
// 覆盖同一会话时先把旧那份的字节数扣掉,否则总量只增不减。
|
|
174
|
+
if (persistCache.has(sessionId)) dropEntry(sessionId);
|
|
175
|
+
const size = estimateBytes(value);
|
|
176
|
+
persistCache.set(sessionId, { stamp, at: Date.now(), value, size });
|
|
177
|
+
persistCacheBytes += size;
|
|
178
|
+
persistReadAt.set(sessionId, Date.now());
|
|
179
|
+
// ⚠️ 淘汰必须同时看**条数**和**字节数**(2026-10-31 全量审查)。
|
|
180
|
+
// 原来只看 240 条:单个大会话解压后就是 134 MB,240 个就是几十 GB,
|
|
181
|
+
// 而插件和 DSH 同进程 —— 真撞上就是整个应用 OOM,不是掉几帧。
|
|
182
|
+
// 条数保留是为了兜住「很多小会话」那种情况(字节预算对它们不敏感)。
|
|
183
|
+
while (
|
|
184
|
+
persistCache.size > PERSIST_CACHE_MAX ||
|
|
185
|
+
(persistCacheBytes > PERSIST_CACHE_BYTES && persistCache.size > 1)
|
|
186
|
+
) {
|
|
187
|
+
const oldest = persistCache.keys().next().value;
|
|
188
|
+
if (oldest === undefined) break;
|
|
189
|
+
// 最后一项即使超预算也不淘汰:否则大会话每次都被自己挤掉,等于没有缓存。
|
|
190
|
+
if (oldest === sessionId && persistCache.size > 1) {
|
|
191
|
+
const fallback = [...persistCache.keys()][1];
|
|
192
|
+
if (fallback !== undefined) dropEntry(fallback);
|
|
193
|
+
else break;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
dropEntry(oldest);
|
|
197
|
+
}
|
|
198
|
+
return value;
|
|
199
|
+
} finally {
|
|
200
|
+
persistInFlight.delete(sessionId);
|
|
201
|
+
}
|
|
202
|
+
})();
|
|
203
|
+
persistInFlight.set(sessionId, task);
|
|
204
|
+
return task;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
53
208
|
const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
|
|
54
209
|
|
|
55
210
|
// DeepSeek 目录价(人民币/百万 tokens),按模型区分。默认取 2026-08-17 生效的
|
|
@@ -158,7 +313,15 @@ async function fetchCalendarYear(year) {
|
|
|
158
313
|
if (!Array.isArray(data?.days)) continue;
|
|
159
314
|
let count = 0;
|
|
160
315
|
for (const item of data.days) {
|
|
161
|
-
|
|
316
|
+
// 键格式与总量都要校验(2026-10-31 审计 M11):上游是第三方仓库,
|
|
317
|
+
// 一个畸形 date 会以垃圾键写进 CN_CALENDAR 并落盘;无上限则一份超大响应能把表撑爆。
|
|
318
|
+
if (count >= 400) break;
|
|
319
|
+
if (
|
|
320
|
+
item &&
|
|
321
|
+
typeof item.date === "string" &&
|
|
322
|
+
/^\d{4}-\d{2}-\d{2}$/.test(item.date) &&
|
|
323
|
+
typeof item.isOffDay === "boolean"
|
|
324
|
+
) {
|
|
162
325
|
const kind = item.isOffDay ? "holiday" : "workday";
|
|
163
326
|
CN_CALENDAR[item.date] = kind;
|
|
164
327
|
cnCalendarState.days[item.date] = kind;
|
|
@@ -185,7 +348,13 @@ async function loadCalendarCache(ctx) {
|
|
|
185
348
|
if (days === null) return false;
|
|
186
349
|
let count = 0;
|
|
187
350
|
for (const [date, kind] of Object.entries(days)) {
|
|
188
|
-
|
|
351
|
+
// 同上:本地缓存也可能是旧版/被手改的,键格式必须校验,否则垃圾键会活到下一轮计费。
|
|
352
|
+
if (count >= 400) break;
|
|
353
|
+
if (
|
|
354
|
+
typeof date === "string" &&
|
|
355
|
+
/^\d{4}-\d{2}-\d{2}$/.test(date) &&
|
|
356
|
+
(kind === "holiday" || kind === "workday")
|
|
357
|
+
) {
|
|
189
358
|
CN_CALENDAR[date] = kind;
|
|
190
359
|
cnCalendarState.days[date] = kind;
|
|
191
360
|
count += 1;
|
|
@@ -213,7 +382,10 @@ async function saveCalendarCache() {
|
|
|
213
382
|
const { mkdir, writeFile, rename } = await import("node:fs/promises");
|
|
214
383
|
const { dirname } = await import("node:path");
|
|
215
384
|
await mkdir(dirname(file), { recursive: true });
|
|
216
|
-
|
|
385
|
+
// 临时名必须唯一(2026-10-31 审计 L3):固定 .tmp 在并发保存时会互相覆盖,
|
|
386
|
+
// 后完成的那次 rename 拿到的是前一次的半份内容,甚至先 rename 完的那个把 tmp 删掉后
|
|
387
|
+
// 另一个 rename 直接 ENOENT。
|
|
388
|
+
const tmp = file + "." + process.pid + "." + Math.random().toString(36).slice(2) + ".tmp";
|
|
217
389
|
const payload = {
|
|
218
390
|
fetchedAt: cnCalendarState.fetchedAt,
|
|
219
391
|
years: cnCalendarState.years,
|
|
@@ -328,7 +500,17 @@ function cnDateKey(d) {
|
|
|
328
500
|
}
|
|
329
501
|
|
|
330
502
|
/** 判断某时间戳(毫秒)是否处于高峰时段(北京时间;含周末、法定节假日与调休上班判断)。 */
|
|
331
|
-
|
|
503
|
+
// 窗口/星期只跟 config 有关,不该每帧重建(2026-10-31 审计 M8):
|
|
504
|
+
// 两万帧的会话 ≈ 4000 次调用,原来每次都要 map 两个数组、分配两份对象。
|
|
505
|
+
// withSettings 每次会产出**新对象**,所以用 WeakMap 按 config 身份缓存,自动随 GC 释放。
|
|
506
|
+
// 日历查询必须留在函数体里(CN_CALENDAR 会随同步被填充,缓存住会读到旧表)。
|
|
507
|
+
const peakRuleCache = new WeakMap();
|
|
508
|
+
function peakRuleOf(config) {
|
|
509
|
+
const cacheable = config != null && typeof config === "object";
|
|
510
|
+
if (cacheable) {
|
|
511
|
+
const hit = peakRuleCache.get(config);
|
|
512
|
+
if (hit !== void 0) return hit;
|
|
513
|
+
}
|
|
332
514
|
const windows = (Array.isArray(config?.peakWindows) && config.peakWindows.length > 0
|
|
333
515
|
? config.peakWindows
|
|
334
516
|
: DEFAULT_PEAK_WINDOWS
|
|
@@ -336,6 +518,12 @@ function isPeakAt(timeMs, config) {
|
|
|
336
518
|
const weekdays = Array.isArray(config?.peakWeekdays) && config.peakWeekdays.length > 0
|
|
337
519
|
? config.peakWeekdays.map(Number)
|
|
338
520
|
: DEFAULT_PEAK_WEEKDAYS;
|
|
521
|
+
const rule = { windows, weekdays };
|
|
522
|
+
if (cacheable) peakRuleCache.set(config, rule);
|
|
523
|
+
return rule;
|
|
524
|
+
}
|
|
525
|
+
function isPeakAt(timeMs, config) {
|
|
526
|
+
const { windows, weekdays } = peakRuleOf(config);
|
|
339
527
|
const d = new Date(Number(timeMs) + 8 * 60 * 60 * 1000); // 北京时间
|
|
340
528
|
const kind = CN_CALENDAR[cnDateKey(d)];
|
|
341
529
|
if (kind === "holiday") return false; // 法定节假日:全天按空闲计费
|
|
@@ -418,15 +606,26 @@ function sessionEvents(session) {
|
|
|
418
606
|
function detectModel(session) {
|
|
419
607
|
if (!session) return "";
|
|
420
608
|
// 1) DSH 官方折叠 API:requestHeader().config.model 或 requestContext().model
|
|
609
|
+
// ⚠️ 两个调用都必须包住(2026-10-31 全量审查):它们是 DSH 内部方法,
|
|
610
|
+
// 会话处于半装载 / 已释放状态时抛错很正常。detectModel 在 ?session= 主路径上,
|
|
611
|
+
// 这里一抛就是整条接口 500,而模型只是「用来选价格」的次要信息,拿不到还有兜底。
|
|
421
612
|
if (typeof session.requestHeader === "function") {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
613
|
+
try {
|
|
614
|
+
const header = session.requestHeader();
|
|
615
|
+
const m = header?.config?.model;
|
|
616
|
+
if (typeof m === "string" && m !== "") return m;
|
|
617
|
+
} catch {
|
|
618
|
+
// 折叠头拿不到就继续往下走
|
|
619
|
+
}
|
|
425
620
|
}
|
|
426
621
|
if (typeof session.requestContext === "function") {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
622
|
+
try {
|
|
623
|
+
const ctx = session.requestContext();
|
|
624
|
+
const m = ctx?.model;
|
|
625
|
+
if (typeof m === "string" && m !== "") return m;
|
|
626
|
+
} catch {
|
|
627
|
+
// 同上
|
|
628
|
+
}
|
|
430
629
|
}
|
|
431
630
|
// 2) 兜底:遍历事件
|
|
432
631
|
let model = "";
|
|
@@ -446,12 +645,19 @@ async function resolveApiKey(ctx, config) {
|
|
|
446
645
|
if (typeof config?.apiKey === "string" && config.apiKey.trim() !== "") {
|
|
447
646
|
return config.apiKey.trim();
|
|
448
647
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
648
|
+
// credentials 是可选服务,ctx.get 本身可能抛;resolve() 也会抛(凭据存储损坏 / 未解锁)。
|
|
649
|
+
// resolved.value 更不能裸取长度(2026-10-31 审计 L4)—— 这里的异常会一路冒到 HTTP handler,
|
|
650
|
+
// 变成零字节响应 + 客户端挂到超时。拿不到就当没有,交给后面的环境变量。
|
|
651
|
+
try {
|
|
652
|
+
const credentials = ctx.get("credentials");
|
|
653
|
+
if (credentials !== void 0 && credentials !== null && typeof credentials.resolve === "function") {
|
|
654
|
+
const resolved = await credentials.resolve("DEEPSEEK_API_KEY");
|
|
655
|
+
if (resolved != null && typeof resolved.value === "string" && resolved.value.length > 0) {
|
|
656
|
+
return resolved.value;
|
|
657
|
+
}
|
|
454
658
|
}
|
|
659
|
+
} catch {
|
|
660
|
+
// 凭据服务不可用:继续往下找环境变量
|
|
455
661
|
}
|
|
456
662
|
if (process.env.DEEPSEEK_API_KEY && process.env.DEEPSEEK_API_KEY.trim() !== "") {
|
|
457
663
|
return process.env.DEEPSEEK_API_KEY.trim();
|
|
@@ -499,7 +705,12 @@ function normalizeBalance(data) {
|
|
|
499
705
|
topped_up_balance: "0.00",
|
|
500
706
|
};
|
|
501
707
|
}
|
|
502
|
-
|
|
708
|
+
// ⚠️ 不能直接 infos[0](2026-10-31 全量审查):
|
|
709
|
+
// 官方账号路径(normalizeAccountBalance)已经改成「只认计 CNY 钱包」,
|
|
710
|
+
// 而这条 API Key 路径还是取数组第一个 —— 两条路径对同一个账号
|
|
711
|
+
// 可能给出不同币种、不同数字。取币种名确定一的那个,
|
|
712
|
+
// 没有 CNY 才退回首位。
|
|
713
|
+
const info = infos.find((w) => String(w && w.currency).toUpperCase() === "CNY") ?? infos[0];
|
|
503
714
|
return {
|
|
504
715
|
is_available: data?.is_available !== false,
|
|
505
716
|
currency: info.currency || "",
|
|
@@ -513,7 +724,8 @@ function formatBalance(balance) {
|
|
|
513
724
|
if (!balance.is_available) {
|
|
514
725
|
return "余额不可用:账户当前没有可用余额。";
|
|
515
726
|
}
|
|
516
|
-
|
|
727
|
+
// 主数字是充值余额;充值花光但还有赠送时不能报「暂无余额」。
|
|
728
|
+
if ((!balance.total_balance || balance.total_balance === "0.00") && (!balance.granted_balance || balance.granted_balance === "0.00")) {
|
|
517
729
|
return "余额查询成功,但暂无余额明细。";
|
|
518
730
|
}
|
|
519
731
|
return `余额(${balance.currency || "CNY"}):${balance.total_balance}\n 充值余额:${balance.topped_up_balance}\n 赠送余额:${balance.granted_balance}`;
|
|
@@ -527,7 +739,31 @@ function formatBalance(balance) {
|
|
|
527
739
|
// | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
|
|
528
740
|
// client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
|
|
529
741
|
// 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
|
|
530
|
-
const PLUGIN_VERSION = "2.0
|
|
742
|
+
const PLUGIN_VERSION = "2.1.0";
|
|
743
|
+
|
|
744
|
+
/** 磁盘上**实际安装**的版本号。
|
|
745
|
+
*
|
|
746
|
+
* `PLUGIN_VERSION` 是编译进代码里的常量 —— 插件升级后新文件已经落盘,但插件进程
|
|
747
|
+
* 仍是旧实例,于是设置页一直显示旧版本号,非要重启 DeepSeek Harness 才变
|
|
748
|
+
* (用户 2026-10-31 反馈)。
|
|
749
|
+
*
|
|
750
|
+
* 这里改成读 package.json,按 mtime 缓存:文件没换过只多一次 statSync,
|
|
751
|
+
* 换过立刻读到新值。读不到(打包方式异常 / 权限)就退回常量,绝不抛错。
|
|
752
|
+
*/
|
|
753
|
+
let installedVersionCache = { mtimeMs: -1, value: PLUGIN_VERSION };
|
|
754
|
+
function installedVersion() {
|
|
755
|
+
try {
|
|
756
|
+
const file = new URL("../package.json", import.meta.url);
|
|
757
|
+
const stat = statSync(file);
|
|
758
|
+
if (stat.mtimeMs === installedVersionCache.mtimeMs) return installedVersionCache.value;
|
|
759
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
760
|
+
const v = typeof parsed?.version === "string" && parsed.version !== "" ? parsed.version : PLUGIN_VERSION;
|
|
761
|
+
installedVersionCache = { mtimeMs: stat.mtimeMs, value: v };
|
|
762
|
+
return v;
|
|
763
|
+
} catch {
|
|
764
|
+
return PLUGIN_VERSION;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
531
767
|
|
|
532
768
|
// ─── 检查更新 ───────────────────────────────────────────────────────────────
|
|
533
769
|
// 只查 npm registry 的 latest 标签(一个很小的 JSON),不下载任何东西。
|
|
@@ -622,8 +858,11 @@ async function installLatestVersion(ctx) {
|
|
|
622
858
|
const found = await fetchLatestVersion();
|
|
623
859
|
if (found.error) return { ok: false, error: found.error };
|
|
624
860
|
const latest = found.latest;
|
|
625
|
-
|
|
626
|
-
|
|
861
|
+
// 比的是「磁盘上装的版本」而不是编译时常量:否则插件升级后、进程还没重启时,
|
|
862
|
+
// 明明已经是最新也会被判成有更新可装。
|
|
863
|
+
const currentVer = installedVersion();
|
|
864
|
+
if (compareVersion(latest, currentVer) <= 0) {
|
|
865
|
+
return { ok: true, installed: false, from: currentVer, to: latest, note: "已是最新版本" };
|
|
627
866
|
}
|
|
628
867
|
|
|
629
868
|
const root = await installedPluginDir();
|
|
@@ -656,7 +895,7 @@ async function installLatestVersion(ctx) {
|
|
|
656
895
|
// 备份目录:拿不到 dshHome 就跳过备份(仍继续更新,失败项会体现在 failed 里)
|
|
657
896
|
const home = dshHomeDir(ctx);
|
|
658
897
|
const backupRoot =
|
|
659
|
-
home === "" ? "" : join(home, "balance-update-backup",
|
|
898
|
+
home === "" ? "" : join(home, "balance-update-backup", installedVersion() + "-" + Date.now());
|
|
660
899
|
|
|
661
900
|
const written = [];
|
|
662
901
|
const failed = [];
|
|
@@ -712,7 +951,7 @@ async function installLatestVersion(ctx) {
|
|
|
712
951
|
return {
|
|
713
952
|
ok: true,
|
|
714
953
|
installed: true,
|
|
715
|
-
from:
|
|
954
|
+
from: currentVer,
|
|
716
955
|
to: latest,
|
|
717
956
|
files: written.length,
|
|
718
957
|
failed,
|
|
@@ -749,44 +988,92 @@ async function fetchLatestVersion(force) {
|
|
|
749
988
|
* 读取官方账号余额并归一化成与 normalizeBalance 相同的形状。
|
|
750
989
|
* 服务不存在、未登录、接口报错一律返回 null,由调用方回退 API Key,绝不因此抛错。
|
|
751
990
|
*/
|
|
991
|
+
//
|
|
992
|
+
// ⚠️ 服务端单飞(2026-10-31 全量审查):官方余额接口实测 419ms,
|
|
993
|
+
// 而 /balance-api 的每一个分支(?session= / ?today=1 / ?stats=1)都会顺手叫一次。
|
|
994
|
+
// 多窗口 / 多 slot / 启动时短信几个请求并发就会重复查同一份余额。
|
|
995
|
+
// 这里只合并**同时进行中**的请求(single-flight),**不做 TTL 缓存** ——
|
|
996
|
+
// 余额不能因为服务端缓存而变旧(用户右键刷新就是要看最新值),
|
|
997
|
+
// 只把「同一瞬间的重复开销」去掉,单个请求的新鲜度与原来完全一致。
|
|
998
|
+
let accountBalanceInFlight = null;
|
|
999
|
+
|
|
752
1000
|
async function fetchAccountBalance(ctx) {
|
|
753
1001
|
const account = typeof ctx.get === "function" ? ctx.get("deepseekAccount") : void 0;
|
|
754
1002
|
if (account === void 0 || account === null || typeof account.getBalance !== "function") return null;
|
|
755
|
-
|
|
1003
|
+
if (accountBalanceInFlight !== null) return accountBalanceInFlight;
|
|
1004
|
+
accountBalanceInFlight = (async () => {
|
|
1005
|
+
let result;
|
|
1006
|
+
try {
|
|
1007
|
+
result = await account.getBalance({
|
|
1008
|
+
version: PLUGIN_VERSION,
|
|
1009
|
+
locale: "zh-CN",
|
|
1010
|
+
// 平台要求“东为正”的整秒偏移;JS 的 getTimezoneOffset() 是西为正,故取反。
|
|
1011
|
+
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
|
|
1012
|
+
});
|
|
1013
|
+
} catch {
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
if (result === null || result === void 0 || result.status !== "ready") return null;
|
|
1017
|
+
return normalizeAccountBalance(result);
|
|
1018
|
+
})();
|
|
756
1019
|
try {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
// 平台要求“东为正”的整秒偏移;JS 的 getTimezoneOffset() 是西为正,故取反。
|
|
761
|
-
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
|
|
762
|
-
});
|
|
763
|
-
} catch {
|
|
764
|
-
return null;
|
|
1020
|
+
return await accountBalanceInFlight;
|
|
1021
|
+
} finally {
|
|
1022
|
+
accountBalanceInFlight = null;
|
|
765
1023
|
}
|
|
766
|
-
if (result === null || result === void 0 || result.status !== "ready") return null;
|
|
767
|
-
return normalizeAccountBalance(result);
|
|
768
1024
|
}
|
|
769
1025
|
|
|
770
1026
|
/** 官方钱包结构 → 统一余额形状。value 是充值钱包,bonusWallets 是赠送钱包(官方不计入充值余额)。 */
|
|
771
1027
|
function normalizeAccountBalance(result) {
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1028
|
+
// ⚠️ 必须把同币种的**所有**钱包求和,不能只取第一个(原来用 find 取首笔)。
|
|
1029
|
+
// 充值可能拆成多个钱包(分批到账、或充值款与其它款分开管),只取首笔会让总额
|
|
1030
|
+
// 跟着数组返回顺序变化 —— 用户 2026-10-31 报「刚充值,手动刷新几次数字反复跳动」
|
|
1031
|
+
// 就是这种表现。求和对顺序天然免疫,也才等于「账户余额」的语义。
|
|
1032
|
+
const sumByCurrency = (list, currency) => {
|
|
1033
|
+
const arr = Array.isArray(list) ? list : [];
|
|
1034
|
+
let sum = 0;
|
|
1035
|
+
let found = false;
|
|
1036
|
+
for (const w of arr) {
|
|
1037
|
+
if (!w || typeof w.balance !== "string") continue;
|
|
1038
|
+
if (w.currency !== currency) continue;
|
|
1039
|
+
const n = Number(w.balance);
|
|
1040
|
+
if (!Number.isFinite(n)) continue;
|
|
1041
|
+
sum += n;
|
|
1042
|
+
found = true;
|
|
1043
|
+
}
|
|
1044
|
+
return found ? sum : null;
|
|
775
1045
|
};
|
|
776
|
-
|
|
777
|
-
const
|
|
1046
|
+
// 币种先看充值钱包,再看赠送钱包,都没有就按 CNY。
|
|
1047
|
+
const currencyOf = (list) => {
|
|
1048
|
+
const arr = Array.isArray(list) ? list : [];
|
|
1049
|
+
const hit = arr.find((w) => w && typeof w.currency === "string" && w.currency !== "");
|
|
1050
|
+
return hit ? hit.currency : null;
|
|
1051
|
+
};
|
|
1052
|
+
const currency = currencyOf(result.value) ?? currencyOf(result.bonusWallets) ?? "CNY";
|
|
1053
|
+
const toppedSum = sumByCurrency(result.value, currency);
|
|
1054
|
+
const grantedSum = sumByCurrency(result.bonusWallets, currency);
|
|
778
1055
|
// 官方金额字符串遵循 big.js 文法(允许 0E-16 这类写法),Number() 能正确解析。
|
|
779
|
-
const
|
|
780
|
-
const
|
|
781
|
-
const toppedUp = Number.isFinite(toppedRaw) ? toppedRaw : 0;
|
|
782
|
-
const grantedAmount = Number.isFinite(grantedRaw) ? grantedRaw : 0;
|
|
783
|
-
const total = toppedUp + grantedAmount;
|
|
1056
|
+
const toppedUp = toppedSum !== null ? toppedSum : 0;
|
|
1057
|
+
const grantedAmount = grantedSum !== null ? grantedSum : 0;
|
|
784
1058
|
return {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1059
|
+
// 有赠送余额也算可用(充值款花完了、赠送还在,仍然能继续用)。
|
|
1060
|
+
is_available: toppedUp + grantedAmount > 0,
|
|
1061
|
+
currency,
|
|
1062
|
+
// ⚠️ 主数字 = **充值余额**,必须和官网「充值余额」那个数字一模一样。
|
|
1063
|
+
// 2026-10-31 用户贴官网截图「充值余额 ¥7.80」,插件显示 ¥8.10 —— 差的正是
|
|
1064
|
+
// 赠送钱包的 ¥0.30。官方页面上「充值余额」只统计 value,bonusWallets 是**另一栏**,
|
|
1065
|
+
// 把两者相加就永远对不上官网。赠送余额单独放在 granted_balance 里,不并入主数字。
|
|
1066
|
+
// (注意:value 内部仍然要**求和所有同币种钱包**,那是修「反复跳动」的另一件事,别退回 find。)
|
|
1067
|
+
total_balance: toppedUp.toFixed(2),
|
|
788
1068
|
granted_balance: grantedAmount.toFixed(2),
|
|
789
1069
|
topped_up_balance: toppedUp.toFixed(2),
|
|
1070
|
+
// 官方返回的**原始字符串**,只用于核对「插件显示的数 vs 官网显示的数」到底差在哪
|
|
1071
|
+
// (2026-10-31:官网「充值余额 ¥7.36」、插件 ¥7.37,差 0.01,光看两位小数分不出是
|
|
1072
|
+
// 舍入口径不同还是两次读取的时刻不同)。不进 UI,只在 /balance-api 里带出来。
|
|
1073
|
+
raw: {
|
|
1074
|
+
value: (Array.isArray(result.value) ? result.value : []).map((w) => (w ? { currency: w.currency, balance: w.balance } : null)),
|
|
1075
|
+
bonusWallets: (Array.isArray(result.bonusWallets) ? result.bonusWallets : []).map((w) => (w ? { currency: w.currency, balance: w.balance } : null)),
|
|
1076
|
+
},
|
|
790
1077
|
source: "account",
|
|
791
1078
|
};
|
|
792
1079
|
}
|
|
@@ -1148,7 +1435,17 @@ function estimateCost(usage, pricing, multiplier = 2) {
|
|
|
1148
1435
|
}
|
|
1149
1436
|
|
|
1150
1437
|
const STATS_DAYS = 90;
|
|
1151
|
-
|
|
1438
|
+
// 单会话解压实测 700 ms(35 MB 会话 19331 帧),扫 200 个就是 140 秒 —— 首次打开
|
|
1439
|
+
// 统计面板会像死机。默认降到 80,配合 inspectPersistedCached 的指纹缓存,
|
|
1440
|
+
// 第二次之后基本是 0 成本。要更久的历史可在设置里调 statsSessionLimit。
|
|
1441
|
+
const STATS_MAX_SESSIONS = 80;
|
|
1442
|
+
// 今日消耗单独一个上限,不再跟统计面板共用(用户 2026-10-31:「启动时如果过多可以
|
|
1443
|
+
// 计算久一点,并且在设置里添加今日消耗扫描上限」)。今日是常驻数字,漏算比慢更难接受;
|
|
1444
|
+
// 配合落盘快照 + 指纹缓存,稳态下几乎不重扫,冷启动那一次慢一点可以接受。
|
|
1445
|
+
const TODAY_MAX_SESSIONS = 200;
|
|
1446
|
+
// 子代理后代会话的扫描上限。子代理会话通常比主会话小得多,但 `?session=` 每轮都要跑
|
|
1447
|
+
// 这段枚举,一个开很多子代理的长对话不能让它变成几百次串行解压(同步 CPU,会掉帧)。
|
|
1448
|
+
const SUBAGENT_MAX_SESSIONS = 60;
|
|
1152
1449
|
const STATS_CACHE_MS = 5 * 60 * 1000;
|
|
1153
1450
|
|
|
1154
1451
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -1160,6 +1457,19 @@ const STATS_CACHE_MS = 5 * 60 * 1000;
|
|
|
1160
1457
|
|
|
1161
1458
|
const SETTINGS_FILENAME = "balance-dsh.json";
|
|
1162
1459
|
|
|
1460
|
+
// ── 今日消耗快照 ──────────────────────────────────────────────────────────
|
|
1461
|
+
// 冷启动时「今日消耗」要列出今天动过的会话,再逐个读 zstd 持久化文件解压,
|
|
1462
|
+
// 会话一多就是几百 MB 的解压量,而它和余额共用同一条 /balance-api 请求 ——
|
|
1463
|
+
// 余额行得等它算完才出来,加载动效就卡在那一段上(2026-10-31 反馈「每次打开
|
|
1464
|
+
// 加载动画都会卡」)。
|
|
1465
|
+
// 解法:算完就把结果落盘,下次启动直接读这个小 JSON 立刻显示,重算放到后台。
|
|
1466
|
+
const TODAY_SNAPSHOT_FILENAME = "balance-today.json";
|
|
1467
|
+
// 跨天时旧快照不能直接丢:强制退出时最后一段 usage 可能还没落到会话文件里,
|
|
1468
|
+
// 那部分是快照独有的。先把它结算进这里,统计弹窗再按日期合并。
|
|
1469
|
+
const DAILY_SETTLED_FILENAME = "balance-daily-settled.json";
|
|
1470
|
+
// 快照超过这个年龄就认为「可能过期了」,触发一次后台重算(不阻塞响应)。
|
|
1471
|
+
const TODAY_SNAPSHOT_STALE_MS = 45 * 1000;
|
|
1472
|
+
|
|
1163
1473
|
/** 出厂默认值;同时是字段白名单(不在这里的键一律丢弃)。 */
|
|
1164
1474
|
const DEFAULT_SETTINGS = {
|
|
1165
1475
|
// 余额
|
|
@@ -1187,7 +1497,7 @@ const DEFAULT_SETTINGS = {
|
|
|
1187
1497
|
// 与版本号无关:只有用户真正点开过这个面板才会被写成 true,之后永不回退。
|
|
1188
1498
|
entrySeen: false,
|
|
1189
1499
|
// NEW 徽标的计时起点:记录「当前版本首次启动」的时间戳与版本号。
|
|
1190
|
-
//
|
|
1500
|
+
// 版本一变(用户升级了)就重置计时;设置项旁那颗 NEW 亮 24 小时。
|
|
1191
1501
|
newTagVersion: "",
|
|
1192
1502
|
newTagSince: 0,
|
|
1193
1503
|
// 升级前的版本(用户上一次跑的是哪个版本)。
|
|
@@ -1195,16 +1505,20 @@ const DEFAULT_SETTINGS = {
|
|
|
1195
1505
|
// 这样跨几个版本一次升上来的用户能看到中间所有新功能,
|
|
1196
1506
|
// 已经跑过某个版本的用户不会被重复提示。
|
|
1197
1507
|
newTagFrom: "",
|
|
1198
|
-
// 逐项计时:{ [配置项 key]: { v: 引入版本, t:
|
|
1199
|
-
// 每个新功能各自走自己的
|
|
1200
|
-
//
|
|
1508
|
+
// 逐项计时:{ [配置项 key]: { v: 引入版本, t: 首次看到的时间戳, e?: 点进过设置页 } }。
|
|
1509
|
+
// 每个新功能各自走自己的 24 小时,互不影响:
|
|
1510
|
+
// 旧功能没亮完时又升级,也不会被这次升级重新计时——
|
|
1201
1511
|
// 因为它记录的版本已经是用户见过的版本了。
|
|
1512
|
+
// e:true 只影响侧栏入口那颗 NEW(点进去过就不再提示),不影响设置项旁那颗。
|
|
1202
1513
|
newTagSeen: {},
|
|
1203
1514
|
// 收起态圆钮外观
|
|
1204
1515
|
fabSize: 60, // 直径(px)
|
|
1205
1516
|
fabLeft: 44, // 距窗口左缘(px)
|
|
1206
1517
|
// 统计
|
|
1207
1518
|
statsSessionLimit: STATS_MAX_SESSIONS, // 统计扫描的最近会话数
|
|
1519
|
+
// 今日消耗扫描的最近会话数。和 statsSessionLimit 分开:今日是常驻数字,
|
|
1520
|
+
// 默认放得比统计宽(用户 2026-10-31 要求「过多可以计算久一点」)。
|
|
1521
|
+
todaySessionLimit: TODAY_MAX_SESSIONS,
|
|
1208
1522
|
statsCacheSec: 300, // 统计结果缓存秒数
|
|
1209
1523
|
// 计费
|
|
1210
1524
|
// 高峰时段窗口。官方当前是「工作日北京 9-12、14-18」,但这条也是会变的
|
|
@@ -1234,16 +1548,32 @@ function clampNumber(value, min, max, fallback) {
|
|
|
1234
1548
|
* 其余一律丢弃(宁可丢一条记录重新计时,也不要带着脏数据跑)。 */
|
|
1235
1549
|
function normalizeNewTagSeen(raw) {
|
|
1236
1550
|
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
1237
|
-
const
|
|
1551
|
+
const entries = [];
|
|
1238
1552
|
for (const key of Object.keys(raw)) {
|
|
1239
|
-
if (typeof key !== "string" || key === "") continue;
|
|
1553
|
+
if (typeof key !== "string" || key === "" || key.length > 64) continue;
|
|
1240
1554
|
const rec = raw[key];
|
|
1241
1555
|
if (rec == null || typeof rec !== "object" || Array.isArray(rec)) continue;
|
|
1242
1556
|
const v = typeof rec.v === "string" ? rec.v.trim() : "";
|
|
1243
1557
|
const t = Number(rec.t);
|
|
1244
1558
|
if (v === "" || !Number.isFinite(t) || t <= 0) continue;
|
|
1245
|
-
|
|
1559
|
+
const item = { v, t: Math.floor(t) };
|
|
1560
|
+
// 额外的布尔标记(e = 用户点进过设置页)原样保留。
|
|
1561
|
+
// ⚠️ 这里**不要**逐字段白名单(只认 e、扔掉别的):每加一个标记就得改服务端,
|
|
1562
|
+
// 漏改一次就是「客户端写了、服务端一存一取给丢了」,表现为标签点了又亮回来
|
|
1563
|
+
// (2026-10-31 用户报「点进去消失 出来继续显示」,就是这个)。
|
|
1564
|
+
// 只收布尔值 + 短键名,杜绝被塞进大对象把设置文件撑爆。
|
|
1565
|
+
for (const mark of Object.keys(rec)) {
|
|
1566
|
+
if (mark === "v" || mark === "t") continue;
|
|
1567
|
+
if (mark.length > 8) continue;
|
|
1568
|
+
if (rec[mark] === true) item[mark] = true;
|
|
1569
|
+
}
|
|
1570
|
+
entries.push([key, item]);
|
|
1246
1571
|
}
|
|
1572
|
+
// 硬上限 24 条,按时间取最近的。NEW_TAGS 现在只有 4 项,正常永远碰不到这条;
|
|
1573
|
+
// 它是给「版本不断加新功能、老 key 却没人删」兜底的,防止设置文件越写越大。
|
|
1574
|
+
entries.sort((a, b) => b[1].t - a[1].t);
|
|
1575
|
+
const out = {};
|
|
1576
|
+
for (const [key, item] of entries.slice(0, 24)) out[key] = item;
|
|
1247
1577
|
return out;
|
|
1248
1578
|
}
|
|
1249
1579
|
|
|
@@ -1322,6 +1652,7 @@ function normalizeSettings(raw) {
|
|
|
1322
1652
|
fabSize: clampNumber(src.fabSize, 32, 120, d.fabSize),
|
|
1323
1653
|
fabLeft: clampNumber(src.fabLeft, 0, 600, d.fabLeft),
|
|
1324
1654
|
statsSessionLimit: clampNumber(src.statsSessionLimit, 10, 2000, d.statsSessionLimit),
|
|
1655
|
+
todaySessionLimit: clampNumber(src.todaySessionLimit, 10, 2000, d.todaySessionLimit),
|
|
1325
1656
|
statsCacheSec: clampNumber(src.statsCacheSec, 0, 3600, d.statsCacheSec),
|
|
1326
1657
|
peakWindowsText: normalizePeakWindowsText(src.peakWindowsText, d.peakWindowsText),
|
|
1327
1658
|
peakMultiplier: clampNumber(src.peakMultiplier, 1, 10, d.peakMultiplier),
|
|
@@ -1345,6 +1676,7 @@ function withSettings(config, settings) {
|
|
|
1345
1676
|
peakMultiplier: s.peakMultiplier,
|
|
1346
1677
|
priceByModel,
|
|
1347
1678
|
statsSessionLimit: s.statsSessionLimit,
|
|
1679
|
+
todaySessionLimit: s.todaySessionLimit,
|
|
1348
1680
|
statsCacheMs: s.statsCacheSec * 1000,
|
|
1349
1681
|
};
|
|
1350
1682
|
}
|
|
@@ -1377,6 +1709,167 @@ function settingsFilePath(ctx) {
|
|
|
1377
1709
|
return `${home.replace(/[\\/]+$/, "")}${sep}${SETTINGS_FILENAME}`;
|
|
1378
1710
|
}
|
|
1379
1711
|
|
|
1712
|
+
/** 与设置文件同目录的兄弟文件路径;定位不到 dshHome 时返回空串(调用方静默降级)。 */
|
|
1713
|
+
function siblingFilePath(ctx, filename) {
|
|
1714
|
+
const settings = settingsFilePath(ctx);
|
|
1715
|
+
if (settings === "") return "";
|
|
1716
|
+
const sep = settings.includes("\\") && !settings.includes("/") ? "\\" : "/";
|
|
1717
|
+
const dir = settings.slice(0, settings.lastIndexOf(sep));
|
|
1718
|
+
return `${dir}${sep}${filename}`;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/** 北京当日 0 点对应的真实时间戳(用 UTC 分量读北京日期,再减回去)。
|
|
1722
|
+
* 「今天」的分界线按北京时间算:用户看的是北京时间,跨天清理也必须跟着它走。 */
|
|
1723
|
+
function beijingDayStart(now = Date.now()) {
|
|
1724
|
+
const bj = new Date(now + 8 * 60 * 60 * 1000);
|
|
1725
|
+
return Date.UTC(bj.getUTCFullYear(), bj.getUTCMonth(), bj.getUTCDate()) - 8 * 60 * 60 * 1000;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/** 读今日消耗快照;不存在 / 损坏 / 不是今天的都返回 null(调用方回退到现算)。 */
|
|
1729
|
+
async function readTodaySnapshot(file) {
|
|
1730
|
+
if (file === "") return null;
|
|
1731
|
+
try {
|
|
1732
|
+
const { readFile } = await import("node:fs/promises");
|
|
1733
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
1734
|
+
if (parsed == null || typeof parsed !== "object") return null;
|
|
1735
|
+
const date = typeof parsed.date === "string" ? parsed.date : "";
|
|
1736
|
+
if (date === "" || date !== beijingDayKey()) return null; // 昨天的快照一律不认
|
|
1737
|
+
const cost = Number(parsed.cost);
|
|
1738
|
+
const totalTokens = Number(parsed.totalTokens);
|
|
1739
|
+
// ⚠️ `bySession` 必须一起还原(2026-10-31 全量审查)。
|
|
1740
|
+
// 增量路径 updateTodayCostWithSession 的口径是「其余会话原样搬运上一次的 bySession,
|
|
1741
|
+
// 只把当前会话那一桶换掉」,它读的就是 base.bySession。快照里丢掉这个字段,
|
|
1742
|
+
// 冷启动第一次 ?session= 拿到的 base 就只有 {cost, totalTokens, sessions},
|
|
1743
|
+
// bySession 变成空表 —— 重算出的今日总额 = 当前会话一个桶(实测 ¥16 直接塌成 ¥3.2),
|
|
1744
|
+
// 而且这个虚低的数还带着「非 partial」身份被 ?today=1 当定型值发给客户端,
|
|
1745
|
+
// 要等 45 秒后一次全量重算才纠正回来。症状就是「打开时数字先小后大」。
|
|
1746
|
+
const bySession = {};
|
|
1747
|
+
if (parsed.bySession != null && typeof parsed.bySession === "object") {
|
|
1748
|
+
for (const [key, rec] of Object.entries(parsed.bySession)) {
|
|
1749
|
+
if (typeof key !== "string" || key === "") continue;
|
|
1750
|
+
if (rec == null || typeof rec !== "object") continue;
|
|
1751
|
+
const c = Number(rec.cost);
|
|
1752
|
+
const t = Number(rec.totalTokens);
|
|
1753
|
+
bySession[key] = {
|
|
1754
|
+
cost: Number.isFinite(c) && c >= 0 ? c : 0,
|
|
1755
|
+
totalTokens: Number.isFinite(t) && t >= 0 ? t : 0,
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
return {
|
|
1760
|
+
date,
|
|
1761
|
+
cost: Number.isFinite(cost) && cost >= 0 ? cost : 0,
|
|
1762
|
+
totalTokens: Number.isFinite(totalTokens) && totalTokens >= 0 ? totalTokens : 0,
|
|
1763
|
+
sessions: Number.isFinite(Number(parsed.sessions)) ? Number(parsed.sessions) : 0,
|
|
1764
|
+
at: Number.isFinite(Number(parsed.at)) ? Number(parsed.at) : 0,
|
|
1765
|
+
bySession,
|
|
1766
|
+
};
|
|
1767
|
+
} catch {
|
|
1768
|
+
return null;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
async function writeJsonAtomic(file, value) {
|
|
1773
|
+
if (file === "") return;
|
|
1774
|
+
const { mkdir, writeFile, rename } = await import("node:fs/promises");
|
|
1775
|
+
const { dirname } = await import("node:path");
|
|
1776
|
+
await mkdir(dirname(file), { recursive: true });
|
|
1777
|
+
const tmp = `${file}.tmp`;
|
|
1778
|
+
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
1779
|
+
await rename(tmp, file);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
async function writeTodaySnapshot(file, snapshot) {
|
|
1783
|
+
// ⚠️ **增量结果绝不落盘**(`provisional` 由 updateTodayCostWithSession 打上)。
|
|
1784
|
+
// 增量是「旧基线 + 当前对话那一桶」的临时视图,比全量扫描多出一点(实测 +0.211),
|
|
1785
|
+
// 一旦写进快照,下次冷启动就会先显示这个虚高的数、随后被补拉的精算值「缩水」
|
|
1786
|
+
// (2026-10-31:「打开加载完显示17.几 下一秒变成16.几,而且每次打开还不一样」)。
|
|
1787
|
+
// 快照必须只由全量扫描(buildTodayCost)写入 —— 它是唯一权威口径。
|
|
1788
|
+
if (snapshot != null && typeof snapshot === "object" && snapshot.provisional === true) return;
|
|
1789
|
+
await writeJsonAtomic(file, snapshot);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/** 已结算的历史日消耗:{ "YYYY-MM-DD": { cost, totalTokens } }。
|
|
1793
|
+
* 只保留最近 STATS_DAYS 天,超出直接丢——统计弹窗最多就看 90 天。 */
|
|
1794
|
+
async function readDailySettled(file) {
|
|
1795
|
+
if (file === "") return {};
|
|
1796
|
+
try {
|
|
1797
|
+
const { readFile } = await import("node:fs/promises");
|
|
1798
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
1799
|
+
if (parsed == null || typeof parsed !== "object") return {};
|
|
1800
|
+
const out = {};
|
|
1801
|
+
for (const [date, rec] of Object.entries(parsed)) {
|
|
1802
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
|
|
1803
|
+
if (rec == null || typeof rec !== "object") continue;
|
|
1804
|
+
const cost = Number(rec.cost);
|
|
1805
|
+
const totalTokens = Number(rec.totalTokens);
|
|
1806
|
+
out[date] = {
|
|
1807
|
+
cost: Number.isFinite(cost) && cost >= 0 ? cost : 0,
|
|
1808
|
+
totalTokens: Number.isFinite(totalTokens) && totalTokens >= 0 ? totalTokens : 0,
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
return out;
|
|
1812
|
+
} catch {
|
|
1813
|
+
return {};
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
async function writeDailySettled(file, map) {
|
|
1818
|
+
const keep = new Set(recentDayKeys(STATS_DAYS));
|
|
1819
|
+
const pruned = {};
|
|
1820
|
+
for (const [date, rec] of Object.entries(map)) {
|
|
1821
|
+
if (keep.has(date)) pruned[date] = rec;
|
|
1822
|
+
}
|
|
1823
|
+
await writeJsonAtomic(file, pruned);
|
|
1824
|
+
return pruned;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/** 跨天清理:把昨天(及更早)的快照结算进 dailySettled,然后让今天的快照归零。
|
|
1828
|
+
* 按日期取**较大值**合并——统计本身会重扫会话算历史,若把快照值全额加进去
|
|
1829
|
+
* 就重复计数了;取 max 既保住「强制退出时还没落盘的那段增量」,又不会翻倍。
|
|
1830
|
+
* @returns {Promise<{settled:boolean, date:string}>} */
|
|
1831
|
+
async function settleStaleTodaySnapshot(ctx) {
|
|
1832
|
+
const snapshotFile = siblingFilePath(ctx, TODAY_SNAPSHOT_FILENAME);
|
|
1833
|
+
const settledFile = siblingFilePath(ctx, DAILY_SETTLED_FILENAME);
|
|
1834
|
+
if (snapshotFile === "") return { settled: false, date: "" };
|
|
1835
|
+
let raw = null;
|
|
1836
|
+
try {
|
|
1837
|
+
const { readFile } = await import("node:fs/promises");
|
|
1838
|
+
raw = JSON.parse(await readFile(snapshotFile, "utf8"));
|
|
1839
|
+
} catch {
|
|
1840
|
+
return { settled: false, date: "" }; // 没有快照 = 没什么可结算
|
|
1841
|
+
}
|
|
1842
|
+
const date = raw != null && typeof raw === "object" && typeof raw.date === "string" ? raw.date : "";
|
|
1843
|
+
if (date === "" || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return { settled: false, date: "" };
|
|
1844
|
+
const today = beijingDayKey();
|
|
1845
|
+
if (date === today) return { settled: false, date }; // 还是今天,不用结算
|
|
1846
|
+
const cost = Number(raw.cost);
|
|
1847
|
+
const totalTokens = Number(raw.totalTokens);
|
|
1848
|
+
const map = await readDailySettled(settledFile);
|
|
1849
|
+
const prev = map[date] ?? { cost: 0, totalTokens: 0 };
|
|
1850
|
+
map[date] = {
|
|
1851
|
+
cost: Math.max(prev.cost, Number.isFinite(cost) && cost > 0 ? cost : 0),
|
|
1852
|
+
totalTokens: Math.max(prev.totalTokens, Number.isFinite(totalTokens) && totalTokens > 0 ? totalTokens : 0),
|
|
1853
|
+
};
|
|
1854
|
+
// ⚠️ 写失败就不许删快照(2026-10-31 审计 L2):原来 catch 完照删,
|
|
1855
|
+
// 那一份昨天(含强制退出前还没落盘的部分)就那么没了 —— 统计面板永远补不回来。
|
|
1856
|
+
// 留着它下次启动会再结算一次,重复结算由 writeDailySettled 的取较大值保证不会翻倍。
|
|
1857
|
+
try {
|
|
1858
|
+
await writeDailySettled(settledFile, map);
|
|
1859
|
+
} catch {
|
|
1860
|
+
// 结算写失败也不能挡住今天的显示,下次启动会再试;快照保留
|
|
1861
|
+
return { settled: false, date };
|
|
1862
|
+
}
|
|
1863
|
+
// 删掉旧快照,今天的从 0 重新累积(用户要求:第二天打开直接就是 0)
|
|
1864
|
+
try {
|
|
1865
|
+
const { unlink } = await import("node:fs/promises");
|
|
1866
|
+
await unlink(snapshotFile);
|
|
1867
|
+
} catch {
|
|
1868
|
+
// ignore
|
|
1869
|
+
}
|
|
1870
|
+
return { settled: true, date };
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1380
1873
|
/** 读设置文件;不存在 / JSON 损坏 / 无权限一律返回 null(调用方保持默认值)。
|
|
1381
1874
|
* 额外做一件事:老版本写下的文件里没有 showSidebarEntry 这个键,
|
|
1382
1875
|
* 读到这种「升级前」的文件时把侧边栏入口强制打开一次,让用户第一次升级后
|
|
@@ -1399,16 +1892,32 @@ async function readSettingsFile(file) {
|
|
|
1399
1892
|
// 文件不存在 = 全新安装,这里不能当「未读」处理(否则新用户一装就看到一堆 NEW,
|
|
1400
1893
|
// 对他来说每一项都是新的,标 NEW 毫无信息量)。用字符串标记区分于「读失败」。
|
|
1401
1894
|
if (error && error.code === "ENOENT") return "missing";
|
|
1402
|
-
|
|
1895
|
+
// **JSON 坏了 / 读不动:绝不能静默降级成默认值然后把它写回去**(用户 2026-10-31:
|
|
1896
|
+
// 「这些设置保存到用户配置文件里,不要每次更新都改回去」)。原文件先另存一份
|
|
1897
|
+
// `.corrupt-<时间戳>`,调用方拿到 "corrupt" 后禁止自动写盘 —— 用户的配置留在磁盘上,
|
|
1898
|
+
// 任何时候都能找回,而不是被一份出厂默认值覆盖掉。
|
|
1899
|
+
try {
|
|
1900
|
+
const { rename } = await import("node:fs/promises");
|
|
1901
|
+
await rename(file, `${file}.corrupt-${Date.now()}`);
|
|
1902
|
+
} catch {
|
|
1903
|
+
// 连改名都失败(权限/占用):至少不让调用方写盘
|
|
1904
|
+
}
|
|
1905
|
+
return "corrupt";
|
|
1403
1906
|
}
|
|
1404
1907
|
}
|
|
1405
1908
|
|
|
1406
|
-
/** 写设置文件:先写临时文件再改名,避免写一半把配置写坏。
|
|
1909
|
+
/** 写设置文件:先写临时文件再改名,避免写一半把配置写坏。
|
|
1910
|
+
* 写之前把上一份留成 `.bak` —— 任何一次写盘出问题,用户的配置都还在。 */
|
|
1407
1911
|
async function writeSettingsFile(file, settings) {
|
|
1408
1912
|
if (file === "") throw new Error("无法定位 DeepSeek Harness 配置目录");
|
|
1409
|
-
const { mkdir, writeFile, rename } = await import("node:fs/promises");
|
|
1913
|
+
const { mkdir, writeFile, rename, copyFile } = await import("node:fs/promises");
|
|
1410
1914
|
const { dirname } = await import("node:path");
|
|
1411
1915
|
await mkdir(dirname(file), { recursive: true });
|
|
1916
|
+
try {
|
|
1917
|
+
await copyFile(file, `${file}.bak`);
|
|
1918
|
+
} catch {
|
|
1919
|
+
// 首次写盘(还没有原文件):没有可备份的东西,继续
|
|
1920
|
+
}
|
|
1412
1921
|
const tmp = `${file}.tmp`;
|
|
1413
1922
|
await writeFile(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
1414
1923
|
await rename(tmp, file);
|
|
@@ -1430,10 +1939,13 @@ function readRequestBody(req, limit = 64 * 1024) {
|
|
|
1430
1939
|
});
|
|
1431
1940
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
1432
1941
|
req.on("error", reject);
|
|
1942
|
+
// 客户端中途断开时既没有 end 也没有 error(2026-10-31 审计 L9):
|
|
1943
|
+
// Promise 永不 settle,await 它的 handler 就一直挂着、连接和内存都不释放。
|
|
1944
|
+
req.on("close", () => reject(new Error("连接已关闭")));
|
|
1433
1945
|
});
|
|
1434
1946
|
}
|
|
1435
1947
|
|
|
1436
|
-
function beijingDayKey(timeMs) {
|
|
1948
|
+
function beijingDayKey(timeMs = Date.now()) {
|
|
1437
1949
|
return new Date(Number(timeMs) + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
1438
1950
|
}
|
|
1439
1951
|
|
|
@@ -1462,6 +1974,9 @@ function ensureDay(days, key) {
|
|
|
1462
1974
|
totalTokens: 0,
|
|
1463
1975
|
cost: 0,
|
|
1464
1976
|
sessionIds: new Set(),
|
|
1977
|
+
// 按会话拆分(sessionId -> { cost, totalTokens }):供「今日消耗」复用
|
|
1978
|
+
// ?session= 请求里已经解压好的事件、做实时刷新,避免为了某个会话再解一次。
|
|
1979
|
+
bySession: new Map(),
|
|
1465
1980
|
// —— 以下为细分维度,供统计面板展示;不改动上方总额口径 ——
|
|
1466
1981
|
byModel: new Map(), // modelKey -> { model, tokens..., totalTokens, cost }
|
|
1467
1982
|
peak: emptyTokenBucket(),
|
|
@@ -1481,6 +1996,113 @@ function ensureDay(days, key) {
|
|
|
1481
1996
|
return day;
|
|
1482
1997
|
}
|
|
1483
1998
|
|
|
1999
|
+
/** 把一笔费用记到「属于哪个会话」的桶,供「今日消耗」按会话拆分做实时刷新。
|
|
2000
|
+
* 没有 sessionId(老调用方)就跳过,不影响总额。 */
|
|
2001
|
+
function addDaySessionCost(day, sessionId, cost, tokens) {
|
|
2002
|
+
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
2003
|
+
let rec = day.bySession.get(sessionId);
|
|
2004
|
+
if (rec == null) {
|
|
2005
|
+
rec = { cost: 0, totalTokens: 0 };
|
|
2006
|
+
day.bySession.set(sessionId, rec);
|
|
2007
|
+
}
|
|
2008
|
+
rec.cost += cost;
|
|
2009
|
+
rec.totalTokens += tokens;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/** 增量结果的短期缓存:同一会话在 600 ms 内重复问,直接返回上次算好的结果。
|
|
2013
|
+
*
|
|
2014
|
+
* `?session=` 每轮对话会被问**两次** —— 一次给 `resolveSessionModel` 解析模型、一次给
|
|
2015
|
+
* `fetchSessionUsage` 拉 usage,打的是同一个 URL。服务端没必要把整份
|
|
2016
|
+
* `collectDailyUsage`(对 40 MB 会话是好几遍全量事件遍历)跑两遍。600 ms 足够覆盖这两次
|
|
2017
|
+
* 请求的间隔,又短到用户感知不到「今日金额没跟上」。
|
|
2018
|
+
*
|
|
2019
|
+
* Map 上限 64:今天动过的会话不可能比这多到离谱,超了直接整体丢弃(重建成本极低)。 */
|
|
2020
|
+
const todayIncrementCache = new Map();
|
|
2021
|
+
// 1500ms 是**实测标定值**(2026-10-31 审计 L8 核实):模型解析和 usage 拉取打的是同一个 URL,
|
|
2022
|
+
// 两趟之间通常差几百毫秒;早期注释里写的 600ms 会让第二趟必然落空、白跑一遍整会话遍历。
|
|
2023
|
+
// 改这个值等于改「重复请求复用窗口」,属行为参数,别随手调。
|
|
2024
|
+
const TODAY_INCREMENT_TTL_MS = 1500;
|
|
2025
|
+
|
|
2026
|
+
/** 用「单个会话的实时事件」增量更新今日消耗,**完全不扫描其他会话**。
|
|
2027
|
+
*
|
|
2028
|
+
* 用户 2026-10-31 明确要求:「本对话消耗只扫对应对话的,其他对话别扫描」。
|
|
2029
|
+
* 原来 `?session=` 会把 today 整份重算 —— 列一遍今天动过的所有会话、逐个遍历它们的
|
|
2030
|
+
* 事件数组。主会话 40 MB / 两万帧时,这一趟就是几百毫秒的同步 CPU;插件和 DSH 同进程,
|
|
2031
|
+
* 于是对话一推进界面就掉帧、聊天里的图片解码也跟着排队。
|
|
2032
|
+
*
|
|
2033
|
+
* 这里改成:其余会话的贡献**原样搬运**上一次算好的结果,只把当前会话那一桶换成刚算
|
|
2034
|
+
* 出来的值。复杂度从 O(今天所有会话的事件总数) 降到 O(当前会话的事件数)。
|
|
2035
|
+
*
|
|
2036
|
+
* 返回 null 表示「没法增量」(今天还没算过 / 快照是昨天的),调用方回退到完整路径 ——
|
|
2037
|
+
* 那种情况一天只发生一次(冷启动、刚跨天),代价可以接受。
|
|
2038
|
+
*
|
|
2039
|
+
* @returns {Promise<object|null>} 与 buildTodayCost 同形状 */
|
|
2040
|
+
async function updateTodayCostWithSession(ctx, config, state, sessionId, events) {
|
|
2041
|
+
if (typeof sessionId !== "string" || sessionId === "" || !Array.isArray(events)) return null;
|
|
2042
|
+
const now = Date.now();
|
|
2043
|
+
const todayKey = beijingDayKey(now);
|
|
2044
|
+
// ⚠️ **键必须正规化成 `session-<uuid>`**:全量扫描那条路径的 bySession 键
|
|
2045
|
+
// 来自会话列表接口,带 `session-` 前缀;而这里的 `sessionId` 是客户端传来的裸 UUID。
|
|
2046
|
+
// 直接用裸 UUID 写会**在表里新增一个键**,同一个会话于是出现两次
|
|
2047
|
+
// (`session-9877…` 和 `9877…`),求和时算两遍。
|
|
2048
|
+
// 症状:快照逐渐偏高 → 冷启动显示一个虚高的数 → 补拉精算后「变小」
|
|
2049
|
+
// (2026-10-31:「每次打开还不一样」,因为每次运行的增量调用次数不同)。
|
|
2050
|
+
const sessionKey = sessionId.startsWith("session-") ? sessionId : `session-${sessionId}`;
|
|
2051
|
+
const base = state.cache.value ?? (await readTodaySnapshot(state.snapshotFile));
|
|
2052
|
+
if (base == null || base.date !== todayKey) return null;
|
|
2053
|
+
// 基线里没有 bySession 就**根本没有增量可言**:下面那套「其余会话原样搬运」全靠它,
|
|
2054
|
+
// 空表算出来的总额只剩当前会话一个桶,是个远低于真实值的假数。
|
|
2055
|
+
// 旧版本写下的快照就长这样(readTodaySnapshot 以前不还原这个字段),所以这里必须挡一道,
|
|
2056
|
+
// 让它返回 null 回退到完整重算 —— 一天只多算一次,比显示一个错数字便宜得多。
|
|
2057
|
+
// ⚠️ 空表也必须挡(2026-10-31 审计 NEW-1):上面 readTodaySnapshot 现在
|
|
2058
|
+
// **无条件**构造 bySession = {},而 {} 是 object,只判类型的闸门永远不会触发 ——
|
|
2059
|
+
// 旧版快照(没有这个字段)就会被当成合法基线,增量把空表当基线去搬运,
|
|
2060
|
+
// 今日总额塌成只剩当前会话一个桶(就是注释里那个 16 元塌成 3.2)。
|
|
2061
|
+
// 还会被当成非 partial 发给客户端,于是「打开时先小后大」换个入口又复发。
|
|
2062
|
+
if (base.bySession == null || typeof base.bySession !== "object") return null;
|
|
2063
|
+
if (Object.keys(base.bySession).length === 0) return null;
|
|
2064
|
+
// 同一会话 600 ms 内重复问(模型解析 + usage 拉取打的是同一个 URL)直接复用上次结果,
|
|
2065
|
+
// 省掉一遍整会话事件遍历。必须在 collectDailyUsage 之前短路,否则等于没省。
|
|
2066
|
+
const cachedIncrement = todayIncrementCache.get(sessionKey);
|
|
2067
|
+
// 缓存必须绑定基线对象本身(2026-10-31 审计 M1):
|
|
2068
|
+
// 快照回填 / 全量重算会把 state.cache.value 换成另一个对象,
|
|
2069
|
+
// 标准只看 TTL 的话,这 1.5 秒内仍会返回按旧基线算的整份总额,今日数字往回跳一下。
|
|
2070
|
+
// 用引用相等做版本戳最廉价。
|
|
2071
|
+
if (cachedIncrement != null && cachedIncrement.base === base && now - cachedIncrement.at < TODAY_INCREMENT_TTL_MS) {
|
|
2072
|
+
return cachedIncrement.value;
|
|
2073
|
+
}
|
|
2074
|
+
const local = new Map();
|
|
2075
|
+
collectDailyUsage(
|
|
2076
|
+
events,
|
|
2077
|
+
config,
|
|
2078
|
+
detectModel({ requestHeader: () => void 0, requestContext: () => void 0, events }),
|
|
2079
|
+
beijingDayStart(now),
|
|
2080
|
+
local,
|
|
2081
|
+
sessionId,
|
|
2082
|
+
);
|
|
2083
|
+
const localRec = local.get(todayKey)?.bySession?.get(sessionId);
|
|
2084
|
+
const bySession = { ...(base.bySession ?? {}) };
|
|
2085
|
+
bySession[sessionKey] = {
|
|
2086
|
+
cost: Number(localRec?.cost ?? 0),
|
|
2087
|
+
totalTokens: Number(localRec?.totalTokens ?? 0),
|
|
2088
|
+
};
|
|
2089
|
+
let cost = 0;
|
|
2090
|
+
let totalTokens = 0;
|
|
2091
|
+
let sessions = 0;
|
|
2092
|
+
for (const rec of Object.values(bySession)) {
|
|
2093
|
+
cost += Number(rec?.cost ?? 0);
|
|
2094
|
+
totalTokens += Number(rec?.totalTokens ?? 0);
|
|
2095
|
+
sessions += 1;
|
|
2096
|
+
}
|
|
2097
|
+
const value = { date: todayKey, cost, totalTokens, sessions, bySession, provisional: true };
|
|
2098
|
+
// 只更新值、不动过期时间:增量的可信度取决于基线,基线多旧它就多旧。
|
|
2099
|
+
// 让原来的 staleMs 正常触发一次完整重算,把其他会话的过时数字纠正回来。
|
|
2100
|
+
state.cache.value = value;
|
|
2101
|
+
if (todayIncrementCache.size >= 64) todayIncrementCache.clear();
|
|
2102
|
+
todayIncrementCache.set(sessionKey, { at: now, base, value });
|
|
2103
|
+
return value;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
1484
2106
|
/** 按北京时间把一个会话的最终 assistant/message usage 聚合到每日统计。 */
|
|
1485
2107
|
function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessionId) {
|
|
1486
2108
|
const switches = collectModelSwitches(events);
|
|
@@ -1527,6 +2149,7 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
|
|
|
1527
2149
|
day.webSearchBillable += 1;
|
|
1528
2150
|
day.webSearchCost += WEB_TOOL_PRICE_PER_CALL;
|
|
1529
2151
|
day.cost += WEB_TOOL_PRICE_PER_CALL;
|
|
2152
|
+
addDaySessionCost(day, sessionId, WEB_TOOL_PRICE_PER_CALL, 0);
|
|
1530
2153
|
}
|
|
1531
2154
|
} else {
|
|
1532
2155
|
day.webFetchCalls += 1;
|
|
@@ -1534,6 +2157,7 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
|
|
|
1534
2157
|
day.webFetchBillable += 1;
|
|
1535
2158
|
day.webFetchCost += WEB_TOOL_PRICE_PER_CALL;
|
|
1536
2159
|
day.cost += WEB_TOOL_PRICE_PER_CALL;
|
|
2160
|
+
addDaySessionCost(day, sessionId, WEB_TOOL_PRICE_PER_CALL, 0);
|
|
1537
2161
|
}
|
|
1538
2162
|
}
|
|
1539
2163
|
day.sessionIds.add(sessionId);
|
|
@@ -1559,6 +2183,7 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
|
|
|
1559
2183
|
day.totalTokens += tokens;
|
|
1560
2184
|
day.cost += stepCost;
|
|
1561
2185
|
day.sessionIds.add(sessionId);
|
|
2186
|
+
addDaySessionCost(day, sessionId, stepCost, tokens);
|
|
1562
2187
|
|
|
1563
2188
|
// 高峰 / 空闲拆分(token 与费用)
|
|
1564
2189
|
addTokenUsage(isPeak ? day.peak : day.offpeak, usage);
|
|
@@ -1590,56 +2215,101 @@ function recentDayKeys(days, now = Date.now()) {
|
|
|
1590
2215
|
}
|
|
1591
2216
|
|
|
1592
2217
|
/** 只算「今天」的消耗总额,供余额行上方的 meta 行用。
|
|
1593
|
-
* 与 buildUsageStats 的区别:只扫 updatedAt
|
|
1594
|
-
*
|
|
1595
|
-
*
|
|
1596
|
-
|
|
2218
|
+
* 与 buildUsageStats 的区别:只扫 updatedAt 落在今天的会话,通常只有几个。
|
|
2219
|
+
* @param {{ sessionId?: string, events?: unknown[] }} [reuse] 调用方已持有的会话事件,
|
|
2220
|
+
* 命中时不再为它解压一遍(?session= 请求靠这个实现「零额外成本」的实时刷新)。
|
|
2221
|
+
* @returns {Promise<{ cost:number, totalTokens:number, sessions:number, bySession:object }>} */
|
|
2222
|
+
async function buildTodayCost(ctx, config, reuse = null) {
|
|
1597
2223
|
const getService = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
|
|
1598
2224
|
const controller = getService?.("sessionController");
|
|
1599
2225
|
const persistence = getService?.("sessionPersistence");
|
|
1600
2226
|
const now = Date.now();
|
|
1601
|
-
|
|
1602
|
-
const
|
|
1603
|
-
|
|
1604
|
-
|
|
2227
|
+
const dayStart = beijingDayStart(now);
|
|
2228
|
+
const todayKey = beijingDayKey(now);
|
|
2229
|
+
// ⚠️ 会话时间戳单位不保证是毫秒(2026-10-31:「怎么变成0了」)。
|
|
2230
|
+
// `controller.list()` 的 updatedAt 有可能是**秒**,而 dayStart 是毫秒 ——
|
|
2231
|
+
// `x.at >= dayStart` 就永远为假,今天动过的会话被整批滤掉,daily 为空,
|
|
2232
|
+
// 于是算出 cost:0 并把 0 落进快照,界面显示 ¥0.0000。
|
|
2233
|
+
// stats 那条路径只拿 sessionId、不比较时间,所以它一直是好的
|
|
2234
|
+
// (实测同刻 stats 里 10-31 有 15.13),这就是当时唯一的差异点。
|
|
2235
|
+
// 归一化:小于 1e11 的一律当秒。1e11 ms ≈ 1973 年,1e11 s 远在未来,分界安全。
|
|
2236
|
+
const toMs = (v) => {
|
|
2237
|
+
const n = Number(v);
|
|
2238
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
2239
|
+
return n < 1e11 ? n * 1000 : n;
|
|
2240
|
+
};
|
|
1605
2241
|
// 只挑今天动过的会话
|
|
1606
2242
|
let candidates = [];
|
|
2243
|
+
let listedCount = 0;
|
|
1607
2244
|
if (typeof controller?.list === "function") {
|
|
1608
2245
|
const listed = await controller.list({});
|
|
1609
2246
|
const items = Array.isArray(listed) ? listed : (listed?.items ?? []);
|
|
2247
|
+
listedCount = items.length;
|
|
1610
2248
|
candidates = items
|
|
1611
|
-
.map((item) => (item && typeof item === "object" ? { id: item.sessionId ?? item.id, at:
|
|
2249
|
+
.map((item) => (item && typeof item === "object" ? { id: item.sessionId ?? item.id, at: toMs(item.updatedAt ?? item.createdAt ?? 0) } : null))
|
|
1612
2250
|
.filter((x) => x && typeof x.id === "string" && x.id !== "");
|
|
1613
|
-
// list() 已按活动降序;万一没有 updatedAt 字段就保守全扫(上限
|
|
2251
|
+
// list() 已按活动降序;万一没有 updatedAt 字段就保守全扫(上限 TODAY_MAX_SESSIONS)。
|
|
2252
|
+
// ⚠️ 没有时间戳(at === 0)的会话**不许被过滤掉**(2026-10-31 全量审查):
|
|
2253
|
+
// 只要列表里有一部分带时间戳,t >= dayStart 就会把 at=0 的那批整批丢掉 ——
|
|
2254
|
+
// 而「今天真的用过它」完全可能,症状就是今日金额系统性偏低。
|
|
2255
|
+
// 保留它们不会造成多算:collectDailyUsage 内部按每条事件的时刻和 dayStart 比,
|
|
2256
|
+
// 老会话的旧事件自然贡献 0,代价只是多读一份文件。
|
|
1614
2257
|
if (candidates.some((x) => x.at > 0)) {
|
|
1615
|
-
candidates = candidates.filter((x) => x.at >= dayStart);
|
|
2258
|
+
candidates = candidates.filter((x) => x.at >= dayStart || !(x.at > 0));
|
|
1616
2259
|
}
|
|
1617
2260
|
} else if (typeof persistence?.list === "function") {
|
|
1618
2261
|
const headers = await persistence.list();
|
|
1619
2262
|
const list = Array.isArray(headers) ? headers : [];
|
|
2263
|
+
listedCount = list.length;
|
|
1620
2264
|
candidates = list
|
|
1621
|
-
.map((h) => (h && typeof h.id === "string" ? { id: h.id, at:
|
|
2265
|
+
.map((h) => (h && typeof h.id === "string" ? { id: h.id, at: toMs(h.updatedAt ?? h.createdAt ?? 0) } : null))
|
|
1622
2266
|
.filter(Boolean);
|
|
2267
|
+
// 同上:at === 0(无时间戳)的会话保留,别被 at >= dayStart 整批滤掉。
|
|
1623
2268
|
if (candidates.some((x) => x.at > 0)) {
|
|
1624
|
-
candidates = candidates.filter((x) => x.at >= dayStart);
|
|
2269
|
+
candidates = candidates.filter((x) => x.at >= dayStart || !(x.at > 0));
|
|
1625
2270
|
}
|
|
1626
2271
|
} else {
|
|
1627
2272
|
throw new Error("会话持久化服务不可用");
|
|
1628
2273
|
}
|
|
2274
|
+
// 有会话但今天一个都没筛出来时,**不许**把 0 写进快照:
|
|
2275
|
+
// 这既可能是真的今天还没用(合法 0),也可能是时间戳口径又变了(假 0)。
|
|
2276
|
+
// 假的 0 一旦落盘会被 readTodaySnapshot 认成"今天的值",界面就一直显示 ¥0.0000,
|
|
2277
|
+
// 而且后台重算只要继续踩同一个坑就永远修不回来。所以这种情况宁可不落盘,
|
|
2278
|
+
// 让下次冷启动重新现算。
|
|
2279
|
+
const suspiciousEmpty = listedCount > 0 && candidates.length === 0;
|
|
1629
2280
|
const selected = candidates
|
|
1630
|
-
|
|
2281
|
+
// 今日用自己的上限(默认 200),不再跟统计面板的 80 绑在一起 —— 见 TODAY_MAX_SESSIONS。
|
|
2282
|
+
.slice(0, clampNumber(config?.todaySessionLimit, 10, 2000, TODAY_MAX_SESSIONS))
|
|
1631
2283
|
.map((x) => x.id);
|
|
2284
|
+
// reuse:调用方(?session= 请求)手里已经持有某个会话的完整 events,
|
|
2285
|
+
// 别再为它解压一遍 —— 那正是「今日消耗实时刷新」的成本来源。
|
|
2286
|
+
const reuseId = typeof reuse?.sessionId === "string" ? reuse.sessionId : "";
|
|
2287
|
+
const reuseEvents = Array.isArray(reuse?.events) ? reuse.events : null;
|
|
2288
|
+
// ⚠️ 必须按**归一化键**比较(2026-10-31 审计 M3):list() 给的是带 session- 前缀的 id,
|
|
2289
|
+
// 而 reuseId 是客户端传来的裸 UUID —— 直接 includes 永远为 false,同一个会话被 unshift 第二份,
|
|
2290
|
+
// 一份读盘、一份 reuse,金额双算而且会被写进快照。
|
|
2291
|
+
const reuseKey = reuseId === "" ? "" : sessionIdKey(reuseId);
|
|
2292
|
+
if (
|
|
2293
|
+
reuseId !== "" &&
|
|
2294
|
+
reuseEvents !== null &&
|
|
2295
|
+
!selected.some((id) => sessionIdKey(id) === reuseKey)
|
|
2296
|
+
) {
|
|
2297
|
+
selected.unshift(reuseId);
|
|
2298
|
+
}
|
|
1632
2299
|
const daily = new Map();
|
|
1633
2300
|
let cursor = 0;
|
|
1634
2301
|
const worker = async () => {
|
|
1635
2302
|
while (cursor < selected.length) {
|
|
1636
2303
|
const sessionId = selected[cursor++];
|
|
1637
2304
|
try {
|
|
1638
|
-
const
|
|
2305
|
+
const events =
|
|
2306
|
+
reuseId !== "" && sessionIdKey(sessionId) === reuseKey && reuseEvents !== null
|
|
2307
|
+
? reuseEvents
|
|
2308
|
+
: (await inspectPersistedCached(ctx, sessionId))?.events ?? [];
|
|
1639
2309
|
collectDailyUsage(
|
|
1640
|
-
|
|
2310
|
+
events,
|
|
1641
2311
|
config,
|
|
1642
|
-
detectModel({ requestHeader: () => void 0, requestContext: () => void 0, events
|
|
2312
|
+
detectModel({ requestHeader: () => void 0, requestContext: () => void 0, events }),
|
|
1643
2313
|
dayStart,
|
|
1644
2314
|
daily,
|
|
1645
2315
|
sessionId,
|
|
@@ -1647,24 +2317,132 @@ async function buildTodayCost(ctx, config) {
|
|
|
1647
2317
|
} catch {
|
|
1648
2318
|
// 单个会话读不出来不影响总额
|
|
1649
2319
|
}
|
|
2320
|
+
// 每读完一个就让出一次主线程:单会话解压实测 723 ms 且是同步 CPU,
|
|
2321
|
+
// 连着读几个会把 Node 主线程占死 —— 和 DSH 同进程,界面就掉帧。
|
|
2322
|
+
// 让出之后余额/指针/动效都还能跑,只是总额晚几百毫秒到。
|
|
2323
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1650
2324
|
}
|
|
1651
2325
|
};
|
|
2326
|
+
// 并发度回到 3。**不要改成 1**:串行化会让「A 还没解压完就点 B」变成严格排队 ——
|
|
2327
|
+
// B 的请求要等 A 的同步解压整段跑完才能开始,用户体感就是「两个对话一起卡住」。
|
|
2328
|
+
// 单线程下并发不会更快,但能让不同会话的解压机会**交错**,切会话时不必等前一个走完。
|
|
2329
|
+
// 真正的阻塞控制靠下面每会话解压之后的 setImmediate 让出。
|
|
1652
2330
|
await Promise.all(Array.from({ length: Math.min(3, selected.length) }, () => worker()));
|
|
1653
2331
|
const day = daily.get(todayKey);
|
|
2332
|
+
// bySession 摊平成普通对象:快照要能 JSON 序列化,Map 存不了。
|
|
2333
|
+
const bySession = {};
|
|
2334
|
+
if (day?.bySession != null) {
|
|
2335
|
+
for (const [id, rec] of day.bySession) bySession[id] = { cost: rec.cost, totalTokens: rec.totalTokens };
|
|
2336
|
+
}
|
|
1654
2337
|
return {
|
|
1655
2338
|
date: todayKey,
|
|
1656
2339
|
cost: day?.cost ?? 0,
|
|
1657
2340
|
totalTokens: day?.totalTokens ?? 0,
|
|
1658
2341
|
sessions: day?.sessionIds?.size ?? 0,
|
|
2342
|
+
bySession,
|
|
2343
|
+
// 只给 computeAndStoreToday 用(决定要不要落盘),客户端不读这个字段。
|
|
2344
|
+
suspiciousEmpty,
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
/** 真算一次今日消耗,并把结果落盘成快照。
|
|
2349
|
+
* 快照是「下次启动秒开」的全部依据,所以每次算完都要写;
|
|
2350
|
+
* 写失败(目录只读等)只影响下次启动会不会卡,不影响这一次的结果。 */
|
|
2351
|
+
async function computeAndStoreToday(ctx, config, state, reuse = null) {
|
|
2352
|
+
const value = await buildTodayCost(ctx, config, reuse);
|
|
2353
|
+
// ⚠️ 可疑的空结果**不落盘**(见 buildTodayCost 里 suspiciousEmpty 的注释)。
|
|
2354
|
+
// 真的今天还没用过(0)明天也会被跨天结算清掉,代价只是下次冷启动多算一遍;
|
|
2355
|
+
// 而假的 0 一旦落盘会被当成"今天的值"长期显示 ¥0.0000,重算若继续踩同一个坑
|
|
2356
|
+
// 就永远修不回来 —— 两害相权,宁可不写。
|
|
2357
|
+
const snapshot = value.suspiciousEmpty ? { ...value, at: 0 } : { ...value, at: Date.now() };
|
|
2358
|
+
try {
|
|
2359
|
+
if (!value.suspiciousEmpty) await writeTodaySnapshot(state.snapshotFile, snapshot);
|
|
2360
|
+
} catch {
|
|
2361
|
+
// 落盘失败:当作只有内存缓存,下次启动再算一遍
|
|
2362
|
+
}
|
|
2363
|
+
// 内存里也把 at 归零,让上层把这次结果当"不新鲜":不写盘 + 立即允许重算,
|
|
2364
|
+
// 而不是拿着假 0 缓存 45 秒。
|
|
2365
|
+
return snapshot;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
/** 后台重算(单飞):同一时刻只跑一个,避免轮询把解压任务叠罗汉。 */
|
|
2369
|
+
function scheduleTodayRecompute(ctx, config, state) {
|
|
2370
|
+
if (state.recompute !== null) return;
|
|
2371
|
+
state.recompute = (async () => {
|
|
2372
|
+
try {
|
|
2373
|
+
const snapshot = await computeAndStoreToday(ctx, config, state);
|
|
2374
|
+
state.cache.value = snapshot;
|
|
2375
|
+
// 可疑空结果(有会话但今天一个都没筛出来)**不许被缓存满 staleMs**:
|
|
2376
|
+
// 落盘那边已经用 at:0 表达了不新鲜,内存里必须跟着立即允许重算,
|
|
2377
|
+
// 否则界面会拿着一个可疑的 0 硬撑 45 秒(2026-10-31 全量审查)。
|
|
2378
|
+
state.cache.expiresAt = snapshot.suspiciousEmpty === true ? 0 : Date.now() + state.staleMs;
|
|
2379
|
+
// 全量重算换了基线,增量缓存里的旧值全部作废 —— 否则同一会话在 TTL 内
|
|
2380
|
+
// 会拿「按旧基线算的增量」覆盖新结果(2026-10-31 全量审查)。
|
|
2381
|
+
todayIncrementCache.clear();
|
|
2382
|
+
} catch {
|
|
2383
|
+
// 后台失败不打扰任何人:界面上继续显示旧快照
|
|
2384
|
+
} finally {
|
|
2385
|
+
state.recompute = null;
|
|
2386
|
+
}
|
|
2387
|
+
})();
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
/** 「今日消耗」对外入口 —— 快照优先,现算兜底。
|
|
2391
|
+
*
|
|
2392
|
+
* 分层顺序(越靠前越快):
|
|
2393
|
+
* ① 内存缓存新鲜 → 直接给,partial=false
|
|
2394
|
+
* ② 落盘快照 → 直接给,同时后台重算(这是消除冷启动卡顿的那一步)
|
|
2395
|
+
* ③ 现算 → 只在「首次安装」「刚跨天」「快照文件损坏」时走到这里
|
|
2396
|
+
*
|
|
2397
|
+
* `partial=true` 表示「这是旧值,后台正在算新的」,客户端会隔几秒再问一次。
|
|
2398
|
+
* @returns {Promise<{value:object|null, partial:boolean}>} */
|
|
2399
|
+
async function resolveTodayCost(ctx, config, state, reuse = null) {
|
|
2400
|
+
const now = Date.now();
|
|
2401
|
+
// 带了 reuse(?session= 场景)就**强制重算**:当前会话的事件已经在手上,
|
|
2402
|
+
// 其余今天动过的会话指纹没变、直接命中持久化缓存 —— 于是这次重算是纯计算、
|
|
2403
|
+
// 零额外磁盘开销,正好用来做「今日消耗实时刷新」。走缓存反而会拿到旧值。
|
|
2404
|
+
if (reuse === null) {
|
|
2405
|
+
// ⚠️ provisional(增量算出来的不精确值)**不能当定型值命中缓存**(2026-10-31 审计 M2):
|
|
2406
|
+
// ?session= 把它写进 state.cache.value 后,45 秒内的 ?today=1 会命中它、
|
|
2407
|
+
// 带着 partial=false 发回去,客户端于是 settled=true 压 20 秒不补拉,
|
|
2408
|
+
// 数字就锁在一个不精确值上。
|
|
2409
|
+
if (state.cache.value != null && state.cache.expiresAt > now && state.cache.value.provisional !== true) {
|
|
2410
|
+
return { value: state.cache.value, partial: state.recompute !== null };
|
|
2411
|
+
}
|
|
2412
|
+
const snapshot = await readTodaySnapshot(state.snapshotFile);
|
|
2413
|
+
if (snapshot != null) {
|
|
2414
|
+
state.cache.value = snapshot;
|
|
2415
|
+
const age = snapshot.at > 0 ? now - snapshot.at : Number.POSITIVE_INFINITY;
|
|
2416
|
+
const fresh = age < state.staleMs;
|
|
2417
|
+
state.cache.expiresAt = now + (fresh ? state.staleMs - age : 3000);
|
|
2418
|
+
if (!fresh) scheduleTodayRecompute(ctx, config, state);
|
|
2419
|
+
return { value: snapshot, partial: !fresh };
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
// 快照不可用 / 带了 reuse:现算。reuse 路径算完同样落盘,下一次冷启动照样秒开。
|
|
2423
|
+
const value = await computeAndStoreToday(ctx, config, state, reuse);
|
|
2424
|
+
// 同 scheduleTodayRecompute:可疑空结果立即允许重算,别缓存满 staleMs。
|
|
2425
|
+
state.cache = {
|
|
2426
|
+
value,
|
|
2427
|
+
expiresAt: value.suspiciousEmpty === true ? 0 : Date.now() + state.staleMs,
|
|
1659
2428
|
};
|
|
2429
|
+
todayIncrementCache.clear();
|
|
2430
|
+
// 可疑空结果必须如实标 partial(2026-10-31 审计 M4):硬写 false 客户端就不补拉,
|
|
2431
|
+
// 一个可疑的 0 就真的长期显示下去了。
|
|
2432
|
+
return { value, partial: value.suspiciousEmpty === true };
|
|
1660
2433
|
}
|
|
1661
2434
|
|
|
2435
|
+
|
|
1662
2436
|
async function buildUsageStats(ctx, config) {
|
|
1663
2437
|
const getService = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
|
|
1664
2438
|
const controller = getService?.("sessionController");
|
|
1665
2439
|
const persistence = getService?.("sessionPersistence");
|
|
1666
2440
|
const now = Date.now();
|
|
1667
|
-
|
|
2441
|
+
// ⚠️ 要跟**自然日**对齐(2026-10-31 审计 L6):
|
|
2442
|
+
// now - 89 天 = 今天的同一时刻,而统计是按北京自然日分行的,
|
|
2443
|
+
// 于是第 90 行只算到「当天此刻为止」,比其余 89 天少一截,看起来像那天特别省。
|
|
2444
|
+
// 改成「今天 0 点往前推 89 个整天」,窗口正好覆盖 90 个完整自然日。
|
|
2445
|
+
const cutoffMs = beijingDayStart(now) - (STATS_DAYS - 1) * 24 * 60 * 60 * 1000;
|
|
1668
2446
|
// 列举会话要跨 DSH 版本:新版 sessionController.list() 返回 { items:[{ sessionId, updatedAt }] }
|
|
1669
2447
|
// (已按活动降序,字段叫 sessionId);旧版 sessionPersistence.list() 返回 [{ id, createdAt }]
|
|
1670
2448
|
// (字段叫 id)。旧版 persistence 还提供 inspect(),新版改成 handle-based 的 open(),
|
|
@@ -1698,7 +2476,7 @@ async function buildUsageStats(ctx, config) {
|
|
|
1698
2476
|
while (cursor < selected.length) {
|
|
1699
2477
|
const sessionId = selected[cursor++];
|
|
1700
2478
|
try {
|
|
1701
|
-
const inspected = await
|
|
2479
|
+
const inspected = await inspectPersistedCached(ctx, sessionId);
|
|
1702
2480
|
collectDailyUsage(
|
|
1703
2481
|
inspected?.events ?? [],
|
|
1704
2482
|
config,
|
|
@@ -1711,6 +2489,9 @@ async function buildUsageStats(ctx, config) {
|
|
|
1711
2489
|
} catch {
|
|
1712
2490
|
failedSessions++;
|
|
1713
2491
|
}
|
|
2492
|
+
// 同 buildTodayCost:统计要扫几十个会话,每个解压 700 ms,必须逐个让出,
|
|
2493
|
+
// 否则打开统计面板那一下整页都会卡住。
|
|
2494
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1714
2495
|
}
|
|
1715
2496
|
};
|
|
1716
2497
|
await Promise.all(Array.from({ length: Math.min(3, selected.length) }, () => worker()));
|
|
@@ -1729,6 +2510,17 @@ async function buildUsageStats(ctx, config) {
|
|
|
1729
2510
|
offpeak: emptyTokenBucket(),
|
|
1730
2511
|
peakCost: 0,
|
|
1731
2512
|
offpeakCost: 0,
|
|
2513
|
+
// ⚠️ 这 7 个 web* 键一个都不能少(2026-10-31 审计 L1):
|
|
2514
|
+
// 下面汇总时是 acc.webSearchCalls += day.webSearchCalls,0 + undefined = NaN,
|
|
2515
|
+
// JSON.stringify 把 NaN 写成 null,客户端 num() 又把它吃成 0 ——
|
|
2516
|
+
// 于是「网页工具」90 天汇总恒为 0,而实际天天在用。
|
|
2517
|
+
webSearchCalls: 0,
|
|
2518
|
+
webFetchCalls: 0,
|
|
2519
|
+
webSearchBillable: 0,
|
|
2520
|
+
webFetchBillable: 0,
|
|
2521
|
+
webSearchCost: 0,
|
|
2522
|
+
webFetchCost: 0,
|
|
2523
|
+
webToolCost: 0,
|
|
1732
2524
|
};
|
|
1733
2525
|
}
|
|
1734
2526
|
return {
|
|
@@ -1754,6 +2546,25 @@ async function buildUsageStats(ctx, config) {
|
|
|
1754
2546
|
webToolCost: day.webSearchCost + day.webFetchCost,
|
|
1755
2547
|
};
|
|
1756
2548
|
});
|
|
2549
|
+
// ── 已结算的历史日(跨天时从「今日快照」转存下来的,见 settleStaleTodaySnapshot)──
|
|
2550
|
+
// 统计本身会重扫会话算历史,所以这里按**较大值**合并、且只补差额:
|
|
2551
|
+
// 快照可能含「强制退出时还没落盘的那段 usage」,那是快照独有的;
|
|
2552
|
+
// 但若重扫已经算得更多(会话最终落盘了),就以重扫为准,绝不重复计数。
|
|
2553
|
+
// 差额记进「空闲」桶 —— 快照没留时段明细,按低峰价补是最保守的取整,
|
|
2554
|
+
// 并且能保住 peakCost + offpeakCost + webToolCost === cost 这个不变量。
|
|
2555
|
+
// token 分项不动:改了会让「各分项之和 === totalTokens」也对不上。
|
|
2556
|
+
const settledDaily = await readDailySettled(siblingFilePath(ctx, DAILY_SETTLED_FILENAME));
|
|
2557
|
+
if (Object.keys(settledDaily).length > 0) {
|
|
2558
|
+
for (const row of rows) {
|
|
2559
|
+
const rec = settledDaily[row.date];
|
|
2560
|
+
if (rec == null) continue;
|
|
2561
|
+
const extra = rec.cost - row.cost;
|
|
2562
|
+
if (!(extra > 1e-7)) continue;
|
|
2563
|
+
row.cost += extra;
|
|
2564
|
+
row.offpeakCost += extra;
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
|
|
1757
2568
|
const total = rows.reduce((acc, day) => {
|
|
1758
2569
|
acc.inputTokens += day.inputTokens;
|
|
1759
2570
|
acc.outputTokens += day.outputTokens;
|
|
@@ -1814,35 +2625,133 @@ async function buildUsageStats(ctx, config) {
|
|
|
1814
2625
|
};
|
|
1815
2626
|
}
|
|
1816
2627
|
|
|
2628
|
+
/** 会话 id 归一化:客户端传的是裸 UUID(DSH 的 sessionId prop),
|
|
2629
|
+
* 会话列表摘要和持久化目录用的是 `session-<uuid>`。比较父子关系前必须先抹平,
|
|
2630
|
+
* 否则同一棵树永远匹配不上(这正是「子代理一直是 0 个」的一半原因)。 */
|
|
2631
|
+
function sessionIdKey(id) {
|
|
2632
|
+
if (typeof id !== "string" || id === "") return "";
|
|
2633
|
+
return id.startsWith("session-") ? id.slice("session-".length) : id;
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
/** 列出全部会话并带上父子关系(跨 DSH 版本)。
|
|
2637
|
+
* @returns {Promise<Array<{id:string,parentId:string,origin:string}>>}
|
|
2638
|
+
*
|
|
2639
|
+
* 这是枚举子代理的**主要**依据,不是备用方案。DSH 的 sessionController.list() 每个
|
|
2640
|
+
* 摘要都经过 listFields(header),里面会带出 parentSessionId / origin;旧版
|
|
2641
|
+
* sessionPersistence.list() 通常没有这两项,那就只能退回 subagents 服务那条路。 */
|
|
2642
|
+
async function listSessionRows(ctx) {
|
|
2643
|
+
const getService = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
|
|
2644
|
+
const rows = [];
|
|
2645
|
+
const push = (id, parentId, origin) => {
|
|
2646
|
+
if (typeof id !== "string" || id === "") return;
|
|
2647
|
+
rows.push({
|
|
2648
|
+
id,
|
|
2649
|
+
parentId: typeof parentId === "string" ? parentId : "",
|
|
2650
|
+
origin: typeof origin === "string" ? origin : "",
|
|
2651
|
+
});
|
|
2652
|
+
};
|
|
2653
|
+
const controller = getService?.("sessionController");
|
|
2654
|
+
if (typeof controller?.list === "function") {
|
|
2655
|
+
try {
|
|
2656
|
+
const listed = await controller.list({});
|
|
2657
|
+
const items = Array.isArray(listed) ? listed : (listed?.items ?? []);
|
|
2658
|
+
for (const item of items) {
|
|
2659
|
+
if (item == null || typeof item !== "object") continue;
|
|
2660
|
+
push(item.sessionId ?? item.id, item.parentSessionId ?? item.parentSession, item.origin);
|
|
2661
|
+
}
|
|
2662
|
+
if (rows.length > 0) return rows;
|
|
2663
|
+
} catch {
|
|
2664
|
+
/* 新版列不出来就试旧版 */
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
const persistence = getService?.("sessionPersistence");
|
|
2668
|
+
if (typeof persistence?.list === "function") {
|
|
2669
|
+
try {
|
|
2670
|
+
const headers = await persistence.list();
|
|
2671
|
+
for (const h of Array.isArray(headers) ? headers : []) {
|
|
2672
|
+
if (h == null || typeof h !== "object") continue;
|
|
2673
|
+
push(h.id ?? h.sessionId, h.parentSession ?? h.parentSessionId, h.origin);
|
|
2674
|
+
}
|
|
2675
|
+
} catch {
|
|
2676
|
+
/* 两套都不可用就是没有 */
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
return rows;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
1817
2682
|
/** 汇总某会话的所有子代理后代会话的 usage(跨时段精确计费,与主会话分开)。
|
|
1818
2683
|
* @param ctx DSH 上下文(用于 subagents 服务 + 跨版本持久化会话读取)
|
|
1819
2684
|
* @param sessionId 父会话 id
|
|
1820
2685
|
* @param config 插件配置
|
|
1821
|
-
*
|
|
1822
|
-
*
|
|
1823
|
-
*
|
|
2686
|
+
*
|
|
2687
|
+
* 两条枚举路径,缺一条就会在某种 DSH 版本上永远拿到 0:
|
|
2688
|
+
* ① subagents.listDescendants —— 官方口径,但它内部要求 sessions + sessionQuery +
|
|
2689
|
+
* subagentCatalog 投影三个同时在线,任何一个缺失就抛 SubagentError。原来的实现
|
|
2690
|
+
* 直接 `catch { descendants = [] }`,于是**这条唯一路径一失败就等于「没有子代理」**,
|
|
2691
|
+
* 面板上那两行永远不出现,而且没有任何日志(2026-10-31:「把子智能体的消费也算上」)。
|
|
2692
|
+
* ② 会话列表摘要里的 parentSessionId —— 不依赖任何投影,只要有 sessionController.list()
|
|
2693
|
+
* 就能建出父子图。子代理还能再派子代理,所以按层展开而不是只看直接子级。
|
|
2694
|
+
* 两条取并集:服务可用时多一层保险,服务不可用(实测就是这种)时②独立成立。 */
|
|
1824
2695
|
async function sumSubagentUsage(ctx, sessionId, config) {
|
|
1825
2696
|
if (ctx == null || sessionId === "") return { usage: null };
|
|
1826
|
-
|
|
1827
|
-
|
|
2697
|
+
const rootKey = sessionIdKey(sessionId);
|
|
2698
|
+
const childIds = new Set();
|
|
2699
|
+
const seenKeys = new Set([rootKey]);
|
|
2700
|
+
let source = "none";
|
|
2701
|
+
|
|
2702
|
+
// 路径①:官方服务(可空探测,缺失不影响下面)
|
|
1828
2703
|
const subagentsSvc = typeof ctx.get === "function" ? ctx.get("subagents") : void 0;
|
|
1829
|
-
const listDescendants =
|
|
1830
|
-
|
|
1831
|
-
|
|
2704
|
+
const listDescendants =
|
|
2705
|
+
subagentsSvc != null && typeof subagentsSvc.listDescendants === "function"
|
|
2706
|
+
? subagentsSvc.listDescendants.bind(subagentsSvc)
|
|
2707
|
+
: void 0;
|
|
1832
2708
|
if (listDescendants != null) {
|
|
1833
2709
|
try {
|
|
1834
|
-
descendants = await listDescendants(sessionId);
|
|
2710
|
+
const descendants = await listDescendants(sessionId);
|
|
2711
|
+
for (const e of Array.isArray(descendants) ? descendants : []) {
|
|
2712
|
+
if (e == null || typeof e !== "object") continue;
|
|
2713
|
+
if (typeof e.id !== "string" || e.id === "") continue;
|
|
2714
|
+
const key = sessionIdKey(e.id);
|
|
2715
|
+
if (key === "" || seenKeys.has(key)) continue;
|
|
2716
|
+
seenKeys.add(key);
|
|
2717
|
+
childIds.add(e.id);
|
|
2718
|
+
}
|
|
2719
|
+
if (childIds.size > 0) source = "service";
|
|
1835
2720
|
} catch {
|
|
1836
|
-
|
|
2721
|
+
/* 投影不全时它必抛;不是错误,走路径② */
|
|
1837
2722
|
}
|
|
1838
2723
|
}
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
2724
|
+
|
|
2725
|
+
// 路径②:从会话摘要的 parentSessionId 建父子图,逐层展开
|
|
2726
|
+
try {
|
|
2727
|
+
const rows = await listSessionRows(ctx);
|
|
2728
|
+
const byParent = new Map();
|
|
2729
|
+
for (const row of rows) {
|
|
2730
|
+
const parentKey = sessionIdKey(row.parentId);
|
|
2731
|
+
if (parentKey === "") continue;
|
|
2732
|
+
if (!byParent.has(parentKey)) byParent.set(parentKey, []);
|
|
2733
|
+
byParent.get(parentKey).push(row.id);
|
|
2734
|
+
}
|
|
2735
|
+
let frontier = [rootKey];
|
|
2736
|
+
while (frontier.length > 0) {
|
|
2737
|
+
const next = [];
|
|
2738
|
+
for (const parentKey of frontier) {
|
|
2739
|
+
for (const childId of byParent.get(parentKey) ?? []) {
|
|
2740
|
+
const key = sessionIdKey(childId);
|
|
2741
|
+
if (key === "" || seenKeys.has(key)) continue;
|
|
2742
|
+
seenKeys.add(key);
|
|
2743
|
+
childIds.add(childId);
|
|
2744
|
+
next.push(key);
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
frontier = next;
|
|
2748
|
+
}
|
|
2749
|
+
if (childIds.size > 0 && source === "none") source = "catalog";
|
|
2750
|
+
} catch {
|
|
2751
|
+
/* 列不出来就当没有子代理 */
|
|
1844
2752
|
}
|
|
1845
|
-
|
|
2753
|
+
|
|
2754
|
+
if (childIds.size === 0) return { usage: null, debug: { source } };
|
|
1846
2755
|
|
|
1847
2756
|
// 2) 对每个子代理读取持久化事件并求和
|
|
1848
2757
|
const empty = () => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
|
|
@@ -1850,9 +2759,12 @@ async function sumSubagentUsage(ctx, sessionId, config) {
|
|
|
1850
2759
|
const offpeak = empty();
|
|
1851
2760
|
const byModelMap = new Map(); // model -> { model, peak, offpeak }
|
|
1852
2761
|
let count = 0;
|
|
1853
|
-
|
|
2762
|
+
// 上限:子代理会话虽然通常比主会话小,但这里是每轮 ?session= 都会跑的路径,
|
|
2763
|
+
// 一个会开很多子代理的长对话要防止它变成几百次串行解压(主线程同步 CPU)。
|
|
2764
|
+
const scanIds = [...childIds].slice(0, SUBAGENT_MAX_SESSIONS);
|
|
2765
|
+
for (const id of scanIds) {
|
|
1854
2766
|
try {
|
|
1855
|
-
const inspected = await
|
|
2767
|
+
const inspected = await inspectPersistedCached(ctx, id);
|
|
1856
2768
|
const events = Array.isArray(inspected?.events) ? inspected.events : [];
|
|
1857
2769
|
const u = sumEventsUsage(events, config);
|
|
1858
2770
|
if (u === null || u === void 0) continue;
|
|
@@ -1865,10 +2777,19 @@ async function sumSubagentUsage(ctx, sessionId, config) {
|
|
|
1865
2777
|
offpeak.outputTokens += u.offpeak.outputTokens;
|
|
1866
2778
|
offpeak.cacheReadTokens += u.offpeak.cacheReadTokens;
|
|
1867
2779
|
offpeak.cacheWriteTokens += u.offpeak.cacheWriteTokens;
|
|
1868
|
-
//
|
|
2780
|
+
// 子代理也可能换模型,按模型同样分桶聚合。
|
|
2781
|
+
// ⚠️ 键必须带 era(2026-10-31 审计 L5):出口处是按
|
|
2782
|
+
// estimateCost(b, catalogPricing(config, b.model, b.era), ...) 算钱的,
|
|
2783
|
+
// 原来只按 model 分桶、桶里没有 era → b.era 恒 undefined → 一律用**当前**价。
|
|
2784
|
+
// 于是跨过调价点的老子代理用量被按新价重算(旧价贵 50%,方向是低估),
|
|
2785
|
+
// 而且同一个 model 在新旧两段被并成一桶,「各模型之和 = 总额」当场破。
|
|
1869
2786
|
for (const b of u.byModel ?? []) {
|
|
1870
|
-
|
|
1871
|
-
|
|
2787
|
+
const bucketKey = b.model + "\u0000" + (b.era ?? "");
|
|
2788
|
+
let be = byModelMap.get(bucketKey);
|
|
2789
|
+
if (!be) {
|
|
2790
|
+
be = { model: b.model, era: b.era, peak: empty(), offpeak: empty() };
|
|
2791
|
+
byModelMap.set(bucketKey, be);
|
|
2792
|
+
}
|
|
1872
2793
|
be.peak.inputTokens += b.peak.inputTokens;
|
|
1873
2794
|
be.peak.outputTokens += b.peak.outputTokens;
|
|
1874
2795
|
be.peak.cacheReadTokens += b.peak.cacheReadTokens;
|
|
@@ -1881,8 +2802,13 @@ async function sumSubagentUsage(ctx, sessionId, config) {
|
|
|
1881
2802
|
} catch {
|
|
1882
2803
|
// 该子代理读取失败则跳过
|
|
1883
2804
|
}
|
|
2805
|
+
// 每个会话后让出主线程:插件和 DSH 同进程,连着解压十几个会把 UI 卡住
|
|
2806
|
+
await new Promise((resolve) => {
|
|
2807
|
+
if (typeof setImmediate === "function") setImmediate(resolve);
|
|
2808
|
+
else setTimeout(resolve, 0);
|
|
2809
|
+
});
|
|
1884
2810
|
}
|
|
1885
|
-
if (count === 0) return { usage: null };
|
|
2811
|
+
if (count === 0) return { usage: null, debug: { source, scanned: scanIds.length, counted: 0 } };
|
|
1886
2812
|
const inputTokens = peak.inputTokens + offpeak.inputTokens;
|
|
1887
2813
|
const outputTokens = peak.outputTokens + offpeak.outputTokens;
|
|
1888
2814
|
const cacheReadTokens = peak.cacheReadTokens + offpeak.cacheReadTokens;
|
|
@@ -1900,10 +2826,20 @@ async function sumSubagentUsage(ctx, sessionId, config) {
|
|
|
1900
2826
|
byModel: [...byModelMap.values()],
|
|
1901
2827
|
sessions: count,
|
|
1902
2828
|
};
|
|
1903
|
-
return { usage };
|
|
2829
|
+
return { usage, debug: { source, scanned: scanIds.length, counted: count } };
|
|
1904
2830
|
}
|
|
1905
2831
|
|
|
1906
2832
|
function formatUsage(usage, pricing, multiplier = 2, model = "") {
|
|
2833
|
+
// ⚠️ usage 为 null 时绝不许直接解引用(2026-10-31 全量审查)。
|
|
2834
|
+
// sumSessionUsage 在会话还在装载 / 事件为空时会返回 null,
|
|
2835
|
+
// 而 /balance 命令把它直接传进来 —— 下面一行 usage.byModel 就是 TypeError,
|
|
2836
|
+
// 整个命令面板报错。命令只是查看入口,拿不到数字就说一句话。
|
|
2837
|
+
if (usage == null || typeof usage !== "object") {
|
|
2838
|
+
return "当前会话消耗:暂时无数据(会话可能还在加载,稍后再试)";
|
|
2839
|
+
}
|
|
2840
|
+
// peak / offpeak 也可能缺失(旧版数据),先落到空桶再取字段。
|
|
2841
|
+
const peakBucket = usage.peak ?? {};
|
|
2842
|
+
const offpeakBucket = usage.offpeak ?? {};
|
|
1907
2843
|
// 优先按 byModel 逐段累加(每段用各自模型 + 价格时代的价格,历史消耗不因调价而变);
|
|
1908
2844
|
// 无 byModel 时回退单一价格估算(兼容旧数据)。pricing 是**目录价**,倍率单独传。
|
|
1909
2845
|
const cost =
|
|
@@ -1913,9 +2849,9 @@ function formatUsage(usage, pricing, multiplier = 2, model = "") {
|
|
|
1913
2849
|
0,
|
|
1914
2850
|
)
|
|
1915
2851
|
: estimateCost(usage, pricing, multiplier);
|
|
1916
|
-
const newTokens = usage.inputTokens + usage.outputTokens;
|
|
1917
|
-
const peakTokens =
|
|
1918
|
-
const offpeakTokens =
|
|
2852
|
+
const newTokens = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
|
2853
|
+
const peakTokens = (peakBucket.inputTokens ?? 0) + (peakBucket.cacheReadTokens ?? 0) + (peakBucket.cacheWriteTokens ?? 0) + (peakBucket.outputTokens ?? 0);
|
|
2854
|
+
const offpeakTokens = (offpeakBucket.inputTokens ?? 0) + (offpeakBucket.cacheReadTokens ?? 0) + (offpeakBucket.cacheWriteTokens ?? 0) + (offpeakBucket.outputTokens ?? 0);
|
|
1919
2855
|
const modelLine = model === "" ? "" : `(模型 ${model})`;
|
|
1920
2856
|
const lines = [
|
|
1921
2857
|
`当前会话消耗${modelLine}:`,
|
|
@@ -1940,6 +2876,26 @@ function writeJson(res, status, payload) {
|
|
|
1940
2876
|
res.end(body);
|
|
1941
2877
|
}
|
|
1942
2878
|
|
|
2879
|
+
/** 是否来自同源的 JSON 请求。
|
|
2880
|
+
* 本地 HTTP 接口默认对浏览器里**任意网页**开放:一个 text/plain 的「简单请求」
|
|
2881
|
+
* 不需要 CORS 预检就能打到 127.0.0.1,改设置、触发自更新都算副作用。
|
|
2882
|
+
* 所以写入口要求:Content-Type 是 application/json,且(若带 Origin)Origin 的
|
|
2883
|
+
* host:port 等于请求自己的 Host。兼容不带 Origin 的非浏览器调用(curl / 测试)。 */
|
|
2884
|
+
function isSameOriginJsonRequest(req) {
|
|
2885
|
+
const headers = req && req.headers ? req.headers : {};
|
|
2886
|
+
const contentType = String(headers["content-type"] || "").toLowerCase();
|
|
2887
|
+
if (!contentType.startsWith("application/json")) return false;
|
|
2888
|
+
const origin = headers.origin;
|
|
2889
|
+
if (origin === void 0 || origin === "") return true;
|
|
2890
|
+
try {
|
|
2891
|
+
const originHost = new URL(origin).host;
|
|
2892
|
+
const host = String(headers.host || "");
|
|
2893
|
+
return host !== "" && originHost === host;
|
|
2894
|
+
} catch {
|
|
2895
|
+
return false;
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
|
|
1943
2899
|
/** 后台异步任务的兜底包装。
|
|
1944
2900
|
*
|
|
1945
2901
|
* 为什么必须有:本插件里所有「不 await 的后台任务」(读设置文件、同步节假日、拉更新)
|
|
@@ -1974,11 +2930,27 @@ function runQuietly(label, fn) {
|
|
|
1974
2930
|
}
|
|
1975
2931
|
|
|
1976
2932
|
export function apply(ctx, config = {}) {
|
|
1977
|
-
|
|
2933
|
+
// 今日消耗的运行时状态:内存缓存 + 落盘快照路径 + 后台重算的单飞句柄。
|
|
2934
|
+
// 快照路径在读到 dshHome 之后填(见下面 runQuietly「读取设置」),
|
|
2935
|
+
// 空串表示定位不到目录 —— 此时退化成纯内存缓存,功能照常、只是下次启动仍要现算。
|
|
2936
|
+
const todayState = {
|
|
2937
|
+
cache: { expiresAt: 0, value: null },
|
|
2938
|
+
// ⚠️ 不能先置空串、稍后再赋值(2026-10-31 全量审查)。
|
|
2939
|
+
// todayState 一旦在赋值之前被读(异步回调、快速手动刷新、dispose 落盘),
|
|
2940
|
+
// readTodaySnapshot("") 会直接返回 null —— 表现就是「冷启动一直算不出今日」。
|
|
2941
|
+
// siblingFilePath 是同步的,直接在构造时就算好。
|
|
2942
|
+
snapshotFile: siblingFilePath(ctx, TODAY_SNAPSHOT_FILENAME),
|
|
2943
|
+
staleMs: TODAY_SNAPSHOT_STALE_MS,
|
|
2944
|
+
recompute: null,
|
|
2945
|
+
};
|
|
1978
2946
|
let statsCache = { expiresAt: 0, value: null };
|
|
2947
|
+
// 统计单飞(2026-10-31 审计 M7a):并发请求共用同一次全量扫描。
|
|
2948
|
+
let statsInFlight = null;
|
|
1979
2949
|
// 用户设置(「插件」页配置卡片读写):先按出厂默认值跑,异步读到文件后热替换。
|
|
1980
2950
|
// 读不到文件不是错误——全新安装就是没有文件,保持默认值即可。
|
|
1981
|
-
|
|
2951
|
+
// `ready` 是「文件已经读过一轮」的闸门:在它变 true 之前,POST 一律拒绝写盘,
|
|
2952
|
+
// 否则出厂默认值会把用户配置整份覆盖(见下面 handler 里的说明)。
|
|
2953
|
+
const settingsState = { value: { ...DEFAULT_SETTINGS }, file: "", readOnly: false, ready: false };
|
|
1982
2954
|
let effectiveConfig = withSettings(config, settingsState.value);
|
|
1983
2955
|
// 读设置是后台任务:settingsFilePath 走 dshHomePath 服务、readSettingsFile 走 fs,
|
|
1984
2956
|
// 两者都可能在 DSH 换版本后抛错(内部服务改名 / 目录权限变化)。
|
|
@@ -1992,12 +2964,19 @@ export function apply(ctx, config = {}) {
|
|
|
1992
2964
|
// 并立即落盘固化,保证后续每次启动都走「已读」分支。
|
|
1993
2965
|
settingsState.value = { ...DEFAULT_SETTINGS, entrySeen: true };
|
|
1994
2966
|
dirty = true;
|
|
2967
|
+
} else if (loaded === "corrupt") {
|
|
2968
|
+
// 配置文件坏了(已被另存为 .corrupt-<ts>):这一轮用出厂默认值跑,
|
|
2969
|
+
// 但**禁止启动时自动写盘** —— 否则一份默认值会把用户的 60 秒轮询、
|
|
2970
|
+
// 阈值、扫描上限全盖掉,正是「每次更新都改回去」的来源。
|
|
2971
|
+
// 用户之后在设置页改任何一项时会正常写盘(那时是用户自己的意图)。
|
|
2972
|
+
settingsState.value = { ...DEFAULT_SETTINGS, entrySeen: true };
|
|
2973
|
+
settingsState.readOnly = true;
|
|
1995
2974
|
} else if (loaded) {
|
|
1996
2975
|
settingsState.value = loaded;
|
|
1997
2976
|
}
|
|
1998
2977
|
// NEW 徽标的计时:记下「这个版本首次启动」的时间。
|
|
1999
2978
|
// 版本号变了(用户升级了)就重置,否则沿用上次记录的起点——
|
|
2000
|
-
// 否则每次启动都会把计时推到“现在”,永远过不了
|
|
2979
|
+
// 否则每次启动都会把计时推到“现在”,永远过不了 24 小时。
|
|
2001
2980
|
const tagVersion = settingsState.value.newTagVersion || "";
|
|
2002
2981
|
const tagSince = Number(settingsState.value.newTagSince) || 0;
|
|
2003
2982
|
if (tagVersion !== PLUGIN_VERSION || !(tagSince > 0)) {
|
|
@@ -2009,13 +2988,25 @@ export function apply(ctx, config = {}) {
|
|
|
2009
2988
|
dirty = true;
|
|
2010
2989
|
}
|
|
2011
2990
|
effectiveConfig = withSettings(config, settingsState.value);
|
|
2012
|
-
|
|
2991
|
+
// 闸门放开:从这里往后 settingsState.value 才是「用户磁盘上那一份」,
|
|
2992
|
+
// 之前的任何写入都是拿默认值覆盖用户配置。
|
|
2993
|
+
settingsState.ready = true;
|
|
2994
|
+
if (dirty && !settingsState.readOnly) {
|
|
2013
2995
|
try {
|
|
2014
2996
|
await writeSettingsFile(settingsState.file, settingsState.value);
|
|
2015
2997
|
} catch {
|
|
2016
2998
|
// 写盘失败(目录只读等)不影响这一次运行:内存里已经是最新值
|
|
2017
2999
|
}
|
|
2018
3000
|
}
|
|
3001
|
+
// 今日消耗快照:先填路径,再结算「昨天留下的那份」。
|
|
3002
|
+
// 结算必须在读快照之前做 —— settleStaleTodaySnapshot 会把过期的快照
|
|
3003
|
+
// 并进 dailySettled 然后删掉它,之后的 readTodaySnapshot 才不会读到旧值。
|
|
3004
|
+
// snapshotFile 已在 todayState 构造时就位(见上),这里不再重复赋值。
|
|
3005
|
+
try {
|
|
3006
|
+
await settleStaleTodaySnapshot(ctx);
|
|
3007
|
+
} catch {
|
|
3008
|
+
// 结算失败不影响今天的显示,下次启动会再试
|
|
3009
|
+
}
|
|
2019
3010
|
});
|
|
2020
3011
|
// DSH 热重载(reload)时会重新 apply 本插件;若旧实例注册的 /balance-api 路由
|
|
2021
3012
|
// 未被清理,新实例再次 register 就会抛出 duplicate route,导致整个 apply 失败、
|
|
@@ -2045,16 +3036,21 @@ export function apply(ctx, config = {}) {
|
|
|
2045
3036
|
const pricing = catalogPricing(effectiveConfig, model);
|
|
2046
3037
|
const multiplier = peakMultiplierOf(effectiveConfig);
|
|
2047
3038
|
// 余额来源:官方账号优先(官方版内置登录,无需 API Key),回退 API Key。
|
|
3039
|
+
// ⚠️ usage 可能是 null(session 未就绪 / sumSessionUsage 判定拿不到),
|
|
3040
|
+
// 而 formatUsage 第一行就取 usage.byModel(2026-10-31 审计 M10)。
|
|
3041
|
+
// 一旦炸掉,连余额文本都拿不到 —— 命令面板只剩一条异常。所以这里先算成文本,
|
|
3042
|
+
// 两条返回分支共用。
|
|
3043
|
+
const usageText = formatUsage(usage, pricing, multiplier, model);
|
|
2048
3044
|
const { balance, error } = await resolveBalance(ctx, effectiveConfig, signal);
|
|
2049
3045
|
if (balance === null) {
|
|
2050
3046
|
return {
|
|
2051
3047
|
kind: "error",
|
|
2052
|
-
text: `${error}\n\n${
|
|
3048
|
+
text: `${error}\n\n${usageText}`,
|
|
2053
3049
|
};
|
|
2054
3050
|
}
|
|
2055
3051
|
return {
|
|
2056
3052
|
kind: "success",
|
|
2057
|
-
text: `${formatBalance(balance)}\n\n${
|
|
3053
|
+
text: `${formatBalance(balance)}\n\n${usageText}`,
|
|
2058
3054
|
};
|
|
2059
3055
|
},
|
|
2060
3056
|
});
|
|
@@ -2093,7 +3089,7 @@ export function apply(ctx, config = {}) {
|
|
|
2093
3089
|
if (configParams?.get("config") === "1") {
|
|
2094
3090
|
try {
|
|
2095
3091
|
if (req.method === "GET") {
|
|
2096
|
-
writeJson(res, 200, { ok: true, settings: settingsState.value, version:
|
|
3092
|
+
writeJson(res, 200, { ok: true, settings: settingsState.value, version: installedVersion() });
|
|
2097
3093
|
return;
|
|
2098
3094
|
}
|
|
2099
3095
|
if (req.method !== "POST") {
|
|
@@ -2103,13 +3099,36 @@ export function apply(ctx, config = {}) {
|
|
|
2103
3099
|
const body = await readRequestBody(req);
|
|
2104
3100
|
const parsed = body.trim() === "" ? {} : JSON.parse(body);
|
|
2105
3101
|
const patch = parsed != null && typeof parsed === "object" && parsed.settings != null ? parsed.settings : parsed;
|
|
3102
|
+
// ⚠️ 设置文件还没读完时**绝不许写盘**(2026-10-31 全量审查)。
|
|
3103
|
+
// settingsState.value 的初值是 DEFAULT_SETTINGS,真正的值要等上面那个
|
|
3104
|
+
// runQuietly 异步读完文件。这中间任何一次 POST 都会拿出厂默认值当底
|
|
3105
|
+
// 做 normalizeSettings({...默认值, ...patch}) 然后整份落盘 ——
|
|
3106
|
+
// 用户改一个开关,轮询间隔 / 低余额阈值 / 扫描上限 / apiKey 全被盖回出厂。
|
|
3107
|
+
// 客户端也有同样的闸门,但那是浏览器侧,冷启动抢跑时挡不住。
|
|
3108
|
+
if (settingsState.ready !== true) {
|
|
3109
|
+
writeJson(res, 503, { ok: false, error: "设置还没加载完成,请稍后再试" });
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
// ⚠️ 写入口必须校验来源与 Content-Type(2026-10-31 审计 L10)。
|
|
3113
|
+
// 本接口监听在 127.0.0.1,浏览器里任意一个网页都能对它发 text/plain
|
|
3114
|
+
// 的「简单请求」—— 不需要预检就能改设置或触发自更新。
|
|
3115
|
+
// 只放行同源(本机 webServer)与 application/json。
|
|
3116
|
+
if (req.method === "POST" && !isSameOriginJsonRequest(req)) {
|
|
3117
|
+
writeJson(res, 403, { ok: false, error: "forbidden" });
|
|
3118
|
+
return;
|
|
3119
|
+
}
|
|
2106
3120
|
const next = normalizeSettings({ ...settingsState.value, ...(patch ?? {}) });
|
|
2107
3121
|
await writeSettingsFile(settingsState.file || settingsFilePath(ctx), next);
|
|
2108
3122
|
settingsState.value = next;
|
|
2109
3123
|
effectiveConfig = withSettings(config, next);
|
|
2110
3124
|
// 价格与扫描范围可能变了,作废统计缓存
|
|
2111
3125
|
statsCache = { expiresAt: 0, value: null };
|
|
2112
|
-
|
|
3126
|
+
// ⚠️ today 也必须一起作废(2026-10-31 审计 L7):
|
|
3127
|
+
// 改的是价格 / 倍率 / 峰谷窗口时,统计面板立刻刷新了,
|
|
3128
|
+
// 而今日金额还拿着旧价算出来的值最多 45 秒 —— 同一个界面两个口径。
|
|
3129
|
+
todayState.cache = { value: null, expiresAt: 0 };
|
|
3130
|
+
todayIncrementCache.clear();
|
|
3131
|
+
writeJson(res, 200, { ok: true, settings: next, version: installedVersion() });
|
|
2113
3132
|
} catch (error) {
|
|
2114
3133
|
writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
2115
3134
|
}
|
|
@@ -2130,11 +3149,11 @@ export function apply(ctx, config = {}) {
|
|
|
2130
3149
|
// 下载 + 逐文件写盘期间 await 会让出事件循环,不会卡住余额请求。
|
|
2131
3150
|
try {
|
|
2132
3151
|
const result = await installLatestVersion(httpCtx);
|
|
2133
|
-
writeJson(res, 200, { current:
|
|
3152
|
+
writeJson(res, 200, { current: installedVersion(), ...result });
|
|
2134
3153
|
} catch (error) {
|
|
2135
3154
|
writeJson(res, 200, {
|
|
2136
3155
|
ok: false,
|
|
2137
|
-
current:
|
|
3156
|
+
current: installedVersion(),
|
|
2138
3157
|
error: error instanceof Error ? error.message : String(error),
|
|
2139
3158
|
});
|
|
2140
3159
|
}
|
|
@@ -2147,9 +3166,9 @@ export function apply(ctx, config = {}) {
|
|
|
2147
3166
|
// force:用户手动点「检查更新」时不走 10 分钟缓存,否则刚更新过会显示旧结果
|
|
2148
3167
|
const found = await fetchLatestVersion(true);
|
|
2149
3168
|
if (found && found.error) {
|
|
2150
|
-
writeJson(res, 200, { ok: false, current:
|
|
3169
|
+
writeJson(res, 200, { ok: false, current: installedVersion(), error: found.error });
|
|
2151
3170
|
} else {
|
|
2152
|
-
writeJson(res, 200, { ok: true, current:
|
|
3171
|
+
writeJson(res, 200, { ok: true, current: installedVersion(), latest: found.latest });
|
|
2153
3172
|
}
|
|
2154
3173
|
return;
|
|
2155
3174
|
}
|
|
@@ -2191,64 +3210,148 @@ export function apply(ctx, config = {}) {
|
|
|
2191
3210
|
let sessionParam = "";
|
|
2192
3211
|
let wantStats = false;
|
|
2193
3212
|
let wantToday = false;
|
|
3213
|
+
let wantDiag = false;
|
|
2194
3214
|
try {
|
|
2195
3215
|
const search = new URL(req.url, "http://localhost").searchParams;
|
|
2196
3216
|
model = search.get("model") ?? "";
|
|
2197
3217
|
sessionParam = search.get("session") ?? "";
|
|
2198
3218
|
wantStats = search.get("stats") === "1";
|
|
2199
3219
|
wantToday = search.get("today") === "1";
|
|
3220
|
+
wantDiag = search.get("diag") === "1";
|
|
2200
3221
|
} catch {
|
|
2201
3222
|
model = "";
|
|
2202
3223
|
sessionParam = "";
|
|
2203
3224
|
wantStats = false;
|
|
2204
3225
|
wantToday = false;
|
|
3226
|
+
wantDiag = false;
|
|
2205
3227
|
}
|
|
2206
3228
|
let stats = null;
|
|
2207
3229
|
let statsError = "";
|
|
2208
3230
|
if (wantStats) {
|
|
2209
3231
|
try {
|
|
2210
3232
|
if (statsCache.value == null || statsCache.expiresAt <= Date.now()) {
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
3233
|
+
// ⚠️ 统计要全量扫最多 80 个会话(每个可能 700ms 同步解压),
|
|
3234
|
+
// 并发而来的第二个请求原来会**再扫一遍**(2026-10-31 审计 M7a)。
|
|
3235
|
+
// 单飞:同一时刻只允许一个 buildUsageStats 在跑,其余共用同一个 Promise。
|
|
3236
|
+
if (statsInFlight === null) {
|
|
3237
|
+
statsInFlight = (async () => {
|
|
3238
|
+
try {
|
|
3239
|
+
const value = await buildUsageStats(httpCtx, effectiveConfig);
|
|
3240
|
+
statsCache = {
|
|
3241
|
+
value,
|
|
3242
|
+
expiresAt: Date.now() + (effectiveConfig.statsCacheMs ?? STATS_CACHE_MS),
|
|
3243
|
+
};
|
|
3244
|
+
return value;
|
|
3245
|
+
} finally {
|
|
3246
|
+
statsInFlight = null;
|
|
3247
|
+
}
|
|
3248
|
+
})();
|
|
3249
|
+
}
|
|
3250
|
+
await statsInFlight;
|
|
2215
3251
|
}
|
|
2216
3252
|
stats = statsCache.value;
|
|
2217
3253
|
} catch (error) {
|
|
2218
3254
|
statsError = error instanceof Error ? error.message : String(error);
|
|
2219
3255
|
}
|
|
2220
3256
|
}
|
|
2221
|
-
//
|
|
2222
|
-
|
|
2223
|
-
if (wantToday) {
|
|
2224
|
-
try {
|
|
2225
|
-
if (todayCache.value == null || todayCache.expiresAt <= Date.now()) {
|
|
2226
|
-
todayCache = { value: await buildTodayCost(httpCtx, effectiveConfig), expiresAt: Date.now() + 60 * 1000 };
|
|
2227
|
-
}
|
|
2228
|
-
today = todayCache.value;
|
|
2229
|
-
} catch {
|
|
2230
|
-
today = null; // 拿不到就不显示,不阻塞余额
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
3257
|
+
// 今日消耗放在会话处理之后算:?session= 请求手里已经有该会话的完整 events,
|
|
3258
|
+
// 传给 buildTodayCost 就不用再解压一遍 —— 这就是「实时刷新」的零成本来源。
|
|
2233
3259
|
// 按 session id 取会话并分时段汇总 usage(拿不到则返回 null,客户端回退用本地投影)
|
|
2234
3260
|
let usage = null;
|
|
2235
3261
|
// 子代理后代会话的 usage 汇总(与主会话分开)
|
|
2236
3262
|
let subagentUsage = null;
|
|
2237
3263
|
// 从会话事件识别实际使用模型(客户端未传 ?model= 时兜底)
|
|
2238
3264
|
let detectedModel = "";
|
|
3265
|
+
// 今日消耗复用句柄:谁解压过当前会话的 events,就记在这里
|
|
3266
|
+
let todayReuse = null;
|
|
3267
|
+
// 会话是否已经装进 DSH 内存。false = DSH 正在读它的持久化文件,
|
|
3268
|
+
// 这一轮插件**完全不碰磁盘**(不读会话、不算 today、不扫子代理)。
|
|
3269
|
+
let sessionLoaded = false;
|
|
3270
|
+
// 是否**真的**在装载中(sessions 服务存在但该会话没值)。
|
|
3271
|
+
// 注意它与 `!sessionLoaded` 不等价:服务端根本没有 sessions 服务时,
|
|
3272
|
+
// sessionLoaded 恒为 false 但并非"正在加载",此时必须照常读持久化。
|
|
3273
|
+
let sessionLoading = false;
|
|
3274
|
+
// 临时诊断:`?diag=1` 时随响应带回,用来查「为什么 usage 恒为 null」。
|
|
3275
|
+
// 只带类型和布尔,不带 token 内容,开销为零。
|
|
3276
|
+
let sessionDiag = null;
|
|
2239
3277
|
if (sessionParam !== "") {
|
|
2240
3278
|
try {
|
|
2241
3279
|
const sessions = ctx.get("sessions");
|
|
2242
|
-
|
|
3280
|
+
// ⚠️ `sessions.get(id)` 拿不到**不等于**会话正在装载 —— DSH 启动后不会预加载会话,
|
|
3281
|
+
// 只有用户点进某个对话时它才进内存。所以「没值」最常见的原因其实是「这个对话
|
|
3282
|
+
// 还没被点开过」,此时磁盘上的 zstd 完好、根本没人跟插件抢。
|
|
3283
|
+
//
|
|
3284
|
+
// 2026-10-31 实测:重启后 `?session=9877db7c` 恒 null(界面 ¥73.6),
|
|
3285
|
+
// 用户点进该对话后同一请求立刻正常(¥90.9)——差别就是 `get()` 有没有值。
|
|
3286
|
+
// 所以「没值」时**必须**先去磁盘拿真数,不能直接给 null 让客户端估算,
|
|
3287
|
+
// 估算那条路不分 era,跨调价点的会话会被压低近 20%。
|
|
3288
|
+
//
|
|
3289
|
+
// 抢线程的风险靠 `SESSION_REREAD_MIN_MS` 节流兜住:真在装载时最多也只解压一次。
|
|
3290
|
+
const sessionsUsable = sessions !== void 0 && sessions !== null && typeof sessions.get === "function";
|
|
3291
|
+
const session = sessionsUsable ? sessions.get(sessionParam) : void 0;
|
|
3292
|
+
// 仅用于诊断:区分「服务不存在」和「服务在但会话没进内存」。
|
|
3293
|
+
sessionLoading = sessionsUsable && session === void 0;
|
|
3294
|
+
if (wantDiag) {
|
|
3295
|
+
sessionDiag = {
|
|
3296
|
+
sessionsType: typeof sessions,
|
|
3297
|
+
sessionsIsNull: sessions === null,
|
|
3298
|
+
hasGet: typeof sessions?.get === "function",
|
|
3299
|
+
sessionsUsable,
|
|
3300
|
+
sessionUndefined: session === void 0,
|
|
3301
|
+
sessionType: typeof session,
|
|
3302
|
+
sessionLoading,
|
|
3303
|
+
persisted: null,
|
|
3304
|
+
persistedError: null,
|
|
3305
|
+
};
|
|
3306
|
+
}
|
|
3307
|
+
// **会话确实正在装载时,绝不去读它的持久化文件**(2026-10-31:「点击 A 对话
|
|
3308
|
+
// 之后加载没完成,我再点击 B 对话会导致对话都加载很慢」,「让行那几十毫秒不行」)。
|
|
3309
|
+
// `sessions.get()` 有服务但没值 = DSH 正在把那个 zstd 读进内存。此时插件再去解压
|
|
3310
|
+
// 同一个 40 MB 文件,就是两个同步解压抢同一条线程,A 和 B 一起卡 —— 让行 60 ms
|
|
3311
|
+
// 只是把拥堵推后 60 ms,堵本身一点没少,所以那个方案无效。
|
|
3312
|
+
// 正确做法是**根本不参与**:返回 null,客户端用本地投影估算先显示;等 DSH 把这个
|
|
3313
|
+
// 会话装进内存(下一轮 usageKey 变化再来问时 session 就有值了),插件才读它,
|
|
3314
|
+
// 那时 DSH 已经读完,不存在竞争。
|
|
3315
|
+
// 「会话正在装载」只可能是**服务存在但没值**这一种。sessions 服务不存在时
|
|
3316
|
+
// 永远不是加载中 —— 否则在这个环境里 usage 会恒为 null(2026-10-31 实测)。
|
|
3317
|
+
sessionLoaded = session !== void 0;
|
|
2243
3318
|
if (session !== void 0) {
|
|
2244
3319
|
usage = sumSessionUsage(session, effectiveConfig);
|
|
2245
3320
|
detectedModel = detectModel(session);
|
|
3321
|
+
// live 事件本身就是全量,直接给「今日消耗」复用,省掉一次解压
|
|
3322
|
+
const liveEvents = sessionEvents(session);
|
|
3323
|
+
if (Array.isArray(liveEvents) && liveEvents.length > 0) {
|
|
3324
|
+
todayReuse = { sessionId: sessionParam, events: liveEvents };
|
|
3325
|
+
}
|
|
2246
3326
|
}
|
|
2247
|
-
// DSH 2.0.4 起 live session(sessions.get)可能拿不到 events,导致 usage 全 0
|
|
2248
|
-
//
|
|
3327
|
+
// DSH 2.0.4 起 live session(sessions.get)可能拿不到 events,导致 usage 全 0;
|
|
3328
|
+
// 会话还没被点开时更是根本拿不到 live。两种情况都回退持久化读事件,
|
|
3329
|
+
// 避免客户端拿到"空但非 null"的 usage 而把金额显示成 ¥0.0000。
|
|
3330
|
+
// 这里**不再**用 sessionLoading 拦:拿不到 live 最常见的原因是该对话还没被点开,
|
|
3331
|
+
// 磁盘上没人跟插件抢;真在装载时由 SESSION_REREAD_MIN_MS 节流兜住。
|
|
2249
3332
|
if (usage == null || usage.totalTokens === 0) {
|
|
2250
3333
|
try {
|
|
2251
|
-
|
|
3334
|
+
// 4 秒内不重复解压同一个会话:对话进行中 `updatedAt` 每轮都变,
|
|
3335
|
+
// 只看指纹会把 46 MB 的 zstd 每轮重解一遍(800 ms 同步 CPU)。
|
|
3336
|
+
//
|
|
3337
|
+
// ⚠️ **两套 id**:客户端传的是裸 UUID(DSH 的 sessionId prop),
|
|
3338
|
+
// 而 `sessions.get()` 认裸 UUID、持久化目录却叫 `session-<uuid>`。
|
|
3339
|
+
// 直接拿裸 UUID 去 inspect 会报 `session "…" not found`
|
|
3340
|
+
// (2026-10-31 实测,`?session=` 因此恒为 null → 界面走不分 era 的估算)。
|
|
3341
|
+
// 先按带前缀的试,失败再退回原值,两边都能覆盖。
|
|
3342
|
+
const persistedId = sessionParam.startsWith("session-") ? sessionParam : `session-${sessionParam}`;
|
|
3343
|
+
let persisted = await inspectPersistedCached(ctx, persistedId, SESSION_REREAD_MIN_MS);
|
|
3344
|
+
if ((persisted == null || !Array.isArray(persisted.events) || persisted.events.length === 0) && persistedId !== sessionParam) {
|
|
3345
|
+
persisted = await inspectPersistedCached(ctx, sessionParam, SESSION_REREAD_MIN_MS);
|
|
3346
|
+
}
|
|
3347
|
+
if (sessionDiag !== null) {
|
|
3348
|
+
sessionDiag.persisted = {
|
|
3349
|
+
isNull: persisted == null,
|
|
3350
|
+
hasEvents: Array.isArray(persisted?.events),
|
|
3351
|
+
eventCount: Array.isArray(persisted?.events) ? persisted.events.length : 0,
|
|
3352
|
+
meta: persisted?.meta == null ? null : typeof persisted.meta,
|
|
3353
|
+
};
|
|
3354
|
+
}
|
|
2252
3355
|
if (Array.isArray(persisted?.events) && persisted.events.length > 0) {
|
|
2253
3356
|
const persistedUsage = sumEventsUsage(persisted.events, effectiveConfig, detectedModel);
|
|
2254
3357
|
if (persistedUsage.totalTokens > 0) {
|
|
@@ -2258,19 +3361,31 @@ export function apply(ctx, config = {}) {
|
|
|
2258
3361
|
if (switches.length > 0) detectedModel = switches[switches.length - 1].model;
|
|
2259
3362
|
}
|
|
2260
3363
|
}
|
|
3364
|
+
// 刚解压出来的事件也给今日消耗复用(缓存之外的第二份,避免重复读)
|
|
3365
|
+
todayReuse = { sessionId: sessionParam, events: persisted.events };
|
|
2261
3366
|
}
|
|
2262
|
-
} catch {
|
|
3367
|
+
} catch (err) {
|
|
2263
3368
|
// 持久化读取不可用:忽略,走下方统一降级
|
|
3369
|
+
if (sessionDiag !== null) {
|
|
3370
|
+
sessionDiag.persistedError = String(err && err.message ? err.message : err).slice(0, 300);
|
|
3371
|
+
}
|
|
2264
3372
|
}
|
|
2265
3373
|
}
|
|
2266
3374
|
// 仍无任何 token 就返回 null,让客户端回退本地投影估算(而不是当成 0 消耗显示 ¥0.0000)
|
|
2267
3375
|
if (usage != null && usage.totalTokens === 0) usage = null;
|
|
2268
|
-
// 子代理枚举走持久化目录(不依赖 live
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
3376
|
+
// 子代理枚举走持久化目录(不依赖 live 会话),即使父会话不在内存也能拿到。
|
|
3377
|
+
try {
|
|
3378
|
+
const subRes = await sumSubagentUsage(httpCtx, sessionParam, effectiveConfig);
|
|
3379
|
+
subagentUsage = subRes.usage;
|
|
3380
|
+
} catch {
|
|
3381
|
+
subagentUsage = null;
|
|
3382
|
+
}
|
|
3383
|
+
} catch (err) {
|
|
2272
3384
|
usage = null;
|
|
2273
3385
|
subagentUsage = null;
|
|
3386
|
+
if (sessionDiag !== null) {
|
|
3387
|
+
sessionDiag.outerError = String(err && err.stack ? err.stack : err).slice(0, 600);
|
|
3388
|
+
}
|
|
2274
3389
|
}
|
|
2275
3390
|
}
|
|
2276
3391
|
// 客户端优先用自己解析的 model;为空则回退到服务端 detectModel
|
|
@@ -2282,26 +3397,66 @@ export function apply(ctx, config = {}) {
|
|
|
2282
3397
|
if (subagentUsage && Array.isArray(subagentUsage.byModel)) {
|
|
2283
3398
|
subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b, catalogPricing(effectiveConfig, b.model, b.era), peakMultiplierOf(effectiveConfig)) }));
|
|
2284
3399
|
}
|
|
2285
|
-
//
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
3400
|
+
// 今日消耗:三条路径。
|
|
3401
|
+
// ① 带 ?session= 且手里有该会话的实时事件 → **只增量算这一个会话**,
|
|
3402
|
+
// 其余会话的贡献从上一次结果里原样搬运,一笔都不重扫(用户 2026-10-31:
|
|
3403
|
+
// 「本对话消耗只扫对应对话的,其他对话别扫描」)。
|
|
3404
|
+
// ② 增量无从谈起(今天还没算过 / 快照是昨天的)→ 回退完整路径,一天只碰一次。
|
|
3405
|
+
// ③ 只带 ?today=1 → 走快照三层(冷启动秒开)。
|
|
3406
|
+
// partial 表示给的是旧值、后台正在算。
|
|
3407
|
+
let today = null;
|
|
3408
|
+
let todayPartial = false;
|
|
3409
|
+
if (wantToday || sessionParam !== "") {
|
|
3410
|
+
try {
|
|
3411
|
+
// today 也照常算:会话没进内存不代表 DSH 在解锁它(可能只是没被点开),
|
|
3412
|
+
// 此时扫盘不冲突,而漏算今日比慢更难接受。
|
|
3413
|
+
if (sessionParam !== "" && todayReuse !== null && todayReuse.sessionId === sessionParam) {
|
|
3414
|
+
today = await updateTodayCostWithSession(
|
|
3415
|
+
httpCtx, effectiveConfig, todayState, sessionParam, todayReuse.events,
|
|
3416
|
+
);
|
|
3417
|
+
}
|
|
3418
|
+
if (today === null && wantToday) {
|
|
3419
|
+
const resolved = await resolveTodayCost(httpCtx, effectiveConfig, todayState, todayReuse);
|
|
3420
|
+
today = resolved.value;
|
|
3421
|
+
todayPartial = resolved.partial;
|
|
3422
|
+
}
|
|
3423
|
+
} catch {
|
|
3424
|
+
today = null; // 拿不到就不显示,不阻塞余额
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
// ⚠️ 主干尾部必须自己兜住(2026-10-31 审计 M12):
|
|
3428
|
+
// 前面每个分支都有局部 try,唯独这段没有。resolveBalance / catalogPricing /
|
|
3429
|
+
// writeJson 任一抛出(resolveApiKey、网络、res 已销毁)都会让 handler 直接 reject,
|
|
3430
|
+
// 客户端收到零字节响应挂到超时,还可能升级成 unhandledRejection。
|
|
3431
|
+
try {
|
|
3432
|
+
// 余额来源:官方账号优先(官方桌面版内置登录,无需 API Key),回退 DeepSeek API Key。
|
|
3433
|
+
const { balance, error: balanceError } = await resolveBalance(ctx, effectiveConfig);
|
|
3434
|
+
if (balance === null && !wantStats) {
|
|
3435
|
+
writeJson(res, 200, { ok: false, error: balanceError });
|
|
3436
|
+
return;
|
|
3437
|
+
}
|
|
3438
|
+
writeJson(res, 200, {
|
|
3439
|
+
ok: true,
|
|
3440
|
+
balance,
|
|
3441
|
+
balanceError: balance === null ? balanceError : null,
|
|
3442
|
+
// 传给客户端的是**目录价**(不含峰谷倍率):客户端自己按高峰时段乘倍率,
|
|
3443
|
+
// 若这里给 resolvePricing(已乘倍率)客户端会再乘一次,金额虚高约 3 倍。
|
|
3444
|
+
pricing: catalogPricing(effectiveConfig, effectiveModel),
|
|
3445
|
+
model: effectiveModel,
|
|
3446
|
+
usage,
|
|
3447
|
+
subagentUsage,
|
|
3448
|
+
stats,
|
|
3449
|
+
statsError,
|
|
3450
|
+
today,
|
|
3451
|
+
todayPartial,
|
|
3452
|
+
...(wantDiag ? { diag: sessionDiag } : {}),
|
|
3453
|
+
});
|
|
3454
|
+
} catch (error) {
|
|
3455
|
+
// headersSent 之后不能再写头,只能放弃这一次响应
|
|
3456
|
+
if (!res.headersSent) {
|
|
3457
|
+
writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
3458
|
+
}
|
|
2290
3459
|
}
|
|
2291
|
-
writeJson(res, 200, {
|
|
2292
|
-
ok: true,
|
|
2293
|
-
balance,
|
|
2294
|
-
balanceError: balance === null ? balanceError : null,
|
|
2295
|
-
// 传给客户端的是**目录价**(不含峰谷倍率):客户端自己按高峰时段乘倍率,
|
|
2296
|
-
// 若这里给 resolvePricing(已乘倍率)客户端会再乘一次,金额虚高约 3 倍。
|
|
2297
|
-
pricing: catalogPricing(effectiveConfig, effectiveModel),
|
|
2298
|
-
model: effectiveModel,
|
|
2299
|
-
usage,
|
|
2300
|
-
subagentUsage,
|
|
2301
|
-
stats,
|
|
2302
|
-
statsError,
|
|
2303
|
-
today,
|
|
2304
|
-
});
|
|
2305
3460
|
},
|
|
2306
3461
|
});
|
|
2307
3462
|
} catch (error) {
|
|
@@ -2320,6 +3475,14 @@ export function apply(ctx, config = {}) {
|
|
|
2320
3475
|
for (const d of disposers) {
|
|
2321
3476
|
try { d(); } catch { /* ignore */ }
|
|
2322
3477
|
}
|
|
3478
|
+
// 退出/热重载时把今天最后一次算出的值落盘(用户要求:退出时保留缓存,下次直接读)。
|
|
3479
|
+
// 不重算、不刷新 at —— at 记录的是「算出来的时刻」,改成写盘时刻会让下次启动
|
|
3480
|
+
// 误判成「刚算过」而不去重算,快照就永远停在这里。
|
|
3481
|
+
// writeJsonAtomic 是先写 .tmp 再 rename,中途被打断也不会留下半个文件。
|
|
3482
|
+
const last = todayState.cache.value;
|
|
3483
|
+
if (last != null && typeof last === "object" && todayState.snapshotFile !== "") {
|
|
3484
|
+
runQuietly("保存今日消耗快照", () => writeTodaySnapshot(todayState.snapshotFile, last));
|
|
3485
|
+
}
|
|
2323
3486
|
};
|
|
2324
3487
|
if (typeof ctx.on === "function") ctx.on("dispose", release);
|
|
2325
3488
|
}
|