dsh-mobile-plugin 0.5.1 → 0.5.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/lib/host-guard.cjs +11 -13
- package/lib/impl.js +64 -43
- package/lib/index.js +21 -22
- package/lib/mobile/agent-presets.js +67 -1
- package/lib/mobile/debug-trace.js +180 -0
- package/lib/mobile/deepseek-account.js +3 -0
- package/lib/mobile/entry-window.js +6 -1
- package/lib/mobile/history-derive.js +148 -9
- package/lib/mobile/local-images.js +200 -47
- package/lib/mobile/session-local-image.js +45 -0
- package/lib/mobile/session-mirror.js +2 -1
- package/lib/mobile/session-send.js +13 -1
- package/lib/mobile/session-snapshot.js +37 -54
- package/lib/mobile/session-stats.js +51 -0
- package/lib/mobile/sessions.js +3 -2
- package/lib/mobile/tool-result-cache.js +75 -0
- package/lib/panel/index.html +6 -6
- package/lib/relay/firewall-probe.js +11 -5
- package/lib/routes/mobile-api.js +11 -0
- package/lib/shell/runtime.js +3 -2
- package/lib/sidecar-lifecycle.cjs +18 -1
- package/lib/update-install.cjs +2 -2
- package/package.json +2 -2
package/lib/host-guard.cjs
CHANGED
|
@@ -9,15 +9,17 @@
|
|
|
9
9
|
* 1. 游离的 Promise 拒绝——Node 15+ 默认 throw 并杀进程,插件里大量 fire-and-forget;
|
|
10
10
|
* 2. 第三方回调(事件流、WS、timer)里抛错,没有 try 包着就是 uncaughtException。
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* 策略:全部记入内存账本并保持存活,**不主动退出进程**。这个进程里跑的不止我们:无头宿主
|
|
13
|
+
* 把整份 profile 的插件都装在同一个 process 上,别人的崩溃循环也走同一条 `uncaughtException`。
|
|
14
|
+
* 在这里 exit 就等于「别人的插件崩五次,我们把用户的 DSH 关掉」,而桌面端根本没有守护来拉起。
|
|
15
|
+
* 所以只记账、只报警,生死交给部署方(systemd 的 Restart、宿主的 installFailLoud 只管装载期)。
|
|
16
|
+
*
|
|
17
|
+
* `createGuard()` 是纯状态机,`installProcessGuard()` 才是进程接线,拆分是为了能被守卫测试覆盖。
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
const FATAL_FAULT_COUNT = 5;
|
|
18
21
|
const CONSECUTIVE_FAULT_WINDOW_MS = 60 * 1000;
|
|
19
22
|
const FAULT_LEDGER_LIMIT = 50;
|
|
20
|
-
const FATAL_EXIT_CODE = 1;
|
|
21
23
|
|
|
22
24
|
/** 把任意 throw 出来的值压成一行可读文本。 */
|
|
23
25
|
function describeError(err) {
|
|
@@ -38,7 +40,6 @@ function describeError(err) {
|
|
|
38
40
|
* @param {number} [options.ledgerLimit] 账本上限,缺省 50
|
|
39
41
|
* @param {() => number} [options.now] 注入时钟,便于测试
|
|
40
42
|
* @param {(line: string) => void} [options.log] 注入日志,便于测试
|
|
41
|
-
* @param {(code: number) => void} [options.exit] 注入退出,便于测试
|
|
42
43
|
* @returns {{handleUncaughtException: Function, handleUnhandledRejection: Function,
|
|
43
44
|
* recordFault: Function, faults: Function, stats: Function, clear: Function,
|
|
44
45
|
* isFatal: Function}}
|
|
@@ -48,7 +49,6 @@ function createGuard(options = {}) {
|
|
|
48
49
|
const windowMs = options.windowMs ?? CONSECUTIVE_FAULT_WINDOW_MS;
|
|
49
50
|
const ledgerLimit = options.ledgerLimit ?? FAULT_LEDGER_LIMIT;
|
|
50
51
|
const now = options.now ?? (() => Date.now());
|
|
51
|
-
const exit = options.exit ?? ((code) => process.exit(code));
|
|
52
52
|
const log = options.log ?? ((line) => { process.stderr.write(`${line}\n`); });
|
|
53
53
|
|
|
54
54
|
/** @type {{at: number, kind: string, message: string, fatal: boolean}[]} */
|
|
@@ -65,8 +65,8 @@ function createGuard(options = {}) {
|
|
|
65
65
|
|
|
66
66
|
return {
|
|
67
67
|
/**
|
|
68
|
-
* `process.on('uncaughtException')`
|
|
69
|
-
*
|
|
68
|
+
* `process.on('uncaughtException')` 的处理函数。只记一笔并计入连续计数:够数**不退出**,
|
|
69
|
+
* 只把这条故障在账本里标成 fatal,让 `/api/relay/status` 的自检能说出「这台在崩溃循环」。
|
|
70
70
|
*/
|
|
71
71
|
handleUncaughtException(err) {
|
|
72
72
|
const at = now();
|
|
@@ -77,16 +77,15 @@ function createGuard(options = {}) {
|
|
|
77
77
|
const fatal = consecutive >= fatalCount;
|
|
78
78
|
if (fatal) fatalEver = true;
|
|
79
79
|
record('uncaughtException', describeError(err), fatal);
|
|
80
|
-
log(`[dsh-mobile-plugin] 未捕获异常(连续 ${consecutive}/${fatalCount}${fatal ? '
|
|
81
|
-
if (fatal) exit(FATAL_EXIT_CODE);
|
|
80
|
+
log(`[dsh-mobile-plugin] 未捕获异常(连续 ${consecutive}/${fatalCount}${fatal ? ',判定崩溃循环;不退出进程,请查宿主日志' : ''}): ${describeError(err)}`);
|
|
82
81
|
return fatal;
|
|
83
82
|
},
|
|
84
83
|
|
|
85
84
|
/**
|
|
86
85
|
* `process.on('unhandledRejection')` 的处理函数。
|
|
87
86
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
87
|
+
* 刻意**不计入连续计数**:游离 reject 在长驻服务里非常常见(一次后台推送失败就
|
|
88
|
+
* 一个),把它们算进崩溃循环的判据会制造新的误报。挂上处理器本身就已经把
|
|
90
89
|
* Node 的默认「throw 并杀进程」改成了「记录并继续」。
|
|
91
90
|
*/
|
|
92
91
|
handleUnhandledRejection(reason) {
|
|
@@ -196,5 +195,4 @@ module.exports = {
|
|
|
196
195
|
FATAL_FAULT_COUNT,
|
|
197
196
|
CONSECUTIVE_FAULT_WINDOW_MS,
|
|
198
197
|
FAULT_LEDGER_LIMIT,
|
|
199
|
-
FATAL_EXIT_CODE,
|
|
200
198
|
};
|
package/lib/impl.js
CHANGED
|
@@ -51,37 +51,27 @@ const { activeGuard } = require('./host-guard.cjs')
|
|
|
51
51
|
// 有一个落盘处:客户端半边(lib/client.js)上报 → 这里 → 移动端读取。
|
|
52
52
|
|
|
53
53
|
export const name = 'dsh-mobile-plugin'
|
|
54
|
-
//
|
|
55
|
-
// `
|
|
54
|
+
// 0.2.0 移除了 `settings.register(namespace, schema, { base, applies })` 那套 API,所以不再
|
|
55
|
+
// 注入 `settings`:配置改由插件自己的 relay.json 存储负责(见下面 createConfigStore)。
|
|
56
|
+
// 0.2.0 里也没有 `apiProxy` 这个服务,宿主 RPC 全走 `typertGateway`;`ctx.apiProxy` 的
|
|
57
|
+
// 旧形状由 apply 在本 fiber 上 provide 的兼容桥兜住(fiber.store 里就有,get trap 第一站
|
|
58
|
+
// 就命中)。漏了这条分支,读 `ctx.apiProxy` 会抛 "without inject",服务器宿主上 mirror
|
|
59
|
+
// 推送曾整条挂掉。
|
|
56
60
|
//
|
|
57
|
-
//
|
|
58
|
-
// (`@deepseek-ai/dsh-settings` 现在只导出 SettingsForms / redactSecrets /
|
|
59
|
-
// SettingsConflictError),所以这里不再注入 `settings`,配置改由插件自己的
|
|
60
|
-
// relay.json 存储负责 —— 见下面 createConfigStore。
|
|
61
|
+
// 只有 `webServer` 是硬依赖:没有它这个插件一行路由都注册不上,等不到就是真的没得做。
|
|
61
62
|
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// pending ——
|
|
66
|
-
// 而日志只有一行 "pending (waiting for service: apiProxy)"。
|
|
63
|
+
// 其余服务**一律不写进 inject**,改成用到时 `ctx.get`。cordis 的 inject 是「缺一个就整行
|
|
64
|
+
// pending」:`typertGateway` 由后装载的另一行提供、`sessions` 来自别的 bundle,任何一个被
|
|
65
|
+
// 别人关掉 / 换掉 / 延迟,注进去的代价都是路由、sidecar、配对、二维码**一起不出现**,日志
|
|
66
|
+
// 里只剩一行 pending —— 手机上表现为「装了插件但完全连不上」。
|
|
67
67
|
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// provide 兼容桥(fiber.store 里
|
|
72
|
-
// 有,get trap 第一站就命中)。
|
|
73
|
-
// 漏了这两个分支里的任何一个,读 `ctx.apiProxy` 都会抛
|
|
74
|
-
// `cannot get property "apiProxy" without inject`:服务器宿主上 mirror 推送曾整条挂掉。
|
|
68
|
+
// 取不到时如实降级:宿主 RPC 那条回 `host-unavailable`(见 host-bridge/transport.js 的
|
|
69
|
+
// gatewayOf,它每次调用都现取),路由、局域网直连、控制面、镜像照常工作。实时流那条在
|
|
70
|
+
// 网关永远不出现时保持空闲 + 心跳(`host-stream-unavailable`),下一次重连就会接上。
|
|
75
71
|
//
|
|
76
|
-
// `
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
// 上实时推送整条挂掉,而收发消息、会话列表这些走普通请求的功能一切正常,看着像只有
|
|
80
|
-
// 「刷新不实时」的小毛病。注入之后 cordis 会等服务就绪再激活本插件。
|
|
81
|
-
// 旧运行时那一支不加:那版没有这个服务,注入只会让这一行永远 pending。
|
|
82
|
-
export const inject = runtimeProvidesApiProxy()
|
|
83
|
-
? ['webServer', 'apiProxy', 'sessions']
|
|
84
|
-
: ['webServer', 'sessions', 'typertGateway']
|
|
72
|
+
// 旧运行时那一支多一个 `apiProxy`:宿主自己提供它(`@deepseek-ai/dsh-host-apiproxy` 装上
|
|
73
|
+
// 了,就一定会被提供),读 `ctx.apiProxy` 必须有 inject,否则 get trap 直接抛。
|
|
74
|
+
export const inject = runtimeProvidesApiProxy() ? ['webServer', 'apiProxy'] : ['webServer']
|
|
85
75
|
|
|
86
76
|
export const REMOTE_CONTROL_SETTINGS_NAMESPACE = 'mobile-remote-control'
|
|
87
77
|
export const RemoteControlSettingsSchema = z.object({
|
|
@@ -258,7 +248,27 @@ function apply(ctx) {
|
|
|
258
248
|
// 这两条二选一,判据同源:0.1.x 宿主自带真 apiProxy(审批帧与回答都还走得通),
|
|
259
249
|
// 0.2.x 才需要兼容桥,也才需要审批桥(见 mobile/approvals.js 的文件头)。
|
|
260
250
|
const hasHostApiProxy = runtimeProvidesApiProxy()
|
|
261
|
-
|
|
251
|
+
/** 装配层自己的告警出口:拿不到 logger 也照样往下走,不打日志不是失败。 */
|
|
252
|
+
const warn = (message, ...rest) => {
|
|
253
|
+
try { ctx.logger?.warn?.(`${name}: ${message}`, ...rest) } catch { /* 日志本身不许反噬装配 */ }
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* 每一块装配各自兜住:一块挂不上只留一行日志,剩下的照常启动。
|
|
257
|
+
*
|
|
258
|
+
* 代价说清楚:别人撞了同一个路由、先占了一个服务名,缺的必须是**那一块**,不能是整张
|
|
259
|
+
* 移动 API —— 装配层里一个没兜住的 throw 会让 fiber 回滚,已经注册好的路由一起消失。
|
|
260
|
+
*/
|
|
261
|
+
const mount = (label, fn) => {
|
|
262
|
+
try { return fn() } catch (error) {
|
|
263
|
+
warn(`${label} 没有挂上:%s`, error?.message || error)
|
|
264
|
+
return undefined
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!hasHostApiProxy) {
|
|
268
|
+
// `apiProxy` 是公共名字:另一个兼容旧 API 的插件也可能 provide 它。撞上时用对方那份
|
|
269
|
+
// (同形状、同语义),不跟它抢 —— 抢的结果是我们自己整块装配失败,手机那边全断。
|
|
270
|
+
mount('apiProxy 兼容桥', () => ctx.reflect.provide('apiProxy', createApiProxy(ctx)))
|
|
271
|
+
}
|
|
262
272
|
|
|
263
273
|
const state = core.loadState()
|
|
264
274
|
// 0.2.0 之前是 `ctx.settings.register(REMOTE_CONTROL_SETTINGS_NAMESPACE,
|
|
@@ -329,11 +339,11 @@ function apply(ctx) {
|
|
|
329
339
|
// 隧道刚连上时补推一次全量:未连通期间每轮推送都被 worker 以 503 拒绝。
|
|
330
340
|
sidecarLifecycle.onTunnelUp(() => { void mirror.pushMirror(); })
|
|
331
341
|
|
|
332
|
-
ctx.effect(() => sidecarLifecycle.start(), `${name}: facade sidecar`)
|
|
333
|
-
ctx.effect(() => mirror.start(), `${name}: mobile mirror sync`)
|
|
342
|
+
mount('sidecar', () => ctx.effect(() => sidecarLifecycle.start(), `${name}: facade sidecar`))
|
|
343
|
+
mount('镜像推送', () => ctx.effect(() => mirror.start(), `${name}: mobile mirror sync`))
|
|
334
344
|
|
|
335
|
-
if (!hasHostApiProxy) ctx.effect(() => installApprovalBridge(ctx), `${name}: host approval bridge`)
|
|
336
|
-
if (!hasHostApiProxy) ctx.effect(() => installQuestionBridge(ctx), `${name}: host question bridge`)
|
|
345
|
+
if (!hasHostApiProxy) mount('审批桥', () => ctx.effect(() => installApprovalBridge(ctx), `${name}: host approval bridge`))
|
|
346
|
+
if (!hasHostApiProxy) mount('提问桥', () => ctx.effect(() => installQuestionBridge(ctx), `${name}: host question bridge`))
|
|
337
347
|
ctx.effect(() => {
|
|
338
348
|
// Profiles without the optional tools injection still support core remote control.
|
|
339
349
|
try { installSessionCleanup(ctx) } catch (error) {
|
|
@@ -348,7 +358,7 @@ function apply(ctx) {
|
|
|
348
358
|
}
|
|
349
359
|
return undefined
|
|
350
360
|
}, `${name}: extension tool`)
|
|
351
|
-
ctx.effect(() => statsScheduler.start(ctx), `${name}: stats collect`)
|
|
361
|
+
mount('统计采集', () => ctx.effect(() => statsScheduler.start(ctx), `${name}: stats collect`))
|
|
352
362
|
|
|
353
363
|
const status = createStatusView({
|
|
354
364
|
state,
|
|
@@ -365,11 +375,24 @@ function apply(ctx) {
|
|
|
365
375
|
function applyServices() {
|
|
366
376
|
void syncSidecar().catch(() => undefined)
|
|
367
377
|
}
|
|
378
|
+
/**
|
|
379
|
+
* 一条路由挂不上只丢这一条。
|
|
380
|
+
*
|
|
381
|
+
* `webServer.register` 对重复地址是抛错(另一个插件用了同一个路径、上一代没卸干净都在
|
|
382
|
+
* 这一类),而这个调用本来就在 effect 里 —— 让它抛出去会把整个 fiber 带走,连已经挂上
|
|
383
|
+
* 的几百条一起消失。所以这里逐条兜住,日志里留下路径,别让手机端拿到一片 404。
|
|
384
|
+
*/
|
|
385
|
+
const mountRoute = (route, upgrade) => mount(`路由 ${route.path}`, () => ctx.effect(() => {
|
|
386
|
+
try { return upgrade ? ctx.webServer.registerUpgrade(route) : ctx.webServer.register(route) } catch (error) {
|
|
387
|
+
warn('路由 %s 没挂上:%s', route.path, error?.message || error)
|
|
388
|
+
return () => {}
|
|
389
|
+
}
|
|
390
|
+
}, `${name}: ${route.path}`))
|
|
368
391
|
function registerRoute(route) {
|
|
369
|
-
|
|
392
|
+
mountRoute(route)
|
|
370
393
|
}
|
|
371
394
|
|
|
372
|
-
registerControlPlaneRoutes({
|
|
395
|
+
mount('控制面路由', () => registerControlPlaneRoutes({
|
|
373
396
|
registerRoute,
|
|
374
397
|
// 更新的机制住在外壳(`lib/index.js`)里,代这边只把它开成本机接口。取不到就如实报
|
|
375
398
|
// 不可用(`lib/impl.js` 被单独挂载时就是这样)。
|
|
@@ -408,7 +431,7 @@ function apply(ctx) {
|
|
|
408
431
|
// 吊销时收掉这台设备已经开着的流(§3.3):token 换了只保证「下一次请求」被拒,
|
|
409
432
|
// 一条已经开着的 SSE 不会自己停,被踢的手机还能继续收消息直到它自己重连。
|
|
410
433
|
dropDeviceStreams: (deviceId) => streamRegistry.drop(deviceId),
|
|
411
|
-
})
|
|
434
|
+
}))
|
|
412
435
|
|
|
413
436
|
|
|
414
437
|
/**
|
|
@@ -452,13 +475,11 @@ function apply(ctx) {
|
|
|
452
475
|
|
|
453
476
|
// 移动端路由表在 lib/routes/mobile-api.js:那里是一张可以对着 App 侧 apiPaths.ts
|
|
454
477
|
// 核对的「路径 → handler」表,不用在 1000 行装配代码里翻。
|
|
455
|
-
registerMobileRoutes({
|
|
478
|
+
mount('移动端路由', () => registerMobileRoutes({
|
|
456
479
|
registerRoute,
|
|
457
480
|
// 预览是 WebSocket 升级路由,走的是 webServer 的另一套名字空间;与 registerRoute
|
|
458
481
|
// 一样必须包在 ctx.effect 里,否则插件重载时重复注册会直接抛错。
|
|
459
|
-
registerUpgradeRoute: (route) =>
|
|
460
|
-
ctx.effect(() => ctx.webServer.registerUpgrade(route), `${name}: ${route.path}`)
|
|
461
|
-
},
|
|
482
|
+
registerUpgradeRoute: (route) => mountRoute(route, true),
|
|
462
483
|
preview,
|
|
463
484
|
guarded,
|
|
464
485
|
state,
|
|
@@ -495,8 +516,8 @@ function apply(ctx) {
|
|
|
495
516
|
credentials,
|
|
496
517
|
activeGuard,
|
|
497
518
|
},
|
|
498
|
-
})
|
|
499
|
-
ctx.effect(() => {
|
|
519
|
+
}))
|
|
520
|
+
mount('服务同步', () => ctx.effect(() => {
|
|
500
521
|
const stopWatching = settings.watch(next => {
|
|
501
522
|
Object.assign(state, next)
|
|
502
523
|
core.saveState(state)
|
|
@@ -509,7 +530,7 @@ function apply(ctx) {
|
|
|
509
530
|
// 那个 effect 独占。两个 effect 都停一次会让 worker 在 dispose 期间收到
|
|
510
531
|
// 两次 stop,第二次撞上已关闭的进程句柄并抛出未捕获异常。
|
|
511
532
|
}
|
|
512
|
-
}, `${name}: services`)
|
|
533
|
+
}, `${name}: services`))
|
|
513
534
|
}
|
|
514
535
|
|
|
515
536
|
export { apply }
|
package/lib/index.js
CHANGED
|
@@ -31,14 +31,14 @@ import { createDeviceOta } from './shell/device-ota.js'
|
|
|
31
31
|
import { installHostHalf } from './shell/half-route.js'
|
|
32
32
|
|
|
33
33
|
export const name = 'dsh-mobile-plugin'
|
|
34
|
-
|
|
34
|
+
// 外壳同样只等 `webServer`:它不读 `sessions`,多注一个只是多一种「整行 pending」的理由。
|
|
35
|
+
export const inject = ['webServer']
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* 装载当前代,并把「查 / 装 / 换代码」这套动作暴露成服务。
|
|
38
39
|
*
|
|
39
|
-
* 对照 `lib/impl.js` 的 `apply
|
|
40
|
-
*
|
|
41
|
-
* fiber 作为它的子节点一起被 dispose,代里那些 `ctx.effect` 的清理动作照常执行。
|
|
40
|
+
* 对照 `lib/impl.js` 的 `apply`:那边注册真正的移动 API,这里注册「装载它」。两份 effect
|
|
41
|
+
* 各自归属自己那层 fiber —— 外壳被卸载时,代的 fiber 作为子节点一起 dispose。
|
|
42
42
|
*/
|
|
43
43
|
export function apply(ctx) {
|
|
44
44
|
const runtime = createRuntime()
|
|
@@ -97,32 +97,31 @@ export function apply(ctx) {
|
|
|
97
97
|
* 就重试」都在 `updater.performUpdate` 里 —— 入口只留装载逻辑,行数钉着。
|
|
98
98
|
*/
|
|
99
99
|
async function applyUpdate({ force = false, auto = false } = {}) {
|
|
100
|
-
return updater.performUpdate({
|
|
101
|
-
runtime, base, hostService, updateState, force, auto, log,
|
|
102
|
-
beforeInstall: snapshotRunning,
|
|
103
|
-
})
|
|
100
|
+
return updater.performUpdate({ runtime, base, hostService, updateState, force, auto, log, beforeInstall: snapshotRunning })
|
|
104
101
|
}
|
|
105
102
|
|
|
106
|
-
// 换代前要知道有没有请求在飞(见 runtime.drain
|
|
107
|
-
//
|
|
108
|
-
//
|
|
103
|
+
// 换代前要知道有没有请求在飞(见 runtime.drain)。webServer 没有中间件机制,所以包一层
|
|
104
|
+
// register 把每条 handler 变成「进 +1 / 出 -1」(`wrapHandler` 认不出的形状原样放行)。
|
|
105
|
+
//
|
|
106
|
+
// 这层包装必须**可叠加**:别人后来也包一层时闭包着我们的包装,撤销时无条件还原会把
|
|
107
|
+
// 别人的那层一起摘掉。所以只在「现在挂的就是我们这层」时才还原。
|
|
109
108
|
ctx.effect(() => {
|
|
110
109
|
const server = ctx.webServer
|
|
111
|
-
const
|
|
112
|
-
if (typeof
|
|
113
|
-
|
|
114
|
-
return
|
|
110
|
+
const outer = server?.register
|
|
111
|
+
if (typeof outer !== 'function') return () => {}
|
|
112
|
+
const wrapper = function register(route) {
|
|
113
|
+
return outer.call(this, runtime.wrapHandler(route))
|
|
115
114
|
}
|
|
115
|
+
server.register = wrapper
|
|
116
116
|
return () => {
|
|
117
|
-
server.register =
|
|
117
|
+
if (server.register === wrapper) server.register = outer
|
|
118
118
|
return runtime.detach()
|
|
119
119
|
}
|
|
120
120
|
}, 'dsh-mobile-plugin: generation runtime')
|
|
121
121
|
|
|
122
122
|
const installedShellHash = () => updater.shellFingerprint(installedPackageDir())
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
// 都不在,只有外壳还活着 —— 那是「它为什么没起来」唯一说得通的落点。
|
|
123
|
+
// 宿主半边的自检口(`shell/half-route.js`):实现装载失败时整张控制面路由都不在,只有
|
|
124
|
+
// 外壳还活着 —— 那是「它为什么没起来」唯一说得通的落点。
|
|
126
125
|
ctx.effect(() => installHostHalf(ctx, runtime, { installedVersion, installedShellHash, runningShellHash }), 'dsh-mobile-plugin: host half self check')
|
|
127
126
|
|
|
128
127
|
ctx.reflect.provide('dshMobilePluginRuntime', {
|
|
@@ -163,9 +162,8 @@ export function apply(ctx) {
|
|
|
163
162
|
.start(ctx, { log })
|
|
164
163
|
.then(() => {
|
|
165
164
|
log('info', 'dsh-mobile-plugin: 已装载 %s(来源 %s)', runtime.status().version, runtime.status().source)
|
|
166
|
-
// 每 2 分钟让已落盘的正式版接管 OTA
|
|
167
|
-
//
|
|
168
|
-
// 补换代不受开关管:它不下载任何东西,只是让已经在盘上的代码生效。
|
|
165
|
+
// 每 2 分钟让已落盘的正式版接管 OTA,并补上「盘上比运行中新」的那次换代(补换代不
|
|
166
|
+
// 受开关管:它不下载任何东西);自动安装由开关控制,开着就不等正在跑的对话。
|
|
169
167
|
const timer = setInterval(() => {
|
|
170
168
|
deviceOta.reconcileOfficial()
|
|
171
169
|
.then(() => updater.reconcileInstalledGeneration({ runtime, log }))
|
|
@@ -179,6 +177,7 @@ export function apply(ctx) {
|
|
|
179
177
|
ctx.effect(() => () => clearInterval(timer), 'dsh-mobile-plugin: auto update timer')
|
|
180
178
|
// 启动后延后一次:别和绑端口、连隧道抢带宽。
|
|
181
179
|
if (updater.autoUpdateEnabled()) {
|
|
180
|
+
|
|
182
181
|
const kick = setTimeout(() => {
|
|
183
182
|
applyUpdate({ auto: true }).catch(error => {
|
|
184
183
|
updateState.lastError = String(error?.message || error)
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* 移动端「Agent 预设管理」API:把桌面端设置页的预设管理分区补齐到手机上。
|
|
5
5
|
*
|
|
6
6
|
* 手机端只保留管理分区(app 的 PresetManager):桌面那边设置里的一行与新建会话芯片已下线,
|
|
7
|
-
*
|
|
7
|
+
* 手机一律吃宿主默认档。四条路由:read / copy / remove 转发宿主的 agentPreset 域;default
|
|
8
|
+
* 写宿主 settings 里存「默认档」的那一格(见 handleAgentPresetDefault)。
|
|
8
9
|
*
|
|
9
10
|
* 语义按宿主原文透传:copy 是整份复制,标识符成为目录名且事后无法更改;remove 只对 user
|
|
10
11
|
* 预设有效(随附预设回 agent-preset-read-only)。宿主的失败对象是 code + details,按 code
|
|
@@ -143,10 +144,73 @@ async function handleAgentPresetRemove(ctx, req, res) {
|
|
|
143
144
|
sendJson(res, 200, { ok: true, agentPreset: id.value });
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
/** 宿主 settings 里存「默认预设」的那一格:桌面端「设为默认」写的就是它。 */
|
|
148
|
+
const PRESET_SETTINGS_NS = 'agent-preset-registry';
|
|
149
|
+
const PRESET_DEFAULT_FIELD = 'selectedDefault';
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* POST /api/mobile/v1/agent-presets/default(token){ agentPreset } → { ok, agentPreset }
|
|
153
|
+
*
|
|
154
|
+
* 「哪一档是新会话的默认档」不是预设自己的属性:宿主注册表从 settings 的
|
|
155
|
+
* `agent-preset-registry.selectedDefault` 读(没写过就退回部署时那个 default),桌面端那颗
|
|
156
|
+
* 「设为默认」写的就是这一格。手机此前没有这个入口,新会话只能吃部署时那一档。
|
|
157
|
+
*
|
|
158
|
+
* 先按名单确认这一档真在,再写:写一个不存在的 id 不会报错,但之后每次新建会话都会被宿主
|
|
159
|
+
* 拒(agent-preset-not-found),那时用户已经不知道是自己哪一步弄坏的。
|
|
160
|
+
*/
|
|
161
|
+
async function handleAgentPresetDefault(ctx, req, res) {
|
|
162
|
+
const body = await readJsonBody(req, PRESET_MAX_BODY);
|
|
163
|
+
const id = normalizeId(body && body.agentPreset);
|
|
164
|
+
if (!id.value) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: id.problem } });
|
|
165
|
+
|
|
166
|
+
const listed = unwrap(await callAgentPresets(ctx, 'list', {}));
|
|
167
|
+
if (listed.error) return sendJson(res, statusOf(listed.error.code), { ok: false, error: failure(listed.error) });
|
|
168
|
+
const rows = listed.value && Array.isArray(listed.value.presets) ? listed.value.presets : [];
|
|
169
|
+
const row = rows.find((item) => item && item.id === id.value);
|
|
170
|
+
if (!row) {
|
|
171
|
+
return sendJson(res, 404, {
|
|
172
|
+
ok: false,
|
|
173
|
+
error: {
|
|
174
|
+
code: 'agent-preset-not-found',
|
|
175
|
+
message: presetMessage('agent-preset-not-found'),
|
|
176
|
+
details: { agentPreset: id.value, available: rows.map((item) => item && item.id).filter(Boolean) },
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
// 已经是默认档就不再写:settings.mutate 一次就推进一次 revision,白写等于让别处正在
|
|
181
|
+
// 改设置的人撞上冲突。
|
|
182
|
+
if (row.isDefault === true) return sendJson(res, 200, { ok: true, agentPreset: id.value });
|
|
183
|
+
|
|
184
|
+
const settings = ctx.apiProxy && ctx.apiProxy.settings;
|
|
185
|
+
if (!settings || typeof settings.mutate !== 'function') {
|
|
186
|
+
return sendJson(res, 400, {
|
|
187
|
+
ok: false,
|
|
188
|
+
error: { code: 'unsupported', message: '这台宿主没有设置写入接口,改不了默认预设' },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const { error } = unwrap(await settings.mutate({
|
|
192
|
+
rpcId: 'mobile',
|
|
193
|
+
method: 'settings.mutate',
|
|
194
|
+
payload: { ns: PRESET_SETTINGS_NS, ops: [{ op: 'set', path: [PRESET_DEFAULT_FIELD], value: id.value }] },
|
|
195
|
+
}));
|
|
196
|
+
if (error) {
|
|
197
|
+
const hostMessage = error && typeof error.message === 'string' ? error.message : '';
|
|
198
|
+
return sendJson(res, 400, {
|
|
199
|
+
ok: false,
|
|
200
|
+
error: {
|
|
201
|
+
code: error && typeof error.code === 'string' && error.code ? error.code : 'internal',
|
|
202
|
+
message: hostMessage ? `默认预设没写进去:${hostMessage}` : '默认预设没写进去',
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
sendJson(res, 200, { ok: true, agentPreset: id.value });
|
|
207
|
+
}
|
|
208
|
+
|
|
146
209
|
module.exports = {
|
|
147
210
|
handleAgentPresetRead,
|
|
148
211
|
handleAgentPresetCopy,
|
|
149
212
|
handleAgentPresetRemove,
|
|
213
|
+
handleAgentPresetDefault,
|
|
150
214
|
// 下面这些是给守卫测试直接驱动的纯函数(与 references.js 同做法)
|
|
151
215
|
normalizeId,
|
|
152
216
|
normalizeName,
|
|
@@ -159,4 +223,6 @@ module.exports = {
|
|
|
159
223
|
PRESET_ID_MAX_CHARS,
|
|
160
224
|
PRESET_NAME_MAX_CHARS,
|
|
161
225
|
PRESET_BODY_MAX_CHARS,
|
|
226
|
+
PRESET_SETTINGS_NS,
|
|
227
|
+
PRESET_DEFAULT_FIELD,
|
|
162
228
|
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 手机端调试轨迹的落盘口(`POST /api/mobile/v1/debug/trace`)。
|
|
5
|
+
*
|
|
6
|
+
* 为什么要有它:滑动手感出问题时,现场在手机上(偏移轨迹、每一行的实际高度、图片什么时候
|
|
7
|
+
* 才到),而能看这些东西的人在这台电脑上。让人把几万行日志抄进聊天不现实,所以 App 边录边把
|
|
8
|
+
* 一行行 JSONL 打到这里,插件落进 `<DSH_HOME>/mobile-debug/<capture>.jsonl`,维护者在这台
|
|
9
|
+
* 电脑上直接读文件。
|
|
10
|
+
*
|
|
11
|
+
* 三条边界,别放宽:
|
|
12
|
+
* - **不是任意文件写入**:文件名只由 `capture` 生成,且只认 `[A-Za-z0-9._-]`。构造一个别的
|
|
13
|
+
* capture 只会在这一个目录里多一份空文件,碰不到用户的任何东西;
|
|
14
|
+
* - **只追加**:一次请求往 `<capture>.jsonl` 末尾续一段,从不覆盖、从不删别的文件;
|
|
15
|
+
* - **封顶**:一份轨迹 8MB 之后停写(回 `capped: true`,App 收到就停手),目录里最多留 10 份,
|
|
16
|
+
* 第 11 份进来时删掉最旧那份。录音不会把盘写满。
|
|
17
|
+
*
|
|
18
|
+
* 与 `session/file`、`session/attachment` 那些读口方向相反:这条只写、写的位置固定,回给手机
|
|
19
|
+
* 的只有文件名与字节数,不回路径。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const fsp = require('node:fs/promises');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const { dshHome } = require('../shared/paths');
|
|
27
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
28
|
+
|
|
29
|
+
/** 一份轨迹的字节上限。真到这一步说明录得太久,该停了。 */
|
|
30
|
+
const MAX_CAPTURE_BYTES = 8 * 1024 * 1024;
|
|
31
|
+
/** 目录里最多留几份(按 mtime,新的来、旧的走)。 */
|
|
32
|
+
const MAX_CAPTURES = 10;
|
|
33
|
+
/** 一次请求最多带多少行;App 那边按 40KB 切批,正常远小于这个数。 */
|
|
34
|
+
const MAX_LINES_PER_BATCH = 4000;
|
|
35
|
+
/** 一次请求最多多少字节(`readJsonBody` 本身还有 344KB 的封顶,这里再收一道)。 */
|
|
36
|
+
const MAX_BATCH_BYTES = 512 * 1024;
|
|
37
|
+
/** 轨迹名(也就是文件名):只认这几类字符,别的一律 400。 */
|
|
38
|
+
const CAPTURE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
39
|
+
|
|
40
|
+
/** 轨迹目录。按调用时解析,测试可注入 `DSH_HOME`。 */
|
|
41
|
+
function traceDir() {
|
|
42
|
+
return path.join(dshHome(), 'mobile-debug');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 某一份轨迹的落盘路径(`capture` 已经过白名单校验)。 */
|
|
46
|
+
function capturePath(capture) {
|
|
47
|
+
return path.join(traceDir(), `${capture}.jsonl`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 目录里留最近 `MAX_CAPTURES` 份。
|
|
52
|
+
*
|
|
53
|
+
* 按 mtime 而不是文件名:轨迹名由 App 带时间戳生成,但同一秒的两份、或者用户手改过的名字都
|
|
54
|
+
* 不该让清理算错。读不动 stat 的(正在被删)直接跳过,清理失败不该让这次上传失败。
|
|
55
|
+
*/
|
|
56
|
+
async function pruneCaptures() {
|
|
57
|
+
let names;
|
|
58
|
+
try {
|
|
59
|
+
names = await fsp.readdir(traceDir());
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const files = [];
|
|
64
|
+
for (const name of names) {
|
|
65
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
66
|
+
const full = path.join(traceDir(), name);
|
|
67
|
+
try {
|
|
68
|
+
const info = await fsp.stat(full);
|
|
69
|
+
if (info.isFile()) files.push({ name, full, mtime: info.mtimeMs });
|
|
70
|
+
} catch { /* 刚被删掉 / 读不动:跳过 */ }
|
|
71
|
+
}
|
|
72
|
+
if (files.length <= MAX_CAPTURES) return;
|
|
73
|
+
files.sort((a, b) => a.mtime - b.mtime);
|
|
74
|
+
for (const file of files.slice(0, files.length - MAX_CAPTURES)) {
|
|
75
|
+
try {
|
|
76
|
+
await fsp.unlink(file.full);
|
|
77
|
+
} catch { /* 删不掉就留着,下次再说 */ }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 续写一段轨迹。
|
|
83
|
+
*
|
|
84
|
+
* `lines` 是已经序列化好的 JSON 行(App 侧拼的),这里只做「拼成一段文本 + 追加」。一行都
|
|
85
|
+
* 不合法的批次按空处理:回 ok、写 0 字节,别让手机那头因为一次形状不对就整段录不上。
|
|
86
|
+
*/
|
|
87
|
+
async function handleDebugTrace(_ctx, req, res) {
|
|
88
|
+
const body = await readJsonBody(req);
|
|
89
|
+
const capture = body && typeof body.capture === 'string' ? body.capture : '';
|
|
90
|
+
if (!CAPTURE_RE.test(capture)) {
|
|
91
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { capture },且只认字母数字与 ._-' } });
|
|
92
|
+
}
|
|
93
|
+
const raw = body && Array.isArray(body.lines) ? body.lines : null;
|
|
94
|
+
if (!raw) {
|
|
95
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { lines: string[] }' } });
|
|
96
|
+
}
|
|
97
|
+
if (raw.length > MAX_LINES_PER_BATCH) {
|
|
98
|
+
return sendJson(res, 413, { ok: false, error: { code: 'too-many-lines', message: `一次最多 ${MAX_LINES_PER_BATCH} 行` } });
|
|
99
|
+
}
|
|
100
|
+
const lines = raw.filter((line) => typeof line === 'string' && line.length > 0);
|
|
101
|
+
if (lines.length === 0) {
|
|
102
|
+
return sendJson(res, 200, { ok: true, file: `${capture}.jsonl`, bytes: 0, lines: 0, capped: false });
|
|
103
|
+
}
|
|
104
|
+
const text = `${lines.join('\n')}\n`;
|
|
105
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
106
|
+
if (bytes > MAX_BATCH_BYTES) {
|
|
107
|
+
return sendJson(res, 413, { ok: false, error: { code: 'too-large', message: `一次最多 ${MAX_BATCH_BYTES} 字节` } });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const file = capturePath(capture);
|
|
111
|
+
try {
|
|
112
|
+
await fsp.mkdir(traceDir(), { recursive: true });
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return sendJson(res, 500, { ok: false, error: { code: 'io', message: `建目录失败:${error && error.message ? error.message : ''}` } });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let existing = 0;
|
|
118
|
+
let created = false;
|
|
119
|
+
try {
|
|
120
|
+
existing = (await fsp.stat(file)).size;
|
|
121
|
+
} catch {
|
|
122
|
+
created = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (existing >= MAX_CAPTURE_BYTES) {
|
|
126
|
+
// 已经写满:回 capped 让 App 停手。文件留着,人要看的正是前面那一段。
|
|
127
|
+
return sendJson(res, 200, { ok: true, file: `${capture}.jsonl`, bytes: existing, lines: 0, capped: true });
|
|
128
|
+
}
|
|
129
|
+
// 快写满时**按整行留**:切在字节中间会让最后一行变成半截 JSON,读的人只会看到一行坏数据。
|
|
130
|
+
const room = MAX_CAPTURE_BYTES - existing;
|
|
131
|
+
let payload = text;
|
|
132
|
+
if (bytes > room) {
|
|
133
|
+
const kept = [];
|
|
134
|
+
let used = 0;
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
const size = Buffer.byteLength(line, 'utf8') + 1;
|
|
137
|
+
if (used + size > room) break;
|
|
138
|
+
kept.push(line);
|
|
139
|
+
used += size;
|
|
140
|
+
}
|
|
141
|
+
payload = kept.length > 0 ? `${kept.join('\n')}\n` : '';
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
await fsp.appendFile(file, payload);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return sendJson(res, 500, { ok: false, error: { code: 'io', message: `写盘失败:${error && error.message ? error.message : ''}` } });
|
|
147
|
+
}
|
|
148
|
+
// 清理放在写完之后:这一份刚建出来的文件按 mtime 是最新的,留下来的正是它。
|
|
149
|
+
if (created) await pruneCaptures();
|
|
150
|
+
let total = existing;
|
|
151
|
+
try {
|
|
152
|
+
total = (await fsp.stat(file)).size;
|
|
153
|
+
} catch { /* 写成功但 stat 失败:回一个估计值,不影响 App */ }
|
|
154
|
+
sendJson(res, 200, {
|
|
155
|
+
ok: true,
|
|
156
|
+
file: `${capture}.jsonl`,
|
|
157
|
+
bytes: total,
|
|
158
|
+
lines: payload === text ? lines.length : (payload ? payload.split('\n').length - 1 : 0),
|
|
159
|
+
// 「这一段被切了」或「已经顶到上限」都算满:App 收到就停手,别继续往一条死路上发。
|
|
160
|
+
capped: payload !== text || total >= MAX_CAPTURE_BYTES,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 同步读一份轨迹(维护者在电脑上直接看文件;这里只给测试与将来的面板用)。 */
|
|
165
|
+
function readCapture(capture) {
|
|
166
|
+
if (!CAPTURE_RE.test(capture)) return null;
|
|
167
|
+
try {
|
|
168
|
+
return fs.readFileSync(capturePath(capture), 'utf8');
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
handleDebugTrace,
|
|
176
|
+
readCapture,
|
|
177
|
+
traceDir,
|
|
178
|
+
MAX_CAPTURE_BYTES,
|
|
179
|
+
MAX_CAPTURES,
|
|
180
|
+
};
|
|
@@ -45,6 +45,9 @@ function normalizeProfile(result) {
|
|
|
45
45
|
status: 'ready',
|
|
46
46
|
name: textOrNull(profile.name),
|
|
47
47
|
contact: textOrNull(profile.contact),
|
|
48
|
+
// 头像是资料的一部分(和名字、联系方式同级),手机拿去当 <Image> 的 uri 用;
|
|
49
|
+
// 账号 ID、token、attempt 仍然不出这一层。
|
|
50
|
+
avatarUrl: textOrNull(profile.avatarUrl),
|
|
48
51
|
};
|
|
49
52
|
}
|
|
50
53
|
|
|
@@ -15,9 +15,14 @@
|
|
|
15
15
|
* 2000 不是随手取的上限:本机 1420 个真实回合量下来,最长一轮 576 条消息、超过 500 的
|
|
16
16
|
* 有 3 个、超过 200 的有 18 个。它只是**上界**,宿主在跨过 `minTurns` 且遇到 turn/start
|
|
17
17
|
* 时就会停,所以正常情况下读的仍是那几十条;只有碰上超长回合才会真往上读到 2000。
|
|
18
|
+
*
|
|
19
|
+
* `minTurns` 取 1,**不是 6**:进对话要的是「尾部这一屏」,不是「六轮上下文」。
|
|
20
|
+
* 取 6 的时候,一轮 40 步的会话会把窗口撑到一百多条,手机侧要铺十几屏才追得到底 ——
|
|
21
|
+
* 用户看到的是「进来之后内容一路往下滑」。窗口大小是这条链的总闸:窗口小,
|
|
22
|
+
* 「一屏就是终态」才成立;更早的内容由「加载更早」按需补。
|
|
18
23
|
*/
|
|
19
24
|
const ENTRY_THIN_MAX_MESSAGES = 2000;
|
|
20
|
-
const ENTRY_THIN_MIN_TURNS =
|
|
25
|
+
const ENTRY_THIN_MIN_TURNS = 1;
|
|
21
26
|
|
|
22
27
|
/** 把入口窗口的取数参数写进宿主请求体(就地修改并返回,调用方只关心副作用)。 */
|
|
23
28
|
function entryWindowPayload(payload, limit) {
|