dsh-mobile-plugin 0.4.0 → 0.4.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/dsh-mobile-credentials/lib/credentials.js +8 -8
- 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 -8
- 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/approvals.js +18 -44
- package/lib/mobile/archive-clean.js +10 -19
- package/lib/mobile/attachments.js +11 -36
- 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/git-repos.js +6 -15
- package/lib/mobile/git.js +11 -19
- package/lib/mobile/history-derive.js +23 -41
- 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 +11 -17
- package/lib/mobile/monitor.js +8 -17
- package/lib/mobile/pairing.js +7 -19
- package/lib/mobile/plugin-self.js +5 -22
- 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 +6 -27
- 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-file.js +8 -21
- 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 +10 -24
- package/lib/mobile/stats-ledger.cjs +13 -20
- package/lib/mobile/stream.js +12 -23
- package/lib/mobile/subagents.js +5 -18
- package/lib/mobile/uploads.js +8 -17
- package/lib/mobile/workspaces.js +20 -8
- package/lib/panel/index.html +7 -7
- package/lib/read-receipt.cjs +10 -21
- package/lib/relay/lan-network.cjs +14 -28
- package/lib/relay/lan-proxy.js +14 -21
- package/lib/relay/pairing-policy.js +6 -9
- package/lib/relay/preview.js +8 -25
- package/lib/relay/protocol.js +6 -13
- package/lib/relay/status.js +5 -8
- package/lib/relay/tunnel.js +9 -14
- package/lib/routes/control-plane.js +28 -45
- package/lib/routes/mobile-api.js +19 -32
- package/lib/session-cleanup.cjs +16 -27
- package/lib/shared/credentials-entry.js +4 -11
- 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 +13 -26
- package/lib/sidecar-facade-worker.cjs +20 -33
- package/lib/sidecar-facade.cjs +7 -7
- package/lib/sidecar-lifecycle.cjs +7 -9
- package/lib/update-install.cjs +14 -59
- package/lib/update-registry.cjs +6 -18
- package/package.json +1 -1
package/lib/impl.js
CHANGED
|
@@ -217,16 +217,14 @@ function pairingPayload(state, lanPort) {
|
|
|
217
217
|
// 「手机上最近来过没有」同理,住在 `./mobile/activity.cjs`(守卫记、控制面状态读)。
|
|
218
218
|
|
|
219
219
|
/**
|
|
220
|
-
* 运行时是否自带 `@deepseek-ai/dsh-host-apiproxy
|
|
220
|
+
* 运行时是否自带 `@deepseek-ai/dsh-host-apiproxy`(`api-gateway` 那个入口)。
|
|
221
221
|
*
|
|
222
|
-
* 从本模块所在位置解析,而不是读 DSH 版本号:桌面端插件装在
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* 无头宿主直接跑在插件包里,包内 `node_modules` 有整套 `@deepseek-ai/*` → 解析得到
|
|
226
|
-
* → 用宿主自己那份。解析失败只是「没有」,不该把插件带崩。
|
|
222
|
+
* 从本模块所在位置解析,而不是读 DSH 版本号:桌面端插件装在 profile 的 node_modules 里,
|
|
223
|
+
* 往上碰不到 DSH 的依赖树(在 app.asar 里),解析不到就用兼容桥;无头宿主跑在插件包里,
|
|
224
|
+
* 包内就有整套 `@deepseek-ai/*`,解析得到就用宿主自己那份。解析失败只是「没有」。
|
|
227
225
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
226
|
+
* 模块顶层就被 `export const inject` 用了一次,所以必须是函数声明(提升),不能写成 `const`
|
|
227
|
+
* 箭头函数 —— 那会撞上 TDZ。
|
|
230
228
|
*/
|
|
231
229
|
function runtimeProvidesApiProxy() {
|
|
232
230
|
try {
|
package/lib/mirror/pusher.cjs
CHANGED
|
@@ -3,25 +3,11 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 镜像推送调度器:把宿主的会话状态推到中转,手机端才看得到列表与消息。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 三条不能丢的约束:隧道没连上就不构建快照(否则 503 刷屏,真故障被淹没);空清单/空快照
|
|
7
|
+
* 不许广播(「没取到」与「被删光」形状相同,放行会让手机侧整行消失且零报错);推送成功后
|
|
8
|
+
* 先 `sessionSignal.bump` 再 `announceSessionsRevision`,顺序反了手机会来取上一份镜像。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* 1. 隧道没连上就不构建快照——worker 的 `/tunnel/send` 在未连接时直接回 503,旧实现
|
|
12
|
-
* 照推不误,500ms 一个 tick 完整构建五份快照再撞一次 503,宿主日志被
|
|
13
|
-
* 「sidecar facade request failed: 503」刷屏(约 2 行/秒),真故障信号被淹没。
|
|
14
|
-
* 2. 空工作区/空会话快照不许广播——「没取到」和「真被删光」在单份快照上都是
|
|
15
|
-
* { ok: true, items: [] },直接放行会让手机侧工作区整行消失、会话全塌进「未分组」,
|
|
16
|
-
* 且中转只附一个 App 不看的 stale 标记,全程零报错。
|
|
17
|
-
*
|
|
18
|
-
* 注意扩展名是 .cjs:包根声明 type=module,而 lib/ 下只有 shared / mobile / relay /
|
|
19
|
-
* routes / http 这几个子目录放了自己的 {"type":"commonjs"},本目录(lib/mirror/)没有 ——
|
|
20
|
-
* 所以放这里的模块必须是 .cjs,才能被同为 CJS 的 mobile/* 与 shared/* 直接 require。
|
|
21
|
-
*
|
|
22
|
-
* 推送成功后要做两件广播(顺序不能反):先进 sessionSignal.bump(正文水位,驱动手机补拉
|
|
23
|
-
* 没打开的会话),再 announceSessionsRevision(清单 revision,驱动列表刷新)。镜像先落地、
|
|
24
|
-
* 广播后发出——反过来的话手机会立刻来取,而取的还是上一份镜像。
|
|
10
|
+
* 必须是 .cjs:包根声明 type=module,而 lib/mirror/ 没有自己的 `{"type":"commonjs"}`。
|
|
25
11
|
*/
|
|
26
12
|
|
|
27
13
|
const path = require('node:path');
|
|
@@ -43,12 +29,9 @@ const BG_TICK_INTERVAL_MS = 500;
|
|
|
43
29
|
/**
|
|
44
30
|
* 没人看的时候,后台 tick 降到这个节拍。
|
|
45
31
|
*
|
|
46
|
-
* 500ms
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* 上限由 sidecar 的快照 TTL 卡着:镜像快照 15s 过期,降频必须明显小于它,
|
|
51
|
-
* 取 5s(= TTL/3,`test/mirror-tick-budget.test.cjs` 钉着这条比例)。
|
|
32
|
+
* 500ms 是给「手机正在用」准备的:清单或工作区一变,半秒内就该看到。没人看时每 500ms
|
|
33
|
+
* 整轮重建并推一次纯属空转。上限由 sidecar 的 15s 快照 TTL 卡着,取 5s(= TTL/3,
|
|
34
|
+
* `test/mirror-tick-budget.test.cjs` 钉着这条比例)。
|
|
52
35
|
*/
|
|
53
36
|
const IDLE_TICK_INTERVAL_MS = 5_000;
|
|
54
37
|
/**
|
|
@@ -62,21 +45,13 @@ const AUDIENCE_IDLE_MS = 30_000;
|
|
|
62
45
|
/**
|
|
63
46
|
* 冷会话轮转的最小间隔(清单/工作区都没变时才生效)。
|
|
64
47
|
*
|
|
65
|
-
* 500ms 的 tick 不能省:清单与工作区的内容指纹就是这个节奏读宿主才发现的,
|
|
66
|
-
*
|
|
67
|
-
* lib/relay/lan-proxy.js 的 ?fresh=1、lib/mirror-snapshot.cjs 的 15s TTL)也押在它上面。
|
|
48
|
+
* 500ms 的 tick 不能省:清单与工作区的内容指纹就是这个节奏读宿主才发现的,App 侧
|
|
49
|
+
* 「镜像正常最多旧 500ms」的新鲜度契约也押在它上面。
|
|
68
50
|
*
|
|
69
|
-
* 但 tick
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* 按定义(isHighPriority 之外:没在跑、5 分钟没动过)正文不可能变,每 500ms 重做一遍
|
|
74
|
-
* 纯属白烧宿主 CPU/内存 —— 真机事故里宿主被内存压力拖慢时,这一路正是 504 的主要来源。
|
|
75
|
-
*
|
|
76
|
-
* 所以:清单或工作区指纹一变就照旧立刻整轮重建并推送(新鲜度分毫不动),两者都没变时
|
|
77
|
-
* 轮转退到 5s 一次,它只剩「把还没热身过的冷会话正文补进中转镜像」这一个作用
|
|
78
|
-
* (N 个冷会话最迟 N × 5s 补齐)。5s 必须明显小于 lib/mirror-snapshot.cjs 的
|
|
79
|
-
* MIRROR_SNAPSHOT_TTL_MS(15s),否则 sidecar 的镜像快照会被判过期。
|
|
51
|
+
* 但 tick 里最贵的是轮转冷会话:合成 summary 让每轮指纹都不同,session-mirror 的缓存
|
|
52
|
+
* 永不命中,每 500ms 真的去拉 200 条消息,而冷会话的正文按定义不会变。所以两个指纹都没变
|
|
53
|
+
* 时轮转退到 5s 一次,只留「把冷会话正文补进镜像」这一个作用。5s 必须明显小于
|
|
54
|
+
* `MIRROR_SNAPSHOT_TTL_MS`(15s),否则 sidecar 的镜像快照会被判过期。
|
|
80
55
|
*/
|
|
81
56
|
const BG_COLD_ROTATE_MIN_INTERVAL_MS = 5000;
|
|
82
57
|
const MAX_CONCURRENT_SESSION_BUILDS = 2;
|
|
@@ -233,19 +208,10 @@ function createMirrorPusher(deps) {
|
|
|
233
208
|
/**
|
|
234
209
|
* 「这些会话的正文又长了」的唯一广播点。
|
|
235
210
|
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* - 隧道断开(`!tunnelConnected()`)或纯局域网模式下 pushMirrorPayload 会短路,旧实现
|
|
241
|
-
* 把广播放在成功分支里,于是**一帧水位都不发** —— 手机侧非打开会话的「有新内容」
|
|
242
|
-
* 提示只能靠 60s 兜底轮询或用户点开,正好是「点开对话要等一会儿才看到内容」的来源。
|
|
243
|
-
* - 内容未变(指纹相同)时同样不推,但快照里的水位可能已经涨过(上一帧失败过、或
|
|
244
|
-
* 这一帧由 pushMirror 用更新的事件重建)—— 这里补一次广播是幂等的。
|
|
245
|
-
*
|
|
246
|
-
* 幂等由 sessionSignal 保证(同会话水位不变或更低时不广播),所以即使在每 500ms 的
|
|
247
|
-
* tick 上调用也不会变成刷新风暴。顺序上「先落地后广播」在局域网下天然成立:正文读的
|
|
248
|
-
* 就是本进程同一份会话历史。
|
|
211
|
+
* 广播的不是「镜像里有新正文」,而是「本机这份会话的正文到了这个水位」——手机拿水位和
|
|
212
|
+
* 自己的缓存一比就知道该不该增量拉。所以它与「镜像有没有推成功」是两件事:隧道断开或
|
|
213
|
+
* 纯局域网模式下推送会短路,广播绑在成功分支里就一帧水位都发不出去,非打开会话只能靠
|
|
214
|
+
* 60s 兜底轮询。内容没变时补一次广播是幂等的,sessionSignal 保证水位不变或更低时不发。
|
|
249
215
|
*/
|
|
250
216
|
const announceSessionActivity = (messages) => {
|
|
251
217
|
if (!messages || typeof messages !== 'object') return;
|
|
@@ -331,11 +297,8 @@ function createMirrorPusher(deps) {
|
|
|
331
297
|
/**
|
|
332
298
|
* 镜像清单本身的规模(自检台账用):会话总数、其中活跃的、其中子会话。
|
|
333
299
|
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
* 在重建,顺手记三个计数不额外花销。
|
|
337
|
-
*
|
|
338
|
-
* 只在真拿到清单时更新:拿不到时保留上一次的读数(连带 `at`),与「清单突然变成 0」区分开。
|
|
300
|
+
* 这是插件侧能诚实报出的那部分规模:宿主内存里挂着多少会话、正文占多少,插件看不到。
|
|
301
|
+
* 只在真拿到清单时更新,拿不到时保留上一次读数,与「清单突然变成 0」区分开。
|
|
339
302
|
*/
|
|
340
303
|
let mirrorListSessions = 0;
|
|
341
304
|
let mirrorListActiveSessions = 0;
|
|
@@ -402,20 +365,12 @@ function createMirrorPusher(deps) {
|
|
|
402
365
|
/**
|
|
403
366
|
* 构建一个会话的镜像数据(正文快照 + 统计投影 + 模型列表)。
|
|
404
367
|
*
|
|
405
|
-
* `summary`
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
* (isHighPriority)用的就是这个字段,不能在这里改口径。
|
|
368
|
+
* `summary` 必须是清单里的那一行(或它的 Promise):`session-mirror.js` 的后台缓存指纹
|
|
369
|
+
* 就是 `updatedAt:running|idle:limit`,硬塞 `Date.now()` 会让 2 分钟 TTL 永不命中。
|
|
370
|
+
* `running` 如实取清单值,高优先判定用的就是它。传 Promise 是为了与清单构建并行。
|
|
409
371
|
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
* `reuseCache` 默认 true,但**事件驱动路径必须传 false**:那个缓存只认「宿主抬了
|
|
414
|
-
* updatedAt」这一种内容变化。一条新消息落盘而清单摘要还没变(或宿主不按消息抬
|
|
415
|
-
* updatedAt)时,缓存会把上一份正文当成这一份发出去 —— device-session-activity 的守卫
|
|
416
|
-
* 钉的正是这个场景(新消息必须广播新水位,见 test/device-session-activity.test.cjs)。
|
|
417
|
-
* 实现方式是 session-mirror 的 `noCache`(既不读也不写缓存),不是伪造 updatedAt。
|
|
418
|
-
* 收益落在「没人动的冷会话轮转」:那里每 5s 重建 200 条正文才是成本大头。
|
|
372
|
+
* `reuseCache` 默认 true,但事件驱动路径必须传 false:缓存只认「updatedAt 抬了」这一种
|
|
373
|
+
* 变化,新消息落盘而摘要未变时会把上一份正文当成这一份发出去(`noCache` 既不读也不写缓存)。
|
|
419
374
|
*/
|
|
420
375
|
const buildSessionDataUnlocked = async (sessionId, summaryOrPromise, { reuseCache = true } = {}) => {
|
|
421
376
|
const summary = summaryOrPromise && typeof summaryOrPromise.then === 'function'
|
|
@@ -425,19 +380,13 @@ function createMirrorPusher(deps) {
|
|
|
425
380
|
// 缓存指纹,不影响正文内容本身。
|
|
426
381
|
const updatedAt = (summary && summary.updatedAt) || Date.now();
|
|
427
382
|
/**
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
* (`session/agent-busy: subagent Sessions require their durable parent address`),
|
|
432
|
-
* 而镜像这份正文手机本来就用不到:手机打开子代理读的是子代理那条专用通道(带父会话
|
|
433
|
-
* 地址与 mode,见 lib/mobile/subagents.js 的文件头)。以前每次都照读、每次都被拒,
|
|
434
|
-
* 于是 5 秒一次的冷轮转里固定夹着一次注定失败的读取,`skippedBodies` 一路涨
|
|
435
|
-
* (真机实测 4 分钟 408 → 451),日志里那条 skip 也永远说不清到底哪坏了。
|
|
383
|
+
* 子会话的正文刻意不建:普通历史接口读子会话会被宿主按 durable parent address 拒掉
|
|
384
|
+
* (`agent-busy: subagent Sessions require their durable parent address`),而手机打开
|
|
385
|
+
* 子代理读的是专用通道(见 lib/mobile/subagents.js),这份正文本来就用不到。
|
|
436
386
|
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
* 「正文缺席」那条路径完全一致。
|
|
387
|
+
* 提前认出清单行上的 `origin === 'subagent'` 就不发那次请求,且 skipReason 留空:它不是
|
|
388
|
+
* 失败,不该进 `skippedBodies`。清单行、stats、models 照旧构建,载荷形状与「正文缺席」
|
|
389
|
+
* 那条路径一致。
|
|
441
390
|
*/
|
|
442
391
|
if (summary && summary.origin === 'subagent') {
|
|
443
392
|
const [stats, models] = await Promise.all([
|
|
@@ -683,10 +632,9 @@ function createMirrorPusher(deps) {
|
|
|
683
632
|
// 清单会一直挂到 15s TTL 过期,手机上已删的会话最长还能看到十几秒;而且这条路径
|
|
684
633
|
// 完全不记账,运维看不出镜像为什么不动。
|
|
685
634
|
//
|
|
686
|
-
// 跨包契约:中转侧对「空覆盖非空」有同口径保护,只认 emptyConfirmed === true
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
// 原样 body,天然带这个字段。
|
|
635
|
+
// 跨包契约:中转侧对「空覆盖非空」有同口径保护,只认 emptyConfirmed === true 的空
|
|
636
|
+
// 清单(中转的镜像状态里同一条谓词)。这里推的是 buildSessionsResponse 的原样 body,
|
|
637
|
+
// 天然带这个字段。
|
|
690
638
|
const workspacesBodyEmpty = await workspaces.buildWorkspacesBody(ctx).then(observeWorkspaces);
|
|
691
639
|
const wsBlockedEmpty = guardWorkspaces(workspacesBodyEmpty);
|
|
692
640
|
if (wsBlockedEmpty) {
|
package/lib/mirror-snapshot.cjs
CHANGED
|
@@ -1,33 +1,15 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
// (与 reconnect-scheduler.cjs 同样的做法),而不是把判定散在 sidecar 的读取函数里。
|
|
3
|
+
// 桌面推送的「移动端镜像快照」新鲜度判定。独立成模块是为了能被 test/ 直接驱动。
|
|
5
4
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// - 它依赖的 sidecar `/tunnel/send` 自己也要求 `tunnel && tunnel.connected`,否则 503
|
|
10
|
-
// (sidecar-facade-worker.cjs:186-190)。
|
|
11
|
-
// 于是「中转断掉、局域网仍然健康」时快照被永久冻结在最后一个版本,而 snapshotRead 依旧把
|
|
12
|
-
// 它当权威读:新建的会话/工作区在 App 里**永远不出现**。实测证据(Mac 宿主,kill 掉中转后
|
|
13
|
-
// 走局域网新建并发送):
|
|
14
|
-
// POST /session/new → session-1c9b048a-…,POST /session/send → {"ok":true,"accepted":true}
|
|
15
|
-
// GET /sessions → count 9(与 kill 之前一模一样),新会话 absent,+70s 仍 absent
|
|
16
|
-
// GET /app → 设备侧同样看不到
|
|
17
|
-
// GET /session/messages?sessionId=<新的> → 能读到 user 'SNAPSHOT-STALE-1'
|
|
18
|
-
// 最后一条说明回源主进程的通道是好的(快照里没有这个 sessionId 就自然回源),坏的只是
|
|
19
|
-
// 「列表类读被冻结的镜像顶掉」。
|
|
20
|
-
//
|
|
21
|
-
// 修法:把镜像当**短时代读缓存**,不是权威存储——超过 TTL 就当作缺失,让调用方回源主进程。
|
|
22
|
-
// 隧道连着时快照每几百毫秒就被重推一次(processBgQueue 的 500ms tick),所以 15s 足够长,
|
|
23
|
-
// 快路径照常生效;一旦中转断开,最多 15s 后所有读都回源,数据重新变准。
|
|
5
|
+
// 镜像只是**短时代读缓存**,不是权威存储:快照只能靠隧道推来,中转断开而局域网仍健康时
|
|
6
|
+
// 它会冻在最后一个版本,列表读于是把新建的会话/工作区一直藏住。超过 TTL 就当作缺失,
|
|
7
|
+
// 让调用方回源主进程。隧道连着时快照每几百毫秒重推一次,15s 足够长,快路径照常生效。
|
|
24
8
|
|
|
25
9
|
const MIRROR_SNAPSHOT_TTL_MS = 15_000
|
|
26
10
|
|
|
27
|
-
// 只有带可信时间戳 `at` 且在 TTL
|
|
28
|
-
//
|
|
29
|
-
// `at: Date.now()`,没有 `at` 说明这不是预期的镜像载荷,宁可回源也不要拿它当权威。
|
|
30
|
-
// 时间戳来自同一台机器,不存在跨机时钟偏移,所以未来时间戳按不可信处理。
|
|
11
|
+
// 只有带可信时间戳 `at` 且在 TTL 内的快照才可用。缺 `at` 的载荷按陈旧处理:预期的镜像载荷
|
|
12
|
+
// 一定写了 `at`,没有就说明来路不对,宁可回源。时间戳来自同一台机器,未来时间戳按不可信处理。
|
|
31
13
|
function isMirrorSnapshotFresh(snapshot, now = Date.now()) {
|
|
32
14
|
if (!snapshot || typeof snapshot !== 'object') return false
|
|
33
15
|
const at = snapshot.at
|
|
@@ -1,34 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* 移动端「Agent 预设管理」API
|
|
4
|
+
* 移动端「Agent 预设管理」API:把桌面端设置页的预设管理分区补齐到手机上。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* AgentPresetChip(list + select),现已整颗删除 —— 手机端一律吃宿主默认档,
|
|
9
|
-
* `POST /session/select-agent-preset` 路由也一并删除(消费端没了就是死路由)。
|
|
10
|
-
* 2. 会话标题旁的只读标签 → 手机:随 1 一起下线
|
|
11
|
-
* 3. 名单本身 → 手机:GET /agent-presets
|
|
12
|
-
* 4. **管理分区**:名单卡片 + 只读查看器 + 复制对话框 + 删除确认
|
|
13
|
-
* —— 这才是现在手机上唯一与预设有关的界面(app 的 PresetManager)。
|
|
6
|
+
* 手机端只保留管理分区(app 的 PresetManager):桌面那边设置里的一行与新建会话芯片已下线,
|
|
7
|
+
* 手机一律吃宿主默认档。三条路由都只转发宿主的 agentPreset 域:read / copy / remove。
|
|
14
8
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* - POST /agent-presets/copy { from, agentPreset, name? } → agentPreset.copy
|
|
19
|
-
* - POST /agent-presets/remove { agentPreset } → agentPreset.remove
|
|
20
|
-
*
|
|
21
|
-
* 语义按宿主原文透传,不在这里自作主张:
|
|
22
|
-
* - copy 是「整份复制」,标识符会成为目录名且事后无法更改;宿主自己会重校验 id
|
|
23
|
-
* 规则与重名(PresetExistsError → agent-preset-invalid),这里只做便利检查。
|
|
24
|
-
* - remove 只对 user 预设有效:随附预设宿主回 agent-preset-read-only,删不掉。
|
|
25
|
-
* - openDocument 不做。它打开的是**宿主机器**上的目录(与 host.openPath 同性质),
|
|
26
|
-
* 手机点了也看不见;桌面端在没有打开器时(hasDocument:false)本来就是把目录
|
|
27
|
-
* 当文本显示,手机端照此办理即可,不需要这条 RPC。
|
|
28
|
-
*
|
|
29
|
-
* 宿主的失败对象是「code + details」:not-found 带 available[]、read-only 带 reason、
|
|
30
|
-
* invalid 带 reason(可能是 id 非法,也可能是重名),都不是给用户看的文案;
|
|
31
|
-
* 按 code 说中文,同时把原文留在 details.hostMessage 里备查。
|
|
9
|
+
* 语义按宿主原文透传:copy 是整份复制,标识符成为目录名且事后无法更改;remove 只对 user
|
|
10
|
+
* 预设有效(随附预设回 agent-preset-read-only)。宿主的失败对象是 code + details,按 code
|
|
11
|
+
* 说中文,原文留在 details.hostMessage。
|
|
32
12
|
*/
|
|
33
13
|
|
|
34
14
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
package/lib/mobile/approvals.js
CHANGED
|
@@ -3,40 +3,17 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 手机端审批桥(DSH 0.2.x)。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
* rpcId
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `dsh-api-remotes` 把它转给桌面窗口那类 owner 连接,`dsh-acp` 也接同一个钩子)。
|
|
12
|
-
* 帧级 rpcId 就此消失,而 App 的审批面板**刻意**只认带 rpcId 的帧(不给一个按了必然 409
|
|
13
|
-
* 的按钮),于是「桌面有卡、手机上一片空白,连一条 409 都没有」。
|
|
6
|
+
* 0.2.x 删掉了 apiProxy,审批变成会话日志里的 `approval/asked` / `approval/decided` 事件对
|
|
7
|
+
* 加一个进程内 waterfall 钩子 `approval/request`,帧级 rpcId 就此消失,而 App 的审批面板
|
|
8
|
+
* 只认带 rpcId 的帧。这里按新形状把那半条通道补起来,App 契约一行不改:听 `approval/asked`
|
|
9
|
+
* 自己铸 rpcId 推卡,同时挂 `approval/request` 把手机挂成候选人,`/approval/respond` 按
|
|
10
|
+
* rpcId 找到挂起项把结论交回钩子。
|
|
14
11
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* 钩子必须 prepend:宿主自己的转发器在桌面窗口开着时会一直等桌面回答、不调用 `next()`,
|
|
13
|
+
* 老实排在注册顺序后面等于「桌面开着时手机永远收不到」。
|
|
17
14
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* 手机。approvalId 直接用宿主自己的 id —— App 靠它把 `approval/decided` 对上、自动
|
|
21
|
-
* 收掉那张卡。
|
|
22
|
-
* 2. 听 `approval/request` 钩子(**prepend**,见下):把这台手机挂成候选人。手机没在看
|
|
23
|
-
* 这个会话时与下游竞速:下游(桌面窗口 / ACP / 宿主默认的 fail-closed)照旧拿到请求、
|
|
24
|
-
* 卡片照旧出现,手机答得早就用手机的,桌面答得早就用桌面的,输的那张卡由结算帧收掉。
|
|
25
|
-
* 手机**正在看**这个会话时则不转发,由手机独家作答 —— 桌面那张卡挂在被转发的请求上,
|
|
26
|
-
* 本桥没有替它收尾的口子,转发出去、手机先答,桌面那张就永远悬着(2026-10-23,
|
|
27
|
-
* 与提问桥同一条坑)。
|
|
28
|
-
* 3. `/approval/respond` 拿 rpcId 找到挂起项,把结论交回钩子 —— 这才是真正让 agent
|
|
29
|
-
* 继续往下跑的那一步。
|
|
30
|
-
*
|
|
31
|
-
* ⚠️ 钩子上**没有**宿主 id(既没有 approvalId 也没有 rpcId),而且它和 `approval/asked`
|
|
32
|
-
* 谁先到取决于宿主的派发顺序(事件在 `request()` 里先 append、钩子随后派发,但两者都走
|
|
33
|
-
* 微任务)。所以两个来源共用一张挂起项表:先到的建项,后到的补字段(事件补真实
|
|
34
|
-
* approvalId,钩子补 displayReason 与 signal)。卡片在任一路径上都不等对方。
|
|
35
|
-
*
|
|
36
|
-
* ⚠️ 为什么必须 prepend:宿主自己的转发器(`dsh-api-remotes`)在桌面窗口开着时会一直等
|
|
37
|
-
* 桌面回答、**不调用 next()**,而且那些核心包必然先于本插件装载。老实按注册顺序排在后面
|
|
38
|
-
* 等于「桌面开着时手机永远收不到」,正是这次要修的现象。prepend 是本桥能生效的前提,
|
|
39
|
-
* 不是优化。
|
|
15
|
+
* 钩子上没有宿主 id,且与 `approval/asked` 的到达顺序不定,所以两个来源共用一张挂起项表:
|
|
16
|
+
* 先到的建项,后到的补字段。
|
|
40
17
|
*/
|
|
41
18
|
|
|
42
19
|
const { randomUUID } = require('node:crypto');
|
|
@@ -266,14 +243,12 @@ function bindRequest(entry, request) {
|
|
|
266
243
|
/**
|
|
267
244
|
* 挂起这条请求直到有人给结论。
|
|
268
245
|
*
|
|
269
|
-
* 传了 `next`
|
|
270
|
-
*
|
|
271
|
-
* 由结算帧收掉。两边都没人答时这里会一直挂着,和今天宿主自己的行为一致(审批本来就等人工)。
|
|
246
|
+
* 传了 `next` 就与下游竞速:`next()` 一调,桌面窗口那张卡照旧出现,谁先给结论谁算数,
|
|
247
|
+
* 输的那张卡由结算帧收掉;两边都没人答就一直挂着。
|
|
272
248
|
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
* `approval/decided` 落进会话记录,桌面看得到裁决结果,只是没有可点的卡。
|
|
249
|
+
* 不传 `next`(手机正在看这个会话)就是手机独家作答:不往下游转发,桌面从源头就不弹卡。
|
|
250
|
+
* 桌面那张卡挂在被转发的请求上,本桥没有替它收尾的口子,转发出去手机先答,它只会永远
|
|
251
|
+
* 悬着。独家作答后结论照常走 `approval/decided`,桌面看得到裁决结果。
|
|
277
252
|
*/
|
|
278
253
|
function holdForPhone(entry, next) {
|
|
279
254
|
const downstream = next === undefined
|
|
@@ -369,12 +344,11 @@ async function handleApprovalRespond(ctx, req, res) {
|
|
|
369
344
|
}
|
|
370
345
|
|
|
371
346
|
/**
|
|
372
|
-
* 把结论交回旧通道的 `ctx.apiProxy.respond
|
|
347
|
+
* 把结论交回旧通道的 `ctx.apiProxy.respond`,Promise 与同步对象两种返回都收。
|
|
373
348
|
*
|
|
374
|
-
* 两条通道形态不同:0.1.x 的真 apiProxy 回 Promise,0.2.x
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
* 抛错与「通道说不认」一律折算成 null,调用方据此如实回 409。
|
|
349
|
+
* 两条通道形态不同:0.1.x 的真 apiProxy 回 Promise,0.2.x 的兼容桥回同步对象。同步对象上
|
|
350
|
+
* 没有 `.catch`,直接链 Promise 会抛 TypeError 被路由守卫兜成 500。抛错与「通道说不认」
|
|
351
|
+
* 一律折算成 null,调用方据此如实回 409。
|
|
378
352
|
*/
|
|
379
353
|
async function respondViaLegacyProxy(ctx, message) {
|
|
380
354
|
const proxy = ctx && ctx.apiProxy;
|
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 移动端「清理所有已归档的对话」:按宿主归档集合确定范围,把它们排进待删队列。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* 范围取 workspace.list 的 archivedSessionIds,不用可能过时的列表状态缩小范围;请求必须
|
|
7
|
+
* 显式带 confirm: true,磁盘删除不可恢复。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* (2026-10-20 真机现场)。本进程只把 id 压进删除集,让手机列表立刻看不到它们。
|
|
13
|
-
*
|
|
14
|
-
* 也**不擦宿主的归档集合**:进程里擦会把桌面端快照里那些其实已经没有日志的行放回正常列表。
|
|
15
|
-
* 归档集合的收尾同样在宿主启动时做,见 pruneOrphanedArchivedSessionsOnce。
|
|
9
|
+
* 这里不删磁盘,也不擦宿主的归档集合:只把 id 排进 session-cleanup.cjs 的待删队列,由宿主
|
|
10
|
+
* 下次启动时删。活着的宿主随时可能去打开那些文件,文件不在就是一次未捕获的 ENOENT,会带走
|
|
11
|
+
* 整个进程。
|
|
16
12
|
*/
|
|
17
13
|
|
|
18
14
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
@@ -26,16 +22,11 @@ const { queueArchivedClearIds } = require('../session-cleanup.cjs');
|
|
|
26
22
|
/**
|
|
27
23
|
* 清理前这次补采的预算。
|
|
28
24
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* (`app/src/api/core.ts` 的 `REQUEST_TIMEOUT_MS`),否则手机先报超时、清理由这边照旧
|
|
32
|
-
* 执行,用户会以为没成功。
|
|
33
|
-
*
|
|
34
|
-
* 同时这次补采跳过「最后一句」预览(`skipPreview`):那一步要读会话日志,是整轮里
|
|
35
|
-
* 最重的一趟。删日志前先把数字记住,比给即将消失的卡片多留一行摘要重要。
|
|
25
|
+
* 统计页只给 1.5 秒是为了别让用户转好久;清理是不可恢复的删除,值得多等几秒把账补全,
|
|
26
|
+
* 但不能超过 App 单发请求的 8 秒总预算,否则手机先报超时、这边照旧执行。
|
|
36
27
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
28
|
+
* 这次补采跳过「最后一句」预览:那一步要读会话日志,是整轮最重的一趟。删日志前先把数字
|
|
29
|
+
* 记住,比给即将消失的卡片多留一行摘要重要。
|
|
39
30
|
*/
|
|
40
31
|
const CLEAR_ARCHIVED_COLLECT_BUDGET_MS = 6_000;
|
|
41
32
|
|
|
@@ -3,53 +3,28 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 图片归一:会话历史存的是 `{type:'image', attachment}` 而不是内联字节。
|
|
5
5
|
*
|
|
6
|
-
* 宿主事件里图片可能出现在四处:message/content、inserted 上下文项、
|
|
7
|
-
*
|
|
8
|
-
* 这样历史与 SSE 暴露给手机端的图片形状是同一个。
|
|
6
|
+
* 宿主事件里图片可能出现在四处:message/content、inserted 上下文项、assistant chunk 的
|
|
7
|
+
* block,以及更深的嵌套 content;全部在这里归一,历史与 SSE 暴露给手机的图片形状才一致。
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* 默认(`referencesOnly`)不取字节:图片块留着 `attachment` 引用,由 history-derive 投影成
|
|
10
|
+
* `{mediaType, name?, attachmentId}`,手机按 attachmentId 单张去取。每张都内联 base64 会把
|
|
11
|
+
* 一页 200 条带截图的响应推到近 10MB。只有显式 `referencesOnly: false` 才取回字节,取不到
|
|
12
|
+
* 的打 `dataUnavailable` 标。
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* 另外一类图片在正文里:`` 这种指向**宿主本机文件**的写法
|
|
19
|
-
* (本机绝对路径、file://、~)在手机上取不到字节,也会被 App 当成非 http 图片降级成字面
|
|
20
|
-
* markdown。这里顺手按同一形状内联成 data URI(实现与预算见 local-images.js)。
|
|
21
|
-
* 这条与附件引用是两件事:它走的是正文里的路径、没有 attachmentId,预算与行为保持原样。
|
|
14
|
+
* 正文里 `` 这类指向宿主本机文件的写法另有内联(见 local-images.js),
|
|
15
|
+
* 与附件引用是两件事:它没有 attachmentId。
|
|
22
16
|
*/
|
|
23
17
|
|
|
24
18
|
const { unwrap } = require('./envelope.js');
|
|
25
19
|
const { callSessions } = require('./rpc.js');
|
|
26
20
|
const { inlineTextImages, createTextImageBudget } = require('./local-images.js');
|
|
27
21
|
|
|
28
|
-
/**
|
|
29
|
-
* Resolve durable image references before exposing messages to mobile clients.
|
|
30
|
-
*
|
|
31
|
-
* Session history stores `{type:'image', attachment}` rather than inline bytes.
|
|
32
|
-
* By default (`referencesOnly`) the block is left as a reference and the mobile
|
|
33
|
-
* client fetches the bytes per attachment (`/session/attachment`); no host RPC
|
|
34
|
-
* happens on this path at all. Pass `referencesOnly: false` for the legacy
|
|
35
|
-
* inline-bytes shape.
|
|
36
|
-
*
|
|
37
|
-
* Host events can carry content in several places: message/content, inserted
|
|
38
|
-
* context items, or an assistant/chunk block. Keep this normalization here so
|
|
39
|
-
* history and SSE expose the same image shape to mobile clients.
|
|
40
|
-
*
|
|
41
|
-
* Text blocks additionally get local-file image syntax inlined as data URIs
|
|
42
|
-
* (see local-images.js); `opts.budget` is a shared per-response budget so one
|
|
43
|
-
* paged history response or mirror snapshot stays bounded.
|
|
44
|
-
*/
|
|
45
22
|
/**
|
|
46
23
|
* 取不到字节的图片块:原样留着,只加一个标记。
|
|
47
24
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* attachment 没有 data),不加标记就只能跟着静默消失——用户既不知道有过一张图,
|
|
52
|
-
* 也没有任何重试入口。
|
|
25
|
+
* 这里绝不能抛,也绝不能返回 null:结果会直接摊进整段历史响应或一整帧 SSE,一个 null
|
|
26
|
+
* 或异常会让整页消息一起挂掉。加标记是因为「附件取不到」和「本来就没有这张图」在 App 侧
|
|
27
|
+
* 长得一样,不加标记就只能静默消失,用户也不知道有过一张图。
|
|
53
28
|
*/
|
|
54
29
|
function dataUnavailableBlock(block) {
|
|
55
30
|
return { ...block, dataUnavailable: true };
|
package/lib/mobile/channels.js
CHANGED
|
@@ -3,35 +3,15 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 移动端「渠道管理」API:列出这台宿主上的渠道,并增删「本机自建」那一类。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* 判据不是「谁建的」,而是这条 provider 在不在 `llm-pi-ai` 的 `providers` 里:在 = 本机配置
|
|
7
|
+
* 的、可增删(设置走 `settings.mutate`,Key 走 `credentials.set` / `unset`);不在 = 别的
|
|
8
|
+
* 插件提供的、只读,移动端一个写操作都不做。
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* (免费模型插件那十几家、官方 DeepSeek 等)。移动端**一个写操作都不做**——
|
|
11
|
-
* 那不是我们的渠道,改它就是越界。App 那边只保留「本机显示 / 不显示」的开关。
|
|
12
|
-
* - **本机自建渠道**:`llm-pi-ai` 设置命名空间里 `providers` 下的每一条路由
|
|
13
|
-
* (协议 + 地址 + 一把 Key + 模型清单)。桌面端免费模型面板的「添加渠道」写的
|
|
14
|
-
* 就是这一层,所以手机照同一份口径做:`settings.mutate` 按路径 set / unset,
|
|
15
|
-
* 凭据走 `credentials.set` / `unset`。
|
|
10
|
+
* 不读桌面面板另存的那份自建渠道元数据:那是另一个插件的私有状态,两个进程抢同一个文件
|
|
11
|
+
* 只会写出半截。代价是两边列表的标记会对不上,但真源都是 `llm-pi-ai` 的路由表。
|
|
16
12
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `cordis.patch.yml` 里的 pi-ai 路由也会被列成可删——那是他自己机器上的配置,
|
|
20
|
-
* App 在删除前会二次确认并写出渠道名与地址。
|
|
21
|
-
*
|
|
22
|
-
* ## 为什么不用「免费模型插件的元数据」
|
|
23
|
-
*
|
|
24
|
-
* 桌面面板另存了一份 `$DSH_HOME/our-free-model/custom.json`(描述、介绍、关掉的模型)。
|
|
25
|
-
* 手机不读也不写:那是另一个插件的私有状态,两个进程抢同一个文件只会写出半截。
|
|
26
|
-
* 代价是手机上删掉的渠道,桌面面板的「自建渠道」列表里会显示成「路由缺失」;反过来
|
|
27
|
-
* 手机上建的渠道,桌面面板不会把它当成自己建的。两边都不影响模型选择器——真源是
|
|
28
|
-
* `llm-pi-ai` 的路由表。
|
|
29
|
-
*
|
|
30
|
-
* ## 获取模型为什么在宿主侧发
|
|
31
|
-
*
|
|
32
|
-
* 渠道端点常常在内网 / 只有这台电脑能直连(本机网关、局域网模型服务)。手机直接发
|
|
33
|
-
* 请求既可能不通,也绕过了宿主自己的协议适配。这里借宿主 `llm.discoverModels`
|
|
34
|
-
* (桌面面板同一条路)在电脑上问一次,只回模型 id / 名字,不落盘。
|
|
13
|
+
* 获取模型在宿主侧发:渠道端点常常只有这台电脑能直连,这里借宿主 `llm.discoverModels` 问
|
|
14
|
+
* 一次,只回 id 与名字;手机直接发既可能不通,也绕过宿主的协议适配。
|
|
35
15
|
*/
|
|
36
16
|
|
|
37
17
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
@@ -146,15 +126,12 @@ function normalizeModels(raw) {
|
|
|
146
126
|
/**
|
|
147
127
|
* 一条渠道 → pi-ai 的 provider profile。
|
|
148
128
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
129
|
+
* 只写必须显式说的字段,与桌面端 `profileOf` 一致:自建路由没有内置目录,协议必须点名;
|
|
130
|
+
* 思考档位不写就是「不思考」,所以这里给默认四档。
|
|
151
131
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* - 同一模型仍在清单里 → 沿用它的能力字段;
|
|
156
|
-
* - 新加的模型 → 表单报了就用表单的,没报才落缺省;
|
|
157
|
-
* - 其它不认识的字段(用户手写的)原样带回去。
|
|
132
|
+
* 编辑时按已有那一条做基底:手机表单只有名称 / 协议 / 地址 / 模型 id,能力字段不在界面上,
|
|
133
|
+
* 直接按缺省值重写等于改一次名字就把桌面调过的参数全冲掉。同一模型沿用原能力字段,新加的
|
|
134
|
+
* 模型用表单值或缺省,不认识的字段原样带回去。
|
|
158
135
|
*/
|
|
159
136
|
function profileOf(channel, existing) {
|
|
160
137
|
const previous = new Map();
|
|
@@ -413,12 +390,9 @@ function takenIdsOf(registered, providers) {
|
|
|
413
390
|
* { id?, name, protocol, baseUrl, apiKey?, models: [{id, name?, contextWindow?, maxTokens?, vision?}] }
|
|
414
391
|
* → { ok, channel }
|
|
415
392
|
*
|
|
416
|
-
* 带 `id` = 编辑那一条(id 不变、Key
|
|
417
|
-
* 写 Key →
|
|
418
|
-
*
|
|
419
|
-
* **路由只能按路径写**:`settings.mutate` 的 `set path:['providers', id]` 一次只动这一段——
|
|
420
|
-
* 整段覆盖会把用户手写的其它路由抹掉(桌面端 2026-10-19 踩过:保存一次自建渠道,
|
|
421
|
-
* 另一条路由整段蒸发)。
|
|
393
|
+
* 带 `id` = 编辑那一条(id 不变、Key 留空表示沿用原来的);不带 = 新建(Key 必填)。
|
|
394
|
+
* 写 Key → 写路由,两步都成了才回成功;路由写失败就把这一次新写的 Key 清掉,不留孤儿凭据。
|
|
395
|
+
* 路由只能按路径写:整段覆盖会把用户手写的其它路由抹掉。
|
|
422
396
|
*/
|
|
423
397
|
async function handleChannelSave(ctx, req, res) {
|
|
424
398
|
const body = await readJsonBody(req, 64 * 1024);
|
|
@@ -2,24 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* 旧凭据原地升级:把协议 v1 的会话(HMAC 令牌 / 只带配对码的 `dshc_`)换成带设备主密钥的
|
|
5
|
-
* T1
|
|
5
|
+
* T1,早配好的手机不用重新扫码就能开始加密。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* 主密钥以手机为准:手机在本地生成 `deviceKey` 与 `keyId`,升级请求里明文带上,宿主原样存
|
|
8
|
+
* 库并把同一个 keyId 回给手机。宿主自己生成一把再回标识会让两端各拿一把钥匙,升级完每条
|
|
9
|
+
* 请求都是 `e2ee-bad-signature`。明文上行是一次性取舍——老会话没有别的保密通道,不这么做
|
|
10
|
+
* 只能重新扫码;升级后所有流量仍由这把钥匙端到端加密。
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 不这么做就只能让用户重新扫一次码。升级之后所有流量仍由这把钥匙端到端加密。
|
|
15
|
-
*
|
|
16
|
-
* 换成什么不在本次判断里:拿到完整一套(凭据 + 密钥 + 标识)才回补丁,缺一样就当没升级。
|
|
17
|
-
*
|
|
18
|
-
* **请求体必须现读**(2026-10-25 修):这条路服务的恰好是协议 v1 的老会话,而老会话发的
|
|
19
|
-
* 是明文 —— 守卫只为「带信封」的那条路预先解好 `req.body`,明文请求的字节还挂在 socket 上
|
|
20
|
-
* 没人读过。原来这里直接取 `req.body`,于是永远拿到空对象、宿主每次都回「没升级」,手机
|
|
21
|
-
* 那边一次都没成功过(真机表现是「App 与插件都更新了,设备行仍旧写链路未加密」)。
|
|
22
|
-
* 现在与其它移动端路由同一条读法(`readJsonBody`:`req.body` 有就用,没有就读流)。
|
|
12
|
+
* 请求体必须现读:这条路服务的恰好是发明文的老会话,守卫只替带信封的请求解好 `req.body`,
|
|
13
|
+
* 直接取只会永远拿到空对象。拿到完整一套(凭据 + 密钥 + 标识)才算升级。
|
|
23
14
|
*/
|
|
24
15
|
|
|
25
16
|
const crypto = require('node:crypto');
|