dsh-mobile-plugin 0.1.100 → 0.2.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/README.md +14 -5
- package/dsh-mobile-credentials/lib/credentials.js +405 -84
- package/lib/client.js +31 -8
- package/lib/http/routing.js +165 -9
- package/lib/impl.js +88 -9
- package/lib/index.js +3 -2
- package/lib/mirror/pusher.cjs +35 -4
- package/lib/mobile/approvals.js +62 -15
- package/lib/mobile/auth.js +46 -26
- package/lib/mobile/credential-upgrade.js +137 -0
- package/lib/mobile/entry-window.js +32 -0
- package/lib/mobile/events.js +14 -4
- package/lib/mobile/extensions.js +279 -0
- package/lib/mobile/history-derive.js +209 -11
- package/lib/mobile/host-bridge/event-synthesis.js +61 -10
- package/lib/mobile/pairing.js +150 -64
- package/lib/mobile/plugin-self.js +187 -0
- package/lib/mobile/push-notify-route.js +37 -0
- package/lib/mobile/push-notify.js +84 -0
- package/lib/mobile/questions.js +75 -10
- package/lib/mobile/relay-control.js +547 -0
- package/lib/mobile/session-cache.js +60 -2
- package/lib/mobile/session-file.js +133 -34
- package/lib/mobile/session-list-shortcut.js +38 -0
- package/lib/mobile/session-model-options.js +3 -1
- package/lib/mobile/session-models.js +15 -3
- package/lib/mobile/session-send.js +30 -27
- package/lib/mobile/session-snapshot.js +12 -12
- package/lib/mobile/sessions.js +20 -0
- package/lib/mobile/stats-collect.cjs +55 -12
- package/lib/mobile/stats-ledger.cjs +140 -21
- package/lib/mobile/stream-registry.js +73 -0
- package/lib/mobile/stream.js +382 -31
- package/lib/mobile/subagents.js +11 -2
- package/lib/mobile/uploads.js +91 -5
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +28 -2
- package/lib/relay/lan-network.cjs +47 -0
- package/lib/relay/lan-proxy.js +283 -122
- package/lib/relay/protocol.js +4 -2
- package/lib/relay/tunnel.js +20 -14
- package/lib/routes/control-plane.js +139 -9
- package/lib/routes/mobile-api.js +92 -9
- package/lib/shared/e2ee-pair.js +91 -0
- package/lib/shared/e2ee-plain.js +79 -0
- package/lib/shared/e2ee-request.js +331 -0
- package/lib/shared/e2ee.js +173 -0
- package/lib/shared/http-util.js +27 -4
- package/lib/shared/list-signal.js +16 -0
- package/lib/shared/pair-throttle.js +150 -0
- package/lib/shared/sse-write.js +16 -5
- package/lib/shared/via-header.js +65 -0
- package/lib/shell/runtime.js +147 -42
- package/lib/shell/self-check-after-swap.js +93 -0
- package/lib/shell/updater.js +44 -3
- package/lib/sidecar-facade-worker.cjs +70 -6
- package/lib/update-install.cjs +13 -5
- package/package.json +3 -2
package/lib/mobile/stream.js
CHANGED
|
@@ -21,9 +21,20 @@
|
|
|
21
21
|
* 生命周期:所有失败路径都必须经 logStream 留痕(守卫 test/stream-liveness.test.cjs
|
|
22
22
|
* 逐条钉着字面量),收尾一定给客户端一帧 `session/resync-required`;keepAlive 定时器
|
|
23
23
|
* 要 unref —— 它只是保活,不该把进程退出按在 15s 一拍上。
|
|
24
|
+
*
|
|
25
|
+
* 出流量的三个口子(2026-10-25,都在这一层收):
|
|
26
|
+
* - 草稿增量按 200ms 合并成一帧(`DRAFT_FLUSH_MS`):一帧固定成本两三百字节,而一个
|
|
27
|
+
* token 只有十几字节,逐帧发等于按帧收费;
|
|
28
|
+
* - 草稿的填充窗口 250ms(`DRAFT_PAD_INTERVAL_MS`):填充是按时间补的,120ms 就是
|
|
29
|
+
* 16KB/s 的底线,与正文多少无关;
|
|
30
|
+
* - 推理只推每一步的开头 600 字、工具结果只推前 2000 字:运行中用户不读它们,完整内容
|
|
31
|
+
* 仍在 `assistant/message` 与 `/session/messages` 里,一个字都不丢。
|
|
24
32
|
*/
|
|
25
33
|
|
|
26
34
|
const { sendJson } = require('../shared/http-util');
|
|
35
|
+
const e2ee = require('../shared/e2ee');
|
|
36
|
+
const { headerOf, wrapper, signedPath } = require('../shared/e2ee-request');
|
|
37
|
+
const { ACCEPT_ENC_HEADER, compressPlain } = require('../shared/e2ee-plain');
|
|
27
38
|
const {
|
|
28
39
|
writeSse: sharedWriteSse,
|
|
29
40
|
waitForDrain,
|
|
@@ -31,11 +42,15 @@ const {
|
|
|
31
42
|
shedWithoutSeq,
|
|
32
43
|
} = require('../shared/sse-write.js');
|
|
33
44
|
const { resolveEventAttachments } = require('./sessions.js');
|
|
45
|
+
const { createTextImageBudget } = require('./local-images.js');
|
|
34
46
|
const { callSessions } = require('./rpc.js');
|
|
35
47
|
const { unwrap } = require('./envelope.js');
|
|
36
48
|
const { normalizeJobs } = require('./jobs.js');
|
|
37
49
|
const approvals = require('./approvals.js');
|
|
38
50
|
const questions = require('./questions.js');
|
|
51
|
+
const streamRegistry = require('./stream-registry.js');
|
|
52
|
+
const { credentialsEntry } = require('../shared/credentials-entry.js');
|
|
53
|
+
const { viaChannel } = require('../shared/via-header');
|
|
39
54
|
|
|
40
55
|
// Android React Native's XHR may buffer tiny chunked responses until its
|
|
41
56
|
// internal progress threshold. Keep each window of records padded past that
|
|
@@ -54,6 +69,67 @@ const SSE_FLUSH_PADDING_BYTES = 2048;
|
|
|
54
69
|
const SSE_PAD_MIN_INTERVAL_MS = 120;
|
|
55
70
|
const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
|
|
56
71
|
|
|
72
|
+
/**
|
|
73
|
+
* 一次断线重放最多灌多少字节。
|
|
74
|
+
*
|
|
75
|
+
* `REPLAY_WINDOW` 按事件条数限量,挡不住「几百条事件、每个都带大附件」的会话:实测有
|
|
76
|
+
* 一条会话单次重放 36MB,同一个游标被反复重放一天烧掉 450MB。到了这条线就不补了,
|
|
77
|
+
* 改成让客户端去按页拉权威历史 —— 那条路一页两百条、实测几十 KB,便宜一个数量级。
|
|
78
|
+
*/
|
|
79
|
+
const REPLAY_MAX_BYTES = 512 * 1024;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 缺口超过这个跨度就干脆不回放。
|
|
83
|
+
*
|
|
84
|
+
* 客户端落后得这么远时,把窗口整个灌过去再让它去拉历史,等于同一段内容走了两遍
|
|
85
|
+
* (App 收到 resync 帧本来就会做一次全量 authoritative 拉取)。这个值远小于
|
|
86
|
+
* `REPLAY_WINDOW`:只补「刚刚断线这几下」的缺口,剩下的交给分页历史。
|
|
87
|
+
*/
|
|
88
|
+
const REPLAY_MAX_GAP = 200;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 草稿增量的合并窗口。
|
|
92
|
+
*
|
|
93
|
+
* 这条流的流量有一多半是「每来一个 token 写一帧」的固定成本:一帧正文三十来个字节,线上
|
|
94
|
+
* 的 SSE 头 + 稳定 id + 加密信封要两三百字节。同一步里同一块的增量可以合并 —— 客户端就是
|
|
95
|
+
* 按块序号累积、文本按拼接处理,所以攒一小段时间再发一次,内容一个字不少。
|
|
96
|
+
*
|
|
97
|
+
* 200ms 是一次渲染周期的一点几倍:再久,正文出字就会一跳一跳;攒到 DRAFT_FLUSH_BYTES
|
|
98
|
+
* 提前发,避免一次写出去几千字节被 RN 的 XHR 一口吞下。
|
|
99
|
+
*/
|
|
100
|
+
const DRAFT_FLUSH_MS = 200;
|
|
101
|
+
const DRAFT_FLUSH_BYTES = 1024;
|
|
102
|
+
/** 可合并的增量类型:文本与工具参数都是「按块序号累积」的拼接语义。 */
|
|
103
|
+
const MERGEABLE_CHUNK_TYPES = new Set(['text-delta', 'tool-call-delta']);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 草稿填充的窗口(覆盖 `SSE_PAD_MIN_INTERVAL_MS`,只给草稿用)。
|
|
107
|
+
*
|
|
108
|
+
* 填充的唯一理由是让安卓 RN 的 XHR 越过进度阈值尽快吐字节;它按窗口补,所以流量等于
|
|
109
|
+
* 「2048 / 窗口」—— 120ms 就是 16KB/s 的底线,与正文多少无关。合并成 200ms 一帧之后,
|
|
110
|
+
* 窗口放到 250ms:每帧本身就带一两百字节正文,补到 2048 仍是 4 拍/秒的出字节奏,
|
|
111
|
+
* 底线降到 8KB/s。
|
|
112
|
+
*/
|
|
113
|
+
const DRAFT_PAD_INTERVAL_MS = 250;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 推理(思考)增量在实时流里只推开头这么多字,之后改推轻量进度帧。
|
|
117
|
+
*
|
|
118
|
+
* 运行中那段思考正文用户基本不读(它自己滚、而且长得飞快),却按 token 一帧地发,是
|
|
119
|
+
* 这条流里帧数最多的一类。完整思考仍然在每一步落定的 `assistant/message` 里跟着来,
|
|
120
|
+
* 也仍然在 `/session/messages` 的历史里 —— 这里省的只是「逐字推」那一段。
|
|
121
|
+
*/
|
|
122
|
+
const REASONING_LIVE_CHARS = 600;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 工具结果在实时流里最多带这么长的可见输出。
|
|
126
|
+
*
|
|
127
|
+
* 工具输出(读文件、跑命令、构建日志)是这条流里单帧最大的一类,几十到几百 KB 很常见,
|
|
128
|
+
* 而运行中那几秒用户看的是工具卡的状态与一两行摘要;要看全文时走历史那一条
|
|
129
|
+
* (`/session/messages` 的块与补丁),那里给的是全文。
|
|
130
|
+
*/
|
|
131
|
+
const TOOL_RESULT_LIVE_CHARS = 2000;
|
|
132
|
+
|
|
57
133
|
/**
|
|
58
134
|
* keepalive 节奏,同时也是 `session/heartbeat` 的间隔。
|
|
59
135
|
*
|
|
@@ -128,14 +204,20 @@ function writeSse(res, payload, frame, options = {}) {
|
|
|
128
204
|
// 要落地的东西,宁可多花 2KB 也不让它们卡在客户端的缓冲里等下一次写入。
|
|
129
205
|
return sharedWriteSse(res, payload, {
|
|
130
206
|
frame,
|
|
131
|
-
...(options.burst === true
|
|
207
|
+
...(options.burst === true
|
|
132
208
|
? { paddingBytes: SSE_FLUSH_PADDING_BYTES, padEveryMs: SSE_PAD_MIN_INTERVAL_MS }
|
|
133
|
-
:
|
|
209
|
+
: isStreamDraft(payload)
|
|
210
|
+
// 草稿已经按 200ms 合并过,窗口放到 250ms:出字仍是 4 拍/秒,底线从 16KB/s 降到 8KB/s。
|
|
211
|
+
? { paddingBytes: SSE_FLUSH_PADDING_BYTES, padEveryMs: DRAFT_PAD_INTERVAL_MS }
|
|
212
|
+
: { padding: ssePadding(SSE_FLUSH_PADDING_BYTES) }),
|
|
134
213
|
// 审批帧没有 seq,按默认口径会在软水位上被丢掉(见上面那条策略)。但它是「人工决策
|
|
135
214
|
// 的入口」不是草稿:丢一张,手机就再也看不到那张卡(软水位只在客户端跟不上时到,
|
|
136
215
|
// 回放也不覆盖这一段)。它一次审批也就一两帧,不是撑爆写队列的那类帧。
|
|
137
216
|
// 心跳同理必须保住:丢了它,App 会把一条健康的流判成死链(见 isLivenessHeartbeat)。
|
|
138
217
|
shedWhenBuffered: (value) => !isApprovalPayload(value) && !isLivenessHeartbeat(value) && shedWithoutSeq(value),
|
|
218
|
+
// 协议 v2:每帧一条独立密文(§8.3(5))。封装器由开流那一处给 —— 它才知道这条流的
|
|
219
|
+
// 请求 ts / nonce 与帧序号(AAD 要绑这三样)。
|
|
220
|
+
sealFrame: typeof options.sealFrame === 'function' ? options.sealFrame : null,
|
|
139
221
|
});
|
|
140
222
|
}
|
|
141
223
|
|
|
@@ -191,6 +273,213 @@ function parseResumeSeq(req, sessionId) {
|
|
|
191
273
|
* SSE:订阅宿主事件流,仅转发目标会话的 session/event 帧(增量事件),
|
|
192
274
|
* 每 15s 发一行注释保持连接;客户端断开即取消订阅。
|
|
193
275
|
*/
|
|
276
|
+
/**
|
|
277
|
+
* 造这条流的帧封装器(§8.3(5))。
|
|
278
|
+
*
|
|
279
|
+
* AAD 绑「路径 + 请求的 ts/nonce + 帧序号」:前三个把这一帧钉死在这条流上(别的流、
|
|
280
|
+
* 上一次连接都塞不进来),帧序号再挡住「把上一段流的帧插到这一段里」,顺带让客户端能发现丢帧。
|
|
281
|
+
*
|
|
282
|
+
* 帧序号从 1 起、宿主自己数、不进明文。
|
|
283
|
+
*
|
|
284
|
+
* 逐帧也先压再封(`x-dsh-accept-enc` 带 `g` 时):一轮流式回复里带完整正文的
|
|
285
|
+
* `assistant/message` 常常几十 KB,草稿增量里的中文也压得很动,而每帧压一次的开销
|
|
286
|
+
* 远小于它省下的字节。小于阈值(1KB)的帧不压,信封形状与原来逐字节一致。
|
|
287
|
+
*
|
|
288
|
+
* @returns {null | ((payload: unknown) => { v: number, z?: string, n: string, c: string })}
|
|
289
|
+
*/
|
|
290
|
+
function createFrameSealer(req) {
|
|
291
|
+
const opened = req && req.__dshE2ee;
|
|
292
|
+
if (!opened || !opened.keys || !opened.req) return null;
|
|
293
|
+
const { keys, req: request } = opened;
|
|
294
|
+
const path = signedPath(req);
|
|
295
|
+
const acceptEnc = headerOf(req, ACCEPT_ENC_HEADER);
|
|
296
|
+
let seq = 0;
|
|
297
|
+
return (payload) => {
|
|
298
|
+
seq += 1;
|
|
299
|
+
const { payload: plain, z } = compressPlain(Buffer.from(JSON.stringify(payload), 'utf8'), acceptEnc);
|
|
300
|
+
const sealed = e2ee.seal(keys.down, plain, e2ee.sseAad(path, request.ts, request.nonce, seq));
|
|
301
|
+
return wrapper(sealed, z);
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** 工具结果被截断时留在正文末尾的说明(客户端原样显示,不参与解析)。 */
|
|
306
|
+
const TOOL_RESULT_LIVE_ELIDED = '\n…(运行中只推前一段,展开时取全文)';
|
|
307
|
+
|
|
308
|
+
/** 草稿增量的合并键:同一步、同一块、同一类型才可并。 */
|
|
309
|
+
function chunkKeyOf(event) {
|
|
310
|
+
const data = event && event.data;
|
|
311
|
+
const chunk = data && data.chunk;
|
|
312
|
+
if (!chunk || typeof chunk.index !== 'number') return null;
|
|
313
|
+
return `${data.turn}:${data.step}:${chunk.index}:${chunk.type}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 把后到的增量并进已经攒着的那一帧。
|
|
318
|
+
*
|
|
319
|
+
* 只并「客户端按块序号累积」的两类:文本(拼接 `text`)与工具参数(拼接 `argumentsDelta`)。
|
|
320
|
+
* 其余类型(block-start/end、推理、协议 v2 的其它通知)一律不并 —— 它们带着语义边界,
|
|
321
|
+
* 合并等于把边界抹掉。
|
|
322
|
+
*/
|
|
323
|
+
function mergeDraft(into, from) {
|
|
324
|
+
const a = into && into.data && into.data.chunk;
|
|
325
|
+
const b = from && from.data && from.data.chunk;
|
|
326
|
+
if (!a || !b || a.type !== b.type) return null;
|
|
327
|
+
if (a.type === 'text-delta') {
|
|
328
|
+
if (typeof a.text !== 'string' || typeof b.text !== 'string') return null;
|
|
329
|
+
return { ...into, time: from.time, data: { ...into.data, chunk: { ...a, text: a.text + b.text } } };
|
|
330
|
+
}
|
|
331
|
+
if (a.type === 'tool-call-delta') {
|
|
332
|
+
const chunk = { ...a };
|
|
333
|
+
if (typeof b.argumentsDelta === 'string' && b.argumentsDelta) {
|
|
334
|
+
chunk.argumentsDelta = (typeof a.argumentsDelta === 'string' ? a.argumentsDelta : '') + b.argumentsDelta;
|
|
335
|
+
}
|
|
336
|
+
return { ...into, time: from.time, data: { ...into.data, chunk } };
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 草稿缓冲区:把 200ms 内的同类增量并成一帧再写出去。
|
|
343
|
+
*
|
|
344
|
+
* 两条必须守住的语义:
|
|
345
|
+
* - **顺序**:任何非草稿帧写之前都要 `flushAll()`,否则「攒着的正文」会跑到收尾的
|
|
346
|
+
* `assistant/message` 后面,界面先把整段画好、又把草稿补一遍;
|
|
347
|
+
* - **不丢**:收尾(正常结束 / 客户端断开 / 硬水位)都要先把攒着的写完,再 res.end。
|
|
348
|
+
* 合并出来的帧仍然是「可丢的草稿」(没有 seq),软水位照旧能 shed 它。
|
|
349
|
+
*/
|
|
350
|
+
function createDraftBuffer({ push }) {
|
|
351
|
+
const pending = [];
|
|
352
|
+
const byKey = new Map();
|
|
353
|
+
let timer = null;
|
|
354
|
+
let running = null;
|
|
355
|
+
let closed = false;
|
|
356
|
+
|
|
357
|
+
const bufferedBytes = () => pending.reduce((sum, item) => sum + item.bytes, 0);
|
|
358
|
+
const stopTimer = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
|
359
|
+
|
|
360
|
+
const drain = async () => {
|
|
361
|
+
stopTimer();
|
|
362
|
+
while (pending.length && !closed) {
|
|
363
|
+
const item = pending.shift();
|
|
364
|
+
if (item.key !== null) byKey.delete(item.key);
|
|
365
|
+
if (await push(item.event, item.frame) === 'closed') {
|
|
366
|
+
closed = true;
|
|
367
|
+
pending.length = 0;
|
|
368
|
+
byKey.clear();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const schedule = () => {
|
|
374
|
+
if (timer || running || !pending.length || closed) return;
|
|
375
|
+
timer = setTimeout(() => { timer = null; void flush(); }, DRAFT_FLUSH_MS);
|
|
376
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
function flush() {
|
|
380
|
+
if (running) return running;
|
|
381
|
+
running = drain().finally(() => {
|
|
382
|
+
running = null;
|
|
383
|
+
if (pending.length && !closed) schedule();
|
|
384
|
+
});
|
|
385
|
+
return running;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 把攒着的全部写完(写的过程中新攒进来的也写掉),供「非草稿帧要出去」时调用。 */
|
|
389
|
+
const flushAll = async () => {
|
|
390
|
+
while (!closed && (pending.length || running)) await flush();
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const accept = (event, frame) => {
|
|
394
|
+
const key = chunkKeyOf(event);
|
|
395
|
+
const existing = key === null ? null : byKey.get(key);
|
|
396
|
+
if (existing) {
|
|
397
|
+
const merged = mergeDraft(existing.event, event);
|
|
398
|
+
if (merged) {
|
|
399
|
+
existing.event = merged;
|
|
400
|
+
existing.frame = frame;
|
|
401
|
+
existing.bytes = Buffer.byteLength(JSON.stringify(merged), 'utf8');
|
|
402
|
+
return Promise.resolve();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const item = { key, event, frame, bytes: Buffer.byteLength(JSON.stringify(event), 'utf8') };
|
|
406
|
+
pending.push(item);
|
|
407
|
+
if (key !== null) byKey.set(key, item);
|
|
408
|
+
if (bufferedBytes() >= DRAFT_FLUSH_BYTES) return flush();
|
|
409
|
+
schedule();
|
|
410
|
+
return Promise.resolve();
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
return { accept, flush, flushAll, isClosed: () => closed };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 推理增量的实时截断(见 `REASONING_LIVE_CHARS`)。
|
|
418
|
+
*
|
|
419
|
+
* 每一步只推开头那一段,之后整段丢掉 —— 不新增帧类型,也就不动 App:组头那句「正在思考」
|
|
420
|
+
* 本身一直在闪,用户看到的就是「它还在想」;完整正文在 `assistant/message` 与历史里,
|
|
421
|
+
* 一个字都不丢。返回 `null` 表示这一帧不写。
|
|
422
|
+
*/
|
|
423
|
+
function createReasoningGate() {
|
|
424
|
+
const state = { step: null, sent: 0 };
|
|
425
|
+
return (event) => {
|
|
426
|
+
const data = event && event.data;
|
|
427
|
+
const chunk = data && data.chunk;
|
|
428
|
+
if (!chunk || typeof chunk.index !== 'number' || typeof data.turn !== 'number') return event;
|
|
429
|
+
const step = `${data.turn}:${data.step}`;
|
|
430
|
+
if (step !== state.step) {
|
|
431
|
+
state.step = step;
|
|
432
|
+
state.sent = 0;
|
|
433
|
+
}
|
|
434
|
+
const remaining = REASONING_LIVE_CHARS - state.sent;
|
|
435
|
+
if (remaining <= 0) return null;
|
|
436
|
+
if (chunk.text.length <= remaining) {
|
|
437
|
+
state.sent += chunk.text.length;
|
|
438
|
+
return event;
|
|
439
|
+
}
|
|
440
|
+
const text = chunk.text.slice(0, remaining);
|
|
441
|
+
state.sent += text.length;
|
|
442
|
+
return { ...event, data: { ...data, chunk: { ...chunk, text } } };
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** 实时流里把工具结果的可见输出截到 `TOOL_RESULT_LIVE_CHARS`(两种内容形状都认)。 */
|
|
447
|
+
function trimTextParts(parts, state) {
|
|
448
|
+
let changed = false;
|
|
449
|
+
const next = parts.map((part) => {
|
|
450
|
+
if (!part || typeof part !== 'object') return part;
|
|
451
|
+
if (Array.isArray(part.content)) {
|
|
452
|
+
const nested = trimTextParts(part.content, state);
|
|
453
|
+
if (nested === part.content) return part;
|
|
454
|
+
changed = true;
|
|
455
|
+
return { ...part, content: nested };
|
|
456
|
+
}
|
|
457
|
+
if (typeof part.text !== 'string' || !part.text) return part;
|
|
458
|
+
if (state.budget <= 0) {
|
|
459
|
+
changed = true;
|
|
460
|
+
return { ...part, text: TOOL_RESULT_LIVE_ELIDED.trimStart() };
|
|
461
|
+
}
|
|
462
|
+
if (part.text.length <= state.budget) {
|
|
463
|
+
state.budget -= part.text.length;
|
|
464
|
+
return part;
|
|
465
|
+
}
|
|
466
|
+
const text = `${part.text.slice(0, state.budget)}${TOOL_RESULT_LIVE_ELIDED}`;
|
|
467
|
+
state.budget = 0;
|
|
468
|
+
changed = true;
|
|
469
|
+
return { ...part, text };
|
|
470
|
+
});
|
|
471
|
+
return changed ? next : parts;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function truncateToolResultOutput(event) {
|
|
475
|
+
const data = event && event.data;
|
|
476
|
+
const message = data && data.message;
|
|
477
|
+
if (!message || !Array.isArray(message.content)) return event;
|
|
478
|
+
const content = trimTextParts(message.content, { budget: TOOL_RESULT_LIVE_CHARS });
|
|
479
|
+
if (content === message.content) return event;
|
|
480
|
+
return { ...event, data: { ...data, message: { ...message, content } } };
|
|
481
|
+
}
|
|
482
|
+
|
|
194
483
|
async function handleStream(ctx, req, res) {
|
|
195
484
|
const url = new URL(req.url, 'http://x');
|
|
196
485
|
const sessionId = url.searchParams.get('sessionId');
|
|
@@ -202,22 +491,37 @@ async function handleStream(ctx, req, res) {
|
|
|
202
491
|
const streamAddress = typeof parentSessionId === 'string' && parentSessionId
|
|
203
492
|
? { sessionId, parentSessionId, ...(mode === 'one-shot' || mode === 'continuable' ? { mode } : {}) }
|
|
204
493
|
: { sessionId };
|
|
494
|
+
// 协议 v2:这条流要逐帧封装。`req.__dshE2ee` 由 `createGuarded` 在解封请求时挂上。
|
|
495
|
+
// **要在 writeHead 之前算**:`X-Dsh-Proto: 2` 只能在头发出去之前声明,写过头再
|
|
496
|
+
// `setHeader` 是无效的(Node 会静默忽略),客户端就永远不知道帧是信封。
|
|
497
|
+
const sealFrame = createFrameSealer(req);
|
|
498
|
+
const head = {
|
|
499
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
500
|
+
'cache-control': 'no-cache',
|
|
501
|
+
connection: 'keep-alive',
|
|
502
|
+
'x-accel-buffering': 'no',
|
|
503
|
+
'content-encoding': 'identity',
|
|
504
|
+
...(sealFrame ? { 'x-dsh-proto': '2' } : {}),
|
|
505
|
+
};
|
|
205
506
|
try {
|
|
206
|
-
res.writeHead(200,
|
|
207
|
-
'content-type': 'text/event-stream; charset=utf-8',
|
|
208
|
-
'cache-control': 'no-cache',
|
|
209
|
-
connection: 'keep-alive',
|
|
210
|
-
'x-accel-buffering': 'no',
|
|
211
|
-
'content-encoding': 'identity',
|
|
212
|
-
});
|
|
507
|
+
res.writeHead(200, head);
|
|
213
508
|
} catch (error) { logStream(sessionId, 'writeHead failed', error); return; }
|
|
214
509
|
res.write(': connected\n\n');
|
|
215
510
|
const ac = new AbortController();
|
|
216
|
-
|
|
511
|
+
// 按设备登记这条流:吊销时宿主主动断它(§3.3)。没有这一步,被踢的手机还能继续
|
|
512
|
+
// 收消息,直到它自己重连 —— 「单台吊销」就只生效了一半。
|
|
513
|
+
const untrack = streamRegistry.track(req.__dshRecord && req.__dshRecord.deviceId, res);
|
|
514
|
+
res.on('close', () => { untrack(); ac.abort(); });
|
|
217
515
|
// 与设备级流(mobile/events.js)对齐:写坏掉的连接不该让 'error' 在 res 上无人接住
|
|
218
516
|
// 而变成未捕获异常,那时 aborted 仍是 false,收尾分支会以为客户端还在、白写一帧 resync。
|
|
219
517
|
res.on('error', () => ac.abort());
|
|
518
|
+
|
|
220
519
|
const keepAlive = setInterval(() => {
|
|
520
|
+
// 开着流但没轮询的会话也要算「在线」(§3.4):只靠请求命中来记「最近连接」的话,
|
|
521
|
+
// 一条长对话流开着、列表不轮询,面板上那台设备会被误判成离线。
|
|
522
|
+
if (req.__dshRecord) {
|
|
523
|
+
try { credentialsEntry().touch(req.__dshRecord, { channel: viaChannel(req) }); } catch { /* 记账失败不影响流 */ }
|
|
524
|
+
}
|
|
221
525
|
try {
|
|
222
526
|
res.write(': keepalive\n\n');
|
|
223
527
|
} catch (error) {
|
|
@@ -232,7 +536,7 @@ async function handleStream(ctx, req, res) {
|
|
|
232
536
|
// 注释行客户端看不见,所以每一拍再补一帧数据心跳:App 靠「最近真的收到过帧」判断
|
|
233
537
|
// 这条流还在说话(见 app 侧 services/viewmodels/stream-liveness.ts)。写不出去同样
|
|
234
538
|
// 说明客户端已经不在,按上面那条路径收尾。
|
|
235
|
-
if (!writeSse(res, { type: 'session/heartbeat', sessionId, at: Date.now() }).ok) {
|
|
539
|
+
if (!writeSse(res, { type: 'session/heartbeat', sessionId, at: Date.now() }, null, { sealFrame }).ok) {
|
|
236
540
|
logStream(sessionId, 'heartbeat write failed', null);
|
|
237
541
|
clearInterval(keepAlive);
|
|
238
542
|
ac.abort();
|
|
@@ -247,11 +551,11 @@ async function handleStream(ctx, req, res) {
|
|
|
247
551
|
// 审批卡由审批桥在帧循环之外推进这条连接:宿主那半条通道(`approval/request` 钩子)
|
|
248
552
|
// 跟 mux 帧流没关系,见 mobile/approvals.js。接入时它会把仍然可作答的卡补发一遍。
|
|
249
553
|
const detachApprovals = approvals.attachSession(sessionId, (frame) => {
|
|
250
|
-
writeSse(res, frame, { payload: frame });
|
|
554
|
+
writeSse(res, frame, { payload: frame }, { sealFrame });
|
|
251
555
|
});
|
|
252
556
|
// 提问卡同理(见 mobile/questions.js):`user-questions/request` 钩子与 mux 帧流无关。
|
|
253
557
|
const detachQuestions = questions.attachSession(sessionId, (frame) => {
|
|
254
|
-
writeSse(res, frame, { payload: frame });
|
|
558
|
+
writeSse(res, frame, { payload: frame }, { sealFrame });
|
|
255
559
|
});
|
|
256
560
|
|
|
257
561
|
const resumeSeq = parseResumeSeq(req, sessionId);
|
|
@@ -261,6 +565,9 @@ async function handleStream(ctx, req, res) {
|
|
|
261
565
|
// 后者必须回写,否则客户端会把一次静默断流当成正常状态一直等下去。
|
|
262
566
|
let clientGone = false;
|
|
263
567
|
let loopEnded = false;
|
|
568
|
+
// 本机路径图片的内联预算:**一条流共用一份**,与历史那一页共用一份同口径。
|
|
569
|
+
// 此前实时帧是每帧一预算,一条消息里几张截图就能把几百 KB 到几 MB 的 base64 推过去。
|
|
570
|
+
const imageBudget = createTextImageBudget();
|
|
264
571
|
|
|
265
572
|
try {
|
|
266
573
|
// **先开实时流、再回放**:之前是先 replay 再 mux,两步之间的 follow 还没订阅,
|
|
@@ -276,11 +583,11 @@ async function handleStream(ctx, req, res) {
|
|
|
276
583
|
|
|
277
584
|
// 断线续传:回放 history 中 seq > resumeSeq 的事件(收敛状态)。
|
|
278
585
|
if (resumeSeq != null && resumeSeq > 0) {
|
|
279
|
-
const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); }, streamAddress);
|
|
586
|
+
const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); }, streamAddress, sealFrame, imageBudget);
|
|
280
587
|
if (replay.gap && !ac.signal.aborted) {
|
|
281
588
|
const value = { type: 'session/resync-required', sessionId, resumeSeq, oldestSeq: replay.oldestSeq };
|
|
282
589
|
// 旧实现在这里直接 `return`:既不清 keepAlive 也不 end,漏出一条永不收尾的连接。
|
|
283
|
-
if (!writeSse(res, value, { payload: value }).ok) clientGone = true;
|
|
590
|
+
if (!writeSse(res, value, { payload: value }, { sealFrame }).ok) clientGone = true;
|
|
284
591
|
}
|
|
285
592
|
}
|
|
286
593
|
|
|
@@ -289,7 +596,7 @@ async function handleStream(ctx, req, res) {
|
|
|
289
596
|
// 「需要等 drain」这一步是这次 OOM 的正解 —— 写不动就停手,等底层消化完再继续,
|
|
290
597
|
// 而不是把帧继续往内存里塞。
|
|
291
598
|
const push = async (payload, frameArg) => {
|
|
292
|
-
const sent = writeSse(res, payload, frameArg);
|
|
599
|
+
const sent = writeSse(res, payload, frameArg, { sealFrame });
|
|
293
600
|
if (sent.shed) return 'shed';
|
|
294
601
|
if (!sent.ok) return 'closed';
|
|
295
602
|
if (sent.needDrain) {
|
|
@@ -298,6 +605,15 @@ async function handleStream(ctx, req, res) {
|
|
|
298
605
|
}
|
|
299
606
|
return 'sent';
|
|
300
607
|
};
|
|
608
|
+
// 草稿合并 + 推理限流 + 整条流共用一份内联图片预算(见上面三组常量与
|
|
609
|
+
// `createDraftBuffer` / `createReasoningGate` 的注释)。
|
|
610
|
+
const drafts = createDraftBuffer({ push });
|
|
611
|
+
const reasoningGate = createReasoningGate();
|
|
612
|
+
/**
|
|
613
|
+
* 非草稿帧写之前先把攒着的草稿写出去:草稿是按发生顺序攒的,晚写一步就会变成
|
|
614
|
+
* 「收尾的 assistant/message 先到、被并过的正文后到」,界面上同一段话画两遍。
|
|
615
|
+
*/
|
|
616
|
+
const pushOrdered = async (payload, frameArg) => { await drafts.flushAll(); return push(payload, frameArg); };
|
|
301
617
|
for (;;) {
|
|
302
618
|
const step = firstFrame ? await firstFrame : await frameIterator.next();
|
|
303
619
|
firstFrame = null;
|
|
@@ -311,34 +627,55 @@ async function handleStream(ctx, req, res) {
|
|
|
311
627
|
// 回放已覆盖该 seq(回放与实时交界处)→ 跳过,避免重复交付。
|
|
312
628
|
if (seq !== null && seq <= lastDeliveredSeq) continue;
|
|
313
629
|
// 引用模式:图片只下发 attachmentId,不取字节(见 attachments.js)。
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (
|
|
320
|
-
|
|
630
|
+
// 预算按**整条流**共享:本机路径的图片内联(local-images.js)此前是每帧一份,
|
|
631
|
+
// 一条消息里几张截图就能把几百 KB 到几 MB 的 base64 推进去。
|
|
632
|
+
let event = await resolveEventAttachments(ctx, sessionId, p.event, { referencesOnly: true, budget: imageBudget });
|
|
633
|
+
if (event.type === 'tool/result') event = truncateToolResultOutput(event);
|
|
634
|
+
const chunk = event.data && event.data.chunk;
|
|
635
|
+
if (seq !== null || !chunk || typeof chunk.type !== 'string') {
|
|
636
|
+
// 持久事件(或不是增量的会话事件):先把攒着的草稿写掉,再按原样写这一帧。
|
|
637
|
+
const result = await pushOrdered(event, { ...frame, payload: { ...p, event } });
|
|
638
|
+
if (result === 'shed') continue;
|
|
639
|
+
if (result === 'closed') { clientGone = true; break; }
|
|
640
|
+
if (seq !== null) lastDeliveredSeq = seq;
|
|
641
|
+
} else if (chunk.type === 'reasoning-delta') {
|
|
642
|
+
const kept = reasoningGate(event);
|
|
643
|
+
if (kept === null) continue;
|
|
644
|
+
await drafts.accept(kept, { ...frame, payload: { ...p, event: kept } });
|
|
645
|
+
if (drafts.isClosed()) { clientGone = true; break; }
|
|
646
|
+
} else if (MERGEABLE_CHUNK_TYPES.has(chunk.type)) {
|
|
647
|
+
// 被 shed 的只可能是没有 seq 的增量(见 writeSse 的软水位闸);它不推进水位,
|
|
648
|
+
// 同一轮收尾的 assistant/message 会把完整正文补上。
|
|
649
|
+
await drafts.accept(event, { ...frame, payload: { ...p, event } });
|
|
650
|
+
if (drafts.isClosed()) { clientGone = true; break; }
|
|
651
|
+
} else {
|
|
652
|
+
// 块边界这类带语义的增量不合并,直接写(但要排在攒着的草稿后面)。
|
|
653
|
+
if (await pushOrdered(event, { ...frame, payload: { ...p, event } }) === 'closed') { clientGone = true; break; }
|
|
654
|
+
}
|
|
321
655
|
} else if (p.type === 'session/queue' || p.type === 'session/projection') {
|
|
322
656
|
// 队列/投影整帧转发;不假定存在可重放的宿主 API。
|
|
323
|
-
const result = await
|
|
657
|
+
const result = await pushOrdered(p, frame);
|
|
324
658
|
if (result === 'shed') continue;
|
|
325
659
|
if (result === 'closed') { clientGone = true; break; }
|
|
326
660
|
} else if (p.type === 'session/jobs') {
|
|
327
661
|
// 后台任务整帧(桌面端会话头部任务面板的数据源)。宿主只在任务有变化时发,
|
|
328
662
|
// 且**空数组也要转发** —— 那是「任务都结束了」的唯一信号,丢掉它就永远清不掉。
|
|
329
663
|
const value = { type: 'session/jobs', sessionId, jobs: normalizeJobs(p.jobs) };
|
|
330
|
-
if (await
|
|
664
|
+
if (await pushOrdered(value, frame) === 'closed') { clientGone = true; break; }
|
|
331
665
|
} else if (p.type === 'question/requested') {
|
|
332
666
|
// 问题是可回答的 mux server-request,而非 session/event;保留 rpcId 供回答关联。
|
|
333
667
|
const value = { ...p, rpcId: frame.rpcId };
|
|
334
|
-
if (await
|
|
668
|
+
if (await pushOrdered(value, { ...frame, payload: value }) === 'closed') { clientGone = true; break; }
|
|
335
669
|
} else if (p.type === 'question/resolved' || p.type === 'approval/requested' || p.type === 'approval/asked' || p.type === 'approval/decided' || p.type === 'approval/resolved') {
|
|
336
670
|
// Approval frames are server requests/resolutions, not session/event frames;
|
|
337
671
|
// forward the rpcId and approval identity so the mobile client can answer safely.
|
|
338
672
|
const value = ANSWERABLE_APPROVAL_TYPES.has(p.type) && frame.rpcId ? { ...p, rpcId: frame.rpcId } : p;
|
|
339
|
-
if (await
|
|
673
|
+
if (await pushOrdered(value, { ...frame, payload: value }) === 'closed') { clientGone = true; break; }
|
|
340
674
|
}
|
|
341
675
|
}
|
|
676
|
+
// 收尾也要把攒着的草稿写完:它们是正常结束前最后那一两百毫秒的正文。
|
|
677
|
+
await drafts.flushAll();
|
|
678
|
+
if (drafts.isClosed()) clientGone = true;
|
|
342
679
|
if (!clientGone) loopEnded = true;
|
|
343
680
|
}
|
|
344
681
|
} catch (error) {
|
|
@@ -355,7 +692,7 @@ async function handleStream(ctx, req, res) {
|
|
|
355
692
|
// 明确告诉客户端「流到此为止,请带 lastSeq 重连」:它会走已验证可用的 replay
|
|
356
693
|
// 路径补上缺口,而不是把静默断流当成正常状态。
|
|
357
694
|
if (!ac.signal.aborted && !clientGone) {
|
|
358
|
-
writeSse(res, { type: 'session/resync-required', sessionId, oldestSeq: lastDeliveredSeq + 1, final: true });
|
|
695
|
+
writeSse(res, { type: 'session/resync-required', sessionId, oldestSeq: lastDeliveredSeq + 1, final: true }, null, { sealFrame });
|
|
359
696
|
}
|
|
360
697
|
res.end();
|
|
361
698
|
logStream(sessionId, `stream closed (loopEnded=${loopEnded}, aborted=${ac.signal.aborted}, clientGone=${clientGone}, deliveredSeq=${lastDeliveredSeq})`, null);
|
|
@@ -370,7 +707,7 @@ async function handleStream(ctx, req, res) {
|
|
|
370
707
|
* 从会话 history 回放 seq > sinceSeq 的事件(升序),逐个经附件解析后写 SSE。
|
|
371
708
|
* 通过 onDelivered(seq) 回报已交付的最大 seq,供上层去重。
|
|
372
709
|
*/
|
|
373
|
-
async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, address) {
|
|
710
|
+
async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, address, sealFrame = null, imageBudget = null) {
|
|
374
711
|
try {
|
|
375
712
|
// 子会话的重放也要带父会话(同 historyAddress 的口径),否则宿主回 agent-busy,
|
|
376
713
|
// 这条断线续传静默退化成「后面缺一段」。
|
|
@@ -393,6 +730,14 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, a
|
|
|
393
730
|
return e && Number.isFinite(e.seq) && e.seq > sinceSeq;
|
|
394
731
|
})
|
|
395
732
|
.sort((a, b) => ((a?.event || a).seq) - ((b?.event || b).seq));
|
|
733
|
+
// 落后太远就别在流里补了:这一段客户端本来就要靠权威历史重拉一遍,再从流里灌一遍
|
|
734
|
+
// 是同一份内容走两趟。空回放 + 报缺口,让 App 走分页历史那条便宜的路。
|
|
735
|
+
const farBehind = Boolean(candidates.length) && (candidates[candidates.length - 1].event || candidates[candidates.length - 1]).seq - sinceSeq > REPLAY_MAX_GAP;
|
|
736
|
+
if (gap || farBehind) {
|
|
737
|
+
logStream(sessionId, `replay skipped (gap=${gap} farBehind=${farBehind} cursor=${sinceSeq} newest=${(candidates[candidates.length - 1]?.event || candidates[candidates.length - 1])?.seq})`, null);
|
|
738
|
+
return { gap: true, oldestSeq: oldestSeq ?? sinceSeq + 1 };
|
|
739
|
+
}
|
|
740
|
+
let written = 0;
|
|
396
741
|
for (const entry of candidates) {
|
|
397
742
|
if (signal.aborted) break;
|
|
398
743
|
const e = entry?.event || entry;
|
|
@@ -402,11 +747,17 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, a
|
|
|
402
747
|
const payload = { type: 'session/event', sessionId, event };
|
|
403
748
|
// 成批灌入:这一节几百条连着写,逐条补满 2048 字节注释等于给一次重连凭空加几 MB
|
|
404
749
|
// (见 writeSse 的 options.burst)。
|
|
405
|
-
const sent = writeSse(res, event, { payload }, { burst: true });
|
|
750
|
+
const sent = writeSse(res, event, { payload }, { burst: true, sealFrame });
|
|
406
751
|
if (!sent.ok) break;
|
|
752
|
+
written += (sent.bytes ?? 0);
|
|
407
753
|
// 回放是「一次性灌几千条」,最容易把写队列撑爆:写不动就在这里等,别一口气塞完。
|
|
408
754
|
if (sent.needDrain && !(await waitForDrain(res, signal))) break;
|
|
409
755
|
if (Number.isFinite(event.seq)) onDelivered(event.seq);
|
|
756
|
+
// 字节到顶也一样:剩下的交给分页历史,别再灌。
|
|
757
|
+
if (written >= REPLAY_MAX_BYTES) {
|
|
758
|
+
logStream(sessionId, `replay truncated at ${written}B (cursor=${sinceSeq})`, null);
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
410
761
|
}
|
|
411
762
|
return { gap, oldestSeq };
|
|
412
763
|
} catch (error) { /* 回放尽力而为;失败不阻塞实时流 */
|
|
@@ -415,4 +766,4 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, a
|
|
|
415
766
|
}
|
|
416
767
|
}
|
|
417
768
|
|
|
418
|
-
module.exports = { handleStream, parseResumeSeq };
|
|
769
|
+
module.exports = { handleStream, parseResumeSeq, createFrameSealer };
|
package/lib/mobile/subagents.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
28
28
|
const { unwrap } = require('./sessions.js');
|
|
29
|
+
const { promptOnceFor } = require('./session-cache.js');
|
|
29
30
|
|
|
30
31
|
const SUBAGENT_MAX_ENTRIES = 100; // 子代理条数上限(正常宿主只有个位数)
|
|
31
32
|
const SUBAGENT_ID_MAX = 200; // 会话 id 长度上限
|
|
@@ -210,9 +211,10 @@ async function handleSubagentPrompt(ctx, req, res) {
|
|
|
210
211
|
const clientTimeZone = typeof body.clientTimeZone === 'string' && body.clientTimeZone.trim()
|
|
211
212
|
? body.clientTimeZone.trim()
|
|
212
213
|
: undefined;
|
|
214
|
+
const clientMessageId = typeof body.clientMessageId === 'string' ? body.clientMessageId.trim() : '';
|
|
213
215
|
|
|
214
216
|
const controller = new AbortController();
|
|
215
|
-
const
|
|
217
|
+
const call = () => ctx.apiProxy.subagents.prompt({
|
|
216
218
|
rpcId: 'mobile',
|
|
217
219
|
method: 'subagent.prompt',
|
|
218
220
|
payload: {
|
|
@@ -222,7 +224,14 @@ async function handleSubagentPrompt(ctx, req, res) {
|
|
|
222
224
|
content: normalized.blocks,
|
|
223
225
|
...(clientTimeZone === undefined ? {} : { clientTimeZone }),
|
|
224
226
|
},
|
|
225
|
-
}, controller.signal)
|
|
227
|
+
}, controller.signal);
|
|
228
|
+
// 续话与主会话发送走同一条重试链路(乐观行 + 超时后自动 / 手点重发),所以同样按
|
|
229
|
+
// `clientMessageId` 去重:没有它,超时的那一发多半已经在子会话里说过了,重试会说第二遍
|
|
230
|
+
// (口径见 session-cache 的 `promptOnceFor`)。
|
|
231
|
+
const response = clientMessageId
|
|
232
|
+
? await promptOnceFor(ctx, `subagent:${parentSessionId}:${childSessionId}:${clientMessageId}`, call)
|
|
233
|
+
: await call();
|
|
234
|
+
const { error } = unwrap(response);
|
|
226
235
|
if (error) return sendJson(res, 400, { ok: false, error });
|
|
227
236
|
|
|
228
237
|
// 宿主回的是 { messageId },但手机端自己生成乐观行的 clientMessageId,用不上它。
|