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.
- package/README.md +71 -74
- package/dsh-mobile-credentials/lib/credentials.js +39 -16
- package/lib/impl.js +0 -4
- package/lib/index.js +1 -1
- package/lib/mobile/allowed-zone.js +100 -0
- package/lib/mobile/archive-clean.js +4 -3
- package/lib/mobile/attachments.js +5 -1
- package/lib/mobile/file-read.js +168 -0
- package/lib/mobile/history-derive.js +67 -98
- package/lib/mobile/local-images.js +38 -7
- package/lib/mobile/model-vendors.js +105 -0
- package/lib/mobile/plugin-market.js +475 -0
- package/lib/mobile/plugin-self.js +1 -1
- package/lib/mobile/plugin-update.js +1 -1
- package/lib/mobile/relay-control.js +131 -7
- package/lib/mobile/session-cwd.js +28 -0
- package/lib/mobile/session-file.js +1 -12
- package/lib/mobile/stats-collect.cjs +146 -15
- package/lib/mobile/stats-ledger.cjs +249 -59
- package/lib/mobile/stats-scheduler.cjs +41 -0
- package/lib/mobile/stream.js +30 -110
- package/lib/mobile/uploads.js +68 -5
- package/lib/mobile/vendor-logos.cjs +30 -0
- package/lib/mobile/wire-cut.js +212 -0
- package/lib/panel/index.html +2 -2
- package/lib/relay/apply-ledger.cjs +52 -0
- package/lib/relay/core.js +14 -3
- package/lib/relay/migrate.js +90 -0
- package/lib/relay/protocol.js +4 -0
- package/lib/relay/verify.js +52 -0
- package/lib/routes/control-plane.js +12 -3
- package/lib/routes/mobile-api.js +28 -2
- package/lib/session-cleanup.cjs +10 -0
- package/lib/shared/device-headers.js +12 -4
- package/lib/shell/updater.js +2 -2
- package/lib/sidecar-facade-worker.cjs +55 -8
- package/lib/sidecar-facade.cjs +11 -1
- package/lib/sidecar-lifecycle.cjs +14 -1
- package/lib/update-install.cjs +13 -6
- package/package.json +1 -1
- package/lib/auto-archive.cjs +0 -98
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* 改地址 / 关遥控 / 吊销 / 重连 / 重启后台服务会掐断承载当前请求的隧道,所以一律先把响应
|
|
11
11
|
* 吐完再动手,手机看到 200 只代表「已受理」,结果靠重连后读状态。
|
|
12
12
|
*
|
|
13
|
+
* 换中转地址是唯一**不能**「先落盘再重建」的一项:填错地址的后果就是这台电脑从网上消失,
|
|
14
|
+
* 而改地址的入口正挂在要被拆掉的那条隧道上。所以它分成两阶段 —— `stage:"prepare"` 拿新
|
|
15
|
+
* 地址真连一次(不落盘),`stage:"commit"` 才改,并在旧隧道上先发 `MIGRATE` 再重建
|
|
16
|
+
* (顺序见 lib/relay/migrate.js)。失败台账(`apply-ledger`)在 `/diagnostics` 里报出来。
|
|
17
|
+
*
|
|
13
18
|
* 注册密钥只进不出:状态里只报「设没设」,手机看不到它。
|
|
14
19
|
*/
|
|
15
20
|
|
|
@@ -17,6 +22,8 @@ const { randomBytes } = require('node:crypto');
|
|
|
17
22
|
|
|
18
23
|
const core = require('../relay/core.js');
|
|
19
24
|
const pairingApproval = require('../relay/pairing-policy.js');
|
|
25
|
+
const applyLedger = require('../relay/apply-ledger.cjs');
|
|
26
|
+
const { probeInstanceId, verifyRelayRegistration } = require('../relay/verify.js');
|
|
20
27
|
const { canListen } = require('../relay/port-allocator.cjs');
|
|
21
28
|
const { lanCandidates, publicCandidates, ipv6Candidates } = require('../relay/lan-network.cjs');
|
|
22
29
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
@@ -27,6 +34,9 @@ const CONFIG_BODY_MAX = 4 * 1024;
|
|
|
27
34
|
/** 「先回包、再动手」的延迟:装/换一次隧道要几百毫秒,留一点让响应真正写完。 */
|
|
28
35
|
const AFTER_RESPONSE_MS = 120;
|
|
29
36
|
|
|
37
|
+
/** 换中转地址的两个阶段(见文件头)。不传 `stage` 的老客户端按 `commit` 走。 */
|
|
38
|
+
const STAGES = ['prepare', 'commit'];
|
|
39
|
+
|
|
30
40
|
/** 测速的单条上限,与控制面 `/probe-relays` 同一档(6 秒)。 */
|
|
31
41
|
const PROBE_TIMEOUT_MS = 6000;
|
|
32
42
|
|
|
@@ -147,6 +157,7 @@ function createRelayControlHandlers(deps) {
|
|
|
147
157
|
credentials = null,
|
|
148
158
|
activeGuard = () => null,
|
|
149
159
|
probeUrls = null,
|
|
160
|
+
verifyRelay = null,
|
|
150
161
|
} = deps || {};
|
|
151
162
|
|
|
152
163
|
/**
|
|
@@ -173,6 +184,49 @@ function createRelayControlHandlers(deps) {
|
|
|
173
184
|
return syncSidecar().catch(() => undefined);
|
|
174
185
|
}
|
|
175
186
|
|
|
187
|
+
/**
|
|
188
|
+
* 把配置推下去,并**等它真的落地**(换地址那一发要等新隧道注册完)。
|
|
189
|
+
*
|
|
190
|
+
* 与 `applyNow()` 走同一条路:先让 config store 通知订阅者(桌面面板改配置也是这一条),
|
|
191
|
+
* 再等这次下发的结果。失败会一路抛上来 —— 换地址失败必须如实回给调用方,而不是先回 200
|
|
192
|
+
* 再往台账里记一笔。
|
|
193
|
+
*/
|
|
194
|
+
async function applyNowAndWait() {
|
|
195
|
+
if (typeof settings.notify === 'function') {
|
|
196
|
+
try { settings.notify(); } catch { /* 退回收 syncSidecar */ }
|
|
197
|
+
}
|
|
198
|
+
return syncSidecar();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 试连一次新地址:`stage:"prepare"` 的全部实现。
|
|
203
|
+
*
|
|
204
|
+
* **一行都不落盘**,也不碰正在服务的隧道:用临时实例 id(`probeInstanceId`)在一个**新**
|
|
205
|
+
* 连接上注册,成功立刻断开。地址形状不对、连不上、注册被拒都把原因原样带回调用方 ——
|
|
206
|
+
* 手机上那句错误就是它。
|
|
207
|
+
*/
|
|
208
|
+
async function verifyTarget(patch) {
|
|
209
|
+
const snapshot = typeof status === 'function' ? status() : {};
|
|
210
|
+
const upstreamPort = Number(snapshot?.localPort);
|
|
211
|
+
if (!Number.isInteger(upstreamPort) || upstreamPort <= 0) {
|
|
212
|
+
return { ok: false, message: '宿主没报出本地网页端口,没法试连' };
|
|
213
|
+
}
|
|
214
|
+
const runner = typeof verifyRelay === 'function' ? verifyRelay : verifyRelayRegistration;
|
|
215
|
+
try {
|
|
216
|
+
await runner({
|
|
217
|
+
relayUrl: patch.relayUrl,
|
|
218
|
+
// 临时 id 必须与正式 id 不同:中转按实例 id 认人,拿正式 id 试连会把正在服务的那条顶掉。
|
|
219
|
+
instanceId: probeInstanceId(state.instanceId),
|
|
220
|
+
upstreamPort,
|
|
221
|
+
name: typeof state.name === 'string' ? state.name : '',
|
|
222
|
+
registerSecret: patch.registerSecret === undefined ? state.registerSecret : patch.registerSecret,
|
|
223
|
+
});
|
|
224
|
+
return { ok: true };
|
|
225
|
+
} catch (error) {
|
|
226
|
+
return { ok: false, message: String(error?.message || error) };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
176
230
|
/** 地址候选:与控制面 `/addresses` 同一份来源,三份分开报(理由见 lan-network.cjs)。 */
|
|
177
231
|
function addressView() {
|
|
178
232
|
const port = typeof lanPortInUse === 'function' ? lanPortInUse() : state.lanPort;
|
|
@@ -238,7 +292,11 @@ function createRelayControlHandlers(deps) {
|
|
|
238
292
|
* 一次给全:手机进这一页只要一发就能把「配了什么、现在通不通、能填哪条地址」都拿到,
|
|
239
293
|
* 不需要连打四五条再自己拼。
|
|
240
294
|
*/
|
|
241
|
-
async function handleRelayConfig(
|
|
295
|
+
async function handleRelayConfig(ctx, req, res) {
|
|
296
|
+
// POST 也收在这一条路径上:契约写的是 `POST /api/mobile/v1/relay/config`,而 App 现在打
|
|
297
|
+
// 的是 `/relay/config/save`(同一件事的两个名字)。宿主的注册器不按方法分派,所以这里
|
|
298
|
+
// 自己分一次 —— 两条入口共用下面同一份实现,不存在第二套「改配置」。
|
|
299
|
+
if (req && req.method === 'POST') return handleRelayConfigSave(ctx, req, res);
|
|
242
300
|
if (!wired) return unavailable(res);
|
|
243
301
|
sendJson(res, 200, {
|
|
244
302
|
ok: true,
|
|
@@ -248,13 +306,19 @@ function createRelayControlHandlers(deps) {
|
|
|
248
306
|
}
|
|
249
307
|
|
|
250
308
|
/**
|
|
251
|
-
* `POST /api/mobile/v1/relay/config`
|
|
309
|
+
* `POST /api/mobile/v1/relay/config/save`(`POST /relay/config` 同一份)—— 改配置。
|
|
252
310
|
*
|
|
253
|
-
*
|
|
254
|
-
* ·
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
311
|
+
* 三档结果(`stage` 是这次新增的那一格,老客户端不传):
|
|
312
|
+
* · `stage:"prepare"` —— **一行都不落盘**,拿新地址用临时实例 id 真连一次中转,注册上了
|
|
313
|
+
* 才回 `{ok:true, verified:true, target}`,否则 400 带原因。填错地址不再等于把自己锁在
|
|
314
|
+
* 门外:先验证,再让用户点保存。
|
|
315
|
+
* · `stage:"commit"` —— 落盘并**等这次下发真的完成**:换地址时宿主会先在旧隧道上登记
|
|
316
|
+
* 迁移、再重建(顺序见 lib/relay/migrate.js),失败会撤销登记并如实回错误。
|
|
317
|
+
* · 不传 `stage` —— 老 App 的行为一字不差:先回 200 再让旁路去重配。隧道照旧「先登记
|
|
318
|
+
* 迁移再重建」,只是结果只能靠重连后读状态(`/diagnostics` 里有失败台账)。
|
|
319
|
+
*
|
|
320
|
+
* 只改了不进 sidecar 的项(`preferredLanAddress` / `name` / `manualLanUrl`)时当场改完、
|
|
321
|
+
* 回新配置,手机不用重连。
|
|
258
322
|
*/
|
|
259
323
|
async function handleRelayConfigSave(_ctx, req, res) {
|
|
260
324
|
if (!wired) return unavailable(res);
|
|
@@ -268,6 +332,45 @@ function createRelayControlHandlers(deps) {
|
|
|
268
332
|
if (!parsed.ok) {
|
|
269
333
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: parsed.message } });
|
|
270
334
|
}
|
|
335
|
+
const stage = body.stage === undefined ? null : body.stage;
|
|
336
|
+
if (stage !== null && !STAGES.includes(stage)) {
|
|
337
|
+
return sendJson(res, 400, {
|
|
338
|
+
ok: false,
|
|
339
|
+
error: { code: 'bad-request', message: `stage 只能是 ${STAGES.join(' / ')}` },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
// ── 第一段:试连,不落盘 ──
|
|
343
|
+
if (stage === 'prepare') {
|
|
344
|
+
const target = parsed.patch.relayUrl;
|
|
345
|
+
if (target === undefined) {
|
|
346
|
+
return sendJson(res, 400, {
|
|
347
|
+
ok: false,
|
|
348
|
+
error: { code: 'bad-request', message: '试连要带新的 relayUrl(它才是要验证的那个地址)' },
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (!core.normalizeRelayUrl(target)) {
|
|
352
|
+
return sendJson(res, 400, {
|
|
353
|
+
ok: false,
|
|
354
|
+
verified: false,
|
|
355
|
+
target: '',
|
|
356
|
+
error: { code: 'bad-request', message: '空地址没什么可试连的:要关掉中转请直接提交' },
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
const probe = await verifyTarget(parsed.patch);
|
|
360
|
+
if (!probe.ok) {
|
|
361
|
+
return sendJson(res, 400, {
|
|
362
|
+
ok: false,
|
|
363
|
+
verified: false,
|
|
364
|
+
target: core.normalizeRelayUrl(target),
|
|
365
|
+
error: { code: 'relay-unverified', message: `这个地址连不上:${probe.message}` },
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
// 到这里为止既没调 settings.update、也没碰压载着本请求的那条隧道。
|
|
369
|
+
return sendJson(res, 200, { ok: true, verified: true, target: core.normalizeRelayUrl(target) });
|
|
370
|
+
}
|
|
371
|
+
// ── 第二段:落盘(`stage:"commit"` 或不传 stage)──
|
|
372
|
+
const urlChanged = parsed.patch.relayUrl !== undefined
|
|
373
|
+
&& core.normalizeRelayUrl(parsed.patch.relayUrl) !== core.normalizeRelayUrl(state.relayUrl);
|
|
271
374
|
try {
|
|
272
375
|
// `notify: false` 是关键:订阅者(装配层那个 watch)一跑就会重配隧道,而那一发响应
|
|
273
376
|
// 还没吐完。所以先只落盘,回完包再通知。
|
|
@@ -284,6 +387,20 @@ function createRelayControlHandlers(deps) {
|
|
|
284
387
|
await resolveLanPort().catch(() => undefined);
|
|
285
388
|
}
|
|
286
389
|
const view = { ok: true, config: configView(state), addresses: addressView() };
|
|
390
|
+
// 显式 commit 且换了地址:必须等这次下发的结果。旧隧道在这期间还开着(见
|
|
391
|
+
// lib/relay/migrate.js 的 linger),所以这一发响应仍然送得回去。
|
|
392
|
+
if (stage === 'commit' && urlChanged) {
|
|
393
|
+
try {
|
|
394
|
+
await applyNowAndWait();
|
|
395
|
+
} catch (error) {
|
|
396
|
+
return sendJson(res, 502, {
|
|
397
|
+
ok: false,
|
|
398
|
+
config: configView(state),
|
|
399
|
+
error: { code: 'relay-switch-failed', message: String(error?.message || error) },
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
return sendJson(res, 200, { ...view, migrated: true });
|
|
403
|
+
}
|
|
287
404
|
sendJson(res, 200, view);
|
|
288
405
|
// 推给旁路:改地址 / 端口 / 局域网开关都会重配隧道与直连,这一下多半会断。
|
|
289
406
|
afterResponse(() => applyNow());
|
|
@@ -480,6 +597,10 @@ function createRelayControlHandlers(deps) {
|
|
|
480
597
|
* 隧道为什么断、局域网为什么没起来,答案在故障台账与端口自检这两处;两条一起给,
|
|
481
598
|
* 手机上一页就能回答「这台到底怎么了」。
|
|
482
599
|
*
|
|
600
|
+
* `apply` 那一格是「按新配置重建隧道」的失败台账:以前那一发被 `.catch(() => undefined)`
|
|
601
|
+
* 吞掉,换地址失败在手机上表现为「保存成功、然后永久离线」,谁也说不清为什么。它只留最近
|
|
602
|
+
* 一条(时间 + 一句话 + 当时要切过去的地址),不带堆栈 —— 宿主内部路径对手机没用。
|
|
603
|
+
*
|
|
483
604
|
* **不搬堆栈**:台账的 message 只取第一行,截断 300 字。
|
|
484
605
|
*/
|
|
485
606
|
async function handleRelayDiagnostics(_ctx, _req, res) {
|
|
@@ -488,6 +609,8 @@ function createRelayControlHandlers(deps) {
|
|
|
488
609
|
sendJson(res, 200, {
|
|
489
610
|
ok: true,
|
|
490
611
|
faults: faultsView(),
|
|
612
|
+
/** 最近一次「按新配置重建隧道失败」的错误与时间(见上方说明)。 */
|
|
613
|
+
apply: applyLedger.view(),
|
|
491
614
|
addresses: addressView(),
|
|
492
615
|
/** 端口那两格的来历:「配置里写的」与「真的在监听的」可能不同。 */
|
|
493
616
|
port: {
|
|
@@ -523,4 +646,5 @@ module.exports = {
|
|
|
523
646
|
PROBE_MAX_URLS,
|
|
524
647
|
FAULT_TAIL,
|
|
525
648
|
NAME_MAX_CHARS,
|
|
649
|
+
STAGES,
|
|
526
650
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 会话的工作区目录:正文里相对路径的解析基准。
|
|
5
|
+
*
|
|
6
|
+
* 两处在用,口径必须是同一个 —— 正文里的图片(`local-images.js` 按它把 `deliverables/a.png`
|
|
7
|
+
* 换成内嵌字节)与点开正文里的文件链接(`session-file.js` 按它找文件)。
|
|
8
|
+
*
|
|
9
|
+
* 它是**可选的旁路**:取不到就回空串,调用方一律退回「只认绝对路径」的旧行为。这条跑在
|
|
10
|
+
* 历史投影与 SSE 的同一条管线上,抛一次会让整页消息或一整帧事件一起挂掉,所以这里不抛。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { unwrap } = require('./envelope.js');
|
|
14
|
+
const { callSessions } = require('./rpc.js');
|
|
15
|
+
|
|
16
|
+
/** 会话的工作区目录(相对路径的解析基准)。取不到就回空串,由调用方决定怎么办。 */
|
|
17
|
+
async function cwdOf(ctx, sessionId) {
|
|
18
|
+
try {
|
|
19
|
+
const { value } = unwrap(await callSessions(ctx, 'list', {}));
|
|
20
|
+
const items = (value && value.items) || [];
|
|
21
|
+
const row = items.find((item) => item && item.sessionId === sessionId);
|
|
22
|
+
return row && typeof row.cwd === 'string' ? row.cwd : '';
|
|
23
|
+
} catch {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { cwdOf };
|
|
@@ -23,6 +23,7 @@ const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
|
23
23
|
const { signedPath } = require('../shared/e2ee-request');
|
|
24
24
|
const { unwrap } = require('./envelope.js');
|
|
25
25
|
const { callSessions } = require('./rpc.js');
|
|
26
|
+
const { cwdOf } = require('./session-cwd.js');
|
|
26
27
|
const { presentedPathsOf } = require('./history-derive.js');
|
|
27
28
|
|
|
28
29
|
/** 单文件上限:手机内存与中转链路都不适合再大的东西(超出如实报 413)。 */
|
|
@@ -105,18 +106,6 @@ function isInsideWorkspace(cwd, target) {
|
|
|
105
106
|
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
/** 会话的工作区目录(相对路径的解析基准)。取不到就回空串,由调用方决定怎么办。 */
|
|
109
|
-
async function cwdOf(ctx, sessionId) {
|
|
110
|
-
try {
|
|
111
|
-
const { value } = unwrap(await callSessions(ctx, 'list', {}));
|
|
112
|
-
const items = (value && value.items) || [];
|
|
113
|
-
const row = items.find((item) => item && item.sessionId === sessionId);
|
|
114
|
-
return row && typeof row.cwd === 'string' ? row.cwd : '';
|
|
115
|
-
} catch {
|
|
116
|
-
return '';
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
109
|
/**
|
|
121
110
|
* POST { sessionId, path } → 该文件的原始字节。
|
|
122
111
|
*
|
|
@@ -7,15 +7,23 @@
|
|
|
7
7
|
* (宿主累计值 − 账本水位 = 这次入账的量),零依赖,代价是入账日期取「插件看到增量的那一天」,
|
|
8
8
|
* 跨零点的一轮会落在后一天。首采会把历史全量入账,日期取它最后一次活动的日期,算一次性回填。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* 采集分三条路进来:打开统计页 / 下拉刷新(`DEFAULT_BUDGET_MS`)、宿主侧每几分钟自己跑一轮
|
|
11
|
+
* (`PERIODIC_INTERVAL_MS`,只会读真有变动的会话)、以及删除会话之前的那一刀补采。
|
|
11
12
|
*
|
|
12
13
|
* 清理前那次补采(`skipPreview`)不读「最后一句」:那一步要读会话日志,是整轮最重的一趟,
|
|
13
14
|
* 而删除不可恢复、时间又卡在请求预算里。宁可让首次见到的会话卡片少一行摘要。
|
|
15
|
+
*
|
|
16
|
+
* 模型维度(`book.models`)是**近似**,写清楚省得以后有人当账本该有的精度去用:增量记在
|
|
17
|
+
* 「采集到它的这一刻这条会话正在用的模型」名下(`readSessionModel`)。水位法只认观测点,
|
|
18
|
+
* 会话中途换模型、而换模型到下一次采集之间的量分不开了,整段归到换后的那个 —— 这与首采
|
|
19
|
+
* 回填记在「最后活动那天」是同一种近似。想要真按模型拆,得让宿主按模型累计用量,那是另一
|
|
20
|
+
* 件事(现在的投影只有会话级累计)。
|
|
14
21
|
*/
|
|
15
22
|
|
|
16
23
|
const { unwrap } = require('./envelope.js');
|
|
17
24
|
const { callSessions } = require('./rpc.js');
|
|
18
25
|
const { sessionPreviewFor } = require('./session-preview.js');
|
|
26
|
+
const { listTitle } = require('./history-derive.js');
|
|
19
27
|
const ledger = require('./stats-ledger.cjs');
|
|
20
28
|
|
|
21
29
|
/** 同一条会话两次采集之间的最小间隔(打开页面时的重复请求直接复用上一轮结果)。 */
|
|
@@ -43,7 +51,26 @@ const RECENT_WINDOW_MS = 7 * 86400_000;
|
|
|
43
51
|
*/
|
|
44
52
|
const DEFAULT_BUDGET_MS = 1500;
|
|
45
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 删除会话之前那次补采的预算。
|
|
56
|
+
*
|
|
57
|
+
* 统计页那 1.5 秒是为了别让用户转好久;删除是不可恢复的,值得多等几秒把账补全,但不能
|
|
58
|
+
* 超过 App 单发请求的 8 秒总预算。两条删除路径(手机那个「清理已归档」按钮、agent 的
|
|
59
|
+
* 删除工具)共用这一个数,改预算别只改一处。
|
|
60
|
+
*/
|
|
61
|
+
const DELETING_COLLECT_BUDGET_MS = 6_000;
|
|
62
|
+
|
|
63
|
+
/** 宿主侧定期采集的间隔。
|
|
64
|
+
*
|
|
65
|
+
* 以前只在「有人打开统计页 / 下拉刷新 / 点清理」时采,水位差就把「上次观测到现在」的整段
|
|
66
|
+
* 用量记在**观测到它的那一天**:连着几天没打开页面,那几天的量全压到最后一天,按天图上是
|
|
67
|
+
* 一片空白加一根尖柱。定期跑一轮之后,归属误差从「几天」收到「几分钟」,顺带把「清理前
|
|
68
|
+
* 没采到的那一截」一起收掉。一轮的开销靠 `seenUpdatedAt` 兜住:一点没动的会话连投影都不读。
|
|
69
|
+
*/
|
|
70
|
+
const PERIODIC_INTERVAL_MS = 5 * 60_000;
|
|
71
|
+
|
|
46
72
|
const num = ledger.num;
|
|
73
|
+
const billedOf = ledger.billedOf;
|
|
47
74
|
|
|
48
75
|
function normalizePath(value) {
|
|
49
76
|
if (typeof value !== 'string') return '';
|
|
@@ -62,22 +89,28 @@ async function listSessions(ctx) {
|
|
|
62
89
|
}
|
|
63
90
|
}
|
|
64
91
|
|
|
65
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* cwd → 项目展示名。
|
|
94
|
+
*
|
|
95
|
+
* 宿主 `workspace.list` 的形状是「数组或 `{ items }`」,条目上目录叫 `path`、展示名是
|
|
96
|
+
* `title`(与 App 侧那份 `Workspace` 同一套)。以前按 `{ workspaces }` + `cwd` / `name`
|
|
97
|
+
* 读,三个字段一个都对不上,真机上卡片从来就没有过项目名——而这条取不到的错是静默的。
|
|
98
|
+
*/
|
|
66
99
|
async function workspaceNames(ctx) {
|
|
67
|
-
const
|
|
100
|
+
const byPath = new Map();
|
|
68
101
|
try {
|
|
69
102
|
const { value } = unwrap(await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} }));
|
|
70
|
-
const
|
|
71
|
-
for (const workspace of
|
|
103
|
+
const items = Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : []);
|
|
104
|
+
for (const workspace of items) {
|
|
72
105
|
if (!workspace || typeof workspace !== 'object') continue;
|
|
73
|
-
const
|
|
74
|
-
const name = typeof workspace.
|
|
75
|
-
if (
|
|
106
|
+
const dir = normalizePath(workspace.path);
|
|
107
|
+
const name = typeof workspace.title === 'string' ? workspace.title : '';
|
|
108
|
+
if (dir && name) byPath.set(dir, name);
|
|
76
109
|
}
|
|
77
110
|
} catch {
|
|
78
111
|
/* 拿不到项目名不影响数字 */
|
|
79
112
|
}
|
|
80
|
-
return
|
|
113
|
+
return byPath;
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
/** 一条会话的投影(tokenUsage / sessionStats / contextBreakdown);读不到返回 null。 */
|
|
@@ -131,6 +164,50 @@ function hasDelta(usage) {
|
|
|
131
164
|
return Object.values(usage).some((value) => value > 0);
|
|
132
165
|
}
|
|
133
166
|
|
|
167
|
+
/**
|
|
168
|
+
* 会话标题:宿主列表摘要里的 title 投影(与 `/sessions` 那条列表同一份来源,见 `listTitle`)。
|
|
169
|
+
*
|
|
170
|
+
* 摘要顶层的 `title` 是不存在的字段(以前照着它读,于是账本里几百条记录的标题全是空串,
|
|
171
|
+
* 卡片只能退到目录名兜底)。这里只取投影里那个真标题:取不到就留空,由 App 用目录末段兜底。
|
|
172
|
+
*/
|
|
173
|
+
function titleOf(summary) {
|
|
174
|
+
const projected = summary && summary.projections && summary.projections.values
|
|
175
|
+
? summary.projections.values.title
|
|
176
|
+
: '';
|
|
177
|
+
return typeof projected === 'string' ? projected : '';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 这条会话此刻的渠道 + 模型(`session.models` 的 `current`),读不到返回 null。
|
|
182
|
+
*
|
|
183
|
+
* 展示名要另外去 `groups` 里对:宿主 `current` 只给 id,页面那块模型卡得画「渠道名 / 模型名」。
|
|
184
|
+
* 对不上(模型已从目录里消失、渠道被卸载)就留空串,归属仍按 id 记 —— 名字对不上不等于这条
|
|
185
|
+
* 用量没有主,而且账本读盘也不拿展示名当有效性判据(见 `sanitizeModelSelection`)。
|
|
186
|
+
*
|
|
187
|
+
* 任何异常都返回 null:统计是旁路,不能因为这条读失败就把整轮记账丢掉。
|
|
188
|
+
*/
|
|
189
|
+
async function readSessionModel(ctx, sessionId) {
|
|
190
|
+
try {
|
|
191
|
+
const { value, error } = unwrap(await callSessions(ctx, 'models', { sessionId }));
|
|
192
|
+
if (error || !value || typeof value !== 'object') return null;
|
|
193
|
+
const current = value.current;
|
|
194
|
+
if (!current || typeof current !== 'object') return null;
|
|
195
|
+
const selection = ledger.sanitizeModelSelection({ provider: current.provider, model: current.model });
|
|
196
|
+
if (!selection) return null;
|
|
197
|
+
const groups = Array.isArray(value.groups) ? value.groups : [];
|
|
198
|
+
const group = groups.find((item) => item && item.id === selection.provider);
|
|
199
|
+
const models = group && Array.isArray(group.models) ? group.models : [];
|
|
200
|
+
const entry = models.find((item) => item && item.id === selection.model);
|
|
201
|
+
return ledger.sanitizeModelSelection({
|
|
202
|
+
...selection,
|
|
203
|
+
providerName: group ? group.name : '',
|
|
204
|
+
modelName: entry ? entry.name : '',
|
|
205
|
+
});
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
134
211
|
/**
|
|
135
212
|
* 采集串行化:同一时刻只跑一轮。
|
|
136
213
|
*
|
|
@@ -168,12 +245,17 @@ async function collectOnce(ctx, options = {}) {
|
|
|
168
245
|
|
|
169
246
|
const wanted = Array.isArray(options.sessionIds) ? new Set(options.sessionIds) : null;
|
|
170
247
|
const names = await workspaceNames(ctx);
|
|
248
|
+
// 这一轮有没有真改过账本:定期采集在空闲时应当一个字节都不写(见 PERIODIC_INTERVAL_MS)。
|
|
249
|
+
let touched = false;
|
|
171
250
|
|
|
172
251
|
// 消失检测只在「采整份列表」时做:定向补采拿到的是子集,凭它判「会话没了」会误标。
|
|
173
252
|
if (!wanted) {
|
|
174
253
|
const liveIds = new Set(items.map((item) => item.sessionId));
|
|
175
254
|
for (const [sessionId, record] of Object.entries(book.sessions)) {
|
|
176
|
-
if (!liveIds.has(sessionId)
|
|
255
|
+
if (!liveIds.has(sessionId) && !record.gone) {
|
|
256
|
+
record.gone = true;
|
|
257
|
+
touched = true;
|
|
258
|
+
}
|
|
177
259
|
}
|
|
178
260
|
}
|
|
179
261
|
|
|
@@ -203,10 +285,27 @@ async function collectOnce(ctx, options = {}) {
|
|
|
203
285
|
const summary = targets[index];
|
|
204
286
|
const sessionId = summary.sessionId;
|
|
205
287
|
const record = book.sessions[sessionId];
|
|
288
|
+
const title = titleOf(summary);
|
|
289
|
+
/** 名字补上 / 重新出现在列表里:这两件都要落盘,数字一个不动。 */
|
|
290
|
+
const renameIfNeeded = () => {
|
|
291
|
+
if (title && title !== record.title) {
|
|
292
|
+
record.title = title;
|
|
293
|
+
touched = true;
|
|
294
|
+
}
|
|
295
|
+
if (record.gone) {
|
|
296
|
+
record.gone = false;
|
|
297
|
+
touched = true;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
206
300
|
if (!options.force && record && record.collectedAt && now - record.collectedAt < COLLECT_INTERVAL_MS) {
|
|
207
301
|
// 还没到下一次采集的点:只把可能变了的名字补上,数字一个字不动。
|
|
208
|
-
|
|
209
|
-
|
|
302
|
+
renameIfNeeded();
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
// 宿主摘要的 `updatedAt` 与上次读到的一模一样 = 这条一点没动过,水位差必然是 0。
|
|
306
|
+
// 定期采集每几分钟一轮,靠这一条把空闲那几轮压成「一次列表调用」。
|
|
307
|
+
if (!options.force && record && record.seenUpdatedAt && num(summary.updatedAt) <= num(record.seenUpdatedAt)) {
|
|
308
|
+
renameIfNeeded();
|
|
210
309
|
continue;
|
|
211
310
|
}
|
|
212
311
|
|
|
@@ -224,6 +323,23 @@ async function collectOnce(ctx, options = {}) {
|
|
|
224
323
|
const cwd = normalizePath(summary.cwd);
|
|
225
324
|
const workspaceName = cwd ? names.get(cwd) : undefined;
|
|
226
325
|
|
|
326
|
+
// 模型只在「这一轮真会往模型表里记东西」时才去读:三种情形 —— 首采(全量要挂到模型
|
|
327
|
+
// 名下)、有增量、以及老账本里那条会话已经有量却还没有模型(升级当天的回填)。
|
|
328
|
+
// 其余轮次一条都不读,否则定期采集每 5 分钟就给每条会话多一趟宿主调用。
|
|
329
|
+
const needsModel = firstSight || hasDelta(delta) || Boolean(record && !record.model && billedOf(record.billed) > 0);
|
|
330
|
+
const model = needsModel ? await readSessionModel(ctx, sessionId) : null;
|
|
331
|
+
// 老账本回填:这条会话在升级前记过累计量,但它没有模型维度,模型卡会是 0 —— 把它那条
|
|
332
|
+
// 会话的累计量一次性复制进模型桶。日期与会话最后一次活动那天一致(同一条近似)。
|
|
333
|
+
// 首采不在这里做:首采的 delta 就是全量,已经跟着 applyDelta 记进模型桶了,再补一次会翻倍。
|
|
334
|
+
if (model && record && !record.model && billedOf(record.billed) > 0 && !firstSight) {
|
|
335
|
+
ledger.applyModelUsage(book, {
|
|
336
|
+
model,
|
|
337
|
+
day: ledger.dayKeyOf(num(record.lastAt) || lastActiveAt),
|
|
338
|
+
usage: record.billed,
|
|
339
|
+
});
|
|
340
|
+
touched = true;
|
|
341
|
+
}
|
|
342
|
+
|
|
227
343
|
if (hasDelta(delta) || firstSight) {
|
|
228
344
|
// 最后一条消息的摘要只在真有增量时去读(它是日志,属于重活):
|
|
229
345
|
// 卡片上那一行「最后一句说了什么」比什么都更能让人认出这是哪条会话。
|
|
@@ -242,18 +358,26 @@ async function collectOnce(ctx, options = {}) {
|
|
|
242
358
|
sessionId,
|
|
243
359
|
at: firstSight ? lastActiveAt : now,
|
|
244
360
|
delta: firstSight ? host : delta,
|
|
245
|
-
title
|
|
361
|
+
title,
|
|
246
362
|
cwd,
|
|
247
363
|
workspaceName,
|
|
248
364
|
lastText,
|
|
365
|
+
model,
|
|
249
366
|
});
|
|
367
|
+
touched = true;
|
|
250
368
|
}
|
|
251
369
|
|
|
252
370
|
const saved = book.sessions[sessionId];
|
|
253
371
|
if (saved) {
|
|
372
|
+
// 这一刻的模型落在这条会话的记录上:它换过模型的话,下一次增量就记到新模型名下
|
|
373
|
+
// (这一轮的量仍按「采集这一刻」归,见文件头)。
|
|
374
|
+
if (model) saved.model = model;
|
|
254
375
|
saved.watermark = host;
|
|
255
376
|
saved.collectedAt = now;
|
|
256
377
|
saved.gone = false;
|
|
378
|
+
// 记下这一次读到的那条会话 `updatedAt`:下一轮它没变就不用再读投影(见文件头的间隔说明)。
|
|
379
|
+
saved.seenUpdatedAt = num(summary.updatedAt) || now;
|
|
380
|
+
touched = true;
|
|
257
381
|
// 上下文构成是**这条会话**的快照:跟着记录走,页面才能说清「这是哪条会话的构成」。
|
|
258
382
|
// 以前全局只留一份、每轮被最后读完的那条覆盖(四条并发,谁后回是谁),
|
|
259
383
|
// 于是卡片上的构成跟用户此刻在用的那条毫无关系。
|
|
@@ -272,15 +396,22 @@ async function collectOnce(ctx, options = {}) {
|
|
|
272
396
|
|
|
273
397
|
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
|
274
398
|
|
|
275
|
-
|
|
276
|
-
|
|
399
|
+
// 一点没改就别写盘:定期采集空闲时要真的不花钱,而整份 JSON 每次重写一遍不是免费的。
|
|
400
|
+
if (touched) {
|
|
401
|
+
ledger.trim(book);
|
|
402
|
+
ledger.saveLedger(book);
|
|
403
|
+
}
|
|
277
404
|
return book;
|
|
278
405
|
}
|
|
279
406
|
|
|
280
407
|
module.exports = {
|
|
281
408
|
COLLECT_INTERVAL_MS,
|
|
282
409
|
DEFAULT_BUDGET_MS,
|
|
410
|
+
DELETING_COLLECT_BUDGET_MS,
|
|
411
|
+
PERIODIC_INTERVAL_MS,
|
|
283
412
|
collectStats,
|
|
284
413
|
hostUsageOf,
|
|
285
414
|
diffUsage,
|
|
415
|
+
titleOf,
|
|
416
|
+
readSessionModel,
|
|
286
417
|
};
|