dsh-mobile-plugin 0.4.0 → 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.
- package/README.md +71 -74
- package/dsh-mobile-credentials/lib/credentials.js +47 -24
- package/host.js +22 -36
- package/lib/client.js +29 -82
- package/lib/desktop-view.cjs +5 -10
- package/lib/git/read.js +6 -8
- package/lib/host-compat.cjs +13 -25
- package/lib/host-guard.cjs +9 -39
- package/lib/host-patches.cjs +5 -16
- package/lib/http/routing.js +7 -27
- package/lib/impl.js +6 -12
- package/lib/index.js +1 -1
- package/lib/mirror/pusher.cjs +33 -85
- package/lib/mirror-snapshot.cjs +6 -24
- package/lib/mobile/agent-presets.js +6 -26
- package/lib/mobile/allowed-zone.js +100 -0
- package/lib/mobile/approvals.js +18 -44
- package/lib/mobile/archive-clean.js +13 -21
- package/lib/mobile/attachments.js +16 -37
- package/lib/mobile/channels.js +15 -41
- package/lib/mobile/credential-upgrade.js +7 -16
- package/lib/mobile/credentials.js +5 -21
- package/lib/mobile/directory-browse.js +8 -27
- package/lib/mobile/directory-listing.js +10 -23
- package/lib/mobile/drive-roots.js +59 -0
- package/lib/mobile/envelope.js +4 -15
- package/lib/mobile/events.js +35 -65
- package/lib/mobile/extensions.js +5 -8
- package/lib/mobile/file-read.js +168 -0
- package/lib/mobile/git-repos.js +6 -15
- package/lib/mobile/git.js +11 -19
- package/lib/mobile/history-derive.js +85 -134
- package/lib/mobile/host-bridge/endpoints.js +4 -6
- package/lib/mobile/host-bridge/event-synthesis.js +21 -35
- package/lib/mobile/host-bridge/shims.js +12 -22
- package/lib/mobile/host-bridge.js +8 -20
- package/lib/mobile/host-settings.js +9 -19
- package/lib/mobile/inbox-clean.js +7 -24
- package/lib/mobile/local-images.js +48 -23
- package/lib/mobile/model-vendors.js +105 -0
- package/lib/mobile/monitor.js +8 -17
- package/lib/mobile/pairing.js +7 -19
- package/lib/mobile/plugin-market.js +475 -0
- package/lib/mobile/plugin-self.js +6 -23
- package/lib/mobile/plugin-update.js +8 -27
- package/lib/mobile/plugins.js +6 -24
- package/lib/mobile/questions.js +15 -40
- package/lib/mobile/references.js +11 -31
- package/lib/mobile/relay-control.js +136 -33
- package/lib/mobile/rpc.js +5 -13
- package/lib/mobile/schedule.js +8 -33
- package/lib/mobile/self-check.cjs +9 -36
- package/lib/mobile/session-cache.js +13 -19
- package/lib/mobile/session-cwd.js +28 -0
- package/lib/mobile/session-file.js +9 -33
- package/lib/mobile/session-flags.js +7 -13
- package/lib/mobile/session-mirror.js +8 -15
- package/lib/mobile/session-models.js +3 -3
- package/lib/mobile/session-schedule-mark.js +8 -14
- package/lib/mobile/session-send.js +13 -21
- package/lib/mobile/session-snapshot.js +4 -6
- package/lib/mobile/sessions.js +7 -23
- package/lib/mobile/stats-collect.cjs +154 -37
- package/lib/mobile/stats-ledger.cjs +258 -75
- package/lib/mobile/stats-scheduler.cjs +41 -0
- package/lib/mobile/stream.js +39 -130
- package/lib/mobile/subagents.js +5 -18
- package/lib/mobile/uploads.js +75 -21
- package/lib/mobile/vendor-logos.cjs +30 -0
- package/lib/mobile/wire-cut.js +212 -0
- package/lib/mobile/workspaces.js +20 -8
- package/lib/panel/index.html +7 -7
- package/lib/read-receipt.cjs +10 -21
- package/lib/relay/apply-ledger.cjs +52 -0
- package/lib/relay/core.js +14 -3
- package/lib/relay/lan-network.cjs +14 -28
- package/lib/relay/lan-proxy.js +14 -21
- package/lib/relay/migrate.js +90 -0
- package/lib/relay/pairing-policy.js +6 -9
- package/lib/relay/preview.js +8 -25
- package/lib/relay/protocol.js +9 -12
- package/lib/relay/status.js +5 -8
- package/lib/relay/tunnel.js +9 -14
- package/lib/relay/verify.js +52 -0
- package/lib/routes/control-plane.js +39 -47
- package/lib/routes/mobile-api.js +47 -34
- package/lib/session-cleanup.cjs +26 -27
- package/lib/shared/credentials-entry.js +4 -11
- package/lib/shared/device-headers.js +12 -4
- package/lib/shared/dshtoken.js +4 -4
- package/lib/shared/e2ee-request.js +1 -1
- package/lib/shared/e2ee.js +8 -14
- package/lib/shared/fresh-request.js +6 -22
- package/lib/shared/http-util.js +3 -4
- package/lib/shared/list-revision.js +5 -14
- package/lib/shared/session-signal.js +7 -16
- package/lib/shared/sse-write.js +14 -20
- package/lib/shared/workspace-guard.js +7 -15
- package/lib/shell/device-ota.js +4 -6
- package/lib/shell/manager.js +12 -27
- package/lib/shell/ota-package.js +7 -12
- package/lib/shell/runtime.js +8 -16
- package/lib/shell/self-check-after-swap.js +9 -23
- package/lib/shell/updater.js +15 -28
- package/lib/sidecar-facade-worker.cjs +75 -41
- package/lib/sidecar-facade.cjs +18 -8
- package/lib/sidecar-lifecycle.cjs +21 -10
- package/lib/update-install.cjs +24 -62
- package/lib/update-registry.cjs +6 -18
- package/package.json +1 -1
- package/lib/auto-archive.cjs +0 -98
|
@@ -3,41 +3,40 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 统计账本:按天累计的用量 + 每条会话的累计与身份(`$DSH_HOME/mobile-stats.json`)。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 表现为「今天的使用量突然掉了一半」)。所以采集一旦读到增量就落进这个文件,之后会话
|
|
9
|
-
* 在不在都不影响历史数字 —— 那张会话卡片只会被标成「已清理」。
|
|
6
|
+
* 它不寄在会话日志上:用户会清理已归档的会话,删的就是日志本身;统计每次现算的话,清理之后
|
|
7
|
+
* 那部分用量会凭空消失。一旦读到增量就落进这个文件,之后会话在不在都不影响历史数字。
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* - 会话卡片保留最近 `MAX_SESSIONS` 条(按最后活动时间),更老的会话从卡片里消失,
|
|
16
|
-
* 但它们的量**仍然留在按天的账上**(总量不会因为裁剪而缩水)。卡片退场只是不再展示
|
|
17
|
-
* (`retired`),水位留着 —— 水位一丢,这条会话下次再有动作就会被当成首采、把整段
|
|
18
|
-
* 历史重记一遍,按天的账凭空多出一截。
|
|
9
|
+
* 口径(与桌面 dsh-context 的每日账本一致,只保留手机用得着的部分):一天的账落在「观测到
|
|
10
|
+
* 增量」的宿主本地日期上;计费 token = 未命中输入 + 缓存读 + 缓存写 + 输出,四个桶分开存;
|
|
11
|
+
* 每条会话一条记录,范围内有量的都上卡片 —— 卡片条数就是页面那个「会话」数字,所以
|
|
12
|
+
* **不按展示需要裁剪**(裁了那个数字就变成上限值了,不再是真的条数)。
|
|
19
13
|
*
|
|
20
|
-
*
|
|
14
|
+
* 第三张表是模型维度 `models`:按「渠道 + 模型」攒量,服务统计页那块「模型用量」。它账的是
|
|
15
|
+
* 同一个增量 —— 也就是说这里没有「按模型拆过一遍」的原始数据,模型名取自**采集那一刻**这条
|
|
16
|
+
* 会话正在用的那个选择(见 stats-collect.cjs 文件头),会话中途换模型的那一段整体归到换后的
|
|
17
|
+
* 那个名下。这是水位法本来就有的近似,不是这里另加的简化。
|
|
18
|
+
*
|
|
19
|
+
* 只做纯数据(读写 + 折算),不碰网络;采集在 stats-collect.cjs,路由在 stats.js。
|
|
21
20
|
*/
|
|
22
21
|
|
|
23
22
|
const fs = require('node:fs');
|
|
24
23
|
const path = require('node:path');
|
|
25
24
|
|
|
26
25
|
const paths = require('../shared/paths');
|
|
26
|
+
const { identifyVendor, vendorLogoOf } = require('./model-vendors.js');
|
|
27
27
|
|
|
28
28
|
/** 账本格式版本:将来改结构时据此决定是丢弃还是迁移。 */
|
|
29
29
|
const VERSION = 1;
|
|
30
30
|
|
|
31
|
-
/** 会话卡片的条数上限(按最后活动时间保留最近的)。 */
|
|
32
|
-
const MAX_SESSIONS = 200;
|
|
33
|
-
|
|
34
31
|
/**
|
|
35
|
-
*
|
|
32
|
+
* 账本里会话记录的条数上限(按最后活动时间删最老的)。
|
|
36
33
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* 这是**文件存储**的兜底,不是统计口径:页面上那个「会话」数字就是这条记录的条数,按展示
|
|
35
|
+
* 需要裁到两百就会变成一个永远不动的上限值。所以档位抬到一台机器几年也用不完 —— 一台机器
|
|
36
|
+
* 一天最多新增百来条(子代理各算一条),四千条约等于一个月的重度使用;真要顶到,删的也是
|
|
37
|
+
* 最老的整条记录,按天的总账不受影响。
|
|
39
38
|
*/
|
|
40
|
-
const
|
|
39
|
+
const MAX_RECORDS = 4000;
|
|
41
40
|
|
|
42
41
|
/** 按天的账保留天数(约三年;一天一百多字节,超出的从最老的开始裁)。 */
|
|
43
42
|
const MAX_DAYS = 1100;
|
|
@@ -47,7 +46,8 @@ const MAX_DAYS = 1100;
|
|
|
47
46
|
*
|
|
48
47
|
* 它存在只为回答「这张卡片在这个范围里用了多少」:页面的三档范围最大 30 天,
|
|
49
48
|
* 再留一倍余量足够;更老的日期按天的总账仍然还在,只是卡片不再按那条会话拆得开。
|
|
50
|
-
*
|
|
49
|
+
* 不封顶的话,几千条会话 × 三年就是一份几十 MB 的 JSON,每次采集都要整份读写。
|
|
50
|
+
* 超过这个窗口没再动过的记录会被 `trim` 瘦身(明细、最后一句、上下文构成一起丢掉)。
|
|
51
51
|
*/
|
|
52
52
|
const MAX_DAILY_DAYS = 62;
|
|
53
53
|
|
|
@@ -63,6 +63,15 @@ const HEATMAP_DAYS = 52 * 7;
|
|
|
63
63
|
/** 每张会话卡片上留的最后一条消息摘要长度:卡片一行装得下的大致字数。 */
|
|
64
64
|
const LAST_TEXT_MAX = 120;
|
|
65
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
|
+
|
|
66
75
|
/** 用量字段:四个 token 桶 + 轮次/步数/耗时。顺序即写盘顺序,便于人工比对。 */
|
|
67
76
|
const USAGE_KEYS = [
|
|
68
77
|
'uncachedInputTokens',
|
|
@@ -86,7 +95,56 @@ function emptyDay() {
|
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
function emptyLedger() {
|
|
89
|
-
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
|
+
};
|
|
90
148
|
}
|
|
91
149
|
|
|
92
150
|
/** 账本文件:`$DSH_HOME/mobile-stats.json`(DSH_HOME 在调用时解析,便于测试注入)。 */
|
|
@@ -127,6 +185,18 @@ function startDayOf(range, now) {
|
|
|
127
185
|
return null;
|
|
128
186
|
}
|
|
129
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
|
+
|
|
130
200
|
function sanitizeUsage(source) {
|
|
131
201
|
const usage = emptyUsage();
|
|
132
202
|
if (!source || typeof source !== 'object') return usage;
|
|
@@ -170,8 +240,8 @@ function sanitizeLedger(raw) {
|
|
|
170
240
|
}
|
|
171
241
|
}
|
|
172
242
|
const billed = sanitizeUsage(value.billed);
|
|
173
|
-
//
|
|
174
|
-
//
|
|
243
|
+
// 更早的账本没有每条会话的按天用量:把它的历史量记在它最后一次活动那天。
|
|
244
|
+
// 这跟「首采回填」是同一条近似,也是一次性回填 —— 之后就只记增量。
|
|
175
245
|
if (Object.keys(daily).length === 0 && billedOf(billed) > 0) {
|
|
176
246
|
daily[dayKeyOf(num(value.lastAt) || Date.now())] = { ...billed };
|
|
177
247
|
}
|
|
@@ -184,19 +254,48 @@ function sanitizeLedger(raw) {
|
|
|
184
254
|
lastAt: num(value.lastAt),
|
|
185
255
|
collectedAt: num(value.collectedAt),
|
|
186
256
|
gone: value.gone === true,
|
|
187
|
-
//
|
|
188
|
-
|
|
257
|
+
// 上一次读到的那条会话 `updatedAt`:定期采集靠它跳过「一点没动」的会话。
|
|
258
|
+
seenUpdatedAt: num(value.seenUpdatedAt),
|
|
189
259
|
billed,
|
|
190
260
|
// 这条会话自己那份按天用量:卡片上的数字要跟着页面范围走(见 `rangeUsageOf`)。
|
|
191
261
|
daily,
|
|
192
262
|
// 最后一次观测到的上下文构成:跟着会话走,不再全局只留一份。
|
|
193
263
|
composition: readComposition(value.composition),
|
|
264
|
+
// 采集那一刻这条会话用的渠道 + 模型。升级前的老记录没有这一栏,当没有。
|
|
265
|
+
model: sanitizeModelSelection(value.model),
|
|
194
266
|
// 水位是「宿主那边这条会话的累计值」,缺省为 null 表示还没采过(首采全量入账)。
|
|
195
267
|
watermark: value.watermark && typeof value.watermark === 'object' ? sanitizeUsage(value.watermark) : null,
|
|
196
268
|
};
|
|
197
269
|
}
|
|
198
270
|
}
|
|
199
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
|
+
|
|
200
299
|
if (raw.composition && typeof raw.composition === 'object') {
|
|
201
300
|
// 老账本只有一份全局构成(每轮被最后读完的那条覆盖)。留着当兜底:
|
|
202
301
|
// 没有任何一条会话带自己的构成时才用它。
|
|
@@ -243,35 +342,53 @@ function forgetLedger() {
|
|
|
243
342
|
}
|
|
244
343
|
|
|
245
344
|
/**
|
|
246
|
-
*
|
|
345
|
+
* 裁剪:只防文件无限长,不动「有多少条会话」这个口径。
|
|
247
346
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
347
|
+
* 两道都跟**存储**有关,跟页面显示无关:
|
|
348
|
+
* - 每条记录自己那份按天用量只服务三档范围里最大的 30 天,最老的日子从它自己的账上裁掉,
|
|
349
|
+
* 按天的总账不动;
|
|
350
|
+
* - 超过 `MAX_DAILY_DAYS` 没再动过的记录顺手瘦身:明细、最后一句、上下文构成一起丢掉,
|
|
351
|
+
* 只留身份 + 累计 + 水位。它照旧算一条会话(`all` 那档用累计值),只是最近这几档本来就
|
|
352
|
+
* 轮不到它,留着一百多字节的摘要和几十天明细纯属占地方。
|
|
353
|
+
*
|
|
354
|
+
* 记录条数超过 `MAX_RECORDS` 才真删,删最老的整条:水位跟着一起没,那条会话以后再活跃会
|
|
355
|
+
* 被当成首采、把整段历史重记一遍 —— 所以档位要抬到几年也用不完,别拿它当展示上限。
|
|
250
356
|
*/
|
|
251
357
|
function trim(ledger) {
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
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
|
+
}
|
|
260
365
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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;
|
|
266
371
|
}
|
|
267
372
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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];
|
|
275
392
|
}
|
|
276
393
|
}
|
|
277
394
|
const days = Object.keys(ledger.days).sort();
|
|
@@ -291,20 +408,55 @@ function emptySession(at) {
|
|
|
291
408
|
lastAt: 0,
|
|
292
409
|
collectedAt: 0,
|
|
293
410
|
gone: false,
|
|
294
|
-
|
|
411
|
+
seenUpdatedAt: 0,
|
|
295
412
|
billed: emptyUsage(),
|
|
296
413
|
daily: {},
|
|
297
414
|
composition: null,
|
|
415
|
+
model: null,
|
|
298
416
|
watermark: null,
|
|
299
417
|
};
|
|
300
418
|
}
|
|
301
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
|
+
|
|
302
451
|
/**
|
|
303
452
|
* 把一次采集到的增量记进账本(纯函数,原地改并返回它)。
|
|
304
453
|
*
|
|
305
454
|
* `entry.date` 只在「首采回填」时传:一条会话第一次被采到时,它历史上有多少量我们都
|
|
306
455
|
* 不知道发生在哪天,只能记到它最后一次活动的那天 —— 记到今天会让今天凭空多出一大截,
|
|
307
456
|
* 记到创建那天又会让所有历史堆在最早一天。两者都是近似,取「最后活动日」更接近直觉。
|
|
457
|
+
*
|
|
458
|
+
* `entry.model` 是这个选择时可选带的 `{ provider, providerName, model, modelName }`:给了就
|
|
459
|
+
* 把同一份 `delta` 也记进模型桶。名字为空的半个组合不建桶(见 `modelKeyOf`)。
|
|
308
460
|
*/
|
|
309
461
|
function applyDelta(ledger, entry) {
|
|
310
462
|
const sessionId = entry.sessionId;
|
|
@@ -326,8 +478,6 @@ function applyDelta(ledger, entry) {
|
|
|
326
478
|
for (const key of USAGE_KEYS) ownDay[key] += delta[key];
|
|
327
479
|
record.lastAt = Math.max(record.lastAt, at);
|
|
328
480
|
if (!record.firstAt) record.firstAt = at;
|
|
329
|
-
// 退场的卡片又有了新用量:回到列表里来(水位一直在,所以这次只记增量)。
|
|
330
|
-
record.retired = false;
|
|
331
481
|
if (entry.title) record.title = entry.title;
|
|
332
482
|
if (entry.cwd) record.cwd = entry.cwd;
|
|
333
483
|
if (entry.workspaceName) record.workspaceName = entry.workspaceName;
|
|
@@ -335,6 +485,9 @@ function applyDelta(ledger, entry) {
|
|
|
335
485
|
if (typeof entry.lastText === 'string' && entry.lastText) {
|
|
336
486
|
record.lastText = entry.lastText.slice(0, LAST_TEXT_MAX);
|
|
337
487
|
}
|
|
488
|
+
// 模型维度:同一份增量在「采集这一刻这条会话正在用的模型」名下再记一笔。日期与上面两处
|
|
489
|
+
// 必须同一个(首采回填也是),否则模型卡和按天账在切范围时会各说一套。
|
|
490
|
+
if (entry.model) applyModelUsage(ledger, { model: entry.model, day: date, at, usage: delta });
|
|
338
491
|
return ledger;
|
|
339
492
|
}
|
|
340
493
|
|
|
@@ -364,12 +517,10 @@ function usageInRange(record, startDay, today) {
|
|
|
364
517
|
/**
|
|
365
518
|
* 按范围折算成 App 要的总览(纯函数)。
|
|
366
519
|
*
|
|
367
|
-
*
|
|
368
|
-
* 由页面决定怎么省略日期标签。
|
|
520
|
+
* 逐日序列会把范围内每一天都列出来(没有活动的补 0),柱状图靠它保持等距。
|
|
369
521
|
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
* 卡片加起来永远解释不了总量,是这个页面被读成「数据对不上」的根源。
|
|
522
|
+
* 三块口径必须对得上:`kpi` 是范围内总量,`sessions` 里的每张卡片是这条会话在同一个范围里
|
|
523
|
+
* 的用量,`untracked` 把「卡片解释不了总量」的那一截(老版本删过记录,只剩按天账)明说出来。
|
|
373
524
|
*/
|
|
374
525
|
function summarize(ledger, range, now) {
|
|
375
526
|
const today = dayKeyOf(now);
|
|
@@ -430,22 +581,11 @@ function summarize(ledger, range, now) {
|
|
|
430
581
|
}
|
|
431
582
|
|
|
432
583
|
const sessions = [];
|
|
433
|
-
let retiredSessions = 0;
|
|
434
|
-
let retiredBilled = 0;
|
|
435
584
|
// 构成快照取「最近有动作的那条会话」:每轮只在不是同一条时换,不再被并发读完的最后一条覆盖。
|
|
436
585
|
let composition = null;
|
|
437
586
|
let compositionSession = null;
|
|
438
587
|
for (const [sessionId, record] of Object.entries(ledger.sessions)) {
|
|
439
588
|
const usage = usageInRange(record, startDay, today);
|
|
440
|
-
if (record.retired === true) {
|
|
441
|
-
// 退场记录只留水位,不上卡片:它的量早就记在按天的账上了。范围里有量时在汇总行报个数。
|
|
442
|
-
const billed = billedOf(usage);
|
|
443
|
-
if (billed > 0) {
|
|
444
|
-
retiredSessions += 1;
|
|
445
|
-
retiredBilled += billed;
|
|
446
|
-
}
|
|
447
|
-
continue;
|
|
448
|
-
}
|
|
449
589
|
if (record.composition && num(record.lastAt) > num(compositionSession && compositionSession.lastAt)) {
|
|
450
590
|
composition = {
|
|
451
591
|
systemTokens: num(record.composition.systemTokens),
|
|
@@ -456,6 +596,7 @@ function summarize(ledger, range, now) {
|
|
|
456
596
|
}
|
|
457
597
|
const billedTokens = billedOf(usage);
|
|
458
598
|
// 范围内一个 token、一轮都没有的会话不上卡片:切到「近 7 天」时老会话不该占着位置。
|
|
599
|
+
// 除此之外一条都不许筛 —— 卡片条数就是页面上那个「会话」数字。
|
|
459
600
|
if (billedTokens === 0 && num(usage.turns) === 0) continue;
|
|
460
601
|
sessions.push({
|
|
461
602
|
sessionId,
|
|
@@ -476,17 +617,53 @@ function summarize(ledger, range, now) {
|
|
|
476
617
|
if (b.billedTokens !== a.billedTokens) return b.billedTokens - a.billedTokens;
|
|
477
618
|
return b.lastAt - a.lastAt;
|
|
478
619
|
});
|
|
620
|
+
// 「会话」这一格就是条款数:范围内有量的会话全部在这儿,没有第二条口径。
|
|
479
621
|
kpi.sessions = sessions.length;
|
|
480
622
|
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
const tracked = sessions.reduce((sum, row) => sum + row.billedTokens, 0) + retiredBilled;
|
|
623
|
+
// 卡片加起来仍然少的那一截:老版本把整条记录删掉过(水位与明细都没了),按天的账上却还
|
|
624
|
+
// 留着那些天。只报量、不报条数 —— 那些会话连记录都没了,条数是编不出来的。
|
|
625
|
+
const tracked = sessions.reduce((sum, row) => sum + row.billedTokens, 0);
|
|
485
626
|
const untrackedBilled = Math.max(0, rangeBilled - tracked);
|
|
486
627
|
|
|
487
628
|
// 没有任何一条会话带自己的构成时(升级前的老账本),退回那份全局快照。
|
|
488
629
|
if (!composition && ledger.composition) composition = { ...ledger.composition };
|
|
489
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
|
+
|
|
490
667
|
return {
|
|
491
668
|
generatedAt: now,
|
|
492
669
|
range,
|
|
@@ -495,16 +672,18 @@ function summarize(ledger, range, now) {
|
|
|
495
672
|
heatmap,
|
|
496
673
|
...(composition ? { composition } : {}),
|
|
497
674
|
...(compositionSession ? { compositionSessionId: compositionSession.sessionId } : {}),
|
|
498
|
-
retired: { sessions: retiredSessions, tokens: retiredBilled },
|
|
499
675
|
untrackedBilled,
|
|
500
676
|
sessions,
|
|
677
|
+
// 一个桶都没有也要给空数组:App 那边缺这个字段就整块不画,给空数组才是「画一块空」。
|
|
678
|
+
models,
|
|
679
|
+
// 一个厂商都没认出来时整项不出:老 App 不认识这个字段,新 App 缺它就是「没有图标」。
|
|
680
|
+
...(Object.keys(vendors).length > 0 ? { vendors } : {}),
|
|
501
681
|
};
|
|
502
682
|
}
|
|
503
683
|
|
|
504
684
|
module.exports = {
|
|
505
685
|
VERSION,
|
|
506
|
-
|
|
507
|
-
MAX_RETIRED,
|
|
686
|
+
MAX_RECORDS,
|
|
508
687
|
MAX_DAYS,
|
|
509
688
|
MAX_DAILY_DAYS,
|
|
510
689
|
HEATMAP_DAYS,
|
|
@@ -513,7 +692,9 @@ module.exports = {
|
|
|
513
692
|
readLedger,
|
|
514
693
|
saveLedger,
|
|
515
694
|
forgetLedger,
|
|
695
|
+
sanitizeLedger,
|
|
516
696
|
applyDelta,
|
|
697
|
+
applyModelUsage,
|
|
517
698
|
summarize,
|
|
518
699
|
trim,
|
|
519
700
|
dayKeyOf,
|
|
@@ -522,5 +703,7 @@ module.exports = {
|
|
|
522
703
|
emptyUsage,
|
|
523
704
|
billedOf,
|
|
524
705
|
usageInRange,
|
|
706
|
+
modelKeyOf,
|
|
707
|
+
sanitizeModelSelection,
|
|
525
708
|
num,
|
|
526
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 };
|