dsh-plugin-mobile-gateway 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PROTOCOL.md CHANGED
@@ -866,6 +866,38 @@ DSH `present` 工具成功后持久化 `deliverables/presented`,移动端根
866
866
  - 默认每块为 512 KiB、同时最多 4 个下载、单个文件最多 512 MiB。部署方可用 `fileDownloadChunkBytes`、`fileDownloadMaxTransfers`、`fileDownloadMaxBytes`、`fileDownloadIdleMs` 调整;`fileDownloadsEnabled: false` 会关闭该能力,且 `hello.capabilities` 不再包含 `file-downloads`。
867
867
  - 绝对路径、空路径(`file-download-open`)、`..` 路径段、NUL 字符、工作目录外的符号链接、目录和其他非普通文件都会被拒绝。`file-list` 不返回符号链接,避免客户端误认为其可下载。
868
868
 
869
+ ### 普通文件上传
870
+
871
+ 已配对连接在启用 `fileUploadsEnabled` 时公布 `file-upload-v1`。上传请求走 control 连接(兼容 legacy),消息走 conversation 连接(兼容 legacy)。普通文件按原始字节进入官方附件存储,取得会话专属 `receiptId`,不接受客户端指定的主机路径。自定义 Host 未组合官方文件上传服务时返回 `file-upload-unavailable`。
872
+
873
+ | type | 必填参数 | 成功 kind |
874
+ |---|---|---|
875
+ | `file-upload-open` | `requestId`, `sessionId`, `name`, `size`, `sha256` | `file-upload-opened` |
876
+ | `file-upload-chunk` | `requestId`, `uploadId`, `offset`, `data` | `file-upload-chunk-accepted` |
877
+ | `file-upload-finish` | `requestId`, `uploadId` | `file-upload-completed` |
878
+ | `file-upload-cancel` | `requestId`, `uploadId` | `file-upload-cancelled` |
879
+
880
+ `requestId` 为 1 至 128 字符的非空字符串,所有上传响应和错误均回显。`sessionId` 必须是已有普通会话;新草稿先使用 `session-create`,子智能体暂不支持文件附件。`name` 只允许不带目录和控制字符的 1 至 255 字符文件名。`size` 为非负安全整数,`sha256` 是完整原始字节的 64 位十六进制摘要,允许空文件。
881
+
882
+ `file-upload-opened` 返回 `uploadId/sessionId/name/size/chunkBytes/maxBytes/idleTimeoutMs`。chunk 使用标准 Base64,`offset` 按解码字节计数,必须等于当前已确认位置;每次等待 `file-upload-chunk-accepted.nextOffset` 后再发下一块,不支持分块流水线或跨连接续传。finish 校验长度和 SHA-256 后通过官方流式接口提交:
883
+
884
+ ```json
885
+ { "kind": "file-upload-completed", "requestId": "finish-1", "uploadId": "upload-uuid", "sessionId": "s1",
886
+ "receiptId": "host-receipt-uuid", "sha256": "<64 位十六进制摘要>",
887
+ "file": { "attachmentId": "opaque-file-id", "name": "报告.pdf", "bytes": 1234 } }
888
+
889
+ { "type": "message", "requestId": "submission-uuid", "sessionId": "s1", "text": "分析这个报告",
890
+ "files": [{ "receiptId": "host-receipt-uuid" }], "mode": "queue" }
891
+ ```
892
+
893
+ 含文件消息必须有 `requestId`,会原样传给 Host 用于相同提交的重试去重。`files` 最多 20 项,不允许重复凭证;可以纯文件发送或与已有图片、文字混合。Host 校验凭证所属的准确 Session,并将其转换为原生 file 内容块;上传完成不会自动发送消息。
894
+
895
+ 实时 `user/message` 新增 `event.files: [{attachmentId,name,bytes}]`;历史 `data.content[]` 保留 `{type:"file",attachment:{attachmentId,name,bytes}}`。附件 ID 不等于工作区路径,现有图片 `attachment` 和工作区 `file-download-*` 不能用于按此 ID 下载文件。
896
+
897
+ 默认单文件 512 MiB、每块 256 KiB、全网关最多 4 个活动上传、空闲 2 分钟、Host 提交超时 180 秒。可配置 `fileUploadMaxBytes/fileUploadChunkBytes/fileUploadMaxTransfers/fileUploadIdleMs/fileUploadCommitTimeoutMs`,实际分块上限还受 `maxPayloadBytes` 限制。上传开关独立于下载开关。
898
+
899
+ uploadId 仅归属创建它的 socket;取消、断线、空闲超时和网关停止会清理网关暂存并中止活动提交。原连接可在短期完成缓存内重试 finish 取得同一结果,缓存最多 128 条、默认保留 2 分钟;不提供断点续传或 Host 附件回滚删除。更完整的官方 WebUI 源码对应关系、JSON 示例、错误表、App 状态机和验收步骤见 [移动端普通文件上传对接指南](docs/file-upload-app-integration.md)。
900
+
869
901
  ### `session-stats` 详细(输入框统计条)
870
902
  ```json
871
903
  { "type": "session-stats", "sessionId": "session-abc" }
@@ -1155,7 +1187,7 @@ Control/旧单连接接收全局模式状态增量,不受所订阅会话过滤
1155
1187
  "event": { "type": "assistant/message", "turn": 1, "step": 0, "text": "已完成", "reasoning": "", "toolCalls": [] } }
1156
1188
  ```
1157
1189
  `event.type` 覆盖(精炼字段):
1158
- - `user/message` → `{text, source, images?: ImageAttachmentRef[]}`
1190
+ - `user/message` → `{text, source, images?: ImageAttachmentRef[], files?: [{attachmentId,name,bytes}]}`
1159
1191
  - 实时 token 使用独立 `assistant-stream`,不是带持久 seq 的 `assistant/chunk`;详见 [rc.2 接入说明](docs/dsh-rc2-mobile-integration.md)。
1160
1192
  - `assistant/attempt` → `{turn, step, stream[], finish?: {kind, failure?}}`
1161
1193
  - `assistant/message` → `{turn, step, text, reasoning, toolCalls[], interrupted?, usage?, finish?: {kind, failure?}}`
package/README.md CHANGED
@@ -10,7 +10,9 @@ DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、排
10
10
  >
11
11
  > 实时流已改为独立 `assistant-stream` 帧,移动端需要按 [rc.2 接入说明](docs/dsh-rc2-mobile-integration.md) 更新订阅、缓存与分页处理。当前修改尚未发布。
12
12
  >
13
- > v0.11.0(当前源码,尚未发布):新增智能体团队与子会话协议,支持团队成员、共享任务看板、多层子智能体目录和运行状态实时同步,以及子智能体独立历史、视图切换、继续对话、图片输入与停止执行。APP 需按能力发现接入,详见 [智能体团队 APP 详细接入指南](docs/agent-team-app-integration.md)。
13
+ > v0.11.1(当前源码,尚未发布):新增普通文件分块上传,支持 SHA-256 校验、进度确认、取消与断线清理,复用 DSH 官方附件存储和会话专属凭证,支持文件随消息发送及实时和历史文件引用。App 需按 `file-upload-v1` 能力接入,详见 [移动端普通文件上传对接指南](docs/file-upload-app-integration.md)。
14
+ >
15
+ > v0.11.0:新增智能体团队与子会话协议,支持团队成员、共享任务看板、多层子智能体目录和运行状态实时同步,以及子智能体独立历史、视图切换、继续对话、图片输入与停止执行。APP 需按能力发现接入,详见 [智能体团队 APP 详细接入指南](docs/agent-team-app-integration.md)。
14
16
  >
15
17
  > v0.10.4:精简会话列表投影,仅保留标题与投影切点,修复大量会话通过公网隧道传输时超时的问题;保留会话元信息和历史完整投影,现有移动客户端无需配套修改。
16
18
  >
@@ -54,6 +56,8 @@ DSH_INSTALL_ROOT=/absolute/path/to/@deepseek-ai/dsh node test/host-official.test
54
56
 
55
57
  当前源码新增 `turn-outline-v1`:透传 WebUI 使用的完整轮次大纲及实时更新,支持 App 在未加载全部正文时展示会话刻度、问答预览并按需分页跳转。控制连接重建和新设备连接会恢复大纲基线;Host 未提供大纲时可降级为已加载轮次导航。详见 [App 会话轮次导航接入指南](docs/turn-outline-app-integration.md)。App 界面需单独接入,本次源码尚未发布。
56
58
 
59
+ 当前源码新增 `file-upload-v1`:已配对移动端可通过控制连接分块上传 PDF、Office、压缩包、音频等普通文件,校验 SHA-256 后存入 DSH 官方附件存储,再以会话专属凭证随消息发送。支持进度确认、取消、断线清理及实时/历史文件引用;复用官方 WebUI 的附件准入,不写入客户端指定的主机路径。帧示例、App 状态机、限制及验收见 [移动端普通文件上传对接指南](docs/file-upload-app-integration.md)。App 页面需单独接入,本次源码尚未发布。
60
+
57
61
  当前源码新增 `agent-team-v1`、`subagent-conversations-v1`:支持团队成员与共享任务板查询、各层子智能体目录、团队投影和成员运行状态实时同步,以及子会话独立历史、订阅、继续对话与停止执行。APP 的目标地址、完整 JSON 示例、看板规则、切换与重连状态机、错误处理和验收清单见 [智能体团队 APP 详细接入指南](docs/agent-team-app-integration.md)。本次只提供网关能力,APP 页面需单独接入,源码尚未发布。
58
62
 
59
63
  ## 多网关第一阶段(当前源码)
@@ -0,0 +1,250 @@
1
+ # 移动端普通文件上传对接指南
2
+
3
+ ## 功能与接入前提
4
+
5
+ 网关通过 `file-upload-v1` 支持 PDF、Office 文档、压缩包、音频及其他普通文件上传。文件先由网关分块接收和校验,再通过 DSH 官方流式接口进入附件存储,得到会话专属的上传凭证;App 随后把凭证与文字一起发送。文件不会被写到用户指定的工作区路径,也不会仅以文字路径代替附件。
6
+
7
+ 基线为 DSH 0.2.1-rc.2、`dsh-mobile-v1`、`hello.protocol: 3`、Session format 4。App 必须已完成设备配对。新增能力只在 `fileUploadsEnabled: true` 的已配对连接中公布;Host 还需组合官方文件上传服务和支持文件流式存储的附件后端。能力声明表示网关协议可用,实际 Host 不提供上传服务时返回 `file-upload-unavailable`。
8
+
9
+ 本功能提供上传、原生附件消息和历史引用。文件内容能否被 Agent 解析,仍取决于 DSH 的工具、技能和文件类型;上传 PDF 不等于模型直接获得 PDF 模态输入。图片继续使用原有 `images` 协议,语音转文字继续使用 `speech-transcribe`。
10
+
11
+ ## 官方 WebUI 的实现方式
12
+
13
+ 源码基于本机安装的 DSH 0.2.1-rc.2:
14
+
15
+ | 官方模块与入口 | 行为 | App 对应实现 |
16
+ |---|---|---|
17
+ | `dsh-client-ui-conversation` 的 `beginFileUpload` | 创建可取消任务,维护 `uploading/ready/error`,上传成功保存 `receiptId/file` | 共享层保存每个草稿附件的状态、进度和凭证 |
18
+ | 同模块的 `pumpFileUploads` | 按并发限制调度,不一次启动全部上传 | 客户端使用有界上传队列 |
19
+ | 同模块的 `sendSession` | 非图片附件必须为 ready,发送 `{type:"file", receiptId}` | 等所有文件获得完成响应后再发送消息 |
20
+ | `dsh-client-file-upload` 的 `ctx.fileUpload.upload` | Blob 经 Worker/XHR 上传并报告进度;stream 经 Worker/Fetch 传输;精确字节可走 Remote | 移动端改走现有已鉴权 WebSocket 的分块协议 |
21
+ | 官方 Host 路由 `/api/session/uploadFileBinary` | 接收原始二进制流,返回 Session 专属凭证与持久附件引用 | 网关在进程内通过 Connection 路由注册表和固定 owner Peer 调用,不要求手机访问此 HTTP 路由 |
22
+ | 官方 `SessionCommandController.prompt` | 校验同 Session 的凭证,转成原生 file 内容块,再进入 inbox | App 发送网关 `message.files`,不提交任意 `attachmentId` 或主机路径 |
23
+
24
+ WebUI 切换 Session 时会为携带的文件重新上传和绑定。发送失败时保留草稿,成功后再清理。上传和消息发送是两个独立步骤,上传成功不会自动唤醒 Agent。凭证绑定精确的 Session 实例;Host 处理成功消息后会退休凭证,不能把它当作永久附件 ID。
25
+
26
+ ## 推荐流程与状态
27
+
28
+ 1. 检查 `hello.capabilities` 包含 `file-upload-v1`,并保留当前 `gatewayId` 和连接代际。
29
+ 2. 新草稿尚无会话时,先用已有 `session-create` 创建空白普通 Session。上传请求必须指定现有 `sessionId`,不能自动创建会话,也不支持子智能体会话。
30
+ 3. 系统选择器取得文件后,复制到 App 私有草稿目录。后台流式计算字节长度和 SHA-256;不要把文件全部读入内存。上传期间固定这份副本,不能改用可能变化的外部文件。
31
+ 4. 控制连接发送 open,按返回的 `chunkBytes` 逐块编码、发送并等待确认。
32
+ 5. 所有字节确认后发送 finish。UI 显示“处理中”,直到收到 `file-upload-completed`,才能标记为 ready。
33
+ 6. 对话连接发送 `message`,包含原始文字、可选图片和所有 ready 文件的 `receiptId`。没有文字的纯文件消息也可发送。
34
+ 7. 收到 `sent` 后清理对应草稿;实时和历史渲染使用持久 `file` 引用。发送失败保留草稿,根据错误决定重试还是重新上传。
35
+
36
+ 建议状态为:`preparing → opening → uploading → committing → ready → sending → sent`。各阶段允许进入 `error`;未完成上传可进入 `cancelling → cancelled`。每个草稿附件应有本地稳定 ID,并记录其所属网关、会话、连接代际、uploadId、字节数、摘要、已确认偏移、receiptId 和 file 引用。
37
+
38
+ ## 通道与请求关联
39
+
40
+ 上传的四类请求使用 **control 连接**,兼容 legacy 单连接。`message` 使用 **conversation 连接**,同样兼容 legacy 单连接。文件上传与消息发送可以位于两条不同连接,上传凭证由同一 Host 的目标 Session 校验。
41
+
42
+ 所有上传请求必须带 1 至 128 字符的非空 `requestId`,响应和错误回显。建议每次 RPC 使用新的 UUID;消息发送的 requestId 则固定为该次提交的 UUID,重试同一次提交时保持不变。`uploadId` 仅标识传输,不代替 requestId。
43
+
44
+ 上传中的 uploadId 只属于创建它的 WebSocket;即使另一个 socket 属于同一设备,也不能继续写入或取消。切换网关、Session 或连接代际后,不得把旧回调应用到新草稿。
45
+
46
+ ## 协议帧
47
+
48
+ ### 开始上传
49
+
50
+ ```json
51
+ {
52
+ "type": "file-upload-open",
53
+ "requestId": "open-1",
54
+ "sessionId": "s1",
55
+ "name": "分析报告.pdf",
56
+ "size": 7,
57
+ "sha256": "7d1a54127b222502f5b79b5fb0803061152a44f92b37e23c6527baf665d4da9a"
58
+ }
59
+ ```
60
+
61
+ 上例摘要对应 ASCII 内容 `abcdefg`,size 为解码后的精确字节数。摘要为 64 位十六进制,大小写均可;响应规范化为小写。允许零字节文件,使用空文件摘要,open 后直接 finish。
62
+
63
+ ```json
64
+ {
65
+ "kind": "file-upload-opened",
66
+ "requestId": "open-1",
67
+ "uploadId": "upload-uuid",
68
+ "sessionId": "s1",
69
+ "name": "分析报告.pdf",
70
+ "size": 7,
71
+ "chunkBytes": 262144,
72
+ "maxBytes": 536870912,
73
+ "idleTimeoutMs": 120000
74
+ }
75
+ ```
76
+
77
+ `name` 仅为显示文件名,最多 255 个 UTF-16 code unit,不得包含目录分隔符、控制字符、NUL,也不能是 `.` 或 `..`。不要发送移动端完整路径。Host 仍会对最终文件名做清理,后续展示以完成响应中的 `file.name` 为准。
78
+
79
+ 不传 `path`、`cwd`、`parentSessionId` 或 `subagentMode`。通用文件不做扩展名白名单,字节原样存储,不执行、不解压。传输大小使用 64 位整数,并保证落在 JSON 安全整数范围;不要用字符串替代数值。
80
+
81
+ ### 发送分块
82
+
83
+ ```json
84
+ {
85
+ "type": "file-upload-chunk",
86
+ "requestId": "chunk-1",
87
+ "uploadId": "upload-uuid",
88
+ "offset": 0,
89
+ "data": "YWJjZGVmZw=="
90
+ }
91
+ ```
92
+
93
+ ```json
94
+ {
95
+ "kind": "file-upload-chunk-accepted",
96
+ "requestId": "chunk-1",
97
+ "uploadId": "upload-uuid",
98
+ "sessionId": "s1",
99
+ "nextOffset": 7
100
+ }
101
+ ```
102
+
103
+ - `offset` 按解码后的字节计数,必须等于网关当前已接收字节数。下一块必须使用确认帧的 `nextOffset`。
104
+ - 每块为非空、标准带 padding 的 Base64,不带 data URL 前缀、空白或换行。每块独立编码,不能按字符直接切割整文件 Base64。
105
+ - 每个 uploadId 同时只进行一项操作,采用“发一块、等确认、再发下一块”。不同文件可以在服务端并发限制内并行。
106
+ - `chunkBytes` 为解码后的块上限,会同时受 WebSocket payload 配置约束;不能硬编码为 256 KiB。最后一块可以更短。
107
+ - 界面进度根据已确认字节数计算,不使用客户端已发出的 Base64 长度。100% 字节已传输仍须等待 finish 完成。
108
+
109
+ ### 校验并提交
110
+
111
+ ```json
112
+ { "type": "file-upload-finish", "requestId": "finish-1", "uploadId": "upload-uuid" }
113
+ ```
114
+
115
+ ```json
116
+ {
117
+ "kind": "file-upload-completed",
118
+ "requestId": "finish-1",
119
+ "uploadId": "upload-uuid",
120
+ "sessionId": "s1",
121
+ "receiptId": "host-receipt-uuid",
122
+ "file": {
123
+ "attachmentId": "sha256:7d1a54127b222502f5b79b5fb0803061152a44f92b37e23c6527baf665d4da9a",
124
+ "name": "分析报告.pdf",
125
+ "bytes": 7
126
+ },
127
+ "sha256": "7d1a54127b222502f5b79b5fb0803061152a44f92b37e23c6527baf665d4da9a"
128
+ }
129
+ ```
130
+
131
+ 网关先校验接收长度与摘要,再流式交给官方附件存储。只有 Host 返回合法凭证后才发送完成响应,并删除网关私有暂存文件。`attachmentId` 是不透明持久引用,即使当前带 `sha256:` 前缀,也不要依赖其字符串结构;`receiptId` 是发送新消息所需的会话权限凭证,两者不可互换。
132
+
133
+ 完成结果在原连接上短暂缓存,默认最多 2 分钟,且全网关最多保留 128 条,较早结果可能被逐出。同一连接再次发送相同 uploadId 的 finish,可以取得相同凭证,不重复创建 Host 上传。它不是跨连接断点续传,也不是永久查询接口。
134
+
135
+ ### 取消
136
+
137
+ ```json
138
+ { "type": "file-upload-cancel", "requestId": "cancel-1", "uploadId": "upload-uuid" }
139
+ ```
140
+
141
+ ```json
142
+ { "kind": "file-upload-cancelled", "requestId": "cancel-1", "uploadId": "upload-uuid", "sessionId": "s1" }
143
+ ```
144
+
145
+ 取消会中止接收或正在执行的 Host 提交,等待操作退出,再释放临时文件和并发名额;被取消的活动请求可能同时返回 `file-upload-cancelled` 错误,App 需按各自 requestId 结算。取消已完成的上传不会删除 Host 持久附件。提交与取消竞争时,Host 可能已经完成存储,因此不能承诺取消能回滚已落盘的附件。
146
+
147
+ ## 发送文件消息
148
+
149
+ ```json
150
+ {
151
+ "type": "message",
152
+ "requestId": "submission-uuid",
153
+ "sessionId": "s1",
154
+ "text": "帮我分析这个报告",
155
+ "files": [{ "receiptId": "host-receipt-uuid" }],
156
+ "mode": "queue"
157
+ }
158
+ ```
159
+
160
+ ```json
161
+ { "kind": "sent", "requestId": "submission-uuid", "sessionId": "s1", "mode": "queue" }
162
+ ```
163
+
164
+ `files` 最多 20 项,不允许重复凭证;每项只需 `receiptId`。网关不会采信客户端提供的主机路径或持久 attachmentId。含文件消息必须有已存在的普通 sessionId 和 1 至 128 字符的 requestId。`text` 可省略,可同时携带已有 `images`,`mode` 可为 `queue` 或 `steer`。当前移动协议组装顺序为图片、文件、文字,不表达图片与文件任意交错排序。
165
+
166
+ 网关将文件项转成官方 `{type:"file", receiptId}`。Host 校验该凭证属于当前 Session,准入后转换为 `{type:"file", attachment}` 并处理凭证生命周期;跨 Session、伪造或已退休凭证返回 `session/attachment-invalid`。
167
+
168
+ 含文件消息的 requestId 会原样传给 Host。消息回执丢失但连接可恢复时,用同一个 requestId 重试完全相同的提交,Host 可根据 inbox 或已记录用户消息去重。不要给同一 ID 换正文,也不要在不明确是否送达时用新 ID 自动重复发送。Host 重启或 Session 实例替换后应先核对队列和原始历史中的 `source.rpcId`,凭证可能已失效。
169
+
170
+ 上传成功、消息发送失败时保留草稿;普通可恢复错误可重试原提交,凭证失效则重新上传。切换 Session 必须重新上传并取得新凭证,不能把旧会话凭证携带过去。关闭后重新打开 App 时,也不要仅凭数据库存过 receiptId 就判定文件 ready,应重新校验草稿所属连接与 Host 状态,必要时重新上传。
171
+
172
+ ## 实时消息与历史渲染
173
+
174
+ 持久用户消息的实时帧新增可选 `event.files`:
175
+
176
+ ```json
177
+ {
178
+ "kind": "event", "sessionId": "s1", "seq": 42, "time": 1000,
179
+ "event": {
180
+ "type": "user/message", "text": "帮我分析这个报告", "source": "user",
181
+ "files": [{ "attachmentId": "opaque-file-id", "name": "分析报告.pdf", "bytes": 7 }]
182
+ }
183
+ }
184
+ ```
185
+
186
+ `history` 与 `session-snapshot` 保留原生 `{type,seq,time,data}` 结构,文件位于 `user/message.data.content[]` 的 `{type:"file",attachment:{attachmentId,name,bytes}}`。App reducer 必须识别 file 块,不能因未知类型丢掉整条纯文件消息;实时和历史按网关、会话、格式版本及 seq 合并,得到相同的附件卡片。
187
+
188
+ 推荐保存附件名称、字节数和持久引用,不在消息表内存储大段 Base64。发送设备可继续使用其私有草稿副本进行本地预览;在确认消息已入历史、无需重试后,再按缓存策略释放副本。
189
+
190
+ 本次不增加按 file attachmentId 下载附件的接口。已有 `attachment` 是图片读取接口,`file-download-*` 只读取会话工作目录中的文件,不能把 attachmentId 当路径传给它们。Host 附件存储可以在工作区之外;其他设备即使看见附件卡片,也不能据此假定已有远程预览链路。已有 `deliverables/presented` 文件卡片和本地文件链接仍按原下载协议处理。
191
+
192
+ ## 失败处理与资源生命周期
193
+
194
+ 错误帧为 `kind:"error"`,带 `requestType/requestId/code/message`,并在请求提供时带 sessionId 或 uploadId。错误文案用于显示,分支判断使用 code。
195
+
196
+ | code | 处理方式 |
197
+ |---|---|
198
+ | `authentication-required` / `wrong-channel` | 检查设备配对和通道;即使调试关闭普通鉴权,也不能匿名上传或发送文件凭证 |
199
+ | `file-upload-disabled` / `file-upload-unavailable` | 关闭当前上传入口或提示管理员检查配置;不可退回上传任意路径 |
200
+ | `bad-request` / `session-not-found` / `subagent/attachment-invalid` | 修正文件名、请求字段或会话目标,子会话不提供此入口 |
201
+ | `file-too-large` | 在选择阶段或 open 失败后提示大小上限,不自动反复上传 |
202
+ | `file-upload-limit` / `file-upload-busy` | 降低并发,等待前一请求完成;不要同时发多个 chunk 或 finish |
203
+ | `file-upload-offset` / `file-upload-invalid-data` / `file-upload-size` | 修正偏移、Base64 和长度;chunk 不是幂等请求,回执状态不明时取消并从头重传 |
204
+ | `file-upload-incomplete` | 补齐剩余块后再 finish;当前已接收字节仍保留 |
205
+ | `file-upload-checksum` | 当前传输已销毁,从固定本地副本重新计算后完整重传 |
206
+ | `file-upload-not-found` / `file-upload-cancelled` | 传输属于其他连接、已取消、已过期或已结束;重新 open,不能从旧偏移续传 |
207
+ | `file-upload-timeout` / `file-upload-failed` / `file-upload-invalid-response` / 文件系统错误码 | 当前操作可能失败;finish 的失败会清理传输,保留本地副本,提示重试;磁盘问题应先处理空间或权限 |
208
+ | `session/attachment-invalid` | Host 不支持文件存储或凭证不属于该 Session、已失效;检查 Host 错误消息并视情况重新上传 |
209
+
210
+ 传输只在当前进程和 socket 内有效,不支持断线、重启后断点续传。socket 关闭或网关停止时中止活动上传并删除网关暂存;空闲传输由定时扫描回收,实际回收可晚于 idleTimeoutMs 一个扫描周期。提交阶段有独立超时,且同一传输的取消、写入和清理串行协调。
211
+
212
+ finish 失败或取消时,官方附件存储可能已接收部分或全部内容;网关只保证清理自身暂存,不操作 Host 附件 GC。进程被强制杀死时,系统临时目录可能残留 `dsh-mobile-upload-*`,它们不是可恢复传输。
213
+
214
+ ## 网关配置
215
+
216
+ | 配置 | 默认值 | 作用 |
217
+ |---|---|---|
218
+ | `fileUploadsEnabled` | `true` | 关闭后不公布上传能力,拒绝上传和含文件消息 |
219
+ | `fileUploadMaxBytes` | 512 MiB | 单文件解码后大小上限 |
220
+ | `fileUploadChunkBytes` | 256 KiB | 单块解码后字节上限,实际值还受 maxPayloadBytes 约束 |
221
+ | `fileUploadMaxTransfers` | 4 | 全网关活动上传上限,包含尚未完成的 open 与 Host 提交 |
222
+ | `fileUploadIdleMs` | 120000 | 空闲传输和完成结果缓存的保留时间 |
223
+ | `fileUploadCommitTimeoutMs` | 180000 | 会话查询和 Host 附件提交的单次超时 |
224
+
225
+ 上传开关与 `fileDownloadsEnabled` 独立。手机只需现有 `/ws/mobile` 可达,Cloudflare Quick、命名 Tunnel 和 LAN 入口无需新增 HTTP 路由。网关为每个活动上传使用私有暂存目录,磁盘预算需考虑并发数乘单文件上限,以及官方存储提交时可能同时存在的副本。
226
+
227
+ ## App 实施顺序与验收
228
+
229
+ KMM 共享层新增上传 DTO、按 requestId 分发的请求协调器、草稿附件 reducer 和有界队列;平台层负责系统文件选择、私有副本、后台摘要计算和文件流读取,UI 订阅共享状态。不要在主线程做文件复制、SHA-256 或 Base64 编码,也不要把每块数据长期存进状态流。
230
+
231
+ Android 可使用系统文档选择器和 ContentResolver 流,iOS 可使用文档选择器并在授权访问期间复制到私有目录;两端都应保留原始显示文件名、真实字节长度,以及可重新读取的草稿副本。UI 展示“上传中、处理中、可发送、失败重试、取消”,移除附件时主动取消活动上传。
232
+
233
+ 客户端验收应包含:
234
+
235
+ - PDF、Word、ZIP、音频及含 NUL 的二进制数据上传后与原始 SHA-256 一致;零字节文件可完成。
236
+ - 超限文件、目录路径文件名、错误摘要、错误偏移和非法 Base64 得到可处理错误。
237
+ - 纯文件、文件加文字、图片加文件加文字均能发送;历史恢复与实时消息展示一致。
238
+ - 切 Session、切网关、断线和取消时旧任务不能污染新草稿;另一个 socket 不能操作原 uploadId。
239
+ - 跨 Session 凭证被 Host 拒绝;同一次消息重试沿用 requestId,不重复进入队列。
240
+ - finish 回执丢失时可在原连接缓存有效期内重试;其余未知进度情况从头重传。
241
+ - 最多并发上传数、空闲回收、磁盘写入失败和 Host 取消不会留下仍占用的传输名额。
242
+ - 上传成功不会自动发送给 Agent;App 必须等用户执行发送。
243
+
244
+ 自动验证入口:`npm test` 包含上传单元测试和真实 WebSocket 的鉴权、通道分流、上传到消息与实时文件引用测试。可选官方契约测试使用实际 DSH 流式路由、附件存储、凭证解析及 prompt 准入,所有数据位于测试临时目录:
245
+
246
+ ```bash
247
+ DSH_INSTALL_ROOT=/absolute/path/to/@deepseek-ai/dsh node test/file-upload-official.test.mjs
248
+ ```
249
+
250
+ 代码入口:[上传管理器](../lib/file-upload.mjs)、[Host Adapter](../lib/dsh-host-adapter.mjs)、[消息与 WebSocket 接入](../lib/index.mjs)。这次不修改 App 页面,也不发布 npm;客户端完成后还需在设备上验证文件选择、后台编码、进度、取消和卡片展示。
@@ -1,5 +1,6 @@
1
1
  import crypto from 'node:crypto'
2
2
  import os from 'node:os'
3
+ import { Readable } from 'node:stream'
3
4
  import { sessionAddress } from './session-target.mjs'
4
5
 
5
6
  function requireRecord(value, endpoint) {
@@ -79,7 +80,7 @@ function sessionListArgs(request) {
79
80
  * descriptors. Its public methods intentionally match mobile-gateway domain
80
81
  * operations, not the upstream transport envelope.
81
82
  */
82
- export function createDshHostAdapter(typertGateway, peer) {
83
+ export function createDshHostAdapter(typertGateway, peer, connection) {
83
84
  if (!typertGateway || typeof typertGateway.invoke !== 'function' || typeof typertGateway.stream !== 'function') {
84
85
  throw new Error('mobile-gateway requires the DSH Remote Gateway host service')
85
86
  }
@@ -97,6 +98,11 @@ export function createDshHostAdapter(typertGateway, peer) {
97
98
  const stream = (namespace, method, args, signal) => typertGateway.stream({
98
99
  namespace, method, args, peer, signal,
99
100
  })
101
+ // Dispatch in-process through the official authenticated route; no loopback
102
+ // HTTP listener, browser cookie, or public /api exposure is required.
103
+ const uploadHandler = connection?.createSharedFetchHandler?.('/api')
104
+ const uploadUrl = new URL('http://localhost/api/session/uploadFileBinary')
105
+ const uploadsAvailable = () => uploadHandler?.requestBodyMode({ method: 'POST', url: uploadUrl }) === 'streaming'
100
106
 
101
107
  const sessionSnapshot = async (request, signal) => {
102
108
  const frame = requireRecord(await firstStreamFrame(
@@ -207,6 +213,33 @@ export function createDshHostAdapter(typertGateway, peer) {
207
213
  }
208
214
 
209
215
  return {
216
+ files: {
217
+ available: uploadsAvailable,
218
+ async upload({ sessionId, name, data }, signal) {
219
+ if (!uploadsAvailable()) throw Object.assign(new Error('The official file upload service is unavailable'), { code: 'file-upload-unavailable' })
220
+ const url = new URL(uploadUrl)
221
+ url.searchParams.set('sessionId', sessionId)
222
+ url.searchParams.set('name', name)
223
+ const uploadSignal = signal ? AbortSignal.any([signal, peer.signal]) : peer.signal
224
+ uploadSignal.throwIfAborted()
225
+ const source = Readable.from(data, { objectMode: false })
226
+ try {
227
+ const response = await uploadHandler.fetch(new Request(url, {
228
+ method: 'POST', headers: { 'content-type': 'application/octet-stream' },
229
+ body: Readable.toWeb(source), duplex: 'half',
230
+ signal: uploadSignal,
231
+ }), peer)
232
+ if (!response.ok) throw Object.assign(new Error(`Official file upload returned HTTP ${response.status}`), { code: 'file-upload-unavailable' })
233
+ const result = await response.json()
234
+ if (result.ok !== true) throw Object.assign(new Error(result.error?.message || 'Official file upload failed'),
235
+ { code: result.error?.code || 'file-upload-failed' })
236
+ return requireRecord(result.value, 'file upload')
237
+ } catch (error) {
238
+ uploadSignal.throwIfAborted()
239
+ throw error
240
+ } finally { source.destroy() }
241
+ },
242
+ },
210
243
  speech: {
211
244
  catalog: (signal) => invoke('speech', 'catalog', {}, signal),
212
245
  follow: (signal) => stream('speech', 'follow', {}, signal),
@@ -0,0 +1,205 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import fsp from 'node:fs/promises'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ export const FILE_UPLOAD_REQUEST_TYPES = new Set([
8
+ 'file-upload-open', 'file-upload-chunk', 'file-upload-finish', 'file-upload-cancel',
9
+ ])
10
+ export const FILE_UPLOAD_DEFAULTS = Object.freeze({
11
+ fileUploadsEnabled: true,
12
+ fileUploadMaxBytes: 512 * 1024 * 1024,
13
+ fileUploadChunkBytes: 256 * 1024,
14
+ fileUploadMaxTransfers: 4,
15
+ fileUploadIdleMs: 2 * 60 * 1000,
16
+ fileUploadCommitTimeoutMs: 180_000,
17
+ })
18
+
19
+ function failure(code, message) { return Object.assign(new Error(message), { code }) }
20
+ function check(condition, code, message) { if (!condition) throw failure(code, message) }
21
+ function validName(name) {
22
+ return typeof name === 'string' && name.trim().length > 0 && name.length <= 255
23
+ && name !== '.' && name !== '..' && !/[\x00-\x1f\x7f/\\]/.test(name)
24
+ }
25
+
26
+ // Receive bounded chunks in a private spool, verify exact bytes, then use DSH's
27
+ // streaming attachment admission. Never interpret client names as host paths.
28
+ export function createFileUploadManager(api, configuration = {}) {
29
+ const options = { ...FILE_UPLOAD_DEFAULTS, ...configuration }
30
+ const payloadLimit = Math.max(0, Math.floor(((options.maxPayloadBytes ?? 144 * 1024 * 1024) - 4096) / 4) * 3)
31
+ const chunkBytes = Math.min(options.fileUploadChunkBytes, payloadLimit)
32
+ const transfers = new Map()
33
+ const completed = new Map()
34
+ const closedClients = new WeakSet()
35
+ let disposed = false
36
+
37
+ const cleanup = async transfer => {
38
+ if (transfer.handle) {
39
+ await transfer.handle.close().catch(() => {})
40
+ transfer.handle = undefined
41
+ }
42
+ if (transfer.directory) await fsp.rm(transfer.directory, { recursive: true, force: true })
43
+ }
44
+ const release = async transfer => {
45
+ try { await cleanup(transfer) } finally { transfers.delete(transfer.id) }
46
+ }
47
+ const stop = async transfer => {
48
+ transfer.abort.abort(failure('file-upload-cancelled', 'File upload cancelled'))
49
+ // Keep the slot reserved until its writes/Host commit have actually stopped.
50
+ await transfer.operation?.catch(() => {})
51
+ await release(transfer)
52
+ }
53
+ const assertActive = transfer => {
54
+ transfer.abort.signal.throwIfAborted()
55
+ check(!disposed && !closedClients.has(transfer.client), 'file-upload-cancelled', 'Upload connection closed')
56
+ }
57
+ const run = (transfer, task) => {
58
+ check(!transfer.operation, 'file-upload-busy', 'An upload operation is already in progress')
59
+ const operation = task()
60
+ transfer.operation = operation
61
+ return operation.finally(() => { if (transfer.operation === operation) transfer.operation = undefined })
62
+ }
63
+
64
+ const open = async (client, msg) => {
65
+ check(!disposed && !closedClients.has(client), 'file-upload-cancelled', 'Upload connection closed')
66
+ check(api.files?.available(), 'file-upload-unavailable', 'The official file upload service is unavailable')
67
+ check(typeof msg.sessionId === 'string' && msg.sessionId.trim(), 'bad-request', 'An existing sessionId is required')
68
+ check(msg.parentSessionId === undefined && msg.subagentMode === undefined, 'subagent/attachment-invalid', 'Subagent file uploads are unsupported')
69
+ check(validName(msg.name), 'bad-request', 'name must be a leaf filename of 1..255 characters without control characters')
70
+ check(Number.isSafeInteger(msg.size) && msg.size >= 0, 'bad-request', 'size must be a nonnegative safe integer')
71
+ check(msg.size <= options.fileUploadMaxBytes, 'file-too-large', 'File exceeds the configured upload limit')
72
+ check(typeof msg.sha256 === 'string' && /^[a-fA-F0-9]{64}$/.test(msg.sha256), 'bad-request', 'sha256 must contain 64 hexadecimal digits')
73
+ check(transfers.size < options.fileUploadMaxTransfers, 'file-upload-limit', 'Too many active uploads')
74
+ const transfer = {
75
+ id: crypto.randomUUID(), client, sessionId: msg.sessionId.trim(), name: msg.name,
76
+ size: msg.size, sha256: msg.sha256.toLowerCase(), offset: 0, hash: crypto.createHash('sha256'),
77
+ abort: new AbortController(), lastActiveAt: Date.now(),
78
+ }
79
+ transfers.set(transfer.id, transfer)
80
+ try {
81
+ return await run(transfer, async () => {
82
+ const sessions = await api.sessions.list({}, AbortSignal.any([
83
+ transfer.abort.signal, AbortSignal.timeout(options.fileUploadCommitTimeoutMs),
84
+ ]))
85
+ check(sessions.items?.some(item => item.sessionId === transfer.sessionId && item.origin !== 'subagent'),
86
+ 'session-not-found', 'No visible ordinary session exists with this ID')
87
+ assertActive(transfer)
88
+ transfer.directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'dsh-mobile-upload-'))
89
+ transfer.path = path.join(transfer.directory, 'payload')
90
+ transfer.handle = await fsp.open(transfer.path, 'wx', 0o600)
91
+ assertActive(transfer)
92
+ return { kind: 'file-upload-opened', uploadId: transfer.id, sessionId: transfer.sessionId,
93
+ name: transfer.name, size: transfer.size, chunkBytes, maxBytes: options.fileUploadMaxBytes,
94
+ idleTimeoutMs: options.fileUploadIdleMs }
95
+ })
96
+ } catch (error) {
97
+ await release(transfer)
98
+ throw error
99
+ }
100
+ }
101
+
102
+ const receive = async (client, msg) => {
103
+ if (msg.type === 'file-upload-open') return open(client, msg)
104
+ check(typeof msg.uploadId === 'string' && msg.uploadId.length > 0, 'bad-request', 'uploadId is required')
105
+ const done = completed.get(msg.uploadId)
106
+ if (done?.client === client && msg.type === 'file-upload-finish') return done.frame
107
+ const transfer = transfers.get(msg.uploadId)
108
+ check(transfer?.client === client, 'file-upload-not-found', 'Upload is unknown or belongs to another connection')
109
+ if (msg.type === 'file-upload-cancel') {
110
+ await stop(transfer)
111
+ return { kind: 'file-upload-cancelled', uploadId: msg.uploadId, sessionId: transfer.sessionId }
112
+ }
113
+ assertActive(transfer)
114
+ return run(transfer, async () => {
115
+ transfer.lastActiveAt = Date.now()
116
+ if (msg.type === 'file-upload-chunk') {
117
+ check(Number.isSafeInteger(msg.offset) && msg.offset === transfer.offset,
118
+ 'file-upload-offset', 'offset must equal the next expected byte')
119
+ check(typeof msg.data === 'string' && msg.data.length > 0 && msg.data.length <= Math.ceil(chunkBytes / 3) * 4,
120
+ 'file-upload-invalid-data', 'Chunk must be nonempty canonical base64 within chunkBytes')
121
+ const bytes = Buffer.from(msg.data, 'base64')
122
+ check(bytes.length > 0 && bytes.length <= chunkBytes && bytes.toString('base64') === msg.data,
123
+ 'file-upload-invalid-data', 'Chunk must be nonempty canonical base64 within chunkBytes')
124
+ check(transfer.offset + bytes.length <= transfer.size, 'file-upload-size', 'Chunk exceeds declared size')
125
+ try {
126
+ let written = 0
127
+ while (written < bytes.length) {
128
+ assertActive(transfer)
129
+ const result = await transfer.handle.write(bytes, written, bytes.length - written, transfer.offset + written)
130
+ check(result.bytesWritten > 0, 'file-upload-write-failed', 'Upload write made no progress')
131
+ written += result.bytesWritten
132
+ }
133
+ } catch (error) {
134
+ transfer.abort.abort(error)
135
+ await release(transfer)
136
+ throw error
137
+ }
138
+ assertActive(transfer)
139
+ transfer.hash.update(bytes)
140
+ transfer.offset += bytes.length
141
+ return { kind: 'file-upload-chunk-accepted', uploadId: transfer.id, sessionId: transfer.sessionId, nextOffset: transfer.offset }
142
+ }
143
+ check(transfer.offset === transfer.size, 'file-upload-incomplete', 'Not all declared bytes have arrived')
144
+ try {
145
+ check(transfer.hash.digest('hex') === transfer.sha256, 'file-upload-checksum', 'SHA-256 does not match the uploaded bytes')
146
+ await transfer.handle.close()
147
+ transfer.handle = undefined
148
+ const signal = AbortSignal.any([transfer.abort.signal, AbortSignal.timeout(options.fileUploadCommitTimeoutMs)])
149
+ const result = await api.files.upload({ sessionId: transfer.sessionId, name: transfer.name,
150
+ data: fs.createReadStream(transfer.path, { highWaterMark: chunkBytes }) }, signal)
151
+ signal.throwIfAborted()
152
+ assertActive(transfer)
153
+ check(typeof result.receiptId === 'string' && result.receiptId.length > 0
154
+ && typeof result.file?.attachmentId === 'string' && result.file.attachmentId.length > 0
155
+ && typeof result.file.name === 'string' && result.file.bytes === transfer.size,
156
+ 'file-upload-invalid-response', 'Official upload result does not match the declared file')
157
+ const frame = { kind: 'file-upload-completed', uploadId: transfer.id, sessionId: transfer.sessionId,
158
+ receiptId: result.receiptId, file: { attachmentId: result.file.attachmentId, name: result.file.name, bytes: result.file.bytes },
159
+ sha256: transfer.sha256 }
160
+ completed.set(transfer.id, { client, frame, lastActiveAt: Date.now() })
161
+ if (completed.size > 128) completed.delete(completed.keys().next().value)
162
+ return frame
163
+ } finally {
164
+ await release(transfer)
165
+ }
166
+ })
167
+ }
168
+
169
+ return {
170
+ async handle(client, msg) {
171
+ const correlation = {
172
+ ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}),
173
+ ...(typeof msg.sessionId === 'string' ? { sessionId: msg.sessionId } : {}),
174
+ ...(typeof msg.uploadId === 'string' ? { uploadId: msg.uploadId } : {}),
175
+ }
176
+ try {
177
+ check(options.fileUploadsEnabled, 'file-upload-disabled', 'File uploads are disabled')
178
+ check(typeof msg.requestId === 'string' && msg.requestId.trim().length > 0 && msg.requestId.length <= 128,
179
+ 'bad-request', 'requestId must contain 1..128 characters')
180
+ const frame = await receive(client, msg)
181
+ return { ...frame, requestId: msg.requestId }
182
+ } catch (error) {
183
+ return { kind: 'error', requestType: msg.type, ...correlation,
184
+ code: error.name === 'TimeoutError' ? 'file-upload-timeout'
185
+ : typeof error.code === 'string' ? error.code : 'file-upload-failed',
186
+ message: error.message || 'File upload failed' }
187
+ }
188
+ },
189
+ async closeClient(client) {
190
+ closedClients.add(client)
191
+ await Promise.all([...transfers.values()].filter(transfer => transfer.client === client).map(stop))
192
+ for (const [id, done] of completed) if (done.client === client) completed.delete(id)
193
+ },
194
+ async expire() {
195
+ const cutoff = Date.now() - options.fileUploadIdleMs
196
+ await Promise.all([...transfers.values()].filter(transfer => !transfer.operation && transfer.lastActiveAt < cutoff).map(stop))
197
+ for (const [id, done] of completed) if (done.lastActiveAt < cutoff) completed.delete(id)
198
+ },
199
+ async dispose() {
200
+ disposed = true
201
+ await Promise.all([...transfers.values()].map(stop))
202
+ completed.clear()
203
+ },
204
+ }
205
+ }
package/lib/index.mjs CHANGED
@@ -21,7 +21,7 @@ import { assistantFinish, failureDetails, toolResultDetails, turnReasonDetails }
21
21
  // { "type": "subscribe", "sessionId": "..." } (optional filter)
22
22
  // { "type": "unsubscribe" }
23
23
  // { "type": "message", "sessionId"?, "text"?, "images"?:
24
- // [{ "mediaType", "data", "name"? }], "mode"?: "queue"|"steer",
24
+ // [{ "mediaType", "data", "name"? }], "files"?: [{ "receiptId" }], "mode"?: "queue"|"steer",
25
25
  // "workspaceId"?, "cwd"?, "clientTimeZone"? }
26
26
  // sessionId omitted -> a new session is created first; the
27
27
  // new session can be placed in a workspace via workspaceId
@@ -100,6 +100,10 @@ import { assistantFinish, failureDetails, toolResultDetails, turnReasonDetails }
100
100
  // "path", "name", "mediaType", "size", "chunkBytes" }
101
101
  // { "kind": "file-download-chunk", "transferId", "offset", "data", "eof", "sha256"? }
102
102
  // { "kind": "file-download-cancelled", "transferId" }
103
+ // { "kind": "file-upload-opened", "requestId", "uploadId", "sessionId", "name", "size", "chunkBytes", "maxBytes", "idleTimeoutMs" }
104
+ // { "kind": "file-upload-chunk-accepted", "requestId", "uploadId", "sessionId", "nextOffset" }
105
+ // { "kind": "file-upload-completed", "requestId", "uploadId", "sessionId", "receiptId", "file", "sha256" }
106
+ // { "kind": "file-upload-cancelled", "requestId", "uploadId", "sessionId" }
103
107
  // { "kind": "session-cancelled", "sessionId", "accepted": true }
104
108
  // { "kind": "queue-item-updated", "sessionId", "itemId", "action", "accepted": true }
105
109
  // { "kind": "session-queues", "queues": { "<sessionId>": [...] } }
@@ -137,6 +141,7 @@ import { WebSocketServer } from 'ws'
137
141
  import devicesModule from './devices.js'
138
142
  import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh-host-adapter.mjs'
139
143
  import { createSpeechInputManager, SPEECH_REQUEST_TYPES } from './speech-input.mjs'
144
+ import { createFileUploadManager, FILE_UPLOAD_REQUEST_TYPES, FILE_UPLOAD_DEFAULTS } from './file-upload.mjs'
140
145
  import { handlePluginQuery, PLUGIN_REQUEST_TYPES } from './plugin-management.mjs'
141
146
  import { createSessionFollower } from './session-follower.mjs'
142
147
  import { parseSessionTarget, TEAM_PROJECTION_KINDS } from './session-target.mjs'
@@ -227,6 +232,12 @@ const Config = Schema.object({
227
232
  fileDownloadChunkBytes: Schema.natural().min(64 * 1024).max(1024 * 1024).default(DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES),
228
233
  fileDownloadIdleMs: Schema.natural().min(10_000).max(30 * 60 * 1000).default(DEFAULT_FILE_DOWNLOAD_IDLE_MS),
229
234
  fileDownloadMaxTransfers: Schema.natural().min(1).max(16).default(DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS),
235
+ fileUploadsEnabled: Schema.boolean().default(FILE_UPLOAD_DEFAULTS.fileUploadsEnabled),
236
+ fileUploadMaxBytes: Schema.natural().min(1024 * 1024).max(2 * 1024 * 1024 * 1024).default(FILE_UPLOAD_DEFAULTS.fileUploadMaxBytes),
237
+ fileUploadChunkBytes: Schema.natural().min(64 * 1024).max(1024 * 1024).default(FILE_UPLOAD_DEFAULTS.fileUploadChunkBytes),
238
+ fileUploadMaxTransfers: Schema.natural().min(1).max(16).default(FILE_UPLOAD_DEFAULTS.fileUploadMaxTransfers),
239
+ fileUploadIdleMs: Schema.natural().min(10_000).max(30 * 60 * 1000).default(FILE_UPLOAD_DEFAULTS.fileUploadIdleMs),
240
+ fileUploadCommitTimeoutMs: Schema.natural().min(10_000).max(30 * 60 * 1000).default(FILE_UPLOAD_DEFAULTS.fileUploadCommitTimeoutMs),
230
241
  adminLoopbackOnly: Schema.boolean().default(true),
231
242
  publicUrl: Schema.string().default(''),
232
243
  deviceFile: Schema.string().default(''),
@@ -278,6 +289,12 @@ function imagesOf(blocks) {
278
289
  return images
279
290
  }
280
291
 
292
+ function filesOf(blocks) {
293
+ return blocks.filter(block => block?.type === 'file' && block.attachment).map(({ attachment }) => ({
294
+ attachmentId: attachment.attachmentId, name: attachment.name, bytes: attachment.bytes,
295
+ }))
296
+ }
297
+
281
298
  // Build the small, owned JSON wire record for one session event. Reads only
282
299
  // leaf fields of the live SessionEvent — never serializes live objects.
283
300
  function buildWireEvent(session, event) {
@@ -289,6 +306,7 @@ function buildWireEvent(session, event) {
289
306
  switch (event.type) {
290
307
  case 'user/message': {
291
308
  const images = imagesOf(d.content || [])
309
+ const files = filesOf(d.content || [])
292
310
  return Object.assign(base, {
293
311
  event: {
294
312
  type: 'user/message',
@@ -296,6 +314,7 @@ function buildWireEvent(session, event) {
296
314
  source: d.source && d.source.kind,
297
315
  ...(typeof d.id === 'string' && d.id ? { raw: { id: d.id } } : {}),
298
316
  ...(images.length ? { images } : {}),
317
+ ...(files.length ? { files } : {}),
299
318
  },
300
319
  })
301
320
  }
@@ -554,8 +573,21 @@ async function admitMessage(api, msg) {
554
573
  const parsedImages = parseWireImages(msg.images, 'message')
555
574
  if (parsedImages.error) return rejectMessage(parsedImages.error)
556
575
  const imageParts = parsedImages.value
557
- if (!text && imageParts.length === 0) {
558
- return rejectMessage({ kind: 'error', code: 'bad-request', message: 'message requires non-empty text or at least one image' })
576
+ const files = msg.files === undefined ? [] : msg.files
577
+ if (!Array.isArray(files) || files.length > 20 || files.some(file => !file || typeof file.receiptId !== 'string'
578
+ || !file.receiptId.trim() || file.receiptId.length > 256)
579
+ || new Set(files.map(file => file.receiptId)).size !== files.length) {
580
+ return rejectMessage({ kind: 'error', code: 'bad-request', message: 'files must contain at most 20 distinct receiptId entries' })
581
+ }
582
+ if (files.length && (target.value.parentSessionId !== undefined || typeof msg.sessionId !== 'string' || !msg.sessionId.trim())) {
583
+ return rejectMessage({ kind: 'error', code: target.value.parentSessionId ? 'subagent/attachment-invalid' : 'bad-request',
584
+ message: 'file receipts require an existing ordinary session' })
585
+ }
586
+ if (files.length && (typeof msg.requestId !== 'string' || !msg.requestId.trim() || msg.requestId.length > 128)) {
587
+ return rejectMessage({ kind: 'error', code: 'bad-request', message: 'file messages require a requestId of 1..128 characters' })
588
+ }
589
+ if (!text && imageParts.length === 0 && files.length === 0) {
590
+ return rejectMessage({ kind: 'error', code: 'bad-request', message: 'message requires text, images or uploaded file receipts' })
559
591
  }
560
592
  const mode = msg.mode === 'steer' ? 'steer' : 'queue'
561
593
 
@@ -582,9 +614,10 @@ async function admitMessage(api, msg) {
582
614
  ...target.value,
583
615
  sessionId,
584
616
  mode,
585
- ...(target.value.parentSessionId === undefined ? {} : requestIdentity),
617
+ ...(target.value.parentSessionId === undefined && files.length === 0 ? {} : requestIdentity),
586
618
  // Match the official WebUI ordering: images first, optional text last.
587
- content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
619
+ content: [...imageParts, ...files.map(file => ({ type: 'file', receiptId: file.receiptId })),
620
+ ...(text ? [{ type: 'text', text }] : [])],
588
621
  ...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
589
622
  })
590
623
  log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${text.slice(0, 60)}"`)
@@ -2257,7 +2290,7 @@ const plugin = {
2257
2290
  // 已配对设备沿用本机用户权限;身份只由 Host 创建,不能由移动端参数指定。
2258
2291
  // 问答与审批仍由本插件的 Host 监听器处理,无需另开 Remote 交互流。
2259
2292
  const peer = ctx.connection.createPeer({ principal: { kind: 'owner' }, interactions: 'none' })
2260
- const api = createDshHostAdapter(ctx.typertGateway, peer)
2293
+ const api = createDshHostAdapter(ctx.typertGateway, peer, ctx.connection)
2261
2294
  const host = api
2262
2295
  const agentDefaultModel = ctx.agentDefaultModel
2263
2296
  const options = {
@@ -2274,6 +2307,7 @@ const plugin = {
2274
2307
  fileDownloadChunkBytes: DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES,
2275
2308
  fileDownloadIdleMs: DEFAULT_FILE_DOWNLOAD_IDLE_MS,
2276
2309
  fileDownloadMaxTransfers: DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS,
2310
+ ...FILE_UPLOAD_DEFAULTS,
2277
2311
  adminLoopbackOnly: true,
2278
2312
  publicUrl: '',
2279
2313
  deviceFile: '',
@@ -2331,12 +2365,14 @@ const plugin = {
2331
2365
  const pendingQuestions = new Map()
2332
2366
  const pendingApprovals = new Map()
2333
2367
  const fileTransfers = createFileTransferManager(api, options)
2368
+ const fileUploads = createFileUploadManager(api, options)
2334
2369
  const speechInput = createSpeechInputManager(api, options)
2335
2370
  const fileTransferExpiryTimer = setInterval(() => {
2336
2371
  fileTransfers.expire().catch((error) => {
2337
2372
  log(`file download expiry failed: ${error && error.message ? error.message : String(error)}`)
2338
2373
  })
2339
- }, Math.min(options.fileDownloadIdleMs, 30_000))
2374
+ fileUploads.expire().catch(error => log(`file upload expiry failed: ${error.message}`))
2375
+ }, Math.min(options.fileDownloadIdleMs, options.fileUploadIdleMs, 30_000))
2340
2376
  const backgroundStreamAbort = new AbortController()
2341
2377
  let archivedSessionIds = null
2342
2378
  let sessionQueues = null
@@ -3021,12 +3057,20 @@ const plugin = {
3021
3057
  const conversationRequest = ['message', 'history', 'subscribe', 'unsubscribe'].includes(msg.type)
3022
3058
  if ((ws.mobileChannel === 'control' && conversationRequest) ||
3023
3059
  (ws.mobileChannel === 'conversation' && !conversationRequest && msg.type !== 'ping')) {
3024
- ws.send(stringifyWireFrame({ kind: 'error', code: 'wrong-channel', requestType: msg.type, message: 'Request belongs to the other mobile channel' }))
3060
+ ws.send(stringifyWireFrame({ kind: 'error', code: 'wrong-channel', requestType: msg.type,
3061
+ ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}), message: 'Request belongs to the other mobile channel' }))
3025
3062
  return
3026
3063
  }
3027
3064
 
3028
3065
  if (msg.type === 'ping') {
3029
3066
  ws.send(stringifyWireFrame({ kind: 'pong', at: Date.now() }))
3067
+ } else if (FILE_UPLOAD_REQUEST_TYPES.has(msg.type)) {
3068
+ if (!ws.deviceId) {
3069
+ sendFrame({ kind: 'error', code: 'authentication-required', requestType: msg.type,
3070
+ ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}), message: 'File uploads require a paired device' })
3071
+ return
3072
+ }
3073
+ void fileUploads.handle(ws, msg).then(sendFrame)
3030
3074
  } else if (SPEECH_REQUEST_TYPES.has(msg.type)) {
3031
3075
  if (!ws.deviceId) {
3032
3076
  sendFrame({ kind: 'error', code: 'authentication-required', requestType: msg.type,
@@ -3078,6 +3122,12 @@ const plugin = {
3078
3122
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
3079
3123
  })
3080
3124
  } else if (msg.type === 'message') {
3125
+ if (Array.isArray(msg.files) && msg.files.length > 0 && (!ws.deviceId || !options.fileUploadsEnabled)) {
3126
+ sendFrame({ kind: 'error', code: !ws.deviceId ? 'authentication-required' : 'file-upload-disabled',
3127
+ requestType: 'message', ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}),
3128
+ message: 'File attachments require paired-device file upload access' })
3129
+ return
3130
+ }
3081
3131
  admitMessage(api, msg).then((frame) => {
3082
3132
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
3083
3133
  })
@@ -3128,6 +3178,7 @@ const plugin = {
3128
3178
  fileTransfers.closeClient(ws).catch((error) => {
3129
3179
  log(`file download cleanup failed: ${error && error.message ? error.message : String(error)}`)
3130
3180
  })
3181
+ fileUploads.closeClient(ws).catch(error => log(`file upload cleanup failed: ${error.message}`))
3131
3182
  log(`client disconnected (id=${id}, remaining=${clients.size})`)
3132
3183
  })
3133
3184
 
@@ -3163,6 +3214,7 @@ const plugin = {
3163
3214
  'schedule-management',
3164
3215
  'plugin-management-v1',
3165
3216
  ...(device ? ['speech-input-v1'] : []),
3217
+ ...(device && options.fileUploadsEnabled ? ['file-upload-v1'] : []),
3166
3218
  'session-cancel',
3167
3219
  'queue-control',
3168
3220
  'session-archive',
@@ -3569,6 +3621,7 @@ const plugin = {
3569
3621
  fileTransfers.dispose().catch((error) => {
3570
3622
  log(`file download disposal failed: ${error && error.message ? error.message : String(error)}`)
3571
3623
  })
3624
+ fileUploads.dispose().catch(error => log(`file upload disposal failed: ${error.message}`))
3572
3625
  disposeUpgrade()
3573
3626
  disposeMgmt()
3574
3627
  disposeEvents()
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.11.0",
4
- "description": "DeepSeek Harness 移动网关,支持安全设备配对、实时会话同步、插件管理、官方语音输入、文件卡片与安全预览、局域网及 Cloudflare Tunnel。0.11.0 新增智能体团队与子会话协议,支持团队成员、共享任务看板、多层子智能体目录和运行状态实时同步,以及子智能体独立历史、视图切换、继续对话、图片输入与停止执行;提供完整 APP 接入文档,客户端需按能力发现接入。",
3
+ "version": "0.11.1",
4
+ "description": "DeepSeek Harness 移动网关,支持安全设备配对、实时会话同步、智能体团队与子会话、插件管理、官方语音输入,以及局域网和 Cloudflare Tunnel。0.11.1 新增普通文件分块上传,支持 SHA-256 校验、进度确认、取消与断线清理,复用 DSH 官方附件存储和会话专属凭证,支持文件随消息发送及实时和历史文件引用;提供详细 App 对接指南。",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
7
7
  "bin",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "bin": "bin/setup-ip.mjs",
28
28
  "scripts": {
29
- "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/speech-input.test.mjs && node test/plugin-management.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node --test test/agent-team.test.mjs && node test/session-event-details.test.mjs && node test/gateway.test.mjs && node --test test/approval-replay.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node --test test/public-endpoint-check.test.mjs && node test/gateway-tools.test.mjs && node test/multi-gateway.test.mjs"
29
+ "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node --test test/file-upload.test.mjs && node test/speech-input.test.mjs && node test/plugin-management.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node --test test/agent-team.test.mjs && node test/session-event-details.test.mjs && node test/gateway.test.mjs && node --test test/approval-replay.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node --test test/public-endpoint-check.test.mjs && node test/gateway-tools.test.mjs && node test/multi-gateway.test.mjs"
30
30
  },
31
31
  "exports": {
32
32
  ".": "./lib/index.mjs",