balance-dsh 2.0.9 → 2.1.1
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 +2748 -428
- package/lib/index.js +1362 -154
- 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,177 @@ 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
|
+
// 按会话增长的表一律夹上限(2026-10-31 第二十二轮审计):Map 保持插入序,淘汰最旧的键。
|
|
128
|
+
const PERSIST_READ_MAX = 240;
|
|
129
|
+
function capSessionMap(map, max = PERSIST_READ_MAX) {
|
|
130
|
+
while (map.size > max) {
|
|
131
|
+
const oldest = map.keys().next();
|
|
132
|
+
if (oldest.done) return;
|
|
133
|
+
map.delete(oldest.value);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function inspectPersistedCached(ctx, sessionId, minIntervalMs = 0) {
|
|
137
|
+
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
138
|
+
// 每帧按 7 KB 估算占用:实测 35.8 MB 的 zstd 会话 = 19331 帧 / 解压后 134 MB
|
|
139
|
+
// (约 6.9 KB/帧)。⚠️ 2026-10-31 第二十二轮审计:原来写 4096,低估约 40% ——
|
|
140
|
+
// 512 MB 的预算实际能吃到 ~800 MB 常驻,和 DSH 同进程时会 OOM。
|
|
141
|
+
// 这个数只用来做淘汰决策,不参与任何计费。
|
|
142
|
+
const estimateBytes = (value) => {
|
|
143
|
+
const events = value?.events;
|
|
144
|
+
return Array.isArray(events) ? events.length * 7000 : 0;
|
|
145
|
+
};
|
|
146
|
+
const dropEntry = (key) => {
|
|
147
|
+
const rec = persistCache.get(key);
|
|
148
|
+
if (rec !== undefined) {
|
|
149
|
+
persistCache.delete(key);
|
|
150
|
+
persistCacheBytes -= rec.size ?? 0;
|
|
151
|
+
}
|
|
152
|
+
// persistReadAt 也必须跟着删:否则跨月累积上千个会话 id 永远不会回收。
|
|
153
|
+
persistReadAt.delete(key);
|
|
154
|
+
};
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
const stamps = await sessionStampMap(ctx);
|
|
157
|
+
// ⚠️ 列表里有这条 id,但 updatedAt/createdAt 都是空(新版本 DSH 的某些条目如此)时,
|
|
158
|
+
// 指纹恒为 "",`hit.stamp === stamp` 永远成立 → 走 30 分钟长效 TTL,内容更新了也读到
|
|
159
|
+
// 冻结的旧事件(2026-10-31 第二十二轮审计)。所以「空指纹」一律按拿不到指纹处理。
|
|
160
|
+
const hasStamp = stamps.has(sessionId) && stamps.get(sessionId) !== "";
|
|
161
|
+
const stamp = hasStamp ? stamps.get(sessionId) : "";
|
|
162
|
+
|
|
163
|
+
const hit = persistCache.get(sessionId);
|
|
164
|
+
// 命中条件:指纹相同 + 未过期。指纹可用时给 30 分钟长效(内容一变指纹就不同,
|
|
165
|
+
// 立刻失效);拿不到指纹时只有 5 秒保质期 —— 否则内容更新了也要等半小时才看见。
|
|
166
|
+
const ttl = hasStamp ? PERSIST_CACHE_TTL_MS : PERSIST_STAMPLESS_TTL_MS;
|
|
167
|
+
if (hit !== undefined && hit.stamp === stamp && now - hit.at < ttl) {
|
|
168
|
+
// 命中要把这一项挪到 Map 末尾 —— Map 保持插入序,淘汰取第一个键,
|
|
169
|
+
// 不挪就等于 FIFO:正在用的那个大会话会被后面进来的小会话挤掉,
|
|
170
|
+
// 下一轮又得重解压一遍 134 MB。
|
|
171
|
+
persistCache.delete(sessionId);
|
|
172
|
+
persistCache.set(sessionId, hit);
|
|
173
|
+
return hit.value;
|
|
174
|
+
}
|
|
175
|
+
// 指纹变了但还没到最短重读间隔:先把上一份给出去,别为了几秒的新鲜度去解压 46 MB。
|
|
176
|
+
if (minIntervalMs > 0 && hit !== undefined) {
|
|
177
|
+
const lastAt = persistReadAt.get(sessionId) ?? 0;
|
|
178
|
+
if (now - lastAt < minIntervalMs) return hit.value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const running = persistInFlight.get(sessionId);
|
|
182
|
+
if (running !== undefined) return running;
|
|
183
|
+
|
|
184
|
+
const task = (async () => {
|
|
185
|
+
try {
|
|
186
|
+
const value = await inspectPersistedSession(ctx, sessionId);
|
|
187
|
+
// 覆盖同一会话时先把旧那份的字节数扣掉,否则总量只增不减。
|
|
188
|
+
if (persistCache.has(sessionId)) dropEntry(sessionId);
|
|
189
|
+
const size = estimateBytes(value);
|
|
190
|
+
persistCache.set(sessionId, { stamp, at: Date.now(), value, size });
|
|
191
|
+
persistCacheBytes += size;
|
|
192
|
+
persistReadAt.set(sessionId, Date.now());
|
|
193
|
+
// ⚠️ 淘汰必须同时看**条数**和**字节数**(2026-10-31 全量审查)。
|
|
194
|
+
// 原来只看 240 条:单个大会话解压后就是 134 MB,240 个就是几十 GB,
|
|
195
|
+
// 而插件和 DSH 同进程 —— 真撞上就是整个应用 OOM,不是掉几帧。
|
|
196
|
+
// 条数保留是为了兜住「很多小会话」那种情况(字节预算对它们不敏感)。
|
|
197
|
+
while (
|
|
198
|
+
persistCache.size > PERSIST_CACHE_MAX ||
|
|
199
|
+
(persistCacheBytes > PERSIST_CACHE_BYTES && persistCache.size > 1)
|
|
200
|
+
) {
|
|
201
|
+
const oldest = persistCache.keys().next().value;
|
|
202
|
+
if (oldest === undefined) break;
|
|
203
|
+
// 最后一项即使超预算也不淘汰:否则大会话每次都被自己挤掉,等于没有缓存。
|
|
204
|
+
if (oldest === sessionId && persistCache.size > 1) {
|
|
205
|
+
const fallback = [...persistCache.keys()][1];
|
|
206
|
+
if (fallback !== undefined) dropEntry(fallback);
|
|
207
|
+
else break;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
dropEntry(oldest);
|
|
211
|
+
}
|
|
212
|
+
return value;
|
|
213
|
+
} finally {
|
|
214
|
+
persistInFlight.delete(sessionId);
|
|
215
|
+
// ⚠️ 失败也要记重读时刻(2026-10-31 第二十二轮审计):这条只写在成功路径时,
|
|
216
|
+
// 大会话文件损坏/被占的情况下 minIntervalMs 的节流永远不生效 ——
|
|
217
|
+
// `?session=` 每次请求都完整解压一遍(约 800 ms 同步 CPU),切会话还会再试第二个 id。
|
|
218
|
+
if (minIntervalMs > 0) persistReadAt.set(sessionId, Date.now());
|
|
219
|
+
capSessionMap(persistReadAt);
|
|
220
|
+
}
|
|
221
|
+
})();
|
|
222
|
+
persistInFlight.set(sessionId, task);
|
|
223
|
+
return task;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
53
227
|
const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
|
|
54
228
|
|
|
55
229
|
// DeepSeek 目录价(人民币/百万 tokens),按模型区分。默认取 2026-08-17 生效的
|
|
@@ -158,7 +332,15 @@ async function fetchCalendarYear(year) {
|
|
|
158
332
|
if (!Array.isArray(data?.days)) continue;
|
|
159
333
|
let count = 0;
|
|
160
334
|
for (const item of data.days) {
|
|
161
|
-
|
|
335
|
+
// 键格式与总量都要校验(2026-10-31 审计 M11):上游是第三方仓库,
|
|
336
|
+
// 一个畸形 date 会以垃圾键写进 CN_CALENDAR 并落盘;无上限则一份超大响应能把表撑爆。
|
|
337
|
+
if (count >= 400) break;
|
|
338
|
+
if (
|
|
339
|
+
item &&
|
|
340
|
+
typeof item.date === "string" &&
|
|
341
|
+
/^\d{4}-\d{2}-\d{2}$/.test(item.date) &&
|
|
342
|
+
typeof item.isOffDay === "boolean"
|
|
343
|
+
) {
|
|
162
344
|
const kind = item.isOffDay ? "holiday" : "workday";
|
|
163
345
|
CN_CALENDAR[item.date] = kind;
|
|
164
346
|
cnCalendarState.days[item.date] = kind;
|
|
@@ -185,7 +367,13 @@ async function loadCalendarCache(ctx) {
|
|
|
185
367
|
if (days === null) return false;
|
|
186
368
|
let count = 0;
|
|
187
369
|
for (const [date, kind] of Object.entries(days)) {
|
|
188
|
-
|
|
370
|
+
// 同上:本地缓存也可能是旧版/被手改的,键格式必须校验,否则垃圾键会活到下一轮计费。
|
|
371
|
+
if (count >= 400) break;
|
|
372
|
+
if (
|
|
373
|
+
typeof date === "string" &&
|
|
374
|
+
/^\d{4}-\d{2}-\d{2}$/.test(date) &&
|
|
375
|
+
(kind === "holiday" || kind === "workday")
|
|
376
|
+
) {
|
|
189
377
|
CN_CALENDAR[date] = kind;
|
|
190
378
|
cnCalendarState.days[date] = kind;
|
|
191
379
|
count += 1;
|
|
@@ -213,7 +401,10 @@ async function saveCalendarCache() {
|
|
|
213
401
|
const { mkdir, writeFile, rename } = await import("node:fs/promises");
|
|
214
402
|
const { dirname } = await import("node:path");
|
|
215
403
|
await mkdir(dirname(file), { recursive: true });
|
|
216
|
-
|
|
404
|
+
// 临时名必须唯一(2026-10-31 审计 L3):固定 .tmp 在并发保存时会互相覆盖,
|
|
405
|
+
// 后完成的那次 rename 拿到的是前一次的半份内容,甚至先 rename 完的那个把 tmp 删掉后
|
|
406
|
+
// 另一个 rename 直接 ENOENT。
|
|
407
|
+
const tmp = file + "." + process.pid + "." + Math.random().toString(36).slice(2) + ".tmp";
|
|
217
408
|
const payload = {
|
|
218
409
|
fetchedAt: cnCalendarState.fetchedAt,
|
|
219
410
|
years: cnCalendarState.years,
|
|
@@ -328,7 +519,17 @@ function cnDateKey(d) {
|
|
|
328
519
|
}
|
|
329
520
|
|
|
330
521
|
/** 判断某时间戳(毫秒)是否处于高峰时段(北京时间;含周末、法定节假日与调休上班判断)。 */
|
|
331
|
-
|
|
522
|
+
// 窗口/星期只跟 config 有关,不该每帧重建(2026-10-31 审计 M8):
|
|
523
|
+
// 两万帧的会话 ≈ 4000 次调用,原来每次都要 map 两个数组、分配两份对象。
|
|
524
|
+
// withSettings 每次会产出**新对象**,所以用 WeakMap 按 config 身份缓存,自动随 GC 释放。
|
|
525
|
+
// 日历查询必须留在函数体里(CN_CALENDAR 会随同步被填充,缓存住会读到旧表)。
|
|
526
|
+
const peakRuleCache = new WeakMap();
|
|
527
|
+
function peakRuleOf(config) {
|
|
528
|
+
const cacheable = config != null && typeof config === "object";
|
|
529
|
+
if (cacheable) {
|
|
530
|
+
const hit = peakRuleCache.get(config);
|
|
531
|
+
if (hit !== void 0) return hit;
|
|
532
|
+
}
|
|
332
533
|
const windows = (Array.isArray(config?.peakWindows) && config.peakWindows.length > 0
|
|
333
534
|
? config.peakWindows
|
|
334
535
|
: DEFAULT_PEAK_WINDOWS
|
|
@@ -336,6 +537,12 @@ function isPeakAt(timeMs, config) {
|
|
|
336
537
|
const weekdays = Array.isArray(config?.peakWeekdays) && config.peakWeekdays.length > 0
|
|
337
538
|
? config.peakWeekdays.map(Number)
|
|
338
539
|
: DEFAULT_PEAK_WEEKDAYS;
|
|
540
|
+
const rule = { windows, weekdays };
|
|
541
|
+
if (cacheable) peakRuleCache.set(config, rule);
|
|
542
|
+
return rule;
|
|
543
|
+
}
|
|
544
|
+
function isPeakAt(timeMs, config) {
|
|
545
|
+
const { windows, weekdays } = peakRuleOf(config);
|
|
339
546
|
const d = new Date(Number(timeMs) + 8 * 60 * 60 * 1000); // 北京时间
|
|
340
547
|
const kind = CN_CALENDAR[cnDateKey(d)];
|
|
341
548
|
if (kind === "holiday") return false; // 法定节假日:全天按空闲计费
|
|
@@ -418,15 +625,26 @@ function sessionEvents(session) {
|
|
|
418
625
|
function detectModel(session) {
|
|
419
626
|
if (!session) return "";
|
|
420
627
|
// 1) DSH 官方折叠 API:requestHeader().config.model 或 requestContext().model
|
|
628
|
+
// ⚠️ 两个调用都必须包住(2026-10-31 全量审查):它们是 DSH 内部方法,
|
|
629
|
+
// 会话处于半装载 / 已释放状态时抛错很正常。detectModel 在 ?session= 主路径上,
|
|
630
|
+
// 这里一抛就是整条接口 500,而模型只是「用来选价格」的次要信息,拿不到还有兜底。
|
|
421
631
|
if (typeof session.requestHeader === "function") {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
632
|
+
try {
|
|
633
|
+
const header = session.requestHeader();
|
|
634
|
+
const m = header?.config?.model;
|
|
635
|
+
if (typeof m === "string" && m !== "") return m;
|
|
636
|
+
} catch {
|
|
637
|
+
// 折叠头拿不到就继续往下走
|
|
638
|
+
}
|
|
425
639
|
}
|
|
426
640
|
if (typeof session.requestContext === "function") {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
641
|
+
try {
|
|
642
|
+
const ctx = session.requestContext();
|
|
643
|
+
const m = ctx?.model;
|
|
644
|
+
if (typeof m === "string" && m !== "") return m;
|
|
645
|
+
} catch {
|
|
646
|
+
// 同上
|
|
647
|
+
}
|
|
430
648
|
}
|
|
431
649
|
// 2) 兜底:遍历事件
|
|
432
650
|
let model = "";
|
|
@@ -446,12 +664,19 @@ async function resolveApiKey(ctx, config) {
|
|
|
446
664
|
if (typeof config?.apiKey === "string" && config.apiKey.trim() !== "") {
|
|
447
665
|
return config.apiKey.trim();
|
|
448
666
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
667
|
+
// credentials 是可选服务,ctx.get 本身可能抛;resolve() 也会抛(凭据存储损坏 / 未解锁)。
|
|
668
|
+
// resolved.value 更不能裸取长度(2026-10-31 审计 L4)—— 这里的异常会一路冒到 HTTP handler,
|
|
669
|
+
// 变成零字节响应 + 客户端挂到超时。拿不到就当没有,交给后面的环境变量。
|
|
670
|
+
try {
|
|
671
|
+
const credentials = ctx.get("credentials");
|
|
672
|
+
if (credentials !== void 0 && credentials !== null && typeof credentials.resolve === "function") {
|
|
673
|
+
const resolved = await credentials.resolve("DEEPSEEK_API_KEY");
|
|
674
|
+
if (resolved != null && typeof resolved.value === "string" && resolved.value.length > 0) {
|
|
675
|
+
return resolved.value;
|
|
676
|
+
}
|
|
454
677
|
}
|
|
678
|
+
} catch {
|
|
679
|
+
// 凭据服务不可用:继续往下找环境变量
|
|
455
680
|
}
|
|
456
681
|
if (process.env.DEEPSEEK_API_KEY && process.env.DEEPSEEK_API_KEY.trim() !== "") {
|
|
457
682
|
return process.env.DEEPSEEK_API_KEY.trim();
|
|
@@ -499,7 +724,12 @@ function normalizeBalance(data) {
|
|
|
499
724
|
topped_up_balance: "0.00",
|
|
500
725
|
};
|
|
501
726
|
}
|
|
502
|
-
|
|
727
|
+
// ⚠️ 不能直接 infos[0](2026-10-31 全量审查):
|
|
728
|
+
// 官方账号路径(normalizeAccountBalance)已经改成「只认计 CNY 钱包」,
|
|
729
|
+
// 而这条 API Key 路径还是取数组第一个 —— 两条路径对同一个账号
|
|
730
|
+
// 可能给出不同币种、不同数字。取币种名确定一的那个,
|
|
731
|
+
// 没有 CNY 才退回首位。
|
|
732
|
+
const info = infos.find((w) => String(w && w.currency).toUpperCase() === "CNY") ?? infos[0];
|
|
503
733
|
return {
|
|
504
734
|
is_available: data?.is_available !== false,
|
|
505
735
|
currency: info.currency || "",
|
|
@@ -513,7 +743,8 @@ function formatBalance(balance) {
|
|
|
513
743
|
if (!balance.is_available) {
|
|
514
744
|
return "余额不可用:账户当前没有可用余额。";
|
|
515
745
|
}
|
|
516
|
-
|
|
746
|
+
// 主数字是充值余额;充值花光但还有赠送时不能报「暂无余额」。
|
|
747
|
+
if ((!balance.total_balance || balance.total_balance === "0.00") && (!balance.granted_balance || balance.granted_balance === "0.00")) {
|
|
517
748
|
return "余额查询成功,但暂无余额明细。";
|
|
518
749
|
}
|
|
519
750
|
return `余额(${balance.currency || "CNY"}):${balance.total_balance}\n 充值余额:${balance.topped_up_balance}\n 赠送余额:${balance.granted_balance}`;
|
|
@@ -527,7 +758,31 @@ function formatBalance(balance) {
|
|
|
527
758
|
// | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
|
|
528
759
|
// client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
|
|
529
760
|
// 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
|
|
530
|
-
const PLUGIN_VERSION = "2.
|
|
761
|
+
const PLUGIN_VERSION = "2.1.1";
|
|
762
|
+
|
|
763
|
+
/** 磁盘上**实际安装**的版本号。
|
|
764
|
+
*
|
|
765
|
+
* `PLUGIN_VERSION` 是编译进代码里的常量 —— 插件升级后新文件已经落盘,但插件进程
|
|
766
|
+
* 仍是旧实例,于是设置页一直显示旧版本号,非要重启 DeepSeek Harness 才变
|
|
767
|
+
* (用户 2026-10-31 反馈)。
|
|
768
|
+
*
|
|
769
|
+
* 这里改成读 package.json,按 mtime 缓存:文件没换过只多一次 statSync,
|
|
770
|
+
* 换过立刻读到新值。读不到(打包方式异常 / 权限)就退回常量,绝不抛错。
|
|
771
|
+
*/
|
|
772
|
+
let installedVersionCache = { mtimeMs: -1, value: PLUGIN_VERSION };
|
|
773
|
+
function installedVersion() {
|
|
774
|
+
try {
|
|
775
|
+
const file = new URL("../package.json", import.meta.url);
|
|
776
|
+
const stat = statSync(file);
|
|
777
|
+
if (stat.mtimeMs === installedVersionCache.mtimeMs) return installedVersionCache.value;
|
|
778
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
779
|
+
const v = typeof parsed?.version === "string" && parsed.version !== "" ? parsed.version : PLUGIN_VERSION;
|
|
780
|
+
installedVersionCache = { mtimeMs: stat.mtimeMs, value: v };
|
|
781
|
+
return v;
|
|
782
|
+
} catch {
|
|
783
|
+
return PLUGIN_VERSION;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
531
786
|
|
|
532
787
|
// ─── 检查更新 ───────────────────────────────────────────────────────────────
|
|
533
788
|
// 只查 npm registry 的 latest 标签(一个很小的 JSON),不下载任何东西。
|
|
@@ -622,8 +877,11 @@ async function installLatestVersion(ctx) {
|
|
|
622
877
|
const found = await fetchLatestVersion();
|
|
623
878
|
if (found.error) return { ok: false, error: found.error };
|
|
624
879
|
const latest = found.latest;
|
|
625
|
-
|
|
626
|
-
|
|
880
|
+
// 比的是「磁盘上装的版本」而不是编译时常量:否则插件升级后、进程还没重启时,
|
|
881
|
+
// 明明已经是最新也会被判成有更新可装。
|
|
882
|
+
const currentVer = installedVersion();
|
|
883
|
+
if (compareVersion(latest, currentVer) <= 0) {
|
|
884
|
+
return { ok: true, installed: false, from: currentVer, to: latest, note: "已是最新版本" };
|
|
627
885
|
}
|
|
628
886
|
|
|
629
887
|
const root = await installedPluginDir();
|
|
@@ -656,7 +914,7 @@ async function installLatestVersion(ctx) {
|
|
|
656
914
|
// 备份目录:拿不到 dshHome 就跳过备份(仍继续更新,失败项会体现在 failed 里)
|
|
657
915
|
const home = dshHomeDir(ctx);
|
|
658
916
|
const backupRoot =
|
|
659
|
-
home === "" ? "" : join(home, "balance-update-backup",
|
|
917
|
+
home === "" ? "" : join(home, "balance-update-backup", installedVersion() + "-" + Date.now());
|
|
660
918
|
|
|
661
919
|
const written = [];
|
|
662
920
|
const failed = [];
|
|
@@ -712,7 +970,7 @@ async function installLatestVersion(ctx) {
|
|
|
712
970
|
return {
|
|
713
971
|
ok: true,
|
|
714
972
|
installed: true,
|
|
715
|
-
from:
|
|
973
|
+
from: currentVer,
|
|
716
974
|
to: latest,
|
|
717
975
|
files: written.length,
|
|
718
976
|
failed,
|
|
@@ -749,44 +1007,92 @@ async function fetchLatestVersion(force) {
|
|
|
749
1007
|
* 读取官方账号余额并归一化成与 normalizeBalance 相同的形状。
|
|
750
1008
|
* 服务不存在、未登录、接口报错一律返回 null,由调用方回退 API Key,绝不因此抛错。
|
|
751
1009
|
*/
|
|
1010
|
+
//
|
|
1011
|
+
// ⚠️ 服务端单飞(2026-10-31 全量审查):官方余额接口实测 419ms,
|
|
1012
|
+
// 而 /balance-api 的每一个分支(?session= / ?today=1 / ?stats=1)都会顺手叫一次。
|
|
1013
|
+
// 多窗口 / 多 slot / 启动时短信几个请求并发就会重复查同一份余额。
|
|
1014
|
+
// 这里只合并**同时进行中**的请求(single-flight),**不做 TTL 缓存** ——
|
|
1015
|
+
// 余额不能因为服务端缓存而变旧(用户右键刷新就是要看最新值),
|
|
1016
|
+
// 只把「同一瞬间的重复开销」去掉,单个请求的新鲜度与原来完全一致。
|
|
1017
|
+
let accountBalanceInFlight = null;
|
|
1018
|
+
|
|
752
1019
|
async function fetchAccountBalance(ctx) {
|
|
753
1020
|
const account = typeof ctx.get === "function" ? ctx.get("deepseekAccount") : void 0;
|
|
754
1021
|
if (account === void 0 || account === null || typeof account.getBalance !== "function") return null;
|
|
755
|
-
|
|
1022
|
+
if (accountBalanceInFlight !== null) return accountBalanceInFlight;
|
|
1023
|
+
accountBalanceInFlight = (async () => {
|
|
1024
|
+
let result;
|
|
1025
|
+
try {
|
|
1026
|
+
result = await account.getBalance({
|
|
1027
|
+
version: PLUGIN_VERSION,
|
|
1028
|
+
locale: "zh-CN",
|
|
1029
|
+
// 平台要求“东为正”的整秒偏移;JS 的 getTimezoneOffset() 是西为正,故取反。
|
|
1030
|
+
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
|
|
1031
|
+
});
|
|
1032
|
+
} catch {
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
if (result === null || result === void 0 || result.status !== "ready") return null;
|
|
1036
|
+
return normalizeAccountBalance(result);
|
|
1037
|
+
})();
|
|
756
1038
|
try {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
// 平台要求“东为正”的整秒偏移;JS 的 getTimezoneOffset() 是西为正,故取反。
|
|
761
|
-
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
|
|
762
|
-
});
|
|
763
|
-
} catch {
|
|
764
|
-
return null;
|
|
1039
|
+
return await accountBalanceInFlight;
|
|
1040
|
+
} finally {
|
|
1041
|
+
accountBalanceInFlight = null;
|
|
765
1042
|
}
|
|
766
|
-
if (result === null || result === void 0 || result.status !== "ready") return null;
|
|
767
|
-
return normalizeAccountBalance(result);
|
|
768
1043
|
}
|
|
769
1044
|
|
|
770
1045
|
/** 官方钱包结构 → 统一余额形状。value 是充值钱包,bonusWallets 是赠送钱包(官方不计入充值余额)。 */
|
|
771
1046
|
function normalizeAccountBalance(result) {
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1047
|
+
// ⚠️ 必须把同币种的**所有**钱包求和,不能只取第一个(原来用 find 取首笔)。
|
|
1048
|
+
// 充值可能拆成多个钱包(分批到账、或充值款与其它款分开管),只取首笔会让总额
|
|
1049
|
+
// 跟着数组返回顺序变化 —— 用户 2026-10-31 报「刚充值,手动刷新几次数字反复跳动」
|
|
1050
|
+
// 就是这种表现。求和对顺序天然免疫,也才等于「账户余额」的语义。
|
|
1051
|
+
const sumByCurrency = (list, currency) => {
|
|
1052
|
+
const arr = Array.isArray(list) ? list : [];
|
|
1053
|
+
let sum = 0;
|
|
1054
|
+
let found = false;
|
|
1055
|
+
for (const w of arr) {
|
|
1056
|
+
if (!w || typeof w.balance !== "string") continue;
|
|
1057
|
+
if (w.currency !== currency) continue;
|
|
1058
|
+
const n = Number(w.balance);
|
|
1059
|
+
if (!Number.isFinite(n)) continue;
|
|
1060
|
+
sum += n;
|
|
1061
|
+
found = true;
|
|
1062
|
+
}
|
|
1063
|
+
return found ? sum : null;
|
|
1064
|
+
};
|
|
1065
|
+
// 币种先看充值钱包,再看赠送钱包,都没有就按 CNY。
|
|
1066
|
+
const currencyOf = (list) => {
|
|
1067
|
+
const arr = Array.isArray(list) ? list : [];
|
|
1068
|
+
const hit = arr.find((w) => w && typeof w.currency === "string" && w.currency !== "");
|
|
1069
|
+
return hit ? hit.currency : null;
|
|
775
1070
|
};
|
|
776
|
-
const
|
|
777
|
-
const
|
|
1071
|
+
const currency = currencyOf(result.value) ?? currencyOf(result.bonusWallets) ?? "CNY";
|
|
1072
|
+
const toppedSum = sumByCurrency(result.value, currency);
|
|
1073
|
+
const grantedSum = sumByCurrency(result.bonusWallets, currency);
|
|
778
1074
|
// 官方金额字符串遵循 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;
|
|
1075
|
+
const toppedUp = toppedSum !== null ? toppedSum : 0;
|
|
1076
|
+
const grantedAmount = grantedSum !== null ? grantedSum : 0;
|
|
784
1077
|
return {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1078
|
+
// 有赠送余额也算可用(充值款花完了、赠送还在,仍然能继续用)。
|
|
1079
|
+
is_available: toppedUp + grantedAmount > 0,
|
|
1080
|
+
currency,
|
|
1081
|
+
// ⚠️ 主数字 = **充值余额**,必须和官网「充值余额」那个数字一模一样。
|
|
1082
|
+
// 2026-10-31 用户贴官网截图「充值余额 ¥7.80」,插件显示 ¥8.10 —— 差的正是
|
|
1083
|
+
// 赠送钱包的 ¥0.30。官方页面上「充值余额」只统计 value,bonusWallets 是**另一栏**,
|
|
1084
|
+
// 把两者相加就永远对不上官网。赠送余额单独放在 granted_balance 里,不并入主数字。
|
|
1085
|
+
// (注意:value 内部仍然要**求和所有同币种钱包**,那是修「反复跳动」的另一件事,别退回 find。)
|
|
1086
|
+
total_balance: toppedUp.toFixed(2),
|
|
788
1087
|
granted_balance: grantedAmount.toFixed(2),
|
|
789
1088
|
topped_up_balance: toppedUp.toFixed(2),
|
|
1089
|
+
// 官方返回的**原始字符串**,只用于核对「插件显示的数 vs 官网显示的数」到底差在哪
|
|
1090
|
+
// (2026-10-31:官网「充值余额 ¥7.36」、插件 ¥7.37,差 0.01,光看两位小数分不出是
|
|
1091
|
+
// 舍入口径不同还是两次读取的时刻不同)。不进 UI,只在 /balance-api 里带出来。
|
|
1092
|
+
raw: {
|
|
1093
|
+
value: (Array.isArray(result.value) ? result.value : []).map((w) => (w ? { currency: w.currency, balance: w.balance } : null)),
|
|
1094
|
+
bonusWallets: (Array.isArray(result.bonusWallets) ? result.bonusWallets : []).map((w) => (w ? { currency: w.currency, balance: w.balance } : null)),
|
|
1095
|
+
},
|
|
790
1096
|
source: "account",
|
|
791
1097
|
};
|
|
792
1098
|
}
|
|
@@ -1148,7 +1454,17 @@ function estimateCost(usage, pricing, multiplier = 2) {
|
|
|
1148
1454
|
}
|
|
1149
1455
|
|
|
1150
1456
|
const STATS_DAYS = 90;
|
|
1151
|
-
|
|
1457
|
+
// 单会话解压实测 700 ms(35 MB 会话 19331 帧),扫 200 个就是 140 秒 —— 首次打开
|
|
1458
|
+
// 统计面板会像死机。默认降到 80,配合 inspectPersistedCached 的指纹缓存,
|
|
1459
|
+
// 第二次之后基本是 0 成本。要更久的历史可在设置里调 statsSessionLimit。
|
|
1460
|
+
const STATS_MAX_SESSIONS = 80;
|
|
1461
|
+
// 今日消耗单独一个上限,不再跟统计面板共用(用户 2026-10-31:「启动时如果过多可以
|
|
1462
|
+
// 计算久一点,并且在设置里添加今日消耗扫描上限」)。今日是常驻数字,漏算比慢更难接受;
|
|
1463
|
+
// 配合落盘快照 + 指纹缓存,稳态下几乎不重扫,冷启动那一次慢一点可以接受。
|
|
1464
|
+
const TODAY_MAX_SESSIONS = 200;
|
|
1465
|
+
// 子代理后代会话的扫描上限。子代理会话通常比主会话小得多,但 `?session=` 每轮都要跑
|
|
1466
|
+
// 这段枚举,一个开很多子代理的长对话不能让它变成几百次串行解压(同步 CPU,会掉帧)。
|
|
1467
|
+
const SUBAGENT_MAX_SESSIONS = 60;
|
|
1152
1468
|
const STATS_CACHE_MS = 5 * 60 * 1000;
|
|
1153
1469
|
|
|
1154
1470
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -1160,6 +1476,19 @@ const STATS_CACHE_MS = 5 * 60 * 1000;
|
|
|
1160
1476
|
|
|
1161
1477
|
const SETTINGS_FILENAME = "balance-dsh.json";
|
|
1162
1478
|
|
|
1479
|
+
// ── 今日消耗快照 ──────────────────────────────────────────────────────────
|
|
1480
|
+
// 冷启动时「今日消耗」要列出今天动过的会话,再逐个读 zstd 持久化文件解压,
|
|
1481
|
+
// 会话一多就是几百 MB 的解压量,而它和余额共用同一条 /balance-api 请求 ——
|
|
1482
|
+
// 余额行得等它算完才出来,加载动效就卡在那一段上(2026-10-31 反馈「每次打开
|
|
1483
|
+
// 加载动画都会卡」)。
|
|
1484
|
+
// 解法:算完就把结果落盘,下次启动直接读这个小 JSON 立刻显示,重算放到后台。
|
|
1485
|
+
const TODAY_SNAPSHOT_FILENAME = "balance-today.json";
|
|
1486
|
+
// 跨天时旧快照不能直接丢:强制退出时最后一段 usage 可能还没落到会话文件里,
|
|
1487
|
+
// 那部分是快照独有的。先把它结算进这里,统计弹窗再按日期合并。
|
|
1488
|
+
const DAILY_SETTLED_FILENAME = "balance-daily-settled.json";
|
|
1489
|
+
// 快照超过这个年龄就认为「可能过期了」,触发一次后台重算(不阻塞响应)。
|
|
1490
|
+
const TODAY_SNAPSHOT_STALE_MS = 45 * 1000;
|
|
1491
|
+
|
|
1163
1492
|
/** 出厂默认值;同时是字段白名单(不在这里的键一律丢弃)。 */
|
|
1164
1493
|
const DEFAULT_SETTINGS = {
|
|
1165
1494
|
// 余额
|
|
@@ -1206,6 +1535,9 @@ const DEFAULT_SETTINGS = {
|
|
|
1206
1535
|
fabLeft: 44, // 距窗口左缘(px)
|
|
1207
1536
|
// 统计
|
|
1208
1537
|
statsSessionLimit: STATS_MAX_SESSIONS, // 统计扫描的最近会话数
|
|
1538
|
+
// 今日消耗扫描的最近会话数。和 statsSessionLimit 分开:今日是常驻数字,
|
|
1539
|
+
// 默认放得比统计宽(用户 2026-10-31 要求「过多可以计算久一点」)。
|
|
1540
|
+
todaySessionLimit: TODAY_MAX_SESSIONS,
|
|
1209
1541
|
statsCacheSec: 300, // 统计结果缓存秒数
|
|
1210
1542
|
// 计费
|
|
1211
1543
|
// 高峰时段窗口。官方当前是「工作日北京 9-12、14-18」,但这条也是会变的
|
|
@@ -1339,6 +1671,7 @@ function normalizeSettings(raw) {
|
|
|
1339
1671
|
fabSize: clampNumber(src.fabSize, 32, 120, d.fabSize),
|
|
1340
1672
|
fabLeft: clampNumber(src.fabLeft, 0, 600, d.fabLeft),
|
|
1341
1673
|
statsSessionLimit: clampNumber(src.statsSessionLimit, 10, 2000, d.statsSessionLimit),
|
|
1674
|
+
todaySessionLimit: clampNumber(src.todaySessionLimit, 10, 2000, d.todaySessionLimit),
|
|
1342
1675
|
statsCacheSec: clampNumber(src.statsCacheSec, 0, 3600, d.statsCacheSec),
|
|
1343
1676
|
peakWindowsText: normalizePeakWindowsText(src.peakWindowsText, d.peakWindowsText),
|
|
1344
1677
|
peakMultiplier: clampNumber(src.peakMultiplier, 1, 10, d.peakMultiplier),
|
|
@@ -1362,6 +1695,7 @@ function withSettings(config, settings) {
|
|
|
1362
1695
|
peakMultiplier: s.peakMultiplier,
|
|
1363
1696
|
priceByModel,
|
|
1364
1697
|
statsSessionLimit: s.statsSessionLimit,
|
|
1698
|
+
todaySessionLimit: s.todaySessionLimit,
|
|
1365
1699
|
statsCacheMs: s.statsCacheSec * 1000,
|
|
1366
1700
|
};
|
|
1367
1701
|
}
|
|
@@ -1394,6 +1728,169 @@ function settingsFilePath(ctx) {
|
|
|
1394
1728
|
return `${home.replace(/[\\/]+$/, "")}${sep}${SETTINGS_FILENAME}`;
|
|
1395
1729
|
}
|
|
1396
1730
|
|
|
1731
|
+
/** 与设置文件同目录的兄弟文件路径;定位不到 dshHome 时返回空串(调用方静默降级)。 */
|
|
1732
|
+
function siblingFilePath(ctx, filename) {
|
|
1733
|
+
const settings = settingsFilePath(ctx);
|
|
1734
|
+
if (settings === "") return "";
|
|
1735
|
+
const sep = settings.includes("\\") && !settings.includes("/") ? "\\" : "/";
|
|
1736
|
+
const dir = settings.slice(0, settings.lastIndexOf(sep));
|
|
1737
|
+
return `${dir}${sep}${filename}`;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/** 北京当日 0 点对应的真实时间戳(用 UTC 分量读北京日期,再减回去)。
|
|
1741
|
+
* 「今天」的分界线按北京时间算:用户看的是北京时间,跨天清理也必须跟着它走。 */
|
|
1742
|
+
function beijingDayStart(now = Date.now()) {
|
|
1743
|
+
const bj = new Date(now + 8 * 60 * 60 * 1000);
|
|
1744
|
+
return Date.UTC(bj.getUTCFullYear(), bj.getUTCMonth(), bj.getUTCDate()) - 8 * 60 * 60 * 1000;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/** 读今日消耗快照;不存在 / 损坏 / 不是今天的都返回 null(调用方回退到现算)。 */
|
|
1748
|
+
async function readTodaySnapshot(file) {
|
|
1749
|
+
if (file === "") return null;
|
|
1750
|
+
try {
|
|
1751
|
+
const { readFile } = await import("node:fs/promises");
|
|
1752
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
1753
|
+
if (parsed == null || typeof parsed !== "object") return null;
|
|
1754
|
+
const date = typeof parsed.date === "string" ? parsed.date : "";
|
|
1755
|
+
if (date === "" || date !== beijingDayKey()) return null; // 昨天的快照一律不认
|
|
1756
|
+
const cost = Number(parsed.cost);
|
|
1757
|
+
const totalTokens = Number(parsed.totalTokens);
|
|
1758
|
+
// ⚠️ `bySession` 必须一起还原(2026-10-31 全量审查)。
|
|
1759
|
+
// 增量路径 updateTodayCostWithSession 的口径是「其余会话原样搬运上一次的 bySession,
|
|
1760
|
+
// 只把当前会话那一桶换掉」,它读的就是 base.bySession。快照里丢掉这个字段,
|
|
1761
|
+
// 冷启动第一次 ?session= 拿到的 base 就只有 {cost, totalTokens, sessions},
|
|
1762
|
+
// bySession 变成空表 —— 重算出的今日总额 = 当前会话一个桶(实测 ¥16 直接塌成 ¥3.2),
|
|
1763
|
+
// 而且这个虚低的数还带着「非 partial」身份被 ?today=1 当定型值发给客户端,
|
|
1764
|
+
// 要等 45 秒后一次全量重算才纠正回来。症状就是「打开时数字先小后大」。
|
|
1765
|
+
const bySession = {};
|
|
1766
|
+
if (parsed.bySession != null && typeof parsed.bySession === "object") {
|
|
1767
|
+
for (const [key, rec] of Object.entries(parsed.bySession)) {
|
|
1768
|
+
if (typeof key !== "string" || key === "") continue;
|
|
1769
|
+
if (rec == null || typeof rec !== "object") continue;
|
|
1770
|
+
const c = Number(rec.cost);
|
|
1771
|
+
const t = Number(rec.totalTokens);
|
|
1772
|
+
bySession[key] = {
|
|
1773
|
+
cost: Number.isFinite(c) && c >= 0 ? c : 0,
|
|
1774
|
+
totalTokens: Number.isFinite(t) && t >= 0 ? t : 0,
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
return {
|
|
1779
|
+
date,
|
|
1780
|
+
cost: Number.isFinite(cost) && cost >= 0 ? cost : 0,
|
|
1781
|
+
totalTokens: Number.isFinite(totalTokens) && totalTokens >= 0 ? totalTokens : 0,
|
|
1782
|
+
sessions: Number.isFinite(Number(parsed.sessions)) ? Number(parsed.sessions) : 0,
|
|
1783
|
+
at: Number.isFinite(Number(parsed.at)) ? Number(parsed.at) : 0,
|
|
1784
|
+
bySession,
|
|
1785
|
+
};
|
|
1786
|
+
} catch {
|
|
1787
|
+
return null;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
async function writeJsonAtomic(file, value) {
|
|
1792
|
+
if (file === "") return;
|
|
1793
|
+
const { mkdir, writeFile, rename } = await import("node:fs/promises");
|
|
1794
|
+
const { dirname } = await import("node:path");
|
|
1795
|
+
await mkdir(dirname(file), { recursive: true });
|
|
1796
|
+
// ⚠️ 临时名必须唯一(2026-10-31 第二十二轮审计):固定 `.tmp` 时两次并发写会互相截断,
|
|
1797
|
+
// rename 也可能报 ENOENT。saveCalendarCache 早就改成唯一名了,这里跟上。
|
|
1798
|
+
const tmp = file + "." + process.pid + "." + Math.random().toString(36).slice(2) + ".tmp";
|
|
1799
|
+
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
1800
|
+
await rename(tmp, file);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
async function writeTodaySnapshot(file, snapshot) {
|
|
1804
|
+
// ⚠️ **增量结果绝不落盘**(`provisional` 由 updateTodayCostWithSession 打上)。
|
|
1805
|
+
// 增量是「旧基线 + 当前对话那一桶」的临时视图,比全量扫描多出一点(实测 +0.211),
|
|
1806
|
+
// 一旦写进快照,下次冷启动就会先显示这个虚高的数、随后被补拉的精算值「缩水」
|
|
1807
|
+
// (2026-10-31:「打开加载完显示17.几 下一秒变成16.几,而且每次打开还不一样」)。
|
|
1808
|
+
// 快照必须只由全量扫描(buildTodayCost)写入 —— 它是唯一权威口径。
|
|
1809
|
+
if (snapshot != null && typeof snapshot === "object" && snapshot.provisional === true) return;
|
|
1810
|
+
await writeJsonAtomic(file, snapshot);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/** 已结算的历史日消耗:{ "YYYY-MM-DD": { cost, totalTokens } }。
|
|
1814
|
+
* 只保留最近 STATS_DAYS 天,超出直接丢——统计弹窗最多就看 90 天。 */
|
|
1815
|
+
async function readDailySettled(file) {
|
|
1816
|
+
if (file === "") return {};
|
|
1817
|
+
try {
|
|
1818
|
+
const { readFile } = await import("node:fs/promises");
|
|
1819
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
1820
|
+
if (parsed == null || typeof parsed !== "object") return {};
|
|
1821
|
+
const out = {};
|
|
1822
|
+
for (const [date, rec] of Object.entries(parsed)) {
|
|
1823
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
|
|
1824
|
+
if (rec == null || typeof rec !== "object") continue;
|
|
1825
|
+
const cost = Number(rec.cost);
|
|
1826
|
+
const totalTokens = Number(rec.totalTokens);
|
|
1827
|
+
out[date] = {
|
|
1828
|
+
cost: Number.isFinite(cost) && cost >= 0 ? cost : 0,
|
|
1829
|
+
totalTokens: Number.isFinite(totalTokens) && totalTokens >= 0 ? totalTokens : 0,
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
return out;
|
|
1833
|
+
} catch {
|
|
1834
|
+
return {};
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
async function writeDailySettled(file, map) {
|
|
1839
|
+
const keep = new Set(recentDayKeys(STATS_DAYS));
|
|
1840
|
+
const pruned = {};
|
|
1841
|
+
for (const [date, rec] of Object.entries(map)) {
|
|
1842
|
+
if (keep.has(date)) pruned[date] = rec;
|
|
1843
|
+
}
|
|
1844
|
+
await writeJsonAtomic(file, pruned);
|
|
1845
|
+
return pruned;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
/** 跨天清理:把昨天(及更早)的快照结算进 dailySettled,然后让今天的快照归零。
|
|
1849
|
+
* 按日期取**较大值**合并——统计本身会重扫会话算历史,若把快照值全额加进去
|
|
1850
|
+
* 就重复计数了;取 max 既保住「强制退出时还没落盘的那段增量」,又不会翻倍。
|
|
1851
|
+
* @returns {Promise<{settled:boolean, date:string}>} */
|
|
1852
|
+
async function settleStaleTodaySnapshot(ctx) {
|
|
1853
|
+
const snapshotFile = siblingFilePath(ctx, TODAY_SNAPSHOT_FILENAME);
|
|
1854
|
+
const settledFile = siblingFilePath(ctx, DAILY_SETTLED_FILENAME);
|
|
1855
|
+
if (snapshotFile === "") return { settled: false, date: "" };
|
|
1856
|
+
let raw = null;
|
|
1857
|
+
try {
|
|
1858
|
+
const { readFile } = await import("node:fs/promises");
|
|
1859
|
+
raw = JSON.parse(await readFile(snapshotFile, "utf8"));
|
|
1860
|
+
} catch {
|
|
1861
|
+
return { settled: false, date: "" }; // 没有快照 = 没什么可结算
|
|
1862
|
+
}
|
|
1863
|
+
const date = raw != null && typeof raw === "object" && typeof raw.date === "string" ? raw.date : "";
|
|
1864
|
+
if (date === "" || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return { settled: false, date: "" };
|
|
1865
|
+
const today = beijingDayKey();
|
|
1866
|
+
if (date === today) return { settled: false, date }; // 还是今天,不用结算
|
|
1867
|
+
const cost = Number(raw.cost);
|
|
1868
|
+
const totalTokens = Number(raw.totalTokens);
|
|
1869
|
+
const map = await readDailySettled(settledFile);
|
|
1870
|
+
const prev = map[date] ?? { cost: 0, totalTokens: 0 };
|
|
1871
|
+
map[date] = {
|
|
1872
|
+
cost: Math.max(prev.cost, Number.isFinite(cost) && cost > 0 ? cost : 0),
|
|
1873
|
+
totalTokens: Math.max(prev.totalTokens, Number.isFinite(totalTokens) && totalTokens > 0 ? totalTokens : 0),
|
|
1874
|
+
};
|
|
1875
|
+
// ⚠️ 写失败就不许删快照(2026-10-31 审计 L2):原来 catch 完照删,
|
|
1876
|
+
// 那一份昨天(含强制退出前还没落盘的部分)就那么没了 —— 统计面板永远补不回来。
|
|
1877
|
+
// 留着它下次启动会再结算一次,重复结算由 writeDailySettled 的取较大值保证不会翻倍。
|
|
1878
|
+
try {
|
|
1879
|
+
await writeDailySettled(settledFile, map);
|
|
1880
|
+
} catch {
|
|
1881
|
+
// 结算写失败也不能挡住今天的显示,下次启动会再试;快照保留
|
|
1882
|
+
return { settled: false, date };
|
|
1883
|
+
}
|
|
1884
|
+
// 删掉旧快照,今天的从 0 重新累积(用户要求:第二天打开直接就是 0)
|
|
1885
|
+
try {
|
|
1886
|
+
const { unlink } = await import("node:fs/promises");
|
|
1887
|
+
await unlink(snapshotFile);
|
|
1888
|
+
} catch {
|
|
1889
|
+
// ignore
|
|
1890
|
+
}
|
|
1891
|
+
return { settled: true, date };
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1397
1894
|
/** 读设置文件;不存在 / JSON 损坏 / 无权限一律返回 null(调用方保持默认值)。
|
|
1398
1895
|
* 额外做一件事:老版本写下的文件里没有 showSidebarEntry 这个键,
|
|
1399
1896
|
* 读到这种「升级前」的文件时把侧边栏入口强制打开一次,让用户第一次升级后
|
|
@@ -1416,17 +1913,34 @@ async function readSettingsFile(file) {
|
|
|
1416
1913
|
// 文件不存在 = 全新安装,这里不能当「未读」处理(否则新用户一装就看到一堆 NEW,
|
|
1417
1914
|
// 对他来说每一项都是新的,标 NEW 毫无信息量)。用字符串标记区分于「读失败」。
|
|
1418
1915
|
if (error && error.code === "ENOENT") return "missing";
|
|
1419
|
-
|
|
1916
|
+
// **JSON 坏了 / 读不动:绝不能静默降级成默认值然后把它写回去**(用户 2026-10-31:
|
|
1917
|
+
// 「这些设置保存到用户配置文件里,不要每次更新都改回去」)。原文件先另存一份
|
|
1918
|
+
// `.corrupt-<时间戳>`,调用方拿到 "corrupt" 后禁止自动写盘 —— 用户的配置留在磁盘上,
|
|
1919
|
+
// 任何时候都能找回,而不是被一份出厂默认值覆盖掉。
|
|
1920
|
+
try {
|
|
1921
|
+
const { rename } = await import("node:fs/promises");
|
|
1922
|
+
await rename(file, `${file}.corrupt-${Date.now()}`);
|
|
1923
|
+
} catch {
|
|
1924
|
+
// 连改名都失败(权限/占用):至少不让调用方写盘
|
|
1925
|
+
}
|
|
1926
|
+
return "corrupt";
|
|
1420
1927
|
}
|
|
1421
1928
|
}
|
|
1422
1929
|
|
|
1423
|
-
/** 写设置文件:先写临时文件再改名,避免写一半把配置写坏。
|
|
1930
|
+
/** 写设置文件:先写临时文件再改名,避免写一半把配置写坏。
|
|
1931
|
+
* 写之前把上一份留成 `.bak` —— 任何一次写盘出问题,用户的配置都还在。 */
|
|
1424
1932
|
async function writeSettingsFile(file, settings) {
|
|
1425
1933
|
if (file === "") throw new Error("无法定位 DeepSeek Harness 配置目录");
|
|
1426
|
-
const { mkdir, writeFile, rename } = await import("node:fs/promises");
|
|
1934
|
+
const { mkdir, writeFile, rename, copyFile } = await import("node:fs/promises");
|
|
1427
1935
|
const { dirname } = await import("node:path");
|
|
1428
1936
|
await mkdir(dirname(file), { recursive: true });
|
|
1429
|
-
|
|
1937
|
+
try {
|
|
1938
|
+
await copyFile(file, `${file}.bak`);
|
|
1939
|
+
} catch {
|
|
1940
|
+
// 首次写盘(还没有原文件):没有可备份的东西,继续
|
|
1941
|
+
}
|
|
1942
|
+
// 唯一临时名:并发写(设置卡片连点保存)时固定 `.tmp` 会互相截断。
|
|
1943
|
+
const tmp = file + "." + process.pid + "." + Math.random().toString(36).slice(2) + ".tmp";
|
|
1430
1944
|
await writeFile(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
1431
1945
|
await rename(tmp, file);
|
|
1432
1946
|
}
|
|
@@ -1447,10 +1961,13 @@ function readRequestBody(req, limit = 64 * 1024) {
|
|
|
1447
1961
|
});
|
|
1448
1962
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
1449
1963
|
req.on("error", reject);
|
|
1964
|
+
// 客户端中途断开时既没有 end 也没有 error(2026-10-31 审计 L9):
|
|
1965
|
+
// Promise 永不 settle,await 它的 handler 就一直挂着、连接和内存都不释放。
|
|
1966
|
+
req.on("close", () => reject(new Error("连接已关闭")));
|
|
1450
1967
|
});
|
|
1451
1968
|
}
|
|
1452
1969
|
|
|
1453
|
-
function beijingDayKey(timeMs) {
|
|
1970
|
+
function beijingDayKey(timeMs = Date.now()) {
|
|
1454
1971
|
return new Date(Number(timeMs) + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
1455
1972
|
}
|
|
1456
1973
|
|
|
@@ -1479,6 +1996,9 @@ function ensureDay(days, key) {
|
|
|
1479
1996
|
totalTokens: 0,
|
|
1480
1997
|
cost: 0,
|
|
1481
1998
|
sessionIds: new Set(),
|
|
1999
|
+
// 按会话拆分(sessionId -> { cost, totalTokens }):供「今日消耗」复用
|
|
2000
|
+
// ?session= 请求里已经解压好的事件、做实时刷新,避免为了某个会话再解一次。
|
|
2001
|
+
bySession: new Map(),
|
|
1482
2002
|
// —— 以下为细分维度,供统计面板展示;不改动上方总额口径 ——
|
|
1483
2003
|
byModel: new Map(), // modelKey -> { model, tokens..., totalTokens, cost }
|
|
1484
2004
|
peak: emptyTokenBucket(),
|
|
@@ -1498,6 +2018,120 @@ function ensureDay(days, key) {
|
|
|
1498
2018
|
return day;
|
|
1499
2019
|
}
|
|
1500
2020
|
|
|
2021
|
+
/** 把一笔费用记到「属于哪个会话」的桶,供「今日消耗」按会话拆分做实时刷新。
|
|
2022
|
+
* 没有 sessionId(老调用方)就跳过,不影响总额。 */
|
|
2023
|
+
function addDaySessionCost(day, sessionId, cost, tokens) {
|
|
2024
|
+
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
2025
|
+
let rec = day.bySession.get(sessionId);
|
|
2026
|
+
if (rec == null) {
|
|
2027
|
+
rec = { cost: 0, totalTokens: 0 };
|
|
2028
|
+
day.bySession.set(sessionId, rec);
|
|
2029
|
+
}
|
|
2030
|
+
rec.cost += cost;
|
|
2031
|
+
rec.totalTokens += tokens;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/** 增量结果的短期缓存:同一会话在 600 ms 内重复问,直接返回上次算好的结果。
|
|
2035
|
+
*
|
|
2036
|
+
* `?session=` 每轮对话会被问**两次** —— 一次给 `resolveSessionModel` 解析模型、一次给
|
|
2037
|
+
* `fetchSessionUsage` 拉 usage,打的是同一个 URL。服务端没必要把整份
|
|
2038
|
+
* `collectDailyUsage`(对 40 MB 会话是好几遍全量事件遍历)跑两遍。600 ms 足够覆盖这两次
|
|
2039
|
+
* 请求的间隔,又短到用户感知不到「今日金额没跟上」。
|
|
2040
|
+
*
|
|
2041
|
+
* Map 上限 64:今天动过的会话不可能比这多到离谱,超了直接整体丢弃(重建成本极低)。 */
|
|
2042
|
+
const todayIncrementCache = new Map();
|
|
2043
|
+
// 1500ms 是**实测标定值**(2026-10-31 审计 L8 核实):模型解析和 usage 拉取打的是同一个 URL,
|
|
2044
|
+
// 两趟之间通常差几百毫秒;早期注释里写的 600ms 会让第二趟必然落空、白跑一遍整会话遍历。
|
|
2045
|
+
// 改这个值等于改「重复请求复用窗口」,属行为参数,别随手调。
|
|
2046
|
+
const TODAY_INCREMENT_TTL_MS = 1500;
|
|
2047
|
+
|
|
2048
|
+
/** 用「单个会话的实时事件」增量更新今日消耗,**完全不扫描其他会话**。
|
|
2049
|
+
*
|
|
2050
|
+
* 用户 2026-10-31 明确要求:「本对话消耗只扫对应对话的,其他对话别扫描」。
|
|
2051
|
+
* 原来 `?session=` 会把 today 整份重算 —— 列一遍今天动过的所有会话、逐个遍历它们的
|
|
2052
|
+
* 事件数组。主会话 40 MB / 两万帧时,这一趟就是几百毫秒的同步 CPU;插件和 DSH 同进程,
|
|
2053
|
+
* 于是对话一推进界面就掉帧、聊天里的图片解码也跟着排队。
|
|
2054
|
+
*
|
|
2055
|
+
* 这里改成:其余会话的贡献**原样搬运**上一次算好的结果,只把当前会话那一桶换成刚算
|
|
2056
|
+
* 出来的值。复杂度从 O(今天所有会话的事件总数) 降到 O(当前会话的事件数)。
|
|
2057
|
+
*
|
|
2058
|
+
* 返回 null 表示「没法增量」(今天还没算过 / 快照是昨天的),调用方回退到完整路径 ——
|
|
2059
|
+
* 那种情况一天只发生一次(冷启动、刚跨天),代价可以接受。
|
|
2060
|
+
*
|
|
2061
|
+
* @returns {Promise<object|null>} 与 buildTodayCost 同形状 */
|
|
2062
|
+
async function updateTodayCostWithSession(ctx, config, state, sessionId, events) {
|
|
2063
|
+
if (typeof sessionId !== "string" || sessionId === "" || !Array.isArray(events)) return null;
|
|
2064
|
+
const now = Date.now();
|
|
2065
|
+
const todayKey = beijingDayKey(now);
|
|
2066
|
+
// ⚠️ **键必须正规化成 `session-<uuid>`**:全量扫描那条路径的 bySession 键
|
|
2067
|
+
// 来自会话列表接口,带 `session-` 前缀;而这里的 `sessionId` 是客户端传来的裸 UUID。
|
|
2068
|
+
// 直接用裸 UUID 写会**在表里新增一个键**,同一个会话于是出现两次
|
|
2069
|
+
// (`session-9877…` 和 `9877…`),求和时算两遍。
|
|
2070
|
+
// 症状:快照逐渐偏高 → 冷启动显示一个虚高的数 → 补拉精算后「变小」
|
|
2071
|
+
// (2026-10-31:「每次打开还不一样」,因为每次运行的增量调用次数不同)。
|
|
2072
|
+
const sessionKey = sessionId.startsWith("session-") ? sessionId : `session-${sessionId}`;
|
|
2073
|
+
const base = state.cache.value ?? (await readTodaySnapshot(state.snapshotFile));
|
|
2074
|
+
if (base == null || base.date !== todayKey) return null;
|
|
2075
|
+
// 基线里没有 bySession 就**根本没有增量可言**:下面那套「其余会话原样搬运」全靠它,
|
|
2076
|
+
// 空表算出来的总额只剩当前会话一个桶,是个远低于真实值的假数。
|
|
2077
|
+
// 旧版本写下的快照就长这样(readTodaySnapshot 以前不还原这个字段),所以这里必须挡一道,
|
|
2078
|
+
// 让它返回 null 回退到完整重算 —— 一天只多算一次,比显示一个错数字便宜得多。
|
|
2079
|
+
// ⚠️ 空表也必须挡(2026-10-31 审计 NEW-1):上面 readTodaySnapshot 现在
|
|
2080
|
+
// **无条件**构造 bySession = {},而 {} 是 object,只判类型的闸门永远不会触发 ——
|
|
2081
|
+
// 旧版快照(没有这个字段)就会被当成合法基线,增量把空表当基线去搬运,
|
|
2082
|
+
// 今日总额塌成只剩当前会话一个桶(就是注释里那个 16 元塌成 3.2)。
|
|
2083
|
+
// 还会被当成非 partial 发给客户端,于是「打开时先小后大」换个入口又复发。
|
|
2084
|
+
if (base.bySession == null || typeof base.bySession !== "object") return null;
|
|
2085
|
+
if (Object.keys(base.bySession).length === 0) return null;
|
|
2086
|
+
// 同一会话 600 ms 内重复问(模型解析 + usage 拉取打的是同一个 URL)直接复用上次结果,
|
|
2087
|
+
// 省掉一遍整会话事件遍历。必须在 collectDailyUsage 之前短路,否则等于没省。
|
|
2088
|
+
const cachedIncrement = todayIncrementCache.get(sessionKey);
|
|
2089
|
+
// 缓存必须绑定基线对象本身(2026-10-31 审计 M1):
|
|
2090
|
+
// 快照回填 / 全量重算会把 state.cache.value 换成另一个对象,
|
|
2091
|
+
// 标准只看 TTL 的话,这 1.5 秒内仍会返回按旧基线算的整份总额,今日数字往回跳一下。
|
|
2092
|
+
// 用引用相等做版本戳最廉价。
|
|
2093
|
+
if (cachedIncrement != null && cachedIncrement.base === base && now - cachedIncrement.at < TODAY_INCREMENT_TTL_MS) {
|
|
2094
|
+
return cachedIncrement.value;
|
|
2095
|
+
}
|
|
2096
|
+
const local = new Map();
|
|
2097
|
+
collectDailyUsage(
|
|
2098
|
+
events,
|
|
2099
|
+
config,
|
|
2100
|
+
detectModel({ requestHeader: () => void 0, requestContext: () => void 0, events }),
|
|
2101
|
+
beijingDayStart(now),
|
|
2102
|
+
local,
|
|
2103
|
+
sessionId,
|
|
2104
|
+
);
|
|
2105
|
+
const localRec = local.get(todayKey)?.bySession?.get(sessionId);
|
|
2106
|
+
const bySession = { ...(base.bySession ?? {}) };
|
|
2107
|
+
// ⚠️ 桶只能往大走(2026-10-31 第二十二轮审计):`events` 可能是被
|
|
2108
|
+
// SESSION_REREAD_MIN_MS 节流后的**旧数组**(`?session=` 4 秒内复用上一份),
|
|
2109
|
+
// 直接覆盖会让今日金额往回跳(实测 0.168 → 0.112),而且这个较小的 provisional
|
|
2110
|
+
// 值还会写进 state.cache.value。当天消耗只增不减,取较大者即可。
|
|
2111
|
+
const prevRec = bySession[sessionKey];
|
|
2112
|
+
const nextCost = Number(localRec?.cost ?? 0);
|
|
2113
|
+
const nextTokens = Number(localRec?.totalTokens ?? 0);
|
|
2114
|
+
bySession[sessionKey] = {
|
|
2115
|
+
cost: Math.max(Number(prevRec?.cost ?? 0), nextCost),
|
|
2116
|
+
totalTokens: Math.max(Number(prevRec?.totalTokens ?? 0), nextTokens),
|
|
2117
|
+
};
|
|
2118
|
+
let cost = 0;
|
|
2119
|
+
let totalTokens = 0;
|
|
2120
|
+
let sessions = 0;
|
|
2121
|
+
for (const rec of Object.values(bySession)) {
|
|
2122
|
+
cost += Number(rec?.cost ?? 0);
|
|
2123
|
+
totalTokens += Number(rec?.totalTokens ?? 0);
|
|
2124
|
+
sessions += 1;
|
|
2125
|
+
}
|
|
2126
|
+
const value = { date: todayKey, cost, totalTokens, sessions, bySession, provisional: true };
|
|
2127
|
+
// 只更新值、不动过期时间:增量的可信度取决于基线,基线多旧它就多旧。
|
|
2128
|
+
// 让原来的 staleMs 正常触发一次完整重算,把其他会话的过时数字纠正回来。
|
|
2129
|
+
state.cache.value = value;
|
|
2130
|
+
if (todayIncrementCache.size >= 64) todayIncrementCache.clear();
|
|
2131
|
+
todayIncrementCache.set(sessionKey, { at: now, base, value });
|
|
2132
|
+
return value;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
1501
2135
|
/** 按北京时间把一个会话的最终 assistant/message usage 聚合到每日统计。 */
|
|
1502
2136
|
function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessionId) {
|
|
1503
2137
|
const switches = collectModelSwitches(events);
|
|
@@ -1544,6 +2178,7 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
|
|
|
1544
2178
|
day.webSearchBillable += 1;
|
|
1545
2179
|
day.webSearchCost += WEB_TOOL_PRICE_PER_CALL;
|
|
1546
2180
|
day.cost += WEB_TOOL_PRICE_PER_CALL;
|
|
2181
|
+
addDaySessionCost(day, sessionId, WEB_TOOL_PRICE_PER_CALL, 0);
|
|
1547
2182
|
}
|
|
1548
2183
|
} else {
|
|
1549
2184
|
day.webFetchCalls += 1;
|
|
@@ -1551,6 +2186,7 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
|
|
|
1551
2186
|
day.webFetchBillable += 1;
|
|
1552
2187
|
day.webFetchCost += WEB_TOOL_PRICE_PER_CALL;
|
|
1553
2188
|
day.cost += WEB_TOOL_PRICE_PER_CALL;
|
|
2189
|
+
addDaySessionCost(day, sessionId, WEB_TOOL_PRICE_PER_CALL, 0);
|
|
1554
2190
|
}
|
|
1555
2191
|
}
|
|
1556
2192
|
day.sessionIds.add(sessionId);
|
|
@@ -1576,6 +2212,7 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
|
|
|
1576
2212
|
day.totalTokens += tokens;
|
|
1577
2213
|
day.cost += stepCost;
|
|
1578
2214
|
day.sessionIds.add(sessionId);
|
|
2215
|
+
addDaySessionCost(day, sessionId, stepCost, tokens);
|
|
1579
2216
|
|
|
1580
2217
|
// 高峰 / 空闲拆分(token 与费用)
|
|
1581
2218
|
addTokenUsage(isPeak ? day.peak : day.offpeak, usage);
|
|
@@ -1607,81 +2244,260 @@ function recentDayKeys(days, now = Date.now()) {
|
|
|
1607
2244
|
}
|
|
1608
2245
|
|
|
1609
2246
|
/** 只算「今天」的消耗总额,供余额行上方的 meta 行用。
|
|
1610
|
-
* 与 buildUsageStats 的区别:只扫 updatedAt
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
|
|
2247
|
+
* 与 buildUsageStats 的区别:只扫 updatedAt 落在今天的会话,通常只有几个。
|
|
2248
|
+
* @param {{ sessionId?: string, events?: unknown[] }} [reuse] 调用方已持有的会话事件,
|
|
2249
|
+
* 命中时不再为它解压一遍(?session= 请求靠这个实现「零额外成本」的实时刷新)。
|
|
2250
|
+
* @returns {Promise<{ cost:number, totalTokens:number, sessions:number, bySession:object }>} */
|
|
2251
|
+
async function buildTodayCost(ctx, config, reuse = null) {
|
|
1614
2252
|
const getService = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
|
|
1615
2253
|
const controller = getService?.("sessionController");
|
|
1616
2254
|
const persistence = getService?.("sessionPersistence");
|
|
1617
2255
|
const now = Date.now();
|
|
1618
|
-
|
|
1619
|
-
const
|
|
1620
|
-
|
|
1621
|
-
|
|
2256
|
+
const dayStart = beijingDayStart(now);
|
|
2257
|
+
const todayKey = beijingDayKey(now);
|
|
2258
|
+
// ⚠️ 会话时间戳单位不保证是毫秒(2026-10-31:「怎么变成0了」)。
|
|
2259
|
+
// `controller.list()` 的 updatedAt 有可能是**秒**,而 dayStart 是毫秒 ——
|
|
2260
|
+
// `x.at >= dayStart` 就永远为假,今天动过的会话被整批滤掉,daily 为空,
|
|
2261
|
+
// 于是算出 cost:0 并把 0 落进快照,界面显示 ¥0.0000。
|
|
2262
|
+
// stats 那条路径只拿 sessionId、不比较时间,所以它一直是好的
|
|
2263
|
+
// (实测同刻 stats 里 10-31 有 15.13),这就是当时唯一的差异点。
|
|
2264
|
+
// 归一化:小于 1e11 的一律当秒。1e11 ms ≈ 1973 年,1e11 s 远在未来,分界安全。
|
|
2265
|
+
const toMs = (v) => {
|
|
2266
|
+
const n = Number(v);
|
|
2267
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
2268
|
+
return n < 1e11 ? n * 1000 : n;
|
|
2269
|
+
};
|
|
1622
2270
|
// 只挑今天动过的会话
|
|
1623
2271
|
let candidates = [];
|
|
2272
|
+
let listedCount = 0;
|
|
1624
2273
|
if (typeof controller?.list === "function") {
|
|
1625
2274
|
const listed = await controller.list({});
|
|
1626
2275
|
const items = Array.isArray(listed) ? listed : (listed?.items ?? []);
|
|
2276
|
+
listedCount = items.length;
|
|
1627
2277
|
candidates = items
|
|
1628
|
-
.map((item) => (item && typeof item === "object" ? { id: item.sessionId ?? item.id, at:
|
|
2278
|
+
.map((item) => (item && typeof item === "object" ? { id: item.sessionId ?? item.id, at: toMs(item.updatedAt ?? item.createdAt ?? 0) } : null))
|
|
1629
2279
|
.filter((x) => x && typeof x.id === "string" && x.id !== "");
|
|
1630
|
-
// list() 已按活动降序;万一没有 updatedAt 字段就保守全扫(上限
|
|
2280
|
+
// list() 已按活动降序;万一没有 updatedAt 字段就保守全扫(上限 TODAY_MAX_SESSIONS)。
|
|
2281
|
+
// ⚠️ 没有时间戳(at === 0)的会话**不许被过滤掉**(2026-10-31 全量审查):
|
|
2282
|
+
// 只要列表里有一部分带时间戳,t >= dayStart 就会把 at=0 的那批整批丢掉 ——
|
|
2283
|
+
// 而「今天真的用过它」完全可能,症状就是今日金额系统性偏低。
|
|
2284
|
+
// 保留它们不会造成多算:collectDailyUsage 内部按每条事件的时刻和 dayStart 比,
|
|
2285
|
+
// 老会话的旧事件自然贡献 0,代价只是多读一份文件。
|
|
1631
2286
|
if (candidates.some((x) => x.at > 0)) {
|
|
1632
|
-
candidates = candidates.filter((x) => x.at >= dayStart);
|
|
2287
|
+
candidates = candidates.filter((x) => x.at >= dayStart || !(x.at > 0));
|
|
1633
2288
|
}
|
|
1634
2289
|
} else if (typeof persistence?.list === "function") {
|
|
1635
2290
|
const headers = await persistence.list();
|
|
1636
2291
|
const list = Array.isArray(headers) ? headers : [];
|
|
2292
|
+
listedCount = list.length;
|
|
1637
2293
|
candidates = list
|
|
1638
|
-
.map((h) => (h && typeof h.id === "string" ? { id: h.id, at:
|
|
2294
|
+
.map((h) => (h && typeof h.id === "string" ? { id: h.id, at: toMs(h.updatedAt ?? h.createdAt ?? 0) } : null))
|
|
1639
2295
|
.filter(Boolean);
|
|
2296
|
+
// 同上:at === 0(无时间戳)的会话保留,别被 at >= dayStart 整批滤掉。
|
|
1640
2297
|
if (candidates.some((x) => x.at > 0)) {
|
|
1641
|
-
candidates = candidates.filter((x) => x.at >= dayStart);
|
|
2298
|
+
candidates = candidates.filter((x) => x.at >= dayStart || !(x.at > 0));
|
|
1642
2299
|
}
|
|
1643
2300
|
} else {
|
|
1644
2301
|
throw new Error("会话持久化服务不可用");
|
|
1645
2302
|
}
|
|
1646
|
-
|
|
1647
|
-
|
|
2303
|
+
// 有会话但今天一个都没筛出来时,**不许**把 0 写进快照:
|
|
2304
|
+
// 这既可能是真的今天还没用(合法 0),也可能是时间戳口径又变了(假 0)。
|
|
2305
|
+
// 假的 0 一旦落盘会被 readTodaySnapshot 认成"今天的值",界面就一直显示 ¥0.0000,
|
|
2306
|
+
// 而且后台重算只要继续踩同一个坑就永远修不回来。所以这种情况宁可不落盘,
|
|
2307
|
+
// 让下次冷启动重新现算。
|
|
2308
|
+
const suspiciousEmpty = listedCount > 0 && candidates.length === 0; const selected = candidates
|
|
2309
|
+
// 今日用自己的上限(默认 200),不再跟统计面板的 80 绑在一起 —— 见 TODAY_MAX_SESSIONS。
|
|
2310
|
+
.slice(0, clampNumber(config?.todaySessionLimit, 10, 2000, TODAY_MAX_SESSIONS))
|
|
1648
2311
|
.map((x) => x.id);
|
|
2312
|
+
// reuse:调用方(?session= 请求)手里已经持有某个会话的完整 events,
|
|
2313
|
+
// 别再为它解压一遍 —— 那正是「今日消耗实时刷新」的成本来源。
|
|
2314
|
+
const reuseId = typeof reuse?.sessionId === "string" ? reuse.sessionId : "";
|
|
2315
|
+
const reuseEvents = Array.isArray(reuse?.events) ? reuse.events : null;
|
|
2316
|
+
// ⚠️ 必须按**归一化键**比较(2026-10-31 审计 M3):list() 给的是带 session- 前缀的 id,
|
|
2317
|
+
// 而 reuseId 是客户端传来的裸 UUID —— 直接 includes 永远为 false,同一个会话被 unshift 第二份,
|
|
2318
|
+
// 一份读盘、一份 reuse,金额双算而且会被写进快照。
|
|
2319
|
+
const reuseKey = reuseId === "" ? "" : sessionIdKey(reuseId);
|
|
2320
|
+
if (
|
|
2321
|
+
reuseId !== "" &&
|
|
2322
|
+
reuseEvents !== null &&
|
|
2323
|
+
!selected.some((id) => sessionIdKey(id) === reuseKey)
|
|
2324
|
+
) {
|
|
2325
|
+
selected.unshift(reuseId);
|
|
2326
|
+
}
|
|
1649
2327
|
const daily = new Map();
|
|
1650
2328
|
let cursor = 0;
|
|
2329
|
+
// 成功读到事件的会话数(2026-10-31 第二十二轮审计)。⚠️ 原来只要「候选非空」就当正常,
|
|
2330
|
+
// 而候选是按 list() 的 updatedAt 筛出来的、跟"读不读得出来"无关 —— 所有会话的 inspect
|
|
2331
|
+
// 都抛错时 cost 会是 0,却带着新鲜 at 落盘,界面长期 ¥0.0000(被当成今天的定型值)。
|
|
2332
|
+
let okCount = 0;
|
|
1651
2333
|
const worker = async () => {
|
|
1652
2334
|
while (cursor < selected.length) {
|
|
1653
2335
|
const sessionId = selected[cursor++];
|
|
1654
2336
|
try {
|
|
1655
|
-
const
|
|
2337
|
+
const events =
|
|
2338
|
+
reuseId !== "" && sessionIdKey(sessionId) === reuseKey && reuseEvents !== null
|
|
2339
|
+
? reuseEvents
|
|
2340
|
+
: (await inspectPersistedCached(ctx, sessionId))?.events ?? [];
|
|
1656
2341
|
collectDailyUsage(
|
|
1657
|
-
|
|
2342
|
+
events,
|
|
1658
2343
|
config,
|
|
1659
|
-
detectModel({ requestHeader: () => void 0, requestContext: () => void 0, events
|
|
2344
|
+
detectModel({ requestHeader: () => void 0, requestContext: () => void 0, events }),
|
|
1660
2345
|
dayStart,
|
|
1661
2346
|
daily,
|
|
1662
2347
|
sessionId,
|
|
1663
2348
|
);
|
|
2349
|
+
okCount += 1;
|
|
1664
2350
|
} catch {
|
|
1665
2351
|
// 单个会话读不出来不影响总额
|
|
1666
2352
|
}
|
|
2353
|
+
// 每读完一个就让出一次主线程:单会话解压实测 723 ms 且是同步 CPU,
|
|
2354
|
+
// 连着读几个会把 Node 主线程占死 —— 和 DSH 同进程,界面就掉帧。
|
|
2355
|
+
// 让出之后余额/指针/动效都还能跑,只是总额晚几百毫秒到。
|
|
2356
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1667
2357
|
}
|
|
1668
2358
|
};
|
|
2359
|
+
// 并发度回到 3。**不要改成 1**:串行化会让「A 还没解压完就点 B」变成严格排队 ——
|
|
2360
|
+
// B 的请求要等 A 的同步解压整段跑完才能开始,用户体感就是「两个对话一起卡住」。
|
|
2361
|
+
// 单线程下并发不会更快,但能让不同会话的解压机会**交错**,切会话时不必等前一个走完。
|
|
2362
|
+
// 真正的阻塞控制靠下面每会话解压之后的 setImmediate 让出。
|
|
1669
2363
|
await Promise.all(Array.from({ length: Math.min(3, selected.length) }, () => worker()));
|
|
1670
|
-
const day = daily.get(todayKey);
|
|
2364
|
+
const day = daily.get(todayKey); // bySession 摊平成普通对象:快照要能 JSON 序列化,Map 存不了。
|
|
2365
|
+
const bySession = {};
|
|
2366
|
+
if (day?.bySession != null) {
|
|
2367
|
+
for (const [id, rec] of day.bySession) bySession[id] = { cost: rec.cost, totalTokens: rec.totalTokens };
|
|
2368
|
+
}
|
|
1671
2369
|
return {
|
|
1672
2370
|
date: todayKey,
|
|
1673
2371
|
cost: day?.cost ?? 0,
|
|
1674
2372
|
totalTokens: day?.totalTokens ?? 0,
|
|
1675
2373
|
sessions: day?.sessionIds?.size ?? 0,
|
|
2374
|
+
bySession,
|
|
2375
|
+
// 只给 computeAndStoreToday 用(决定要不要落盘),客户端不读这个字段。
|
|
2376
|
+
// ⚠️ 除了「候选被筛空」,还要判「一个会话都没读成功」(2026-10-31 第二十二轮审计):
|
|
2377
|
+
// list() 正常、每个 inspect 都抛错时 candidates 非空、cost 却是 0 —— 那种 0 是假的,
|
|
2378
|
+
// 带着新鲜 at 落盘就会长期显示 ¥0.0000。
|
|
2379
|
+
suspiciousEmpty: suspiciousEmpty || (listedCount > 0 && okCount === 0),
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
/** 真算一次今日消耗,并把结果落盘成快照。
|
|
2384
|
+
* 快照是「下次启动秒开」的全部依据,所以每次算完都要写;
|
|
2385
|
+
* 写失败(目录只读等)只影响下次启动会不会卡,不影响这一次的结果。 */
|
|
2386
|
+
async function computeAndStoreToday(ctx, config, state, reuse = null) {
|
|
2387
|
+
const value = await buildTodayCost(ctx, config, reuse);
|
|
2388
|
+
// ⚠️ 可疑的空结果**不落盘**(见 buildTodayCost 里 suspiciousEmpty 的注释)。
|
|
2389
|
+
// 真的今天还没用过(0)明天也会被跨天结算清掉,代价只是下次冷启动多算一遍;
|
|
2390
|
+
// 而假的 0 一旦落盘会被当成"今天的值"长期显示 ¥0.0000,重算若继续踩同一个坑
|
|
2391
|
+
// 就永远修不回来 —— 两害相权,宁可不写。
|
|
2392
|
+
const snapshot = value.suspiciousEmpty ? { ...value, at: 0 } : { ...value, at: Date.now() };
|
|
2393
|
+
try {
|
|
2394
|
+
if (!value.suspiciousEmpty) {
|
|
2395
|
+
// ⚠️ 写今天这份之前,先把昨天那份结算进 balance-daily-settled.json
|
|
2396
|
+
// (2026-10-31 第二十二轮审计):结算原来只在 apply 的启动块里跑一次,
|
|
2397
|
+
// 桌面端常驻不重启、跨过北京时间 0 点后,昨天快照的收尾部分会被今天这份直接覆盖,
|
|
2398
|
+
// 统计里昨天就少一截。这里是"即将覆盖"的唯一位置,放这里天然只多一次小 JSON 读。
|
|
2399
|
+
try {
|
|
2400
|
+
await settleStaleTodaySnapshot(ctx);
|
|
2401
|
+
} catch {
|
|
2402
|
+
// 结算失败不影响今天的数字
|
|
2403
|
+
}
|
|
2404
|
+
await writeTodaySnapshot(state.snapshotFile, snapshot);
|
|
2405
|
+
}
|
|
2406
|
+
} catch {
|
|
2407
|
+
// 落盘失败:当作只有内存缓存,下次启动再算一遍
|
|
2408
|
+
}
|
|
2409
|
+
// 内存里也把 at 归零,让上层把这次结果当"不新鲜":不写盘 + 立即允许重算,
|
|
2410
|
+
// 而不是拿着假 0 缓存 45 秒。
|
|
2411
|
+
return snapshot;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
/** 后台重算(单飞):同一时刻只跑一个,避免轮询把解压任务叠罗汉。 */
|
|
2415
|
+
function scheduleTodayRecompute(ctx, config, state) {
|
|
2416
|
+
if (state.recompute !== null) return;
|
|
2417
|
+
state.recompute = (async () => {
|
|
2418
|
+
try {
|
|
2419
|
+
const snapshot = await computeAndStoreToday(ctx, config, state);
|
|
2420
|
+
state.cache.value = snapshot;
|
|
2421
|
+
// 可疑空结果(有会话但今天一个都没筛出来)**不许被缓存满 staleMs**:
|
|
2422
|
+
// 落盘那边已经用 at:0 表达了不新鲜,内存里必须跟着立即允许重算,
|
|
2423
|
+
// 否则界面会拿着一个可疑的 0 硬撑 45 秒(2026-10-31 全量审查)。
|
|
2424
|
+
state.cache.expiresAt = snapshot.suspiciousEmpty === true ? 0 : Date.now() + state.staleMs;
|
|
2425
|
+
// 全量重算换了基线,增量缓存里的旧值全部作废 —— 否则同一会话在 TTL 内
|
|
2426
|
+
// 会拿「按旧基线算的增量」覆盖新结果(2026-10-31 全量审查)。
|
|
2427
|
+
todayIncrementCache.clear();
|
|
2428
|
+
} catch {
|
|
2429
|
+
// 后台失败不打扰任何人:界面上继续显示旧快照
|
|
2430
|
+
} finally {
|
|
2431
|
+
state.recompute = null;
|
|
2432
|
+
}
|
|
2433
|
+
})();
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
/** 「今日消耗」对外入口 —— 快照优先,现算兜底。
|
|
2437
|
+
*
|
|
2438
|
+
* 分层顺序(越靠前越快):
|
|
2439
|
+
* ① 内存缓存新鲜 → 直接给,partial=false
|
|
2440
|
+
* ② 落盘快照 → 直接给,同时后台重算(这是消除冷启动卡顿的那一步)
|
|
2441
|
+
* ③ 现算 → 只在「首次安装」「刚跨天」「快照文件损坏」时走到这里
|
|
2442
|
+
*
|
|
2443
|
+
* `partial=true` 表示「这是旧值,后台正在算新的」,客户端会隔几秒再问一次。
|
|
2444
|
+
* @returns {Promise<{value:object|null, partial:boolean}>} */
|
|
2445
|
+
async function resolveTodayCost(ctx, config, state, reuse = null) {
|
|
2446
|
+
const now = Date.now();
|
|
2447
|
+
// 带了 reuse(?session= 场景)就**强制重算**:当前会话的事件已经在手上,
|
|
2448
|
+
// 其余今天动过的会话指纹没变、直接命中持久化缓存 —— 于是这次重算是纯计算、
|
|
2449
|
+
// 零额外磁盘开销,正好用来做「今日消耗实时刷新」。走缓存反而会拿到旧值。
|
|
2450
|
+
if (reuse === null) {
|
|
2451
|
+
// ⚠️ provisional(增量算出来的不精确值)**不能当定型值命中缓存**(2026-10-31 审计 M2):
|
|
2452
|
+
// ?session= 把它写进 state.cache.value 后,45 秒内的 ?today=1 会命中它、
|
|
2453
|
+
// 带着 partial=false 发回去,客户端于是 settled=true 压 20 秒不补拉,
|
|
2454
|
+
// 数字就锁在一个不精确值上。
|
|
2455
|
+
if (state.cache.value != null && state.cache.expiresAt > now && state.cache.value.provisional !== true) {
|
|
2456
|
+
return { value: state.cache.value, partial: state.recompute !== null };
|
|
2457
|
+
}
|
|
2458
|
+
const snapshot = await readTodaySnapshot(state.snapshotFile);
|
|
2459
|
+
if (snapshot != null) {
|
|
2460
|
+
// ⚠️ 增量值(provisional)可能**比快照大**(快照是几十秒前算的),直接拿快照覆盖
|
|
2461
|
+
// 会让今日金额往回跳、而且带着 partial=false 让客户端不再补拉,锁死在旧值上
|
|
2462
|
+
// (2026-10-31 第二十二轮审计:实测 17.3 → 16.0)。取较大者,并强制 partial=true
|
|
2463
|
+
// 让客户端继续补拉、后台重算把真值补上。
|
|
2464
|
+
const prev = state.cache.value;
|
|
2465
|
+
const keepPrev = prev != null && prev.provisional === true
|
|
2466
|
+
&& Number(prev.cost ?? 0) > Number(snapshot.cost ?? 0);
|
|
2467
|
+
const chosen = keepPrev ? prev : snapshot;
|
|
2468
|
+
const forcedPartial = keepPrev;
|
|
2469
|
+
state.cache.value = chosen;
|
|
2470
|
+
const age = chosen.at > 0 ? now - chosen.at : Number.POSITIVE_INFINITY;
|
|
2471
|
+
const fresh = age < state.staleMs;
|
|
2472
|
+
state.cache.expiresAt = now + (fresh && !forcedPartial ? state.staleMs - age : 3000);
|
|
2473
|
+
if (!fresh || forcedPartial) scheduleTodayRecompute(ctx, config, state);
|
|
2474
|
+
return { value: chosen, partial: !fresh || forcedPartial };
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
// 快照不可用 / 带了 reuse:现算。reuse 路径算完同样落盘,下一次冷启动照样秒开。
|
|
2478
|
+
const value = await computeAndStoreToday(ctx, config, state, reuse);
|
|
2479
|
+
// 同 scheduleTodayRecompute:可疑空结果立即允许重算,别缓存满 staleMs。
|
|
2480
|
+
state.cache = {
|
|
2481
|
+
value,
|
|
2482
|
+
expiresAt: value.suspiciousEmpty === true ? 0 : Date.now() + state.staleMs,
|
|
1676
2483
|
};
|
|
2484
|
+
todayIncrementCache.clear();
|
|
2485
|
+
// 可疑空结果必须如实标 partial(2026-10-31 审计 M4):硬写 false 客户端就不补拉,
|
|
2486
|
+
// 一个可疑的 0 就真的长期显示下去了。
|
|
2487
|
+
return { value, partial: value.suspiciousEmpty === true };
|
|
1677
2488
|
}
|
|
1678
2489
|
|
|
2490
|
+
|
|
1679
2491
|
async function buildUsageStats(ctx, config) {
|
|
1680
2492
|
const getService = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
|
|
1681
2493
|
const controller = getService?.("sessionController");
|
|
1682
2494
|
const persistence = getService?.("sessionPersistence");
|
|
1683
2495
|
const now = Date.now();
|
|
1684
|
-
|
|
2496
|
+
// ⚠️ 要跟**自然日**对齐(2026-10-31 审计 L6):
|
|
2497
|
+
// now - 89 天 = 今天的同一时刻,而统计是按北京自然日分行的,
|
|
2498
|
+
// 于是第 90 行只算到「当天此刻为止」,比其余 89 天少一截,看起来像那天特别省。
|
|
2499
|
+
// 改成「今天 0 点往前推 89 个整天」,窗口正好覆盖 90 个完整自然日。
|
|
2500
|
+
const cutoffMs = beijingDayStart(now) - (STATS_DAYS - 1) * 24 * 60 * 60 * 1000;
|
|
1685
2501
|
// 列举会话要跨 DSH 版本:新版 sessionController.list() 返回 { items:[{ sessionId, updatedAt }] }
|
|
1686
2502
|
// (已按活动降序,字段叫 sessionId);旧版 sessionPersistence.list() 返回 [{ id, createdAt }]
|
|
1687
2503
|
// (字段叫 id)。旧版 persistence 还提供 inspect(),新版改成 handle-based 的 open(),
|
|
@@ -1715,7 +2531,7 @@ async function buildUsageStats(ctx, config) {
|
|
|
1715
2531
|
while (cursor < selected.length) {
|
|
1716
2532
|
const sessionId = selected[cursor++];
|
|
1717
2533
|
try {
|
|
1718
|
-
const inspected = await
|
|
2534
|
+
const inspected = await inspectPersistedCached(ctx, sessionId);
|
|
1719
2535
|
collectDailyUsage(
|
|
1720
2536
|
inspected?.events ?? [],
|
|
1721
2537
|
config,
|
|
@@ -1728,6 +2544,9 @@ async function buildUsageStats(ctx, config) {
|
|
|
1728
2544
|
} catch {
|
|
1729
2545
|
failedSessions++;
|
|
1730
2546
|
}
|
|
2547
|
+
// 同 buildTodayCost:统计要扫几十个会话,每个解压 700 ms,必须逐个让出,
|
|
2548
|
+
// 否则打开统计面板那一下整页都会卡住。
|
|
2549
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1731
2550
|
}
|
|
1732
2551
|
};
|
|
1733
2552
|
await Promise.all(Array.from({ length: Math.min(3, selected.length) }, () => worker()));
|
|
@@ -1746,6 +2565,17 @@ async function buildUsageStats(ctx, config) {
|
|
|
1746
2565
|
offpeak: emptyTokenBucket(),
|
|
1747
2566
|
peakCost: 0,
|
|
1748
2567
|
offpeakCost: 0,
|
|
2568
|
+
// ⚠️ 这 7 个 web* 键一个都不能少(2026-10-31 审计 L1):
|
|
2569
|
+
// 下面汇总时是 acc.webSearchCalls += day.webSearchCalls,0 + undefined = NaN,
|
|
2570
|
+
// JSON.stringify 把 NaN 写成 null,客户端 num() 又把它吃成 0 ——
|
|
2571
|
+
// 于是「网页工具」90 天汇总恒为 0,而实际天天在用。
|
|
2572
|
+
webSearchCalls: 0,
|
|
2573
|
+
webFetchCalls: 0,
|
|
2574
|
+
webSearchBillable: 0,
|
|
2575
|
+
webFetchBillable: 0,
|
|
2576
|
+
webSearchCost: 0,
|
|
2577
|
+
webFetchCost: 0,
|
|
2578
|
+
webToolCost: 0,
|
|
1749
2579
|
};
|
|
1750
2580
|
}
|
|
1751
2581
|
return {
|
|
@@ -1771,6 +2601,25 @@ async function buildUsageStats(ctx, config) {
|
|
|
1771
2601
|
webToolCost: day.webSearchCost + day.webFetchCost,
|
|
1772
2602
|
};
|
|
1773
2603
|
});
|
|
2604
|
+
// ── 已结算的历史日(跨天时从「今日快照」转存下来的,见 settleStaleTodaySnapshot)──
|
|
2605
|
+
// 统计本身会重扫会话算历史,所以这里按**较大值**合并、且只补差额:
|
|
2606
|
+
// 快照可能含「强制退出时还没落盘的那段 usage」,那是快照独有的;
|
|
2607
|
+
// 但若重扫已经算得更多(会话最终落盘了),就以重扫为准,绝不重复计数。
|
|
2608
|
+
// 差额记进「空闲」桶 —— 快照没留时段明细,按低峰价补是最保守的取整,
|
|
2609
|
+
// 并且能保住 peakCost + offpeakCost + webToolCost === cost 这个不变量。
|
|
2610
|
+
// token 分项不动:改了会让「各分项之和 === totalTokens」也对不上。
|
|
2611
|
+
const settledDaily = await readDailySettled(siblingFilePath(ctx, DAILY_SETTLED_FILENAME));
|
|
2612
|
+
if (Object.keys(settledDaily).length > 0) {
|
|
2613
|
+
for (const row of rows) {
|
|
2614
|
+
const rec = settledDaily[row.date];
|
|
2615
|
+
if (rec == null) continue;
|
|
2616
|
+
const extra = rec.cost - row.cost;
|
|
2617
|
+
if (!(extra > 1e-7)) continue;
|
|
2618
|
+
row.cost += extra;
|
|
2619
|
+
row.offpeakCost += extra;
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
|
|
1774
2623
|
const total = rows.reduce((acc, day) => {
|
|
1775
2624
|
acc.inputTokens += day.inputTokens;
|
|
1776
2625
|
acc.outputTokens += day.outputTokens;
|
|
@@ -1831,35 +2680,133 @@ async function buildUsageStats(ctx, config) {
|
|
|
1831
2680
|
};
|
|
1832
2681
|
}
|
|
1833
2682
|
|
|
2683
|
+
/** 会话 id 归一化:客户端传的是裸 UUID(DSH 的 sessionId prop),
|
|
2684
|
+
* 会话列表摘要和持久化目录用的是 `session-<uuid>`。比较父子关系前必须先抹平,
|
|
2685
|
+
* 否则同一棵树永远匹配不上(这正是「子代理一直是 0 个」的一半原因)。 */
|
|
2686
|
+
function sessionIdKey(id) {
|
|
2687
|
+
if (typeof id !== "string" || id === "") return "";
|
|
2688
|
+
return id.startsWith("session-") ? id.slice("session-".length) : id;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
/** 列出全部会话并带上父子关系(跨 DSH 版本)。
|
|
2692
|
+
* @returns {Promise<Array<{id:string,parentId:string,origin:string}>>}
|
|
2693
|
+
*
|
|
2694
|
+
* 这是枚举子代理的**主要**依据,不是备用方案。DSH 的 sessionController.list() 每个
|
|
2695
|
+
* 摘要都经过 listFields(header),里面会带出 parentSessionId / origin;旧版
|
|
2696
|
+
* sessionPersistence.list() 通常没有这两项,那就只能退回 subagents 服务那条路。 */
|
|
2697
|
+
async function listSessionRows(ctx) {
|
|
2698
|
+
const getService = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
|
|
2699
|
+
const rows = [];
|
|
2700
|
+
const push = (id, parentId, origin) => {
|
|
2701
|
+
if (typeof id !== "string" || id === "") return;
|
|
2702
|
+
rows.push({
|
|
2703
|
+
id,
|
|
2704
|
+
parentId: typeof parentId === "string" ? parentId : "",
|
|
2705
|
+
origin: typeof origin === "string" ? origin : "",
|
|
2706
|
+
});
|
|
2707
|
+
};
|
|
2708
|
+
const controller = getService?.("sessionController");
|
|
2709
|
+
if (typeof controller?.list === "function") {
|
|
2710
|
+
try {
|
|
2711
|
+
const listed = await controller.list({});
|
|
2712
|
+
const items = Array.isArray(listed) ? listed : (listed?.items ?? []);
|
|
2713
|
+
for (const item of items) {
|
|
2714
|
+
if (item == null || typeof item !== "object") continue;
|
|
2715
|
+
push(item.sessionId ?? item.id, item.parentSessionId ?? item.parentSession, item.origin);
|
|
2716
|
+
}
|
|
2717
|
+
if (rows.length > 0) return rows;
|
|
2718
|
+
} catch {
|
|
2719
|
+
/* 新版列不出来就试旧版 */
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
const persistence = getService?.("sessionPersistence");
|
|
2723
|
+
if (typeof persistence?.list === "function") {
|
|
2724
|
+
try {
|
|
2725
|
+
const headers = await persistence.list();
|
|
2726
|
+
for (const h of Array.isArray(headers) ? headers : []) {
|
|
2727
|
+
if (h == null || typeof h !== "object") continue;
|
|
2728
|
+
push(h.id ?? h.sessionId, h.parentSession ?? h.parentSessionId, h.origin);
|
|
2729
|
+
}
|
|
2730
|
+
} catch {
|
|
2731
|
+
/* 两套都不可用就是没有 */
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
return rows;
|
|
2735
|
+
}
|
|
2736
|
+
|
|
1834
2737
|
/** 汇总某会话的所有子代理后代会话的 usage(跨时段精确计费,与主会话分开)。
|
|
1835
2738
|
* @param ctx DSH 上下文(用于 subagents 服务 + 跨版本持久化会话读取)
|
|
1836
2739
|
* @param sessionId 父会话 id
|
|
1837
2740
|
* @param config 插件配置
|
|
1838
|
-
*
|
|
1839
|
-
*
|
|
1840
|
-
*
|
|
2741
|
+
*
|
|
2742
|
+
* 两条枚举路径,缺一条就会在某种 DSH 版本上永远拿到 0:
|
|
2743
|
+
* ① subagents.listDescendants —— 官方口径,但它内部要求 sessions + sessionQuery +
|
|
2744
|
+
* subagentCatalog 投影三个同时在线,任何一个缺失就抛 SubagentError。原来的实现
|
|
2745
|
+
* 直接 `catch { descendants = [] }`,于是**这条唯一路径一失败就等于「没有子代理」**,
|
|
2746
|
+
* 面板上那两行永远不出现,而且没有任何日志(2026-10-31:「把子智能体的消费也算上」)。
|
|
2747
|
+
* ② 会话列表摘要里的 parentSessionId —— 不依赖任何投影,只要有 sessionController.list()
|
|
2748
|
+
* 就能建出父子图。子代理还能再派子代理,所以按层展开而不是只看直接子级。
|
|
2749
|
+
* 两条取并集:服务可用时多一层保险,服务不可用(实测就是这种)时②独立成立。 */
|
|
1841
2750
|
async function sumSubagentUsage(ctx, sessionId, config) {
|
|
1842
2751
|
if (ctx == null || sessionId === "") return { usage: null };
|
|
1843
|
-
|
|
1844
|
-
|
|
2752
|
+
const rootKey = sessionIdKey(sessionId);
|
|
2753
|
+
const childIds = new Set();
|
|
2754
|
+
const seenKeys = new Set([rootKey]);
|
|
2755
|
+
let source = "none";
|
|
2756
|
+
|
|
2757
|
+
// 路径①:官方服务(可空探测,缺失不影响下面)
|
|
1845
2758
|
const subagentsSvc = typeof ctx.get === "function" ? ctx.get("subagents") : void 0;
|
|
1846
|
-
const listDescendants =
|
|
1847
|
-
|
|
1848
|
-
|
|
2759
|
+
const listDescendants =
|
|
2760
|
+
subagentsSvc != null && typeof subagentsSvc.listDescendants === "function"
|
|
2761
|
+
? subagentsSvc.listDescendants.bind(subagentsSvc)
|
|
2762
|
+
: void 0;
|
|
1849
2763
|
if (listDescendants != null) {
|
|
1850
2764
|
try {
|
|
1851
|
-
descendants = await listDescendants(sessionId);
|
|
2765
|
+
const descendants = await listDescendants(sessionId);
|
|
2766
|
+
for (const e of Array.isArray(descendants) ? descendants : []) {
|
|
2767
|
+
if (e == null || typeof e !== "object") continue;
|
|
2768
|
+
if (typeof e.id !== "string" || e.id === "") continue;
|
|
2769
|
+
const key = sessionIdKey(e.id);
|
|
2770
|
+
if (key === "" || seenKeys.has(key)) continue;
|
|
2771
|
+
seenKeys.add(key);
|
|
2772
|
+
childIds.add(e.id);
|
|
2773
|
+
}
|
|
2774
|
+
if (childIds.size > 0) source = "service";
|
|
1852
2775
|
} catch {
|
|
1853
|
-
|
|
2776
|
+
/* 投影不全时它必抛;不是错误,走路径② */
|
|
1854
2777
|
}
|
|
1855
2778
|
}
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
2779
|
+
|
|
2780
|
+
// 路径②:从会话摘要的 parentSessionId 建父子图,逐层展开
|
|
2781
|
+
try {
|
|
2782
|
+
const rows = await listSessionRows(ctx);
|
|
2783
|
+
const byParent = new Map();
|
|
2784
|
+
for (const row of rows) {
|
|
2785
|
+
const parentKey = sessionIdKey(row.parentId);
|
|
2786
|
+
if (parentKey === "") continue;
|
|
2787
|
+
if (!byParent.has(parentKey)) byParent.set(parentKey, []);
|
|
2788
|
+
byParent.get(parentKey).push(row.id);
|
|
2789
|
+
}
|
|
2790
|
+
let frontier = [rootKey];
|
|
2791
|
+
while (frontier.length > 0) {
|
|
2792
|
+
const next = [];
|
|
2793
|
+
for (const parentKey of frontier) {
|
|
2794
|
+
for (const childId of byParent.get(parentKey) ?? []) {
|
|
2795
|
+
const key = sessionIdKey(childId);
|
|
2796
|
+
if (key === "" || seenKeys.has(key)) continue;
|
|
2797
|
+
seenKeys.add(key);
|
|
2798
|
+
childIds.add(childId);
|
|
2799
|
+
next.push(key);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
frontier = next;
|
|
2803
|
+
}
|
|
2804
|
+
if (childIds.size > 0 && source === "none") source = "catalog";
|
|
2805
|
+
} catch {
|
|
2806
|
+
/* 列不出来就当没有子代理 */
|
|
1861
2807
|
}
|
|
1862
|
-
|
|
2808
|
+
|
|
2809
|
+
if (childIds.size === 0) return { usage: null, debug: { source } };
|
|
1863
2810
|
|
|
1864
2811
|
// 2) 对每个子代理读取持久化事件并求和
|
|
1865
2812
|
const empty = () => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
|
|
@@ -1867,9 +2814,12 @@ async function sumSubagentUsage(ctx, sessionId, config) {
|
|
|
1867
2814
|
const offpeak = empty();
|
|
1868
2815
|
const byModelMap = new Map(); // model -> { model, peak, offpeak }
|
|
1869
2816
|
let count = 0;
|
|
1870
|
-
|
|
2817
|
+
// 上限:子代理会话虽然通常比主会话小,但这里是每轮 ?session= 都会跑的路径,
|
|
2818
|
+
// 一个会开很多子代理的长对话要防止它变成几百次串行解压(主线程同步 CPU)。
|
|
2819
|
+
const scanIds = [...childIds].slice(0, SUBAGENT_MAX_SESSIONS);
|
|
2820
|
+
for (const id of scanIds) {
|
|
1871
2821
|
try {
|
|
1872
|
-
const inspected = await
|
|
2822
|
+
const inspected = await inspectPersistedCached(ctx, id);
|
|
1873
2823
|
const events = Array.isArray(inspected?.events) ? inspected.events : [];
|
|
1874
2824
|
const u = sumEventsUsage(events, config);
|
|
1875
2825
|
if (u === null || u === void 0) continue;
|
|
@@ -1882,10 +2832,19 @@ async function sumSubagentUsage(ctx, sessionId, config) {
|
|
|
1882
2832
|
offpeak.outputTokens += u.offpeak.outputTokens;
|
|
1883
2833
|
offpeak.cacheReadTokens += u.offpeak.cacheReadTokens;
|
|
1884
2834
|
offpeak.cacheWriteTokens += u.offpeak.cacheWriteTokens;
|
|
1885
|
-
//
|
|
2835
|
+
// 子代理也可能换模型,按模型同样分桶聚合。
|
|
2836
|
+
// ⚠️ 键必须带 era(2026-10-31 审计 L5):出口处是按
|
|
2837
|
+
// estimateCost(b, catalogPricing(config, b.model, b.era), ...) 算钱的,
|
|
2838
|
+
// 原来只按 model 分桶、桶里没有 era → b.era 恒 undefined → 一律用**当前**价。
|
|
2839
|
+
// 于是跨过调价点的老子代理用量被按新价重算(旧价贵 50%,方向是低估),
|
|
2840
|
+
// 而且同一个 model 在新旧两段被并成一桶,「各模型之和 = 总额」当场破。
|
|
1886
2841
|
for (const b of u.byModel ?? []) {
|
|
1887
|
-
|
|
1888
|
-
|
|
2842
|
+
const bucketKey = b.model + "\u0000" + (b.era ?? "");
|
|
2843
|
+
let be = byModelMap.get(bucketKey);
|
|
2844
|
+
if (!be) {
|
|
2845
|
+
be = { model: b.model, era: b.era, peak: empty(), offpeak: empty() };
|
|
2846
|
+
byModelMap.set(bucketKey, be);
|
|
2847
|
+
}
|
|
1889
2848
|
be.peak.inputTokens += b.peak.inputTokens;
|
|
1890
2849
|
be.peak.outputTokens += b.peak.outputTokens;
|
|
1891
2850
|
be.peak.cacheReadTokens += b.peak.cacheReadTokens;
|
|
@@ -1898,8 +2857,13 @@ async function sumSubagentUsage(ctx, sessionId, config) {
|
|
|
1898
2857
|
} catch {
|
|
1899
2858
|
// 该子代理读取失败则跳过
|
|
1900
2859
|
}
|
|
2860
|
+
// 每个会话后让出主线程:插件和 DSH 同进程,连着解压十几个会把 UI 卡住
|
|
2861
|
+
await new Promise((resolve) => {
|
|
2862
|
+
if (typeof setImmediate === "function") setImmediate(resolve);
|
|
2863
|
+
else setTimeout(resolve, 0);
|
|
2864
|
+
});
|
|
1901
2865
|
}
|
|
1902
|
-
if (count === 0) return { usage: null };
|
|
2866
|
+
if (count === 0) return { usage: null, debug: { source, scanned: scanIds.length, counted: 0 } };
|
|
1903
2867
|
const inputTokens = peak.inputTokens + offpeak.inputTokens;
|
|
1904
2868
|
const outputTokens = peak.outputTokens + offpeak.outputTokens;
|
|
1905
2869
|
const cacheReadTokens = peak.cacheReadTokens + offpeak.cacheReadTokens;
|
|
@@ -1917,10 +2881,20 @@ async function sumSubagentUsage(ctx, sessionId, config) {
|
|
|
1917
2881
|
byModel: [...byModelMap.values()],
|
|
1918
2882
|
sessions: count,
|
|
1919
2883
|
};
|
|
1920
|
-
return { usage };
|
|
2884
|
+
return { usage, debug: { source, scanned: scanIds.length, counted: count } };
|
|
1921
2885
|
}
|
|
1922
2886
|
|
|
1923
2887
|
function formatUsage(usage, pricing, multiplier = 2, model = "") {
|
|
2888
|
+
// ⚠️ usage 为 null 时绝不许直接解引用(2026-10-31 全量审查)。
|
|
2889
|
+
// sumSessionUsage 在会话还在装载 / 事件为空时会返回 null,
|
|
2890
|
+
// 而 /balance 命令把它直接传进来 —— 下面一行 usage.byModel 就是 TypeError,
|
|
2891
|
+
// 整个命令面板报错。命令只是查看入口,拿不到数字就说一句话。
|
|
2892
|
+
if (usage == null || typeof usage !== "object") {
|
|
2893
|
+
return "当前会话消耗:暂时无数据(会话可能还在加载,稍后再试)";
|
|
2894
|
+
}
|
|
2895
|
+
// peak / offpeak 也可能缺失(旧版数据),先落到空桶再取字段。
|
|
2896
|
+
const peakBucket = usage.peak ?? {};
|
|
2897
|
+
const offpeakBucket = usage.offpeak ?? {};
|
|
1924
2898
|
// 优先按 byModel 逐段累加(每段用各自模型 + 价格时代的价格,历史消耗不因调价而变);
|
|
1925
2899
|
// 无 byModel 时回退单一价格估算(兼容旧数据)。pricing 是**目录价**,倍率单独传。
|
|
1926
2900
|
const cost =
|
|
@@ -1930,9 +2904,9 @@ function formatUsage(usage, pricing, multiplier = 2, model = "") {
|
|
|
1930
2904
|
0,
|
|
1931
2905
|
)
|
|
1932
2906
|
: estimateCost(usage, pricing, multiplier);
|
|
1933
|
-
const newTokens = usage.inputTokens + usage.outputTokens;
|
|
1934
|
-
const peakTokens =
|
|
1935
|
-
const offpeakTokens =
|
|
2907
|
+
const newTokens = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
|
2908
|
+
const peakTokens = (peakBucket.inputTokens ?? 0) + (peakBucket.cacheReadTokens ?? 0) + (peakBucket.cacheWriteTokens ?? 0) + (peakBucket.outputTokens ?? 0);
|
|
2909
|
+
const offpeakTokens = (offpeakBucket.inputTokens ?? 0) + (offpeakBucket.cacheReadTokens ?? 0) + (offpeakBucket.cacheWriteTokens ?? 0) + (offpeakBucket.outputTokens ?? 0);
|
|
1936
2910
|
const modelLine = model === "" ? "" : `(模型 ${model})`;
|
|
1937
2911
|
const lines = [
|
|
1938
2912
|
`当前会话消耗${modelLine}:`,
|
|
@@ -1957,6 +2931,26 @@ function writeJson(res, status, payload) {
|
|
|
1957
2931
|
res.end(body);
|
|
1958
2932
|
}
|
|
1959
2933
|
|
|
2934
|
+
/** 是否来自同源的 JSON 请求。
|
|
2935
|
+
* 本地 HTTP 接口默认对浏览器里**任意网页**开放:一个 text/plain 的「简单请求」
|
|
2936
|
+
* 不需要 CORS 预检就能打到 127.0.0.1,改设置、触发自更新都算副作用。
|
|
2937
|
+
* 所以写入口要求:Content-Type 是 application/json,且(若带 Origin)Origin 的
|
|
2938
|
+
* host:port 等于请求自己的 Host。兼容不带 Origin 的非浏览器调用(curl / 测试)。 */
|
|
2939
|
+
function isSameOriginJsonRequest(req) {
|
|
2940
|
+
const headers = req && req.headers ? req.headers : {};
|
|
2941
|
+
const contentType = String(headers["content-type"] || "").toLowerCase();
|
|
2942
|
+
if (!contentType.startsWith("application/json")) return false;
|
|
2943
|
+
const origin = headers.origin;
|
|
2944
|
+
if (origin === void 0 || origin === "") return true;
|
|
2945
|
+
try {
|
|
2946
|
+
const originHost = new URL(origin).host;
|
|
2947
|
+
const host = String(headers.host || "");
|
|
2948
|
+
return host !== "" && originHost === host;
|
|
2949
|
+
} catch {
|
|
2950
|
+
return false;
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
|
|
1960
2954
|
/** 后台异步任务的兜底包装。
|
|
1961
2955
|
*
|
|
1962
2956
|
* 为什么必须有:本插件里所有「不 await 的后台任务」(读设置文件、同步节假日、拉更新)
|
|
@@ -1991,11 +2985,27 @@ function runQuietly(label, fn) {
|
|
|
1991
2985
|
}
|
|
1992
2986
|
|
|
1993
2987
|
export function apply(ctx, config = {}) {
|
|
1994
|
-
|
|
2988
|
+
// 今日消耗的运行时状态:内存缓存 + 落盘快照路径 + 后台重算的单飞句柄。
|
|
2989
|
+
// 快照路径在读到 dshHome 之后填(见下面 runQuietly「读取设置」),
|
|
2990
|
+
// 空串表示定位不到目录 —— 此时退化成纯内存缓存,功能照常、只是下次启动仍要现算。
|
|
2991
|
+
const todayState = {
|
|
2992
|
+
cache: { expiresAt: 0, value: null },
|
|
2993
|
+
// ⚠️ 不能先置空串、稍后再赋值(2026-10-31 全量审查)。
|
|
2994
|
+
// todayState 一旦在赋值之前被读(异步回调、快速手动刷新、dispose 落盘),
|
|
2995
|
+
// readTodaySnapshot("") 会直接返回 null —— 表现就是「冷启动一直算不出今日」。
|
|
2996
|
+
// siblingFilePath 是同步的,直接在构造时就算好。
|
|
2997
|
+
snapshotFile: siblingFilePath(ctx, TODAY_SNAPSHOT_FILENAME),
|
|
2998
|
+
staleMs: TODAY_SNAPSHOT_STALE_MS,
|
|
2999
|
+
recompute: null,
|
|
3000
|
+
};
|
|
1995
3001
|
let statsCache = { expiresAt: 0, value: null };
|
|
3002
|
+
// 统计单飞(2026-10-31 审计 M7a):并发请求共用同一次全量扫描。
|
|
3003
|
+
let statsInFlight = null;
|
|
1996
3004
|
// 用户设置(「插件」页配置卡片读写):先按出厂默认值跑,异步读到文件后热替换。
|
|
1997
3005
|
// 读不到文件不是错误——全新安装就是没有文件,保持默认值即可。
|
|
1998
|
-
|
|
3006
|
+
// `ready` 是「文件已经读过一轮」的闸门:在它变 true 之前,POST 一律拒绝写盘,
|
|
3007
|
+
// 否则出厂默认值会把用户配置整份覆盖(见下面 handler 里的说明)。
|
|
3008
|
+
const settingsState = { value: { ...DEFAULT_SETTINGS }, file: "", readOnly: false, ready: false };
|
|
1999
3009
|
let effectiveConfig = withSettings(config, settingsState.value);
|
|
2000
3010
|
// 读设置是后台任务:settingsFilePath 走 dshHomePath 服务、readSettingsFile 走 fs,
|
|
2001
3011
|
// 两者都可能在 DSH 换版本后抛错(内部服务改名 / 目录权限变化)。
|
|
@@ -2009,6 +3019,13 @@ export function apply(ctx, config = {}) {
|
|
|
2009
3019
|
// 并立即落盘固化,保证后续每次启动都走「已读」分支。
|
|
2010
3020
|
settingsState.value = { ...DEFAULT_SETTINGS, entrySeen: true };
|
|
2011
3021
|
dirty = true;
|
|
3022
|
+
} else if (loaded === "corrupt") {
|
|
3023
|
+
// 配置文件坏了(已被另存为 .corrupt-<ts>):这一轮用出厂默认值跑,
|
|
3024
|
+
// 但**禁止启动时自动写盘** —— 否则一份默认值会把用户的 60 秒轮询、
|
|
3025
|
+
// 阈值、扫描上限全盖掉,正是「每次更新都改回去」的来源。
|
|
3026
|
+
// 用户之后在设置页改任何一项时会正常写盘(那时是用户自己的意图)。
|
|
3027
|
+
settingsState.value = { ...DEFAULT_SETTINGS, entrySeen: true };
|
|
3028
|
+
settingsState.readOnly = true;
|
|
2012
3029
|
} else if (loaded) {
|
|
2013
3030
|
settingsState.value = loaded;
|
|
2014
3031
|
}
|
|
@@ -2026,13 +3043,25 @@ export function apply(ctx, config = {}) {
|
|
|
2026
3043
|
dirty = true;
|
|
2027
3044
|
}
|
|
2028
3045
|
effectiveConfig = withSettings(config, settingsState.value);
|
|
2029
|
-
|
|
3046
|
+
// 闸门放开:从这里往后 settingsState.value 才是「用户磁盘上那一份」,
|
|
3047
|
+
// 之前的任何写入都是拿默认值覆盖用户配置。
|
|
3048
|
+
settingsState.ready = true;
|
|
3049
|
+
if (dirty && !settingsState.readOnly) {
|
|
2030
3050
|
try {
|
|
2031
3051
|
await writeSettingsFile(settingsState.file, settingsState.value);
|
|
2032
3052
|
} catch {
|
|
2033
3053
|
// 写盘失败(目录只读等)不影响这一次运行:内存里已经是最新值
|
|
2034
3054
|
}
|
|
2035
3055
|
}
|
|
3056
|
+
// 今日消耗快照:先填路径,再结算「昨天留下的那份」。
|
|
3057
|
+
// 结算必须在读快照之前做 —— settleStaleTodaySnapshot 会把过期的快照
|
|
3058
|
+
// 并进 dailySettled 然后删掉它,之后的 readTodaySnapshot 才不会读到旧值。
|
|
3059
|
+
// snapshotFile 已在 todayState 构造时就位(见上),这里不再重复赋值。
|
|
3060
|
+
try {
|
|
3061
|
+
await settleStaleTodaySnapshot(ctx);
|
|
3062
|
+
} catch {
|
|
3063
|
+
// 结算失败不影响今天的显示,下次启动会再试
|
|
3064
|
+
}
|
|
2036
3065
|
});
|
|
2037
3066
|
// DSH 热重载(reload)时会重新 apply 本插件;若旧实例注册的 /balance-api 路由
|
|
2038
3067
|
// 未被清理,新实例再次 register 就会抛出 duplicate route,导致整个 apply 失败、
|
|
@@ -2062,16 +3091,21 @@ export function apply(ctx, config = {}) {
|
|
|
2062
3091
|
const pricing = catalogPricing(effectiveConfig, model);
|
|
2063
3092
|
const multiplier = peakMultiplierOf(effectiveConfig);
|
|
2064
3093
|
// 余额来源:官方账号优先(官方版内置登录,无需 API Key),回退 API Key。
|
|
3094
|
+
// ⚠️ usage 可能是 null(session 未就绪 / sumSessionUsage 判定拿不到),
|
|
3095
|
+
// 而 formatUsage 第一行就取 usage.byModel(2026-10-31 审计 M10)。
|
|
3096
|
+
// 一旦炸掉,连余额文本都拿不到 —— 命令面板只剩一条异常。所以这里先算成文本,
|
|
3097
|
+
// 两条返回分支共用。
|
|
3098
|
+
const usageText = formatUsage(usage, pricing, multiplier, model);
|
|
2065
3099
|
const { balance, error } = await resolveBalance(ctx, effectiveConfig, signal);
|
|
2066
3100
|
if (balance === null) {
|
|
2067
3101
|
return {
|
|
2068
3102
|
kind: "error",
|
|
2069
|
-
text: `${error}\n\n${
|
|
3103
|
+
text: `${error}\n\n${usageText}`,
|
|
2070
3104
|
};
|
|
2071
3105
|
}
|
|
2072
3106
|
return {
|
|
2073
3107
|
kind: "success",
|
|
2074
|
-
text: `${formatBalance(balance)}\n\n${
|
|
3108
|
+
text: `${formatBalance(balance)}\n\n${usageText}`,
|
|
2075
3109
|
};
|
|
2076
3110
|
},
|
|
2077
3111
|
});
|
|
@@ -2110,7 +3144,7 @@ export function apply(ctx, config = {}) {
|
|
|
2110
3144
|
if (configParams?.get("config") === "1") {
|
|
2111
3145
|
try {
|
|
2112
3146
|
if (req.method === "GET") {
|
|
2113
|
-
writeJson(res, 200, { ok: true, settings: settingsState.value, version:
|
|
3147
|
+
writeJson(res, 200, { ok: true, settings: settingsState.value, version: installedVersion() });
|
|
2114
3148
|
return;
|
|
2115
3149
|
}
|
|
2116
3150
|
if (req.method !== "POST") {
|
|
@@ -2120,13 +3154,36 @@ export function apply(ctx, config = {}) {
|
|
|
2120
3154
|
const body = await readRequestBody(req);
|
|
2121
3155
|
const parsed = body.trim() === "" ? {} : JSON.parse(body);
|
|
2122
3156
|
const patch = parsed != null && typeof parsed === "object" && parsed.settings != null ? parsed.settings : parsed;
|
|
3157
|
+
// ⚠️ 设置文件还没读完时**绝不许写盘**(2026-10-31 全量审查)。
|
|
3158
|
+
// settingsState.value 的初值是 DEFAULT_SETTINGS,真正的值要等上面那个
|
|
3159
|
+
// runQuietly 异步读完文件。这中间任何一次 POST 都会拿出厂默认值当底
|
|
3160
|
+
// 做 normalizeSettings({...默认值, ...patch}) 然后整份落盘 ——
|
|
3161
|
+
// 用户改一个开关,轮询间隔 / 低余额阈值 / 扫描上限 / apiKey 全被盖回出厂。
|
|
3162
|
+
// 客户端也有同样的闸门,但那是浏览器侧,冷启动抢跑时挡不住。
|
|
3163
|
+
if (settingsState.ready !== true) {
|
|
3164
|
+
writeJson(res, 503, { ok: false, error: "设置还没加载完成,请稍后再试" });
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
// ⚠️ 写入口必须校验来源与 Content-Type(2026-10-31 审计 L10)。
|
|
3168
|
+
// 本接口监听在 127.0.0.1,浏览器里任意一个网页都能对它发 text/plain
|
|
3169
|
+
// 的「简单请求」—— 不需要预检就能改设置或触发自更新。
|
|
3170
|
+
// 只放行同源(本机 webServer)与 application/json。
|
|
3171
|
+
if (req.method === "POST" && !isSameOriginJsonRequest(req)) {
|
|
3172
|
+
writeJson(res, 403, { ok: false, error: "forbidden" });
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
2123
3175
|
const next = normalizeSettings({ ...settingsState.value, ...(patch ?? {}) });
|
|
2124
3176
|
await writeSettingsFile(settingsState.file || settingsFilePath(ctx), next);
|
|
2125
3177
|
settingsState.value = next;
|
|
2126
3178
|
effectiveConfig = withSettings(config, next);
|
|
2127
3179
|
// 价格与扫描范围可能变了,作废统计缓存
|
|
2128
3180
|
statsCache = { expiresAt: 0, value: null };
|
|
2129
|
-
|
|
3181
|
+
// ⚠️ today 也必须一起作废(2026-10-31 审计 L7):
|
|
3182
|
+
// 改的是价格 / 倍率 / 峰谷窗口时,统计面板立刻刷新了,
|
|
3183
|
+
// 而今日金额还拿着旧价算出来的值最多 45 秒 —— 同一个界面两个口径。
|
|
3184
|
+
todayState.cache = { value: null, expiresAt: 0 };
|
|
3185
|
+
todayIncrementCache.clear();
|
|
3186
|
+
writeJson(res, 200, { ok: true, settings: next, version: installedVersion() });
|
|
2130
3187
|
} catch (error) {
|
|
2131
3188
|
writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
2132
3189
|
}
|
|
@@ -2143,15 +3200,22 @@ export function apply(ctx, config = {}) {
|
|
|
2143
3200
|
}
|
|
2144
3201
|
if (wantUpdate) {
|
|
2145
3202
|
if (req.method === "POST") {
|
|
3203
|
+
// ⚠️ 必须和设置写入同一道同源校验(2026-10-31 第二十二轮审计):
|
|
3204
|
+
// 这条分支会真的下载 npm 包并覆盖插件安装目录。少了校验,本机任意网页
|
|
3205
|
+
// 用一个 text/plain 的简单请求就能触发写盘(CSRF)。
|
|
3206
|
+
if (!isSameOriginJsonRequest(req)) {
|
|
3207
|
+
writeJson(res, 403, { ok: false, error: "forbidden" });
|
|
3208
|
+
return;
|
|
3209
|
+
}
|
|
2146
3210
|
// POST → 下载 npm 上最新 tarball 覆盖本机安装(见 installLatestVersion)。
|
|
2147
3211
|
// 下载 + 逐文件写盘期间 await 会让出事件循环,不会卡住余额请求。
|
|
2148
3212
|
try {
|
|
2149
3213
|
const result = await installLatestVersion(httpCtx);
|
|
2150
|
-
writeJson(res, 200, { current:
|
|
3214
|
+
writeJson(res, 200, { current: installedVersion(), ...result });
|
|
2151
3215
|
} catch (error) {
|
|
2152
3216
|
writeJson(res, 200, {
|
|
2153
3217
|
ok: false,
|
|
2154
|
-
current:
|
|
3218
|
+
current: installedVersion(),
|
|
2155
3219
|
error: error instanceof Error ? error.message : String(error),
|
|
2156
3220
|
});
|
|
2157
3221
|
}
|
|
@@ -2164,9 +3228,9 @@ export function apply(ctx, config = {}) {
|
|
|
2164
3228
|
// force:用户手动点「检查更新」时不走 10 分钟缓存,否则刚更新过会显示旧结果
|
|
2165
3229
|
const found = await fetchLatestVersion(true);
|
|
2166
3230
|
if (found && found.error) {
|
|
2167
|
-
writeJson(res, 200, { ok: false, current:
|
|
3231
|
+
writeJson(res, 200, { ok: false, current: installedVersion(), error: found.error });
|
|
2168
3232
|
} else {
|
|
2169
|
-
writeJson(res, 200, { ok: true, current:
|
|
3233
|
+
writeJson(res, 200, { ok: true, current: installedVersion(), latest: found.latest });
|
|
2170
3234
|
}
|
|
2171
3235
|
return;
|
|
2172
3236
|
}
|
|
@@ -2208,64 +3272,148 @@ export function apply(ctx, config = {}) {
|
|
|
2208
3272
|
let sessionParam = "";
|
|
2209
3273
|
let wantStats = false;
|
|
2210
3274
|
let wantToday = false;
|
|
3275
|
+
let wantDiag = false;
|
|
2211
3276
|
try {
|
|
2212
3277
|
const search = new URL(req.url, "http://localhost").searchParams;
|
|
2213
3278
|
model = search.get("model") ?? "";
|
|
2214
3279
|
sessionParam = search.get("session") ?? "";
|
|
2215
3280
|
wantStats = search.get("stats") === "1";
|
|
2216
3281
|
wantToday = search.get("today") === "1";
|
|
3282
|
+
wantDiag = search.get("diag") === "1";
|
|
2217
3283
|
} catch {
|
|
2218
3284
|
model = "";
|
|
2219
3285
|
sessionParam = "";
|
|
2220
3286
|
wantStats = false;
|
|
2221
3287
|
wantToday = false;
|
|
3288
|
+
wantDiag = false;
|
|
2222
3289
|
}
|
|
2223
3290
|
let stats = null;
|
|
2224
3291
|
let statsError = "";
|
|
2225
3292
|
if (wantStats) {
|
|
2226
3293
|
try {
|
|
2227
3294
|
if (statsCache.value == null || statsCache.expiresAt <= Date.now()) {
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
3295
|
+
// ⚠️ 统计要全量扫最多 80 个会话(每个可能 700ms 同步解压),
|
|
3296
|
+
// 并发而来的第二个请求原来会**再扫一遍**(2026-10-31 审计 M7a)。
|
|
3297
|
+
// 单飞:同一时刻只允许一个 buildUsageStats 在跑,其余共用同一个 Promise。
|
|
3298
|
+
if (statsInFlight === null) {
|
|
3299
|
+
statsInFlight = (async () => {
|
|
3300
|
+
try {
|
|
3301
|
+
const value = await buildUsageStats(httpCtx, effectiveConfig);
|
|
3302
|
+
statsCache = {
|
|
3303
|
+
value,
|
|
3304
|
+
expiresAt: Date.now() + (effectiveConfig.statsCacheMs ?? STATS_CACHE_MS),
|
|
3305
|
+
};
|
|
3306
|
+
return value;
|
|
3307
|
+
} finally {
|
|
3308
|
+
statsInFlight = null;
|
|
3309
|
+
}
|
|
3310
|
+
})();
|
|
3311
|
+
}
|
|
3312
|
+
await statsInFlight;
|
|
2232
3313
|
}
|
|
2233
3314
|
stats = statsCache.value;
|
|
2234
3315
|
} catch (error) {
|
|
2235
3316
|
statsError = error instanceof Error ? error.message : String(error);
|
|
2236
3317
|
}
|
|
2237
3318
|
}
|
|
2238
|
-
//
|
|
2239
|
-
|
|
2240
|
-
if (wantToday) {
|
|
2241
|
-
try {
|
|
2242
|
-
if (todayCache.value == null || todayCache.expiresAt <= Date.now()) {
|
|
2243
|
-
todayCache = { value: await buildTodayCost(httpCtx, effectiveConfig), expiresAt: Date.now() + 60 * 1000 };
|
|
2244
|
-
}
|
|
2245
|
-
today = todayCache.value;
|
|
2246
|
-
} catch {
|
|
2247
|
-
today = null; // 拿不到就不显示,不阻塞余额
|
|
2248
|
-
}
|
|
2249
|
-
}
|
|
3319
|
+
// 今日消耗放在会话处理之后算:?session= 请求手里已经有该会话的完整 events,
|
|
3320
|
+
// 传给 buildTodayCost 就不用再解压一遍 —— 这就是「实时刷新」的零成本来源。
|
|
2250
3321
|
// 按 session id 取会话并分时段汇总 usage(拿不到则返回 null,客户端回退用本地投影)
|
|
2251
3322
|
let usage = null;
|
|
2252
3323
|
// 子代理后代会话的 usage 汇总(与主会话分开)
|
|
2253
3324
|
let subagentUsage = null;
|
|
2254
3325
|
// 从会话事件识别实际使用模型(客户端未传 ?model= 时兜底)
|
|
2255
3326
|
let detectedModel = "";
|
|
3327
|
+
// 今日消耗复用句柄:谁解压过当前会话的 events,就记在这里
|
|
3328
|
+
let todayReuse = null;
|
|
3329
|
+
// 会话是否已经装进 DSH 内存。false = DSH 正在读它的持久化文件,
|
|
3330
|
+
// 这一轮插件**完全不碰磁盘**(不读会话、不算 today、不扫子代理)。
|
|
3331
|
+
let sessionLoaded = false;
|
|
3332
|
+
// 是否**真的**在装载中(sessions 服务存在但该会话没值)。
|
|
3333
|
+
// 注意它与 `!sessionLoaded` 不等价:服务端根本没有 sessions 服务时,
|
|
3334
|
+
// sessionLoaded 恒为 false 但并非"正在加载",此时必须照常读持久化。
|
|
3335
|
+
let sessionLoading = false;
|
|
3336
|
+
// 临时诊断:`?diag=1` 时随响应带回,用来查「为什么 usage 恒为 null」。
|
|
3337
|
+
// 只带类型和布尔,不带 token 内容,开销为零。
|
|
3338
|
+
let sessionDiag = null;
|
|
2256
3339
|
if (sessionParam !== "") {
|
|
2257
3340
|
try {
|
|
2258
3341
|
const sessions = ctx.get("sessions");
|
|
2259
|
-
|
|
3342
|
+
// ⚠️ `sessions.get(id)` 拿不到**不等于**会话正在装载 —— DSH 启动后不会预加载会话,
|
|
3343
|
+
// 只有用户点进某个对话时它才进内存。所以「没值」最常见的原因其实是「这个对话
|
|
3344
|
+
// 还没被点开过」,此时磁盘上的 zstd 完好、根本没人跟插件抢。
|
|
3345
|
+
//
|
|
3346
|
+
// 2026-10-31 实测:重启后 `?session=9877db7c` 恒 null(界面 ¥73.6),
|
|
3347
|
+
// 用户点进该对话后同一请求立刻正常(¥90.9)——差别就是 `get()` 有没有值。
|
|
3348
|
+
// 所以「没值」时**必须**先去磁盘拿真数,不能直接给 null 让客户端估算,
|
|
3349
|
+
// 估算那条路不分 era,跨调价点的会话会被压低近 20%。
|
|
3350
|
+
//
|
|
3351
|
+
// 抢线程的风险靠 `SESSION_REREAD_MIN_MS` 节流兜住:真在装载时最多也只解压一次。
|
|
3352
|
+
const sessionsUsable = sessions !== void 0 && sessions !== null && typeof sessions.get === "function";
|
|
3353
|
+
const session = sessionsUsable ? sessions.get(sessionParam) : void 0;
|
|
3354
|
+
// 仅用于诊断:区分「服务不存在」和「服务在但会话没进内存」。
|
|
3355
|
+
sessionLoading = sessionsUsable && session === void 0;
|
|
3356
|
+
if (wantDiag) {
|
|
3357
|
+
sessionDiag = {
|
|
3358
|
+
sessionsType: typeof sessions,
|
|
3359
|
+
sessionsIsNull: sessions === null,
|
|
3360
|
+
hasGet: typeof sessions?.get === "function",
|
|
3361
|
+
sessionsUsable,
|
|
3362
|
+
sessionUndefined: session === void 0,
|
|
3363
|
+
sessionType: typeof session,
|
|
3364
|
+
sessionLoading,
|
|
3365
|
+
persisted: null,
|
|
3366
|
+
persistedError: null,
|
|
3367
|
+
};
|
|
3368
|
+
}
|
|
3369
|
+
// **会话确实正在装载时,绝不去读它的持久化文件**(2026-10-31:「点击 A 对话
|
|
3370
|
+
// 之后加载没完成,我再点击 B 对话会导致对话都加载很慢」,「让行那几十毫秒不行」)。
|
|
3371
|
+
// `sessions.get()` 有服务但没值 = DSH 正在把那个 zstd 读进内存。此时插件再去解压
|
|
3372
|
+
// 同一个 40 MB 文件,就是两个同步解压抢同一条线程,A 和 B 一起卡 —— 让行 60 ms
|
|
3373
|
+
// 只是把拥堵推后 60 ms,堵本身一点没少,所以那个方案无效。
|
|
3374
|
+
// 正确做法是**根本不参与**:返回 null,客户端用本地投影估算先显示;等 DSH 把这个
|
|
3375
|
+
// 会话装进内存(下一轮 usageKey 变化再来问时 session 就有值了),插件才读它,
|
|
3376
|
+
// 那时 DSH 已经读完,不存在竞争。
|
|
3377
|
+
// 「会话正在装载」只可能是**服务存在但没值**这一种。sessions 服务不存在时
|
|
3378
|
+
// 永远不是加载中 —— 否则在这个环境里 usage 会恒为 null(2026-10-31 实测)。
|
|
3379
|
+
sessionLoaded = session !== void 0;
|
|
2260
3380
|
if (session !== void 0) {
|
|
2261
3381
|
usage = sumSessionUsage(session, effectiveConfig);
|
|
2262
3382
|
detectedModel = detectModel(session);
|
|
3383
|
+
// live 事件本身就是全量,直接给「今日消耗」复用,省掉一次解压
|
|
3384
|
+
const liveEvents = sessionEvents(session);
|
|
3385
|
+
if (Array.isArray(liveEvents) && liveEvents.length > 0) {
|
|
3386
|
+
todayReuse = { sessionId: sessionParam, events: liveEvents };
|
|
3387
|
+
}
|
|
2263
3388
|
}
|
|
2264
|
-
// DSH 2.0.4 起 live session(sessions.get)可能拿不到 events,导致 usage 全 0
|
|
2265
|
-
//
|
|
3389
|
+
// DSH 2.0.4 起 live session(sessions.get)可能拿不到 events,导致 usage 全 0;
|
|
3390
|
+
// 会话还没被点开时更是根本拿不到 live。两种情况都回退持久化读事件,
|
|
3391
|
+
// 避免客户端拿到"空但非 null"的 usage 而把金额显示成 ¥0.0000。
|
|
3392
|
+
// 这里**不再**用 sessionLoading 拦:拿不到 live 最常见的原因是该对话还没被点开,
|
|
3393
|
+
// 磁盘上没人跟插件抢;真在装载时由 SESSION_REREAD_MIN_MS 节流兜住。
|
|
2266
3394
|
if (usage == null || usage.totalTokens === 0) {
|
|
2267
3395
|
try {
|
|
2268
|
-
|
|
3396
|
+
// 4 秒内不重复解压同一个会话:对话进行中 `updatedAt` 每轮都变,
|
|
3397
|
+
// 只看指纹会把 46 MB 的 zstd 每轮重解一遍(800 ms 同步 CPU)。
|
|
3398
|
+
//
|
|
3399
|
+
// ⚠️ **两套 id**:客户端传的是裸 UUID(DSH 的 sessionId prop),
|
|
3400
|
+
// 而 `sessions.get()` 认裸 UUID、持久化目录却叫 `session-<uuid>`。
|
|
3401
|
+
// 直接拿裸 UUID 去 inspect 会报 `session "…" not found`
|
|
3402
|
+
// (2026-10-31 实测,`?session=` 因此恒为 null → 界面走不分 era 的估算)。
|
|
3403
|
+
// 先按带前缀的试,失败再退回原值,两边都能覆盖。
|
|
3404
|
+
const persistedId = sessionParam.startsWith("session-") ? sessionParam : `session-${sessionParam}`;
|
|
3405
|
+
let persisted = await inspectPersistedCached(ctx, persistedId, SESSION_REREAD_MIN_MS);
|
|
3406
|
+
if ((persisted == null || !Array.isArray(persisted.events) || persisted.events.length === 0) && persistedId !== sessionParam) {
|
|
3407
|
+
persisted = await inspectPersistedCached(ctx, sessionParam, SESSION_REREAD_MIN_MS);
|
|
3408
|
+
}
|
|
3409
|
+
if (sessionDiag !== null) {
|
|
3410
|
+
sessionDiag.persisted = {
|
|
3411
|
+
isNull: persisted == null,
|
|
3412
|
+
hasEvents: Array.isArray(persisted?.events),
|
|
3413
|
+
eventCount: Array.isArray(persisted?.events) ? persisted.events.length : 0,
|
|
3414
|
+
meta: persisted?.meta == null ? null : typeof persisted.meta,
|
|
3415
|
+
};
|
|
3416
|
+
}
|
|
2269
3417
|
if (Array.isArray(persisted?.events) && persisted.events.length > 0) {
|
|
2270
3418
|
const persistedUsage = sumEventsUsage(persisted.events, effectiveConfig, detectedModel);
|
|
2271
3419
|
if (persistedUsage.totalTokens > 0) {
|
|
@@ -2275,19 +3423,31 @@ export function apply(ctx, config = {}) {
|
|
|
2275
3423
|
if (switches.length > 0) detectedModel = switches[switches.length - 1].model;
|
|
2276
3424
|
}
|
|
2277
3425
|
}
|
|
3426
|
+
// 刚解压出来的事件也给今日消耗复用(缓存之外的第二份,避免重复读)
|
|
3427
|
+
todayReuse = { sessionId: sessionParam, events: persisted.events };
|
|
2278
3428
|
}
|
|
2279
|
-
} catch {
|
|
3429
|
+
} catch (err) {
|
|
2280
3430
|
// 持久化读取不可用:忽略,走下方统一降级
|
|
3431
|
+
if (sessionDiag !== null) {
|
|
3432
|
+
sessionDiag.persistedError = String(err && err.message ? err.message : err).slice(0, 300);
|
|
3433
|
+
}
|
|
2281
3434
|
}
|
|
2282
3435
|
}
|
|
2283
3436
|
// 仍无任何 token 就返回 null,让客户端回退本地投影估算(而不是当成 0 消耗显示 ¥0.0000)
|
|
2284
3437
|
if (usage != null && usage.totalTokens === 0) usage = null;
|
|
2285
|
-
// 子代理枚举走持久化目录(不依赖 live
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
3438
|
+
// 子代理枚举走持久化目录(不依赖 live 会话),即使父会话不在内存也能拿到。
|
|
3439
|
+
try {
|
|
3440
|
+
const subRes = await sumSubagentUsage(httpCtx, sessionParam, effectiveConfig);
|
|
3441
|
+
subagentUsage = subRes.usage;
|
|
3442
|
+
} catch {
|
|
3443
|
+
subagentUsage = null;
|
|
3444
|
+
}
|
|
3445
|
+
} catch (err) {
|
|
2289
3446
|
usage = null;
|
|
2290
3447
|
subagentUsage = null;
|
|
3448
|
+
if (sessionDiag !== null) {
|
|
3449
|
+
sessionDiag.outerError = String(err && err.stack ? err.stack : err).slice(0, 600);
|
|
3450
|
+
}
|
|
2291
3451
|
}
|
|
2292
3452
|
}
|
|
2293
3453
|
// 客户端优先用自己解析的 model;为空则回退到服务端 detectModel
|
|
@@ -2299,26 +3459,66 @@ export function apply(ctx, config = {}) {
|
|
|
2299
3459
|
if (subagentUsage && Array.isArray(subagentUsage.byModel)) {
|
|
2300
3460
|
subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b, catalogPricing(effectiveConfig, b.model, b.era), peakMultiplierOf(effectiveConfig)) }));
|
|
2301
3461
|
}
|
|
2302
|
-
//
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
3462
|
+
// 今日消耗:三条路径。
|
|
3463
|
+
// ① 带 ?session= 且手里有该会话的实时事件 → **只增量算这一个会话**,
|
|
3464
|
+
// 其余会话的贡献从上一次结果里原样搬运,一笔都不重扫(用户 2026-10-31:
|
|
3465
|
+
// 「本对话消耗只扫对应对话的,其他对话别扫描」)。
|
|
3466
|
+
// ② 增量无从谈起(今天还没算过 / 快照是昨天的)→ 回退完整路径,一天只碰一次。
|
|
3467
|
+
// ③ 只带 ?today=1 → 走快照三层(冷启动秒开)。
|
|
3468
|
+
// partial 表示给的是旧值、后台正在算。
|
|
3469
|
+
let today = null;
|
|
3470
|
+
let todayPartial = false;
|
|
3471
|
+
if (wantToday || sessionParam !== "") {
|
|
3472
|
+
try {
|
|
3473
|
+
// today 也照常算:会话没进内存不代表 DSH 在解锁它(可能只是没被点开),
|
|
3474
|
+
// 此时扫盘不冲突,而漏算今日比慢更难接受。
|
|
3475
|
+
if (sessionParam !== "" && todayReuse !== null && todayReuse.sessionId === sessionParam) {
|
|
3476
|
+
today = await updateTodayCostWithSession(
|
|
3477
|
+
httpCtx, effectiveConfig, todayState, sessionParam, todayReuse.events,
|
|
3478
|
+
);
|
|
3479
|
+
}
|
|
3480
|
+
if (today === null && wantToday) {
|
|
3481
|
+
const resolved = await resolveTodayCost(httpCtx, effectiveConfig, todayState, todayReuse);
|
|
3482
|
+
today = resolved.value;
|
|
3483
|
+
todayPartial = resolved.partial;
|
|
3484
|
+
}
|
|
3485
|
+
} catch {
|
|
3486
|
+
today = null; // 拿不到就不显示,不阻塞余额
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
// ⚠️ 主干尾部必须自己兜住(2026-10-31 审计 M12):
|
|
3490
|
+
// 前面每个分支都有局部 try,唯独这段没有。resolveBalance / catalogPricing /
|
|
3491
|
+
// writeJson 任一抛出(resolveApiKey、网络、res 已销毁)都会让 handler 直接 reject,
|
|
3492
|
+
// 客户端收到零字节响应挂到超时,还可能升级成 unhandledRejection。
|
|
3493
|
+
try {
|
|
3494
|
+
// 余额来源:官方账号优先(官方桌面版内置登录,无需 API Key),回退 DeepSeek API Key。
|
|
3495
|
+
const { balance, error: balanceError } = await resolveBalance(ctx, effectiveConfig);
|
|
3496
|
+
if (balance === null && !wantStats) {
|
|
3497
|
+
writeJson(res, 200, { ok: false, error: balanceError });
|
|
3498
|
+
return;
|
|
3499
|
+
}
|
|
3500
|
+
writeJson(res, 200, {
|
|
3501
|
+
ok: true,
|
|
3502
|
+
balance,
|
|
3503
|
+
balanceError: balance === null ? balanceError : null,
|
|
3504
|
+
// 传给客户端的是**目录价**(不含峰谷倍率):客户端自己按高峰时段乘倍率,
|
|
3505
|
+
// 若这里给 resolvePricing(已乘倍率)客户端会再乘一次,金额虚高约 3 倍。
|
|
3506
|
+
pricing: catalogPricing(effectiveConfig, effectiveModel),
|
|
3507
|
+
model: effectiveModel,
|
|
3508
|
+
usage,
|
|
3509
|
+
subagentUsage,
|
|
3510
|
+
stats,
|
|
3511
|
+
statsError,
|
|
3512
|
+
today,
|
|
3513
|
+
todayPartial,
|
|
3514
|
+
...(wantDiag ? { diag: sessionDiag } : {}),
|
|
3515
|
+
});
|
|
3516
|
+
} catch (error) {
|
|
3517
|
+
// headersSent 之后不能再写头,只能放弃这一次响应
|
|
3518
|
+
if (!res.headersSent) {
|
|
3519
|
+
writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
3520
|
+
}
|
|
2307
3521
|
}
|
|
2308
|
-
writeJson(res, 200, {
|
|
2309
|
-
ok: true,
|
|
2310
|
-
balance,
|
|
2311
|
-
balanceError: balance === null ? balanceError : null,
|
|
2312
|
-
// 传给客户端的是**目录价**(不含峰谷倍率):客户端自己按高峰时段乘倍率,
|
|
2313
|
-
// 若这里给 resolvePricing(已乘倍率)客户端会再乘一次,金额虚高约 3 倍。
|
|
2314
|
-
pricing: catalogPricing(effectiveConfig, effectiveModel),
|
|
2315
|
-
model: effectiveModel,
|
|
2316
|
-
usage,
|
|
2317
|
-
subagentUsage,
|
|
2318
|
-
stats,
|
|
2319
|
-
statsError,
|
|
2320
|
-
today,
|
|
2321
|
-
});
|
|
2322
3522
|
},
|
|
2323
3523
|
});
|
|
2324
3524
|
} catch (error) {
|
|
@@ -2337,6 +3537,14 @@ export function apply(ctx, config = {}) {
|
|
|
2337
3537
|
for (const d of disposers) {
|
|
2338
3538
|
try { d(); } catch { /* ignore */ }
|
|
2339
3539
|
}
|
|
3540
|
+
// 退出/热重载时把今天最后一次算出的值落盘(用户要求:退出时保留缓存,下次直接读)。
|
|
3541
|
+
// 不重算、不刷新 at —— at 记录的是「算出来的时刻」,改成写盘时刻会让下次启动
|
|
3542
|
+
// 误判成「刚算过」而不去重算,快照就永远停在这里。
|
|
3543
|
+
// writeJsonAtomic 是先写 .tmp 再 rename,中途被打断也不会留下半个文件。
|
|
3544
|
+
const last = todayState.cache.value;
|
|
3545
|
+
if (last != null && typeof last === "object" && todayState.snapshotFile !== "") {
|
|
3546
|
+
runQuietly("保存今日消耗快照", () => writeTodaySnapshot(todayState.snapshotFile, last));
|
|
3547
|
+
}
|
|
2340
3548
|
};
|
|
2341
3549
|
if (typeof ctx.on === "function") ctx.on("dispose", release);
|
|
2342
3550
|
}
|