dsh-mobile-plugin 0.2.0 → 0.2.2
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 +1 -1
- package/lib/impl.js +9 -4
- package/lib/index.js +2 -1
- package/lib/mobile/credential-upgrade.js +11 -3
- package/lib/mobile/directory-listing.js +144 -0
- package/lib/mobile/history-derive.js +52 -13
- package/lib/mobile/host-bridge/event-synthesis.js +21 -9
- package/lib/mobile/monitor.js +140 -25
- package/lib/mobile/relay-control.js +13 -0
- package/lib/mobile/session-cache.js +41 -1
- package/lib/mobile/session-file.js +57 -32
- package/lib/mobile/session-last-turn.js +22 -2
- package/lib/mobile/session-send.js +10 -32
- package/lib/mobile/session-snapshot.js +5 -6
- package/lib/mobile/stream.js +281 -15
- package/lib/mobile/subagents.js +11 -2
- package/lib/mobile/uploads.js +12 -2
- package/lib/panel/index.html +1 -1
- package/lib/read-receipt.cjs +27 -7
- package/lib/relay/core.js +3 -0
- package/lib/relay/lan-network.cjs +47 -0
- package/lib/routes/control-plane.js +4 -0
- package/lib/routes/mobile-api.js +11 -3
- package/lib/shell/updater.js +8 -1
- package/lib/update-install.cjs +13 -5
- package/package.json +1 -1
package/lib/mobile/stream.js
CHANGED
|
@@ -21,6 +21,14 @@
|
|
|
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');
|
|
@@ -34,6 +42,7 @@ const {
|
|
|
34
42
|
shedWithoutSeq,
|
|
35
43
|
} = require('../shared/sse-write.js');
|
|
36
44
|
const { resolveEventAttachments } = require('./sessions.js');
|
|
45
|
+
const { createTextImageBudget } = require('./local-images.js');
|
|
37
46
|
const { callSessions } = require('./rpc.js');
|
|
38
47
|
const { unwrap } = require('./envelope.js');
|
|
39
48
|
const { normalizeJobs } = require('./jobs.js');
|
|
@@ -78,6 +87,49 @@ const REPLAY_MAX_BYTES = 512 * 1024;
|
|
|
78
87
|
*/
|
|
79
88
|
const REPLAY_MAX_GAP = 200;
|
|
80
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
|
+
|
|
81
133
|
/**
|
|
82
134
|
* keepalive 节奏,同时也是 `session/heartbeat` 的间隔。
|
|
83
135
|
*
|
|
@@ -152,9 +204,12 @@ function writeSse(res, payload, frame, options = {}) {
|
|
|
152
204
|
// 要落地的东西,宁可多花 2KB 也不让它们卡在客户端的缓冲里等下一次写入。
|
|
153
205
|
return sharedWriteSse(res, payload, {
|
|
154
206
|
frame,
|
|
155
|
-
...(options.burst === true
|
|
207
|
+
...(options.burst === true
|
|
156
208
|
? { paddingBytes: SSE_FLUSH_PADDING_BYTES, padEveryMs: SSE_PAD_MIN_INTERVAL_MS }
|
|
157
|
-
:
|
|
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) }),
|
|
158
213
|
// 审批帧没有 seq,按默认口径会在软水位上被丢掉(见上面那条策略)。但它是「人工决策
|
|
159
214
|
// 的入口」不是草稿:丢一张,手机就再也看不到那张卡(软水位只在客户端跟不上时到,
|
|
160
215
|
// 回放也不覆盖这一段)。它一次审批也就一两帧,不是撑爆写队列的那类帧。
|
|
@@ -247,6 +302,184 @@ function createFrameSealer(req) {
|
|
|
247
302
|
};
|
|
248
303
|
}
|
|
249
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
|
+
|
|
250
483
|
async function handleStream(ctx, req, res) {
|
|
251
484
|
const url = new URL(req.url, 'http://x');
|
|
252
485
|
const sessionId = url.searchParams.get('sessionId');
|
|
@@ -332,6 +565,9 @@ async function handleStream(ctx, req, res) {
|
|
|
332
565
|
// 后者必须回写,否则客户端会把一次静默断流当成正常状态一直等下去。
|
|
333
566
|
let clientGone = false;
|
|
334
567
|
let loopEnded = false;
|
|
568
|
+
// 本机路径图片的内联预算:**一条流共用一份**,与历史那一页共用一份同口径。
|
|
569
|
+
// 此前实时帧是每帧一预算,一条消息里几张截图就能把几百 KB 到几 MB 的 base64 推过去。
|
|
570
|
+
const imageBudget = createTextImageBudget();
|
|
335
571
|
|
|
336
572
|
try {
|
|
337
573
|
// **先开实时流、再回放**:之前是先 replay 再 mux,两步之间的 follow 还没订阅,
|
|
@@ -347,7 +583,7 @@ async function handleStream(ctx, req, res) {
|
|
|
347
583
|
|
|
348
584
|
// 断线续传:回放 history 中 seq > resumeSeq 的事件(收敛状态)。
|
|
349
585
|
if (resumeSeq != null && resumeSeq > 0) {
|
|
350
|
-
const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); }, streamAddress, sealFrame);
|
|
586
|
+
const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); }, streamAddress, sealFrame, imageBudget);
|
|
351
587
|
if (replay.gap && !ac.signal.aborted) {
|
|
352
588
|
const value = { type: 'session/resync-required', sessionId, resumeSeq, oldestSeq: replay.oldestSeq };
|
|
353
589
|
// 旧实现在这里直接 `return`:既不清 keepAlive 也不 end,漏出一条永不收尾的连接。
|
|
@@ -369,6 +605,15 @@ async function handleStream(ctx, req, res) {
|
|
|
369
605
|
}
|
|
370
606
|
return 'sent';
|
|
371
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); };
|
|
372
617
|
for (;;) {
|
|
373
618
|
const step = firstFrame ? await firstFrame : await frameIterator.next();
|
|
374
619
|
firstFrame = null;
|
|
@@ -382,34 +627,55 @@ async function handleStream(ctx, req, res) {
|
|
|
382
627
|
// 回放已覆盖该 seq(回放与实时交界处)→ 跳过,避免重复交付。
|
|
383
628
|
if (seq !== null && seq <= lastDeliveredSeq) continue;
|
|
384
629
|
// 引用模式:图片只下发 attachmentId,不取字节(见 attachments.js)。
|
|
385
|
-
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (
|
|
391
|
-
|
|
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
|
+
}
|
|
392
655
|
} else if (p.type === 'session/queue' || p.type === 'session/projection') {
|
|
393
656
|
// 队列/投影整帧转发;不假定存在可重放的宿主 API。
|
|
394
|
-
const result = await
|
|
657
|
+
const result = await pushOrdered(p, frame);
|
|
395
658
|
if (result === 'shed') continue;
|
|
396
659
|
if (result === 'closed') { clientGone = true; break; }
|
|
397
660
|
} else if (p.type === 'session/jobs') {
|
|
398
661
|
// 后台任务整帧(桌面端会话头部任务面板的数据源)。宿主只在任务有变化时发,
|
|
399
662
|
// 且**空数组也要转发** —— 那是「任务都结束了」的唯一信号,丢掉它就永远清不掉。
|
|
400
663
|
const value = { type: 'session/jobs', sessionId, jobs: normalizeJobs(p.jobs) };
|
|
401
|
-
if (await
|
|
664
|
+
if (await pushOrdered(value, frame) === 'closed') { clientGone = true; break; }
|
|
402
665
|
} else if (p.type === 'question/requested') {
|
|
403
666
|
// 问题是可回答的 mux server-request,而非 session/event;保留 rpcId 供回答关联。
|
|
404
667
|
const value = { ...p, rpcId: frame.rpcId };
|
|
405
|
-
if (await
|
|
668
|
+
if (await pushOrdered(value, { ...frame, payload: value }) === 'closed') { clientGone = true; break; }
|
|
406
669
|
} else if (p.type === 'question/resolved' || p.type === 'approval/requested' || p.type === 'approval/asked' || p.type === 'approval/decided' || p.type === 'approval/resolved') {
|
|
407
670
|
// Approval frames are server requests/resolutions, not session/event frames;
|
|
408
671
|
// forward the rpcId and approval identity so the mobile client can answer safely.
|
|
409
672
|
const value = ANSWERABLE_APPROVAL_TYPES.has(p.type) && frame.rpcId ? { ...p, rpcId: frame.rpcId } : p;
|
|
410
|
-
if (await
|
|
673
|
+
if (await pushOrdered(value, { ...frame, payload: value }) === 'closed') { clientGone = true; break; }
|
|
411
674
|
}
|
|
412
675
|
}
|
|
676
|
+
// 收尾也要把攒着的草稿写完:它们是正常结束前最后那一两百毫秒的正文。
|
|
677
|
+
await drafts.flushAll();
|
|
678
|
+
if (drafts.isClosed()) clientGone = true;
|
|
413
679
|
if (!clientGone) loopEnded = true;
|
|
414
680
|
}
|
|
415
681
|
} catch (error) {
|
|
@@ -441,7 +707,7 @@ async function handleStream(ctx, req, res) {
|
|
|
441
707
|
* 从会话 history 回放 seq > sinceSeq 的事件(升序),逐个经附件解析后写 SSE。
|
|
442
708
|
* 通过 onDelivered(seq) 回报已交付的最大 seq,供上层去重。
|
|
443
709
|
*/
|
|
444
|
-
async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, address, sealFrame = null) {
|
|
710
|
+
async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, address, sealFrame = null, imageBudget = null) {
|
|
445
711
|
try {
|
|
446
712
|
// 子会话的重放也要带父会话(同 historyAddress 的口径),否则宿主回 agent-busy,
|
|
447
713
|
// 这条断线续传静默退化成「后面缺一段」。
|
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,用不上它。
|
package/lib/mobile/uploads.js
CHANGED
|
@@ -281,12 +281,22 @@ async function handleUploadChunk(_ctx, req, res) {
|
|
|
281
281
|
sendJson(res, 200, { ok: true, received: job.received });
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
/** POST /api/mobile/v1/upload/finish { uploadId } → { ok, path, name, bytes }。
|
|
284
|
+
/** POST /api/mobile/v1/upload/finish { uploadId } → { ok, path, name, bytes }。
|
|
285
|
+
*
|
|
286
|
+
* **同一 uploadId 再收一次是回放,不是新上传**:落盘(rename)已经做完、回包却在路上丢了
|
|
287
|
+
* 的时候,手机那边只能再收一次 —— 那一发必须拿回第一次的那份结果,否则它会把一次成功的
|
|
288
|
+
* 上传判成失败、让用户白传一遍整个文件。认不出的 uploadId 仍然 404(不能靠「新建一个空
|
|
289
|
+
* 上传」蒙过去)。
|
|
290
|
+
*/
|
|
285
291
|
async function handleUploadFinish(_ctx, req, res) {
|
|
286
292
|
const body = await readJsonBody(req);
|
|
287
293
|
const uploadId = String((body && body.uploadId) || '');
|
|
288
294
|
const job = uploads.get(uploadId);
|
|
289
|
-
if (!job)
|
|
295
|
+
if (!job) {
|
|
296
|
+
const replay = resolveFinishedUpload(uploadId);
|
|
297
|
+
if (replay) return sendJson(res, 200, { ok: true, ...replay, uploadId });
|
|
298
|
+
return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
|
|
299
|
+
}
|
|
290
300
|
removeUpload(uploadId, job);
|
|
291
301
|
|
|
292
302
|
const dir = path.join(inboxRoot(), today());
|