dsh-mobile-plugin 0.4.1 → 0.4.4

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.
Files changed (41) hide show
  1. package/README.md +71 -74
  2. package/dsh-mobile-credentials/lib/credentials.js +39 -16
  3. package/lib/impl.js +0 -4
  4. package/lib/index.js +1 -1
  5. package/lib/mobile/allowed-zone.js +100 -0
  6. package/lib/mobile/archive-clean.js +4 -3
  7. package/lib/mobile/attachments.js +5 -1
  8. package/lib/mobile/file-read.js +168 -0
  9. package/lib/mobile/history-derive.js +67 -98
  10. package/lib/mobile/local-images.js +38 -7
  11. package/lib/mobile/model-vendors.js +105 -0
  12. package/lib/mobile/plugin-market.js +475 -0
  13. package/lib/mobile/plugin-self.js +1 -1
  14. package/lib/mobile/plugin-update.js +1 -1
  15. package/lib/mobile/relay-control.js +131 -7
  16. package/lib/mobile/session-cwd.js +28 -0
  17. package/lib/mobile/session-file.js +1 -12
  18. package/lib/mobile/stats-collect.cjs +146 -15
  19. package/lib/mobile/stats-ledger.cjs +249 -59
  20. package/lib/mobile/stats-scheduler.cjs +41 -0
  21. package/lib/mobile/stream.js +30 -110
  22. package/lib/mobile/uploads.js +68 -5
  23. package/lib/mobile/vendor-logos.cjs +30 -0
  24. package/lib/mobile/wire-cut.js +212 -0
  25. package/lib/panel/index.html +2 -2
  26. package/lib/relay/apply-ledger.cjs +52 -0
  27. package/lib/relay/core.js +14 -3
  28. package/lib/relay/migrate.js +90 -0
  29. package/lib/relay/protocol.js +4 -0
  30. package/lib/relay/verify.js +52 -0
  31. package/lib/routes/control-plane.js +12 -3
  32. package/lib/routes/mobile-api.js +28 -2
  33. package/lib/session-cleanup.cjs +10 -0
  34. package/lib/shared/device-headers.js +12 -4
  35. package/lib/shell/updater.js +2 -2
  36. package/lib/sidecar-facade-worker.cjs +55 -8
  37. package/lib/sidecar-facade.cjs +11 -1
  38. package/lib/sidecar-lifecycle.cjs +14 -1
  39. package/lib/update-install.cjs +13 -6
  40. package/package.json +1 -1
  41. package/lib/auto-archive.cjs +0 -98
@@ -8,8 +8,13 @@
8
8
  *
9
9
  * 口径(与桌面 dsh-context 的每日账本一致,只保留手机用得着的部分):一天的账落在「观测到
10
10
  * 增量」的宿主本地日期上;计费 token = 未命中输入 + 缓存读 + 缓存写 + 输出,四个桶分开存;
11
- * 会话卡片只留最近 `MAX_SESSIONS` 条,更老的退场(`retired`)但量仍留在按天账上,水位也留着
12
- * —— 水位一丢,那条会话下次有动作会被当成首采、把整段历史重记一遍。
11
+ * 每条会话一条记录,范围内有量的都上卡片 —— 卡片条数就是页面那个「会话」数字,所以
12
+ * **不按展示需要裁剪**(裁了那个数字就变成上限值了,不再是真的条数)。
13
+ *
14
+ * 第三张表是模型维度 `models`:按「渠道 + 模型」攒量,服务统计页那块「模型用量」。它账的是
15
+ * 同一个增量 —— 也就是说这里没有「按模型拆过一遍」的原始数据,模型名取自**采集那一刻**这条
16
+ * 会话正在用的那个选择(见 stats-collect.cjs 文件头),会话中途换模型的那一段整体归到换后的
17
+ * 那个名下。这是水位法本来就有的近似,不是这里另加的简化。
13
18
  *
14
19
  * 只做纯数据(读写 + 折算),不碰网络;采集在 stats-collect.cjs,路由在 stats.js。
15
20
  */
@@ -18,20 +23,20 @@ const fs = require('node:fs');
18
23
  const path = require('node:path');
19
24
 
20
25
  const paths = require('../shared/paths');
26
+ const { identifyVendor, vendorLogoOf } = require('./model-vendors.js');
21
27
 
22
28
  /** 账本格式版本:将来改结构时据此决定是丢弃还是迁移。 */
23
29
  const VERSION = 1;
24
30
 
25
- /** 会话卡片的条数上限(按最后活动时间保留最近的)。 */
26
- const MAX_SESSIONS = 200;
27
-
28
31
  /**
29
- * 退场卡片(已不在最近 200 条里、只留水位与身份)的条数上限。
32
+ * 账本里会话记录的条数上限(按最后活动时间删最老的)。
30
33
  *
31
- * 退场记录不展示,存在只为「它再活过来时别重记一遍历史」;每条一百来字节,
32
- * 留这么多足够覆盖一台机器几年的会话,再老的真删掉。
34
+ * 这是**文件存储**的兜底,不是统计口径:页面上那个「会话」数字就是这条记录的条数,按展示
35
+ * 需要裁到两百就会变成一个永远不动的上限值。所以档位抬到一台机器几年也用不完 —— 一台机器
36
+ * 一天最多新增百来条(子代理各算一条),四千条约等于一个月的重度使用;真要顶到,删的也是
37
+ * 最老的整条记录,按天的总账不受影响。
33
38
  */
34
- const MAX_RETIRED = 2000;
39
+ const MAX_RECORDS = 4000;
35
40
 
36
41
  /** 按天的账保留天数(约三年;一天一百多字节,超出的从最老的开始裁)。 */
37
42
  const MAX_DAYS = 1100;
@@ -41,7 +46,8 @@ const MAX_DAYS = 1100;
41
46
  *
42
47
  * 它存在只为回答「这张卡片在这个范围里用了多少」:页面的三档范围最大 30 天,
43
48
  * 再留一倍余量足够;更老的日期按天的总账仍然还在,只是卡片不再按那条会话拆得开。
44
- * 不封顶的话,两百条会话 × 三年就是一份几十 MB 的 JSON,每次采集都要整份读写。
49
+ * 不封顶的话,几千条会话 × 三年就是一份几十 MB 的 JSON,每次采集都要整份读写。
50
+ * 超过这个窗口没再动过的记录会被 `trim` 瘦身(明细、最后一句、上下文构成一起丢掉)。
45
51
  */
46
52
  const MAX_DAILY_DAYS = 62;
47
53
 
@@ -57,6 +63,15 @@ const HEATMAP_DAYS = 52 * 7;
57
63
  /** 每张会话卡片上留的最后一条消息摘要长度:卡片一行装得下的大致字数。 */
58
64
  const LAST_TEXT_MAX = 120;
59
65
 
66
+ /**
67
+ * 模型标识的落盘上限(与 `session-models.js` 下发目录时那份截断一致)。
68
+ *
69
+ * 渠道 / 模型的 id 与展示名由宿主给,长度不可控;账本每次采集都要整份读写,一条几百 KB 的
70
+ * 名字会把文件撑成没法解释的大小。id 与目录同一套上限,两边的 id 才拼得出同一个桶名。
71
+ */
72
+ const MODEL_PROVIDER_MAX = 256;
73
+ const MODEL_NAME_MAX = 512;
74
+
60
75
  /** 用量字段:四个 token 桶 + 轮次/步数/耗时。顺序即写盘顺序,便于人工比对。 */
61
76
  const USAGE_KEYS = [
62
77
  'uncachedInputTokens',
@@ -80,7 +95,56 @@ function emptyDay() {
80
95
  }
81
96
 
82
97
  function emptyLedger() {
83
- return { version: VERSION, updatedAt: 0, days: {}, sessions: {}, composition: null };
98
+ return { version: VERSION, updatedAt: 0, days: {}, sessions: {}, models: {}, composition: null };
99
+ }
100
+
101
+ function textOf(value, max) {
102
+ return typeof value === 'string' ? value.slice(0, max) : '';
103
+ }
104
+
105
+ /**
106
+ * 模型名 → 桶名。任一为空都算「不知道是哪个模型」,返回空串表示不要建桶。
107
+ *
108
+ * 别把空的那半拼成 `\u0000` 之后当垃圾筒:宿主读不到当前模型、或某个插件不上报渠道时,
109
+ * 所有那类用量都会挤进同一个 `\u0000xxx` 桶,页面上一条「模型」指向不存在的东西。
110
+ */
111
+ function modelKeyOf(provider, model) {
112
+ return provider && model ? `${provider}\u0000${model}` : '';
113
+ }
114
+
115
+ /**
116
+ * 模型选择的清洗:provider / model 缺一个才算没有,展示名缺了留空串。
117
+ *
118
+ * 展示名不算有效性判据:目录里那条模型下架了、渠道被卸载了,都只是「名字对不上」,这条用量
119
+ * 该记在哪个渠道哪个模型名下仍然是确定的。以前把「展示名齐不齐」当成归属是否有效的判据,
120
+ * 于是这种记录一重启读盘就变回「没有模型」,下一轮又触发一次老账本回填,同一段累计量在
121
+ * 模型桶里翻倍。
122
+ *
123
+ * 展示名按「非空才覆盖」的规矩用,空串不能把已经记下的名字擦掉。
124
+ */
125
+ function sanitizeModelSelection(source) {
126
+ if (!source || typeof source !== 'object') return null;
127
+ const provider = textOf(source.provider, MODEL_PROVIDER_MAX);
128
+ const model = textOf(source.model, MODEL_NAME_MAX);
129
+ if (!provider || !model) return null;
130
+ return {
131
+ provider,
132
+ providerName: textOf(source.providerName, MODEL_NAME_MAX),
133
+ model,
134
+ modelName: textOf(source.modelName, MODEL_NAME_MAX),
135
+ };
136
+ }
137
+
138
+ function emptyModelBucket(selection) {
139
+ return {
140
+ provider: selection.provider,
141
+ providerName: selection.providerName,
142
+ model: selection.model,
143
+ modelName: selection.modelName,
144
+ billed: emptyUsage(),
145
+ daily: {},
146
+ lastAt: 0,
147
+ };
84
148
  }
85
149
 
86
150
  /** 账本文件:`$DSH_HOME/mobile-stats.json`(DSH_HOME 在调用时解析,便于测试注入)。 */
@@ -121,6 +185,18 @@ function startDayOf(range, now) {
121
185
  return null;
122
186
  }
123
187
 
188
+ /**
189
+ * `YYYY-MM-DD` → 那天本地零点的毫秒数。
190
+ *
191
+ * 模型桶只有「记在哪一天」,没有更细的时刻(增量是按会话水位差算的,采集那一刻才知道量,
192
+ * 不知道它具体花在哪几分钟)。老账本回填那一笔只有日期,`lastAt` 只能落到那天零点上 ——
193
+ * 它只用于「计费量相同」时的排序,差半天不要紧。
194
+ */
195
+ function dayStartAt(day) {
196
+ const [year, month, date] = day.split('-').map((part) => Number(part));
197
+ return new Date(year, month - 1, date).getTime();
198
+ }
199
+
124
200
  function sanitizeUsage(source) {
125
201
  const usage = emptyUsage();
126
202
  if (!source || typeof source !== 'object') return usage;
@@ -178,19 +254,48 @@ function sanitizeLedger(raw) {
178
254
  lastAt: num(value.lastAt),
179
255
  collectedAt: num(value.collectedAt),
180
256
  gone: value.gone === true,
181
- // 退场 = 这条卡片已经不在最近 MAX_SESSIONS 里,只留水位与身份。
182
- retired: value.retired === true,
257
+ // 上一次读到的那条会话 `updatedAt`:定期采集靠它跳过「一点没动」的会话。
258
+ seenUpdatedAt: num(value.seenUpdatedAt),
183
259
  billed,
184
260
  // 这条会话自己那份按天用量:卡片上的数字要跟着页面范围走(见 `rangeUsageOf`)。
185
261
  daily,
186
262
  // 最后一次观测到的上下文构成:跟着会话走,不再全局只留一份。
187
263
  composition: readComposition(value.composition),
264
+ // 采集那一刻这条会话用的渠道 + 模型。升级前的老记录没有这一栏,当没有。
265
+ model: sanitizeModelSelection(value.model),
188
266
  // 水位是「宿主那边这条会话的累计值」,缺省为 null 表示还没采过(首采全量入账)。
189
267
  watermark: value.watermark && typeof value.watermark === 'object' ? sanitizeUsage(value.watermark) : null,
190
268
  };
191
269
  }
192
270
  }
193
271
 
272
+ // 模型维度。老账本没有这张表是正常的(升级前没记过),按空处理 —— 别因为它缺字段就把
273
+ // 整份账本当坏的丢掉,那是 `version` 的事。
274
+ if (raw.models && typeof raw.models === 'object') {
275
+ for (const value of Object.values(raw.models)) {
276
+ const selection = sanitizeModelSelection(value);
277
+ if (!selection) continue;
278
+ const key = modelKeyOf(selection.provider, selection.model);
279
+ if (!key) continue;
280
+ const daily = {};
281
+ if (value.daily && typeof value.daily === 'object') {
282
+ for (const [day, usageOfDay] of Object.entries(value.daily)) {
283
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !usageOfDay || typeof usageOfDay !== 'object') continue;
284
+ daily[day] = sanitizeUsage(usageOfDay);
285
+ }
286
+ }
287
+ ledger.models[key] = {
288
+ provider: selection.provider,
289
+ providerName: selection.providerName,
290
+ model: selection.model,
291
+ modelName: selection.modelName,
292
+ billed: sanitizeUsage(value.billed),
293
+ daily,
294
+ lastAt: num(value.lastAt),
295
+ };
296
+ }
297
+ }
298
+
194
299
  if (raw.composition && typeof raw.composition === 'object') {
195
300
  // 老账本只有一份全局构成(每轮被最后读完的那条覆盖)。留着当兜底:
196
301
  // 没有任何一条会话带自己的构成时才用它。
@@ -237,35 +342,53 @@ function forgetLedger() {
237
342
  }
238
343
 
239
344
  /**
240
- * 裁剪:会话卡片按最后活动时间保留最近的。
345
+ * 裁剪:只防文件无限长,不动「有多少条会话」这个口径。
241
346
  *
242
- * 超出的卡片**不删记录、只标退场**(`retired`):水位与身份留着,那条会话以后再活跃
243
- * 就不会被当成首采、把整段历史重记一遍。退场记录自己也封顶,最老的先删。
347
+ * 两道都跟**存储**有关,跟页面显示无关:
348
+ * - 每条记录自己那份按天用量只服务三档范围里最大的 30 天,最老的日子从它自己的账上裁掉,
349
+ * 按天的总账不动;
350
+ * - 超过 `MAX_DAILY_DAYS` 没再动过的记录顺手瘦身:明细、最后一句、上下文构成一起丢掉,
351
+ * 只留身份 + 累计 + 水位。它照旧算一条会话(`all` 那档用累计值),只是最近这几档本来就
352
+ * 轮不到它,留着一百多字节的摘要和几十天明细纯属占地方。
353
+ *
354
+ * 记录条数超过 `MAX_RECORDS` 才真删,删最老的整条:水位跟着一起没,那条会话以后再活跃会
355
+ * 被当成首采、把整段历史重记一遍 —— 所以档位要抬到几年也用不完,别拿它当展示上限。
244
356
  */
245
357
  function trim(ledger) {
246
- const sessionIds = Object.keys(ledger.sessions);
247
- if (sessionIds.length > MAX_SESSIONS) {
248
- // 已经在退场里的不参与排位:它们本来就该往后排,重新算一遍只会来回抖动。
249
- const live = sessionIds.filter((id) => ledger.sessions[id].retired !== true);
250
- const retired = sessionIds.filter((id) => ledger.sessions[id].retired === true);
251
- if (live.length > MAX_SESSIONS) {
252
- live.sort((a, b) => num(ledger.sessions[b].lastAt) - num(ledger.sessions[a].lastAt));
253
- for (const id of live.slice(MAX_SESSIONS)) ledger.sessions[id].retired = true;
358
+ const staleBefore = addDays(dayKeyOf(Date.now()), -MAX_DAILY_DAYS);
359
+ for (const record of Object.values(ledger.sessions)) {
360
+ if (record.daily) {
361
+ const own = Object.keys(record.daily).sort();
362
+ if (own.length > MAX_DAILY_DAYS) {
363
+ for (const day of own.slice(0, own.length - MAX_DAILY_DAYS)) delete record.daily[day];
364
+ }
254
365
  }
255
- // 退场池按最后活动时间留最新的那一批,其余真删。
256
- const pool = [...retired, ...live.slice(MAX_SESSIONS)];
257
- if (pool.length > MAX_RETIRED) {
258
- pool.sort((a, b) => num(ledger.sessions[b].lastAt) - num(ledger.sessions[a].lastAt));
259
- for (const id of pool.slice(MAX_RETIRED)) delete ledger.sessions[id];
366
+ const lastDay = num(record.lastAt) > 0 ? dayKeyOf(num(record.lastAt)) : '';
367
+ if (lastDay && lastDay < staleBefore) {
368
+ record.daily = {};
369
+ record.lastText = '';
370
+ record.composition = null;
260
371
  }
261
372
  }
262
- // 每条会话自己那份按天用量也有上限:它只服务「这张卡片在这个范围里用了多少」,
263
- // 最老的那些日子从它自己的账上裁掉,按天的总账不动。
264
- for (const record of Object.values(ledger.sessions)) {
265
- if (!record.daily) continue;
266
- const own = Object.keys(record.daily).sort();
267
- if (own.length > MAX_DAILY_DAYS) {
268
- for (const day of own.slice(0, own.length - MAX_DAILY_DAYS)) delete record.daily[day];
373
+ const sessionIds = Object.keys(ledger.sessions);
374
+ if (sessionIds.length > MAX_RECORDS) {
375
+ sessionIds.sort((a, b) => num(ledger.sessions[b].lastAt) - num(ledger.sessions[a].lastAt));
376
+ for (const id of sessionIds.slice(MAX_RECORDS)) delete ledger.sessions[id];
377
+ }
378
+ // 模型桶只裁它自己那份按天用量,不按条数裁:一台机器用过的「渠道 + 模型」组合是十几个的
379
+ // 量级,裁条数没有意义;而 `daily` 与每条会话那份一样只服务最近这几档范围。
380
+ if (ledger.models && typeof ledger.models === 'object') {
381
+ for (const [key, bucket] of Object.entries(ledger.models)) {
382
+ if (!bucket || typeof bucket !== 'object') {
383
+ delete ledger.models[key];
384
+ continue;
385
+ }
386
+ const own = Object.keys(bucket.daily || {}).sort();
387
+ if (own.length > MAX_DAILY_DAYS) {
388
+ for (const day of own.slice(0, own.length - MAX_DAILY_DAYS)) delete bucket.daily[day];
389
+ }
390
+ // 一点量都没有、明细也裁空了的桶留着只会让页面出现一张全 0 的卡。
391
+ if (billedOf(bucket.billed) === 0 && Object.keys(bucket.daily || {}).length === 0) delete ledger.models[key];
269
392
  }
270
393
  }
271
394
  const days = Object.keys(ledger.days).sort();
@@ -285,20 +408,55 @@ function emptySession(at) {
285
408
  lastAt: 0,
286
409
  collectedAt: 0,
287
410
  gone: false,
288
- retired: false,
411
+ seenUpdatedAt: 0,
289
412
  billed: emptyUsage(),
290
413
  daily: {},
291
414
  composition: null,
415
+ model: null,
292
416
  watermark: null,
293
417
  };
294
418
  }
295
419
 
420
+ /**
421
+ * 只往模型表里记一笔(纯函数,原地改并返回账本)。
422
+ *
423
+ * 它不碰 `days` 也不碰 `sessions`:这唯一一个调用点是「老账本回填」—— 一条会话在升级前
424
+ * 已经记过累计量,但账本里没有模型维度;补这张卡的时候把它的累计量在**那条会话的记录上**
425
+ * 已经存在的那份 `billed` 复制进模型桶即可,按天总账与卡片都不该因此多出一份量。
426
+ *
427
+ * `day` 与 `days` / `sessions.daily` 那次记账用同一个日期(首采回填也是同一天),
428
+ * `at` 可不传:老账本的记录上只有最后活动日,落到那天零点就够了(见 `dayStartAt`)。
429
+ */
430
+ function applyModelUsage(ledger, entry) {
431
+ const selection = sanitizeModelSelection(entry && entry.model);
432
+ if (!selection) return ledger;
433
+ const key = modelKeyOf(selection.provider, selection.model);
434
+ if (!key) return ledger;
435
+ if (!ledger.models || typeof ledger.models !== 'object') ledger.models = {};
436
+ const bucket = ledger.models[key] || (ledger.models[key] = emptyModelBucket(selection));
437
+ // 名字以最新的为准;空名不覆盖(渠道被卸载 / 宿主这一轮没带目录时不至于把已知名字擦掉)。
438
+ if (selection.providerName) bucket.providerName = selection.providerName;
439
+ if (selection.modelName) bucket.modelName = selection.modelName;
440
+ const usage = sanitizeUsage(entry.usage);
441
+ for (const usageKey of USAGE_KEYS) bucket.billed[usageKey] += usage[usageKey];
442
+ const day = typeof entry.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(entry.day)
443
+ ? entry.day
444
+ : dayKeyOf(Date.now());
445
+ const daily = bucket.daily[day] || (bucket.daily[day] = emptyUsage());
446
+ for (const usageKey of USAGE_KEYS) daily[usageKey] += usage[usageKey];
447
+ bucket.lastAt = Math.max(bucket.lastAt, num(entry.at) || dayStartAt(day));
448
+ return ledger;
449
+ }
450
+
296
451
  /**
297
452
  * 把一次采集到的增量记进账本(纯函数,原地改并返回它)。
298
453
  *
299
454
  * `entry.date` 只在「首采回填」时传:一条会话第一次被采到时,它历史上有多少量我们都
300
455
  * 不知道发生在哪天,只能记到它最后一次活动的那天 —— 记到今天会让今天凭空多出一大截,
301
456
  * 记到创建那天又会让所有历史堆在最早一天。两者都是近似,取「最后活动日」更接近直觉。
457
+ *
458
+ * `entry.model` 是这个选择时可选带的 `{ provider, providerName, model, modelName }`:给了就
459
+ * 把同一份 `delta` 也记进模型桶。名字为空的半个组合不建桶(见 `modelKeyOf`)。
302
460
  */
303
461
  function applyDelta(ledger, entry) {
304
462
  const sessionId = entry.sessionId;
@@ -320,8 +478,6 @@ function applyDelta(ledger, entry) {
320
478
  for (const key of USAGE_KEYS) ownDay[key] += delta[key];
321
479
  record.lastAt = Math.max(record.lastAt, at);
322
480
  if (!record.firstAt) record.firstAt = at;
323
- // 退场的卡片又有了新用量:回到列表里来(水位一直在,所以这次只记增量)。
324
- record.retired = false;
325
481
  if (entry.title) record.title = entry.title;
326
482
  if (entry.cwd) record.cwd = entry.cwd;
327
483
  if (entry.workspaceName) record.workspaceName = entry.workspaceName;
@@ -329,6 +485,9 @@ function applyDelta(ledger, entry) {
329
485
  if (typeof entry.lastText === 'string' && entry.lastText) {
330
486
  record.lastText = entry.lastText.slice(0, LAST_TEXT_MAX);
331
487
  }
488
+ // 模型维度:同一份增量在「采集这一刻这条会话正在用的模型」名下再记一笔。日期与上面两处
489
+ // 必须同一个(首采回填也是),否则模型卡和按天账在切范围时会各说一套。
490
+ if (entry.model) applyModelUsage(ledger, { model: entry.model, day: date, at, usage: delta });
332
491
  return ledger;
333
492
  }
334
493
 
@@ -361,8 +520,7 @@ function usageInRange(record, startDay, today) {
361
520
  * 逐日序列会把范围内每一天都列出来(没有活动的补 0),柱状图靠它保持等距。
362
521
  *
363
522
  * 三块口径必须对得上:`kpi` 是范围内总量,`sessions` 里的每张卡片是这条会话在同一个范围里
364
- * 的用量,`retired` / `untracked` 把不在卡片上的那部分明说出来 —— 卡片加起来解释不了总量,
365
- * 是页面被读成「数据对不上」的根源。
523
+ * 的用量,`untracked` 把「卡片解释不了总量」的那一截(老版本删过记录,只剩按天账)明说出来。
366
524
  */
367
525
  function summarize(ledger, range, now) {
368
526
  const today = dayKeyOf(now);
@@ -423,22 +581,11 @@ function summarize(ledger, range, now) {
423
581
  }
424
582
 
425
583
  const sessions = [];
426
- let retiredSessions = 0;
427
- let retiredBilled = 0;
428
584
  // 构成快照取「最近有动作的那条会话」:每轮只在不是同一条时换,不再被并发读完的最后一条覆盖。
429
585
  let composition = null;
430
586
  let compositionSession = null;
431
587
  for (const [sessionId, record] of Object.entries(ledger.sessions)) {
432
588
  const usage = usageInRange(record, startDay, today);
433
- if (record.retired === true) {
434
- // 退场记录只留水位,不上卡片:它的量早就记在按天的账上了。范围里有量时在汇总行报个数。
435
- const billed = billedOf(usage);
436
- if (billed > 0) {
437
- retiredSessions += 1;
438
- retiredBilled += billed;
439
- }
440
- continue;
441
- }
442
589
  if (record.composition && num(record.lastAt) > num(compositionSession && compositionSession.lastAt)) {
443
590
  composition = {
444
591
  systemTokens: num(record.composition.systemTokens),
@@ -449,6 +596,7 @@ function summarize(ledger, range, now) {
449
596
  }
450
597
  const billedTokens = billedOf(usage);
451
598
  // 范围内一个 token、一轮都没有的会话不上卡片:切到「近 7 天」时老会话不该占着位置。
599
+ // 除此之外一条都不许筛 —— 卡片条数就是页面上那个「会话」数字。
452
600
  if (billedTokens === 0 && num(usage.turns) === 0) continue;
453
601
  sessions.push({
454
602
  sessionId,
@@ -469,17 +617,53 @@ function summarize(ledger, range, now) {
469
617
  if (b.billedTokens !== a.billedTokens) return b.billedTokens - a.billedTokens;
470
618
  return b.lastAt - a.lastAt;
471
619
  });
620
+ // 「会话」这一格就是条款数:范围内有量的会话全部在这儿,没有第二条口径。
472
621
  kpi.sessions = sessions.length;
473
622
 
474
- // 卡片 + 退场合计之后仍然少的那一截:老版本把整条记录删掉过(水位与明细都没了),
475
- // 按天的账上却还留着那些天。明说出来,别让用户自己拿计算器发现差额。
476
- // 只报量、不报条数 —— 那些会话连记录都没了,条数是编不出来的。
477
- const tracked = sessions.reduce((sum, row) => sum + row.billedTokens, 0) + retiredBilled;
623
+ // 卡片加起来仍然少的那一截:老版本把整条记录删掉过(水位与明细都没了),按天的账上却还
624
+ // 留着那些天。只报量、不报条数 —— 那些会话连记录都没了,条数是编不出来的。
625
+ const tracked = sessions.reduce((sum, row) => sum + row.billedTokens, 0);
478
626
  const untrackedBilled = Math.max(0, rangeBilled - tracked);
479
627
 
480
628
  // 没有任何一条会话带自己的构成时(升级前的老账本),退回那份全局快照。
481
629
  if (!composition && ledger.composition) composition = { ...ledger.composition };
482
630
 
631
+ // 模型卡:折算口径与每张会话卡完全一致(同一个 `usageInRange`),页面切范围时两块一起动。
632
+ const models = [];
633
+ // 这一份里认出来的厂商,只带**用到的那几个**图标(`icon` 是一段 SVG 标记,App 用 SvgXml
634
+ // 直接画):一次响应几十条模型行各自背一份图标没有意义。
635
+ const vendors = {};
636
+ for (const bucket of Object.values(ledger.models || {})) {
637
+ if (!bucket || typeof bucket !== 'object') continue;
638
+ const usage = usageInRange(bucket, startDay, today);
639
+ const billedTokens = billedOf(usage);
640
+ const turns = num(usage.turns);
641
+ // 与卡片同一条规矩:范围内一个 token、一轮都没有的组合不上卡。
642
+ if (billedTokens === 0 && turns === 0) continue;
643
+ // 厂商从模型名 / 模型 id 认,认不出再用渠道兜(见 model-vendors.js);认不出就不带,
644
+ // 页面那一行退回首字母徽标。
645
+ const vendor = identifyVendor(bucket);
646
+ const logo = vendor ? vendorLogoOf(vendor) : null;
647
+ if (logo) vendors[vendor] = logo;
648
+ models.push({
649
+ provider: bucket.provider || '',
650
+ providerName: bucket.providerName || '',
651
+ model: bucket.model || '',
652
+ modelName: bucket.modelName || '',
653
+ ...(logo ? { vendor } : {}),
654
+ uncachedInputTokens: num(usage.uncachedInputTokens),
655
+ cacheReadTokens: num(usage.cacheReadTokens),
656
+ cacheWriteTokens: num(usage.cacheWriteTokens),
657
+ outputTokens: num(usage.outputTokens),
658
+ billedTokens,
659
+ turns,
660
+ // 请求数取 `usage.steps`,与按天账同一个口径(见 applyDelta 里的那条注释)。
661
+ requests: num(usage.steps),
662
+ lastAt: num(bucket.lastAt),
663
+ });
664
+ }
665
+ models.sort((a, b) => (b.billedTokens !== a.billedTokens ? b.billedTokens - a.billedTokens : b.lastAt - a.lastAt));
666
+
483
667
  return {
484
668
  generatedAt: now,
485
669
  range,
@@ -488,16 +672,18 @@ function summarize(ledger, range, now) {
488
672
  heatmap,
489
673
  ...(composition ? { composition } : {}),
490
674
  ...(compositionSession ? { compositionSessionId: compositionSession.sessionId } : {}),
491
- retired: { sessions: retiredSessions, tokens: retiredBilled },
492
675
  untrackedBilled,
493
676
  sessions,
677
+ // 一个桶都没有也要给空数组:App 那边缺这个字段就整块不画,给空数组才是「画一块空」。
678
+ models,
679
+ // 一个厂商都没认出来时整项不出:老 App 不认识这个字段,新 App 缺它就是「没有图标」。
680
+ ...(Object.keys(vendors).length > 0 ? { vendors } : {}),
494
681
  };
495
682
  }
496
683
 
497
684
  module.exports = {
498
685
  VERSION,
499
- MAX_SESSIONS,
500
- MAX_RETIRED,
686
+ MAX_RECORDS,
501
687
  MAX_DAYS,
502
688
  MAX_DAILY_DAYS,
503
689
  HEATMAP_DAYS,
@@ -506,7 +692,9 @@ module.exports = {
506
692
  readLedger,
507
693
  saveLedger,
508
694
  forgetLedger,
695
+ sanitizeLedger,
509
696
  applyDelta,
697
+ applyModelUsage,
510
698
  summarize,
511
699
  trim,
512
700
  dayKeyOf,
@@ -515,5 +703,7 @@ module.exports = {
515
703
  emptyUsage,
516
704
  billedOf,
517
705
  usageInRange,
706
+ modelKeyOf,
707
+ sanitizeModelSelection,
518
708
  num,
519
709
  };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 统计账本的宿主侧定时采集:每几分钟自己跑一轮。
5
+ *
6
+ * 以前采集只在「有人打开统计页 / 下拉刷新 / 点清理」时才发生,水位差于是把「上次观测到现在」
7
+ * 的整段用量记在**观测到它的那一天**:连着几天没打开页面,那几天的量就整批压到最后一天,
8
+ * 按天图上是几天空白加一根尖柱。这一轮挪到宿主身上之后,归属误差从「几天」变成「几分钟」,
9
+ * 顺带把「会话在下次采集之前被删掉」那个丢数窗口一起收掉。
10
+ *
11
+ * 一轮只读真有变动的会话(采集器按摘要的 `updatedAt` 跳过一点没动的),空闲时连账本文件
12
+ * 都不重写 —— 平时几乎不花钱,别因为「每几分钟一次」就以为它在扫盘。
13
+ *
14
+ * 定时器归这里,不归装配层(`test/assembly-layer.test.cjs` 钉着)。
15
+ */
16
+
17
+ const { collectStats, DEFAULT_BUDGET_MS, PERIODIC_INTERVAL_MS } = require('./stats-collect.cjs');
18
+
19
+ /** 启动后第一轮等一会儿:宿主刚起来时各服务还在就位,不抢那几十毫秒。 */
20
+ const FIRST_RUN_DELAY_MS = 30_000;
21
+
22
+ /**
23
+ * 开始定期采集,返回停表函数(`ctx.effect` 的清理那一半)。
24
+ *
25
+ * 两条定时器都 `unref()`:它们是旁路,不该把进程留住等它们跑完。
26
+ */
27
+ function start(ctx) {
28
+ const run = () => {
29
+ void collectStats(ctx, { budgetMs: DEFAULT_BUDGET_MS }).catch(() => undefined);
30
+ };
31
+ const first = setTimeout(run, FIRST_RUN_DELAY_MS);
32
+ const timer = setInterval(run, PERIODIC_INTERVAL_MS);
33
+ first.unref?.();
34
+ timer.unref?.();
35
+ return () => {
36
+ clearTimeout(first);
37
+ clearInterval(timer);
38
+ };
39
+ }
40
+
41
+ module.exports = { start, FIRST_RUN_DELAY_MS, PERIODIC_INTERVAL_MS };