dsh-mobile-plugin 0.5.9 → 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 +3 -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/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/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
|
/**
|
|
@@ -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 = {
|
|
@@ -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/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",
|