dsh-desktop-statusbar 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +132 -108
- package/README.md +130 -108
- package/docs/pricing.en.png +0 -0
- package/docs/pricing.png +0 -0
- package/docs/settings.en.png +0 -0
- package/docs/settings.png +0 -0
- package/lib/balance-api.js +60 -47
- package/lib/client.js +1604 -216
- package/lib/index.js +917 -303
- package/lib/release-age.js +509 -0
- package/lib/self-update.js +368 -0
- package/package.json +8 -2
- package/tools/plugin-dir.cjs +112 -0
- package/tools/shot.cjs +365 -0
- package/tools/sync-from-plugin.cjs +49 -0
- package/tools/sync-to-plugin.cjs +71 -0
- package/tools/test.cjs +99 -0
package/lib/index.js
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import { z } from 'zod'
|
|
41
41
|
import { adapterFor, adaptersFor, pluginRoutesIn } from './balance-api.js'
|
|
42
42
|
import { holidayHtmlToText, parseHolidayNotice } from './holiday.js'
|
|
43
|
+
import { isUpdating, lastUpdateResult, resolveSelfDir, selfUpdateOnce } from './self-update.js'
|
|
43
44
|
import { appendFileSync, closeSync, copyFileSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
44
45
|
import { basename, dirname, join } from 'node:path'
|
|
45
46
|
import { homedir } from 'node:os'
|
|
@@ -142,10 +143,29 @@ const sessionCapacityProjection = {
|
|
|
142
143
|
|
|
143
144
|
/* -------------------------------------------------------- sessionProgress */
|
|
144
145
|
|
|
145
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* 一次**成功**压缩落在哪一轮哪一步。
|
|
148
|
+
* turn 允许 null:用户手动 /compact 是「轮间压缩」(standalone),真实日志里 turn 就是 null
|
|
149
|
+
*(见 ~/.dsh/sessions 的 cmd-* 事务);自动压缩才带当前轮号。
|
|
150
|
+
* step 取压缩前最后一步;取不到(轮间压缩 / 轮首)给 null。
|
|
151
|
+
* afterTurn:只有轮间压缩才有值 —— 压缩发生时最后已知的轮号,客户端据此显示「第 N 轮后」,
|
|
152
|
+
* 不用再把 turn=null 显示成孤零零的横杠;自动压缩有自己的轮号,afterTurn 恒为 null。
|
|
153
|
+
* manual:用户手动(斜杠命令)触发;自动压缩没有 sourceCommandId。
|
|
154
|
+
* 成功/失败**只**看 compaction/end 的 data.error:带 error 是失败,不带就是成功。
|
|
155
|
+
* 不要去数 compaction/summary —— summary 缺失不能反过来否决一次没有 error 的 end
|
|
156
|
+
*(宿主 invariant 会把「成功 end 没有 summary」当成日志损坏拒绝,那是宿主的事,投影不承担)。
|
|
157
|
+
*/
|
|
146
158
|
const compactMark = z.object({
|
|
147
|
-
turn: z.number(),
|
|
159
|
+
turn: z.number().nullable(),
|
|
148
160
|
step: z.number().nullable(),
|
|
161
|
+
afterTurn: z.number().nullable(),
|
|
162
|
+
manual: z.boolean(),
|
|
163
|
+
}).strict()
|
|
164
|
+
|
|
165
|
+
/** 一次技能注入:名字 + 是不是用户手动(斜杠命令)触发。重复注入也各记一条,客户端自己聚合。 */
|
|
166
|
+
const skillMark = z.object({
|
|
167
|
+
name: z.string(),
|
|
168
|
+
manual: z.boolean(),
|
|
149
169
|
}).strict()
|
|
150
170
|
|
|
151
171
|
const sessionProgressSchema = z.object({
|
|
@@ -154,13 +174,14 @@ const sessionProgressSchema = z.object({
|
|
|
154
174
|
toolCalls: z.number(),
|
|
155
175
|
/* 工具名 → 调用次数:气泡里「工具调用」那一行点开要逐个列出来 */
|
|
156
176
|
toolCounts: z.record(z.string(), z.number()),
|
|
157
|
-
|
|
177
|
+
/* 每次注入一条,按发生顺序;manual 区分斜杠命令注入与 skill 工具调用 */
|
|
178
|
+
skills: z.array(skillMark),
|
|
158
179
|
/* 注入进会话的全局提示词文件名(~/.dsh/AGENTS.md → 'AGENTS.md');没注入给 null */
|
|
159
180
|
globalInstruction: z.string().nullable(),
|
|
160
181
|
compactCount: z.number(),
|
|
161
182
|
compactTurn: z.number().nullable(),
|
|
162
183
|
compactStep: z.number().nullable(),
|
|
163
|
-
/*
|
|
184
|
+
/* 只列成功压缩,按发生顺序:气泡里「上下文压缩」那一行点开逐条列;失败的不进来 */
|
|
164
185
|
compacts: z.array(compactMark),
|
|
165
186
|
}).strict()
|
|
166
187
|
|
|
@@ -218,12 +239,18 @@ function globalInstructionOf(data) {
|
|
|
218
239
|
* 所以在 host 侧按事件折一份:
|
|
219
240
|
* step/start → 本轮走到第几步(轮号一变就归零,step 是轮内序号不是会话累计)
|
|
220
241
|
* tool/call → 整个会话的工具调用次数 + 每个工具名各自的次数;
|
|
221
|
-
* 工具名是 skill
|
|
242
|
+
* 工具名是 skill 的那种再把技能名(manual:false)按注入顺序记进 skills
|
|
222
243
|
* user/message → 全局提示词(agent-instructions 注入的那种)的文件名;
|
|
223
|
-
* 斜杠命令注入的技能(skill-invocation
|
|
224
|
-
* compaction/
|
|
225
|
-
*
|
|
226
|
-
*
|
|
244
|
+
* 斜杠命令注入的技能(skill-invocation)以 manual:true 补进 skills
|
|
245
|
+
* compaction/end → 只认**成功**的压缩:判定只看 end 的 data.error(有 error=失败,无 error=成功)。
|
|
246
|
+
* 宿主语义(app.asar 里 dsh-compaction 的 invariant):成功时 end 不带 error,
|
|
247
|
+
* 且日志里会有一条 compaction/summary;失败(模型 400/429 等)只发 end + error、
|
|
248
|
+
* 没有 summary。**但不数 summary**:没 summary 的 end 也不能反过来判失败,
|
|
249
|
+
* 否则就成了「压缩后没有 summary 就算失败」这种没验证过的启发式。
|
|
250
|
+
* 轮号取 end.data.turn(用户手动 /compact 是轮间压缩,turn=null),
|
|
251
|
+
* 步号取压缩前最后一条同轮 step/start;手动触发看 end.data.sourceCommandId
|
|
252
|
+
* (start/summary/end 三个事件都带同一个命令 id,自动压缩没有)。
|
|
253
|
+
* 这几种事件在真实日志里都齐(62 个会话:17 次压缩事务,其中成功 4、失败 13,手动 11)。
|
|
227
254
|
*/
|
|
228
255
|
const sessionProgressProjection = {
|
|
229
256
|
key: 'desktopStatusbarProgress',
|
|
@@ -251,7 +278,8 @@ const sessionProgressProjection = {
|
|
|
251
278
|
}
|
|
252
279
|
if (event.type === 'user/message') {
|
|
253
280
|
const skill = skillInvocationOf(event.data)
|
|
254
|
-
|
|
281
|
+
/* 用户输入 /技能名 走的就是这条:算手动触发 */
|
|
282
|
+
if (skill !== null) return { ...state, skills: state.skills.concat([{ name: skill, manual: true }]) }
|
|
255
283
|
const name = globalInstructionOf(event.data)
|
|
256
284
|
if (name === null || state.globalInstruction === name) return state
|
|
257
285
|
return { ...state, globalInstruction: name }
|
|
@@ -268,29 +296,41 @@ const sessionProgressProjection = {
|
|
|
268
296
|
...state,
|
|
269
297
|
toolCalls: state.toolCalls + 1,
|
|
270
298
|
toolCounts: counts,
|
|
271
|
-
/* 技能有两种注入来源:调 skill
|
|
272
|
-
|
|
273
|
-
skills: skill === null ? state.skills : state.skills.concat([skill]),
|
|
299
|
+
/* 技能有两种注入来源:调 skill 工具(这里,自动/模型触发)与斜杠命令(user/message 分支,
|
|
300
|
+
用户手动触发);名字按注入顺序排,重复注入也照记(数量 = 数组长度) */
|
|
301
|
+
skills: skill === null ? state.skills : state.skills.concat([{ name: skill, manual: false }]),
|
|
274
302
|
}
|
|
275
303
|
}
|
|
276
|
-
if (event.type === 'compaction/
|
|
304
|
+
if (event.type === 'compaction/end') {
|
|
277
305
|
const data = event.data === undefined || event.data === null ? {} : event.data
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
306
|
+
/* 失败压缩不发 summary、end 带 error:不计次数、不落档,客户端只显示成功的那些 */
|
|
307
|
+
if (data.error !== undefined && data.error !== null) return state
|
|
308
|
+
/* 轮间压缩(用户手动 /compact)turn=null;自动压缩用事件自己的轮号 */
|
|
309
|
+
const hasTurn = typeof data.turn === 'number'
|
|
310
|
+
const turn = hasTurn ? data.turn : null
|
|
311
|
+
/* 轮间压缩没有轮号,但客户端要显示「第 N 轮后」:N 取压缩发生时最后已知的轮号。
|
|
312
|
+
state.turn 初值是 0(还没跑过任何轮),这时没有「第 N 轮后」可讲,给 null。 */
|
|
313
|
+
const afterTurn = hasTurn ? null : (typeof state.turn === 'number' && state.turn > 0 ? state.turn : null)
|
|
314
|
+
/* 步号取压缩前最后一条同轮 step/start:压缩事务中间不会插 step/start(真实日志里
|
|
315
|
+
成功事务中间只有一条 compact-checkpoint 的 user/message),end 到达时 turnSteps 仍是压缩前步数 */
|
|
316
|
+
const step = turn !== null && turn === state.turn && state.turnSteps > 0 ? state.turnSteps : null
|
|
317
|
+
/* 手动 /compact 的三个压缩事件都带 sourceCommandId(命令 id),自动压缩没有 */
|
|
318
|
+
const manual = data.sourceCommandId !== undefined && data.sourceCommandId !== null
|
|
281
319
|
return {
|
|
282
320
|
...state,
|
|
283
321
|
compactCount: state.compactCount + 1,
|
|
284
322
|
compactTurn: turn,
|
|
285
323
|
compactStep: step,
|
|
286
|
-
compacts: state.compacts.concat([{ turn: turn, step: step }]),
|
|
324
|
+
compacts: state.compacts.concat([{ turn: turn, step: step, afterTurn: afterTurn, manual: manual }]),
|
|
287
325
|
}
|
|
288
326
|
}
|
|
289
327
|
return state
|
|
290
328
|
},
|
|
291
329
|
wire: { viewSchema: sessionProgressSchema, view: (state) => state },
|
|
292
|
-
//
|
|
293
|
-
|
|
330
|
+
// 字段变了(skills 变成 {name, manual}、compacts 加 manual 且只记成功、压缩 turn 可为 null),提升版本重建旧状态
|
|
331
|
+
// 7 → 8:compacts 新增 afterTurn(轮间/手动压缩显示「第 N 轮后」,替代原来的横杠)。
|
|
332
|
+
// 旧缓存里每条压缩记录没有这个字段,必须重放才能补出来。
|
|
333
|
+
stateVersion: 8,
|
|
294
334
|
}
|
|
295
335
|
|
|
296
336
|
/* ------------------------------------------------------------ sessionUsage */
|
|
@@ -327,8 +367,11 @@ function findUsageInStream(stream) {
|
|
|
327
367
|
}
|
|
328
368
|
|
|
329
369
|
function usageOf(event) {
|
|
330
|
-
if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined
|
|
370
|
+
if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt' && event.type !== 'compaction/summary') return undefined
|
|
331
371
|
const data = event.data === null || event.data === undefined ? {} : event.data
|
|
372
|
+
/* compaction/summary 是压缩引擎自己发起的一次摘要调用:data.usage 就是它的用量(没有 stream)。
|
|
373
|
+
真实会话里这 4 笔摘要都是带用量的,漏掉会让「本轮/总计费用」都比实际少。 */
|
|
374
|
+
if (event.type === 'compaction/summary') return data.usage
|
|
332
375
|
let usage = event.type === 'assistant/message' ? data.usage : undefined
|
|
333
376
|
const fromStream = findUsageInStream(data.stream)
|
|
334
377
|
if (fromStream !== undefined) usage = fromStream
|
|
@@ -404,9 +447,79 @@ const MAX_CALLS = 4000
|
|
|
404
447
|
const appendCalls = (calls, more) =>
|
|
405
448
|
calls.length + more.length <= MAX_CALLS ? calls.concat(more) : calls.slice(calls.length + more.length - MAX_CALLS).concat(more)
|
|
406
449
|
|
|
450
|
+
/**
|
|
451
|
+
* 被 MAX_CALLS 截掉的调用,按「模型 + 峰谷档」累计的 token。
|
|
452
|
+
*
|
|
453
|
+
* 为什么需要它:客户端算「总计费用」是**逐条调用**计价(每条按自己发生的时刻定峰谷),
|
|
454
|
+
* 而 calls 只保留最近 MAX_CALLS 条;超出部分原先直接丢弃,费用就少算了 ——
|
|
455
|
+
* 实测 5000 次调用少 20%、2 万次少 80%,而「总计用量」用的是官方全量数据,
|
|
456
|
+
* 于是同一个界面上用量是全量、费用是残缺的,且没有任何提示。
|
|
457
|
+
*
|
|
458
|
+
* 这里把丢掉的那些按档累计,客户端用档位价补算即可 —— 档位在 host 侧按北京时间判定,
|
|
459
|
+
* 与逐条计价同一套规则,所以两段相加与「一条不丢地算」结果一致。
|
|
460
|
+
*/
|
|
461
|
+
const overflowTier = z.object({
|
|
462
|
+
peak: usageBucket,
|
|
463
|
+
offPeak: usageBucket,
|
|
464
|
+
}).strict()
|
|
465
|
+
|
|
466
|
+
/** 往 overflow 里加一笔;返回新的 overflow(不改原对象)。 */
|
|
467
|
+
function addOverflow(overflow, model, parts, peak) {
|
|
468
|
+
const zero = () => ({ input: 0, cacheRead: 0, cacheWrite: 0, output: 0 })
|
|
469
|
+
const prev = overflow[model] ?? { peak: zero(), offPeak: zero() }
|
|
470
|
+
const tierName = peak === true ? 'peak' : 'offPeak'
|
|
471
|
+
const tier = prev[tierName]
|
|
472
|
+
return {
|
|
473
|
+
...overflow,
|
|
474
|
+
[model]: {
|
|
475
|
+
...prev,
|
|
476
|
+
[tierName]: {
|
|
477
|
+
input: tier.input + parts.input,
|
|
478
|
+
cacheRead: tier.cacheRead + parts.cacheRead,
|
|
479
|
+
cacheWrite: tier.cacheWrite + parts.cacheWrite,
|
|
480
|
+
output: tier.output + parts.output,
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* 把一笔调用并进总计:models 按模型累加、calls 追加,
|
|
488
|
+
* 超过 MAX_CALLS 被挤掉的最老几条按「模型 + 峰谷档」补进 overflow。
|
|
489
|
+
* 正常 assistant 调用与 compaction/summary 两处共用一份实现 —— 两条路各写一遍迟早对不齐口径。
|
|
490
|
+
*/
|
|
491
|
+
function addCallTotals(base, one) {
|
|
492
|
+
const calls = Array.isArray(base.calls) ? base.calls : []
|
|
493
|
+
const prevModel = base.models[one.model]
|
|
494
|
+
const bucket = {
|
|
495
|
+
input: (prevModel === undefined ? 0 : prevModel.input) + one.input,
|
|
496
|
+
cacheRead: (prevModel === undefined ? 0 : prevModel.cacheRead) + one.cacheRead,
|
|
497
|
+
cacheWrite: (prevModel === undefined ? 0 : prevModel.cacheWrite) + one.cacheWrite,
|
|
498
|
+
output: (prevModel === undefined ? 0 : prevModel.output) + one.output,
|
|
499
|
+
}
|
|
500
|
+
/* 追加后若超过 MAX_CALLS,最前面那几条会被丢掉。丢掉的是**真实用过的 token**,
|
|
501
|
+
必须把它们按档累计进 overflow,否则客户端逐条计价时会少算(见 addOverflow 说明)。
|
|
502
|
+
用合并前后的长度差算出丢了几条 —— 它们一定是最老的,也就是 calls 队首那几条。 */
|
|
503
|
+
const merged = appendCalls(calls, [one])
|
|
504
|
+
const droppedCount = calls.length + 1 - merged.length
|
|
505
|
+
let overflow = typeof base.overflow === 'object' && base.overflow !== null ? base.overflow : {}
|
|
506
|
+
for (let i = 0; i < droppedCount; i += 1) {
|
|
507
|
+
const gone = calls[i]
|
|
508
|
+
if (gone === undefined) break
|
|
509
|
+
overflow = addOverflow(overflow, gone.model, gone, isPeakTime(gone.at))
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
calls: merged,
|
|
513
|
+
overflow,
|
|
514
|
+
models: { ...base.models, [one.model]: bucket },
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
407
518
|
const sessionUsageSchema = z.object({
|
|
408
519
|
models: z.record(z.string(), usageBucket),
|
|
409
520
|
calls: z.array(usageCall),
|
|
521
|
+
/* 被 MAX_CALLS 截掉的那部分(见 addOverflow 的说明)。老缓存没有这个键,所以可选。 */
|
|
522
|
+
overflow: z.record(z.string(), overflowTier).optional(),
|
|
410
523
|
turn: z.number().nullable(),
|
|
411
524
|
current: lastUsage.nullable(),
|
|
412
525
|
last: lastUsage.nullable(),
|
|
@@ -414,12 +527,27 @@ const sessionUsageSchema = z.object({
|
|
|
414
527
|
可空且可选 —— 万一某条路径没带上这两个键,也不至于让整个 state 过不了校验。 */
|
|
415
528
|
routeModel: z.string().nullable().optional(),
|
|
416
529
|
routeProvider: z.string().nullable().optional(),
|
|
530
|
+
/* compactionId → 该次压缩的轮号与开始时刻。compaction/summary 没有 turn,只能靠 start 记下的映射
|
|
531
|
+
才能判断这笔摘要属于哪一轮;at 用来圈定「防双计」的时间窗。老缓存没有这个键,所以可选。 */
|
|
532
|
+
compactions: z.record(z.string(), z.object({
|
|
533
|
+
turn: z.number().nullable(),
|
|
534
|
+
at: z.number(),
|
|
535
|
+
}).strict()).optional(),
|
|
536
|
+
/* 已经计过费的 compactionId:同一笔摘要只计一次。老缓存没有这个键,所以可选。 */
|
|
537
|
+
countedCompactions: z.array(z.string()).optional(),
|
|
538
|
+
/* 压缩摘要**累计**的 token 四段(只含摘要、不含普通调用)。
|
|
539
|
+
为什么需要它:「总计用量」那一栏的数据来自官方 tokenUsage 投影,而官方只折
|
|
540
|
+
assistant 事件、**不含压缩摘要**;「总计费用」是我们自己逐条算的、**含摘要**。
|
|
541
|
+
同一面板里两个数字口径不一致(费用算进去了、用量没算)。用户拍板「官方投影继续用,
|
|
542
|
+
只把摘要这部分额外加上」—— 这份累计就是那个「额外加上」的部分。
|
|
543
|
+
老缓存没有这个键,所以可选。 */
|
|
544
|
+
summaryParts: usageBucket.optional(),
|
|
417
545
|
}).strict()
|
|
418
546
|
|
|
419
547
|
const sessionUsageProjection = {
|
|
420
548
|
key: 'desktopStatusbarUsage',
|
|
421
549
|
stateSchema: sessionUsageSchema,
|
|
422
|
-
init: () => ({ models: {}, calls: [], current: null, last: null, turn: null, routeModel: null, routeProvider: null }),
|
|
550
|
+
init: () => ({ models: {}, calls: [], overflow: {}, current: null, last: null, turn: null, routeModel: null, routeProvider: null, compactions: {}, countedCompactions: [], summaryParts: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 } }),
|
|
423
551
|
apply: (state, event) => {
|
|
424
552
|
const calls = Array.isArray(state.calls) ? state.calls : []
|
|
425
553
|
const turnOf = (event) => {
|
|
@@ -433,6 +561,20 @@ const sessionUsageProjection = {
|
|
|
433
561
|
if (state.current === null) return { ...state, turn: started }
|
|
434
562
|
return { ...state, calls, current: null, last: state.current, turn: started }
|
|
435
563
|
}
|
|
564
|
+
/* 压缩事务开始时先记下 compactionId → 轮号:compaction/summary 事件本身没有 turn,
|
|
565
|
+
只能靠这条映射判断这笔摘要费用算不算「本轮」。turn 允许 null(轮间/手动压缩)。 */
|
|
566
|
+
if (event.type === 'compaction/start') {
|
|
567
|
+
const data = event.data === null || event.data === undefined ? {} : event.data
|
|
568
|
+
if (typeof data.compactionId !== 'string' || data.compactionId.length === 0) return state
|
|
569
|
+
const compactions = typeof state.compactions === 'object' && state.compactions !== null ? state.compactions : {}
|
|
570
|
+
return {
|
|
571
|
+
...state,
|
|
572
|
+
compactions: {
|
|
573
|
+
...compactions,
|
|
574
|
+
[data.compactionId]: { turn: typeof data.turn === 'number' ? data.turn : null, at: event.time },
|
|
575
|
+
},
|
|
576
|
+
}
|
|
577
|
+
}
|
|
436
578
|
// 先记下"这次请求用的是哪个模型":没有 message 的事件(assistant/attempt)靠它补型号
|
|
437
579
|
const routed = routedModelOf(event)
|
|
438
580
|
const base = routed === null ? state : {
|
|
@@ -443,23 +585,24 @@ const sessionUsageProjection = {
|
|
|
443
585
|
const usage = usageOf(event)
|
|
444
586
|
if (usage === undefined || usage === null) return base
|
|
445
587
|
// 型号两条路:常规事件从 message.source 取;没有 message 的用上面记下的最近一次请求
|
|
446
|
-
const
|
|
588
|
+
const data = event.data === null || event.data === undefined ? {} : event.data
|
|
589
|
+
const message = data.message
|
|
447
590
|
const source = message === null || message === undefined ? null : message.source
|
|
448
591
|
const fromMessage = source !== null && source.kind === 'model' && typeof source.model === 'string'
|
|
449
|
-
|
|
450
|
-
|
|
592
|
+
/* 摘要调用是压缩引擎自己发起的(没有 message),型号只认 summary 自己的 data.model;
|
|
593
|
+
不能退回 routeModel —— 那是用户上一轮对话的模型,摘要往往跑在另一个模型上。 */
|
|
594
|
+
const isCompactionSummary = event.type === 'compaction/summary'
|
|
595
|
+
const model = isCompactionSummary
|
|
596
|
+
? (typeof data.model === 'string' && data.model.length > 0 ? data.model : '')
|
|
597
|
+
: (fromMessage ? source.model : (typeof base.routeModel === 'string' ? base.routeModel : ''))
|
|
598
|
+
// 拿不到型号:放弃这一笔,好过抛错把整条投影(以及同一次驱动的其它投影)带停
|
|
451
599
|
if (model.length === 0) return base
|
|
452
|
-
const provider =
|
|
453
|
-
?
|
|
454
|
-
: (typeof
|
|
455
|
-
|
|
600
|
+
const provider = isCompactionSummary
|
|
601
|
+
? (typeof data.provider === 'string' ? data.provider : '')
|
|
602
|
+
: (fromMessage && typeof source.provider === 'string'
|
|
603
|
+
? source.provider
|
|
604
|
+
: (typeof base.routeProvider === 'string' ? base.routeProvider : ''))
|
|
456
605
|
const parts = usageParts(usage)
|
|
457
|
-
const bucket = {
|
|
458
|
-
input: (currentModel === undefined ? 0 : currentModel.input) + parts.input,
|
|
459
|
-
cacheRead: (currentModel === undefined ? 0 : currentModel.cacheRead) + parts.cacheRead,
|
|
460
|
-
cacheWrite: (currentModel === undefined ? 0 : currentModel.cacheWrite) + parts.cacheWrite,
|
|
461
|
-
output: (currentModel === undefined ? 0 : currentModel.output) + parts.output,
|
|
462
|
-
}
|
|
463
606
|
const one = {
|
|
464
607
|
at: event.time,
|
|
465
608
|
model,
|
|
@@ -468,13 +611,102 @@ const sessionUsageProjection = {
|
|
|
468
611
|
cacheWrite: parts.cacheWrite,
|
|
469
612
|
output: parts.output,
|
|
470
613
|
}
|
|
614
|
+
/* 摘要事件走单独的归轮路径:它没有 turn,绝不能套用下面"轮次变化就重开 current"的逻辑,
|
|
615
|
+
否则上一轮/轮间压缩的摘要会并进当前轮,或者把 current/last 打乱。 */
|
|
616
|
+
if (isCompactionSummary) {
|
|
617
|
+
/* 摘要的去重身份:正常情况下用 upstream 给的 compactionId;
|
|
618
|
+
拿不到时退回 `event.seq`(**与本文件 usageEventKey 的既有做法一致**,见
|
|
619
|
+
`'compact:' + (compactionId ?? event.seq)`)。为什么不直接跳过空 id:
|
|
620
|
+
那会把一笔真实用量彻底丢掉(费用和用量都少算),而重复计费比漏算更糟但
|
|
621
|
+
两者都有代价 —— 用 seq 兜底能做到"同一事件重复到达只计一次",
|
|
622
|
+
因为同一事件的 seq 相同、不同事件必然不同。 */
|
|
623
|
+
const rawId = typeof data.compactionId === 'string' ? data.compactionId : ''
|
|
624
|
+
const compactionId = rawId.length > 0
|
|
625
|
+
? rawId
|
|
626
|
+
: (typeof event.seq === 'number' ? 'seq:' + event.seq : '')
|
|
627
|
+
const counted = Array.isArray(base.countedCompactions) ? base.countedCompactions : []
|
|
628
|
+
// 同一笔压缩只计一次:重放或重复事件不能让费用翻倍
|
|
629
|
+
if (compactionId.length > 0 && counted.includes(compactionId)) return base
|
|
630
|
+
const compactions = typeof base.compactions === 'object' && base.compactions !== null ? base.compactions : {}
|
|
631
|
+
/* 只有 upstream 真的给了 id 时才有 start 映射可查(空 id 的 start 在更早的分支就被丢弃了) */
|
|
632
|
+
const opened = rawId.length > 0 ? compactions[rawId] : undefined
|
|
633
|
+
/* 防旧宿主双计:旧版本可能把同一笔用量既落成 assistant/message 又落成 summary。
|
|
634
|
+
窗口 = compaction/start 到本 summary 之间;四条用量分量完全相同才算同一笔
|
|
635
|
+
(token 数精确到个位,不同请求撞上的概率可以忽略)。 */
|
|
636
|
+
const duplicate = opened !== undefined && calls.some((call) =>
|
|
637
|
+
call.at >= opened.at && call.at <= event.time
|
|
638
|
+
&& call.input === one.input && call.cacheRead === one.cacheRead
|
|
639
|
+
&& call.cacheWrite === one.cacheWrite && call.output === one.output)
|
|
640
|
+
if (duplicate) return base
|
|
641
|
+
const totals = addCallTotals(base, one)
|
|
642
|
+
/* 摘要用量单独累计一份(与按模型的 models 那份分开):
|
|
643
|
+
客户端「总计用量」用官方 tokenUsage 投影(不含摘要),需要这一份把摘要补回去,
|
|
644
|
+
才能让「总计用量」与「总计费用」口径一致。老 state 没有这个键,按 0 起算。 */
|
|
645
|
+
const prevSummary = base.summaryParts !== undefined && base.summaryParts !== null
|
|
646
|
+
&& typeof base.summaryParts === 'object' ? base.summaryParts : null
|
|
647
|
+
const summaryParts = {
|
|
648
|
+
input: (prevSummary === null ? 0 : Number(prevSummary.input) || 0) + one.input,
|
|
649
|
+
cacheRead: (prevSummary === null ? 0 : Number(prevSummary.cacheRead) || 0) + one.cacheRead,
|
|
650
|
+
cacheWrite: (prevSummary === null ? 0 : Number(prevSummary.cacheWrite) || 0) + one.cacheWrite,
|
|
651
|
+
output: (prevSummary === null ? 0 : Number(prevSummary.output) || 0) + one.output,
|
|
652
|
+
}
|
|
653
|
+
/* 轮归属三条规则:
|
|
654
|
+
① 映射到数字 turn 且等于当前 turn → 同时进总计与本轮;
|
|
655
|
+
② 映射到数字 turn 但不等当前轮 → 只进总计;
|
|
656
|
+
③ 映射为 null(**轮间压缩**,手动 / 自动都是 null)→ 只进总计,
|
|
657
|
+
**并把「本轮」刷新成这一笔**;
|
|
658
|
+
没有映射(日志截断/旧版本)→ 兜底归当前轮,不抛错。
|
|
659
|
+
|
|
660
|
+
为什么叫「轮间压缩」而不是「手动压缩」:宿主语义上两者是同一件事 ——
|
|
661
|
+
手动 `/compact` 必须等这一轮回复结束、用户才能输入,所以它**必然**是轮间压缩,
|
|
662
|
+
`compaction/start` 的 turn 就是 null(见 tests/verify-host.js 里「用户手动 /compact:
|
|
663
|
+
轮间压缩(start/summary/end 都带 sourceCommandId,turn=null)」那条夹具)。
|
|
664
|
+
|
|
665
|
+
反过来说:**自动压缩**(对话进行中达到阈值时触发)的 turn 是当前轮号,
|
|
666
|
+
所以它根本不会走到这个分支 —— 它由上面的 `belongsToCurrent` 命中、
|
|
667
|
+
**累加进当前轮**(那笔费用属于本轮,用户明确要求过)。
|
|
668
|
+
|
|
669
|
+
用户对这一条的原话:「手动压缩的时候,上一轮对话明显已经结束了,此时本轮显示的
|
|
670
|
+
数字依旧是上一轮对话产生的费用,而不是本次手动压缩产生的费用。」 */
|
|
671
|
+
const mappedTurn = opened === undefined ? undefined : opened.turn
|
|
672
|
+
const isInterTurnCompaction = mappedTurn === null
|
|
673
|
+
const belongsToCurrent = mappedTurn === undefined
|
|
674
|
+
? base.current !== null
|
|
675
|
+
: (mappedTurn !== null && stateTurn !== null && mappedTurn === stateTurn)
|
|
676
|
+
let current = base.current
|
|
677
|
+
if (belongsToCurrent || isInterTurnCompaction) {
|
|
678
|
+
/* belongsToCurrent:并进当前轮(累加)—— 自动压缩走这条;
|
|
679
|
+
isInterTurnCompaction:**替换**(不累加)—— 手动 /compact 走这条,
|
|
680
|
+
它代表"上一轮已结束,现在这笔是本轮"。 */
|
|
681
|
+
const prev = belongsToCurrent && base.current !== null && Array.isArray(base.current.calls)
|
|
682
|
+
? base.current
|
|
683
|
+
: null
|
|
684
|
+
current = {
|
|
685
|
+
provider,
|
|
686
|
+
model,
|
|
687
|
+
time: event.time,
|
|
688
|
+
input: (prev === null ? 0 : prev.input) + one.input,
|
|
689
|
+
cacheRead: (prev === null ? 0 : prev.cacheRead) + one.cacheRead,
|
|
690
|
+
cacheWrite: (prev === null ? 0 : prev.cacheWrite) + one.cacheWrite,
|
|
691
|
+
output: (prev === null ? 0 : prev.output) + one.output,
|
|
692
|
+
calls: (prev === null ? [] : prev.calls).concat([one]),
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return {
|
|
696
|
+
...base,
|
|
697
|
+
...totals,
|
|
698
|
+
current,
|
|
699
|
+
summaryParts,
|
|
700
|
+
countedCompactions: compactionId.length > 0 ? counted.concat([compactionId]) : counted,
|
|
701
|
+
}
|
|
702
|
+
}
|
|
471
703
|
const turn = turnOf(event)
|
|
472
704
|
// 轮次变化就重开本轮汇总:不能假设一定有 turn/start
|
|
473
705
|
const sameTurn = base.current !== null && Array.isArray(base.current.calls)
|
|
474
706
|
&& (turn === null || stateTurn === null || turn === stateTurn)
|
|
475
707
|
const prev = sameTurn ? base.current : null
|
|
476
708
|
const callsForTurn = prev === null ? [] : prev.calls
|
|
477
|
-
const
|
|
709
|
+
const turnSummary = {
|
|
478
710
|
provider,
|
|
479
711
|
model,
|
|
480
712
|
time: event.time,
|
|
@@ -483,15 +715,14 @@ const sessionUsageProjection = {
|
|
|
483
715
|
cacheWrite: (prev === null ? 0 : prev.cacheWrite) + one.cacheWrite,
|
|
484
716
|
output: (prev === null ? 0 : prev.output) + one.output,
|
|
485
717
|
}
|
|
486
|
-
const current = { ...
|
|
718
|
+
const current = { ...turnSummary, calls: callsForTurn.concat([one]) }
|
|
487
719
|
return {
|
|
488
720
|
...base,
|
|
721
|
+
...addCallTotals(base, one),
|
|
489
722
|
turn: turn === null ? stateTurn : turn,
|
|
490
|
-
calls: appendCalls(calls, [one]),
|
|
491
723
|
current,
|
|
492
724
|
// 轮次变化时把上一轮结算进 last
|
|
493
725
|
last: prev === null && base.current !== null ? base.current : base.last,
|
|
494
|
-
models: { ...base.models, [model]: bucket },
|
|
495
726
|
}
|
|
496
727
|
},
|
|
497
728
|
wire: {
|
|
@@ -503,7 +734,16 @@ const sessionUsageProjection = {
|
|
|
503
734
|
// 界面上的「本轮」因此与「总计」对不上,提升版本强制从事件日志重放。
|
|
504
735
|
// 4 → 5:旧缓存里缺「会话起标题」那几笔 —— attempt 事件没有 message,取不到型号时
|
|
505
736
|
// 整条投影抛错、该会话的账从此停摆。提升版本强制重放,把这些笔补回账本。
|
|
506
|
-
|
|
737
|
+
// 5 → 6:新增 overflow(被 MAX_CALLS 截掉那段的按档累计)。旧缓存里这个字段不存在,
|
|
738
|
+
// 长会话的费用会一直少算截掉的部分,必须重放才能补出来。
|
|
739
|
+
// 6 → 7:compaction/summary 的摘要调用(真实会话里有 4 笔带用量)以前整笔漏掉,
|
|
740
|
+
// 「本轮/总计费用」都比实际少。新增 compactions/countedCompactions 两个字段做归轮与防双计,
|
|
741
|
+
// 旧缓存里没有它们,必须重放才会把这 4 笔补进账本。
|
|
742
|
+
// 7 → 8:新增 summaryParts(压缩摘要的 token 累计),供客户端把它加到「总计用量」上 ——
|
|
743
|
+
// 官方 tokenUsage 投影不含摘要,导致「总计费用」含摘要而「总计用量」不含,同一面板两个口径。
|
|
744
|
+
// 旧缓存没有这个字段,不重放的话历史会话的「总计用量」会永远少那几笔摘要。
|
|
745
|
+
// 同一次 bump 也覆盖「轮间/手动压缩刷新本轮」的新语义(current 的重建规则变了)。
|
|
746
|
+
stateVersion: 8,
|
|
507
747
|
}
|
|
508
748
|
|
|
509
749
|
/* -------------------------------------------------------- sessionTimeRange */
|
|
@@ -806,26 +1046,82 @@ function sendJson(res, status, body) {
|
|
|
806
1046
|
res.end(text)
|
|
807
1047
|
}
|
|
808
1048
|
|
|
809
|
-
|
|
1049
|
+
/* 写请求的来源校验:只拦「带跨站 Origin 的 POST」。
|
|
1050
|
+
插件把 API 挂在 127.0.0.1 的 webServer 上,浏览器里任意网页都能对本机发简单请求
|
|
1051
|
+
(`Content-Type: text/plain` 的 POST 不触发 CORS 预检,页面读不到响应也照样**把请求送达**)。
|
|
1052
|
+
实测:伪造的价格库被完整接受,settleUnpriced 随后按攻击者单价结算进 `days[].cost`,
|
|
1053
|
+
而 cost 一旦记下就不再改动 —— 污染不可逆;同一面还能触发 /api/self-update 真跑 pnpm 安装。
|
|
1054
|
+
按用户确认的最小方案只做这一条判定,**不**引入 CORS 预检、**不**影响 GET:
|
|
1055
|
+
· 没有 Origin(命令行 / curl / 宿主内部)→ 放行;
|
|
1056
|
+
· Origin 的 host 与请求的 host 相同 → 同源,放行;
|
|
1057
|
+
· 带跨站 Origin(含 opaque 的 `null`)的 POST → 由调用方回 403。
|
|
1058
|
+
取不到 Host 头时判不了,保守放行(本机 webServer 一定带 Host,这一支只防意外形状)。 */
|
|
1059
|
+
function isCrossSiteWrite(req) {
|
|
1060
|
+
if (req === null || req === undefined || req.method !== 'POST') return false
|
|
1061
|
+
const headers = req.headers
|
|
1062
|
+
if (headers === null || headers === undefined || typeof headers !== 'object') return false
|
|
1063
|
+
const raw = typeof headers.origin === 'string' ? headers.origin.trim() : ''
|
|
1064
|
+
if (raw.length === 0) return false
|
|
1065
|
+
const target = typeof headers.host === 'string' ? headers.host.trim().toLowerCase() : ''
|
|
1066
|
+
if (target.length === 0) return false
|
|
1067
|
+
/* opaque 来源(沙箱 iframe、file:// 页面)与谁都不是同源 */
|
|
1068
|
+
if (raw.toLowerCase() === 'null') return true
|
|
1069
|
+
const schemeAt = raw.indexOf('://')
|
|
1070
|
+
const rest = schemeAt === -1 ? raw : raw.slice(schemeAt + 3)
|
|
1071
|
+
const authority = rest.split('/')[0].split('?')[0].toLowerCase()
|
|
1072
|
+
const at = authority.indexOf('@')
|
|
1073
|
+
const hostOnly = at === -1 ? authority : authority.slice(at + 1)
|
|
1074
|
+
if (hostOnly.length === 0) return true
|
|
1075
|
+
return hostOnly !== target
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/* 请求体上限。原先统一 4KB,但**价格库会随模型数增长**:实测一条模型约 186 字节,
|
|
1079
|
+
24 个模型就超过 4096 → 请求被直接断开,客户端 fetch 抛错又被 `.catch(() => {})` 吞掉,
|
|
1080
|
+
于是「设置页改的单价只对底栏生效、活跃总览恒用旧价」而且毫无提示(实测缺陷)。
|
|
1081
|
+
这里的接口只服务本机、只收本插件自己的小 JSON,放宽到 256KB 足够且没有实际风险。 */
|
|
1082
|
+
const JSON_BODY_LIMIT = 256 * 1024
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* 读一小段 JSON 请求体。
|
|
1086
|
+
*
|
|
1087
|
+
* 收的是**原始 Buffer**,最后一次性解码:以前写 `raw += chunk`,每个 chunk 各自跑一次
|
|
1088
|
+
* `toString('utf8')`,一个汉字的 3 个字节被切在两个 chunk 里时两半都变成 U+FFFD ——
|
|
1089
|
+
* 实测中文模型名被污染成 `���定义模型`,接口照样回 `ok:true`,之后查价永远匹配不上。
|
|
1090
|
+
* 上限也按**字节数**判(`raw.length` 是字符数,一个汉字只算 1,拿它当字节数会让
|
|
1091
|
+
* 256KB 的上限实际放到 768KB)。
|
|
1092
|
+
*
|
|
1093
|
+
* @returns {Promise<object|null>} 解析失败或超限返回 null
|
|
1094
|
+
*/
|
|
810
1095
|
function readJsonBody(req) {
|
|
811
1096
|
return new Promise((resolve) => {
|
|
812
|
-
|
|
1097
|
+
const chunks = []
|
|
1098
|
+
let size = 0
|
|
813
1099
|
let settled = false
|
|
1100
|
+
let overLimit = false
|
|
814
1101
|
const finish = (value) => {
|
|
815
1102
|
if (settled) return
|
|
816
1103
|
settled = true
|
|
817
1104
|
resolve(value)
|
|
818
1105
|
}
|
|
819
1106
|
req.on('data', (chunk) => {
|
|
820
|
-
|
|
821
|
-
|
|
1107
|
+
if (overLimit === true) return
|
|
1108
|
+
/* 非 Buffer 的 chunk(测试里的字符串)也按同一语义折成字节数 */
|
|
1109
|
+
const buf = Buffer.isBuffer(chunk) === true ? chunk : Buffer.from(String(chunk), 'utf8')
|
|
1110
|
+
size += buf.length
|
|
1111
|
+
if (size > JSON_BODY_LIMIT) {
|
|
1112
|
+
overLimit = true
|
|
1113
|
+
/* 先记下超限,再断开。**不要**在 destroy 之后写响应(socket 已销毁,写不进去)。 */
|
|
1114
|
+
chunks.length = 0
|
|
822
1115
|
req.destroy()
|
|
823
1116
|
finish(null)
|
|
1117
|
+
return
|
|
824
1118
|
}
|
|
1119
|
+
chunks.push(buf)
|
|
825
1120
|
})
|
|
826
1121
|
req.on('end', () => {
|
|
1122
|
+
if (overLimit === true) return
|
|
827
1123
|
try {
|
|
828
|
-
const parsed = JSON.parse(
|
|
1124
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
829
1125
|
finish(parsed !== null && typeof parsed === 'object' ? parsed : null)
|
|
830
1126
|
} catch (error) {
|
|
831
1127
|
finish(null)
|
|
@@ -1218,6 +1514,10 @@ async function queryLocalAdapter(ctx, adapter, now, provider) {
|
|
|
1218
1514
|
isCredits: parsed.isCredits === true,
|
|
1219
1515
|
isPercent: parsed.isPercent === true,
|
|
1220
1516
|
parts: parsed.parts,
|
|
1517
|
+
/* 积分套餐明细(workbuddy 新插件的 credits.packages 投影,见 balance-api.js 的 parse)。
|
|
1518
|
+
字段名固定 rewards;**只有适配器真的给了才带上** —— 老插件/非积分平台没有这个键,
|
|
1519
|
+
客户端据此走原来的纯总数显示。失败态(上面几个分支)一律不带。 */
|
|
1520
|
+
...(parsed.rewards === undefined ? {} : { rewards: parsed.rewards }),
|
|
1221
1521
|
toppedUp: partAmount(parsed.parts, 'toppedUp'),
|
|
1222
1522
|
granted: partAmount(parsed.parts, 'granted'),
|
|
1223
1523
|
spent: partAmount(parsed.parts, 'spent'),
|
|
@@ -1363,41 +1663,55 @@ async function queryBalance(ctx, force, provider, model) {
|
|
|
1363
1663
|
*
|
|
1364
1664
|
* 为什么必须落盘:会话一旦被删除,日志就没了,而周总计必须把已经发生的消耗留住。
|
|
1365
1665
|
* 台账按「事件」去重(同一 turn/step 只算一次、assistant/message 优先),于是重复扫描不会
|
|
1366
|
-
*
|
|
1666
|
+
* 重复计数,删掉会话也不会让已记下的数字缩水;窗口是**滚动的「近 7 天」**
|
|
1667
|
+
* (见 recentStartMs:今天往前推 6 天,不再对齐自然周)。
|
|
1367
1668
|
* 全程只读 token 数字,不读正文,台账里也只有数字、没有对话内容。
|
|
1368
1669
|
*/
|
|
1369
1670
|
|
|
1370
1671
|
/**
|
|
1371
|
-
*
|
|
1672
|
+
* 柱状窗口的起点:**本机时区**今天 00:00 往前推 6 天,也就是「近 7 天」。
|
|
1372
1673
|
* 这里用的是滚动窗口,不再对齐自然周 —— 用户看到的是最近七天,而不是本周一到现在。
|
|
1674
|
+
*
|
|
1675
|
+
* 日期一律按**装机者所在时区**算(见 localDateKey 的说明),不再固定 UTC+8。
|
|
1373
1676
|
*/
|
|
1374
1677
|
function recentStartMs(now) {
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1678
|
+
const d = new Date(now)
|
|
1679
|
+
/* 按**日历字段**回退 6 天,不要写 `本地今天 00:00 - 6*24h`:
|
|
1680
|
+
有夏令时的时区里,这 6 天中有一天只有 23 小时(春季拨快),
|
|
1681
|
+
减固定毫秒会落到 7 天前的 23:00,被 localDateKey 记成前一天 ——
|
|
1682
|
+
于是窗口变成「7 天前…昨天」,「今天」那根柱子消失、当天用量落在窗口外。
|
|
1683
|
+
实测纽约/伦敦/悉尼/圣地亚哥各错 18 天/3 年(UTC+8 的机器上永远看不到)。 */
|
|
1684
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate() - 6).getTime()
|
|
1379
1685
|
}
|
|
1380
1686
|
|
|
1381
|
-
/** 本月 1 号 00:00
|
|
1687
|
+
/** 本月 1 号 00:00(本机时区)的毫秒时间戳。 */
|
|
1382
1688
|
function monthStartMs(now) {
|
|
1383
|
-
const
|
|
1384
|
-
|
|
1385
|
-
return Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), 1) - BEIJING
|
|
1689
|
+
const d = new Date(now)
|
|
1690
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).getTime()
|
|
1386
1691
|
}
|
|
1387
1692
|
|
|
1388
1693
|
const padTwo = (value) => (value < 10 ? '0' + String(value) : String(value))
|
|
1389
1694
|
|
|
1390
|
-
/** 当年 1 月 1 日 00:00
|
|
1695
|
+
/** 当年 1 月 1 日 00:00(本机时区)的毫秒时间戳 —— 12 个月的统计要从这里扫起。 */
|
|
1391
1696
|
function yearStartMs(now) {
|
|
1392
|
-
const
|
|
1393
|
-
|
|
1394
|
-
return Date.UTC(shifted.getUTCFullYear(), 0, 1) - BEIJING
|
|
1697
|
+
const d = new Date(now)
|
|
1698
|
+
return new Date(d.getFullYear(), 0, 1).getTime()
|
|
1395
1699
|
}
|
|
1396
1700
|
|
|
1397
|
-
/**
|
|
1701
|
+
/**
|
|
1702
|
+
* 日期键 'YYYY-MM-DD' —— 按**装机者所在时区**取,与客户端同一口径。
|
|
1703
|
+
*
|
|
1704
|
+
* 曾经固定用北京时间(UTC+8):那是 DeepSeek 峰谷计价与账单的口径,但**日期归属**
|
|
1705
|
+
* 不该跟着它走 —— 使用者看的是自己日历上的「今天/昨天」,UTC+8 装机时两地一致,
|
|
1706
|
+
* 非 UTC+8 的机器上活跃总览会整体错一天(今天那格高亮成昨天、未来/过去的灰阶也偏)。
|
|
1707
|
+
*
|
|
1708
|
+
* 注意区分两件事,别把这里改回去:
|
|
1709
|
+
* · **日期归属**(这条函数)= 装机者时区,纯粹是给人看的日历。
|
|
1710
|
+
* · **峰谷计价**(isPeakTime)= 北京时间,那是 DeepSeek 的官方计费规则,与装机位置无关。
|
|
1711
|
+
*/
|
|
1398
1712
|
function localDateKey(ms) {
|
|
1399
|
-
const date = new Date(ms
|
|
1400
|
-
return date.
|
|
1713
|
+
const date = new Date(ms)
|
|
1714
|
+
return date.getFullYear() + '-' + padTwo(date.getMonth() + 1) + '-' + padTwo(date.getDate())
|
|
1401
1715
|
}
|
|
1402
1716
|
|
|
1403
1717
|
/* 官方参考价(CNY / 每百万 tokens),与客户端价格表同源、与官方定价页一致。
|
|
@@ -1607,7 +1921,7 @@ let extraHolidays = null
|
|
|
1607
1921
|
/** 落盘过的法定节假日(带缓存);文件不存在或读不动就当没有这回事。 */
|
|
1608
1922
|
function extraHolidayDays() {
|
|
1609
1923
|
if (extraHolidays !== null) return extraHolidays
|
|
1610
|
-
const parsed =
|
|
1924
|
+
const parsed = readJsonObjectFile(holidaysPath())
|
|
1611
1925
|
extraHolidays = parsed !== null && Array.isArray(parsed.days)
|
|
1612
1926
|
? parsed.days.filter((one) => typeof one === 'string')
|
|
1613
1927
|
: []
|
|
@@ -1711,6 +2025,10 @@ function sanitizePriceBook(raw) {
|
|
|
1711
2025
|
output: keep(value.output),
|
|
1712
2026
|
}
|
|
1713
2027
|
: null)
|
|
2028
|
+
/* 普通对象字面量,但写入一律走 defineBookEntry:键名是 `__proto__` 时
|
|
2029
|
+
`out[name] = {...}` 是**设置原型**而不是建自有属性 —— 那条目会从 Object.keys
|
|
2030
|
+
里消失(静默丢条目)、返回对象的原型还被换掉(把不可信输入挂成原型,是原型污染)。
|
|
2031
|
+
用 defineProperty 建可枚举的自有属性,行为与其它键名一致。 */
|
|
1714
2032
|
const out = {}
|
|
1715
2033
|
const names = Object.keys(raw)
|
|
1716
2034
|
for (let i = 0; i < names.length; i += 1) {
|
|
@@ -1724,7 +2042,7 @@ function sanitizePriceBook(raw) {
|
|
|
1724
2042
|
/* 保留「是否峰谷计价」:没勾的条目是**全天一个价**,计价时两档都要取 peak,
|
|
1725
2043
|
否则空闲时段会挑到那个没填的档、把费用算成 0(实测缺陷)。缺省按两档。 */
|
|
1726
2044
|
const code = entry.currency === 'USD' ? 'USD' : (entry.currency === 'CNY' ? 'CNY' : null)
|
|
1727
|
-
out
|
|
2045
|
+
defineBookEntry(out, names[i], {
|
|
1728
2046
|
peak: peak !== null ? peak : offPeak,
|
|
1729
2047
|
offPeak: offPeak !== null ? offPeak : peak,
|
|
1730
2048
|
tiered: entry.tiered !== false,
|
|
@@ -1734,7 +2052,7 @@ function sanitizePriceBook(raw) {
|
|
|
1734
2052
|
host 不替它决定(否则往返一次多出一个字段,深比较基准会被悄悄改掉)。
|
|
1735
2053
|
注意:必须保留,不保留就等于清洗时把币种丢了。 */
|
|
1736
2054
|
...(code === null ? {} : { currency: code }),
|
|
1737
|
-
}
|
|
2055
|
+
})
|
|
1738
2056
|
}
|
|
1739
2057
|
return Object.keys(out).length === 0 ? null : out
|
|
1740
2058
|
}
|
|
@@ -1746,16 +2064,46 @@ function sanitizePriceBook(raw) {
|
|
|
1746
2064
|
*
|
|
1747
2065
|
* 它以前的作用是"不一致就把台账整本丢丢掉重扫",那会连已经算好的费用一起改掉,
|
|
1748
2066
|
* 会话日志一旦被删,历史数字就再也算不回来。现在它只读不写、只用来触发补算。
|
|
2067
|
+
*
|
|
2068
|
+
* 格式(v4 起):`fp4:` + JSON 数组。以前是 `名字:四项:四项|名字:…` 的拼接,两个坑:
|
|
2069
|
+
* · 键名里带 `:`(Ollama 的 `qwen2.5:14b`)或 `|`(`a|b`)会把分隔符吃掉,
|
|
2070
|
+
* `parseFingerprint` 要么整条丢、要么还原出**错的键名** → 回扫把"当年已按自建价
|
|
2071
|
+
* 算过钱"的事件误判成"当时缺价",再补一遍 → 同一笔用量计两次、当天 cost 翻倍;
|
|
2072
|
+
* · 只序列化四项数字,`tiered`(峰谷计价开关)与 `currency` 改了却算不出变化 →
|
|
2073
|
+
* 用户把「峰谷计价」改成「全天同价」或换币种时 `changed=false`,欠账一直挂着不结算。
|
|
2074
|
+
* JSON 天然带转义,键名不再可能撞上分隔符;`tiered` / `currency` 一起进指纹。
|
|
2075
|
+
*
|
|
2076
|
+
* 币种按**缺省即 CNY** 归一:host 的 `sanitizePriceBook` 不补默认币种、客户端会落成显式
|
|
2077
|
+
* `CNY`,两侧必须算出同一串(见 tests/verify-price-parity.js 的差分断言)。
|
|
2078
|
+
* 两档都空的条目(没定价)不进指纹 —— 它们不参与计价,也不参与"当时有没有价"的判定。
|
|
1749
2079
|
*/
|
|
2080
|
+
const FINGERPRINT_MARK = 'fp4:'
|
|
2081
|
+
|
|
1750
2082
|
function fingerprintOf(book) {
|
|
1751
|
-
const
|
|
1752
|
-
const
|
|
2083
|
+
const source = book === null || book === undefined ? {} : book
|
|
2084
|
+
const names = Object.keys(source).sort()
|
|
2085
|
+
const rows = []
|
|
2086
|
+
const tierOf = (t) => (t === null || t === undefined
|
|
2087
|
+
? null
|
|
2088
|
+
: [numOrZero(t.input), numOrZero(t.cacheRead), numOrZero(t.cacheWrite), numOrZero(t.output)])
|
|
1753
2089
|
for (let i = 0; i < names.length; i += 1) {
|
|
1754
|
-
const one =
|
|
1755
|
-
const
|
|
1756
|
-
|
|
2090
|
+
const one = source[names[i]]
|
|
2091
|
+
const rawPeak = tierOf(one === null || one === undefined ? null : one.peak)
|
|
2092
|
+
const rawOffPeak = tierOf(one === null || one === undefined ? null : one.offPeak)
|
|
2093
|
+
if (rawPeak === null && rawOffPeak === null) continue
|
|
2094
|
+
/* 两档互相补全,与 `sanitizePriceBook` 同一语义:只给一档的条目(缺省那档为 undefined)
|
|
2095
|
+
在库里、在 parse 回来的库里都表现为"两档同价"。不补全的话 `parseFingerprint`
|
|
2096
|
+
会把缺档还原成有值的那档,`fingerprintOf(parse(fp)) !== fp` —— 往返不成立,
|
|
2097
|
+
而 verify-price-parity 正是靠"指纹往返回到原串"守住格式的。 */
|
|
2098
|
+
rows.push([
|
|
2099
|
+
names[i],
|
|
2100
|
+
rawPeak !== null ? rawPeak : rawOffPeak,
|
|
2101
|
+
rawOffPeak !== null ? rawOffPeak : rawPeak,
|
|
2102
|
+
one !== null && one !== undefined && one.tiered === false ? 0 : 1,
|
|
2103
|
+
one !== null && one !== undefined && one.currency === 'USD' ? 'USD' : 'CNY',
|
|
2104
|
+
])
|
|
1757
2105
|
}
|
|
1758
|
-
return
|
|
2106
|
+
return FINGERPRINT_MARK + JSON.stringify(rows)
|
|
1759
2107
|
}
|
|
1760
2108
|
|
|
1761
2109
|
function priceFingerprint() {
|
|
@@ -1764,10 +2112,16 @@ function priceFingerprint() {
|
|
|
1764
2112
|
|
|
1765
2113
|
/**
|
|
1766
2114
|
* 指纹 → 价格库(fingerprintOf 的逆运算),迁移回扫用它还原"当时的价格库"。
|
|
1767
|
-
*
|
|
2115
|
+
* 单条形状对不上(不是数组、数字不合法)就跳过那一条;整体为空的返回 null。
|
|
2116
|
+
*
|
|
2117
|
+
* 两种格式都认:
|
|
2118
|
+
* · `fp4:` 前缀 → 新格式(JSON,可往返);
|
|
2119
|
+
* · 别的(老台账里落盘的旧格式)→ 按老的 `:` / `|` 拼接解析,只为让升级前的
|
|
2120
|
+
* 台账还能还原出当年的价目表(解析不出来就返回 null,调用方回退内置表,与旧行为一致)。
|
|
1768
2121
|
*/
|
|
1769
2122
|
function parseFingerprint(text) {
|
|
1770
2123
|
if (typeof text !== 'string' || text.length === 0) return null
|
|
2124
|
+
if (text.startsWith(FINGERPRINT_MARK) === true) return parseFingerprintRows(text.slice(FINGERPRINT_MARK.length))
|
|
1771
2125
|
const out = {}
|
|
1772
2126
|
const segments = text.split('|')
|
|
1773
2127
|
const tier = (raw) => {
|
|
@@ -1790,11 +2144,56 @@ function parseFingerprint(text) {
|
|
|
1790
2144
|
const peak = tier(fields[1])
|
|
1791
2145
|
const offPeak = tier(fields[2])
|
|
1792
2146
|
if (peak === null && offPeak === null) continue
|
|
1793
|
-
out
|
|
2147
|
+
defineBookEntry(out, fields[0], { peak: peak !== null ? peak : offPeak, offPeak: offPeak !== null ? offPeak : peak })
|
|
2148
|
+
}
|
|
2149
|
+
return Object.keys(out).length === 0 ? null : out
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
/** 把 `fp4:` 后面的 JSON 数组还原成价格库。 */
|
|
2153
|
+
function parseFingerprintRows(text) {
|
|
2154
|
+
let rows = null
|
|
2155
|
+
try {
|
|
2156
|
+
rows = JSON.parse(text)
|
|
2157
|
+
} catch (error) {
|
|
2158
|
+
return null
|
|
2159
|
+
}
|
|
2160
|
+
if (Array.isArray(rows) !== true) return null
|
|
2161
|
+
const out = {}
|
|
2162
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
2163
|
+
const row = rows[i]
|
|
2164
|
+
if (Array.isArray(row) !== true || row.length < 5) continue
|
|
2165
|
+
const name = row[0]
|
|
2166
|
+
if (typeof name !== 'string' || name.length === 0) continue
|
|
2167
|
+
const tier = (raw) => {
|
|
2168
|
+
if (Array.isArray(raw) !== true || raw.length !== 4) return null
|
|
2169
|
+
const one = {
|
|
2170
|
+
input: Number(raw[0]),
|
|
2171
|
+
cacheRead: Number(raw[1]),
|
|
2172
|
+
cacheWrite: Number(raw[2]),
|
|
2173
|
+
output: Number(raw[3]),
|
|
2174
|
+
}
|
|
2175
|
+
if (Number.isFinite(one.input) !== true || Number.isFinite(one.cacheRead) !== true
|
|
2176
|
+
|| Number.isFinite(one.cacheWrite) !== true || Number.isFinite(one.output) !== true) return null
|
|
2177
|
+
return one
|
|
2178
|
+
}
|
|
2179
|
+
const peak = tier(row[1])
|
|
2180
|
+
const offPeak = tier(row[2])
|
|
2181
|
+
if (peak === null && offPeak === null) continue
|
|
2182
|
+
defineBookEntry(out, name, {
|
|
2183
|
+
peak: peak !== null ? peak : offPeak,
|
|
2184
|
+
offPeak: offPeak !== null ? offPeak : peak,
|
|
2185
|
+
tiered: row[3] !== 0,
|
|
2186
|
+
currency: row[4] === 'USD' ? 'USD' : 'CNY',
|
|
2187
|
+
})
|
|
1794
2188
|
}
|
|
1795
2189
|
return Object.keys(out).length === 0 ? null : out
|
|
1796
2190
|
}
|
|
1797
2191
|
|
|
2192
|
+
/** 往价格库里写一条:`__proto__` 这类键名必须建成自有属性(见 sanitizePriceBook 处的说明)。 */
|
|
2193
|
+
function defineBookEntry(target, name, value) {
|
|
2194
|
+
Object.defineProperty(target, name, { value: value, enumerable: true, writable: true, configurable: true })
|
|
2195
|
+
}
|
|
2196
|
+
|
|
1798
2197
|
/** 一笔用量按单价折成人民币;模型没有价格表就返回 0(不计入花费)。 */
|
|
1799
2198
|
function tokenCost(model, at, parts) {
|
|
1800
2199
|
const entry = priceEntryFor(model)
|
|
@@ -2131,23 +2530,48 @@ async function subagentIndexSnapshot(files) {
|
|
|
2131
2530
|
}
|
|
2132
2531
|
|
|
2133
2532
|
/**
|
|
2134
|
-
* 一段父会话日志里的"本轮起点"
|
|
2135
|
-
* `
|
|
2533
|
+
* 一段父会话日志里的"本轮起点"。三条时间取 max,规则见下:
|
|
2534
|
+
* ① 最后一条 `turn/start`;
|
|
2535
|
+
* ② 最后一条**真实用户输入**的 `user/message` —— 但**只有**它落在
|
|
2536
|
+
* 「最后一条 `turn/end` 之后、且最后一条 `turn/end` 晚于最后一条 `turn/start`」
|
|
2537
|
+
* 这一段里,才算"新一轮已经开始、而它的 `turn/start` 还没落盘"的候选;
|
|
2538
|
+
* ③ 日志里完全没有 `turn/start` 时,退回用最后一条真实用户消息(老兜底)。
|
|
2539
|
+
* 都找不到就返回 null(调用方据此判 sinceSource='none')。
|
|
2540
|
+
*
|
|
2541
|
+
* **为什么只认 `source.kind === 'user'` 的 `user/message`**(2026-10-17 修复):
|
|
2542
|
+
* 同一轮里 DSH 会往父会话日志注入很多**不是用户输入**的 `user/message` ——
|
|
2543
|
+
* 子代理结算(`subagent-settled`)、队友消息转发(`team-message` / `agent-message`)、
|
|
2544
|
+
* 后台任务完成(`tool-jobs`)、技能目录刷新(`skill-catalog`)、审批回答
|
|
2545
|
+
* (`user-approval`)、运行时上下文(`runtime-context`)……它们**都不改轮次**。
|
|
2546
|
+
* 旧实现把所有 `user/message` 都算进来,于是"最后一条"常常是这些通知:本轮窗口
|
|
2547
|
+
* 起点被推到**子代理结算那一刻**,队友在本轮已经花掉的钱整段被排除(用户实测
|
|
2548
|
+
* 「所有子代理运行完之后『本轮』卡住不涨」;本机真实日志复现:本轮应 ¥34.62 →
|
|
2549
|
+
* 实际 ¥10.58)。只认真实用户输入才能既保住"新一轮已开始"的兜底,又不被注入
|
|
2550
|
+
* 通知推进起点。
|
|
2551
|
+
*
|
|
2552
|
+
* **为什么真人中途插话(steering)也不能算新轮起点**(2026-10-17 补边界):
|
|
2553
|
+
* DSH 支持同一轮里用户再发消息(本轮日志里就有:唯一一条 `turn/start` 之后 7 分钟
|
|
2554
|
+
* 又来了一条 `source.kind='user'` 的消息,`turn/end` 还没出现)。这种消息只改变
|
|
2555
|
+
* 这一轮的上下文、**不开新轮**;若把它当起点,「本轮」会从插话那一刻重新算,把插话
|
|
2556
|
+
* 之前子代理在本轮花掉的钱再次抹掉(实测:本轮 ¥4.06,而总计 ¥5.03,残差 ¥0.97)。
|
|
2557
|
+
* 所以判据是"**轮已经关了**"才认用户消息:看 `turn/end` 是否晚于最后一条
|
|
2558
|
+
* `turn/start`(关轮 = 上一轮已结束,此时新 USER 消息代表新轮已起、`turn/start`
|
|
2559
|
+
* 还没落盘);turn 还开着就只认 `turn/start`。
|
|
2136
2560
|
*
|
|
2137
|
-
* 为什么不是"有 turn/start 就优先用它":用户消息比最后一条 turn/start 还新,说明
|
|
2138
|
-
* **新一轮已经开始、而它的 `turn/start` 还没落盘**;此时沿用旧的 turn/start 会把
|
|
2139
|
-
* **上一轮**的队友用量算进"本轮",方向正是用户不允许的"偏大"。取较新的那个时间
|
|
2140
|
-
* 只会把窗口往回收(宁少不多),符合"本轮只能小不能大"。
|
|
2141
2561
|
* 取**最大值**而不是"文件里最后一行":正常日志是追加写的,两者一致;万一有乱序行,
|
|
2142
2562
|
* 取最大值更稳。
|
|
2143
2563
|
*/
|
|
2144
2564
|
function lastTurnStartOfText(text) {
|
|
2145
2565
|
if (typeof text !== 'string' || text.length === 0) return null
|
|
2146
|
-
let
|
|
2566
|
+
let lastTurnStart = null
|
|
2567
|
+
let lastTurnEnd = null
|
|
2568
|
+
let lastUser = null
|
|
2147
2569
|
const lines = text.split('\n')
|
|
2148
2570
|
for (let i = 0; i < lines.length; i += 1) {
|
|
2149
2571
|
const line = lines[i]
|
|
2150
|
-
|
|
2572
|
+
/* 便宜的预筛:只解析可能相关的三种事件 */
|
|
2573
|
+
if (line.indexOf('"turn/start"') < 0 && line.indexOf('"turn/end"') < 0
|
|
2574
|
+
&& line.indexOf('"user/message"') < 0) continue
|
|
2151
2575
|
let event
|
|
2152
2576
|
try {
|
|
2153
2577
|
event = JSON.parse(line)
|
|
@@ -2155,10 +2579,29 @@ function lastTurnStartOfText(text) {
|
|
|
2155
2579
|
continue
|
|
2156
2580
|
}
|
|
2157
2581
|
if (event === null || typeof event !== 'object' || typeof event.time !== 'number') continue
|
|
2158
|
-
if (event.type
|
|
2159
|
-
|
|
2582
|
+
if (event.type === 'turn/start') {
|
|
2583
|
+
if (lastTurnStart === null || event.time > lastTurnStart) lastTurnStart = event.time
|
|
2584
|
+
continue
|
|
2585
|
+
}
|
|
2586
|
+
if (event.type === 'turn/end') {
|
|
2587
|
+
if (lastTurnEnd === null || event.time > lastTurnEnd) lastTurnEnd = event.time
|
|
2588
|
+
continue
|
|
2589
|
+
}
|
|
2590
|
+
if (event.type !== 'user/message') continue
|
|
2591
|
+
/* 只认真实用户输入:source 缺失(老日志/坏行)也宁可不当成新轮 —— 少算一侧
|
|
2592
|
+
的代价是多算上一轮末的队友用量(偏大),比"整轮花费被抹掉"可接受。 */
|
|
2593
|
+
const data = event.data !== null && event.data !== undefined && typeof event.data === 'object' ? event.data : {}
|
|
2594
|
+
const source = data.source !== null && data.source !== undefined && typeof data.source === 'object' ? data.source : null
|
|
2595
|
+
if (source === null || source.kind !== 'user') continue
|
|
2596
|
+
if (lastUser === null || event.time > lastUser) lastUser = event.time
|
|
2160
2597
|
}
|
|
2161
|
-
|
|
2598
|
+
/* 完全没有 turn/start:老日志/异常日志的兜底 —— 用最后一条真实用户消息(原来就是这样) */
|
|
2599
|
+
if (lastTurnStart === null) return lastUser
|
|
2600
|
+
/* 用户消息只有在"上一轮已结束(turn/end 晚于 turn/start)、且消息晚于那个 turn/end"
|
|
2601
|
+
时才是"新轮已开始、turn/start 还没落盘"的证据;turn 还开着就只是本轮插话,不算 */
|
|
2602
|
+
const turnClosed = lastTurnEnd !== null && lastTurnEnd > lastTurnStart
|
|
2603
|
+
const userStartedNewTurn = turnClosed === true && lastUser !== null && lastUser > lastTurnEnd
|
|
2604
|
+
return userStartedNewTurn === true && lastUser > lastTurnStart ? lastUser : lastTurnStart
|
|
2162
2605
|
}
|
|
2163
2606
|
|
|
2164
2607
|
/**
|
|
@@ -2399,9 +2842,11 @@ function subagentFileUsage(one, fromMs, fingerprint) {
|
|
|
2399
2842
|
* (只收紧、不放大;`since=0` 是显式整会话,不收敛);真收敛了会带
|
|
2400
2843
|
* `sinceClamped: true`(`sinceSource` 仍是 `client`);
|
|
2401
2844
|
* · `host`:没传 → host 读**父会话自己**的日志推一个起点(最后一条 `turn/start`,
|
|
2402
|
-
*
|
|
2403
|
-
* `turn/start`
|
|
2404
|
-
*
|
|
2845
|
+
* 但和最后一条**真实用户输入**的 `user/message` 取较新的那个 —— 后者更新说明新一轮
|
|
2846
|
+
* 已开始、其 `turn/start` 还没落盘,沿用旧起点会把上一轮算进来;同轮注入的
|
|
2847
|
+
* subagent-settled / team-message / tool-jobs 等通知不算,见 lastTurnStartOfText);
|
|
2848
|
+
* 而且**推出来的起点之后必须真的有用量事件**,否则说明起点无从验证,同样当
|
|
2849
|
+
* `none`(宁少不多);
|
|
2405
2850
|
* · `none`:父会话日志缺失/读不出,或推出来的起点事后没有事件 → 本轮**什么都不并**
|
|
2406
2851
|
* (整条返回零值),绝不退回整会话 —— 用户规则:本轮只能小不能大;
|
|
2407
2852
|
* 要整会话(「总计」那一项)就显式传 `since=0`。
|
|
@@ -2448,8 +2893,9 @@ async function collectSubagentUsageNow(sessionId, sinceMs) {
|
|
|
2448
2893
|
|
|
2449
2894
|
/* 窗口起点从哪来:① 客户端给了(它精确知道自己那一轮)→ `client`;
|
|
2450
2895
|
② 没给就问 host 自己 —— 读**父会话自己**的日志取最后一条 turn/start(或更新的
|
|
2451
|
-
user/message,见 lastTurnStartOfText
|
|
2452
|
-
|
|
2896
|
+
真实用户 user/message,见 lastTurnStartOfText;同轮注入的通知不算)→ `host`;
|
|
2897
|
+
③ 两者都没有(父会话文件缺失/读不出)→ `none`:**本轮什么都不并**
|
|
2898
|
+
(用户规则:本轮只能小不能大,绝不能拿整会话的量顶上)。
|
|
2453
2899
|
另外 host 推的起点还要过一道"事后有事件"的验证(见下面聚合之后的那段)。 */
|
|
2454
2900
|
const parentEntry = index.byId.get(sessionId)
|
|
2455
2901
|
let sinceSource = 'none'
|
|
@@ -2531,7 +2977,9 @@ async function collectSubagentUsageNow(sessionId, sinceMs) {
|
|
|
2531
2977
|
total.stats.ttftSteps += agg.stats.ttftSteps
|
|
2532
2978
|
total.stats.decodeMs += agg.stats.decodeMs
|
|
2533
2979
|
total.stats.decodeTokens += agg.stats.decodeTokens
|
|
2534
|
-
|
|
2980
|
+
/* 每 16 个让出一次事件循环:146+ 个队友时 4 个一让太碎,宿主每秒都在被问,
|
|
2981
|
+
让出次数少一点能明显压低单次聚合的调度开销(文件用量已按文件缓存,重算只是少数)。 */
|
|
2982
|
+
if (i % 16 === 15) await new Promise((resolve) => setImmediate(resolve))
|
|
2535
2983
|
}
|
|
2536
2984
|
/* host 推出来的起点必须有"事后证据"才算有效。**证据有两条,任一条成立即可**:
|
|
2537
2985
|
① 父会话**自己**在窗口内有用量事件(fromMs 之后有 assistant/message 或
|
|
@@ -2650,9 +3098,13 @@ function emptyLedger() {
|
|
|
2650
3098
|
}
|
|
2651
3099
|
|
|
2652
3100
|
/**
|
|
2653
|
-
*
|
|
3101
|
+
* 读一个 JSON 对象文件;不存在、读不出或不是合法 JSON 对象时返回 null。
|
|
3102
|
+
*
|
|
3103
|
+
* 名字**刻意不叫** readLedgerFile:它不只读台账,`holidays.json` 也走这里
|
|
3104
|
+
* (两者都是「一个 JSON 对象,坏了就当没有」的语义)。用台账专属的名字会让人
|
|
3105
|
+
* 以为改动只该影响台账。
|
|
2654
3106
|
*/
|
|
2655
|
-
function
|
|
3107
|
+
function readJsonObjectFile(file) {
|
|
2656
3108
|
try {
|
|
2657
3109
|
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
2658
3110
|
return parsed !== null && typeof parsed === 'object' ? parsed : null
|
|
@@ -2704,9 +3156,9 @@ function logLedgerNote(message) {
|
|
|
2704
3156
|
}
|
|
2705
3157
|
|
|
2706
3158
|
function readLedger() {
|
|
2707
|
-
const parsed =
|
|
3159
|
+
const parsed = readJsonObjectFile(ledgerPath())
|
|
2708
3160
|
if (parsed !== null) return migrateLedger(parsed)
|
|
2709
|
-
const backup =
|
|
3161
|
+
const backup = readJsonObjectFile(ledgerPath() + '.bak')
|
|
2710
3162
|
if (backup !== null) {
|
|
2711
3163
|
logLedgerNote('usage-ledger: 主文件读不出来,已回退到自动备份')
|
|
2712
3164
|
const recovered = migrateLedger(backup)
|
|
@@ -2730,7 +3182,7 @@ function writeLedger(ledger) {
|
|
|
2730
3182
|
let kept = false
|
|
2731
3183
|
/* 只有"上一份是能解析的好数据"才留成备份:主文件损坏时我们刚回退过 .bak,
|
|
2732
3184
|
若这时把损坏内容复制成新 .bak,备份就只能救命一次(实测缺陷)。 */
|
|
2733
|
-
if (
|
|
3185
|
+
if (readJsonObjectFile(target) !== null) {
|
|
2734
3186
|
try {
|
|
2735
3187
|
copyFileSync(target, target + '.bak')
|
|
2736
3188
|
kept = true
|
|
@@ -2759,7 +3211,14 @@ function writeLedger(ledger) {
|
|
|
2759
3211
|
function addUnpriced(day, model, parts, peakTime) {
|
|
2760
3212
|
const name = typeof model === 'string' && model.length > 0 ? model : '(未知模型)'
|
|
2761
3213
|
const bucket = day.unpriced ?? {}
|
|
2762
|
-
|
|
3214
|
+
/* 必须按**自有属性**判断,不能用 `bucket[name] ?? 默认值`:模型名是 JS 原型属性名
|
|
3215
|
+
(`constructor` / `toString` / `valueOf` / `__proto__` …)时,`bucket[name]` 会取到
|
|
3216
|
+
原型上的函数或原型对象,`??` 不触发 —— 于是 `one.peak` 是 undefined,下面
|
|
3217
|
+
`tier.input += …` 抛 TypeError。抛错发生在 collectIntoLedger / backfillUnpriced 里,
|
|
3218
|
+
queueLedgerJob 随之 reject,`/api/daily-usage` 恒返回 ledger-failed,活跃总览整块打不开。
|
|
3219
|
+
凡是"用户/上游给的字符串当对象键"的地方都要走这条判据。 */
|
|
3220
|
+
const hasOwn = Object.prototype.hasOwnProperty.call(bucket, name)
|
|
3221
|
+
const one = hasOwn === true ? bucket[name] : {
|
|
2763
3222
|
peak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 },
|
|
2764
3223
|
offPeak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 },
|
|
2765
3224
|
}
|
|
@@ -2768,7 +3227,8 @@ function addUnpriced(day, model, parts, peakTime) {
|
|
|
2768
3227
|
tier.cacheRead += parts.cacheRead
|
|
2769
3228
|
tier.cacheWrite += parts.cacheWrite
|
|
2770
3229
|
tier.output += parts.output
|
|
2771
|
-
bucket[name] = one
|
|
3230
|
+
/* 同理:`bucket[name] = one` 在 name 为 `__proto__` 时是设置原型而不是建条目 */
|
|
3231
|
+
defineBookEntry(bucket, name, one)
|
|
2772
3232
|
day.unpriced = bucket
|
|
2773
3233
|
}
|
|
2774
3234
|
|
|
@@ -2989,13 +3449,29 @@ function pruneLedger(ledger) {
|
|
|
2989
3449
|
|
|
2990
3450
|
/** 把台账里的日桶铺成「近 7 天」固定 7 格(最早一天在最左、今天是最后一格),并算合计;另附当月与全年。 */
|
|
2991
3451
|
function weekUsagePayload(ledger, rangeStart, monthStart, meta) {
|
|
2992
|
-
const DAY = 24 * 3600 * 1000
|
|
2993
3452
|
const days = []
|
|
2994
3453
|
const totals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, total: 0, calls: 0, cost: 0 }
|
|
3454
|
+
/* 逐日按**日历**推进,不用 rangeStart + i*24h:后者在夏令时切换日会重复/跳天
|
|
3455
|
+
(同 monthUsagePayload 的说明)。rangeStart 本身已是本地 00:00。 */
|
|
3456
|
+
const startDate = new Date(rangeStart)
|
|
2995
3457
|
for (let i = 0; i < 7; i += 1) {
|
|
2996
|
-
|
|
2997
|
-
const key = localDateKey(
|
|
2998
|
-
const
|
|
3458
|
+
const at = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate() + i).getTime()
|
|
3459
|
+
const key = localDateKey(at)
|
|
3460
|
+
const raw = ledger.days[key]
|
|
3461
|
+
/* 日桶逐个字段兜底:台账被外部改坏(不是对象、或字段缺失/非数字)时,
|
|
3462
|
+
直接相加会得到 NaN,整个 totals 都变成 NaN、JSON 里还丢键。
|
|
3463
|
+
monthUsagePayload / yearUsagePayload 一直有兜底,只有这里没有。 */
|
|
3464
|
+
const bucket = raw !== null && raw !== undefined && typeof raw === 'object'
|
|
3465
|
+
? {
|
|
3466
|
+
input: Number(raw.input) || 0,
|
|
3467
|
+
cacheRead: Number(raw.cacheRead) || 0,
|
|
3468
|
+
cacheWrite: Number(raw.cacheWrite) || 0,
|
|
3469
|
+
output: Number(raw.output) || 0,
|
|
3470
|
+
calls: Number(raw.calls) || 0,
|
|
3471
|
+
cost: Number(raw.cost) || 0,
|
|
3472
|
+
peak: raw.peak,
|
|
3473
|
+
}
|
|
3474
|
+
: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, cost: 0, peak: undefined }
|
|
2999
3475
|
const total = bucket.input + bucket.cacheRead + bucket.cacheWrite + bucket.output
|
|
3000
3476
|
const cost = typeof bucket.cost === 'number' && Number.isFinite(bucket.cost) ? bucket.cost : 0
|
|
3001
3477
|
/* 高峰那一份:老台账没有 peak 字段时按 0 处理 */
|
|
@@ -3009,8 +3485,6 @@ function weekUsagePayload(ledger, rangeStart, monthStart, meta) {
|
|
|
3009
3485
|
peak.total = peak.input + peak.cacheRead + peak.cacheWrite + peak.output
|
|
3010
3486
|
days.push({
|
|
3011
3487
|
date: key,
|
|
3012
|
-
/* 窗口内第几天(0 = 最早那天,6 = 今天);不再是星期几 */
|
|
3013
|
-
offset: i,
|
|
3014
3488
|
input: bucket.input,
|
|
3015
3489
|
cacheRead: bucket.cacheRead,
|
|
3016
3490
|
cacheWrite: bucket.cacheWrite,
|
|
@@ -3048,22 +3522,31 @@ function weekUsagePayload(ledger, rangeStart, monthStart, meta) {
|
|
|
3048
3522
|
* 「月内最高的一天」(要标紫的那天)只在当月天里评,避免被上月的量抢走。
|
|
3049
3523
|
*/
|
|
3050
3524
|
function monthUsagePayload(ledger, monthStart) {
|
|
3051
|
-
|
|
3052
|
-
const
|
|
3053
|
-
const
|
|
3054
|
-
const
|
|
3055
|
-
const
|
|
3056
|
-
const
|
|
3057
|
-
const
|
|
3058
|
-
const
|
|
3059
|
-
const monthEnd = monthStart + (daysInMonth - 1) * DAY
|
|
3060
|
-
const lastWeekday = (new Date(monthEnd + BEIJING).getUTCDay() + 6) % 7
|
|
3525
|
+
/* 一律按本机时区读年月日,与 localDateKey 同口径(别在这里加 8 小时偏移) */
|
|
3526
|
+
const first = new Date(monthStart)
|
|
3527
|
+
const year = first.getFullYear()
|
|
3528
|
+
const month = first.getMonth() + 1
|
|
3529
|
+
const daysInMonth = new Date(year, month, 0).getDate()
|
|
3530
|
+
const firstWeekday = (first.getDay() + 6) % 7 /* 周一=0 */
|
|
3531
|
+
const monthEnd = new Date(year, month - 1, daysInMonth).getTime()
|
|
3532
|
+
const lastWeekday = (new Date(year, month - 1, daysInMonth).getDay() + 6) % 7
|
|
3061
3533
|
const gridDays = daysInMonth + firstWeekday + (6 - lastWeekday)
|
|
3534
|
+
/* 网格起点:当月 1 号往前退 firstWeekday 天。
|
|
3535
|
+
用**日历逐日构造**而不是 `起点 + i * 24h` —— 后者在有夏令时的时区会错:
|
|
3536
|
+
切换日本地时间整体挪 1 小时,+24h 可能落回同一天(出现两格同一天、另一天缺失),
|
|
3537
|
+
实测纽约 / 伦敦 / 悉尼 / 圣地亚哥都会中(UTC+8 的机器上永远看不到)。
|
|
3538
|
+
逐日构造让每格都稳稳落在本地 00:00。 */
|
|
3539
|
+
const gridStartDate = new Date(year, month - 1, 1 - firstWeekday)
|
|
3540
|
+
const dayAt = (i) => new Date(
|
|
3541
|
+
gridStartDate.getFullYear(),
|
|
3542
|
+
gridStartDate.getMonth(),
|
|
3543
|
+
gridStartDate.getDate() + i
|
|
3544
|
+
).getTime()
|
|
3062
3545
|
const days = []
|
|
3063
3546
|
let max = 0
|
|
3064
3547
|
let maxDate = null
|
|
3065
3548
|
for (let i = 0; i < gridDays; i += 1) {
|
|
3066
|
-
const at =
|
|
3549
|
+
const at = dayAt(i)
|
|
3067
3550
|
const key = localDateKey(at)
|
|
3068
3551
|
const inMonth = at >= monthStart && at <= monthEnd
|
|
3069
3552
|
const bucket = ledger.days[key]
|
|
@@ -3100,13 +3583,13 @@ function monthUsagePayload(ledger, monthStart) {
|
|
|
3100
3583
|
* 客户端拿它画「活跃总览」标题下面那 12 个方块(配色规则与右侧月历一致)。
|
|
3101
3584
|
*/
|
|
3102
3585
|
function yearUsagePayload(ledger, now) {
|
|
3103
|
-
|
|
3104
|
-
const year = new Date(now
|
|
3586
|
+
/* 一律按本机时区读年月日,与 localDateKey 同口径 */
|
|
3587
|
+
const year = new Date(now).getFullYear()
|
|
3105
3588
|
const months = []
|
|
3106
3589
|
let max = 0
|
|
3107
3590
|
let maxMonth = null
|
|
3108
3591
|
for (let m = 1; m <= 12; m += 1) {
|
|
3109
|
-
const daysInMonth = new Date(
|
|
3592
|
+
const daysInMonth = new Date(year, m, 0).getDate()
|
|
3110
3593
|
let input = 0
|
|
3111
3594
|
let cacheRead = 0
|
|
3112
3595
|
let cacheWrite = 0
|
|
@@ -3114,7 +3597,7 @@ function yearUsagePayload(ledger, now) {
|
|
|
3114
3597
|
let calls = 0
|
|
3115
3598
|
let cost = 0
|
|
3116
3599
|
for (let d = 1; d <= daysInMonth; d += 1) {
|
|
3117
|
-
const bucket = ledger.days[localDateKey(Date
|
|
3600
|
+
const bucket = ledger.days[localDateKey(new Date(year, m - 1, d).getTime())]
|
|
3118
3601
|
if (bucket === undefined) continue
|
|
3119
3602
|
input += bucket.input || 0
|
|
3120
3603
|
cacheRead += bucket.cacheRead || 0
|
|
@@ -3181,6 +3664,44 @@ function appendTipDiag(body) {
|
|
|
3181
3664
|
}
|
|
3182
3665
|
}
|
|
3183
3666
|
|
|
3667
|
+
/**
|
|
3668
|
+
* 把客户端上报的「更新失败」描述成一条能直接拿去问 AI 的日志文本。
|
|
3669
|
+
*
|
|
3670
|
+
* 字段都是**客户端自己知道、宿主不知道**的:它在哪个环节失败、目标版本是谁、
|
|
3671
|
+
* 浏览器抛了什么异常。刻意不在这里做任何判断或修复 —— 只把事实摊平成一行,
|
|
3672
|
+
* 让用户把日志贴给 AI 时信息是完整的(这是用户提「补日志」的原始诉求)。
|
|
3673
|
+
*
|
|
3674
|
+
* 只取有限长度的字符串,避免恶意/异常的超长 body 把日志撑爆。
|
|
3675
|
+
*
|
|
3676
|
+
* @param {object|null} body 客户端 POST 上来的 JSON
|
|
3677
|
+
* @returns {string}
|
|
3678
|
+
*/
|
|
3679
|
+
function describeClientUpdateFailure(body) {
|
|
3680
|
+
const src = body !== null && body !== undefined && typeof body === 'object' ? body : {}
|
|
3681
|
+
const text = (value, max) => {
|
|
3682
|
+
if (value === null || value === undefined) return null
|
|
3683
|
+
const raw = typeof value === 'string' ? value : String(value)
|
|
3684
|
+
const limit = max === undefined ? 200 : max
|
|
3685
|
+
return raw.length > limit ? raw.slice(0, limit) + '…' : raw
|
|
3686
|
+
}
|
|
3687
|
+
const parts = []
|
|
3688
|
+
const reason = text(src.reason, 60)
|
|
3689
|
+
parts.push('reason=' + (reason === null ? 'unknown' : reason))
|
|
3690
|
+
parts.push('target=' + (text(src.target, 40) ?? 'null'))
|
|
3691
|
+
parts.push('current=' + (text(src.current, 40) ?? 'null'))
|
|
3692
|
+
/* 时间戳取客户端那一刻:宿主收到的时间可能晚很多(比如用户在断网时才点) */
|
|
3693
|
+
parts.push('at=' + (typeof src.at === 'number' && Number.isFinite(src.at) ? new Date(src.at).toISOString() : 'null'))
|
|
3694
|
+
const stage = text(src.stage, 60)
|
|
3695
|
+
if (stage !== null) parts.push('stage=' + stage)
|
|
3696
|
+
/* HTTP 状态码:fetch 对 4xx/5xx 是 resolve 不是 reject,必须单独记 */
|
|
3697
|
+
if (typeof src.httpStatus === 'number' && Number.isFinite(src.httpStatus)) parts.push('http=' + String(src.httpStatus))
|
|
3698
|
+
const detail = text(src.detail, 300)
|
|
3699
|
+
if (detail !== null && detail.length > 0) parts.push('detail=' + detail)
|
|
3700
|
+
/* 失败那一次尝试了多久:能区分"秒失败(被拒/被拦)"与"等很久才超时" */
|
|
3701
|
+
if (typeof src.elapsedMs === 'number' && Number.isFinite(src.elapsedMs)) parts.push('elapsed=' + String(Math.round(src.elapsedMs)) + 'ms')
|
|
3702
|
+
return parts.join(' ')
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3184
3705
|
/* 台账只在「有新数据」时才重扫(文件 mtime+size 没变就跳过),因此这层只是省掉重复的 stat。
|
|
3185
3706
|
不挂定时器:打开活跃总览面板时会带 force=1 现扫一次,数据只在被看的时候才需要最新。 */
|
|
3186
3707
|
const WEEK_USAGE_CACHE_MS = 30000
|
|
@@ -3397,203 +3918,296 @@ export function apply(ctx) {
|
|
|
3397
3918
|
ctx.effect(() => ctx.webServer.register({
|
|
3398
3919
|
kind: 'prefix',
|
|
3399
3920
|
path: '/dsh-desktop-statusbar/api',
|
|
3921
|
+
/* 整体兜底:handler 里任何一处抛错(如 ctx.get('credentials') 在 try 之外被
|
|
3922
|
+
queryBalance 调到、读盘/解压意外抛出)原本会让这个 Promise reject ——
|
|
3923
|
+
webServer 不替我们收尾,于是既不写响应也不关连接,客户端 fetch **永久 pending**
|
|
3924
|
+
(底栏余额永远停在「查询中」)。这里统一写 500 并结束响应;已经写过响应头
|
|
3925
|
+
(headersSent)时不再二次写,只结束连接,避免 ERR_HTTP_HEADERS_SENT。 */
|
|
3400
3926
|
handler: async (req, res) => {
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
加/改的单价只对底栏生效、活跃总览恒为旧值(实测 2026-10-07)。 */
|
|
3406
|
-
if (req.method === 'POST') {
|
|
3407
|
-
const body = await readJsonBody(req)
|
|
3408
|
-
const book = body === null ? null : sanitizePriceBook(body.models)
|
|
3409
|
-
if (book === null) {
|
|
3410
|
-
sendJson(res, 400, { ok: false, reason: 'bad-models' })
|
|
3411
|
-
return
|
|
3412
|
-
}
|
|
3413
|
-
/* 判断"价格是否变了"用的快照在队列里取(见下):并排上报不会双双报"已变更" */
|
|
3414
|
-
/* **在队列里换价格库**:换库必须与"读台账→结算→写盘"是同一段原子操作,
|
|
3415
|
-
否则在途扫描会一半用旧价、一半用新价,同一天的数字混两套价(实测缺陷)。 */
|
|
3416
|
-
let settled = { entries: 0, amount: 0 }
|
|
3417
|
-
let changed = false
|
|
3418
|
-
const swapped = await queueLedgerJob(async () => {
|
|
3419
|
-
const before = activePriceFingerprint
|
|
3420
|
-
clientPrices = book
|
|
3421
|
-
const nowFingerprint = priceFingerprint()
|
|
3422
|
-
activePriceFingerprint = nowFingerprint
|
|
3423
|
-
const nowChanged = before !== nowFingerprint
|
|
3424
|
-
/* 价格变了只做一件事:把"当时没有单价、还没算钱"的欠账按新价结算掉。
|
|
3425
|
-
已经算好的费用一律不动,台账也不重建 —— 所以会话日志被删也不影响已有数字。 */
|
|
3426
|
-
if (nowChanged !== true) return { changed: false, settled: { entries: 0, amount: 0 } }
|
|
3427
|
-
const ledger = readLedger()
|
|
3428
|
-
const done = settleUnpriced(ledger)
|
|
3429
|
-
/* 两种情况才落盘:真结算了欠账;或者盘上还是老版本,顺手把迁移结果固化一次
|
|
3430
|
-
(免得每次都重迁一遍)。没有台账时不在这里创建。
|
|
3431
|
-
**绝不改写 priceFingerprint** —— 那个字段记的是"上次记账用的那份价目表",
|
|
3432
|
-
是回扫判断"当时有没有价"的唯一依据;价格上报抢先改写它,历史缺价明细就永远补不出来。 */
|
|
3433
|
-
const onDisk = readLedgerFile(ledgerPath())
|
|
3434
|
-
const stale = onDisk !== null && onDisk.version !== LEDGER_VERSION
|
|
3435
|
-
if (done.entries > 0 || stale) {
|
|
3436
|
-
ledger.updatedAt = Date.now()
|
|
3437
|
-
writeLedger(ledger)
|
|
3438
|
-
}
|
|
3439
|
-
return { changed: true, settled: done }
|
|
3440
|
-
})
|
|
3441
|
-
changed = swapped.changed
|
|
3442
|
-
settled = swapped.settled
|
|
3443
|
-
if (changed) {
|
|
3444
|
-
/* 面板的数字要重新取一次(payload 缓存在上面) */
|
|
3445
|
-
weekUsageCache = { at: 0, value: null }
|
|
3446
|
-
weekUsageScan = null
|
|
3447
|
-
}
|
|
3448
|
-
logHost(ctx, 'prices: 收到客户端价格库 ' + String(Object.keys(book).length) + ' 条'
|
|
3449
|
-
+ (changed
|
|
3450
|
-
? '(已变更,结算欠账 ' + String(settled.entries) + ' 项 ¥' + String(settled.amount) + ')'
|
|
3451
|
-
: '(与当前一致)'))
|
|
3452
|
-
sendJson(res, 200, { ok: true, changed: changed, count: Object.keys(book).length, settled: settled })
|
|
3453
|
-
return
|
|
3454
|
-
}
|
|
3455
|
-
/* GET = 官方参考价(价格库为空时客户端拿它自愈),与用户价格库无关 */
|
|
3456
|
-
sendJson(res, 200, {
|
|
3457
|
-
models: {
|
|
3458
|
-
'deepseek-flash': TOKEN_PRICES.peak['deepseek-flash'] === undefined ? null : {
|
|
3459
|
-
peak: TOKEN_PRICES.peak['deepseek-flash'],
|
|
3460
|
-
offPeak: TOKEN_PRICES.offPeak['deepseek-flash'],
|
|
3461
|
-
},
|
|
3462
|
-
'deepseek-v4-pro': {
|
|
3463
|
-
peak: TOKEN_PRICES.peak['deepseek-v4-pro'],
|
|
3464
|
-
offPeak: TOKEN_PRICES.offPeak['deepseek-v4-pro'],
|
|
3465
|
-
},
|
|
3466
|
-
},
|
|
3467
|
-
note: 'CNY per 1M tokens, 高峰=北京时间周一至周五 9-12、14-18(法定节假日与周末全天按空闲价)',
|
|
3468
|
-
})
|
|
3469
|
-
return
|
|
3470
|
-
}
|
|
3471
|
-
if (url.pathname === '/dsh-desktop-statusbar/api/holidays') {
|
|
3472
|
-
const raw = url.searchParams.get('year')
|
|
3473
|
-
const year = raw === null ? NaN : Number(raw)
|
|
3474
|
-
if (Number.isInteger(year) !== true || year < 2020 || year > 2100) {
|
|
3475
|
-
sendJson(res, 400, { ok: false, reason: 'bad-year' })
|
|
3476
|
-
return
|
|
3477
|
-
}
|
|
3927
|
+
try {
|
|
3928
|
+
await handleApi(req, res, ctx)
|
|
3929
|
+
} catch (error) {
|
|
3930
|
+
logHost(ctx, 'api: 未捕获异常 ' + probeDetail(error))
|
|
3478
3931
|
try {
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
logHost(ctxRef, 'holidays: ' + String(year) + ' 年 ' + String(result.days.length)
|
|
3484
|
-
+ ' 天(累计 ' + String(merged.length) + ' 天,已落盘)')
|
|
3485
|
-
}
|
|
3486
|
-
sendJson(res, 200, result)
|
|
3487
|
-
} catch (error) {
|
|
3488
|
-
const detail = error !== null && error !== undefined && typeof error.message === 'string' ? error.message : 'fetch-failed'
|
|
3489
|
-
logHost(ctxRef, 'holidays: 抓取失败 ' + detail)
|
|
3490
|
-
sendJson(res, 200, { ok: false, reason: 'fetch-failed' })
|
|
3932
|
+
if (res.headersSent === true) res.end()
|
|
3933
|
+
else sendJson(res, 500, { ok: false, reason: 'internal-error' })
|
|
3934
|
+
} catch (nested) {
|
|
3935
|
+
/* 连接已经断了,没什么可做的 */
|
|
3491
3936
|
}
|
|
3492
|
-
return
|
|
3493
3937
|
}
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3938
|
+
},
|
|
3939
|
+
}), 'dsh-desktop-statusbar: balance api')
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
async function handleApi(req, res, ctx) {
|
|
3943
|
+
/* 带跨站 Origin 的写请求一律拒绝:浏览器里任意网页都能对本机发简单请求,
|
|
3944
|
+
必须在**改任何状态之前**挡住(判定见 isCrossSiteWrite 的说明)。 */
|
|
3945
|
+
if (isCrossSiteWrite(req) === true) {
|
|
3946
|
+
logHost(ctx, 'api: 拒绝跨站写请求 origin=' + String(req.headers.origin))
|
|
3947
|
+
sendJson(res, 403, { ok: false, reason: 'cross-origin' })
|
|
3948
|
+
return
|
|
3949
|
+
}
|
|
3950
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
3951
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/prices') {
|
|
3952
|
+
/* POST = 客户端把它的价格库(localStorage 那份,用户可增删改)报上来。
|
|
3953
|
+
活跃总览的 cost 由 host 记账时算出,必须用同一份价格,否则用户在设置页
|
|
3954
|
+
加/改的单价只对底栏生效、活跃总览恒为旧值(实测 2026-10-07)。 */
|
|
3955
|
+
if (req.method === 'POST') {
|
|
3956
|
+
const body = await readJsonBody(req)
|
|
3957
|
+
const book = body === null ? null : sanitizePriceBook(body.models)
|
|
3958
|
+
if (book === null) {
|
|
3959
|
+
sendJson(res, 400, { ok: false, reason: 'bad-models' })
|
|
3515
3960
|
return
|
|
3516
3961
|
}
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3962
|
+
/* 判断"价格是否变了"用的快照在队列里取(见下):并排上报不会双双报"已变更" */
|
|
3963
|
+
/* **在队列里换价格库**:换库必须与"读台账→结算→写盘"是同一段原子操作,
|
|
3964
|
+
否则在途扫描会一半用旧价、一半用新价,同一天的数字混两套价(实测缺陷)。 */
|
|
3965
|
+
let settled = { entries: 0, amount: 0 }
|
|
3966
|
+
let changed = false
|
|
3967
|
+
const swapped = await queueLedgerJob(async () => {
|
|
3968
|
+
const before = activePriceFingerprint
|
|
3969
|
+
clientPrices = book
|
|
3970
|
+
const nowFingerprint = priceFingerprint()
|
|
3971
|
+
activePriceFingerprint = nowFingerprint
|
|
3972
|
+
const nowChanged = before !== nowFingerprint
|
|
3973
|
+
/* 价格变了只做一件事:把"当时没有单价、还没算钱"的欠账按新价结算掉。
|
|
3974
|
+
已经算好的费用一律不动,台账也不重建 —— 所以会话日志被删也不影响已有数字。 */
|
|
3975
|
+
if (nowChanged !== true) return { changed: false, settled: { entries: 0, amount: 0 } }
|
|
3976
|
+
const ledger = readLedger()
|
|
3977
|
+
const done = settleUnpriced(ledger)
|
|
3978
|
+
/* 两种情况才落盘:真结算了欠账;或者盘上还是老版本,顺手把迁移结果固化一次
|
|
3979
|
+
(免得每次都重迁一遍)。没有台账时不在这里创建。
|
|
3980
|
+
**绝不改写 priceFingerprint** —— 那个字段记的是"上次记账用的那份价目表",
|
|
3981
|
+
是回扫判断"当时有没有价"的唯一依据;价格上报抢先改写它,历史缺价明细就永远补不出来。 */
|
|
3982
|
+
const onDisk = readJsonObjectFile(ledgerPath())
|
|
3983
|
+
const stale = onDisk !== null && onDisk.version !== LEDGER_VERSION
|
|
3984
|
+
if (done.entries > 0 || stale) {
|
|
3985
|
+
ledger.updatedAt = Date.now()
|
|
3986
|
+
writeLedger(ledger)
|
|
3532
3987
|
}
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3988
|
+
return { changed: true, settled: done }
|
|
3989
|
+
})
|
|
3990
|
+
changed = swapped.changed
|
|
3991
|
+
settled = swapped.settled
|
|
3992
|
+
if (changed) {
|
|
3993
|
+
/* 面板的数字要重新取一次(payload 缓存在上面) */
|
|
3994
|
+
weekUsageCache = { at: 0, value: null }
|
|
3995
|
+
weekUsageScan = null
|
|
3540
3996
|
}
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3997
|
+
logHost(ctx, 'prices: 收到客户端价格库 ' + String(Object.keys(book).length) + ' 条'
|
|
3998
|
+
+ (changed
|
|
3999
|
+
? '(已变更,结算欠账 ' + String(settled.entries) + ' 项 ¥' + String(settled.amount) + ')'
|
|
4000
|
+
: '(与当前一致)'))
|
|
4001
|
+
sendJson(res, 200, { ok: true, changed: changed, count: Object.keys(book).length, settled: settled })
|
|
4002
|
+
return
|
|
4003
|
+
}
|
|
4004
|
+
/* GET = 官方参考价(价格库为空时客户端拿它自愈),与用户价格库无关 */
|
|
4005
|
+
sendJson(res, 200, {
|
|
4006
|
+
models: {
|
|
4007
|
+
'deepseek-flash': TOKEN_PRICES.peak['deepseek-flash'] === undefined ? null : {
|
|
4008
|
+
peak: TOKEN_PRICES.peak['deepseek-flash'],
|
|
4009
|
+
offPeak: TOKEN_PRICES.offPeak['deepseek-flash'],
|
|
4010
|
+
},
|
|
4011
|
+
'deepseek-v4-pro': {
|
|
4012
|
+
peak: TOKEN_PRICES.peak['deepseek-v4-pro'],
|
|
4013
|
+
offPeak: TOKEN_PRICES.offPeak['deepseek-v4-pro'],
|
|
4014
|
+
},
|
|
4015
|
+
},
|
|
4016
|
+
note: 'CNY per 1M tokens, 高峰=北京时间周一至周五 9-12、14-18(法定节假日与周末全天按空闲价)',
|
|
4017
|
+
})
|
|
4018
|
+
return
|
|
4019
|
+
}
|
|
4020
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/holidays') {
|
|
4021
|
+
const raw = url.searchParams.get('year')
|
|
4022
|
+
const year = raw === null ? NaN : Number(raw)
|
|
4023
|
+
if (Number.isInteger(year) !== true || year < 2020 || year > 2100) {
|
|
4024
|
+
sendJson(res, 400, { ok: false, reason: 'bad-year' })
|
|
4025
|
+
return
|
|
4026
|
+
}
|
|
4027
|
+
try {
|
|
4028
|
+
const result = await fetchHolidayDays(year)
|
|
4029
|
+
if (result.ok === true) {
|
|
4030
|
+
/* 落盘:host 自己也要用这份假期表判峰谷,否则 2027 年起底栏与活跃总览口径不一致 */
|
|
4031
|
+
const merged = saveHolidayDays(result.days)
|
|
4032
|
+
logHost(ctxRef, 'holidays: ' + String(year) + ' 年 ' + String(result.days.length)
|
|
4033
|
+
+ ' 天(累计 ' + String(merged.length) + ' 天,已落盘)')
|
|
3573
4034
|
}
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
4035
|
+
sendJson(res, 200, result)
|
|
4036
|
+
} catch (error) {
|
|
4037
|
+
const detail = error !== null && error !== undefined && typeof error.message === 'string' ? error.message : 'fetch-failed'
|
|
4038
|
+
logHost(ctxRef, 'holidays: 抓取失败 ' + detail)
|
|
4039
|
+
sendJson(res, 200, { ok: false, reason: 'fetch-failed' })
|
|
4040
|
+
}
|
|
4041
|
+
return
|
|
4042
|
+
}
|
|
4043
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/version') {
|
|
4044
|
+
/* 启动自检可能还没回来:等这一次在途的查询,别让客户端误判成「没有新版」 */
|
|
4045
|
+
if (latestVersion.checkedAt === 0 && versionCheck !== null) {
|
|
4046
|
+
try {
|
|
4047
|
+
latestVersion = await versionCheck
|
|
4048
|
+
} catch (error) {
|
|
4049
|
+
/* 保持 latest 为 null,下面按「查不到」处理 */
|
|
3584
4050
|
}
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
4051
|
+
}
|
|
4052
|
+
const raw = url.searchParams.get('current')
|
|
4053
|
+
const current = raw === null ? '' : raw.trim()
|
|
4054
|
+
sendJson(res, 200, {
|
|
4055
|
+
ok: latestVersion.latest !== null,
|
|
4056
|
+
current,
|
|
4057
|
+
latest: latestVersion.latest,
|
|
4058
|
+
/* 只有确实拿到版本号、且它比本地这个新,才算「有更新」 */
|
|
4059
|
+
hasUpdate: latestVersion.latest !== null && current.length > 0
|
|
4060
|
+
&& compareVersions(latestVersion.latest, current) > 0,
|
|
4061
|
+
checkedAt: latestVersion.checkedAt,
|
|
4062
|
+
error: latestVersion.error,
|
|
4063
|
+
})
|
|
4064
|
+
return
|
|
4065
|
+
}
|
|
4066
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/self-update') {
|
|
4067
|
+
/* 一键更新:由本插件自己完成,不依赖「插件管理器」这个别的插件,也不走
|
|
4068
|
+
pnpm 的 24 小时发布年龄策略(做法见 self-update.js 顶部说明)。
|
|
4069
|
+
GET = 问当前状态(界面挂在更新中途刷新时能接上);POST = 发起一次更新。 */
|
|
4070
|
+
if (req.method !== 'POST') {
|
|
4071
|
+
const last = lastUpdateResult()
|
|
4072
|
+
sendJson(res, 200, {
|
|
4073
|
+
ok: true,
|
|
4074
|
+
busy: isUpdating(),
|
|
4075
|
+
last: last === null ? null : {
|
|
4076
|
+
ok: last.ok === true,
|
|
4077
|
+
reason: last.reason === undefined ? null : last.reason,
|
|
4078
|
+
from: last.from === undefined ? null : last.from,
|
|
4079
|
+
to: last.to === undefined ? null : last.to,
|
|
4080
|
+
},
|
|
4081
|
+
})
|
|
4082
|
+
return
|
|
4083
|
+
}
|
|
4084
|
+
const body = await readJsonBody(req)
|
|
4085
|
+
const raw = body === null || typeof body.version !== 'string' ? '' : body.version.trim()
|
|
4086
|
+
/* 目标版本必须是明确的三段式版本号:不接受 latest/tag/范围,避免装到别的东西。
|
|
4087
|
+
空则不传 —— host 会自己问 registry 的最新版。 */
|
|
4088
|
+
if (raw.length > 0 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(raw) !== true) {
|
|
4089
|
+
sendJson(res, 400, { ok: false, reason: 'bad-version' })
|
|
4090
|
+
return
|
|
4091
|
+
}
|
|
4092
|
+
if (isUpdating() === true) {
|
|
4093
|
+
sendJson(res, 409, { ok: false, reason: 'busy' })
|
|
4094
|
+
return
|
|
4095
|
+
}
|
|
4096
|
+
let target = raw
|
|
4097
|
+
if (target.length === 0) {
|
|
4098
|
+
/* 客户端没带版本就自己查一次最新的 */
|
|
4099
|
+
const checked = await checkLatestVersion()
|
|
4100
|
+
latestVersion = checked
|
|
4101
|
+
if (checked.latest === null) {
|
|
4102
|
+
sendJson(res, 200, { ok: false, reason: checked.error === null ? 'no-version' : checked.error })
|
|
3588
4103
|
return
|
|
3589
4104
|
}
|
|
3590
|
-
|
|
3591
|
-
|
|
4105
|
+
target = checked.latest
|
|
4106
|
+
}
|
|
4107
|
+
const selfDir = resolveSelfDir()
|
|
4108
|
+
logHost(ctxRef, 'update: 收到更新请求 → ' + target + '(当前目录 ' + selfDir + ')')
|
|
4109
|
+
const value = await selfUpdateOnce({
|
|
4110
|
+
selfDir,
|
|
4111
|
+
version: target,
|
|
4112
|
+
logger: (message) => logHost(ctxRef, message),
|
|
4113
|
+
})
|
|
4114
|
+
logHost(ctxRef, 'update: 结果 ' + JSON.stringify(value))
|
|
4115
|
+
sendJson(res, 200, value)
|
|
4116
|
+
return
|
|
4117
|
+
}
|
|
4118
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/active-model') {
|
|
4119
|
+
if (req.method === 'POST') {
|
|
4120
|
+
const body = await readJsonBody(req)
|
|
4121
|
+
const model = body !== null && typeof body.model === 'string' ? body.model.trim() : ''
|
|
4122
|
+
if (model.length === 0) {
|
|
4123
|
+
sendJson(res, 400, { ok: false, reason: 'model-required' })
|
|
3592
4124
|
return
|
|
3593
4125
|
}
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
4126
|
+
activeModel = {
|
|
4127
|
+
provider: typeof body.provider === 'string' && body.provider.length > 0 ? body.provider : null,
|
|
4128
|
+
model: model,
|
|
4129
|
+
at: Date.now(),
|
|
4130
|
+
}
|
|
4131
|
+
sendJson(res, 200, { ok: true })
|
|
4132
|
+
return
|
|
4133
|
+
}
|
|
4134
|
+
sendJson(res, 200, {
|
|
4135
|
+
ok: true,
|
|
4136
|
+
provider: activeModel.provider,
|
|
4137
|
+
model: activeModel.model,
|
|
4138
|
+
at: activeModel.at,
|
|
4139
|
+
})
|
|
4140
|
+
return
|
|
4141
|
+
}
|
|
4142
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/subagent-usage') {
|
|
4143
|
+
/* 底栏「总计」和「本轮」的队友部分:子智能体是独立会话,它的用量只能这样按 parentSession
|
|
4144
|
+
找回来。`since` 是可选的窗口起点(毫秒):客户端拿"本轮起点"传进来,「本轮」就只算
|
|
4145
|
+
本轮里队友花了多少;不传就是整会话(「总计」用)。
|
|
4146
|
+
参数不对(客户端拿不到会话 id / 传了别的东西)给 400;会话没有队友、或队友日志
|
|
4147
|
+
全都解不出来时给零值而不是 404 —— 客户端按"加上 0"处理,界面不该因此报错。 */
|
|
4148
|
+
const raw = url.searchParams.get('sessionId')
|
|
4149
|
+
const sessionId = raw === null ? '' : raw.trim()
|
|
4150
|
+
if (sessionId.length === 0 || sessionId.length > 200) {
|
|
4151
|
+
sendJson(res, 400, { ok: false, reason: 'bad-session' })
|
|
4152
|
+
return
|
|
4153
|
+
}
|
|
4154
|
+
/* 非法/缺失的 since 一律当"没传":空串、非有限数、负数、以及**未来时刻**。
|
|
4155
|
+
本轮起点必然在过去,未来时刻只可能是客户端传了坏值;真按它过滤会把队友的
|
|
4156
|
+
数字静默清零(少算钱),所以宁可忽略。留 5 分钟余量容忍时钟抖动。
|
|
4157
|
+
"当没传"的新语义(task-18 起):由 host 读父会话日志代推起点;连父会话都没有
|
|
4158
|
+
就本轮为空(`sinceSource='none'`),**不**退回整会话 —— 要整会话就显式传 `since=0`。 */
|
|
4159
|
+
const rawSince = url.searchParams.get('since')
|
|
4160
|
+
const sinceText = rawSince === null ? '' : rawSince.trim()
|
|
4161
|
+
const parsedSince = sinceText.length === 0 ? NaN : Number(sinceText)
|
|
4162
|
+
const usableSince = Number.isFinite(parsedSince) === true && parsedSince >= 0
|
|
4163
|
+
&& parsedSince <= Date.now() + 5 * 60 * 1000
|
|
4164
|
+
/* 这个接口会排队(collectSubagentUsage 内部走 queueLedgerJob)——
|
|
4165
|
+
别按「只读、不排队」去理解它的时序。此处原先的注释与实现矛盾,已改正。 */
|
|
4166
|
+
let value
|
|
4167
|
+
try {
|
|
4168
|
+
value = await collectSubagentUsage(sessionId, usableSince === true ? parsedSince : undefined)
|
|
4169
|
+
} catch (error) {
|
|
4170
|
+
logHost(ctx, 'subagent-usage: 汇总失败 ' + probeDetail(error))
|
|
4171
|
+
value = { ok: false, reason: 'scan-failed' }
|
|
4172
|
+
}
|
|
4173
|
+
sendJson(res, 200, value)
|
|
4174
|
+
return
|
|
4175
|
+
}
|
|
4176
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/daily-usage') {
|
|
4177
|
+
let value
|
|
4178
|
+
try {
|
|
4179
|
+
value = await refreshWeekUsage(url.searchParams.get('force') === '1', ctx)
|
|
4180
|
+
} catch (error) {
|
|
4181
|
+
logHost(ctx, 'usage: 台账刷新失败 ' + probeDetail(error))
|
|
4182
|
+
value = { ok: false, reason: 'ledger-failed' }
|
|
4183
|
+
}
|
|
4184
|
+
sendJson(res, 200, value)
|
|
4185
|
+
return
|
|
4186
|
+
}
|
|
4187
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/tip-diag') {
|
|
4188
|
+
if (req.method === 'POST') appendTipDiag(await readJsonBody(req))
|
|
4189
|
+
sendJson(res, 200, { ok: true })
|
|
4190
|
+
return
|
|
4191
|
+
}
|
|
4192
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/update-failure') {
|
|
4193
|
+
/* 客户端侧更新失败的上报口。为什么需要它:宿主侧失败已经在
|
|
4194
|
+
`update: 结果 {...}` 那条日志里了,但**客户端自己**的失败(fetch 被代理拦、
|
|
4195
|
+
响应解不出来、网络断)发生在浏览器里,宿主完全不知道 —— 用户点完只看到
|
|
4196
|
+
「更新失败」,日志里查不到任何痕迹,想用 AI 排查也无从下手。
|
|
4197
|
+
这里把它补成一条**结构化日志行**:既有给人看的中文摘要,也有完整 JSON。
|
|
4198
|
+
不做任何状态变更、不重试安装,只落日志,所以是纯诊断面。 */
|
|
4199
|
+
if (req.method === 'POST') {
|
|
4200
|
+
const body = await readJsonBody(req)
|
|
4201
|
+
logHost(ctx, 'update: 客户端失败 ' + describeClientUpdateFailure(body))
|
|
4202
|
+
}
|
|
4203
|
+
sendJson(res, 200, { ok: true })
|
|
4204
|
+
return
|
|
4205
|
+
}
|
|
4206
|
+
if (url.pathname !== '/dsh-desktop-statusbar/api/balance') {
|
|
4207
|
+
sendJson(res, 404, { ok: false, reason: 'not-found' })
|
|
4208
|
+
return
|
|
4209
|
+
}
|
|
4210
|
+
const force = url.searchParams.get('force') === '1'
|
|
4211
|
+
const value = await queryBalance(ctx, force, url.searchParams.get('provider'), url.searchParams.get('model'))
|
|
4212
|
+
sendJson(res, 200, Object.assign({}, value, { probe: accountProbeNote }))
|
|
3599
4213
|
}
|