dsh-mobile-plugin 0.5.8 → 0.5.10
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/mobile/attachment-store.js +157 -0
- package/lib/mobile/attachments.js +7 -1
- package/lib/mobile/channels.js +47 -4
- package/lib/mobile/events.js +41 -11
- package/lib/mobile/host-bridge/transport.js +28 -1
- package/lib/mobile/host-settings.js +14 -3
- package/lib/mobile/local-images.js +57 -1
- package/lib/mobile/plugins.js +7 -2
- package/lib/mobile/questions.js +24 -4
- package/lib/mobile/session-presets.js +6 -2
- package/lib/mobile/session-send.js +5 -0
- package/lib/mobile/stream.js +10 -0
- package/lib/shared/list-revision.js +19 -1
- package/lib/shared/sse-write.js +73 -0
- package/lib/update-install.cjs +41 -7
- package/package.json +1 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 宿主附件库的直读 —— 手机取图那条路少绕一圈。
|
|
5
|
+
*
|
|
6
|
+
* 为什么要有它:手机要一张图时,插件今天只能调宿主的取附件接口,拿回**整份 base64** 再
|
|
7
|
+
* 解成字节、分块写给手机。那一发要等宿主把整张读出来、编码完才返回,宿主主线程正忙
|
|
8
|
+
* (比如在派生一条几千条的会话)时,手机那边就是一个字节都没有 —— 2026-10-31 中转日志里
|
|
9
|
+
* 有 109 次请求整整 15 秒收不到任何字节,全部是这一条路上来的。
|
|
10
|
+
*
|
|
11
|
+
* 附件在宿主本机本来就是**按内容哈希分两级目录存的文件**(`<DSH_HOME>/attachments/v1/
|
|
12
|
+
* objects/<前两位>/<其余>`),插件和宿主同机同进程,直接从盘上流进隧道就行:首字节不等
|
|
13
|
+
* 宿主做主活,内存里也不再摊一份两兆七的 base64 字符串。
|
|
14
|
+
*
|
|
15
|
+
* **只认「我们确实告诉过手机的那几个 id」**(`remember` 在引用下发时记账)。这条不是多余的
|
|
16
|
+
* 防御:附着在会话里的图片 id 是我们自己下发的,反过来「拿任意一个 64 位十六进制串来读」
|
|
17
|
+
* 就不该成立 —— 能力面与 `session/file`、`session/local-image` 同一条口径:只读登记过的。
|
|
18
|
+
* 记账没命中、文件不在、不是加密会话,一律退回原来那条宿主接口,行为与今天完全一致。
|
|
19
|
+
*
|
|
20
|
+
* 记账只在内存里:插件重启后手机重连会重新拉一遍消息、重新登记;过期与上限见下面两个常量。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const { dshHome } = require('../shared/paths.js');
|
|
27
|
+
const { chunksHeaders, pipeEncrypted } = require('../shared/e2ee-chunks.js');
|
|
28
|
+
|
|
29
|
+
/** 一个会话最多记多少个附件 id(会话里几千张图也不该无限涨)。 */
|
|
30
|
+
const MAX_IDS_PER_SESSION = 4096;
|
|
31
|
+
/** 最多记多少个会话(滚动淘汰最久没登记的)。 */
|
|
32
|
+
const MAX_SESSIONS = 64;
|
|
33
|
+
/** 记账多久算新鲜:超过这个时间没再被引用过,就当手机手里那份已经过期了。 */
|
|
34
|
+
const TTL_MS = 30 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
/** sessionId -> { at, entries: Map<attachmentId, { mediaType, at }> } */
|
|
37
|
+
const SESSIONS = new Map();
|
|
38
|
+
|
|
39
|
+
/** 内容哈希是 64 位小写十六进制;别的形状一律不认(顺带挡住路径穿越)。 */
|
|
40
|
+
const OBJECT_ID = /^[0-9a-f]{64}$/;
|
|
41
|
+
|
|
42
|
+
function nowMs() {
|
|
43
|
+
return Date.now();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 附件 id → 宿主附件库里的绝对路径。
|
|
48
|
+
*
|
|
49
|
+
* 形状不对(不是 64 位小写十六进制)直接回 null:这条路径要拼进 `createReadStream`,
|
|
50
|
+
* 校验放在这里、而不是靠调用方记得先校验。
|
|
51
|
+
*/
|
|
52
|
+
function objectPathOf(attachmentId) {
|
|
53
|
+
if (typeof attachmentId !== 'string' || !OBJECT_ID.test(attachmentId)) return null;
|
|
54
|
+
const root = path.join(dshHome(), 'attachments', 'v1', 'objects');
|
|
55
|
+
return path.join(root, attachmentId.slice(0, 2), attachmentId.slice(2));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 记账:这个附件我们刚在 `sessionId` 的消息里下发过(手机接下来就可能来取字节)。 */
|
|
59
|
+
function remember(sessionId, attachmentId, mediaType) {
|
|
60
|
+
if (typeof sessionId !== 'string' || !sessionId) return;
|
|
61
|
+
if (typeof attachmentId !== 'string' || !OBJECT_ID.test(attachmentId)) return;
|
|
62
|
+
const at = nowMs();
|
|
63
|
+
let slot = SESSIONS.get(sessionId);
|
|
64
|
+
if (!slot) {
|
|
65
|
+
slot = { at, entries: new Map() };
|
|
66
|
+
SESSIONS.set(sessionId, slot);
|
|
67
|
+
// 会话数到顶:淘汰最久没动静的那一个(Map 保持插入顺序,第一条就是最旧的)。
|
|
68
|
+
while (SESSIONS.size > MAX_SESSIONS) {
|
|
69
|
+
const oldest = SESSIONS.keys().next().value;
|
|
70
|
+
if (oldest === undefined) break;
|
|
71
|
+
SESSIONS.delete(oldest);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
slot.at = at;
|
|
75
|
+
slot.entries.delete(attachmentId);
|
|
76
|
+
slot.entries.set(attachmentId, {
|
|
77
|
+
at,
|
|
78
|
+
mediaType: typeof mediaType === 'string' && mediaType ? mediaType : 'application/octet-stream',
|
|
79
|
+
});
|
|
80
|
+
while (slot.entries.size > MAX_IDS_PER_SESSION) {
|
|
81
|
+
const oldest = slot.entries.keys().next().value;
|
|
82
|
+
if (oldest === undefined) break;
|
|
83
|
+
slot.entries.delete(oldest);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 这个 id 是不是我们为这条会话下发过的;是就带上当时的媒体类型。 */
|
|
88
|
+
function lookup(sessionId, attachmentId, now = nowMs()) {
|
|
89
|
+
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
90
|
+
if (typeof attachmentId !== 'string' || !attachmentId) return null;
|
|
91
|
+
const slot = SESSIONS.get(sessionId);
|
|
92
|
+
if (!slot) return null;
|
|
93
|
+
if (now - slot.at > TTL_MS) {
|
|
94
|
+
SESSIONS.delete(sessionId);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const hit = slot.entries.get(attachmentId);
|
|
98
|
+
if (!hit) return null;
|
|
99
|
+
if (now - hit.at > TTL_MS) {
|
|
100
|
+
slot.entries.delete(attachmentId);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return { mediaType: hit.mediaType };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 打开一条只读流(调用方自己负责在连接关掉时 destroy)。 */
|
|
107
|
+
function openObject(attachmentId) {
|
|
108
|
+
const target = objectPathOf(attachmentId);
|
|
109
|
+
if (!target) return null;
|
|
110
|
+
try {
|
|
111
|
+
const stat = fs.statSync(target);
|
|
112
|
+
if (!stat.isFile() || stat.size <= 0) return null;
|
|
113
|
+
return { path: target, size: stat.size, stream: fs.createReadStream(target) };
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 试一次「直接从盘上流给手机」:写成功返回 true,不该走或走不了返回 false。
|
|
121
|
+
*
|
|
122
|
+
* 三个前提缺一不可:这条请求带信封(协议 v2 才走分块)、这个 id 是我们为这条会话下发过的、
|
|
123
|
+
* 对象文件确实还在。任一不满足就交给调用方走原来那条宿主接口 —— 老插件、老会话、明文会话
|
|
124
|
+
* 的行为因此一个字都不变。
|
|
125
|
+
*/
|
|
126
|
+
async function streamIfRemembered(req, res, sessionId, attachmentId) {
|
|
127
|
+
const opened = req && req.__dshE2ee;
|
|
128
|
+
if (!opened || !opened.keys || !opened.req) return false;
|
|
129
|
+
const known = lookup(sessionId, attachmentId);
|
|
130
|
+
if (!known) return false;
|
|
131
|
+
const object = openObject(attachmentId);
|
|
132
|
+
if (!object) return false;
|
|
133
|
+
res.writeHead(200, chunksHeaders({
|
|
134
|
+
'x-dsh-content-type': known.mediaType,
|
|
135
|
+
'x-dsh-file-size': String(object.size),
|
|
136
|
+
}));
|
|
137
|
+
res.on('close', () => object.stream.destroy());
|
|
138
|
+
await pipeEncrypted(req, res, object.stream);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 测试用:清空记账。 */
|
|
143
|
+
function reset() {
|
|
144
|
+
SESSIONS.clear();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
MAX_IDS_PER_SESSION,
|
|
149
|
+
MAX_SESSIONS,
|
|
150
|
+
TTL_MS,
|
|
151
|
+
objectPathOf,
|
|
152
|
+
remember,
|
|
153
|
+
lookup,
|
|
154
|
+
openObject,
|
|
155
|
+
streamIfRemembered,
|
|
156
|
+
reset,
|
|
157
|
+
};
|
|
@@ -19,6 +19,7 @@ const { unwrap } = require('./envelope.js');
|
|
|
19
19
|
const { callSessions } = require('./rpc.js');
|
|
20
20
|
const { cwdOf } = require('./session-cwd.js');
|
|
21
21
|
const { inlineTextImages, createTextImageBudget, hasRelativeImage } = require('./local-images.js');
|
|
22
|
+
const attachmentStore = require('./attachment-store.js');
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* 取不到字节的图片块:原样留着,只加一个标记。
|
|
@@ -54,7 +55,12 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
|
|
|
54
55
|
const attachmentId = block.attachment.attachmentId;
|
|
55
56
|
if (typeof attachmentId !== 'string' || !attachmentId) return block;
|
|
56
57
|
// 引用模式到此为止:块原样返回,字节由手机端按 attachmentId 单张去取。
|
|
57
|
-
if (referencesOnly)
|
|
58
|
+
if (referencesOnly) {
|
|
59
|
+
// 顺便记一笔:手机接下来就可能拿着这个 id 来取字节。记下来之后那条路可以直接从
|
|
60
|
+
// 宿主附件库流给它,不必再等宿主把整张图读成 base64(见 attachment-store.js)。
|
|
61
|
+
attachmentStore.remember(sessionId, attachmentId, block.attachment.mediaType || block.mediaType);
|
|
62
|
+
return block;
|
|
63
|
+
}
|
|
58
64
|
let result;
|
|
59
65
|
try {
|
|
60
66
|
result = unwrap(await callSessions(ctx, 'attachment', { sessionId, attachmentId }));
|
package/lib/mobile/channels.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
18
18
|
const { unwrap } = require('./envelope.js');
|
|
19
19
|
const { dropSessionModelsForDevice } = require('./session-cache.js');
|
|
20
|
+
const { unchangedListBody, revisionOf, stableRevision } = require('../shared/list-revision.js');
|
|
20
21
|
|
|
21
22
|
/** 自建渠道写在这个命名空间里;桌面端 `src/custom-channels.js` 的 PI_AI_NS 同值。 */
|
|
22
23
|
const PI_AI_NS = 'llm-pi-ai';
|
|
@@ -280,7 +281,8 @@ async function handleChannels(ctx, req, res) {
|
|
|
280
281
|
});
|
|
281
282
|
}
|
|
282
283
|
|
|
283
|
-
|
|
284
|
+
const body = { ok: true, channels, revision: stableRevision(channels) };
|
|
285
|
+
sendJson(res, 200, unchangedListBody(body, revisionOf(req)) || body);
|
|
284
286
|
}
|
|
285
287
|
|
|
286
288
|
/**
|
|
@@ -311,11 +313,41 @@ function lookupService(ctx, name) {
|
|
|
311
313
|
}
|
|
312
314
|
|
|
313
315
|
/**
|
|
314
|
-
*
|
|
316
|
+
* 宿主 `llm.discoverModels` 的失败 → 中文一句(宿主的英文原话留在 `details.hostMessage`)。
|
|
317
|
+
*
|
|
318
|
+
* 宿主的原话是英文(`could not reach …`、`… answered 401; check the API key`),而 App 那边
|
|
319
|
+
* 规定英文原话不上界面(`services/http` 的 `unwrapResult` 会整句换成「电脑那边出了错,请重试」),
|
|
320
|
+
* 「连不上地址」与「Key 不对」在手机上就分不出来。所以在这一层按已经见到的几种形状换成中文:
|
|
321
|
+
* App 拿到的原话是中文就照原样显示,用户读得到下一步该干什么。
|
|
322
|
+
*/
|
|
323
|
+
function discoverFailureMessage(caught) {
|
|
324
|
+
const raw = caught && typeof caught.message === 'string' ? caught.message : String(caught == null ? '' : caught);
|
|
325
|
+
const code = caught && typeof caught.code === 'string' ? caught.code : '';
|
|
326
|
+
if (/could not reach/i.test(raw)) return '连不上这个地址:确认地址没写错,也确认这台电脑访问得到它';
|
|
327
|
+
const answered = /answered (\d{3})/i.exec(raw);
|
|
328
|
+
if (answered) {
|
|
329
|
+
const status = answered[1];
|
|
330
|
+
if (status === '401' || status === '403') return '这个地址要求鉴权:Key 不对或者没填';
|
|
331
|
+
return `这个地址没有模型清单这个接口(HTTP ${status}),检查地址有没有多写路径`;
|
|
332
|
+
}
|
|
333
|
+
if (/did not answer with JSON/i.test(raw)) return '这个地址回的不是模型清单,可能填成了网页地址';
|
|
334
|
+
if (/no model listing this build can read/i.test(raw)) return '这条协议取不到模型清单,手动填模型 id 吧';
|
|
335
|
+
if (/no credential for provider route/i.test(raw) || /API key is blank/i.test(raw)) {
|
|
336
|
+
return '这条渠道在这台电脑上没有可用的 Key,填一次 Key 再获取';
|
|
337
|
+
}
|
|
338
|
+
return `取模型失败${code ? `(${code})` : ''}`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* POST /api/mobile/v1/channels/discover(token){ id?, protocol, baseUrl, apiKey? }
|
|
315
343
|
* → { ok, models: [{id,name,contextWindow?,maxTokens?,vision?}], baseUrl }
|
|
316
344
|
*
|
|
317
345
|
* `baseUrl` 回的是**问通的那个**:`/v1` 回退成功时保存要写它,否则聊天请求还会打到
|
|
318
346
|
* 刚才失败的那个地址上。只读不落盘,用户确认清单之后再调 `/channels/save`。
|
|
347
|
+
*
|
|
348
|
+
* `id` 是编辑中的那条渠道:宿主按路由名就能在「请求没带 Key」时自己去凭据库取原来那把
|
|
349
|
+
* (`discoverModels` 的 `provider`),所以表单里 Key 留空也能取模型。新建时没有 id,
|
|
350
|
+
* 沿用当场填的那把。
|
|
319
351
|
*/
|
|
320
352
|
async function handleChannelDiscover(ctx, req, res) {
|
|
321
353
|
const body = await readJsonBody(req, 64 * 1024);
|
|
@@ -325,6 +357,10 @@ async function handleChannelDiscover(ctx, req, res) {
|
|
|
325
357
|
}
|
|
326
358
|
const base = normalizeBaseUrl(body ? body.baseUrl : undefined);
|
|
327
359
|
if (base.error) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: base.error } });
|
|
360
|
+
const editingId = body && typeof body.id === 'string' ? body.id.trim() : '';
|
|
361
|
+
if (editingId !== '' && !CHANNEL_ID_PATTERN.test(editingId)) {
|
|
362
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '渠道 id 不合法' } });
|
|
363
|
+
}
|
|
328
364
|
const apiKey = body && typeof body.apiKey === 'string' ? body.apiKey.trim() : '';
|
|
329
365
|
if (apiKey.length > MAX_KEY_CHARS) {
|
|
330
366
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'API Key 太长' } });
|
|
@@ -342,6 +378,7 @@ async function handleChannelDiscover(ctx, req, res) {
|
|
|
342
378
|
for (const baseURL of discoverCandidates(base.url, protocol)) {
|
|
343
379
|
try {
|
|
344
380
|
const found = await llm.discoverModels(PI_AI_NS, {
|
|
381
|
+
...(editingId === '' ? {} : { provider: editingId }),
|
|
345
382
|
baseURL,
|
|
346
383
|
api: protocol,
|
|
347
384
|
...(apiKey === '' ? {} : { apiKey }),
|
|
@@ -368,10 +405,16 @@ async function handleChannelDiscover(ctx, req, res) {
|
|
|
368
405
|
lastError = caught;
|
|
369
406
|
}
|
|
370
407
|
}
|
|
371
|
-
//
|
|
408
|
+
// 失败说人话:宿主那几句英文摆到手机的英文过滤后面会变成「电脑那边出了错」,原文留在 details。
|
|
409
|
+
const hostMessage = String((lastError && lastError.message) || lastError).slice(0, 300);
|
|
410
|
+
const hostCode = lastError && typeof lastError.code === 'string' ? lastError.code : '';
|
|
372
411
|
sendJson(res, 422, {
|
|
373
412
|
ok: false,
|
|
374
|
-
error: {
|
|
413
|
+
error: {
|
|
414
|
+
code: 'discover-failed',
|
|
415
|
+
message: discoverFailureMessage(lastError),
|
|
416
|
+
details: { hostMessage, ...(hostCode === '' ? {} : { hostCode }) },
|
|
417
|
+
},
|
|
375
418
|
});
|
|
376
419
|
}
|
|
377
420
|
|
package/lib/mobile/events.js
CHANGED
|
@@ -16,7 +16,7 @@ const { createFrameSealer } = require('./stream.js');
|
|
|
16
16
|
const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
|
|
17
17
|
const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
|
|
18
18
|
const { desktopViewSignal: sharedDesktopViewSignal } = require('../shared/desktop-view-signal.js');
|
|
19
|
-
const { writeSse: sharedWriteSse, stableEventId: sharedEventId } = require('../shared/sse-write.js');
|
|
19
|
+
const { writeSse: sharedWriteSse, stableEventId: sharedEventId, waitForDrain } = require('../shared/sse-write.js');
|
|
20
20
|
|
|
21
21
|
const DEDUP_WINDOW = 2048;
|
|
22
22
|
/** 心跳帧间隔。App 用它判定「这条流真的还活着」,必须与 app 侧的 SIGNAL_HEARTBEAT_MS 一致。 */
|
|
@@ -153,10 +153,35 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
153
153
|
// shed 不是成功:不能继续读,否则这一帧代表的变更永远补不回来。
|
|
154
154
|
if (result.shed) return false;
|
|
155
155
|
framesSent += 1;
|
|
156
|
-
//
|
|
157
|
-
// 由调用方 abort ——
|
|
156
|
+
// 这几处调用点都是同步回调(信号总线、心跳),没法在这里 await;写不动就如实回报,
|
|
157
|
+
// 由调用方 abort —— 这条流本来就是「断了重连、靠补发收敛」的语义。开场那一批补发
|
|
158
|
+
// 不走这里,走下面的 `writeFrameAwaiting`:那批是一口气几十上百帧,必然会撞上写缓冲。
|
|
158
159
|
return !result.needDrain;
|
|
159
160
|
};
|
|
161
|
+
/**
|
|
162
|
+
* 补发快照专用:写一帧,写不动就等缓冲吐出去再接着写。
|
|
163
|
+
*
|
|
164
|
+
* 与 `writeFrame` 只差一件事:`needDrain` 不是失败,是「慢」。开场补发是每台被跟踪的
|
|
165
|
+
* 会话各一帧的水位快照,会话多的机器一上来就超出一条连接的写缓冲(Node 的高水位默认
|
|
166
|
+
* 16KB),把它当失败就等于**每次连上补完就自己掐掉**,手机重连再补一遍,永远建立不起来;
|
|
167
|
+
* 顺带连「积压到软水位才 shed」那道闸都轮不到。会话流(`stream.js`)的 live 循环就是
|
|
168
|
+
* 等 drain 再继续,这里照同一套来。
|
|
169
|
+
*
|
|
170
|
+
* @returns {Promise<boolean>} 这一帧写成功(或等到了 drain)→ true;该收尾 → false。
|
|
171
|
+
*/
|
|
172
|
+
const writeFrameAwaiting = async (devicePayload) => {
|
|
173
|
+
const result = writeSse(res, {
|
|
174
|
+
type: 'device/event',
|
|
175
|
+
version: 1,
|
|
176
|
+
payload: devicePayload,
|
|
177
|
+
}, null, sealFrame);
|
|
178
|
+
if (!result.ok) return false;
|
|
179
|
+
// shed 不是成功:不能继续读,否则这一帧代表的变更永远补不回来。
|
|
180
|
+
if (result.shed) return false;
|
|
181
|
+
framesSent += 1;
|
|
182
|
+
if (result.needDrain && !(await waitForDrain(res, ac.signal))) return false;
|
|
183
|
+
return true;
|
|
184
|
+
};
|
|
160
185
|
/**
|
|
161
186
|
* 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就推给手机。
|
|
162
187
|
*
|
|
@@ -208,26 +233,27 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
208
233
|
* 只读两条信号总线已经广播过的最近值,既不 bump 也不前移基准,所以不会制造新变更;
|
|
209
234
|
* App 对同 revision / 同水位幂等,重连补发不会引发重复刷新。
|
|
210
235
|
*/
|
|
211
|
-
const replayKnownSignals = () => {
|
|
236
|
+
const replayKnownSignals = async () => {
|
|
212
237
|
const list = sharedListSignal.snapshot();
|
|
213
238
|
if (list && list.revision) {
|
|
214
|
-
if (!
|
|
239
|
+
if (!await writeFrameAwaiting({ type: 'device/list-changed', revision: list.revision, at: list.at || Date.now() })) return false;
|
|
215
240
|
}
|
|
216
241
|
// 桌面侧栏折叠态:手机从此只在变化时拉一次。老 App 不认识这一帧(走默认分支忽略),
|
|
217
242
|
// 所以补发它不会改变老客户端的任何行为。
|
|
218
243
|
const view = sharedDesktopViewSignal.snapshot();
|
|
219
244
|
if (view && view.updatedAt > 0) {
|
|
220
|
-
if (!
|
|
245
|
+
if (!await writeFrameAwaiting({ type: 'device/desktop-view-changed', updatedAt: view.updatedAt, at: view.at || Date.now() })) return false;
|
|
221
246
|
}
|
|
222
247
|
for (const [sessionId, seq] of Object.entries(sharedSessionSignal.snapshot())) {
|
|
223
248
|
if (!(seq > 0)) continue;
|
|
224
|
-
if (!
|
|
249
|
+
if (!await writeFrameAwaiting({ type: 'device/session-activity', sessionId, seq, at: Date.now() })) return false;
|
|
225
250
|
}
|
|
226
251
|
return true;
|
|
227
252
|
};
|
|
228
|
-
//
|
|
229
|
-
// 继续跑 mux 循环只会一路失败;abort
|
|
230
|
-
|
|
253
|
+
// 补发失败说明连接真的已经不可写(writeSse 只在 res.write 抛错时返回 false),此时
|
|
254
|
+
// 继续跑 mux 循环只会一路失败;abort 掉让它走正常收尾。写缓冲满了不算失败,见
|
|
255
|
+
// `writeFrameAwaiting`。
|
|
256
|
+
if (!await replayKnownSignals()) ac.abort();
|
|
231
257
|
const keepAlive = setInterval(() => {
|
|
232
258
|
try { res.write(': keepalive\n\n'); } catch { ac.abort(); return; }
|
|
233
259
|
// 心跳必须是**数据帧**:注释帧 App 看不见,而 App 只有「最近真的收到过帧」才敢把
|
|
@@ -273,7 +299,11 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
273
299
|
// Keep the existing device/event JSON envelope unchanged; SSE metadata is additive.
|
|
274
300
|
const sent = writeSse(res, envelope, frame, sealFrame);
|
|
275
301
|
if (sent.shed) { clientGone = true; break; }
|
|
276
|
-
if (!sent.ok
|
|
302
|
+
if (!sent.ok) { clientGone = true; break; }
|
|
303
|
+
// 写不动就等一次 drain 再接着读:这条循环本来就是 async,等一等比断掉重连便宜得多
|
|
304
|
+
// (重连要补发整份快照,手机那边还要重拉一遍清单与正文)。等不到(超时或流被中断)
|
|
305
|
+
// 才收尾。
|
|
306
|
+
if (sent.needDrain && !(await waitForDrain(res, ac.signal))) { clientGone = true; break; }
|
|
277
307
|
framesSent += 1;
|
|
278
308
|
}
|
|
279
309
|
if (!clientGone) loopEnded = true;
|
|
@@ -159,6 +159,33 @@ function hostRequest(ctx, namespace, method, args, signal) {
|
|
|
159
159
|
return request;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* 撤掉网关还挂着的那一发远程转发(桌面窗口那张卡挂在它上面)。
|
|
164
|
+
*
|
|
165
|
+
* 网关按**请求对象本身**登记挂起的转发,所以按 `requestRef` 认回那一发、调它自己的撤回接口:
|
|
166
|
+
* 窗口收到 `cancel` 帧后按 abort 处理,卡跟着撤,不会冒成错误提示。认不到就什么都不做 ——
|
|
167
|
+
* 老宿主没有 `pendingRemoteEvents` / `cancelRemoteEvent`,「桌面那边没卡可收」本来就是对的。
|
|
168
|
+
*
|
|
169
|
+
* 放在这里而不是调用方:调用方(提问桥)只知道「这一问的那一发撤掉」,两个成员名与迭代方式
|
|
170
|
+
* 跟 invoke / stream 一样属于传输层 —— 别在别处再摸一次 `typertGateway`。
|
|
171
|
+
*
|
|
172
|
+
* @returns {number} 真的撤掉了几发
|
|
173
|
+
*/
|
|
174
|
+
function cancelForwardedRequests(ctx, requestRef, reason) {
|
|
175
|
+
const gateway = gatewayOf(ctx);
|
|
176
|
+
const pending = gateway && gateway.pendingRemoteEvents;
|
|
177
|
+
if (!pending || typeof pending.values !== 'function' || typeof gateway.cancelRemoteEvent !== 'function') return 0;
|
|
178
|
+
let cancelled = 0;
|
|
179
|
+
for (const item of [...pending.values()]) {
|
|
180
|
+
if (!item || !item.source || item.source.request !== requestRef) continue;
|
|
181
|
+
try {
|
|
182
|
+
gateway.cancelRemoteEvent(item, reason);
|
|
183
|
+
cancelled += 1;
|
|
184
|
+
} catch { /* 那一发已经走了(别人先收掉):没什么可撤的 */ }
|
|
185
|
+
}
|
|
186
|
+
return cancelled;
|
|
187
|
+
}
|
|
188
|
+
|
|
162
189
|
async function invoke(ctx, namespace, method, args, signal) {
|
|
163
190
|
const gateway = gatewayOf(ctx);
|
|
164
191
|
if (!gateway) return { error: { ...UNAVAILABLE } };
|
|
@@ -222,4 +249,4 @@ async function invokeEnvelope(ctx, namespace, method, args, signal) {
|
|
|
222
249
|
return ok(result.value);
|
|
223
250
|
}
|
|
224
251
|
|
|
225
|
-
module.exports = { GATEWAY_MISSING, UNAVAILABLE, unsupported, deadline, gatewayOf, warn, pick, ok, errorOf, fail, requestId, dropUnexpectedArgs, invoke, openStream, invokeEnvelope, SYNTHETIC_DEADLINE_MS };
|
|
252
|
+
module.exports = { GATEWAY_MISSING, UNAVAILABLE, unsupported, deadline, gatewayOf, warn, pick, ok, errorOf, fail, requestId, dropUnexpectedArgs, invoke, cancelForwardedRequests, openStream, invokeEnvelope, SYNTHETIC_DEADLINE_MS };
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
15
|
+
const { unchangedListBody, revisionOf, stableRevision } = require('../shared/list-revision.js');
|
|
15
16
|
const { unwrap } = require('./sessions.js');
|
|
16
17
|
|
|
17
18
|
const HOST_SETTINGS_MAX_NAMESPACES = 60; // 命名空间数量上限(避免异常宿主把响应撑爆)
|
|
@@ -245,7 +246,12 @@ async function handleHostSettings(ctx, req, res) {
|
|
|
245
246
|
});
|
|
246
247
|
}
|
|
247
248
|
|
|
248
|
-
/**
|
|
249
|
+
/**
|
|
250
|
+
* GET /api/mobile/v1/llm/providers —— 宿主的模型提供方名单(只读)。
|
|
251
|
+
*
|
|
252
|
+
* 带 `?rev=` 时按内容指纹走条件响应:名单没变就只回一句 `unchanged`(几十字节),
|
|
253
|
+
* 不再把整份重传一遍。手机那边把它当设备级目录缓存,进页面先摆上次那份、再拿指纹问一次。
|
|
254
|
+
*/
|
|
249
255
|
async function handleLlmProviders(ctx, req, res) {
|
|
250
256
|
const { value, error } = unwrap(await ctx.apiProxy.llm.providers({
|
|
251
257
|
rpcId: 'mobile',
|
|
@@ -257,7 +263,8 @@ async function handleLlmProviders(ctx, req, res) {
|
|
|
257
263
|
const list = Array.isArray(value && value.providers) ? value.providers : [];
|
|
258
264
|
const providers = list.map(normalizeProvider).filter(Boolean);
|
|
259
265
|
|
|
260
|
-
|
|
266
|
+
const body = { ok: true, providers, revision: stableRevision(providers) };
|
|
267
|
+
sendJson(res, 200, unchangedListBody(body, revisionOf(req)) || body);
|
|
261
268
|
}
|
|
262
269
|
|
|
263
270
|
/**
|
|
@@ -303,6 +310,9 @@ function normalizeModelGroup(group) {
|
|
|
303
310
|
*
|
|
304
311
|
* 与 `/llm/providers` 的区别:那条只说「有哪些提供方 / 哪个在生效」,这条给出每个提供方
|
|
305
312
|
* 下面真正可请求的模型 id。手机上没有活动会话时(比如从 Git 页打开选择器)也能拿到。
|
|
313
|
+
*
|
|
314
|
+
* 这一份是全仓最大的一份目录响应(每个提供方下面几十上百条模型),而它只在用户加渠道、
|
|
315
|
+
* 换模型时变,所以同样吃 `?rev=` 的条件响应:没变时几十字节说清,别整份重传。
|
|
306
316
|
*/
|
|
307
317
|
async function handleLlmModels(ctx, req, res) {
|
|
308
318
|
const { value, error } = unwrap(await ctx.apiProxy.llm.models({
|
|
@@ -314,7 +324,8 @@ async function handleLlmModels(ctx, req, res) {
|
|
|
314
324
|
|
|
315
325
|
const list = Array.isArray(value && value.groups) ? value.groups : [];
|
|
316
326
|
const groups = list.map(normalizeModelGroup).filter(Boolean).slice(0, HOST_SETTINGS_MAX_NAMESPACES);
|
|
317
|
-
|
|
327
|
+
const body = { ok: true, groups, revision: stableRevision(groups) };
|
|
328
|
+
sendJson(res, 200, unchangedListBody(body, revisionOf(req)) || body);
|
|
318
329
|
}
|
|
319
330
|
|
|
320
331
|
const HOST_SETTINGS_MAX_OPS = 40; // 一次写入的 op 数量上限
|
|
@@ -133,6 +133,56 @@ function createTextImageBudget(limit = MAX_TEXT_IMAGES) {
|
|
|
133
133
|
return { remaining: limit };
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* JPEG 的 EXIF 朝向(0x0112),没有就返回 0。
|
|
138
|
+
*
|
|
139
|
+
* 手机相机竖着拍出来的照片,像素本身是横的,靠 EXIF 里的朝向告诉播放器「先转 90°」。
|
|
140
|
+
* 宽高只按像素读的话,正文里就会出现「横盒子装竖图」:App 按 `resizeMode="contain"` 画,
|
|
141
|
+
* 图缩成中间一条、两侧一大片空白(2026-10-31 平板实测)。所以 5–8 这四档要对调宽高。
|
|
142
|
+
*
|
|
143
|
+
* 只认 JPEG:PNG / WebP 里带 eXIf 的极少,不值得为它多扫一遍。
|
|
144
|
+
*/
|
|
145
|
+
function jpegOrientation(buf) {
|
|
146
|
+
let at = 2;
|
|
147
|
+
while (at + 4 < buf.length) {
|
|
148
|
+
if (buf[at] !== 0xff) { at += 1; continue; }
|
|
149
|
+
const marker = buf[at + 1];
|
|
150
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { at += 2; continue; }
|
|
151
|
+
// 到扫描数据了,后面不会再有 APP 段。
|
|
152
|
+
if (marker === 0xda) return 0;
|
|
153
|
+
const length = buf.readUInt16BE(at + 2);
|
|
154
|
+
if (length < 2) return 0;
|
|
155
|
+
if (marker === 0xe1 && at + 10 <= buf.length && buf.toString('latin1', at + 4, at + 10) === 'Exif\0\0') {
|
|
156
|
+
return tiffOrientation(buf, at + 10);
|
|
157
|
+
}
|
|
158
|
+
at += 2 + length;
|
|
159
|
+
}
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** EXIF 里的 TIFF 头 → 朝向值;字节序按 `II` / `MM` 判,读不到返回 0。 */
|
|
164
|
+
function tiffOrientation(buf, base) {
|
|
165
|
+
if (base + 8 > buf.length) return 0;
|
|
166
|
+
const order = buf.toString('latin1', base, base + 2);
|
|
167
|
+
if (order !== 'II' && order !== 'MM') return 0;
|
|
168
|
+
const little = order === 'II';
|
|
169
|
+
const u16 = (at) => (little ? buf.readUInt16LE(at) : buf.readUInt16BE(at));
|
|
170
|
+
const u32 = (at) => (little ? buf.readUInt32LE(at) : buf.readUInt32BE(at));
|
|
171
|
+
if (u16(base + 2) !== 42) return 0;
|
|
172
|
+
const ifd = base + u32(base + 4);
|
|
173
|
+
if (ifd + 2 > buf.length) return 0;
|
|
174
|
+
const count = u16(ifd);
|
|
175
|
+
for (let i = 0; i < count; i += 1) {
|
|
176
|
+
const entry = ifd + 2 + i * 12;
|
|
177
|
+
if (entry + 12 > buf.length) return 0;
|
|
178
|
+
if (u16(entry) === 0x0112) {
|
|
179
|
+
const value = u16(entry + 8);
|
|
180
|
+
return value >= 1 && value <= 8 ? value : 0;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
136
186
|
/** 从文件头解析宽高;认不出来返回 null(不是错误:App 那边照旧现量)。 */
|
|
137
187
|
function imageSizeOf(buf) {
|
|
138
188
|
if (buf.length >= 24 && buf[0] === 0x89 && buf.toString('latin1', 1, 4) === 'PNG') {
|
|
@@ -152,6 +202,10 @@ function imageSizeOf(buf) {
|
|
|
152
202
|
return null;
|
|
153
203
|
}
|
|
154
204
|
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
|
|
205
|
+
// 竖拍照片的像素本身是横的,朝向 5–8 时播放器会转 90° 再画 —— 宽高必须跟着对调,
|
|
206
|
+
// 否则正文那个盒子是横的、图是竖的(见 jpegOrientation)。
|
|
207
|
+
const orientation = jpegOrientation(buf);
|
|
208
|
+
const swap = orientation >= 5 && orientation <= 8;
|
|
155
209
|
let at = 2;
|
|
156
210
|
while (at + 9 < buf.length) {
|
|
157
211
|
if (buf[at] !== 0xff) { at += 1; continue; }
|
|
@@ -159,7 +213,9 @@ function imageSizeOf(buf) {
|
|
|
159
213
|
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { at += 2; continue; }
|
|
160
214
|
const length = buf.readUInt16BE(at + 2);
|
|
161
215
|
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
162
|
-
|
|
216
|
+
const width = buf.readUInt16BE(at + 7);
|
|
217
|
+
const height = buf.readUInt16BE(at + 5);
|
|
218
|
+
return swap ? { width: height, height: width } : { width, height };
|
|
163
219
|
}
|
|
164
220
|
if (length < 2) return null;
|
|
165
221
|
at += 2 + length;
|
package/lib/mobile/plugins.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const { sendJson } = require('../shared/http-util');
|
|
17
|
+
const { unchangedListBody, revisionOf, stableRevision } = require('../shared/list-revision.js');
|
|
17
18
|
const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
|
|
18
19
|
|
|
19
20
|
const PLUGIN_ID_MAX_CHARS = 200; // entryId 长度上限
|
|
@@ -96,7 +97,10 @@ function statusOf(error) {
|
|
|
96
97
|
|
|
97
98
|
/**
|
|
98
99
|
* GET /api/mobile/v1/plugins
|
|
99
|
-
* 成功:{ ok:true, items:[{ entryId, moduleName, enabled, fiberPhase }] }
|
|
100
|
+
* 成功:{ ok:true, items:[{ entryId, moduleName, enabled, fiberPhase }], revision }
|
|
101
|
+
*
|
|
102
|
+
* 带 `?rev=` 时走条件响应:清单没变就只回一句 `unchanged`。插件清单只在装 / 卸 / 启停 /
|
|
103
|
+
* 重启之后变,而手机每进一次「插件清单」页都要读它一遍。
|
|
100
104
|
*/
|
|
101
105
|
async function handlePlugins(ctx, req, res) {
|
|
102
106
|
const result = await callInventory(ctx);
|
|
@@ -109,7 +113,8 @@ async function handlePlugins(ctx, req, res) {
|
|
|
109
113
|
items.push(entry);
|
|
110
114
|
if (items.length >= PLUGIN_MAX_ITEMS) break;
|
|
111
115
|
}
|
|
112
|
-
|
|
116
|
+
const body = { ok: true, items, revision: stableRevision(items) };
|
|
117
|
+
sendJson(res, 200, unchangedListBody(body, revisionOf(req)) || body);
|
|
113
118
|
}
|
|
114
119
|
|
|
115
120
|
module.exports = {
|
package/lib/mobile/questions.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const { randomUUID } = require('node:crypto');
|
|
18
18
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
19
|
+
const { cancelForwardedRequests } = require('./host-bridge/transport.js');
|
|
19
20
|
|
|
20
21
|
/** sessionId → Set<sink>:正在看这个会话的手机连接(每条 SSE 一个 sink)。 */
|
|
21
22
|
const watchers = new Map();
|
|
@@ -208,6 +209,8 @@ function normalizeAnswers(questions, answers) {
|
|
|
208
209
|
/** 绑上钩子请求:从此这条挂起项「答得动」,并接上宿主的取消信号。 */
|
|
209
210
|
function bindRequest(entry, request) {
|
|
210
211
|
entry.bound = true;
|
|
212
|
+
// 留一个身份引用:手机答完之后要按它找回宿主转发给桌面窗口的那一发(见 cancelForwardedCards)。
|
|
213
|
+
entry.requestRef = request;
|
|
211
214
|
pushCard(entry);
|
|
212
215
|
const signal = request.signal;
|
|
213
216
|
if (!signal || typeof signal.addEventListener !== 'function') return;
|
|
@@ -233,10 +236,10 @@ function noAnswerer(error) {
|
|
|
233
236
|
* 挂起这条请求直到有人回答:桌面的转发与手机的卡**同时进行**,谁先答就用谁的答案。
|
|
234
237
|
*
|
|
235
238
|
* 桌面先答:下游给出答案,结算挂起项(手机那张卡由结算帧收掉)。
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
239
|
+
* 手机先答:把手机的回答交回钩子,桌面那张卡由 `cancelForwardedCards` 主动收掉 —— 宿主
|
|
240
|
+
* 只在「限时提问」里给这条转发做了收尾,本桥跑的这版宿主是阻塞式提问,没人替桌面窗口
|
|
241
|
+
* 撤回那一发,卡会一直悬着直到人手动点。那一发被撤时 `downstream` 会再落一次;上面的
|
|
242
|
+
* race 已经挂着它的处理函数,迟到的那次不会冒成未处理异常,`settle` 本身也是幂等的。
|
|
240
243
|
*
|
|
241
244
|
* `NO_PROVIDER`(桌面没开 / 桌面把请求 delegate 回来)不是结论:手机正在看这个会话时
|
|
242
245
|
* 卡还在手机上等着,继续等手机答;没人看时照旧把失败交回去,纯桌面表现一字不改。
|
|
@@ -260,6 +263,22 @@ function holdForPhone(entry, next) {
|
|
|
260
263
|
return Promise.race([fromPhone, downstream]);
|
|
261
264
|
}
|
|
262
265
|
|
|
266
|
+
/**
|
|
267
|
+
* 收掉桌面那张卡:把宿主已经发给桌面窗口、还挂着的那一发转发撤掉。
|
|
268
|
+
*
|
|
269
|
+
* 桌面那张卡不是本桥画的:宿主把提问转发给桌面窗口之后,窗口自己按那一发长出一张卡,
|
|
270
|
+
* 只有三种情况会让它消失 —— 桌面自己答、这一轮被取消、宿主主动撤回那一发。「两边同时
|
|
271
|
+
* 出卡」靠的就是转发,而宿主只在「限时提问」路径上做了落定后的撤回;这版宿主的
|
|
272
|
+
* `ask_user_question` 跑的是阻塞式,手机先答之后没人去收,卡就一直悬着。
|
|
273
|
+
*
|
|
274
|
+
* 认哪一发、怎么撤都由传输层做(网关按请求对象本身登记这些挂起的转发):这里只把「这一问
|
|
275
|
+
* 的请求对象」交出去。认不到(老宿主没这道网关)就什么都不做 —— 「桌面没卡可收」本来就是对的。
|
|
276
|
+
*/
|
|
277
|
+
function cancelForwardedCards(ctx, entry) {
|
|
278
|
+
if (!entry.requestRef) return;
|
|
279
|
+
cancelForwardedRequests(ctx, entry.requestRef, new Error('手机已经回答了这条提问'));
|
|
280
|
+
}
|
|
281
|
+
|
|
263
282
|
function onQuestionRequest(request, next) {
|
|
264
283
|
const sessionId = sessionIdOfRequest(request);
|
|
265
284
|
if (!sessionId) return next();
|
|
@@ -301,6 +320,7 @@ async function handleQuestionRespond(ctx, req, res) {
|
|
|
301
320
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'answer.answers 必须每题恰好一条、含合法 selected' } });
|
|
302
321
|
}
|
|
303
322
|
settle(entry, answers);
|
|
323
|
+
cancelForwardedCards(ctx, entry);
|
|
304
324
|
return sendJson(res, 200, { ok: true, accepted: true });
|
|
305
325
|
}
|
|
306
326
|
const receipt = await respondViaLegacyProxy(ctx, {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const { sendJson } = require('../shared/http-util');
|
|
12
|
+
const { unchangedListBody, revisionOf, stableRevision } = require('../shared/list-revision.js');
|
|
12
13
|
const { unwrap } = require('./envelope.js');
|
|
13
14
|
const { callAgentPresets } = require('./rpc.js');
|
|
14
15
|
|
|
@@ -39,14 +40,17 @@ async function handleAgentPresets(ctx, req, res) {
|
|
|
39
40
|
...(item.broken === true ? { broken: true } : {}),
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
+
const body = {
|
|
43
44
|
ok: true,
|
|
44
45
|
presets,
|
|
45
46
|
// 与条目内的 isDefault / broken 同口径:宿主只该给布尔,非布尔一律当 false,
|
|
46
47
|
// 不让 'yes' / 1 这种值被 App 当成「可以自己写预设」。
|
|
47
48
|
authorable: value != null && value.authorable === true,
|
|
48
49
|
hasDocument: value != null && value.hasDocument === true,
|
|
49
|
-
}
|
|
50
|
+
};
|
|
51
|
+
// 指纹只看内容:`authorable` / `hasDocument` 变了也算变,它们是同一页上的两格状态。
|
|
52
|
+
const withRevision = { ...body, revision: stableRevision(body) };
|
|
53
|
+
sendJson(res, 200, unchangedListBody(withRevision, revisionOf(req)) || withRevision);
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
module.exports = { handleAgentPresets };
|
|
@@ -28,6 +28,7 @@ const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
|
28
28
|
const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
|
|
29
29
|
const { recordSessionRead } = require('./session-last-turn.js');
|
|
30
30
|
const { imageSizeOf } = require('./local-images.js');
|
|
31
|
+
const attachmentStore = require('./attachment-store.js');
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* `/send` 的请求体上限(**明文**字节,图片 base64)。
|
|
@@ -281,6 +282,10 @@ async function handleSessionAttachment(ctx, req, res) {
|
|
|
281
282
|
|| typeof body.attachmentId !== 'string' || !body.attachmentId) {
|
|
282
283
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, attachmentId }' } });
|
|
283
284
|
}
|
|
285
|
+
// 直读宿主附件库(2026-10-31):登记过的 id 直接从本地盘流给手机,省掉「等宿主把整张
|
|
286
|
+
// 读成 base64 再返回」那一段(宿主主线程忙时手机会一个字节都收不到)。前提与回退见
|
|
287
|
+
// `attachment-store.js` 的 `streamIfRemembered`:不满足就往下走原来的宿主接口。
|
|
288
|
+
if (body.raw === true && await attachmentStore.streamIfRemembered(req, res, body.sessionId, body.attachmentId)) return;
|
|
284
289
|
const { value, error } = unwrap(await callSessions(ctx, 'attachment', { sessionId: body.sessionId, attachmentId: body.attachmentId }));
|
|
285
290
|
if (error) return sendJson(res, 400, { ok: false, error });
|
|
286
291
|
const data = value && typeof value.data === 'string' ? value.data : '';
|
package/lib/mobile/stream.js
CHANGED
|
@@ -29,6 +29,7 @@ const {
|
|
|
29
29
|
waitForDrain,
|
|
30
30
|
ssePadding,
|
|
31
31
|
shedWithoutSeq,
|
|
32
|
+
flushFrameBatch,
|
|
32
33
|
} = require('../shared/sse-write.js');
|
|
33
34
|
const { resolveEventAttachments } = require('./sessions.js');
|
|
34
35
|
const { createTextImageBudget } = require('./local-images.js');
|
|
@@ -197,6 +198,7 @@ function writeSse(res, payload, frame, options = {}) {
|
|
|
197
198
|
// 协议 v2:每帧一条独立密文(§8.3(5))。封装器由开流那一处给 —— 它才知道这条流的
|
|
198
199
|
// 请求 ts / nonce 与帧序号(AAD 要绑这三样)。
|
|
199
200
|
sealFrame: typeof options.sealFrame === 'function' ? options.sealFrame : null,
|
|
201
|
+
batch: res.__dshFrameBatch === true,
|
|
200
202
|
});
|
|
201
203
|
}
|
|
202
204
|
|
|
@@ -392,6 +394,9 @@ async function handleStream(ctx, req, res) {
|
|
|
392
394
|
if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
|
|
393
395
|
// 子会话地址:App 打开子会话的流时带上父会话与模式(见 app/src/sse.ts 的
|
|
394
396
|
// openSessionStream 传参)。缺这两个字段就是普通会话,与历史那条同一套判定。
|
|
397
|
+
// 合帧:只有 App 在流地址上带 `frameBatch=1` 才启用(老 App 不带,行为一字不变)。
|
|
398
|
+
// 合帧把一串 `assistant/chunk` 并成一帧,省掉逐帧的加密、写套接字与客户端逐帧解析。
|
|
399
|
+
const frameBatch = url.searchParams.get('frameBatch') === '1';
|
|
395
400
|
const parentSessionId = url.searchParams.get('parentSessionId');
|
|
396
401
|
const mode = url.searchParams.get('mode');
|
|
397
402
|
const streamAddress = typeof parentSessionId === 'string' && parentSessionId
|
|
@@ -401,6 +406,8 @@ async function handleStream(ctx, req, res) {
|
|
|
401
406
|
// **要在 writeHead 之前算**:`X-Dsh-Proto: 2` 只能在头发出去之前声明,写过头再
|
|
402
407
|
// `setHeader` 是无效的(Node 会静默忽略),客户端就永远不知道帧是信封。
|
|
403
408
|
const sealFrame = createFrameSealer(req);
|
|
409
|
+
// 这条连接要不要合帧:`writeSse` 是文件级函数,挂在响应对象上比穿一串参数稳。
|
|
410
|
+
if (frameBatch) res.__dshFrameBatch = true;
|
|
404
411
|
const head = {
|
|
405
412
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
406
413
|
'cache-control': 'no-cache',
|
|
@@ -628,6 +635,9 @@ async function handleStream(ctx, req, res) {
|
|
|
628
635
|
// 明确告诉客户端「流到此为止,请带 lastSeq 重连」:它会走已验证可用的 replay
|
|
629
636
|
// 路径补上缺口,而不是把静默断流当成正常状态。
|
|
630
637
|
if (!ac.signal.aborted && !clientGone) {
|
|
638
|
+
// 收尾帧之前先把攒着的增量放出去:resync-required 自己会触发 flush,这一行是为了
|
|
639
|
+
// 客户端已经走了那条分支不至于把最后几帧留在缓冲里。
|
|
640
|
+
flushFrameBatch(res);
|
|
631
641
|
writeSse(res, { type: 'session/resync-required', sessionId, oldestSeq: lastDeliveredSeq + 1, final: true }, null, { sealFrame });
|
|
632
642
|
}
|
|
633
643
|
res.end();
|
|
@@ -58,4 +58,22 @@ function unchangedListBody(body, rev) {
|
|
|
58
58
|
return { ok: true, unchanged: true, revision: body.revision, generatedAt: body.generatedAt };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/**
|
|
62
|
+
* 请求串里的 `?rev=`(条件刷新的客户端指纹);没有、为空或解析不出来都返回空串。
|
|
63
|
+
*
|
|
64
|
+
* 空串在 `unchangedListBody` 那里是「照常回整份」,所以老 App 不带这个参数时行为不变。
|
|
65
|
+
* 路径匹配发生在宿主那一侧、早就忽略了查询串,这里只负责取值。
|
|
66
|
+
*
|
|
67
|
+
* @param {{ url?: string }} req
|
|
68
|
+
*/
|
|
69
|
+
function revisionOf(req) {
|
|
70
|
+
const raw = req && typeof req.url === 'string' ? req.url : '';
|
|
71
|
+
if (!raw) return '';
|
|
72
|
+
try {
|
|
73
|
+
return (new URL(raw, 'http://x').searchParams.get('rev') || '').trim();
|
|
74
|
+
} catch {
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { stableRevision, listRevision, unchangedListBody, revisionOf };
|
package/lib/shared/sse-write.js
CHANGED
|
@@ -136,6 +136,53 @@ function takePadding(res, written, padBytes, padEveryMs) {
|
|
|
136
136
|
return padding;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* 合帧:把短时间内到达的多条草稿增量并成一帧发。
|
|
141
|
+
*
|
|
142
|
+
* 为什么要它:流式回复一秒几十条 `assistant/chunk`,一帧一条就是几十次「加密 + 写套接字 +
|
|
143
|
+
* 客户端逐帧解密与解析」。并成一帧之后这几步按批走,客户端那边只需要认出 `type:'batch'`。
|
|
144
|
+
*
|
|
145
|
+
* 只有客户端在流地址上带 `frameBatch=1` 时才启用(老 App 行为一个字不变)。只并
|
|
146
|
+
* `assistant/chunk` —— 心跳、审批、排队/任务、`resync-required` 这些帧没有「晚 40ms 无所谓」
|
|
147
|
+
* 的余地,它们必须立刻发,且发之前先把已攒的增量按原顺序放出去。
|
|
148
|
+
*/
|
|
149
|
+
const BATCH_WINDOW_MS = 40;
|
|
150
|
+
const BATCH_MAX_EVENTS = 8;
|
|
151
|
+
const BATCHABLE_TYPES = new Set(['assistant/chunk']);
|
|
152
|
+
/** 一条连接一份攒批状态;连接收掉后随 res 一起被回收。 */
|
|
153
|
+
const BATCH_STATES = new WeakMap();
|
|
154
|
+
|
|
155
|
+
/** 这一帧能不能进合帧(看内层事件类型;回放路径可能带一层 `session/event` 外壳)。 */
|
|
156
|
+
function isBatchablePayload(payload) {
|
|
157
|
+
if (!payload || typeof payload !== 'object') return false;
|
|
158
|
+
const inner = payload.type === 'session/event' && payload.event ? payload.event : payload;
|
|
159
|
+
return BATCHABLE_TYPES.has(inner.type);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function batchStateOf(res) {
|
|
163
|
+
let state = BATCH_STATES.get(res);
|
|
164
|
+
if (!state) {
|
|
165
|
+
state = { items: [], timer: null, options: null };
|
|
166
|
+
BATCH_STATES.set(res, state);
|
|
167
|
+
}
|
|
168
|
+
return state;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 把攒下的增量立刻发成一条 `batch` 帧(幂等:没东西就直接成功)。 */
|
|
172
|
+
function flushFrameBatch(res) {
|
|
173
|
+
const state = BATCH_STATES.get(res);
|
|
174
|
+
if (!state) return { ok: true };
|
|
175
|
+
if (state.timer) {
|
|
176
|
+
clearTimeout(state.timer);
|
|
177
|
+
state.timer = null;
|
|
178
|
+
}
|
|
179
|
+
if (state.items.length === 0) return { ok: true };
|
|
180
|
+
const events = state.items.splice(0);
|
|
181
|
+
const options = state.options || {};
|
|
182
|
+
// 合帧只做一次:`{batch:false}` 保证不会再被攒回去。
|
|
183
|
+
return writeSse(res, { type: 'batch', events }, { ...options, batch: false });
|
|
184
|
+
}
|
|
185
|
+
|
|
139
186
|
/**
|
|
140
187
|
* 写一条 SSE 记录。
|
|
141
188
|
*
|
|
@@ -170,7 +217,29 @@ function writeSse(res, payload, options = {}) {
|
|
|
170
217
|
* 字段,客户端要靠 `id:` 回填 `Last-Event-ID`,而 `id:` 里的 sessionId 本来就在 URL 上。
|
|
171
218
|
*/
|
|
172
219
|
sealFrame = null,
|
|
220
|
+
/**
|
|
221
|
+
* 这条连接要不要合帧(客户端在流地址上带 `frameBatch=1` 才给真)。
|
|
222
|
+
* 见 `flushFrameBatch` / `BATCHABLE_TYPES`。
|
|
223
|
+
*/
|
|
224
|
+
batch = false,
|
|
173
225
|
} = options;
|
|
226
|
+
if (batch) {
|
|
227
|
+
const state = batchStateOf(res);
|
|
228
|
+
state.options = options;
|
|
229
|
+
if (!isBatchablePayload(payload)) {
|
|
230
|
+
// 控制帧 / 心跳 / 收尾:先把攒着的增量按原顺序发完,自己再立刻发。
|
|
231
|
+
const flushed = flushFrameBatch(res);
|
|
232
|
+
if (!flushed.ok) return flushed;
|
|
233
|
+
} else {
|
|
234
|
+
state.items.push(payload);
|
|
235
|
+
if (state.items.length >= BATCH_MAX_EVENTS) return flushFrameBatch(res);
|
|
236
|
+
if (!state.timer) {
|
|
237
|
+
state.timer = setTimeout(() => { state.timer = null; flushFrameBatch(res); }, BATCH_WINDOW_MS);
|
|
238
|
+
if (typeof state.timer.unref === 'function') state.timer.unref();
|
|
239
|
+
}
|
|
240
|
+
return { ok: true, deferred: true };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
174
243
|
const buffered = bufferedBytesOf(res);
|
|
175
244
|
if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
|
|
176
245
|
if (buffered > SSE_SHED_BUFFERED_BYTES && shedWhenBuffered(payload)) return { ok: true, shed: true }; const id = stableEventId(payload, frame, namespace);
|
|
@@ -229,6 +298,10 @@ module.exports = {
|
|
|
229
298
|
bufferedBytesOf,
|
|
230
299
|
stableEventId,
|
|
231
300
|
writeSse,
|
|
301
|
+
flushFrameBatch,
|
|
302
|
+
isBatchablePayload,
|
|
303
|
+
BATCH_WINDOW_MS,
|
|
304
|
+
BATCH_MAX_EVENTS,
|
|
232
305
|
waitForDrain,
|
|
233
306
|
shedWithoutSeq,
|
|
234
307
|
};
|
package/lib/update-install.cjs
CHANGED
|
@@ -8,10 +8,17 @@
|
|
|
8
8
|
*
|
|
9
9
|
* 不自己实现包管理,只驱动它:profile 的 pnpm-workspace.yaml 已经写好 node-linker、peer 等
|
|
10
10
|
* 关键设置,这里只「cd 到 profile 跑一句 pnpm add <包名>@<版本>」,不传布局类开关。必要的
|
|
11
|
-
*
|
|
11
|
+
* 开关有三层:auto-install-peers=false(peer 是运行时注入的,npm 上没有)、minimum-release-age=0
|
|
12
12
|
* (一检测到新版就立刻装,不等「新发布先晾一会儿」;那道策略查的是整份
|
|
13
|
-
* lockfile
|
|
14
|
-
* pnpm
|
|
13
|
+
* lockfile,别的插件刚发也会连累我们)、ignore-workspace-root-check=true(profile 自己带
|
|
14
|
+
* `pnpm-workspace.yaml`,在 pnpm 眼里就是 workspace 根;pnpm 10.4 及以前会拿
|
|
15
|
+
* `ERR_PNPM_ADDING_TO_ROOT` 把这一句 add 直接拒掉,机械地退回 PATH 上那个老 pnpm 的机器全中),
|
|
16
|
+
* 再加上 CI=1(无终端)。命令行带一份、环境变量再带一份:
|
|
17
|
+
* pnpm 12 的原生 CLI 会静默忽略这几个 --config 参数,环境变量那一份还认。
|
|
18
|
+
*
|
|
19
|
+
* 失败要**说出原因**:`failure.message` 是手机、面板与日志唯一显示的那句话,曾经只写「跑过的
|
|
20
|
+
* 命令 + 退出码」—— 排错的人拿着一张只有命令的截图,包管理器自己的报错(ERR_PNPM_* 那一行)
|
|
21
|
+
* 被丢在了 `output` 里没人看。现在报错正文跟着附上。
|
|
15
22
|
*
|
|
16
23
|
* 装完要**登记**:这个版本是被我们绕过晾置期装进去的,不写进 profile 的 minimumReleaseAgeExclude,
|
|
17
24
|
* 它就会留在 lockfile 里,之后 pnpm 校验整份 lockfile 时把这台机器上所有安装都拒掉
|
|
@@ -102,7 +109,7 @@ function spawnEnv(dirs, env = process.env) {
|
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
/**
|
|
105
|
-
* 每次调 pnpm
|
|
112
|
+
* 每次调 pnpm 都带上的那几格开关,**环境变量这一份不是备份,是另一半**。
|
|
106
113
|
*
|
|
107
114
|
* pnpm 11 两种写法都认;pnpm 12 的原生 CLI 会静默忽略部分 `--config.*`(市场在 #600 上实测
|
|
108
115
|
* 过,一次装失败一次白等),但同一个键走 `PNPM_CONFIG_*` 它照读。命令行与环境变量各带一份,
|
|
@@ -111,6 +118,7 @@ function spawnEnv(dirs, env = process.env) {
|
|
|
111
118
|
const POLICY_ENV = {
|
|
112
119
|
PNPM_CONFIG_AUTO_INSTALL_PEERS: 'false',
|
|
113
120
|
PNPM_CONFIG_MINIMUM_RELEASE_AGE: '0',
|
|
121
|
+
PNPM_CONFIG_IGNORE_WORKSPACE_ROOT_CHECK: 'true',
|
|
114
122
|
};
|
|
115
123
|
|
|
116
124
|
/** 「配置里这个包用的是哪个注册表」缓存多久:查更新两分钟一轮,源不常换。 */
|
|
@@ -428,6 +436,26 @@ function runningAgentIds(agents) {
|
|
|
428
436
|
return ids;
|
|
429
437
|
}
|
|
430
438
|
|
|
439
|
+
/**
|
|
440
|
+
* 给一句失败结论附上包管理器自己说的原因。
|
|
441
|
+
*
|
|
442
|
+
* `failure.message` 是手机、面板与日志**唯一**会显示的那句话,所以它必须自带原因:只写
|
|
443
|
+
* 「跑过的命令 + 退出码」时,排错的人拿着一张只有命令的截图,一点办法都没有。pnpm 的诊断
|
|
444
|
+
* 都在 `ERR_PNPM_*` / `ERROR` 那几行里,优先挑它;一行都没有(被 kill、旧版 pnpm 换了措辞)
|
|
445
|
+
* 就退回最后一行有内容的输出 —— 进度条那种噪声先滤掉。挑不满就不硬凑,也别把整份输出塞进
|
|
446
|
+
* 一句话里:完整的那份一直留在 `output` 里。
|
|
447
|
+
*/
|
|
448
|
+
function withReason(message, output) {
|
|
449
|
+
const lines = String(output || '')
|
|
450
|
+
.split('\n')
|
|
451
|
+
.map(line => line.trimEnd())
|
|
452
|
+
.filter(line => line.trim() !== '' && !/^Progress:/.test(line.trim()));
|
|
453
|
+
if (lines.length === 0) return message;
|
|
454
|
+
const marked = lines.filter(line => /ERR_PNPM|ERROR/.test(line));
|
|
455
|
+
const reason = (marked.length > 0 ? marked : lines.slice(-1)).slice(0, 3).join('\n').slice(0, 600);
|
|
456
|
+
return reason === '' ? message : `${message}\n${reason}`;
|
|
457
|
+
}
|
|
458
|
+
|
|
431
459
|
/** 跑一条命令,收 stdout+stderr 的尾巴。超时或非零退出码都如实带出来。 */
|
|
432
460
|
function run(command, args, { cwd, env, timeoutMs, spawnImpl = spawn }) {
|
|
433
461
|
return new Promise(resolve => {
|
|
@@ -558,7 +586,7 @@ async function installLatest({
|
|
|
558
586
|
const spec = `${name}@${version}`;
|
|
559
587
|
// 一律带上:自动更新是「一检测到就装」,手动点的那一下同样是「现在就要」,没理由先让这道
|
|
560
588
|
// 策略挡一次、再靠失败重试绕过来。命令行与环境变量各带一份,pnpm 12 只认后者(见 POLICY_ENV)。
|
|
561
|
-
const policyArgs = ['--config.auto-install-peers=false', '--config.minimum-release-age=0'];
|
|
589
|
+
const policyArgs = ['--config.auto-install-peers=false', '--config.minimum-release-age=0', '--config.ignore-workspace-root-check=true'];
|
|
562
590
|
const args = picked.manager === 'pnpm'
|
|
563
591
|
? ['add', spec, ...policyArgs]
|
|
564
592
|
: ['install', spec, '--no-audit', '--no-fund'];
|
|
@@ -622,7 +650,10 @@ async function installLatest({
|
|
|
622
650
|
// 超时与「真的失败了」分开说:前者可以再试,后者多半要看输出。
|
|
623
651
|
failure: {
|
|
624
652
|
code: result.code === null ? 'INSTALL_ABORTED' : 'INSTALL_FAILED',
|
|
625
|
-
message:
|
|
653
|
+
message: withReason(
|
|
654
|
+
`${command} ${result.code === null ? '没能跑完' : `退出码 ${result.code}`}${result.error ? `:${result.error}` : ''}`,
|
|
655
|
+
output,
|
|
656
|
+
),
|
|
626
657
|
},
|
|
627
658
|
};
|
|
628
659
|
}
|
|
@@ -643,7 +674,10 @@ async function installLatest({
|
|
|
643
674
|
output,
|
|
644
675
|
failure: changed
|
|
645
676
|
? null
|
|
646
|
-
: {
|
|
677
|
+
: {
|
|
678
|
+
code: 'VERSION_UNCHANGED',
|
|
679
|
+
message: withReason(`${command} 跑完了,但盘上还是 ${after ?? '读不到版本'},这一次不算数`, output),
|
|
680
|
+
},
|
|
647
681
|
};
|
|
648
682
|
}
|
|
649
683
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.10",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"icon": "./icon.png",
|