dsh-mobile-plugin 0.5.11 → 0.5.13
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/client.js +825 -303
- package/lib/impl.js +37 -6
- package/lib/mobile/model-vendors.js +16 -1
- package/lib/mobile/session-models.js +5 -2
- package/lib/mobile/uploads.js +234 -10
- package/lib/panel/index.html +13 -11
- package/lib/routes/control-plane.js +48 -3
- package/lib/shared/e2ee-request.js +39 -0
- package/lib/shared/http-util.js +56 -1
- package/package.json +1 -1
package/lib/impl.js
CHANGED
|
@@ -73,7 +73,20 @@ export const name = 'dsh-mobile-plugin'
|
|
|
73
73
|
//
|
|
74
74
|
// 旧运行时那一支多一个 `apiProxy`:宿主自己提供它(`@deepseek-ai/dsh-host-apiproxy` 装上
|
|
75
75
|
// 了,就一定会被提供),读 `ctx.apiProxy` 必须有 inject,否则 get trap 直接抛。
|
|
76
|
-
|
|
76
|
+
//
|
|
77
|
+
// **但「解析得到那个包」不等于「宿主一定会提供这个服务」**(2026-11-02 反馈 #11):新版宿主
|
|
78
|
+
// 上包名仍然解析得到,服务却已经删了。把 apiProxy 写进 inject 的代价是这一行**永远 pending**
|
|
79
|
+
// —— apply 整个不跑,一条路由都不注册,手机上连「装了但连不上」都看不到。所以 inject 只剩
|
|
80
|
+
// `webServer`,apiProxy 改由 apply 里按「过一小会儿真服务还没来就自己兜」决定,见下面的装配段。
|
|
81
|
+
export const inject = ['webServer']
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 解析到包之后,等真服务的宽限期。
|
|
85
|
+
*
|
|
86
|
+
* 旧宿主在启动过程中就会 provide 出 apiProxy(本插件通常比它先挂),所以这段时间只用来看
|
|
87
|
+
* 「服务到底来不来」。等不到就说明这是新版宿主:包在、服务没了,必须自己挂兼容桥。
|
|
88
|
+
*/
|
|
89
|
+
const API_PROXY_GRACE_MS = 3000
|
|
77
90
|
|
|
78
91
|
export const REMOTE_CONTROL_SETTINGS_NAMESPACE = 'mobile-remote-control'
|
|
79
92
|
export const RemoteControlSettingsSchema = z.object({
|
|
@@ -232,10 +245,30 @@ function apply(ctx) {
|
|
|
232
245
|
return undefined
|
|
233
246
|
}
|
|
234
247
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
248
|
+
/**
|
|
249
|
+
* 装兼容桥与两条帧桥(审批 / 提问)。
|
|
250
|
+
*
|
|
251
|
+
* 三者都只在「宿主这一代没有真 apiProxy」时才装:旧宿主自带真服务,多 provide 一次会让
|
|
252
|
+
* 整棵树加载失败(`service "apiProxy" has been registered at <dsh-mobile-plugin>`)。
|
|
253
|
+
* 而 `apiProxy` 是公共名字:另一个兼容旧 API 的插件也可能 provide 它,撞上时用对方那份。
|
|
254
|
+
*/
|
|
255
|
+
const installCompatBridges = () => {
|
|
238
256
|
mount('apiProxy 兼容桥', () => ctx.reflect.provide('apiProxy', createApiProxy(ctx)))
|
|
257
|
+
mount('审批桥', () => ctx.effect(() => installApprovalBridge(ctx), `${name}: host approval bridge`))
|
|
258
|
+
mount('提问桥', () => ctx.effect(() => installQuestionBridge(ctx), `${name}: host question bridge`))
|
|
259
|
+
}
|
|
260
|
+
if (!hasHostApiProxy) installCompatBridges()
|
|
261
|
+
else {
|
|
262
|
+
// 解析到包只说明「宿主可能是旧版」:新版宿主上包名还在、`apiProxy` 服务已经没了
|
|
263
|
+
// (2026-11-02 反馈 #11)。先给它一会儿把真服务 provide 出来(旧宿主在本插件装配
|
|
264
|
+
// 前后就会挂上),到点还没有就当作新版宿主,自己把兼容桥与两条帧桥装上 —— 否则
|
|
265
|
+
// 十几个移动端模块读到的是一个永远不来的服务,手机侧全程静默失败。
|
|
266
|
+
const timer = setTimeout(() => {
|
|
267
|
+
if (typeof ctx.get === 'function' && ctx.get('apiProxy')) return
|
|
268
|
+
warn('等不到宿主的 apiProxy,按新版宿主装配兼容桥')
|
|
269
|
+
installCompatBridges()
|
|
270
|
+
}, API_PROXY_GRACE_MS)
|
|
271
|
+
timer.unref?.()
|
|
239
272
|
}
|
|
240
273
|
|
|
241
274
|
const state = core.loadState()
|
|
@@ -310,8 +343,6 @@ function apply(ctx) {
|
|
|
310
343
|
mount('sidecar', () => ctx.effect(() => sidecarLifecycle.start(), `${name}: facade sidecar`))
|
|
311
344
|
mount('镜像推送', () => ctx.effect(() => mirror.start(), `${name}: mobile mirror sync`))
|
|
312
345
|
|
|
313
|
-
if (!hasHostApiProxy) mount('审批桥', () => ctx.effect(() => installApprovalBridge(ctx), `${name}: host approval bridge`))
|
|
314
|
-
if (!hasHostApiProxy) mount('提问桥', () => ctx.effect(() => installQuestionBridge(ctx), `${name}: host question bridge`))
|
|
315
346
|
ctx.effect(() => {
|
|
316
347
|
// Profiles without the optional tools injection still support core remote control.
|
|
317
348
|
try { installSessionCleanup(ctx) } catch (error) {
|
|
@@ -102,4 +102,19 @@ function vendorLogoOf(vendor) {
|
|
|
102
102
|
return entry ? { name: entry.name, icon: entry.icon } : null;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
/** 给已清洗的模型目录标厂商,并只返回这份目录实际用到的图标。 */
|
|
106
|
+
function decorateModelGroups(groups) {
|
|
107
|
+
const vendors = {};
|
|
108
|
+
for (const group of groups) {
|
|
109
|
+
for (const model of group.models) {
|
|
110
|
+
const vendor = identifyVendor({ modelName: model.name, model: model.id, providerName: group.name, provider: group.id });
|
|
111
|
+
if (!vendor) continue;
|
|
112
|
+
model.vendor = vendor;
|
|
113
|
+
const logo = vendorLogoOf(vendor);
|
|
114
|
+
if (logo) vendors[vendor] = logo;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return vendors;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { foldText, tokensOf, vendorOfText, identifyVendor, vendorLogoOf, decorateModelGroups, VENDOR_PATTERNS };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* 模型目录:会话当前选择 + 可用模型分组 + 各档推理强度,外加「切模型」动作。
|
|
4
|
+
* 模型目录:会话当前选择 + 可用模型分组 + 厂商标识与图标 + 各档推理强度,外加「切模型」动作。
|
|
5
5
|
*
|
|
6
6
|
* 形状校验(safeModelsValue)与 TTL 缓存是这里的核心,两处都有非平凡的理由:
|
|
7
7
|
* 1. safeModelsValue 不是防御性洁癖——宿主是另一个进程,它给的形状会随版本变,
|
|
@@ -15,6 +15,7 @@ const { unwrap } = require('./envelope.js');
|
|
|
15
15
|
const { callSessions } = require('./rpc.js');
|
|
16
16
|
const { canonicalDeviceKey, sessionModelsStateOf, invalidateSessionModels } = require('./session-cache.js');
|
|
17
17
|
const { readModelOptions, safeModelOptions } = require('./session-model-options.js');
|
|
18
|
+
const { decorateModelGroups } = require('./model-vendors.js');
|
|
18
19
|
|
|
19
20
|
const SESSION_MODELS_CACHE_MS = 20000; // 模型目录短 TTL;成功后 stale-while-revalidate
|
|
20
21
|
|
|
@@ -91,6 +92,8 @@ function safeModelsValue(value) {
|
|
|
91
92
|
: {}),
|
|
92
93
|
})),
|
|
93
94
|
}));
|
|
95
|
+
const vendors = decorateModelGroups(result.groups);
|
|
96
|
+
if (Object.keys(vendors).length > 0) result.vendors = vendors;
|
|
94
97
|
}
|
|
95
98
|
// 模型之外的附加选项行(速度、输出详略……):行数与内容都由 session-model-options.js
|
|
96
99
|
// 那张描述表决定,App 只按收到的数组逐行画。空数组不下发(旧宿主没有这个字段)。
|
|
@@ -165,7 +168,7 @@ async function buildModelsSnapshot(ctx, sessionId) {
|
|
|
165
168
|
|
|
166
169
|
/** GET /api/mobile/v1/session/models?sessionId=…(token)
|
|
167
170
|
* 返回会话当前模型选择与可用模型目录(透传宿主 session.models)。
|
|
168
|
-
* 响应:{ ok, current, defaultModel, routable, groups, options?, failures }。
|
|
171
|
+
* 响应:{ ok, current, defaultModel, routable, groups, vendors?, options?, failures }。
|
|
169
172
|
* `current` 是这个会话实际会用的,`defaultModel` 是全局默认(新建会话的落点),
|
|
170
173
|
* `options` 是当前渠道的附加选项行,没有就没有这个字段。
|
|
171
174
|
*/
|
package/lib/mobile/uploads.js
CHANGED
|
@@ -17,6 +17,15 @@
|
|
|
17
17
|
*
|
|
18
18
|
* 顺序与幂等:chunk 只接受下一片,重复送达同一片按重传处理,跳号直接 409。
|
|
19
19
|
*
|
|
20
|
+
* v3(`caps.wire === 'bin'`,见 docs/native-upload.md 的 v3 章):请求体换成裸密文,一片
|
|
21
|
+
* 按**字节偏移**收、允许乱序,靠一个按偏移排序的待拼窗口把连续的部分落盘。协商进去的 job
|
|
22
|
+
* 走这条路;老客户端没有 `caps`,仍走上面那套按 index 的口径 —— 两条路靠 job 上记的 wire
|
|
23
|
+
* 分流,不做「猜」。
|
|
24
|
+
*
|
|
25
|
+
* 图片走两段:先按上面那套把字节传完拿 uploadId,`session/send` 再把 uploadId 换回内联
|
|
26
|
+
* base64 交给宿主 prompt。换的时候整份字节要读进内存 —— 宿主只认内联块,这条通道没有流式
|
|
27
|
+
* 入口。也正因为如此,单张图片的大小上限由桌面端的附件限制定,这里不设体积门槛。
|
|
28
|
+
*
|
|
20
29
|
* 收件根固定 `~/.dsh/mobile-inbox`,与工作区解耦(工作区被删不影响已收到的文件);
|
|
21
30
|
* `DSH_MOBILE_INBOX` 可覆盖,只给测试用。
|
|
22
31
|
*/
|
|
@@ -26,7 +35,9 @@ const os = require('node:os');
|
|
|
26
35
|
const path = require('node:path');
|
|
27
36
|
const crypto = require('node:crypto');
|
|
28
37
|
|
|
29
|
-
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
38
|
+
const { sendJson, readJsonBody, readRawBody } = require('../shared/http-util');
|
|
39
|
+
// 头名的大小写不保证(中转会压成小写,Node 版本之间也有差异),复用信封那一份读法。
|
|
40
|
+
const { headerOf } = require('../shared/e2ee-request');
|
|
30
41
|
const { allowedRoots, inAllowedZone } = require('./allowed-zone.js');
|
|
31
42
|
|
|
32
43
|
// 片大小的**起步值** 256KB(base64 后约 341KB):客户端一次请求的硬预算是 8s
|
|
@@ -42,10 +53,33 @@ const CHUNK_MAX_BYTES = 2 * 1024 * 1024;
|
|
|
42
53
|
* 放大到 11MB —— 按线上长度直接卡 8MB 等于悄悄砍掉四分之一的可用额度。
|
|
43
54
|
*/
|
|
44
55
|
const CHUNK_MAX_BODY = Math.ceil(8 * 1024 * 1024 * 4 / 3);
|
|
56
|
+
/**
|
|
57
|
+
* v3(二进制分片体)的片长:起步 4MB、天花板 6MB。
|
|
58
|
+
*
|
|
59
|
+
* 天花板的来历是**中转隧道单帧 8MB**:v3 一片的线上字节只是明文加 AEAD 的 12 字节 nonce
|
|
60
|
+
* 与 16 字节 tag(不 base64、不套 JSON),所以 6MB 明文装得进一帧还留出信封余量。v2 只能到
|
|
61
|
+
* 2MB 是因为同一份字节被 base64 了两遍(片一次、整条信封再一次),1.78 倍膨胀先把额度吃了。
|
|
62
|
+
*/
|
|
63
|
+
const BIN_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
64
|
+
const BIN_CHUNK_MAX_BYTES = 6 * 1024 * 1024;
|
|
65
|
+
/** 同时在飞的片数缺省值:再小就藏不掉高延迟链路上的一片空等。 */
|
|
66
|
+
const BIN_PIPELINE = 4;
|
|
67
|
+
/**
|
|
68
|
+
* 客户端给的 pipeline 最多认到 8:它是内存与窗口的乘数,没有理由跟着请求往上翻。
|
|
69
|
+
*
|
|
70
|
+
* 窗口的字节上限由它乘出来(片数 × 片长):窗口只需要装下「在飞的那几片」,再大等于给一个
|
|
71
|
+
* 发疯的客户端留一块几十 MB 的无主内存。乘出来的那个数就是 `begin` 回给客户端的那一格。
|
|
72
|
+
*/
|
|
73
|
+
const BIN_PIPELINE_MAX = 8;
|
|
45
74
|
/** 文件名上限(按字符算,避开各文件系统的字节上限差异)。 */
|
|
46
75
|
const NAME_MAX_CHARS = 120;
|
|
47
|
-
/**
|
|
48
|
-
|
|
76
|
+
/**
|
|
77
|
+
* 暂存残片的最长寿命:手机上传中断后没人来 abort 的清理依据。
|
|
78
|
+
*
|
|
79
|
+
* 7 天,跟着手机侧的续传凭据走:凭据活多久,暂存就得留多久,否则手机回来续传的第一片必撞
|
|
80
|
+
* 404(判据见 `app/src/services/viewmodels/upload-resume.ts` 的 `HOST_STAGING_TTL_MS`)。
|
|
81
|
+
*/
|
|
82
|
+
const STAGING_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
49
83
|
/** 分片必须是标准 base64:Node 的 Buffer.from 会静默丢弃非法字符,光靠解码判不出坏片。 */
|
|
50
84
|
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
51
85
|
|
|
@@ -87,7 +121,13 @@ function resolveFinishedUpload(uploadId) {
|
|
|
87
121
|
return { path: record.path, name: record.name, bytes: record.bytes };
|
|
88
122
|
}
|
|
89
123
|
|
|
90
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* 进行中的上传:uploadId → job。
|
|
126
|
+
*
|
|
127
|
+
* 两种 wire 共用这张表,靠 job 上的 `wire` 分流(`json` 是老行为,`bin` 是 v3):
|
|
128
|
+
* - json:`received` 是**片数**,chunk 只吃下一片。
|
|
129
|
+
* - bin:`receivedBytes` 是**已连续落盘的字节数**,片按偏移收,乱序的先进 `window`。
|
|
130
|
+
*/
|
|
91
131
|
const uploads = new Map();
|
|
92
132
|
|
|
93
133
|
function removeUpload(uploadId, job) {
|
|
@@ -95,6 +135,12 @@ function removeUpload(uploadId, job) {
|
|
|
95
135
|
uploads.delete(uploadId);
|
|
96
136
|
if (job.expiryTimer) clearTimeout(job.expiryTimer);
|
|
97
137
|
job.expiryTimer = null;
|
|
138
|
+
// 待拼窗口是纯内存的片缓冲:job 从 Map 摘掉之后还可能被定时器闭包抓着,不清的话一次
|
|
139
|
+
// 中断的大文件能让几十 MB 多活到进程退出(`unlink` 只管暂存文件,管不到这些 Buffer)。
|
|
140
|
+
if (job.window) {
|
|
141
|
+
job.window.length = 0;
|
|
142
|
+
job.pendingWindowBytes = 0;
|
|
143
|
+
}
|
|
98
144
|
return true;
|
|
99
145
|
}
|
|
100
146
|
|
|
@@ -259,13 +305,47 @@ async function resolveDestDir(ctx, body) {
|
|
|
259
305
|
return { dir };
|
|
260
306
|
}
|
|
261
307
|
|
|
262
|
-
/**
|
|
308
|
+
/**
|
|
309
|
+
* begin 里的能力协商(v3)。
|
|
310
|
+
*
|
|
311
|
+
* **只有明确说了 `caps.wire === 'bin'` 才进 v3**:老 App 一个字段都不带,`caps` 为空,
|
|
312
|
+
* 于是它拿到的是和从前一模一样的响应,走的也是从前那条代码路径。
|
|
313
|
+
*
|
|
314
|
+
* v3 必须同时给出 `size`(总字节数):收口时要靠它证明「字节齐了」,没有它就没法区分
|
|
315
|
+
* 「一次传完」和「少了一片但客户端以为传完了」。
|
|
316
|
+
*
|
|
317
|
+
* @returns {{ wire: 'json' } | { wire: 'bin', pipeline: number, windowBytes: number, windowPieces: number, total: number }
|
|
318
|
+
* | { status: number, code: string, message: string }}
|
|
319
|
+
*/
|
|
320
|
+
function resolveCaps(body) {
|
|
321
|
+
const caps = body.caps && typeof body.caps === 'object' ? body.caps : null;
|
|
322
|
+
if (!caps || caps.wire !== 'bin') return { wire: 'json' };
|
|
323
|
+
// 只认真的数字:`null` 会被 Number() 悄悄变成 0,那等于把「没给」当成「传了个空文件」。
|
|
324
|
+
const size = body.size;
|
|
325
|
+
if (typeof size !== 'number' || !Number.isSafeInteger(size) || size < 0) {
|
|
326
|
+
return { status: 400, code: 'bad-request', message: '二进制分片需要 { size }(总字节数)' };
|
|
327
|
+
}
|
|
328
|
+
const asked = Number(caps.pipeline);
|
|
329
|
+
const pipeline = Number.isInteger(asked) && asked > 0 ? Math.min(asked, BIN_PIPELINE_MAX) : BIN_PIPELINE;
|
|
330
|
+
return {
|
|
331
|
+
wire: 'bin',
|
|
332
|
+
pipeline,
|
|
333
|
+
windowBytes: pipeline * BIN_CHUNK_MAX_BYTES,
|
|
334
|
+
// 与字节上限并行的第二道封顶:一片只有几字节时,光靠字节数能塞进几千片。
|
|
335
|
+
windowPieces: Math.max(4, pipeline * 2),
|
|
336
|
+
total: size,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** POST /api/mobile/v1/upload/begin { name, size?, destPath?, sessionId?, caps? } → { ok, uploadId, chunkSize, maxChunkSize, … }。 */
|
|
263
341
|
async function handleUploadBegin(ctx, req, res) {
|
|
264
342
|
const body = await readJsonBody(req);
|
|
265
343
|
if (!body || typeof body.name !== 'string' || !body.name.trim()) {
|
|
266
344
|
return fail(res, 400, 'bad-request', '需要 { name }');
|
|
267
345
|
}
|
|
268
|
-
|
|
346
|
+
const caps = resolveCaps(body);
|
|
347
|
+
if (caps.status) return fail(res, caps.status, caps.code, caps.message);
|
|
348
|
+
// 落点先判:越界 / 不是目录在这一步就回,不先建暂存再报错 —— 残片要躺满 7 天才清。
|
|
269
349
|
const destination = await resolveDestDir(ctx, body);
|
|
270
350
|
if (destination.status) return fail(res, destination.status, destination.code, destination.message);
|
|
271
351
|
const stagingDir = path.join(inboxRoot(), '.staging');
|
|
@@ -289,24 +369,158 @@ async function handleUploadBegin(ctx, req, res) {
|
|
|
289
369
|
// 指定落点就记在这里,finish 才用得上(中途不再判一次范围:begin 已经认过)。
|
|
290
370
|
destDir: destination.dir,
|
|
291
371
|
stagingPath,
|
|
372
|
+
// 老路径(json)的两个计数器;v3 另起 `receivedBytes`,两者语义不同,不混用同一格。
|
|
292
373
|
received: 0,
|
|
293
374
|
bytes: 0,
|
|
375
|
+
wire: caps.wire,
|
|
376
|
+
// v3 才有意义:偏移收片的账、待拼窗口与它的两块封顶。
|
|
377
|
+
receivedBytes: 0,
|
|
378
|
+
total: caps.wire === 'bin' ? caps.total : 0,
|
|
379
|
+
pipeline: caps.wire === 'bin' ? caps.pipeline : 0,
|
|
380
|
+
windowBytes: caps.wire === 'bin' ? caps.windowBytes : 0,
|
|
381
|
+
windowPieces: caps.wire === 'bin' ? caps.windowPieces : 0,
|
|
382
|
+
window: [],
|
|
383
|
+
pendingWindowBytes: 0,
|
|
294
384
|
expiresAt: Date.now() + STAGING_TTL_MS,
|
|
295
385
|
expiryTimer: null,
|
|
296
386
|
};
|
|
297
387
|
uploads.set(uploadId, job);
|
|
298
388
|
scheduleUploadExpiry(uploadId, job);
|
|
299
389
|
// chunkSize 是起步值、maxChunkSize 是天花板:客户端从前者开始按实测周期往上爬。
|
|
390
|
+
//
|
|
391
|
+
// 老客户端(没协商 caps)拿到的是**和从前逐字节相同**的那五个字段:多一个 `wire` 都会
|
|
392
|
+
// 让「不带 caps 就走 v2」这条承诺落空,所以 v3 的字段只在真协商成功时追加。
|
|
393
|
+
if (caps.wire === 'bin') {
|
|
394
|
+
return sendJson(res, 200, {
|
|
395
|
+
ok: true,
|
|
396
|
+
uploadId,
|
|
397
|
+
chunkSize: BIN_CHUNK_BYTES,
|
|
398
|
+
maxChunkSize: BIN_CHUNK_MAX_BYTES,
|
|
399
|
+
wire: 'bin',
|
|
400
|
+
pipeline: job.pipeline,
|
|
401
|
+
windowBytes: job.windowBytes,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
300
404
|
sendJson(res, 200, { ok: true, uploadId, chunkSize: CHUNK_BYTES, maxChunkSize: CHUNK_MAX_BYTES });
|
|
301
405
|
}
|
|
302
406
|
|
|
407
|
+
/**
|
|
408
|
+
* 把一片按偏移接到暂存文件末尾,**只追加 `receivedBytes` 之后的那部分**。
|
|
409
|
+
*
|
|
410
|
+
* 「只追加尾巴」是为了容忍客户端切片的边界与上一片重叠:重叠字节已经在盘上了,再追加一遍
|
|
411
|
+
* 就把文件写坏(大文件里表现为某一段重复,且字节数悄悄变多)。返回写入字节数,0 表示这片
|
|
412
|
+
* 整段落在已收区间内。
|
|
413
|
+
*/
|
|
414
|
+
async function appendPiece(job, offset, data) {
|
|
415
|
+
const skip = job.receivedBytes - offset;
|
|
416
|
+
if (skip >= data.length) return 0;
|
|
417
|
+
const tail = skip > 0 ? data.subarray(skip) : data;
|
|
418
|
+
await fsp.appendFile(job.stagingPath, tail);
|
|
419
|
+
job.receivedBytes += tail.length;
|
|
420
|
+
// finish 落盘报的 `bytes` 与 json 分支同一个字段:两条 wire 的收口代码因此只差那一句判据。
|
|
421
|
+
job.bytes = job.receivedBytes;
|
|
422
|
+
return tail.length;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** 顺着窗口把接得上的后续片一并落盘(它们的偏移正好从 `receivedBytes` 开始)。 */
|
|
426
|
+
async function flushWindow(job) {
|
|
427
|
+
let wrote = false;
|
|
428
|
+
while (job.window.length) {
|
|
429
|
+
const next = job.window[0];
|
|
430
|
+
if (next.offset > job.receivedBytes) break;
|
|
431
|
+
job.window.shift();
|
|
432
|
+
job.pendingWindowBytes -= next.data.length;
|
|
433
|
+
if (await appendPiece(job, next.offset, next.data)) wrote = true;
|
|
434
|
+
}
|
|
435
|
+
return wrote;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* v3 的一条二进制分片:`X-Dsh-Wire: bin` + `X-Dsh-Upload-Id` + `X-Dsh-Upload-Offset`,
|
|
440
|
+
* 请求体是这一片的裸密文(守卫已解成 `req.rawBody` 里的明文)。
|
|
441
|
+
*
|
|
442
|
+
* 偏移三分法(见 docs/native-upload.md 的「偏移与窗口」):
|
|
443
|
+
* - `offset < receivedBytes`:整片已收过 → 200 幂等;跨过边界 → 400 `bad-offset`。
|
|
444
|
+
* - `offset === receivedBytes`:追加,并顺着窗口把接得上的后续片一起落盘。
|
|
445
|
+
* - `offset > receivedBytes`:进窗口等前面补齐;窗口按字节数或积压片数封顶 → 409。
|
|
446
|
+
*/
|
|
447
|
+
async function handleBinChunk(req, res) {
|
|
448
|
+
const uploadId = headerOf(req, 'x-dsh-upload-id').trim();
|
|
449
|
+
const job = uploads.get(uploadId);
|
|
450
|
+
if (!job) return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
|
|
451
|
+
// 同一 job 中途换 wire 一律拒:两种 wire 的 `received` 一个数片、一个数字节,混着收
|
|
452
|
+
// 会拼出一个对不上账的暂存文件,而客户端还以为自己在续传。
|
|
453
|
+
if (job.wire !== 'bin') {
|
|
454
|
+
return fail(res, 400, 'wire-mismatch', '这个上传是按 JSON 分片协商的,不能混发二进制分片');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const offsetText = headerOf(req, 'x-dsh-upload-offset').trim();
|
|
458
|
+
const offset = Number(offsetText);
|
|
459
|
+
if (!/^\d+$/.test(offsetText) || !Number.isSafeInteger(offset)) {
|
|
460
|
+
return fail(res, 400, 'bad-offset', 'X-Dsh-Upload-Offset 必须是非负整数');
|
|
461
|
+
}
|
|
462
|
+
const piece = await readRawBody(req, BIN_CHUNK_MAX_BYTES);
|
|
463
|
+
if (!piece) return fail(res, 413, 'too-large', '分片过大');
|
|
464
|
+
if (!piece.length) return fail(res, 400, 'bad-offset', '分片不能是空的');
|
|
465
|
+
|
|
466
|
+
const end = offset + piece.length;
|
|
467
|
+
if (offset < job.receivedBytes) {
|
|
468
|
+
if (end <= job.receivedBytes) return sendJson(res, 200, { ok: true, received: job.receivedBytes });
|
|
469
|
+
return fail(res, 400, 'bad-offset', `这一片跨过了已收边界(已收 ${job.receivedBytes} 字节)`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (offset === job.receivedBytes) {
|
|
473
|
+
try {
|
|
474
|
+
await appendPiece(job, offset, piece);
|
|
475
|
+
await flushWindow(job);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
removeUpload(uploadId, job);
|
|
478
|
+
return fail(res, 500, 'inbox-unavailable', `写入暂存文件失败:${error.message}`);
|
|
479
|
+
}
|
|
480
|
+
job.expiresAt = Date.now() + STAGING_TTL_MS;
|
|
481
|
+
scheduleUploadExpiry(uploadId, job);
|
|
482
|
+
return sendJson(res, 200, { ok: true, received: job.receivedBytes });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// 到这里就是乱序:这一片比已连续落盘的字节跑得靠后,先攒着。
|
|
486
|
+
const existing = job.window.find((entry) => entry.offset === offset);
|
|
487
|
+
const added = existing ? piece.length - existing.data.length : piece.length;
|
|
488
|
+
if (
|
|
489
|
+
job.pendingWindowBytes + added > job.windowBytes
|
|
490
|
+
|| (!existing && job.window.length >= job.windowPieces)
|
|
491
|
+
) {
|
|
492
|
+
return fail(res, 409, 'window-exceeded', `乱序窗口已满(${job.windowPieces} 片 / ${job.windowBytes} 字节),请退避后重发这一片`);
|
|
493
|
+
}
|
|
494
|
+
if (existing) {
|
|
495
|
+
// 同一偏移重发:以新的字节为准(客户端重传的是同一片,内容应当一样),不重复计数。
|
|
496
|
+
existing.data = piece;
|
|
497
|
+
} else {
|
|
498
|
+
// 保持按偏移升序:flushWindow 只认队首,插错位置会让窗口永远不前进。
|
|
499
|
+
const at = job.window.findIndex((entry) => entry.offset > offset);
|
|
500
|
+
const entry = { offset, data: piece };
|
|
501
|
+
if (at < 0) job.window.push(entry);
|
|
502
|
+
else job.window.splice(at, 0, entry);
|
|
503
|
+
}
|
|
504
|
+
job.pendingWindowBytes += added;
|
|
505
|
+
job.expiresAt = Date.now() + STAGING_TTL_MS;
|
|
506
|
+
scheduleUploadExpiry(uploadId, job);
|
|
507
|
+
return sendJson(res, 200, { ok: true, received: job.receivedBytes });
|
|
508
|
+
}
|
|
509
|
+
|
|
303
510
|
/** POST /api/mobile/v1/upload/chunk { uploadId, index, data } → { ok, received }。 */
|
|
304
511
|
async function handleUploadChunk(_ctx, req, res) {
|
|
512
|
+
// wire 在最前面分流:bin 的请求体是裸密文(守卫已解成 `req.rawBody`),拿它当 JSON 读
|
|
513
|
+
// 只会得到一个「畸形 JSON」的 413 —— 那是假象,不是真的太大。
|
|
514
|
+
if (headerOf(req, 'x-dsh-wire').trim().toLowerCase() === 'bin') return handleBinChunk(req, res);
|
|
515
|
+
|
|
305
516
|
const body = await readJsonBody(req, CHUNK_MAX_BODY);
|
|
306
517
|
if (!body) return fail(res, 413, 'too-large', '分片过大或不是合法 JSON');
|
|
307
518
|
|
|
308
519
|
const job = uploads.get(String(body.uploadId == null ? '' : body.uploadId));
|
|
309
520
|
if (!job) return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
|
|
521
|
+
if (job.wire === 'bin') {
|
|
522
|
+
return fail(res, 400, 'wire-mismatch', '这个上传是按二进制分片协商的,不能混发 JSON 分片');
|
|
523
|
+
}
|
|
310
524
|
|
|
311
525
|
const index = Number(body.index);
|
|
312
526
|
if (!Number.isInteger(index) || index < 0) return fail(res, 400, 'bad-request', 'index 必须是非负整数');
|
|
@@ -349,6 +563,15 @@ async function handleUploadFinish(_ctx, req, res) {
|
|
|
349
563
|
if (replay) return sendJson(res, 200, { ok: true, ...replay, uploadId });
|
|
350
564
|
return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
|
|
351
565
|
}
|
|
566
|
+
|
|
567
|
+
// v3 的收口必须先证明字节齐了:分片可以乱序、可以重发,少一片时落盘出来的文件是残缺的,
|
|
568
|
+
// 而文件名照样对、客户端照样以为传完了。**job 不删** —— 客户端补上缺的那几片后还能再收一次口。
|
|
569
|
+
if (job.wire === 'bin' && job.receivedBytes !== job.total) {
|
|
570
|
+
return sendJson(res, 400, {
|
|
571
|
+
ok: false,
|
|
572
|
+
error: { code: 'incomplete', message: `上传还没收齐(已收 ${job.receivedBytes} / ${job.total} 字节)`, received: job.receivedBytes },
|
|
573
|
+
});
|
|
574
|
+
}
|
|
352
575
|
removeUpload(uploadId, job);
|
|
353
576
|
|
|
354
577
|
const dir = job.destDir || path.join(inboxRoot(), today());
|
|
@@ -379,9 +602,6 @@ async function handleUploadAbort(_ctx, req, res) {
|
|
|
379
602
|
sendJson(res, 200, { ok: true });
|
|
380
603
|
}
|
|
381
604
|
|
|
382
|
-
/** 一张图片的明文上限:超过就按「传太大」拒,而不是把宿主内存撑爆。 */
|
|
383
|
-
const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
384
|
-
|
|
385
605
|
const UPLOAD_EXPIRED_MESSAGE = '这张图片的上传已过期,请重新发送';
|
|
386
606
|
|
|
387
607
|
/**
|
|
@@ -390,6 +610,10 @@ const UPLOAD_EXPIRED_MESSAGE = '这张图片的上传已过期,请重新发送
|
|
|
390
610
|
* 宿主的 `session.prompt` 只认 `{type:'image', mediaType, data}`,所以这一步要在宿主
|
|
391
611
|
* 那一跳之前完成:手机到宿主这一段只传 uploadId,字节早就在分片上传时落盘了。
|
|
392
612
|
*
|
|
613
|
+
* 整份读进内存是这条通道的形状决定的 —— 内联块本身就是一个 base64 字符串,没有按流交给宿主
|
|
614
|
+
* 的入口。也是因此这里不设体积门槛:单张多大由桌面端的附件限制定,真超了由宿主在 prompt 那
|
|
615
|
+
* 一跳用原话回绝;插件再拦一道,只会把「宿主说附件太大」变成一句假的「上传已过期」。
|
|
616
|
+
*
|
|
393
617
|
* @returns {Promise<string|null>} 有块补不上就回一句给用户看的话,全补上了回 null。
|
|
394
618
|
*/
|
|
395
619
|
async function inlineUploadedImageBlocks(content) {
|
|
@@ -400,7 +624,7 @@ async function inlineUploadedImageBlocks(content) {
|
|
|
400
624
|
if (!record) return UPLOAD_EXPIRED_MESSAGE;
|
|
401
625
|
try {
|
|
402
626
|
const bytes = await fsp.readFile(record.path);
|
|
403
|
-
if (!bytes.length
|
|
627
|
+
if (!bytes.length) return UPLOAD_EXPIRED_MESSAGE;
|
|
404
628
|
block.data = bytes.toString('base64');
|
|
405
629
|
if (!block.name) block.name = record.name;
|
|
406
630
|
} catch {
|