dsh-desktop-statusbar 2.1.2 → 2.2.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/README.en.md +13 -10
- package/README.md +13 -11
- package/docs/settings.en.png +0 -0
- package/docs/settings.png +0 -0
- package/lib/balance-api.js +49 -1
- package/lib/client.js +1288 -191
- package/lib/index.js +364 -69
- package/lib/release-age.js +127 -8
- package/lib/self-update.js +243 -44
- package/package.json +3 -2
package/lib/index.js
CHANGED
|
@@ -143,10 +143,29 @@ const sessionCapacityProjection = {
|
|
|
143
143
|
|
|
144
144
|
/* -------------------------------------------------------- sessionProgress */
|
|
145
145
|
|
|
146
|
-
/**
|
|
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
|
+
*/
|
|
147
158
|
const compactMark = z.object({
|
|
148
|
-
turn: z.number(),
|
|
159
|
+
turn: z.number().nullable(),
|
|
149
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(),
|
|
150
169
|
}).strict()
|
|
151
170
|
|
|
152
171
|
const sessionProgressSchema = z.object({
|
|
@@ -155,13 +174,14 @@ const sessionProgressSchema = z.object({
|
|
|
155
174
|
toolCalls: z.number(),
|
|
156
175
|
/* 工具名 → 调用次数:气泡里「工具调用」那一行点开要逐个列出来 */
|
|
157
176
|
toolCounts: z.record(z.string(), z.number()),
|
|
158
|
-
|
|
177
|
+
/* 每次注入一条,按发生顺序;manual 区分斜杠命令注入与 skill 工具调用 */
|
|
178
|
+
skills: z.array(skillMark),
|
|
159
179
|
/* 注入进会话的全局提示词文件名(~/.dsh/AGENTS.md → 'AGENTS.md');没注入给 null */
|
|
160
180
|
globalInstruction: z.string().nullable(),
|
|
161
181
|
compactCount: z.number(),
|
|
162
182
|
compactTurn: z.number().nullable(),
|
|
163
183
|
compactStep: z.number().nullable(),
|
|
164
|
-
/*
|
|
184
|
+
/* 只列成功压缩,按发生顺序:气泡里「上下文压缩」那一行点开逐条列;失败的不进来 */
|
|
165
185
|
compacts: z.array(compactMark),
|
|
166
186
|
}).strict()
|
|
167
187
|
|
|
@@ -219,12 +239,18 @@ function globalInstructionOf(data) {
|
|
|
219
239
|
* 所以在 host 侧按事件折一份:
|
|
220
240
|
* step/start → 本轮走到第几步(轮号一变就归零,step 是轮内序号不是会话累计)
|
|
221
241
|
* tool/call → 整个会话的工具调用次数 + 每个工具名各自的次数;
|
|
222
|
-
* 工具名是 skill
|
|
242
|
+
* 工具名是 skill 的那种再把技能名(manual:false)按注入顺序记进 skills
|
|
223
243
|
* user/message → 全局提示词(agent-instructions 注入的那种)的文件名;
|
|
224
|
-
* 斜杠命令注入的技能(skill-invocation
|
|
225
|
-
* compaction/
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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)。
|
|
228
254
|
*/
|
|
229
255
|
const sessionProgressProjection = {
|
|
230
256
|
key: 'desktopStatusbarProgress',
|
|
@@ -252,7 +278,8 @@ const sessionProgressProjection = {
|
|
|
252
278
|
}
|
|
253
279
|
if (event.type === 'user/message') {
|
|
254
280
|
const skill = skillInvocationOf(event.data)
|
|
255
|
-
|
|
281
|
+
/* 用户输入 /技能名 走的就是这条:算手动触发 */
|
|
282
|
+
if (skill !== null) return { ...state, skills: state.skills.concat([{ name: skill, manual: true }]) }
|
|
256
283
|
const name = globalInstructionOf(event.data)
|
|
257
284
|
if (name === null || state.globalInstruction === name) return state
|
|
258
285
|
return { ...state, globalInstruction: name }
|
|
@@ -269,29 +296,41 @@ const sessionProgressProjection = {
|
|
|
269
296
|
...state,
|
|
270
297
|
toolCalls: state.toolCalls + 1,
|
|
271
298
|
toolCounts: counts,
|
|
272
|
-
/* 技能有两种注入来源:调 skill
|
|
273
|
-
|
|
274
|
-
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 }]),
|
|
275
302
|
}
|
|
276
303
|
}
|
|
277
|
-
if (event.type === 'compaction/
|
|
304
|
+
if (event.type === 'compaction/end') {
|
|
278
305
|
const data = event.data === undefined || event.data === null ? {} : event.data
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
282
319
|
return {
|
|
283
320
|
...state,
|
|
284
321
|
compactCount: state.compactCount + 1,
|
|
285
322
|
compactTurn: turn,
|
|
286
323
|
compactStep: step,
|
|
287
|
-
compacts: state.compacts.concat([{ turn: turn, step: step }]),
|
|
324
|
+
compacts: state.compacts.concat([{ turn: turn, step: step, afterTurn: afterTurn, manual: manual }]),
|
|
288
325
|
}
|
|
289
326
|
}
|
|
290
327
|
return state
|
|
291
328
|
},
|
|
292
329
|
wire: { viewSchema: sessionProgressSchema, view: (state) => state },
|
|
293
|
-
//
|
|
294
|
-
|
|
330
|
+
// 字段变了(skills 变成 {name, manual}、compacts 加 manual 且只记成功、压缩 turn 可为 null),提升版本重建旧状态
|
|
331
|
+
// 7 → 8:compacts 新增 afterTurn(轮间/手动压缩显示「第 N 轮后」,替代原来的横杠)。
|
|
332
|
+
// 旧缓存里每条压缩记录没有这个字段,必须重放才能补出来。
|
|
333
|
+
stateVersion: 8,
|
|
295
334
|
}
|
|
296
335
|
|
|
297
336
|
/* ------------------------------------------------------------ sessionUsage */
|
|
@@ -328,8 +367,11 @@ function findUsageInStream(stream) {
|
|
|
328
367
|
}
|
|
329
368
|
|
|
330
369
|
function usageOf(event) {
|
|
331
|
-
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
|
|
332
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
|
|
333
375
|
let usage = event.type === 'assistant/message' ? data.usage : undefined
|
|
334
376
|
const fromStream = findUsageInStream(data.stream)
|
|
335
377
|
if (fromStream !== undefined) usage = fromStream
|
|
@@ -441,6 +483,38 @@ function addOverflow(overflow, model, parts, peak) {
|
|
|
441
483
|
}
|
|
442
484
|
}
|
|
443
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
|
+
|
|
444
518
|
const sessionUsageSchema = z.object({
|
|
445
519
|
models: z.record(z.string(), usageBucket),
|
|
446
520
|
calls: z.array(usageCall),
|
|
@@ -453,12 +527,27 @@ const sessionUsageSchema = z.object({
|
|
|
453
527
|
可空且可选 —— 万一某条路径没带上这两个键,也不至于让整个 state 过不了校验。 */
|
|
454
528
|
routeModel: z.string().nullable().optional(),
|
|
455
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(),
|
|
456
545
|
}).strict()
|
|
457
546
|
|
|
458
547
|
const sessionUsageProjection = {
|
|
459
548
|
key: 'desktopStatusbarUsage',
|
|
460
549
|
stateSchema: sessionUsageSchema,
|
|
461
|
-
init: () => ({ models: {}, calls: [], overflow: {}, 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 } }),
|
|
462
551
|
apply: (state, event) => {
|
|
463
552
|
const calls = Array.isArray(state.calls) ? state.calls : []
|
|
464
553
|
const turnOf = (event) => {
|
|
@@ -472,6 +561,20 @@ const sessionUsageProjection = {
|
|
|
472
561
|
if (state.current === null) return { ...state, turn: started }
|
|
473
562
|
return { ...state, calls, current: null, last: state.current, turn: started }
|
|
474
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
|
+
}
|
|
475
578
|
// 先记下"这次请求用的是哪个模型":没有 message 的事件(assistant/attempt)靠它补型号
|
|
476
579
|
const routed = routedModelOf(event)
|
|
477
580
|
const base = routed === null ? state : {
|
|
@@ -482,23 +585,24 @@ const sessionUsageProjection = {
|
|
|
482
585
|
const usage = usageOf(event)
|
|
483
586
|
if (usage === undefined || usage === null) return base
|
|
484
587
|
// 型号两条路:常规事件从 message.source 取;没有 message 的用上面记下的最近一次请求
|
|
485
|
-
const
|
|
588
|
+
const data = event.data === null || event.data === undefined ? {} : event.data
|
|
589
|
+
const message = data.message
|
|
486
590
|
const source = message === null || message === undefined ? null : message.source
|
|
487
591
|
const fromMessage = source !== null && source.kind === 'model' && typeof source.model === 'string'
|
|
488
|
-
|
|
489
|
-
|
|
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
|
+
// 拿不到型号:放弃这一笔,好过抛错把整条投影(以及同一次驱动的其它投影)带停
|
|
490
599
|
if (model.length === 0) return base
|
|
491
|
-
const provider =
|
|
492
|
-
?
|
|
493
|
-
: (typeof
|
|
494
|
-
|
|
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 : ''))
|
|
495
605
|
const parts = usageParts(usage)
|
|
496
|
-
const bucket = {
|
|
497
|
-
input: (currentModel === undefined ? 0 : currentModel.input) + parts.input,
|
|
498
|
-
cacheRead: (currentModel === undefined ? 0 : currentModel.cacheRead) + parts.cacheRead,
|
|
499
|
-
cacheWrite: (currentModel === undefined ? 0 : currentModel.cacheWrite) + parts.cacheWrite,
|
|
500
|
-
output: (currentModel === undefined ? 0 : currentModel.output) + parts.output,
|
|
501
|
-
}
|
|
502
606
|
const one = {
|
|
503
607
|
at: event.time,
|
|
504
608
|
model,
|
|
@@ -507,13 +611,102 @@ const sessionUsageProjection = {
|
|
|
507
611
|
cacheWrite: parts.cacheWrite,
|
|
508
612
|
output: parts.output,
|
|
509
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
|
+
}
|
|
510
703
|
const turn = turnOf(event)
|
|
511
704
|
// 轮次变化就重开本轮汇总:不能假设一定有 turn/start
|
|
512
705
|
const sameTurn = base.current !== null && Array.isArray(base.current.calls)
|
|
513
706
|
&& (turn === null || stateTurn === null || turn === stateTurn)
|
|
514
707
|
const prev = sameTurn ? base.current : null
|
|
515
708
|
const callsForTurn = prev === null ? [] : prev.calls
|
|
516
|
-
const
|
|
709
|
+
const turnSummary = {
|
|
517
710
|
provider,
|
|
518
711
|
model,
|
|
519
712
|
time: event.time,
|
|
@@ -522,27 +715,14 @@ const sessionUsageProjection = {
|
|
|
522
715
|
cacheWrite: (prev === null ? 0 : prev.cacheWrite) + one.cacheWrite,
|
|
523
716
|
output: (prev === null ? 0 : prev.output) + one.output,
|
|
524
717
|
}
|
|
525
|
-
const current = { ...
|
|
526
|
-
/* 追加后若超过 MAX_CALLS,最前面那几条会被丢掉。丢掉的是**真实用过的 token**,
|
|
527
|
-
必须把它们按档累计进 overflow,否则客户端逐条计价时会少算(见 addOverflow 说明)。
|
|
528
|
-
用合并前后的长度差算出丢了几条 —— 它们一定是最老的,也就是 calls 队首那几条。 */
|
|
529
|
-
const merged = appendCalls(calls, [one])
|
|
530
|
-
const droppedCount = calls.length + 1 - merged.length
|
|
531
|
-
let overflow = typeof base.overflow === 'object' && base.overflow !== null ? base.overflow : {}
|
|
532
|
-
for (let i = 0; i < droppedCount; i += 1) {
|
|
533
|
-
const gone = calls[i]
|
|
534
|
-
if (gone === undefined) break
|
|
535
|
-
overflow = addOverflow(overflow, gone.model, gone, isPeakTime(gone.at))
|
|
536
|
-
}
|
|
718
|
+
const current = { ...turnSummary, calls: callsForTurn.concat([one]) }
|
|
537
719
|
return {
|
|
538
720
|
...base,
|
|
721
|
+
...addCallTotals(base, one),
|
|
539
722
|
turn: turn === null ? stateTurn : turn,
|
|
540
|
-
calls: merged,
|
|
541
|
-
overflow,
|
|
542
723
|
current,
|
|
543
724
|
// 轮次变化时把上一轮结算进 last
|
|
544
725
|
last: prev === null && base.current !== null ? base.current : base.last,
|
|
545
|
-
models: { ...base.models, [model]: bucket },
|
|
546
726
|
}
|
|
547
727
|
},
|
|
548
728
|
wire: {
|
|
@@ -556,7 +736,14 @@ const sessionUsageProjection = {
|
|
|
556
736
|
// 整条投影抛错、该会话的账从此停摆。提升版本强制重放,把这些笔补回账本。
|
|
557
737
|
// 5 → 6:新增 overflow(被 MAX_CALLS 截掉那段的按档累计)。旧缓存里这个字段不存在,
|
|
558
738
|
// 长会话的费用会一直少算截掉的部分,必须重放才能补出来。
|
|
559
|
-
|
|
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,
|
|
560
747
|
}
|
|
561
748
|
|
|
562
749
|
/* -------------------------------------------------------- sessionTimeRange */
|
|
@@ -1327,6 +1514,10 @@ async function queryLocalAdapter(ctx, adapter, now, provider) {
|
|
|
1327
1514
|
isCredits: parsed.isCredits === true,
|
|
1328
1515
|
isPercent: parsed.isPercent === true,
|
|
1329
1516
|
parts: parsed.parts,
|
|
1517
|
+
/* 积分套餐明细(workbuddy 新插件的 credits.packages 投影,见 balance-api.js 的 parse)。
|
|
1518
|
+
字段名固定 rewards;**只有适配器真的给了才带上** —— 老插件/非积分平台没有这个键,
|
|
1519
|
+
客户端据此走原来的纯总数显示。失败态(上面几个分支)一律不带。 */
|
|
1520
|
+
...(parsed.rewards === undefined ? {} : { rewards: parsed.rewards }),
|
|
1330
1521
|
toppedUp: partAmount(parsed.parts, 'toppedUp'),
|
|
1331
1522
|
granted: partAmount(parsed.parts, 'granted'),
|
|
1332
1523
|
spent: partAmount(parsed.parts, 'spent'),
|
|
@@ -2339,23 +2530,48 @@ async function subagentIndexSnapshot(files) {
|
|
|
2339
2530
|
}
|
|
2340
2531
|
|
|
2341
2532
|
/**
|
|
2342
|
-
* 一段父会话日志里的"本轮起点"
|
|
2343
|
-
* `
|
|
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`。
|
|
2344
2560
|
*
|
|
2345
|
-
* 为什么不是"有 turn/start 就优先用它":用户消息比最后一条 turn/start 还新,说明
|
|
2346
|
-
* **新一轮已经开始、而它的 `turn/start` 还没落盘**;此时沿用旧的 turn/start 会把
|
|
2347
|
-
* **上一轮**的队友用量算进"本轮",方向正是用户不允许的"偏大"。取较新的那个时间
|
|
2348
|
-
* 只会把窗口往回收(宁少不多),符合"本轮只能小不能大"。
|
|
2349
2561
|
* 取**最大值**而不是"文件里最后一行":正常日志是追加写的,两者一致;万一有乱序行,
|
|
2350
2562
|
* 取最大值更稳。
|
|
2351
2563
|
*/
|
|
2352
2564
|
function lastTurnStartOfText(text) {
|
|
2353
2565
|
if (typeof text !== 'string' || text.length === 0) return null
|
|
2354
|
-
let
|
|
2566
|
+
let lastTurnStart = null
|
|
2567
|
+
let lastTurnEnd = null
|
|
2568
|
+
let lastUser = null
|
|
2355
2569
|
const lines = text.split('\n')
|
|
2356
2570
|
for (let i = 0; i < lines.length; i += 1) {
|
|
2357
2571
|
const line = lines[i]
|
|
2358
|
-
|
|
2572
|
+
/* 便宜的预筛:只解析可能相关的三种事件 */
|
|
2573
|
+
if (line.indexOf('"turn/start"') < 0 && line.indexOf('"turn/end"') < 0
|
|
2574
|
+
&& line.indexOf('"user/message"') < 0) continue
|
|
2359
2575
|
let event
|
|
2360
2576
|
try {
|
|
2361
2577
|
event = JSON.parse(line)
|
|
@@ -2363,10 +2579,29 @@ function lastTurnStartOfText(text) {
|
|
|
2363
2579
|
continue
|
|
2364
2580
|
}
|
|
2365
2581
|
if (event === null || typeof event !== 'object' || typeof event.time !== 'number') continue
|
|
2366
|
-
if (event.type
|
|
2367
|
-
|
|
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
|
|
2368
2597
|
}
|
|
2369
|
-
|
|
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
|
|
2370
2605
|
}
|
|
2371
2606
|
|
|
2372
2607
|
/**
|
|
@@ -2607,9 +2842,11 @@ function subagentFileUsage(one, fromMs, fingerprint) {
|
|
|
2607
2842
|
* (只收紧、不放大;`since=0` 是显式整会话,不收敛);真收敛了会带
|
|
2608
2843
|
* `sinceClamped: true`(`sinceSource` 仍是 `client`);
|
|
2609
2844
|
* · `host`:没传 → host 读**父会话自己**的日志推一个起点(最后一条 `turn/start`,
|
|
2610
|
-
*
|
|
2611
|
-
* `turn/start`
|
|
2612
|
-
*
|
|
2845
|
+
* 但和最后一条**真实用户输入**的 `user/message` 取较新的那个 —— 后者更新说明新一轮
|
|
2846
|
+
* 已开始、其 `turn/start` 还没落盘,沿用旧起点会把上一轮算进来;同轮注入的
|
|
2847
|
+
* subagent-settled / team-message / tool-jobs 等通知不算,见 lastTurnStartOfText);
|
|
2848
|
+
* 而且**推出来的起点之后必须真的有用量事件**,否则说明起点无从验证,同样当
|
|
2849
|
+
* `none`(宁少不多);
|
|
2613
2850
|
* · `none`:父会话日志缺失/读不出,或推出来的起点事后没有事件 → 本轮**什么都不并**
|
|
2614
2851
|
* (整条返回零值),绝不退回整会话 —— 用户规则:本轮只能小不能大;
|
|
2615
2852
|
* 要整会话(「总计」那一项)就显式传 `since=0`。
|
|
@@ -2656,8 +2893,9 @@ async function collectSubagentUsageNow(sessionId, sinceMs) {
|
|
|
2656
2893
|
|
|
2657
2894
|
/* 窗口起点从哪来:① 客户端给了(它精确知道自己那一轮)→ `client`;
|
|
2658
2895
|
② 没给就问 host 自己 —— 读**父会话自己**的日志取最后一条 turn/start(或更新的
|
|
2659
|
-
user/message,见 lastTurnStartOfText
|
|
2660
|
-
|
|
2896
|
+
真实用户 user/message,见 lastTurnStartOfText;同轮注入的通知不算)→ `host`;
|
|
2897
|
+
③ 两者都没有(父会话文件缺失/读不出)→ `none`:**本轮什么都不并**
|
|
2898
|
+
(用户规则:本轮只能小不能大,绝不能拿整会话的量顶上)。
|
|
2661
2899
|
另外 host 推的起点还要过一道"事后有事件"的验证(见下面聚合之后的那段)。 */
|
|
2662
2900
|
const parentEntry = index.byId.get(sessionId)
|
|
2663
2901
|
let sinceSource = 'none'
|
|
@@ -2739,7 +2977,9 @@ async function collectSubagentUsageNow(sessionId, sinceMs) {
|
|
|
2739
2977
|
total.stats.ttftSteps += agg.stats.ttftSteps
|
|
2740
2978
|
total.stats.decodeMs += agg.stats.decodeMs
|
|
2741
2979
|
total.stats.decodeTokens += agg.stats.decodeTokens
|
|
2742
|
-
|
|
2980
|
+
/* 每 16 个让出一次事件循环:146+ 个队友时 4 个一让太碎,宿主每秒都在被问,
|
|
2981
|
+
让出次数少一点能明显压低单次聚合的调度开销(文件用量已按文件缓存,重算只是少数)。 */
|
|
2982
|
+
if (i % 16 === 15) await new Promise((resolve) => setImmediate(resolve))
|
|
2743
2983
|
}
|
|
2744
2984
|
/* host 推出来的起点必须有"事后证据"才算有效。**证据有两条,任一条成立即可**:
|
|
2745
2985
|
① 父会话**自己**在窗口内有用量事件(fromMs 之后有 assistant/message 或
|
|
@@ -3424,6 +3664,44 @@ function appendTipDiag(body) {
|
|
|
3424
3664
|
}
|
|
3425
3665
|
}
|
|
3426
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
|
+
|
|
3427
3705
|
/* 台账只在「有新数据」时才重扫(文件 mtime+size 没变就跳过),因此这层只是省掉重复的 stat。
|
|
3428
3706
|
不挂定时器:打开活跃总览面板时会带 force=1 现扫一次,数据只在被看的时候才需要最新。 */
|
|
3429
3707
|
const WEEK_USAGE_CACHE_MS = 30000
|
|
@@ -3799,6 +4077,9 @@ async function handleApi(req, res, ctx) {
|
|
|
3799
4077
|
reason: last.reason === undefined ? null : last.reason,
|
|
3800
4078
|
from: last.from === undefined ? null : last.from,
|
|
3801
4079
|
to: last.to === undefined ? null : last.to,
|
|
4080
|
+
/* 找不到 pnpm 时把「试过哪些路径」一并交给界面:排查这类问题时最费时间的就是
|
|
4081
|
+
手工逐条 Test-Path,这里直接摊开。其它失败原因下是空数组。 */
|
|
4082
|
+
tried: Array.isArray(last.tried) === true ? last.tried : [],
|
|
3802
4083
|
},
|
|
3803
4084
|
})
|
|
3804
4085
|
return
|
|
@@ -3911,6 +4192,20 @@ async function handleApi(req, res, ctx) {
|
|
|
3911
4192
|
sendJson(res, 200, { ok: true })
|
|
3912
4193
|
return
|
|
3913
4194
|
}
|
|
4195
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/update-failure') {
|
|
4196
|
+
/* 客户端侧更新失败的上报口。为什么需要它:宿主侧失败已经在
|
|
4197
|
+
`update: 结果 {...}` 那条日志里了,但**客户端自己**的失败(fetch 被代理拦、
|
|
4198
|
+
响应解不出来、网络断)发生在浏览器里,宿主完全不知道 —— 用户点完只看到
|
|
4199
|
+
「更新失败」,日志里查不到任何痕迹,想用 AI 排查也无从下手。
|
|
4200
|
+
这里把它补成一条**结构化日志行**:既有给人看的中文摘要,也有完整 JSON。
|
|
4201
|
+
不做任何状态变更、不重试安装,只落日志,所以是纯诊断面。 */
|
|
4202
|
+
if (req.method === 'POST') {
|
|
4203
|
+
const body = await readJsonBody(req)
|
|
4204
|
+
logHost(ctx, 'update: 客户端失败 ' + describeClientUpdateFailure(body))
|
|
4205
|
+
}
|
|
4206
|
+
sendJson(res, 200, { ok: true })
|
|
4207
|
+
return
|
|
4208
|
+
}
|
|
3914
4209
|
if (url.pathname !== '/dsh-desktop-statusbar/api/balance') {
|
|
3915
4210
|
sendJson(res, 404, { ok: false, reason: 'not-found' })
|
|
3916
4211
|
return
|