jbc-dssi 1.1.0 → 1.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jbc-dssi",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "type": "module",
5
5
  "description": "鲸编程 - 不只是手机遥控操作电脑,而是将电脑级的Agent能力装进手机",
6
6
  "keywords": [
@@ -71,6 +71,52 @@ function normalizeUsage(usage) {
71
71
  return any ? out : null;
72
72
  }
73
73
 
74
+ /**
75
+ * 把一次模型调用的 usage 累加进本轮 —— **2026-10-31 修的就是这里**。
76
+ *
77
+ * 【为什么必须累加】
78
+ * 一个 turn 会被拆成多个 step,**每一步都发一条 assistant/message、各带这一步的
79
+ * usage**。旧实现 `lastUsage = usage` 只留最后一条,于是面板出现自相矛盾的数字:
80
+ * 「共 10 步」却只有 60,686 tok —— 而第 10 步单独就 60,686(59,008+240+1,438),
81
+ * 前 9 步全丢。真机那一轮(2026-10-31 21:26:57~21:27:45,群会话 session-66663e11)
82
+ * 逐步核对:10 步合计 539,775 tok(缓存命中 521,728 / 未命中 9,159 / 输出 8,888),
83
+ * 谷价 ≈¥0.0551;旧口径只显示 60,686 tok / ≈¥0.0072,少算约 9 步。
84
+ *
85
+ * 【口径】与单聊(`android/.../data/Transcript.kt` 的 `addUsage`)和 Web UI 的
86
+ * `deriveTurnTokenUsage` 完全一致:每条 assistant/message 的 usage 各自相加;
87
+ * totalTokens 也逐条相加,**不**用 input+output 重算(会与 Web 差出数字)。
88
+ * 同一 step 的重试会产生多条 assistant/message:用量按 attempt 累加(与单聊一致),
89
+ * 步数则由调用方按 step 号去重。
90
+ */
91
+ function sumUsage(acc, u) {
92
+ if (!acc) return u;
93
+ return {
94
+ inputTokens: acc.inputTokens + u.inputTokens,
95
+ outputTokens: acc.outputTokens + u.outputTokens,
96
+ cacheReadTokens: acc.cacheReadTokens + u.cacheReadTokens,
97
+ cacheWriteTokens: acc.cacheWriteTokens + u.cacheWriteTokens,
98
+ totalTokens: acc.totalTokens + u.totalTokens,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * 把落库的「逐步用量」数组合成一行用量 —— **读侧唯一口径**(2026-11-01 存储结构修复)。
104
+ *
105
+ * 只在记录带 `stepUsages`(新格式)时使用。旧记录没这个数组,调用方必须原样使用
106
+ * 历史 `usage`,**不允许**拿它去"猜"或"补" —— 按负责人决定历史不救、不做回填。
107
+ *
108
+ * 返回 null:整轮一条有效用量都没有(老 DSH / 这轮没统计到)→ 手机侧不显示用量项。
109
+ */
110
+ function aggregateStepUsages(stepUsages) {
111
+ if (!Array.isArray(stepUsages)) return null;
112
+ let acc = null;
113
+ for (const one of stepUsages) {
114
+ const u = normalizeUsage(one);
115
+ if (u) acc = sumUsage(acc, u);
116
+ }
117
+ return acc;
118
+ }
119
+
74
120
  /**
75
121
  * @param {object} opts
76
122
  * @param {object} opts.ctx DSH 上下文(sessionController 公开方法)
@@ -130,7 +176,11 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
130
176
 
131
177
  /**
132
178
  * 投递一轮给一个成员,等回复完成后 settle。
133
- * @returns {Promise<{text?:string, failed?:string, missed?:boolean, usage?:object|null, steps?:number}>}
179
+ *
180
+ * @returns {Promise<{text?:string, failed?:string, missed?:boolean,
181
+ * usage?:object|null, steps?:number, stepUsages?:Array<object>}>}
182
+ * `stepUsages` = 本轮每一步的用量(每一步一条,按到达顺序);它和 `usage`
183
+ * (各步求和)一起进房间日志,供读侧还原与兼容老客户端。
134
184
  */
135
185
  function deliver(group, member, prompt, attachments = {}) {
136
186
  return new Promise((resolve) => {
@@ -156,7 +206,21 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
156
206
  let prompted = false;
157
207
  let minTurn = null;
158
208
  let lastText = '';
159
- let lastUsage = null;
209
+ /*
210
+ * 本轮用量 = 各 step 的 usage **累加**(见 [sumUsage])。
211
+ *
212
+ * 2026-10-31 之前这里是 `lastUsage`(只留最后一条 assistant/message),
213
+ * 于是「共 N 步」配的是"最后一步"的 token,步数越多的轮次少算越多。
214
+ */
215
+ let turnUsage = null;
216
+ /*
217
+ * 本轮**逐步**用量(2026-11-01 新增):每一步一条,与 turnUsage 同源。
218
+ *
219
+ * 为什么存这个:旧存储只留聚合值,刷新/重进后无法还原「每一步花了多少」,
220
+ * 历史记录也就永远只能信当时写下的那个数。逐步入库后,读侧可以用它求和还原 ——
221
+ * 面板在实时、刷新、重进、换端任何时刻都是同一份数据的计算结果。
222
+ */
223
+ const stepUsages = [];
160
224
  /*
161
225
  * 「共 N 步」的账:turn → 这一轮出现过的 step 号集合。
162
226
  *
@@ -227,13 +291,21 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
227
291
  // 工具调用那几步没有 text,不能拿它们当回答;有正文才覆盖。
228
292
  const text = assistantText(event);
229
293
  if (text) lastText = text;
230
- const usage = normalizeUsage(data.usage);
231
- if (usage) lastUsage = usage;
232
294
  /*
233
295
  * 步数:**所有** assistant/message 都要记,包括只调工具、不画块的那几步
234
296
  * (与单聊一致 —— token 大头正是这些步骤花掉的)。
235
297
  */
236
298
  const step = typeof data.step === 'number' ? data.step : -1;
299
+ const usage = normalizeUsage(data.usage);
300
+ if (usage) {
301
+ /*
302
+ * 逐步入库:步号 + 这一步的五个 token 字段。
303
+ * 同一步重试会各存一条(用量按 attempt 累加,与单聊/Web 一致),
304
+ * 步数仍由 turnStepNums 按 step 号去重,两者互不影响。
305
+ */
306
+ stepUsages.push({ step, ...usage });
307
+ turnUsage = sumUsage(turnUsage, usage);
308
+ }
237
309
  if (step >= 0 && typeof data.turn === 'number') {
238
310
  if (!turnStepNums.has(data.turn)) turnStepNums.set(data.turn, new Set());
239
311
  turnStepNums.get(data.turn).add(step);
@@ -249,7 +321,7 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
249
321
  */
250
322
  const endTurn = typeof data.turn === 'number' ? data.turn : lastTurn;
251
323
  const steps = turnStepNums.get(endTurn)?.size ?? 0;
252
- settle({ text: lastText, usage: lastUsage, steps });
324
+ settle({ text: lastText, usage: turnUsage, steps, stepUsages });
253
325
  }
254
326
  ac.abort();
255
327
  } else if (event?.type === 'turn/error' || event?.type === 'turn/failed') {
@@ -308,7 +380,21 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
308
380
  });
309
381
  }
310
382
 
311
- /** 把一次投递的结果落成房间行(成员/主管同一套:手机侧按 memberId 显示名字与主管标)。 */
383
+ /**
384
+ * 把一次投递的结果落成房间行(成员/主管同一套:手机侧按 memberId 显示名字与主管标)。
385
+ *
386
+ * 房间行里的「本轮用量」有两种格式,**读侧必须都认**:
387
+ *
388
+ * 旧格式(2026-10-31 及以前落盘的行,只保留聚合值、无法重算):
389
+ * { usage: {inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, totalTokens}, steps: 10 }
390
+ * 新格式(2026-11-01 起,多一个逐步数组):
391
+ * { usage: {...恒等于逐步求和...}, steps: 10,
392
+ * stepUsages: [{ step: 1, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, totalTokens }, ...] }
393
+ *
394
+ * `usage` 新格式里照写:给还没升级的老手机读(它们只认这个字段)。
395
+ * 新手机的 GroupStore 优先用 `stepUsages` 求和 —— 刷新/重进后与实时同源。
396
+ * 旧行没有 `stepUsages`:读侧原样显示历史值,不补 0、不重算、不报错(历史不救)。
397
+ */
312
398
  function emitReplyLine(group, round, member, result) {
313
399
  const text = String(result.text ?? '').trim() || '[未回应]';
314
400
  emitLine(group.id, {
@@ -319,6 +405,10 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
319
405
  text,
320
406
  usage: result.usage ?? null,
321
407
  steps: result.steps ?? 0,
408
+ // 只有真的收到过 usage 才写这个字段;否则 JSON 里不出现(= 老格式,读侧回落)
409
+ stepUsages: Array.isArray(result.stepUsages) && result.stepUsages.length > 0
410
+ ? result.stepUsages
411
+ : undefined,
322
412
  round: round?.rounds ?? 0,
323
413
  });
324
414
  round.totalTurns += 1;
@@ -560,9 +650,21 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
560
650
  }
561
651
  }
562
652
 
563
- /** 打开群聊页时拉房间历史。 */
653
+ /**
654
+ * 打开群聊页时拉房间历史。
655
+ *
656
+ * 【读侧口径(2026-11-01)】
657
+ * 带 `stepUsages` 的新记录:用量由逐步数据**当场求和**再下发 —— 刷新/重进拿到的
658
+ * 值和实时那一刻完全同源(这份和也是 `usage` 字段的来源,二者恒等)。
659
+ * 没有 `stepUsages` 的旧记录:**原样返回**,显示当时写下的历史值。按负责人决定
660
+ * 历史不救 —— 这里不猜、不补 0、不拿别的字段假装重算。
661
+ */
564
662
  function roomSnapshot(group) {
565
- return (state.rooms[group.id] ?? []).slice(-ROOM_LOG_MAX);
663
+ return (state.rooms[group.id] ?? []).slice(-ROOM_LOG_MAX).map((line) => {
664
+ if (!Array.isArray(line?.stepUsages) || line.stepUsages.length === 0) return line;
665
+ const usage = aggregateStepUsages(line.stepUsages);
666
+ return usage ? { ...line, usage } : line;
667
+ });
566
668
  }
567
669
 
568
670
  return { handleUserMessage, roomSnapshot, ensureGroupSession };