dsh-mobile-plugin 0.1.76 → 0.1.78
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 +10 -1
- package/lib/impl.js +12 -1
- package/lib/mobile/history-derive.js +77 -42
- package/lib/mobile/host-bridge/event-synthesis.js +55 -12
- package/lib/mobile/pairing.js +211 -0
- package/lib/mobile/sessions.js +1 -8
- package/lib/mobile/uploads.js +48 -6
- package/lib/panel/index.html +8 -6
- package/lib/relay/lan-network.cjs +44 -1
- package/lib/relay/lan-proxy.js +43 -11
- package/lib/relay/protocol.js +5 -4
- package/lib/routes/control-plane.js +38 -0
- package/lib/routes/mobile-api.js +28 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,7 +74,16 @@ pnpm add dsh-mobile-plugin@<版本>
|
|
|
74
74
|
|
|
75
75
|
新版插件的管理面板提供「本机调试 OTA」卡片,可手动检查并安装中转发布的紧急完整包。入口仅限宿主本机访问,不会由手机或公网触发;安装后通过实现代切换生效,切换失败会恢复上一代。npm 仍是正式发布源;后续 npm 正式版本达到 OTA 版本时会接管该版本。首次发布带此功能的 npm 包需要重启一次 DSH,让稳定外壳加载 OTA 能力。
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
发布一个紧急完整包:在插件仓库根目录 `npm pack`,把打出来的 tgz 原样 POST 给中转(包本身就是 tar.gz,不用再解包重打):
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
curl -X POST -H "authorization: Bearer <中转的 OTA 发布 token>" --data-binary @dsh-mobile-plugin-0.1.61.tgz \
|
|
81
|
+
"https://你的中转/api/desktop-plugins/ota/publish?platform=darwin&channel=stable&version=0.1.61"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`platform` 填 `process.platform`(`darwin` / `linux` / `win32`),`channel` 一般 `stable`。发布接口没配 token 时是关闭的,下载不受影响;版本按号比,最后发的那个才是「最新」。
|
|
85
|
+
|
|
86
|
+
打包时**别用 macOS 默认的 tar 参数**——带 PAX 扩展头,宿主那边的解析器不认,会报「OTA 包结构不完整」。要 `COPYFILE_DISABLE=1 tar --no-xattrs --no-mac-metadata -czf …`,且条目以 `package/` 打头。
|
|
78
87
|
|
|
79
88
|
## 开发
|
|
80
89
|
|
package/lib/impl.js
CHANGED
|
@@ -62,9 +62,16 @@ export const name = 'dsh-mobile-plugin'
|
|
|
62
62
|
// 有,get trap 第一站就命中)。
|
|
63
63
|
// 漏了这两个分支里的任何一个,读 `ctx.apiProxy` 都会抛
|
|
64
64
|
// `cannot get property "apiProxy" without inject`:服务器宿主上 mirror 推送曾整条挂掉。
|
|
65
|
+
//
|
|
66
|
+
// `typertGateway` 必须出现在 inject 里(0.2.0 那一支):宿主 RPC 全部经它派发,
|
|
67
|
+
// 而它是**后于本插件**装载的服务。只 `ctx.get('typertGateway')` 而不注入,取到的是
|
|
68
|
+
// 装载那一刻的空值,于是每一条事件流都抛「当前宿主版本没有提供该能力」——服务器宿主
|
|
69
|
+
// 上实时推送整条挂掉,而收发消息、会话列表这些走普通请求的功能一切正常,看着像只有
|
|
70
|
+
// 「刷新不实时」的小毛病。注入之后 cordis 会等服务就绪再激活本插件。
|
|
71
|
+
// 旧运行时那一支不加:那版没有这个服务,注入只会让这一行永远 pending。
|
|
65
72
|
export const inject = runtimeProvidesApiProxy()
|
|
66
73
|
? ['webServer', 'apiProxy', 'sessions']
|
|
67
|
-
: ['webServer', 'sessions']
|
|
74
|
+
: ['webServer', 'sessions', 'typertGateway']
|
|
68
75
|
|
|
69
76
|
export const REMOTE_CONTROL_SETTINGS_NAMESPACE = 'mobile-remote-control'
|
|
70
77
|
export const RemoteControlSettingsSchema = z.object({
|
|
@@ -393,6 +400,10 @@ function apply(ctx) {
|
|
|
393
400
|
// 移动端路由表那边叫它「局域网候选」,取的是同一份私网优先的清单。
|
|
394
401
|
safeLanCandidates: advertisedCandidates,
|
|
395
402
|
lanPortInUse: advertisedLanPort, // LAN 候选用实绑端口
|
|
403
|
+
// 配对那条路由自己要签凭据:core 是签 token 的实现,pairingApproval 决定这台机器
|
|
404
|
+
// 收不收新设备('deny' 时一律拒)。两者本文件都已有,透传给移动端路由。
|
|
405
|
+
core,
|
|
406
|
+
pairingApproval,
|
|
396
407
|
})
|
|
397
408
|
ctx.effect(() => {
|
|
398
409
|
const stopWatching = settings.watch(next => {
|
|
@@ -198,26 +198,6 @@ function retryFailure(failure) {
|
|
|
198
198
|
return out.code !== undefined || out.message !== undefined ? out : null;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
/** 上下文行的 label 提取(与桌面 contextProvenance 同口径;取不到为 null)。 */
|
|
202
|
-
function contextLabelOf(source) {
|
|
203
|
-
const kind = source.kind;
|
|
204
|
-
if (kind === 'session-reference') {
|
|
205
|
-
for (const ref of Array.isArray(source.references) ? source.references : []) {
|
|
206
|
-
if (ref && typeof ref.label === 'string' && ref.label) return ref.label;
|
|
207
|
-
}
|
|
208
|
-
return null;
|
|
209
|
-
}
|
|
210
|
-
if (kind === 'agent-instructions') {
|
|
211
|
-
for (const change of Array.isArray(source.changes) ? source.changes : []) {
|
|
212
|
-
if (change && typeof change.path === 'string' && change.path) return change.path;
|
|
213
|
-
}
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
216
|
-
if (kind === 'plugin') return typeof source.plugin === 'string' && source.plugin ? source.plugin : null;
|
|
217
|
-
if (kind === 'skill-invocation') return typeof source.name === 'string' && source.name ? source.name : null;
|
|
218
|
-
return typeof kind === 'string' ? kind : null;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
201
|
/** 列表/概览的 lastMessage 预览只取有 role 的消息项(结构化行不参与预览)。 */
|
|
222
202
|
function lastMessageOf(messages) {
|
|
223
203
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -229,12 +209,15 @@ function lastMessageOf(messages) {
|
|
|
229
209
|
/**
|
|
230
210
|
* 从 history 事件折叠出消息与「结构化行」(wire v1 扩展,向后兼容):
|
|
231
211
|
* - 普通消息维持现状:{ id, seq, time, role, text, blocks?, images?, clientMessageId? }(user/assistant);
|
|
232
|
-
* - compaction:{ kind:'compaction', id, seq, time, state:'running'|'done',
|
|
212
|
+
* - compaction:{ kind:'compaction', id, seq, time, state:'running'|'done'|'failed', error?, turn?,
|
|
213
|
+
* summary?, shadowedItemCount?, shadowedTokenCount? };
|
|
233
214
|
* 压缩检查点 user/message(source.kind==='plugin' && source.plugin==='compact')绝不渲染成用户气泡,
|
|
234
215
|
* 渲染成 compaction 行(id=compactionId,state='done'),并用同 compactionId 的 compaction/summary 补 counts;
|
|
216
|
+
* compaction/end 给这次尝试落定(带 error → state='failed' + error 原文,否则 'done');
|
|
217
|
+
* **同一轮里的多次尝试只占一行**(新尝试原地顶替还没落定的那条)—— 宿主一步重试一次,
|
|
218
|
+
* 不并就是几十条「正在压缩…」;
|
|
235
219
|
* - retry:{ kind:'retry', id: retryId, seq, time, status:'scheduled'|'started', retry, maxRetries, delayMs?, failure? };
|
|
236
220
|
* - turn-error / max-tokens:{ kind:'turn-error', id:'turn-<turn>', message, code? } / { kind:'max-tokens', id:'turn-<turn>' };
|
|
237
|
-
* - context:{ kind:'context', id:'ctx-<msgId>', contextRole:'inject'|'recall', contextLabel, text };
|
|
238
221
|
* - command:{ kind:'command', id:commandId, name, status:'running'|'done', resultKind?, text? }
|
|
239
222
|
* (command/run 发出行,command/done 原地升级:name 保 run 的,resultKind/text 取 done 的)。
|
|
240
223
|
* 结构化项与消息按事件 seq 升序混排返回;旧载荷字段缺失时跳过对应项,绝不抛错。
|
|
@@ -312,6 +295,8 @@ function deriveMessages(events) {
|
|
|
312
295
|
};
|
|
313
296
|
// 结构化行按 id 唯一:compactionId → 行索引(检查点行原地替换 start 行)、retryId → 行索引(原地升级)。
|
|
314
297
|
const compactionAt = new Map();
|
|
298
|
+
// 一轮里的压缩尝试行(turn → 行索引表):同一轮的多次尝试共用一行,见 pendingCompactionAt。
|
|
299
|
+
const compactionTurns = new Map();
|
|
315
300
|
const retryAt = new Map();
|
|
316
301
|
// 命令行按 commandId 唯一:command/run 发出行,command/done 原地升级(保 run 的 name,位置随 done 事件 seq 归位)。
|
|
317
302
|
const commandAt = new Map();
|
|
@@ -353,12 +338,31 @@ function deriveMessages(events) {
|
|
|
353
338
|
|
|
354
339
|
// 放入一行 compaction:同一 compactionId 只保留一行,检查点行原地替换 start 行(最终按 seq 排序归位)。
|
|
355
340
|
const placeCompaction = (item) => {
|
|
356
|
-
|
|
357
|
-
if (
|
|
358
|
-
|
|
359
|
-
compactionAt.set(item.id, out.length);
|
|
341
|
+
let index = compactionAt.get(item.id);
|
|
342
|
+
if (index === undefined) {
|
|
343
|
+
index = out.length;
|
|
360
344
|
out.push(item);
|
|
345
|
+
} else out[index] = item;
|
|
346
|
+
compactionAt.set(item.id, index);
|
|
347
|
+
if (typeof item.turn === 'number') {
|
|
348
|
+
const list = compactionTurns.get(item.turn);
|
|
349
|
+
if (list === undefined) compactionTurns.set(item.turn, [index]);
|
|
350
|
+
else if (!list.includes(index)) list.push(index);
|
|
361
351
|
}
|
|
352
|
+
return index;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// 同一轮里的多次压缩尝试只占一行:宿主一步重试一次、每次一个新 compactionId,按 id 找必然
|
|
356
|
+
// 「每次都新增一条」——一次跑几十步就是一个回合几十条「正在压缩…」。新尝试顶替这一轮里还
|
|
357
|
+
// 没落定的那条(running / failed),已经落地(done)的不顶替:那是真压过一次的记录。
|
|
358
|
+
const pendingCompactionAt = (turn, id) => {
|
|
359
|
+
if (compactionAt.has(id)) return compactionAt.get(id);
|
|
360
|
+
if (typeof turn !== 'number') return -1;
|
|
361
|
+
for (const index of compactionTurns.get(turn) || []) {
|
|
362
|
+
const row = out[index];
|
|
363
|
+
if (row && row.kind === 'compaction' && (row.state === 'running' || row.state === 'failed')) return index;
|
|
364
|
+
}
|
|
365
|
+
return -1;
|
|
362
366
|
};
|
|
363
367
|
|
|
364
368
|
for (const entry of events) {
|
|
@@ -388,18 +392,8 @@ function deriveMessages(events) {
|
|
|
388
392
|
continue;
|
|
389
393
|
}
|
|
390
394
|
if (src && typeof src === 'object' && src.kind !== 'user') {
|
|
391
|
-
// 上下文注入/召回(session-reference→召回,其余 kind
|
|
392
|
-
|
|
393
|
-
if (text === '' || msg.id == null) continue;
|
|
394
|
-
out.push({
|
|
395
|
-
id: `ctx-${String(msg.id)}`,
|
|
396
|
-
kind: 'context',
|
|
397
|
-
contextRole: src.kind === 'session-reference' ? 'recall' : 'inject',
|
|
398
|
-
contextLabel: contextLabelOf(src),
|
|
399
|
-
text,
|
|
400
|
-
seq,
|
|
401
|
-
time,
|
|
402
|
-
});
|
|
395
|
+
// 上下文注入/召回(session-reference→召回,其余 kind→注入):桌面 `isVisibleChatNode`
|
|
396
|
+
// 只放行携带 tool-addition/tool-removal 的上下文节点,这类提示一律不显示 —— 不下发。
|
|
403
397
|
continue;
|
|
404
398
|
}
|
|
405
399
|
// 普通用户消息(source 缺失或 kind==='user')沿用原折叠。
|
|
@@ -407,8 +401,10 @@ function deriveMessages(events) {
|
|
|
407
401
|
} else if (e.type === 'assistant/message' && e.data && e.data.message && e.data.message.content) {
|
|
408
402
|
pushMessage(e.data.message, e);
|
|
409
403
|
} else if (e.type === 'compaction/start' && e.data && typeof e.data.compactionId === 'string' && e.data.compactionId !== '') {
|
|
410
|
-
// 行已由检查点发出(done
|
|
411
|
-
|
|
404
|
+
// 行已由检查点发出(done)时跳过,避免重复行。同一轮里已有没落定的行就**原地顶替**
|
|
405
|
+
// (换 id/状态,位置留在第一次尝试那一处)—— 否则一次跑几十步就是几十条行。
|
|
406
|
+
const idx = pendingCompactionAt(e.data.turn, e.data.compactionId);
|
|
407
|
+
if (idx < 0) {
|
|
412
408
|
placeCompaction({
|
|
413
409
|
id: e.data.compactionId,
|
|
414
410
|
kind: 'compaction',
|
|
@@ -418,7 +414,46 @@ function deriveMessages(events) {
|
|
|
418
414
|
shadowedTokenCount: null,
|
|
419
415
|
seq,
|
|
420
416
|
time,
|
|
417
|
+
...(typeof e.data.turn === 'number' ? { turn: e.data.turn } : {}),
|
|
418
|
+
});
|
|
419
|
+
} else {
|
|
420
|
+
const existing = out[idx];
|
|
421
|
+
out[idx] = {
|
|
422
|
+
...existing,
|
|
423
|
+
id: e.data.compactionId,
|
|
424
|
+
state: 'running',
|
|
425
|
+
error: null,
|
|
426
|
+
...(typeof e.data.turn === 'number' ? { turn: e.data.turn } : {}),
|
|
427
|
+
};
|
|
428
|
+
compactionAt.set(e.data.compactionId, idx);
|
|
429
|
+
}
|
|
430
|
+
} else if (e.type === 'compaction/end' && e.data && typeof e.data.compactionId === 'string' && e.data.compactionId !== '') {
|
|
431
|
+
// 这次尝试的落定:无 error → done(summary / 检查点随后补齐 counts)、有 error → failed
|
|
432
|
+
// 带原因。这条以前整条丢掉,于是被拒绝的压缩永远停在「正在压缩…」,失败一次都看不见。
|
|
433
|
+
const error = typeof e.data.error === 'string' && e.data.error !== '' ? e.data.error : null;
|
|
434
|
+
const idx = pendingCompactionAt(e.data.turn, e.data.compactionId);
|
|
435
|
+
if (idx < 0) {
|
|
436
|
+
placeCompaction({
|
|
437
|
+
id: e.data.compactionId,
|
|
438
|
+
kind: 'compaction',
|
|
439
|
+
state: error ? 'failed' : 'done',
|
|
440
|
+
error,
|
|
441
|
+
summary: null,
|
|
442
|
+
shadowedItemCount: null,
|
|
443
|
+
shadowedTokenCount: null,
|
|
444
|
+
seq,
|
|
445
|
+
time,
|
|
446
|
+
...(typeof e.data.turn === 'number' ? { turn: e.data.turn } : {}),
|
|
421
447
|
});
|
|
448
|
+
} else {
|
|
449
|
+
const existing = out[idx];
|
|
450
|
+
out[idx] = {
|
|
451
|
+
...existing,
|
|
452
|
+
id: e.data.compactionId,
|
|
453
|
+
state: error ? 'failed' : 'done',
|
|
454
|
+
error,
|
|
455
|
+
};
|
|
456
|
+
compactionAt.set(e.data.compactionId, idx);
|
|
422
457
|
}
|
|
423
458
|
} else if (e.type === 'compaction/summary' && e.data && typeof e.data.compactionId === 'string' && e.data.compactionId !== '') {
|
|
424
459
|
// 已有同 compactionId 的行(start 或检查点发出)→ 升级为 done 并补 counts;否则新增 done 行。
|
|
@@ -426,6 +461,7 @@ function deriveMessages(events) {
|
|
|
426
461
|
const existing = compactionAt.has(id) ? out[compactionAt.get(id)] : null;
|
|
427
462
|
if (existing) {
|
|
428
463
|
existing.state = 'done';
|
|
464
|
+
existing.error = null;
|
|
429
465
|
existing.summary = compactionSummaryText(e.data.summary);
|
|
430
466
|
existing.shadowedItemCount = shadowedCount(e.data.shadowedSeqs);
|
|
431
467
|
existing.shadowedTokenCount = shadowedTokens(e.data.shadowedTokenCount);
|
|
@@ -517,7 +553,7 @@ function deriveMessages(events) {
|
|
|
517
553
|
out.push({ id: `turn-${e.data.turn}`, kind: 'max-tokens', seq, time });
|
|
518
554
|
}
|
|
519
555
|
}
|
|
520
|
-
// tool/result
|
|
556
|
+
// tool/result 等其余类型照旧忽略(工具结果在预扫描里配对回填;compaction 行由 start/end 发出)。
|
|
521
557
|
}
|
|
522
558
|
// 严格按事件 seq 升序(与消息混排;compaction 行升级/替换后位置以最终事件为准)。
|
|
523
559
|
out.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
@@ -601,7 +637,6 @@ module.exports = {
|
|
|
601
637
|
buildToolResultMap,
|
|
602
638
|
parseBlocks,
|
|
603
639
|
blocksToText,
|
|
604
|
-
contextLabelOf,
|
|
605
640
|
lastMessageOf,
|
|
606
641
|
MOBILE_PROMPT_RPC_PREFIX,
|
|
607
642
|
clientMessageIdFromSource,
|
|
@@ -30,15 +30,55 @@ async function* tolerate(frames, what, ctx) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
/**
|
|
34
|
+
* follow 流的一帧 → 0..n 个 mux 帧(事件原样转发;实时增量通知合成 assistant/chunk)。
|
|
35
|
+
*
|
|
36
|
+
* 0.2.0 的宿主把逐块流式增量从「会话事件」里拿了出去:正文/推理的每个 delta 只以 follow 流的
|
|
37
|
+
* `assistant-stream` 通知下发,而且**要显式传 `assistantStream: true` 才发**,事件流里只剩
|
|
38
|
+
* 「一步落定」的 `assistant/message`。不订阅通知的话,正文与运行中明细都只能在一步结束时整块
|
|
39
|
+
* 出现(真机故障 2026-10-21:回复整段蹦出来、运行中那一行没有实时明细)。
|
|
40
|
+
*
|
|
41
|
+
* 合成事件用的是移动协议早已认识的 `assistant/chunk` 形状,但**不带 seq** —— 它没有持久化
|
|
42
|
+
* 事件的身份,不该参与客户端的 seq 序门、去重与续传水位(App 侧对无 seq 帧一律放行),
|
|
43
|
+
* turn/step 由最近一次 start 通知补上。设备级推送只看 turn/start、turn/end、session/title
|
|
44
|
+
* (`lib/mobile/events.js` 的 LIST_EVENT_INNER),合成增量到不了那条通道,不会形成推送风暴。
|
|
45
|
+
*
|
|
46
|
+
* `state` 是这条流的记账({ turn, step, streaming }),由调用方持有 —— 一帧的处理要看
|
|
47
|
+
* 「之前收到过什么」。snapshot 帧不需要处理:历史走 session.history。
|
|
48
|
+
*/
|
|
49
|
+
function* followFramePayloads(frame, sessionId, state) {
|
|
50
|
+
if (!frame) return;
|
|
51
|
+
if (frame.type === 'event') {
|
|
52
|
+
if (!frame.event) return;
|
|
53
|
+
// 增量只交付一次:宿主若同时把 chunk 也当成事件发,以通知为准(通知先到)。
|
|
54
|
+
if (state.streaming && frame.event.type === 'assistant/chunk') return;
|
|
40
55
|
yield { payload: { type: 'session/event', sessionId, event: frame.event } };
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const notice = frame.type === 'assistant-stream' ? frame.frame : null;
|
|
59
|
+
if (!notice) return;
|
|
60
|
+
if (notice.type === 'start') {
|
|
61
|
+
if (Number.isSafeInteger(notice.turn)) state.turn = notice.turn;
|
|
62
|
+
if (Number.isSafeInteger(notice.step)) state.step = notice.step;
|
|
63
|
+
return;
|
|
41
64
|
}
|
|
65
|
+
if (notice.type !== 'chunk' || !notice.chunk || typeof notice.chunk !== 'object') return;
|
|
66
|
+
// 块序号在通知帧上,chunk 自己也带一份;自己没带就补上(App 侧按 chunk.index 拼草稿)。
|
|
67
|
+
const chunk = typeof notice.chunk.index === 'number' || typeof notice.index !== 'number'
|
|
68
|
+
? notice.chunk
|
|
69
|
+
: { ...notice.chunk, index: notice.index };
|
|
70
|
+
state.streaming = true;
|
|
71
|
+
yield {
|
|
72
|
+
payload: {
|
|
73
|
+
type: 'session/event',
|
|
74
|
+
sessionId,
|
|
75
|
+
event: {
|
|
76
|
+
type: 'assistant/chunk',
|
|
77
|
+
time: Number.isFinite(notice.time) ? notice.time : Date.now(),
|
|
78
|
+
data: { turn: state.turn, step: state.step, chunk },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
42
82
|
}
|
|
43
83
|
|
|
44
84
|
/** 后台任务整帧。宿主 job/list 是流,首帧即全量 rows,之后按变化推。 */
|
|
@@ -174,20 +214,23 @@ function muxFrames(ctx, request, signal) {
|
|
|
174
214
|
}
|
|
175
215
|
const control = openStream(ctx, 'session', 'control', {}, signal).then((frames) => tolerate(frames, 'session/control', ctx));
|
|
176
216
|
const jobs = openStream(ctx, 'job', 'list', { request: { sessionId } }, signal).then((frames) => tolerate(frames, `job/${sessionId}`, ctx));
|
|
217
|
+
// assistantStream: 逐块增量只走这条流的 assistant-stream 通知(见 followFramePayloads)。
|
|
177
218
|
const follow = openStream(ctx, 'session', 'follow', {
|
|
178
|
-
request: { address: { kind: 'session', sessionId }, maxMessages: 1 },
|
|
219
|
+
request: { address: { kind: 'session', sessionId }, maxMessages: 1, assistantStream: true },
|
|
179
220
|
}, signal).then((frames) => tolerate(frames, `session/${sessionId}`, ctx));
|
|
180
221
|
const projections = projectionState();
|
|
181
|
-
const
|
|
222
|
+
const followState = { turn: null, step: null, streaming: false };
|
|
223
|
+
const sources = await Promise.all([['control', control], ['jobs', jobs], ['follow', follow]].map(([source, promise]) => promise.then(async function* wrap(iterable) {
|
|
182
224
|
for await (const frame of iterable) {
|
|
183
|
-
if (
|
|
225
|
+
if (source === 'follow') {
|
|
226
|
+
// follow 一帧可能产出 0..n 个 mux 帧(事件原样 + 增量通知合成 chunk 事件)。
|
|
227
|
+
yield* followFramePayloads(frame, sessionId, followState);
|
|
228
|
+
} else if (frame && frame.type === 'rows') {
|
|
184
229
|
yield { payload: { type: 'session/jobs', sessionId, jobs: Array.isArray(frame.jobs) ? frame.jobs : [] } };
|
|
185
230
|
} else if (frame && frame.type === 'baseline') {
|
|
186
231
|
yield* applyBaseline(frame, projections, sessionId, ctx);
|
|
187
232
|
} else if (frame && frame.type === 'projection') {
|
|
188
233
|
yield* applyUpdate(frame, projections, sessionId, ctx);
|
|
189
|
-
} else if (frame && frame.type === 'event' && frame.event) {
|
|
190
|
-
yield { payload: { type: 'session/event', sessionId, event: frame.event } };
|
|
191
234
|
}
|
|
192
235
|
}
|
|
193
236
|
}).catch((error) => {
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端配对:手机在中转上用自己的配对码换到 token 与设备凭据。
|
|
5
|
+
*
|
|
6
|
+
* **这条路由归宿主,不归中转。**「谁可以进这台机器」是宿主自己的判断,配对码本来就存在
|
|
7
|
+
* 宿主的 `state` 里;中转只按实例标识把请求转过来。早先这套判断长在独立的中转进程里
|
|
8
|
+
* (`dsh-mobile-server/server.js` 的 `/pair`),换成 Rust 中转时整块没搬过去,于是手机
|
|
9
|
+
* 按老地址 POST `/pair`、请求被中转原样转给宿主、宿主没有这条路由,只能回 405。
|
|
10
|
+
*
|
|
11
|
+
* 与局域网那条(`lib/relay/lan-proxy.js` 的 `/__lan_pair`)是**同一件事的两个入口**:
|
|
12
|
+
* 都是「配对码 → 签发凭据」,差别只在网络路径。凭据本身两条路签发的是同一种 HMAC
|
|
13
|
+
* (都用宿主 lanSecret 签名),所以经中转配对拿到的 token 可以直接用于局域网请求。
|
|
14
|
+
*
|
|
15
|
+
* 故意**不**在这里做的事:
|
|
16
|
+
* - 不问宿主再确认一次。宿主的配对策略(pairingPolicy)已经决定了它接不接受新设备,
|
|
17
|
+
* `deny` 时中转根本连不上;而这条路由本身就跑在宿主进程里。
|
|
18
|
+
* - 不做「多台机器撞同一个配对码」的处理。那是中转的职责(它才看得见全部实例),
|
|
19
|
+
* 在这台宿主上,配对码要么对上要么对不上。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { sendJson } = require('../shared/http-util');
|
|
23
|
+
const { safeEqual } = require('../shared/dshtoken');
|
|
24
|
+
|
|
25
|
+
/** 与局域网那条同口径:连错 5 次锁 60 秒。 */
|
|
26
|
+
const PAIR_MAX_FAILS = 5;
|
|
27
|
+
const PAIR_LOCK_MS = 60_000;
|
|
28
|
+
|
|
29
|
+
/** ip -> { fails, lockedUntil }。放在模块作用域,两种入口共用同一份额度。 */
|
|
30
|
+
const pairLocks = new Map();
|
|
31
|
+
|
|
32
|
+
function isPairLocked(ip) {
|
|
33
|
+
const entry = pairLocks.get(ip);
|
|
34
|
+
return !!entry && entry.lockedUntil > Date.now();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function recordPairFail(ip) {
|
|
38
|
+
const entry = pairLocks.get(ip) || { fails: 0, lockedUntil: 0 };
|
|
39
|
+
entry.fails += 1;
|
|
40
|
+
if (entry.fails >= PAIR_MAX_FAILS) {
|
|
41
|
+
entry.lockedUntil = Date.now() + PAIR_LOCK_MS;
|
|
42
|
+
entry.fails = 0;
|
|
43
|
+
}
|
|
44
|
+
pairLocks.set(ip, entry);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function clearPairFails(ip) {
|
|
48
|
+
pairLocks.delete(ip);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 测试用:清空限流计数。 */
|
|
52
|
+
function resetPairLocks() {
|
|
53
|
+
pairLocks.clear();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 请求方 IP。中转会把真实来源放进 x-real-ip(与 nginx 同一约定)。 */
|
|
57
|
+
function clientIp(req) {
|
|
58
|
+
const headers = (req && req.headers) || {};
|
|
59
|
+
const raw = headers['x-real-ip'];
|
|
60
|
+
const header = Array.isArray(raw) ? raw[0] : raw;
|
|
61
|
+
if (typeof header === 'string' && header.trim()) return header.trim();
|
|
62
|
+
return (req && req.socket && req.socket.remoteAddress) || '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 配对码错误 / 策略拒绝统一回同一响应。
|
|
67
|
+
*
|
|
68
|
+
* 分开回等于告诉对方「这个码是真的,只是被拒了」,也就是把一个免费的存在性查询送出去。
|
|
69
|
+
*/
|
|
70
|
+
function pairDenied(res) {
|
|
71
|
+
sendJson(res, 401, { ok: false, error: '配对码错误' });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 读请求体。配对体很小(配对码 + 设备元信息),上限给 16KB。
|
|
76
|
+
*/
|
|
77
|
+
function readBody(req) {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
if (!req || typeof req.on !== 'function') {
|
|
80
|
+
resolve({});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let raw = '';
|
|
84
|
+
let done = false;
|
|
85
|
+
const finish = () => {
|
|
86
|
+
if (done) return;
|
|
87
|
+
done = true;
|
|
88
|
+
try {
|
|
89
|
+
resolve(JSON.parse(raw || '{}'));
|
|
90
|
+
} catch {
|
|
91
|
+
resolve(null);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
req.setEncoding('utf8');
|
|
95
|
+
req.on('data', (chunk) => {
|
|
96
|
+
raw += chunk;
|
|
97
|
+
if (raw.length > 16 * 1024) {
|
|
98
|
+
raw = raw.slice(0, 16 * 1024);
|
|
99
|
+
req.destroy();
|
|
100
|
+
finish();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
req.on('end', finish);
|
|
104
|
+
req.on('error', finish);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 构造 `/pair` 的 handler。依赖由 impl.js 注入,与移动端其它路由同一形状。
|
|
110
|
+
*
|
|
111
|
+
* @param {object} deps
|
|
112
|
+
* @param {object} deps.state 宿主状态(instanceId / pairingCode / lanSecret)
|
|
113
|
+
* @param {Function} deps.readJsonBody
|
|
114
|
+
* @param {object} deps.core relay/core.js(签 token 用)
|
|
115
|
+
* @param {object} deps.credentials 设备凭据库(issue 接口)
|
|
116
|
+
* @param {object} deps.pairingApproval pairing-policy.js(决定这台机器收不收新设备)
|
|
117
|
+
* @param {(message: string) => void} deps.log
|
|
118
|
+
*/
|
|
119
|
+
function createPairHandler(deps) {
|
|
120
|
+
const { state, core, credentials, pairingApproval, log } = deps;
|
|
121
|
+
|
|
122
|
+
// ⚠️ 签名必须是 `(req, res)` **两个**参数:宿主 webServer 就是 `handler(req, res)` 调的。
|
|
123
|
+
// 写成 `(_ctx, req, res)` 会把 res 挤成 undefined,而 sendJson 的 catch 会把那个
|
|
124
|
+
// TypeError 当成「连接已关闭」吞掉——既没报错也没回包,请求永久挂住(/health 踩过)。
|
|
125
|
+
return async function handlePair(req, res) {
|
|
126
|
+
const ip = clientIp(req);
|
|
127
|
+
|
|
128
|
+
if (isPairLocked(ip)) {
|
|
129
|
+
sendJson(res, 429, { ok: false, error: '尝试过于频繁,请稍后再试' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const body = await readBody(req);
|
|
134
|
+
if (!body || typeof body !== 'object') {
|
|
135
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const code = typeof body.code === 'string' ? body.code.trim() : '';
|
|
140
|
+
if (!/^\d{6}$/.test(code)) {
|
|
141
|
+
sendJson(res, 400, { ok: false, error: '配对码需为 6 位数字' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const expected = String(state.pairingCode || '');
|
|
146
|
+
if (!expected || !safeEqual(code, expected)) {
|
|
147
|
+
recordPairFail(ip);
|
|
148
|
+
pairDenied(res);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 配对码对了,还要宿主自己的策略点头:'deny' 就是明确不收新设备。
|
|
153
|
+
const decision = pairingApproval.evaluate(state.pairingPolicy, body.device);
|
|
154
|
+
if (!decision.approved) {
|
|
155
|
+
recordPairFail(ip);
|
|
156
|
+
pairDenied(res);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// **配对码正确时不清限流计数**:被拒的尝试同样要记数,否则猜对码但被策略拒的一方
|
|
161
|
+
// 可以无限重试。只有真正放行才清零。
|
|
162
|
+
clearPairFails(ip);
|
|
163
|
+
|
|
164
|
+
const instanceId = String(state.instanceId || '');
|
|
165
|
+
const supplied = body.device && typeof body.device === 'object' ? body.device : {};
|
|
166
|
+
const device = {
|
|
167
|
+
...Object.fromEntries(Object.entries(supplied).filter(([, value]) => value !== undefined)),
|
|
168
|
+
ip,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
let issued;
|
|
172
|
+
try {
|
|
173
|
+
issued = credentials.issue(instanceId, device, '');
|
|
174
|
+
} catch (error) {
|
|
175
|
+
log('pair issue failed:', error && error.message);
|
|
176
|
+
sendJson(res, 500, { ok: false, error: 'internal error' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 两样都要给,缺一样 App 就停在「配对响应缺少 token/cookie」:
|
|
181
|
+
// - `token` 是 **bearer**(`Authorization: Bearer …`),365 天;
|
|
182
|
+
// - `cookie` 是**中转路由**用的那张,30 天。
|
|
183
|
+
// 两者的 TTL 刻意不同,别合成一个常量(见 relay/core.js 的 MOBILE_BEARER_TTL_SEC 注释)。
|
|
184
|
+
const token = core.signLanToken(state.lanSecret, instanceId, core.MOBILE_BEARER_TTL_SEC);
|
|
185
|
+
const cookieValue = core.signLanToken(state.lanSecret, instanceId, core.MOBILE_TOKEN_TTL_SEC);
|
|
186
|
+
const hostPlatform = pairingApproval.hostPlatform().platform;
|
|
187
|
+
sendJson(res, 200, {
|
|
188
|
+
ok: true,
|
|
189
|
+
instanceId,
|
|
190
|
+
token,
|
|
191
|
+
// cookie 同时放进 body:RN 的 fetch 读不到 Set-Cookie,App 得自己带上。
|
|
192
|
+
cookie: `dsh_lan=${cookieValue}`,
|
|
193
|
+
deviceId: issued.deviceId,
|
|
194
|
+
deviceCredential: issued.credential,
|
|
195
|
+
credentialExpiresAt: issued.expiresAt,
|
|
196
|
+
// 宿主自己的系统标识,不是手机的。键名**必须是 `hostPlatform`**:App 落盘的就是它
|
|
197
|
+
//(app/src/services/pairApi.ts 读 `body.hostPlatform` 存进 `MobileSession.hostPlatform`),
|
|
198
|
+
// 局域网那条(lan-proxy.js)给的也是它。`hostPlatform()` 回的是 `{platform}` 对象,
|
|
199
|
+
// 整块展开就变成 `platform` —— App 收不到,设备行退回通用图标(真机上少的就是企鹅)。
|
|
200
|
+
...(hostPlatform ? { hostPlatform } : {}),
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
createPairHandler,
|
|
207
|
+
resetPairLocks,
|
|
208
|
+
clientIp,
|
|
209
|
+
PAIR_MAX_FAILS,
|
|
210
|
+
PAIR_LOCK_MS,
|
|
211
|
+
};
|
package/lib/mobile/sessions.js
CHANGED
|
@@ -42,7 +42,7 @@ const {
|
|
|
42
42
|
const { resolveEventAttachments } = require('./attachments.js');
|
|
43
43
|
const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
|
|
44
44
|
const { buildMessagesSnapshot, buildStatsSnapshot, statsProjections, handleMessages } = require('./session-snapshot.js');
|
|
45
|
-
const {
|
|
45
|
+
const { buildModelsSnapshot, handleModels, handleSelectModel } = require('./session-models.js');
|
|
46
46
|
const { buildSingleSessionMirror, buildMirrorWarmSnapshots } = require('./session-mirror.js');
|
|
47
47
|
const { promptOnce, handleSend, handleCommands, handleSessionRead, handleSessionAttachment, handleStop,
|
|
48
48
|
handleRename, handleFork } = require('./session-send.js');
|
|
@@ -404,12 +404,6 @@ async function handleNew(ctx, req, res) {
|
|
|
404
404
|
if (!sessionId) {
|
|
405
405
|
return { status: 500, body: { ok: false, error: { code: 'internal', message: '新建会话未返回 sessionId' } } };
|
|
406
406
|
}
|
|
407
|
-
// 创建响应携带宿主当前选择;APP 可立即显示,完整目录仍可后台刷新。
|
|
408
|
-
let models;
|
|
409
|
-
try {
|
|
410
|
-
const result = unwrap(await callSessions(ctx, 'models', { sessionId }));
|
|
411
|
-
if (!result.error) models = safeModelsValue(result.value);
|
|
412
|
-
} catch { /* 模型目录不可用时仍允许创建会话 */ }
|
|
413
407
|
// 新建的会话要立刻出现在列表里,否则手机端最长 10 秒看不到它。
|
|
414
408
|
invalidateSessionList(ctx);
|
|
415
409
|
return {
|
|
@@ -419,7 +413,6 @@ async function handleNew(ctx, req, res) {
|
|
|
419
413
|
sessionId,
|
|
420
414
|
cwd: payload.cwd || null,
|
|
421
415
|
...(value && typeof value.agentPreset === 'string' ? { agentPreset: value.agentPreset } : {}),
|
|
422
|
-
...(models?.current ? { models } : {}),
|
|
423
416
|
},
|
|
424
417
|
};
|
|
425
418
|
});
|