dsh-mobile-plugin 0.4.4 → 0.4.6
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/client.js +3 -2
- package/lib/desktop-half-stats.cjs +1 -1
- package/lib/git/exec.js +1 -1
- package/lib/host-guard.cjs +1 -1
- package/lib/host-patches.cjs +2 -2
- package/lib/impl.js +6 -7
- package/lib/index.js +2 -2
- package/lib/mirror/pusher.cjs +2 -2
- package/lib/mobile/events.js +3 -3
- package/lib/mobile/host-bridge/event-synthesis.js +15 -1
- package/lib/mobile/host-bridge/shims.js +2 -2
- package/lib/mobile/inbox-clean.js +3 -2
- package/lib/mobile/relay-control.js +28 -0
- package/lib/mobile/session-delivery.js +68 -0
- package/lib/mobile/session-log.js +4 -0
- package/lib/mobile/session-snapshot.js +1 -1
- package/lib/mobile/stream.js +4 -0
- package/lib/mobile/wire-cut.js +24 -15
- package/lib/panel/index.html +6 -6
- package/lib/relay/apply-ledger.cjs +2 -3
- package/lib/relay/core.js +10 -11
- package/lib/relay/lan-network.cjs +3 -3
- package/lib/relay/lan-proxy.js +4 -3
- package/lib/relay/migrate.js +4 -4
- package/lib/relay/pairing-policy.js +8 -6
- package/lib/relay/port-allocator.cjs +2 -3
- package/lib/relay/preview.js +1 -1
- package/lib/relay/status.js +4 -4
- package/lib/relay/tunnel.js +21 -26
- package/lib/relay/verify.js +5 -3
- package/lib/routes/control-plane.js +1 -1
- package/lib/routes/mobile-api.js +20 -14
- package/lib/shared/device-headers.js +5 -1
- package/lib/shared/dshtoken.js +4 -4
- package/lib/shared/e2ee-request.js +3 -4
- package/lib/shared/e2ee.js +2 -1
- package/lib/shared/fresh-request.js +1 -2
- package/lib/shared/http-util.js +2 -1
- package/lib/shared/sse-write.js +1 -1
- package/lib/shell/manager.js +1 -1
- package/lib/shell/updater.js +2 -1
- package/lib/sidecar-facade-worker.cjs +1 -1
- package/lib/sidecar-facade.cjs +1 -1
- package/lib/sidecar-lifecycle.cjs +3 -3
- package/lib/update-registry.cjs +2 -2
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -50,7 +50,7 @@ window.__ModuleLoader__.load({
|
|
|
50
50
|
document.head.appendChild(tag);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/* ──
|
|
53
|
+
/* ── 一:桌面工作区展开态同步 ── */
|
|
54
54
|
/** 桌面端持久化键:`dsh.workspace.view.v5`(版本号可能上浮,取版本最大的那个)。 */
|
|
55
55
|
const STORAGE_KEY_RE = /^dsh\.workspace\.view\.v(\d+)$/;
|
|
56
56
|
/** 轮询间隔:展开/折叠是低频操作,2s 足够跟手又不费电。 */
|
|
@@ -501,7 +501,8 @@ window.__ModuleLoader__.load({
|
|
|
501
501
|
* 保持一个薄卡片:一个网页版入口,以及一个可信本机开关,控制网页管理是否短时向公网开放。
|
|
502
502
|
* 界面本体仍在宿主吐出的 HTML(源码 `React/`,产物 `lib/panel/index.html`)。
|
|
503
503
|
*
|
|
504
|
-
* 本机设置项半边来自渲染器的 `node_modules
|
|
504
|
+
* 本机设置项半边来自渲染器的 `node_modules`,不可热替换;插件本体由外壳自己装新版
|
|
505
|
+
* (`lib/update-install.cjs`),刷新页面才看得到这一半的变化。
|
|
505
506
|
* 开关只影响控制面网页路由,不改移动 API、配对凭据或隧道状态。
|
|
506
507
|
*/
|
|
507
508
|
function MobileSettingsSection({ t }) {
|
package/lib/git/exec.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 边界仍然成立;
|
|
10
10
|
* 2. **失败不当异常抛**。非零退出是常态(没有上游、坏分支名、钩子拒绝),调用方要拿退出码
|
|
11
11
|
* 和 stderr 判断,所以一律 resolve 成 `{ code, stdout, stderr }`,只有「git 根本没装」
|
|
12
|
-
*
|
|
12
|
+
* 「超时被杀」「输出超限」单独立成 `error` 字段;
|
|
13
13
|
* 3. **按仓库串行**。手机上一次点两下就会并发两次写,撞上 `.git/index.lock` 会互相踩死;
|
|
14
14
|
* 锁的粒度是仓库根,主仓库与它下面的独立子仓库各排各的队。
|
|
15
15
|
*
|
package/lib/host-guard.cjs
CHANGED
|
@@ -40,7 +40,7 @@ function describeError(err) {
|
|
|
40
40
|
* @param {(line: string) => void} [options.log] 注入日志,便于测试
|
|
41
41
|
* @param {(code: number) => void} [options.exit] 注入退出,便于测试
|
|
42
42
|
* @returns {{handleUncaughtException: Function, handleUnhandledRejection: Function,
|
|
43
|
-
*
|
|
43
|
+
* recordFault: Function, faults: Function, stats: Function, clear: Function,
|
|
44
44
|
* isFatal: Function}}
|
|
45
45
|
*/
|
|
46
46
|
function createGuard(options = {}) {
|
package/lib/host-patches.cjs
CHANGED
package/lib/impl.js
CHANGED
|
@@ -41,6 +41,9 @@ const { installQuestionBridge } = require('./mobile/questions.js')
|
|
|
41
41
|
// 「手机上最近来过没有」:守卫记、控制面状态读。单独一个模块,装配层只转交。
|
|
42
42
|
const activity = require('./mobile/activity.cjs')
|
|
43
43
|
const { installSessionCleanup } = require('./session-cleanup.cjs')
|
|
44
|
+
// 统计的定期采集:账本在 mobile/stats-ledger.cjs,采集规则在 stats-collect.cjs,定时器在
|
|
45
|
+
// stats-scheduler.cjs —— 装配层只挂一下(按天归属靠的就是这个节奏)。
|
|
46
|
+
const statsScheduler = require('./mobile/stats-scheduler.cjs')
|
|
44
47
|
// 扩展卡的宿主侧:`extension_present` 工具与卡状态账本(见 mobile/extensions.js)。
|
|
45
48
|
const { installExtensions } = require('./mobile/extensions.js')
|
|
46
49
|
const { activeGuard } = require('./host-guard.cjs')
|
|
@@ -88,8 +91,8 @@ export const RemoteControlSettingsSchema = z.object({
|
|
|
88
91
|
lanPort: z.number().step(1).min(1024).max(65535).default(18787),
|
|
89
92
|
name: z.string().default(''),
|
|
90
93
|
// Pairing approval policy; see lib/relay/pairing-policy.js for the two modes and
|
|
91
|
-
// why 'code' is not the old popup. Kept in the
|
|
92
|
-
//
|
|
94
|
+
// why 'code' is not the old popup. Kept in the config schema so /api/relay/config
|
|
95
|
+
// can change it, and so an unknown value is rejected on write.
|
|
93
96
|
pairingPolicy: z.union(pairingApproval.POLICIES.map(value => z.const(value))).default(pairingApproval.DEFAULT_POLICY),
|
|
94
97
|
// 中转注册密钥(对偶于中转的 TUNNEL_REGISTER_SECRET)。桌面版拿不到 shell
|
|
95
98
|
// 环境变量,所以它和其他配置一样存在 relay.json(0600),由 /api/relay/config
|
|
@@ -345,6 +348,7 @@ function apply(ctx) {
|
|
|
345
348
|
}
|
|
346
349
|
return undefined
|
|
347
350
|
}, `${name}: extension tool`)
|
|
351
|
+
ctx.effect(() => statsScheduler.start(ctx), `${name}: stats collect`)
|
|
348
352
|
|
|
349
353
|
const status = createStatusView({
|
|
350
354
|
state,
|
|
@@ -461,11 +465,6 @@ function apply(ctx) {
|
|
|
461
465
|
hostMetadata,
|
|
462
466
|
// 取值器而非快照对象:snapshot 会被 applySidecarConfig 整体替换,闭包捕获对象会让
|
|
463
467
|
// 连接信息永远停在进程启动那一刻。
|
|
464
|
-
//
|
|
465
|
-
// 曾经这里写的是 `() => sidecarSnapshot` —— 那个标识符在本文件作用域里根本不存在
|
|
466
|
-
// (它只在 lib/sidecar-lifecycle.cjs 内部),于是 GET /api/mobile/v1/connection-info
|
|
467
|
-
// 一进来就 ReferenceError。同一个 apply() 里给 createStatusView 传的是
|
|
468
|
-
// `sidecarLifecycle.getSnapshot`,两处不一致才让这个笔误活了下来。
|
|
469
468
|
getSidecarSnapshot: sidecarLifecycle.getSnapshot,
|
|
470
469
|
// 移动端那边的「局域网候选」:私网优先的清单 + 用户手填的直连地址(网卡扫不出的域名)。
|
|
471
470
|
safeLanCandidates: (port) => phoneCandidates(advertisedCandidates(port), { manual: state.manualLanUrl }),
|
package/lib/index.js
CHANGED
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
*
|
|
18
18
|
* ## 更新来源
|
|
19
19
|
*
|
|
20
|
-
* 正式 npm 更新由 `shell/updater.js`
|
|
20
|
+
* 正式 npm 更新由 `shell/updater.js` 处理(自动更新默认开启,面板可关);紧急完整包由
|
|
21
21
|
* `shell/device-ota.js` 经本机控制面激活,正式版成功接管前保留 OTA 回退包。
|
|
22
|
-
* `updateState`
|
|
22
|
+
* `updateState` 记录查装结果;对外的 `marketResult` 就是它的 `result`。
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import path from 'node:path'
|
package/lib/mirror/pusher.cjs
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* 镜像推送调度器:把宿主的会话状态推到中转,手机端才看得到列表与消息。
|
|
5
5
|
*
|
|
6
6
|
* 三条不能丢的约束:隧道没连上就不构建快照(否则 503 刷屏,真故障被淹没);空清单/空快照
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* 必须过守卫(没有 `emptyConfirmed` 的「没取到」不许推,否则手机侧整行消失且零报错);
|
|
8
|
+
* 镜像先推上去,再广播清单 revision(`announceSessionsRevision`),顺序反了手机会来取上一份镜像。
|
|
9
9
|
*
|
|
10
10
|
* 必须是 .cjs:包根声明 type=module,而 lib/mirror/ 没有自己的 `{"type":"commonjs"}`。
|
|
11
11
|
*/
|
package/lib/mobile/events.js
CHANGED
|
@@ -23,7 +23,7 @@ const DEDUP_WINDOW = 2048;
|
|
|
23
23
|
const HEARTBEAT_MS = 15_000;
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* 诊断日志。失败路径一条都不许静默:手机把兜底轮询降成
|
|
26
|
+
* 诊断日志。失败路径一条都不许静默:手机把兜底轮询降成 3 分钟,宿主日志里却查不到这条流是
|
|
27
27
|
* 正常收尾、被客户端断开,还是循环抛错。会话级流早有 logStream,这里是它的设备级对偶。
|
|
28
28
|
*/
|
|
29
29
|
function logDeviceStream(message, error) {
|
|
@@ -203,7 +203,7 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
203
203
|
* 连上就先补一帧「当前状态」。
|
|
204
204
|
*
|
|
205
205
|
* 这条流没有 Last-Event-ID 续传,断流窗口里发生的清单变更与正文水位必须在重连后补上,
|
|
206
|
-
* 否则手机 state 是空的,而兜底轮询已经因为「流活着」被降到
|
|
206
|
+
* 否则手机 state 是空的,而兜底轮询已经因为「流活着」被降到 3 分钟。
|
|
207
207
|
*
|
|
208
208
|
* 只读两条信号总线已经广播过的最近值,既不 bump 也不前移基准,所以不会制造新变更;
|
|
209
209
|
* App 对同 revision / 同水位幂等,重连补发不会引发重复刷新。
|
|
@@ -231,7 +231,7 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
231
231
|
const keepAlive = setInterval(() => {
|
|
232
232
|
try { res.write(': keepalive\n\n'); } catch { ac.abort(); return; }
|
|
233
233
|
// 心跳必须是**数据帧**:注释帧 App 看不见,而 App 只有「最近真的收到过帧」才敢把
|
|
234
|
-
// 兜底轮询降到
|
|
234
|
+
// 兜底轮询降到 3 分钟(见 app/src/services/viewmodels/signal-liveness.ts)。
|
|
235
235
|
if (!writeFrame({ type: 'device/heartbeat', at: Date.now() })) ac.abort();
|
|
236
236
|
}, HEARTBEAT_MS);
|
|
237
237
|
// 定时器不该拖住进程退出(同 lib/sidecar-facade-worker.cjs 的 shutdown)。测试里的
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 旧 mux 一次给出「这个设备正在发生的一切」,0.2.0 没有对应端点(事件已拆成
|
|
7
7
|
* session/follow、session/control、workspace/follow),这里按 payload 里有没有 sessionId
|
|
8
8
|
* 合成同形帧:带 sessionId → 该会话的 follow + job 整帧 + 投影整帧;不带 → 设备级的
|
|
9
|
-
* 工作区基线 + 全局投影整帧。调用方(stream.js / events.js /
|
|
9
|
+
* 工作区基线 + 全局投影整帧。调用方(stream.js / events.js / lib/mirror/pusher.cjs)本来就
|
|
10
10
|
* 按 frame.payload 里的 type 分发,形状对上了它们一行都不用改。
|
|
11
11
|
*/
|
|
12
12
|
|
|
@@ -295,10 +295,23 @@ function projectionState() {
|
|
|
295
295
|
* `next-turn` → `placement: 'queued'`(排队条只认这个值);`next-step` → `'steering'`,
|
|
296
296
|
* 它只是「宿主已经收下了」的回执,不会多画一行。`message.id` 用宿主行自己的 id,App 靠它
|
|
297
297
|
* 与正文去重。
|
|
298
|
+
*
|
|
299
|
+
* **只摊平用户本人说的话。** 宿主把系统注入(后台任务结束、时间/环境上下文、子代理消息、
|
|
300
|
+
* 模型切换提示这些)也当 user 行塞进同一份 inbox,来源标在 `source.kind` 上(不是 `user`)。
|
|
301
|
+
* 那一列在正文里由历史投影按来源丢掉,但队列帧的条目形状**没有 source 这一格**:原样摊平
|
|
302
|
+
* 出去,手机上这些注入就会以「我发出去的气泡」出现(`placement:'steering'` 在 App 侧画的
|
|
303
|
+
* 正是用户气泡)。所以这里按来源筛掉,注入一条都不进帧 —— 它们不需要排队条,也不需要
|
|
304
|
+
* 回执翻转。缺 source 的老宿主按普通用户消息处理,与历史投影同一口径。
|
|
298
305
|
*/
|
|
299
306
|
function queueItems(inbox, ctx) {
|
|
300
307
|
const items = [];
|
|
301
308
|
let dropped = 0;
|
|
309
|
+
/** 这一行是不是用户本人发的(缺 source 视为用户,对齐 history-derive 的口径)。 */
|
|
310
|
+
const fromUser = (message) => {
|
|
311
|
+
const source = message && typeof message === 'object' ? message.source : null;
|
|
312
|
+
if (!source || typeof source !== 'object') return true;
|
|
313
|
+
return source.kind === undefined || source.kind === 'user';
|
|
314
|
+
};
|
|
302
315
|
/**
|
|
303
316
|
* 摊平一组待发行。`placement` 只标语义:`queued` 进排队条,`steering` 只是回执。
|
|
304
317
|
* 没有 id 的行丢弃并计数(理由见下面的注释)。
|
|
@@ -307,6 +320,7 @@ function queueItems(inbox, ctx) {
|
|
|
307
320
|
if (!Array.isArray(rows)) return;
|
|
308
321
|
for (const message of rows) {
|
|
309
322
|
if (!message || typeof message !== 'object') continue;
|
|
323
|
+
if (!fromUser(message)) continue;
|
|
310
324
|
const id = typeof message.id === 'string' && message.id ? message.id : '';
|
|
311
325
|
if (!id) { dropped += 1; continue; } // 没有 id 的行没法被 session.updateQueue 定位,别发出去
|
|
312
326
|
items.push({ id, placement, message: { id, content: message.content } });
|
|
@@ -134,7 +134,7 @@ async function workspaceListViaFollow(ctx, signal) {
|
|
|
134
134
|
}
|
|
135
135
|
// 流「正常结束却没吐 baseline」不是「你没有工作区」,而是没取到。原先这里返回
|
|
136
136
|
// ok({ items: [] }),与真成功在下游完全无法区分:宿主正在初始化 / 正忙 / 网关给了
|
|
137
|
-
// 空流时都会走到这里,于是 { ok: true, items: [] } 一路畅通地通过 lib/
|
|
137
|
+
// 空流时都会走到这里,于是 { ok: true, items: [] } 一路畅通地通过 lib/mirror/pusher.cjs 的
|
|
138
138
|
// 镜像守卫、被当作权威清单推给中转,手机侧表现为工作区整行消失、会话全塌进
|
|
139
139
|
// 「未分组」,且中转只会附一个 App 根本不看的 stale 标记,不会有任何报错。
|
|
140
140
|
// 拿不到就是拿不到,如实走错误通道,让调用方去重试或保留上一次结果。
|
|
@@ -159,7 +159,7 @@ function projectionValues(item) {
|
|
|
159
159
|
/**
|
|
160
160
|
* 子代理目录的投影基线预算,比通用合成流(SYNTHETIC_DEADLINE_MS)更紧。
|
|
161
161
|
*
|
|
162
|
-
* 手机端一次请求的总预算只有 8 秒(app/src/api.ts 的 REQUEST_TIMEOUT_MS),走中转还要
|
|
162
|
+
* 手机端一次请求的总预算只有 8 秒(app/src/api/core.ts 的 REQUEST_TIMEOUT_MS),走中转还要
|
|
163
163
|
* 多一个 RTT。基线开不出来并不致命 —— session.list 摘要内联的是同一份 values,退回它
|
|
164
164
|
* 就行,所以宁可慢的时候少等几秒,也别把整次 subagents.list 拖成客户端超时。
|
|
165
165
|
*/
|
|
@@ -36,8 +36,9 @@ function autoClearFile() {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* 读自动清理开关。读不到 / 读坏了都当「关」——
|
|
40
|
-
*
|
|
39
|
+
* 读自动清理开关。读不到 / 读坏了都当「关」—— 无人值守删盘这种事,缺省必须在用户这一侧。
|
|
40
|
+
* 自动更新那个开关刻意相反(文件不在就当作开,见 `lib/shell/updater.js` 的
|
|
41
|
+
* `autoUpdateEnabled`):装新版可回退,删文件不可回退,两个缺省因此不该相同。
|
|
41
42
|
*/
|
|
42
43
|
function autoClearEnabled() {
|
|
43
44
|
try {
|
|
@@ -602,10 +602,16 @@ function createRelayControlHandlers(deps) {
|
|
|
602
602
|
* 一条(时间 + 一句话 + 当时要切过去的地址),不带堆栈 —— 宿主内部路径对手机没用。
|
|
603
603
|
*
|
|
604
604
|
* **不搬堆栈**:台账的 message 只取第一行,截断 300 字。
|
|
605
|
+
*
|
|
606
|
+
* 2026-10-27 起多四格:`host` / `relay` / `sidecar` / `mirror`。手机那个「延迟浮窗」里的
|
|
607
|
+
* 「诊断」块就是把这几格抄成几行人话 —— 用户问「这台电脑到底怎么了」时,光有故障台账
|
|
608
|
+
* 答不了「插件是哪一版、后台服务重启过几回、镜像还推不推得动」。
|
|
609
|
+
* **仍然只读、仍然只进不出**:注册密钥、配对码、`lanSecret`、访问 token 一格都不出现。
|
|
605
610
|
*/
|
|
606
611
|
async function handleRelayDiagnostics(_ctx, _req, res) {
|
|
607
612
|
if (!wired) return unavailable(res);
|
|
608
613
|
const snapshot = typeof status === 'function' ? status() : {};
|
|
614
|
+
const text = (value) => (typeof value === 'string' ? value : '');
|
|
609
615
|
sendJson(res, 200, {
|
|
610
616
|
ok: true,
|
|
611
617
|
faults: faultsView(),
|
|
@@ -620,6 +626,28 @@ function createRelayControlHandlers(deps) {
|
|
|
620
626
|
},
|
|
621
627
|
/** 隧道为什么被拒那几条判断(宿主那边按中转错误文案推出来的)。 */
|
|
622
628
|
tunnel: snapshot?.tunnel ?? null,
|
|
629
|
+
/** 这台电脑上跑着的插件与宿主:版本、平台、网页端口,报障时对方第一句就会问这些。 */
|
|
630
|
+
host: {
|
|
631
|
+
plugin: text(snapshot?.appVersion),
|
|
632
|
+
platform: text(snapshot?.platform),
|
|
633
|
+
arch: text(snapshot?.arch),
|
|
634
|
+
osVersion: text(snapshot?.osVersion),
|
|
635
|
+
webPort: Number.isInteger(snapshot?.localPort) && snapshot.localPort > 0 ? snapshot.localPort : null,
|
|
636
|
+
},
|
|
637
|
+
relay: { enabled: snapshot?.enabled === true },
|
|
638
|
+
/** 后台服务(sidecar):换配置要重启它,重启失败正是「保存成功却连不上」的那一档。 */
|
|
639
|
+
sidecar: {
|
|
640
|
+
state: text(snapshot?.sidecar?.state),
|
|
641
|
+
restarts: Number.isInteger(snapshot?.sidecar?.restarts) ? snapshot.sidecar.restarts : 0,
|
|
642
|
+
restartedAt: Number.isInteger(snapshot?.sidecar?.restartedAt) ? snapshot.sidecar.restartedAt : 0,
|
|
643
|
+
restartError: text(snapshot?.sidecar?.restartError) || null,
|
|
644
|
+
},
|
|
645
|
+
/** 镜像推送:手机上看到的列表为什么停住不动,答案在这三格里。 */
|
|
646
|
+
mirror: {
|
|
647
|
+
pushedAt: Number.isInteger(snapshot?.mirror?.pushedAt) ? snapshot.mirror.pushedAt : 0,
|
|
648
|
+
ok: snapshot?.mirror?.ok === undefined ? null : snapshot.mirror.ok === true,
|
|
649
|
+
error: text(snapshot?.mirror?.error) || null,
|
|
650
|
+
},
|
|
623
651
|
});
|
|
624
652
|
}
|
|
625
653
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「这一发到底进没进」:按 `clientMessageId` 回查会话正文。
|
|
5
|
+
*
|
|
6
|
+
* 为什么要有这一条:手机端有一条**离线发件箱**(见 App 侧 `services/outbox.ts`)——
|
|
7
|
+
* 点发送时没网或者电脑够不着,那条消息先留在手机上、标成「等待网络」,等网回来自动重发。
|
|
8
|
+
* 重发沿用同一个 `clientMessageId`,插件侧本来就会按它去重(`session-cache.js` 的
|
|
9
|
+
* `promptOnceFor`)。但那份去重记录**只在进程内存里**:插件重启过、或者 App 在「宿主
|
|
10
|
+
* 已经收下、还没等到回包」那一刻被杀掉,手机这边就分不清「重发会补上」还是「重发会重复」。
|
|
11
|
+
*
|
|
12
|
+
* 所以这里从**会话日志**里查:日志是持久的,重启也在。查到就是进过了,App 直接把发件箱
|
|
13
|
+
* 那一条划掉;查不到才真的重发。这比让 App 发完再拉一整段历史对账便宜,也比「相信内存里
|
|
14
|
+
* 那份去重表」稳。
|
|
15
|
+
*
|
|
16
|
+
* 只读、只听 token、只看最近这一窗:发件箱里的条目最多躺几天,`maxMessages` 取它够用的
|
|
17
|
+
* 上限;更早的不查 —— 查不到就按「没进」走重发,而重发那一步仍有插件的去重兜着。
|
|
18
|
+
*
|
|
19
|
+
* 不解析附件:这里只认「有没有这条 user 消息」,标题、图片字节都不需要
|
|
20
|
+
* (`deriveMessages` 之外的附件解析是这条端点唯一可能变贵的地方,刻意不走)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { sendJson } = require('../shared/http-util');
|
|
24
|
+
const { unwrap } = require('./envelope.js');
|
|
25
|
+
const { callSessions } = require('./rpc.js');
|
|
26
|
+
const { deriveMessages } = require('./history-derive.js');
|
|
27
|
+
|
|
28
|
+
/** 一次回查读多少条事件:发件箱的条目只可能出现在最近这几天。 */
|
|
29
|
+
const DELIVERY_HISTORY_WINDOW = 200;
|
|
30
|
+
/** 两个参数的长度上限(与 `clientMessageId` 的生成形状同一个量级)。 */
|
|
31
|
+
const PARAM_MAX = 200;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* GET /api/mobile/v1/session/delivery?sessionId=…&clientMessageId=…(token)
|
|
35
|
+
* → { ok, delivered, seq? }
|
|
36
|
+
*
|
|
37
|
+
* 宿主没有这条会话(被删 / 换了机器)时回 404:那是「查不到」,不是「没送到」——
|
|
38
|
+
* App 据此把发件箱里那条标成失败,而不是永远重试。
|
|
39
|
+
*/
|
|
40
|
+
async function handleSessionDelivery(ctx, req, res) {
|
|
41
|
+
const url = new URL(req.url, 'http://x');
|
|
42
|
+
const sessionId = (url.searchParams.get('sessionId') || '').trim();
|
|
43
|
+
const clientMessageId = (url.searchParams.get('clientMessageId') || '').trim();
|
|
44
|
+
if (!sessionId || !clientMessageId || sessionId.length > PARAM_MAX || clientMessageId.length > PARAM_MAX) {
|
|
45
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, clientMessageId }' } });
|
|
46
|
+
}
|
|
47
|
+
const { value, error } = unwrap(await callSessions(ctx, 'history', { sessionId, maxMessages: DELIVERY_HISTORY_WINDOW }));
|
|
48
|
+
if (error) {
|
|
49
|
+
const missing = error.code === 'session-not-found';
|
|
50
|
+
return sendJson(res, missing ? 404 : 502, {
|
|
51
|
+
ok: false,
|
|
52
|
+
error: {
|
|
53
|
+
code: typeof error.code === 'string' ? error.code : 'host-call-failed',
|
|
54
|
+
message: typeof error.message === 'string' && error.message ? error.message : '读取会话历史失败',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const events = (value && Array.isArray(value.events)) ? value.events : [];
|
|
59
|
+
const messages = deriveMessages(events);
|
|
60
|
+
const hit = messages.find((message) => message.clientMessageId === clientMessageId);
|
|
61
|
+
sendJson(res, 200, {
|
|
62
|
+
ok: true,
|
|
63
|
+
delivered: Boolean(hit),
|
|
64
|
+
...(hit && typeof hit.seq === 'number' ? { seq: hit.seq } : {}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { handleSessionDelivery, DELIVERY_HISTORY_WINDOW };
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* 的 127.0.0.1 上,手机既直连不到、也没有 `<a download>`,所以这里由插件代取并
|
|
9
9
|
* 原样转发宿主 `apiProxy.downloads.sessionLog` 的 application/zip 流。
|
|
10
10
|
*
|
|
11
|
+
* 0.2.x 的兼容桥没有这条端点(host-bridge.js 的 `downloads.sessionLog` 直接回
|
|
12
|
+
* `host-unsupported`),所以这一代宿主上本路由只能如实回 502;等宿主把导出能力重新
|
|
13
|
+
* 暴露出来,上面这段转发才真的走得到。
|
|
14
|
+
*
|
|
11
15
|
* 与桌面客户端保持同一口径:`includeDescendants` 缺省即 true —— 导出的是「当前会话
|
|
12
16
|
* + 子会话 + 附件」的整棵会话树,而不是单个会话。
|
|
13
17
|
*/
|
|
@@ -80,7 +80,7 @@ async function buildStatsSnapshot(ctx, sessionId) {
|
|
|
80
80
|
* 0.2.0 把整帧投影从 history/page 的返回值里拆走了,「拉一条历史顺带取投影」这条路断了;
|
|
81
81
|
* 改走 `apiProxy.sessions.projections({ request: { sessionId } })`,返回值是
|
|
82
82
|
* `{ asOfSeq, values }`。顺带把 plan 归一化成 planMode(排队条与计划条读它)。
|
|
83
|
-
* 历史轮询是每
|
|
83
|
+
* 历史轮询是每 15 秒一次的热路径,多背一次投影 RPC 划不来,所以统计单独走投影端点。
|
|
84
84
|
*/
|
|
85
85
|
async function statsProjections(ctx, sessionId) {
|
|
86
86
|
// 显式给 signal:宿主部分方法内部直接读 signal.aborted,不做 undefined 兜底。
|
package/lib/mobile/stream.js
CHANGED
|
@@ -538,6 +538,8 @@ async function handleStream(ctx, req, res) {
|
|
|
538
538
|
// 先吸工具名再减:结果自己不带工具名,正文留不留取决于它(见 wire-cut.js)。
|
|
539
539
|
toolNames.absorb(event);
|
|
540
540
|
event = cutEvent(event, toolNames);
|
|
541
|
+
// 整条不上线路的事件(`tool/call`、参数被砍空之后的工具增量帧):见 wire-cut.js。
|
|
542
|
+
if (!event) continue;
|
|
541
543
|
const chunk = event.data && event.data.chunk;
|
|
542
544
|
if (seq !== null || !chunk || typeof chunk.type !== 'string') {
|
|
543
545
|
// 持久事件(或不是增量的会话事件):先把攒着的草稿写掉,再按原样写这一帧。
|
|
@@ -651,6 +653,8 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, a
|
|
|
651
653
|
const e = entry?.event || entry;
|
|
652
654
|
const resolved = await resolveEventAttachments(ctx, sessionId, e, { referencesOnly: true });
|
|
653
655
|
const event = cutEvent(resolved, toolNames);
|
|
656
|
+
// 回放与直播同一副眼镜:整条不上线路的事件这里也跳过(见 wire-cut.js)。
|
|
657
|
+
if (!event) continue;
|
|
654
658
|
// Keep replay data identical to live SSE data: the client reducer consumes
|
|
655
659
|
// the event's own type (turn/start, assistant/message, etc.).
|
|
656
660
|
const payload = { type: 'session/event', sessionId, event };
|
package/lib/mobile/wire-cut.js
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* 料 —— 计划卡读 `exit_plan_mode` 的 `plan`、扩展卡读 `extension_present` 的 `extension`、
|
|
11
11
|
* 定时任务卡解析 `schedule_create` 结果的 JSON。
|
|
12
12
|
*
|
|
13
|
+
* 除了「内容」还要收「帧数」:手机只回答「在干什么」,所以参数被砍空之后的工具增量帧
|
|
14
|
+
* (一次调用几十帧、每窗补 2KB)与没人消费的 `tool/call` 事件整条丢掉 —— 一个工具调用
|
|
15
|
+
* 在线上只剩**一帧身份**(`id` + `name`)加一帧结果。
|
|
16
|
+
*
|
|
13
17
|
* 直播帧循环(stream.js)与历史派生(history-derive.js)调的是同一份实现。同一条规则写两份,
|
|
14
18
|
* 迟早漂成「直播里还带着推理、拉一次历史又没有了」。
|
|
15
19
|
*/
|
|
@@ -111,29 +115,34 @@ function cutAssistantEvent(event) {
|
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
/**
|
|
114
|
-
* 一条 `tool/call`
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
+
* 一条 `tool/call` 事件:**整条不下发**。
|
|
119
|
+
*
|
|
120
|
+
* 手机端对这条事件没有任何消费者:调用名由工具块的身份帧带到,状态与图片由 `tool/result`
|
|
121
|
+
* 回填,三张轮尾卡的料取自 `assistant/message` 里的 tool-call 块(见 App 的 `turn-tail-cards`)。
|
|
122
|
+
* 而它带 seq,属于「其余帧补满整份」那一类 —— 一帧就是 2KB 注释,一个长任务几十次调用、
|
|
123
|
+
* 两次报路(宿主→中转→手机)都是白走的。
|
|
118
124
|
*/
|
|
119
|
-
function cutToolCallEvent(
|
|
120
|
-
|
|
121
|
-
if (!data || typeof data !== 'object') return event;
|
|
122
|
-
if (typeof data.name === 'string' && ARGUMENT_KEEP_TOOLS.has(data.name)) return event;
|
|
123
|
-
if (data.arguments === undefined) return event;
|
|
124
|
-
const { arguments: _droppedArguments, ...rest } = data;
|
|
125
|
-
return { ...event, data: rest };
|
|
125
|
+
function cutToolCallEvent() {
|
|
126
|
+
return null;
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
/**
|
|
129
|
-
* 一块流式增量:`tool-call-delta`
|
|
130
|
-
*
|
|
131
|
-
*
|
|
130
|
+
* 一块流式增量:`tool-call-delta` 只留**身份帧**,其余整帧丢掉。
|
|
131
|
+
*
|
|
132
|
+
* 参数正文不下发(`write` / `edit` 的参数就是整份文件内容,按 token 一帧地推),于是同一块
|
|
133
|
+
* 后面那些帧只剩 `{ type, index }` —— 而 `index` 对手机没有用处,它要的是「有个工具开始了」
|
|
134
|
+
* 和那个工具叫什么。这些空壳在线上按 200ms 一窗合并、每窗补到 2KB(见 `stream.js`),
|
|
135
|
+
* 一个改文件的调用光参数流式那十几秒就能白走几十上百 KB。
|
|
136
|
+
*
|
|
137
|
+
* 判据只看这一帧自己带不带身份(`id` / `name`),不记状态:宿主把名字放在第一帧,也有的
|
|
138
|
+
* 宿主分开给,两种都留得住。
|
|
132
139
|
*/
|
|
133
140
|
function cutAssistantChunkEvent(event) {
|
|
134
141
|
const data = event && event.data;
|
|
135
142
|
const chunk = data && data.chunk;
|
|
136
143
|
if (!chunk || typeof chunk !== 'object' || chunk.type !== 'tool-call-delta') return event;
|
|
144
|
+
const identifies = chunk.id !== undefined || typeof chunk.name === 'string';
|
|
145
|
+
if (!identifies) return null;
|
|
137
146
|
if (chunk.argumentsDelta === undefined) return event;
|
|
138
147
|
const { argumentsDelta: _droppedArguments, ...rest } = chunk;
|
|
139
148
|
return { ...event, data: { ...data, chunk: rest } };
|
|
@@ -141,7 +150,7 @@ function cutAssistantChunkEvent(event) {
|
|
|
141
150
|
|
|
142
151
|
/**
|
|
143
152
|
* 事件类型 → 这条事件该怎么减:能减的类型走对应的一条规则,其余原样透传。
|
|
144
|
-
*
|
|
153
|
+
* **返回 null = 这条事件整条不上线路**,调用方必须跳过它(见 `stream.js` 的两处)。
|
|
145
154
|
*/
|
|
146
155
|
function cutEvent(event, names) {
|
|
147
156
|
if (!event || typeof event !== 'object') return event;
|