dsh-plugin-mobile-gateway 0.10.4 → 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 +71 -1
- package/README.md +9 -1
- package/docs/agent-team-app-integration.md +945 -0
- package/docs/dsh-rc2-mobile-integration.md +10 -10
- package/docs/file-upload-app-integration.md +250 -0
- package/lib/dsh-host-adapter.mjs +59 -6
- package/lib/file-upload.mjs +205 -0
- package/lib/index.mjs +154 -42
- package/lib/session-follower.mjs +5 -4
- package/lib/session-target.mjs +29 -0
- package/package.json +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# DSH 0.1
|
|
1
|
+
# DSH 0.2.1-rc.2 移动端接入
|
|
2
2
|
|
|
3
|
-
本次源码以 DSH 0.1
|
|
3
|
+
本次源码以 DSH 0.2.1-rc.2 为唯一 Host 基线,不提供旧 Host 分支。配对、设备 token、双连接和 `dsh-mobile-v1` 保持原样;以下是新的实时订阅和历史坐标约定。App 需要同步实现本说明,不能把独立流帧交给旧的 `SessionEvent.seq` reducer。
|
|
4
4
|
|
|
5
5
|
## 1. 能力与历史缓存
|
|
6
6
|
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
{
|
|
11
11
|
"kind": "hello",
|
|
12
12
|
"protocol": 3,
|
|
13
|
-
"dshVersion": "0.1
|
|
14
|
-
"historyFormatVersion":
|
|
13
|
+
"dshVersion": "0.2.1-rc.2",
|
|
14
|
+
"historyFormatVersion": 4,
|
|
15
15
|
"capabilities": ["assistant-stream-v1", "history-format-version", "projection-baseline"]
|
|
16
16
|
}
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
示例只列出新增字段和能力;原有能力仍在。`dshVersion` 是本适配器的目标版本声明,非运行时 CLI 版本探测。握手协议值 3 与 Session 格式值
|
|
19
|
+
示例只列出新增字段和能力;原有能力仍在。`dshVersion` 是本适配器的目标版本声明,非运行时 CLI 版本探测。握手协议值 3 与 Session 格式值 4 是独立概念。
|
|
20
20
|
|
|
21
21
|
缓存键至少包含 `(gatewayId, sessionId, historyFormatVersion)`。缓存没有格式版本或与服务端不同,必须丢弃该 Session 的旧历史、分页游标、fork 锚点及临时输出,重新安装基线。DSH 自己迁移磁盘日志,客户端不得迁移旧 seq。
|
|
22
22
|
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"sessionId": "s1",
|
|
43
43
|
"subscriptionId": "<UUID>",
|
|
44
44
|
"streamId": "<本次上游 follow 的 UUID>",
|
|
45
|
-
"historyFormatVersion":
|
|
45
|
+
"historyFormatVersion": 4,
|
|
46
46
|
"cursor": 41,
|
|
47
47
|
"replace": true,
|
|
48
48
|
"view": "conversation",
|
|
@@ -124,16 +124,16 @@
|
|
|
124
124
|
{ "type": "history", "sessionId": "s1", "view": "conversation" }
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
-
返回值新增 `historyFormatVersion:
|
|
127
|
+
返回值新增 `historyFormatVersion: 4`、`cursor`。conversation 视图隐藏 `system/message`、`request/header`、`request/context`,移除 Assistant 事件中的 `data.stream`,保留真实 seq、最终 message、usage 和 interrupted。默认原始视图保留完整内嵌 stream,用于按需读取轨迹。全部为隐藏事件的页仍返回可前进的 nextBeforeSeq。
|
|
128
128
|
|
|
129
129
|
携带游标必须同时携带读取该游标时的格式版本:
|
|
130
130
|
|
|
131
131
|
```json
|
|
132
|
-
{ "type": "history", "sessionId": "s1", "beforeSeq": 20, "historyFormatVersion":
|
|
133
|
-
{ "type": "fork", "sessionId": "s1", "atSeq": 42, "historyFormatVersion":
|
|
132
|
+
{ "type": "history", "sessionId": "s1", "beforeSeq": 20, "historyFormatVersion": 4 }
|
|
133
|
+
{ "type": "fork", "sessionId": "s1", "atSeq": 42, "historyFormatVersion": 4 }
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
-
缺失版本、旧版本或版本不匹配,返回 `code: "history-format-mismatch"`、`resetRequired: true` 和当前 historyFormatVersion,且不会调用上游分页/fork。客户端必须重建缓存后再取新坐标,不能给旧坐标补一个
|
|
136
|
+
缺失版本、旧版本或版本不匹配,返回 `code: "history-format-mismatch"`、`resetRequired: true` 和当前 historyFormatVersion,且不会调用上游分页/fork。客户端必须重建缓存后再取新坐标,不能给旧坐标补一个 4 后重试。游标必须是非负安全整数。省略 atSeq 的 fork 仍表示从最近完成轮次分叉。
|
|
137
137
|
|
|
138
138
|
收到 session-snapshot 后,不要用晚到的普通 history 响应覆盖正在进行的订阅基线;更早历史页只合并到历史部分。这样可以避免独立查询与实时流交错造成回滚。
|
|
139
139
|
|
|
@@ -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;客户端完成后还需在设备上验证文件选择、后台编码、进度、取消和卡片展示。
|
package/lib/dsh-host-adapter.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
2
|
import os from 'node:os'
|
|
3
|
+
import { Readable } from 'node:stream'
|
|
4
|
+
import { sessionAddress } from './session-target.mjs'
|
|
3
5
|
|
|
4
6
|
function requireRecord(value, endpoint) {
|
|
5
7
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
@@ -78,7 +80,7 @@ function sessionListArgs(request) {
|
|
|
78
80
|
* descriptors. Its public methods intentionally match mobile-gateway domain
|
|
79
81
|
* operations, not the upstream transport envelope.
|
|
80
82
|
*/
|
|
81
|
-
export function createDshHostAdapter(typertGateway, peer) {
|
|
83
|
+
export function createDshHostAdapter(typertGateway, peer, connection) {
|
|
82
84
|
if (!typertGateway || typeof typertGateway.invoke !== 'function' || typeof typertGateway.stream !== 'function') {
|
|
83
85
|
throw new Error('mobile-gateway requires the DSH Remote Gateway host service')
|
|
84
86
|
}
|
|
@@ -96,6 +98,11 @@ export function createDshHostAdapter(typertGateway, peer) {
|
|
|
96
98
|
const stream = (namespace, method, args, signal) => typertGateway.stream({
|
|
97
99
|
namespace, method, args, peer, signal,
|
|
98
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'
|
|
99
106
|
|
|
100
107
|
const sessionSnapshot = async (request, signal) => {
|
|
101
108
|
const frame = requireRecord(await firstStreamFrame(
|
|
@@ -105,12 +112,13 @@ export function createDshHostAdapter(typertGateway, peer) {
|
|
|
105
112
|
requestArgs(request),
|
|
106
113
|
signal,
|
|
107
114
|
), 'session/follow')
|
|
108
|
-
return readSessionSnapshot(frame, request.address.
|
|
115
|
+
return readSessionSnapshot(frame, request.address.kind === 'subagent'
|
|
116
|
+
? request.address.childSessionId : request.address.sessionId)
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
const history = async (payload, signal) => {
|
|
112
120
|
const request = {
|
|
113
|
-
address:
|
|
121
|
+
address: sessionAddress(payload),
|
|
114
122
|
// Older-page requests need only the opening cursor/projections, not another large latest page.
|
|
115
123
|
...(payload.beforeSeq !== undefined ? { maxMessages: 1 }
|
|
116
124
|
: payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
@@ -205,6 +213,33 @@ export function createDshHostAdapter(typertGateway, peer) {
|
|
|
205
213
|
}
|
|
206
214
|
|
|
207
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
|
+
},
|
|
208
243
|
speech: {
|
|
209
244
|
catalog: (signal) => invoke('speech', 'catalog', {}, signal),
|
|
210
245
|
follow: (signal) => stream('speech', 'follow', {}, signal),
|
|
@@ -216,10 +251,22 @@ export function createDshHostAdapter(typertGateway, peer) {
|
|
|
216
251
|
transcribe: (request, signal) => invoke('speech', 'transcribe', { request }, signal),
|
|
217
252
|
},
|
|
218
253
|
sessions: {
|
|
254
|
+
projections: (payload, signal) => invoke('session', 'projections', requestArgs(payload), signal),
|
|
219
255
|
list: (payload = {}, signal) => invoke('session', 'list', sessionListArgs(payload), signal),
|
|
220
256
|
search: (payload, signal) => invoke('session', 'search', requestArgs(payload), signal),
|
|
221
257
|
create: (payload, signal) => invoke('session', 'create', requestArgs(payload), signal),
|
|
222
258
|
prompt: (payload, signal) => serializeSession(payload.sessionId, async () => {
|
|
259
|
+
if (payload.parentSessionId !== undefined) {
|
|
260
|
+
if (payload.subagentMode !== 'continuable') {
|
|
261
|
+
throw Object.assign(new Error('only continuable subagents accept messages'), { code: 'subagent/not-resumable' })
|
|
262
|
+
}
|
|
263
|
+
return invoke('subagents', 'prompt', requestArgs({
|
|
264
|
+
requestId: payload.requestId ?? crypto.randomUUID(),
|
|
265
|
+
parentSessionId: payload.parentSessionId, childSessionId: payload.sessionId,
|
|
266
|
+
mode: 'continuable', delivery: payload.mode, content: payload.content,
|
|
267
|
+
...(payload.clientTimeZone === undefined ? {} : { clientTimeZone: payload.clientTimeZone }),
|
|
268
|
+
}), signal)
|
|
269
|
+
}
|
|
223
270
|
// Accepted input may not have reached turn/start yet. Keep that gap
|
|
224
271
|
// locked; the persistent event or a subsequent read retires the marker.
|
|
225
272
|
const alreadyAdmitted = admittedPrompts.has(payload.sessionId)
|
|
@@ -233,7 +280,13 @@ export function createDshHostAdapter(typertGateway, peer) {
|
|
|
233
280
|
}),
|
|
234
281
|
attachment: (payload, signal) => invoke('session', 'attachment', requestArgs(payload), signal),
|
|
235
282
|
fork: (payload, signal) => invoke('session', 'fork', requestArgs(payload), signal),
|
|
236
|
-
cancel: (payload, signal) =>
|
|
283
|
+
cancel: (payload, signal) => payload.parentSessionId === undefined
|
|
284
|
+
? invoke('session', 'cancel', requestArgs(payload), signal)
|
|
285
|
+
: payload.subagentMode !== 'continuable'
|
|
286
|
+
? Promise.reject(Object.assign(new Error('only continuable subagents accept interrupts'), { code: 'subagent/not-resumable' }))
|
|
287
|
+
: invoke('subagents', 'interruptByParent', {
|
|
288
|
+
parentSessionId: payload.parentSessionId, childSessionId: payload.sessionId, mode: 'continuable',
|
|
289
|
+
}, signal),
|
|
237
290
|
updateQueue: (payload, signal) => invoke('session', 'updateQueue', requestArgs(payload), signal),
|
|
238
291
|
rename: (payload, signal) => invoke('session', 'rename', requestArgs(payload), signal),
|
|
239
292
|
selectModel: (payload, signal) => invoke('session', 'selectModel', requestArgs(payload), signal),
|
|
@@ -377,10 +430,10 @@ export function createDshHostAdapter(typertGateway, peer) {
|
|
|
377
430
|
observeSessionEvent(sessionId, event) {
|
|
378
431
|
if (event.type === 'turn/start') admittedPrompts.delete(sessionId)
|
|
379
432
|
},
|
|
380
|
-
openSessionStream(sessionId, signal) {
|
|
433
|
+
openSessionStream(sessionId, signal, target = { sessionId }) {
|
|
381
434
|
return stream('session', 'follow',
|
|
382
435
|
// Bound the opening window at the Host; older records remain available via session.page.
|
|
383
|
-
requestArgs({ address:
|
|
436
|
+
requestArgs({ address: sessionAddress(target), maxMessages: 12, assistantStream: true }),
|
|
384
437
|
signal,
|
|
385
438
|
)
|
|
386
439
|
},
|
|
@@ -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
|
+
}
|