mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
refactor(file-upload): tighten service boundaries
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
|
||||
2026-08-26-generic-file-upload.md: fcb60c2d02ec1aac5d9d40f05dad0eaa5b823cd9
|
||||
2026-08-26-generic-file-upload.zh.md: e696a8173c3f673d0bd574474816550fb8160158
|
||||
2026-08-26-generic-file-upload.md: d8643f5f1a3e0dcdfdc7fc8b79cbb156b07f4402
|
||||
2026-08-26-generic-file-upload.zh.md: 38de46a424e3f02b7c0293e15835d096b108f071
|
||||
|
||||
@@ -12,7 +12,7 @@ The command plane had already established a whole-envelope rule for images: a co
|
||||
|
||||
## Decision
|
||||
|
||||
Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects/<digest-prefix>/<digest>`; each read-only model-facing path at `DSH_HOME/attachments/v1/files/<digest-prefix>/<digest>/<name>` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent dual-face `file-upload` Cordis package owns transfer and staging. Its Client `fileUpload` service accepts an Agent Context together with a `Blob`, exact bytes, or a one-shot `ReadableStream<Uint8Array>`; it obtains the wire identity from Typert's registered `agent` Context adapter and assembles the raw route or scoped Remote request. Its Host `fileUploads` service owns the streaming route, encoded Remote fallback, byte storage, command receipt resolver, and receipt tables keyed by the receiving Agent's Session object. Session Controller registers the resolver for cold ordinary Agents and consumes receipts during prompt admission; Session objects do not expose file transfer. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand.
|
||||
Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects/<digest-prefix>/<digest>`; each read-only model-facing path at `DSH_HOME/attachments/v1/files/<digest-prefix>/<digest>/<name>` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent dual-face `file-upload` Cordis package owns transfer and staging. Its Client `fileUpload` service accepts the owning Session identity together with a `Blob`, exact bytes, or a one-shot `ReadableStream<Uint8Array>`; it uses that identity to assemble the raw route request or invoke the generated Remote directly. Its Host `fileUploads` service owns the streaming route, encoded Remote fallback, command receipt resolver, and receipt tables keyed by the receiving Agent's Session object. Encoded admission and attachment-error recognition go through `ctx.attachments`, whose provider owns byte storage. Session Controller registers the resolver for cold ordinary Agents and consumes receipts during prompt admission; Session objects do not expose file transfer. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand.
|
||||
|
||||
Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session.
|
||||
|
||||
@@ -28,7 +28,7 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow-
|
||||
- **Store objective attachments in the goal domain.** Rejected: one ordinary logged user message gives later goal rounds the same history without widening the goal schema or duplicating attachments into round prompts.
|
||||
- **Consume attachments on any command success.** Rejected: `/goal pause` or `/plan off` would clear unrelated cards. Consumption follows the producer's explicit success; grammar forms that cannot use attachments return an error.
|
||||
- **Enforce declarations only in the client.** Rejected: direct RPC callers can bypass the composer. `CommandRuntime` enforces declaration, store availability, same-Session file-receipt resolution, image base64 admission, and image limits before entering the handler.
|
||||
- **Expose uploads through `ctx.connection` and pass that object through Session constructors.** Rejected: Connection does not own file transfer, and the intermediate object layers do not use the dependency. The independent upload service accepts the existing Agent Context directly and lets browser and Worker-hosted pages replace the carrier without widening Connection or Session.
|
||||
- **Expose uploads through `ctx.connection` and pass that object through Session constructors.** Rejected: Connection does not own file transfer, and the intermediate object layers do not use the dependency. The independent upload service accepts an explicit Session identity and lets browser and Worker-hosted pages replace the carrier without widening Connection or Session.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -37,6 +37,7 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow-
|
||||
- Staged uploads live in Host memory per Session; a Host restart between upload and send surfaces `FILE_NOT_STAGED` and the client re-adds the file.
|
||||
- The browser-to-Host transfer is one streaming HTTP request without resumable offsets; retrying after connection or Host failure retransmits the file from byte zero.
|
||||
- A `ReadableStream` upload consumes and transfers the stream once; retrying requires a newly created stream, and progress cannot report a total unless the producer supplies length through a future API.
|
||||
- The browser upload Worker is emitted from the string form of a self-contained function. Runtime imports require a standalone Worker entry bundled by tsdown.
|
||||
- Commands must declare attachment acceptance. Rejected commands preserve the text draft and every attachment card for retry.
|
||||
- Subagents do not upload files independently. A main agent delegates with the saved path; only same-machine or inherited-history children whose execution environment maps that path can read it.
|
||||
- Uploaded files do not appear in `@` completion yet; that integration is a planned later phase.
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决定
|
||||
|
||||
文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects/<digest-prefix>/<digest>` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files/<digest-prefix>/<digest>/<name>` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收 Agent Context 与 `Blob`、精确字节或只能使用一次的 `ReadableStream<Uint8Array>`;它通过 Typert 已注册的 `agent` Context adapter 取得 wire identity,并组装原始路由或按 scope 寻址的 Remote 请求。其 Host `fileUploads` 服务拥有流式路由、编码 Remote 兜底、字节存储、命令凭证解析器,以及以接收方 Agent 的 Session 对象为键的凭证表。Session Controller 注册用于休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证;Session 对象不公开文件传输。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。
|
||||
文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects/<digest-prefix>/<digest>` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files/<digest-prefix>/<digest>/<name>` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收所属 Session 标识与 `Blob`、精确字节或只能使用一次的 `ReadableStream<Uint8Array>`;它使用该标识组装原始路由请求,或直接调用生成的 Remote。其 Host `fileUploads` 服务拥有流式路由、编码 Remote 兜底、命令凭证解析器,以及以接收方 Agent 的 Session 对象为键的凭证表。编码准入和附件错误识别通过 `ctx.attachments` 完成,字节存储由附件提供方拥有。Session Controller 注册用于休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证;Session 对象不公开文件传输。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。
|
||||
|
||||
默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。
|
||||
|
||||
@@ -28,7 +28,7 @@ Status: implemented
|
||||
- **把目标附件存进 goal 领域。** 拒绝:一条普通的已记录用户消息就能让后续 Goal Round 获得相同历史,无需扩大 goal schema 或把附件复制进 Round 提示词。
|
||||
- **任何命令成功都消费附件。** 拒绝:`/goal pause` 或 `/plan off` 会清除无关卡片。只有生产方明确成功才消费附件;无法使用附件的语法形式返回错误。
|
||||
- **只在客户端执行声明。** 拒绝:直接 RPC 调用方可以绕过 composer。`CommandRuntime` 在处理器运行前强制检查声明、存储可用性、同一 Session 的文件凭证解析、图片 base64 准入与图片限制。
|
||||
- **通过 `ctx.connection` 暴露上传并沿 Session 构造器传递该对象。** 拒绝:Connection 不拥有文件传输,中间对象层也不使用这项依赖。独立上传服务直接接收现有 Agent Context,并允许普通浏览器与 Worker-hosted 页面替换载体,无需扩大 Connection 或 Session。
|
||||
- **通过 `ctx.connection` 暴露上传并沿 Session 构造器传递该对象。** 拒绝:Connection 不拥有文件传输,中间对象层也不使用这项依赖。独立上传服务直接接收 Session 标识,并允许普通浏览器与 Worker-hosted 页面替换载体,无需扩大 Connection 或 Session。
|
||||
|
||||
## 后果
|
||||
|
||||
@@ -37,6 +37,7 @@ Status: implemented
|
||||
- 暂存上传按 Session 存在 Host 内存里;上传与发送之间 Host 重启会出现 `FILE_NOT_STAGED`,客户端重新添加文件即可。
|
||||
- 浏览器到 Host 的传输使用一次不带断点续传偏移的流式 HTTP 请求;连接或 Host 失败后的重试会从第一个字节重新传输整个文件。
|
||||
- `ReadableStream` 上传只消费并转移一次 stream;重试必须重新创建 stream,而且在未来 API 允许生产方提供长度前,进度不能报告总量。
|
||||
- 浏览器上传 Worker 由自包含函数的字符串形式生成。需要运行时 import 时,必须改用由 tsdown 打包的独立 Worker 入口。
|
||||
- 命令必须声明接受附件。命令拒绝时保留文本草稿与全部附件卡,供用户重试。
|
||||
- 子代理不独立上传文件。主 agent 委派时传递保存路径;只有同机或继承父级历史且执行环境能映射该路径的子级可以读取。
|
||||
- 上传的文件尚未进入 `@` 补全;该联动是计划中的后续阶段。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/attachment.md
|
||||
attachment.md: 1ad7e27c4474d5a7544690a8a9e07e77c21597ac
|
||||
attachment.zh.md: bd58de40b9a8bd675e5a555c5a16ff0b5d81d4bd
|
||||
attachment.md: 53b3e51e5b1c87625256178287061386342bc055
|
||||
attachment.zh.md: 549c06cc5abe542e5d6feeba10b24ebf6ba8dfd1
|
||||
|
||||
@@ -157,7 +157,7 @@ interface RequestImageAttachment {
|
||||
}
|
||||
```
|
||||
|
||||
`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion.
|
||||
`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -198,6 +198,21 @@ async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly Image
|
||||
*/
|
||||
async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise<AdmittedPromptContentPart[]>
|
||||
|
||||
/**
|
||||
* Decode and durably commit one canonical base64 file upload.
|
||||
* @param input - canonical base64 bytes and optional display name.
|
||||
* @returns the durable content-addressed file reference.
|
||||
* @throws AttachmentError when the encoding or storage operation is refused.
|
||||
*/
|
||||
admitEncodedFile(input: EncodedFileAttachment): Promise<FileAttachmentRef>
|
||||
|
||||
/**
|
||||
* Identify a failure emitted by this attachment capability by its stable code.
|
||||
* @param error - value caught from an attachment operation.
|
||||
* @returns whether the value is an attachment failure.
|
||||
*/
|
||||
isAttachmentError(error: unknown): error is AttachmentError
|
||||
|
||||
/**
|
||||
* Validate and durably commit one image before its owning session event is appended.
|
||||
* The returned reference describes the persisted normalized image. When
|
||||
|
||||
@@ -157,7 +157,7 @@ interface RequestImageAttachment {
|
||||
}
|
||||
```
|
||||
|
||||
`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
|
||||
`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -198,6 +198,21 @@ async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly Image
|
||||
*/
|
||||
async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise<AdmittedPromptContentPart[]>
|
||||
|
||||
/**
|
||||
* Decode and durably commit one canonical base64 file upload.
|
||||
* @param input - canonical base64 bytes and optional display name.
|
||||
* @returns the durable content-addressed file reference.
|
||||
* @throws AttachmentError when the encoding or storage operation is refused.
|
||||
*/
|
||||
admitEncodedFile(input: EncodedFileAttachment): Promise<FileAttachmentRef>
|
||||
|
||||
/**
|
||||
* Identify a failure emitted by this attachment capability by its stable code.
|
||||
* @param error - value caught from an attachment operation.
|
||||
* @returns whether the value is an attachment failure.
|
||||
*/
|
||||
isAttachmentError(error: unknown): error is AttachmentError
|
||||
|
||||
/**
|
||||
* Validate and durably commit one image before its owning session event is appended.
|
||||
* The returned reference describes the persisted normalized image. When
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md
|
||||
README.md: 73c10f0a7c857291f7fbfe42042b9c9f4ce13da0
|
||||
README.zh.md: 43c95a8b072c6d9680af4a7f83c0e5c962aed48b
|
||||
README.md: a812182aeff6d09506a1ea2d4fa8d9a44a175936
|
||||
README.zh.md: c487f8204c86d8f0bbdfd85280e8fbab6ea14dec
|
||||
|
||||
@@ -72,7 +72,7 @@ This section explains the design decisions behind the seam and the service opera
|
||||
|
||||
### Service operations
|
||||
|
||||
The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts).
|
||||
The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts).
|
||||
|
||||
### Source map
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ kind: "package-reference"
|
||||
|
||||
### 服务操作
|
||||
|
||||
服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host prompt 消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。
|
||||
服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host prompt 消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。
|
||||
|
||||
### 源码地图
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { admitEncodedImages } from './admission.ts'
|
||||
import { AttachmentError } from './error.ts'
|
||||
import { admitEncodedFile as admitFileInput, admitEncodedImages } from './admission.ts'
|
||||
import { AttachmentError, isAttachmentError as matchesAttachmentError } from './error.ts'
|
||||
import type {
|
||||
AdmittedPromptContentPart,
|
||||
AttachmentAdmissionPart,
|
||||
EncodedFileAttachment,
|
||||
FileAttachmentRef,
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
@@ -126,6 +127,25 @@ export abstract class AttachmentStore extends Service {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode and durably commit one canonical base64 file upload.
|
||||
* @param input - canonical base64 bytes and optional display name.
|
||||
* @returns the durable content-addressed file reference.
|
||||
* @throws AttachmentError when the encoding or storage operation is refused.
|
||||
*/
|
||||
admitEncodedFile(input: EncodedFileAttachment): Promise<FileAttachmentRef> {
|
||||
return admitFileInput(this, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify a failure emitted by this attachment capability by its stable code.
|
||||
* @param error - value caught from an attachment operation.
|
||||
* @returns whether the value is an attachment failure.
|
||||
*/
|
||||
isAttachmentError(error: unknown): error is AttachmentError {
|
||||
return matchesAttachmentError(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and durably commit one image before its owning session event is appended.
|
||||
* The returned reference describes the persisted normalized image. When
|
||||
|
||||
@@ -10,6 +10,7 @@ import AttachmentStore, {
|
||||
type ImageMediaType,
|
||||
type ImageRequestPolicy,
|
||||
type RequestImageAttachment,
|
||||
type SaveFileAttachment,
|
||||
type SaveImageAttachment,
|
||||
type StoredImageAttachment,
|
||||
} from '../src/index.ts'
|
||||
@@ -89,6 +90,19 @@ class UnsupportedProjectionStore extends AttachmentStore {
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingFileStore extends RecordingStore {
|
||||
fileInput: SaveFileAttachment | undefined
|
||||
|
||||
override saveFile(input: SaveFileAttachment) {
|
||||
this.fileInput = input
|
||||
return Promise.resolve({
|
||||
attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`),
|
||||
name: input.name ?? 'unnamed',
|
||||
bytes: input.data.byteLength,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function image(value: number, mediaType: ImageMediaType = 'image/png'): SaveImageAttachment {
|
||||
return { data: Uint8Array.of(value), mediaType, name: `${value}.png` }
|
||||
}
|
||||
@@ -178,6 +192,20 @@ describe('AttachmentStore.readImageRequest', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AttachmentStore file admission', () => {
|
||||
it('decodes encoded files through the service and exposes attachment errors', async () => {
|
||||
const store = new RecordingFileStore(new Context())
|
||||
|
||||
await expect(store.admitEncodedFile({ data: 'AQID', name: 'notes.bin' })).resolves.toMatchObject({
|
||||
name: 'notes.bin',
|
||||
bytes: 3,
|
||||
})
|
||||
expect(store.fileInput).toEqual({ data: Uint8Array.of(1, 2, 3), name: 'notes.bin' })
|
||||
expect(store.isAttachmentError(new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED'))).toBe(true)
|
||||
expect(store.isAttachmentError(new Error('unknown failure'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isImageAdmissionError', () => {
|
||||
it('separates caller-correctable image admission failures from storage faults', () => {
|
||||
expect(isImageAdmissionError(new AttachmentError('bad bytes', 'INVALID_IMAGE'))).toBe(true)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/file-upload/README.md
|
||||
README.md: 5527bf6f544529088a255934a87af77be4865e12
|
||||
README.zh.md: 7825326863511cd7098635d563237bddeb46d035
|
||||
README.md: 2c19e1a564d7b58226e6ccd3cb5b7c81e1b1c14e
|
||||
README.zh.md: d1553d40d953dd6378a954c43a9b7a36b7334953
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Agent-scoped browser file uploads with streaming intake, progress, cancellation, and staged receipts for later prompts."
|
||||
description: "Session-addressed browser file uploads with streaming intake, progress, cancellation, and staged receipts for later prompts."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream<Uint8Array>` under one Agent scope and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page uses the generated Remote for replayable Blob and exact-byte inputs.
|
||||
This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream<Uint8Array>` for one Session and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page uses the generated Remote for replayable Blob and exact-byte inputs.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,14 +25,14 @@ This package lets browser features store a `Blob`, exact bytes, or a `ReadableSt
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount the package before a consumer that injects `fileUpload`, then call `ctx.fileUpload.upload(agentContext, body, name, signal, onProgress)`. The service derives the wire identity from `agentContext`; callers do not assemble an upload URL or Remote request.
|
||||
Mount the package before a consumer that injects `fileUpload`, then call `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`. The Session identity addresses both the raw route and generated Remote fallback; callers do not assemble either request.
|
||||
|
||||
```yaml
|
||||
- id: file-upload
|
||||
name: '@deepseek-ai/dsh-client-file-upload'
|
||||
```
|
||||
|
||||
The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier. Exact bytes and fixture Blob inputs use the scoped generated Remote.
|
||||
The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier. Exact bytes and fixture Blob inputs use the generated Remote.
|
||||
|
||||
-----
|
||||
|
||||
@@ -42,13 +42,13 @@ The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest ins
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The Client plugin provides `ctx.fileUpload`. Its `upload()` method asks Typert's registered `agent` Context adapter for the caller's identity, assembles the raw route request, and invokes the generated scoped Remote fallback for replayable inputs. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each non-fixture raw request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks.
|
||||
The Client plugin provides `ctx.fileUpload`. Its `upload()` method receives the owning Session identity, assembles the raw route request, and invokes the generated Remote fallback for replayable inputs. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each non-fixture raw request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks.
|
||||
|
||||
The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, byte storage, command receipt resolver, and staged-receipt lifecycle. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt.
|
||||
The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, command receipt resolver, and staged-receipt lifecycle; encoded admission, attachment-error recognition, and byte storage stay behind `ctx.attachments`. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Host streaming route, storage, and Agent-scoped receipt lifecycle |
|
||||
| [`src/index.ts`](src/index.ts) | Host streaming route, attachment-service admission, and Agent-scoped receipt lifecycle |
|
||||
| [`src/types.ts`](src/types.ts) | encoded request, receipt, and durable result types |
|
||||
| [`src/client/contract.ts`](src/client/contract.ts) | Client upload, progress, and page-hook types |
|
||||
| [`src/client/runtime.ts`](src/client/runtime.ts) | Dedicated Worker and page-owned carrier implementations |
|
||||
@@ -56,7 +56,7 @@ The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming
|
||||
|
||||
</details>
|
||||
|
||||
**Runtime invariant:** No companion is published. Each upload receipt belongs to one exact Agent scope, and each request uses one selected carrier. Unscoped calls and unsupported stream carriers fail before the body is sent.
|
||||
**Runtime invariant:** No companion is published. Each upload receipt belongs to one exact Session, and each request uses one selected carrier. Unsupported stream carriers fail before the body is sent.
|
||||
|
||||
-----
|
||||
|
||||
@@ -88,6 +88,7 @@ These limits apply to the transport operation itself.
|
||||
- **Uploads are not resumable** — a failed or cancelled retry starts from the first byte.
|
||||
- **Stream bodies are one-shot** — transferring a `ReadableStream` locks the caller's object, so retry requires a newly created stream.
|
||||
- **Stream progress has no total** — callers receive consumed-byte counts because the stream API carries no byte length.
|
||||
- **The browser Worker is self-contained** — its source is emitted from a function string. Adding runtime imports requires moving it to a standalone Worker entry bundled by tsdown.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "按 Agent scope 上传浏览器文件,提供流式接收、进度、取消和供后续 prompt 使用的暂存凭证。"
|
||||
description: "按 Session 寻址上传浏览器文件,提供流式接收、进度、取消和供后续 prompt 使用的暂存凭证。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
本包让浏览器功能在一个 Agent scope 下存储 `Blob`、精确字节或 `ReadableStream<Uint8Array>`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面通过生成的 Remote 处理可重放的 Blob 与精确字节输入。
|
||||
本包让浏览器功能为一个 Session 存储 `Blob`、精确字节或 `ReadableStream<Uint8Array>`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面通过生成的 Remote 处理可重放的 Blob 与精确字节输入。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,14 +25,14 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
在注入 `fileUpload` 的消费方之前挂载本包,再调用 `ctx.fileUpload.upload(agentContext, body, name, signal, onProgress)`。服务从 `agentContext` 得到 wire identity;调用方不组装上传 URL 或 Remote 请求。
|
||||
在注入 `fileUpload` 的消费方之前挂载本包,再调用 `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`。Session 标识同时用于寻址原始路由和生成的 Remote 兜底;调用方不组装这两种请求。
|
||||
|
||||
```yaml
|
||||
- id: file-upload
|
||||
name: '@deepseek-ai/dsh-client-file-upload'
|
||||
```
|
||||
|
||||
本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。精确字节与 fixture Blob 输入使用按 scope 寻址的生成 Remote。
|
||||
本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。精确字节与 fixture Blob 输入使用生成的 Remote。
|
||||
|
||||
-----
|
||||
|
||||
@@ -42,13 +42,13 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法通过 Typert 已注册的 `agent` Context adapter 取得调用方身份,组装原始路由请求,并为可重放输入调用生成的按 scope 寻址 Remote 兜底。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个非 fixture 原始请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。
|
||||
Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法接收所属 Session 标识,组装原始路由请求,并为可重放输入调用生成的 Remote 兜底。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个非 fixture 原始请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。
|
||||
|
||||
Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、字节存储、命令凭证解析器与暂存凭证生命周期。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。
|
||||
Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、命令凭证解析器与暂存凭证生命周期;编码准入、附件错误识别与字节存储仍由 `ctx.attachments` 提供。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Host 流式路由、存储与按 Agent scope 管理的凭证生命周期 |
|
||||
| [`src/index.ts`](src/index.ts) | Host 流式路由、附件服务准入与按 Agent scope 管理的凭证生命周期 |
|
||||
| [`src/types.ts`](src/types.ts) | 编码请求、凭证与持久结果类型 |
|
||||
| [`src/client/contract.ts`](src/client/contract.ts) | Client 上传、进度与页面钩子类型 |
|
||||
| [`src/client/runtime.ts`](src/client/runtime.ts) | 专用 Worker 与页面自有载体实现 |
|
||||
@@ -56,7 +56,7 @@ Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编
|
||||
|
||||
</details>
|
||||
|
||||
**运行时不变式:** 不发布伴生入口。每个上传凭证只属于一个准确的 Agent scope,每个请求只使用一个已选定载体。没有 scope 的调用和载体不支持的 stream 会在发送请求体前失败。
|
||||
**运行时不变式:** 不发布伴生入口。每个上传凭证只属于一个准确的 Session,每个请求只使用一个已选定载体。载体不支持的 stream 会在发送请求体前失败。
|
||||
|
||||
-----
|
||||
|
||||
@@ -88,6 +88,7 @@ Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编
|
||||
- **上传不能断点续传**:失败或取消后的重试会从第一个字节开始。
|
||||
- **stream 请求体只能使用一次**:转移 `ReadableStream` 会锁定调用方的对象,因此重试必须重新创建 stream。
|
||||
- **stream 进度没有总量**:stream API 不携带字节长度,因此调用方只能收到已消费字节数。
|
||||
- **浏览器 Worker 必须自包含**:其源代码由函数字符串生成。如果实现需要运行时 import,就必须迁移为由 tsdown 打包的独立 Worker 入口。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -64,8 +64,7 @@
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^"
|
||||
"@deepseek-ai/dsh-brand": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
@@ -74,11 +73,12 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^"
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { FileUploadValue } from '../types.ts'
|
||||
|
||||
/** Browser request body accepted by the background file-upload service. */
|
||||
export type FileUploadBody = Blob | ReadableStream<Uint8Array>
|
||||
@@ -9,14 +11,14 @@ export interface FileUploadProgress {
|
||||
readonly total?: number
|
||||
}
|
||||
|
||||
/** Browser upload service addressed through one Client Agent scope. */
|
||||
/** Browser upload service addressed by one Session identity. */
|
||||
export interface FileUploadService {
|
||||
/** Whether this page has a Host-backed background upload carrier. */
|
||||
readonly available: boolean
|
||||
/**
|
||||
* Store one file under an Agent scope. Blob and stream bodies use
|
||||
* Store one file for a Session. Blob and stream bodies use
|
||||
* the background carrier; exact bytes and fixture fallbacks use Remote.
|
||||
* @param owner - Agent-scoped Client context that owns the staged receipt.
|
||||
* @param sessionId - Session that owns the staged receipt.
|
||||
* @param data - browser Blob, exact bytes, or a one-shot byte stream.
|
||||
* @param name - optional display name.
|
||||
* @param signal - optional cancellation for the active upload.
|
||||
@@ -24,14 +26,12 @@ export interface FileUploadService {
|
||||
* @returns the staged receipt and durable file reference, or a business error.
|
||||
*/
|
||||
upload(
|
||||
owner: Context,
|
||||
sessionId: SessionId,
|
||||
data: Blob | Uint8Array | ReadableStream<Uint8Array>,
|
||||
name?: string,
|
||||
signal?: AbortSignal,
|
||||
onProgress?: (progress: FileUploadProgress) => void,
|
||||
): Promise<import('@deepseek-ai/dsh-typert-protocol').RemoteResult<
|
||||
import('../types.ts').FileUploadValue
|
||||
>>
|
||||
): Promise<RemoteResult<FileUploadValue>>
|
||||
}
|
||||
|
||||
export type { ClientFileUploadHooks, FileUploadFetch } from '../types.ts'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { FileUploadRuntime } from './runtime.ts'
|
||||
import type { FileUploadService } from './contract.ts'
|
||||
|
||||
export type {
|
||||
ClientFileUploadHooks,
|
||||
@@ -14,13 +15,13 @@ export type { FileUploadReceiptId, FileUploadValue } from '../types.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Agent-scoped browser service for staged file uploads. */
|
||||
fileUpload: import('./contract.ts').FileUploadService
|
||||
/** Session-addressed browser service for staged file uploads. */
|
||||
fileUpload: FileUploadService
|
||||
}
|
||||
}
|
||||
|
||||
/** The upload service resolves Agent identity and the generated Remote fallback. */
|
||||
export const inject = ['typert', 'remote']
|
||||
/** The upload service uses the generated Remote fallback. */
|
||||
export const inject = ['remote']
|
||||
|
||||
/**
|
||||
* Provide the browser background-upload service.
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { Service, type Context } from '@deepseek-ai/cordis'
|
||||
import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { RemoteResult, TypertContextRegistry } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { FILE_UPLOAD_PATH } from '../protocol.ts'
|
||||
import type { FileUploadValue } from '../types.ts'
|
||||
import type { EncodedFileUploadRequest, FileUploadValue } from '../types.ts'
|
||||
import type {
|
||||
ClientFileUploadHooks,
|
||||
FileUploadBody,
|
||||
@@ -26,21 +27,18 @@ interface FileUploadResponse {
|
||||
readonly body: string
|
||||
}
|
||||
|
||||
interface AgentFileUploadContext extends Context {
|
||||
interface FileUploadRemoteContext extends Context {
|
||||
readonly remote: {
|
||||
readonly fileUploads: {
|
||||
upload(
|
||||
request: import('../types.ts').EncodedFileUploadRequest,
|
||||
sessionId: SessionId,
|
||||
request: EncodedFileUploadRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RemoteResult<FileUploadValue>>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface FileUploadTypert {
|
||||
readonly contexts: Pick<TypertContextRegistry, 'getClient'>
|
||||
}
|
||||
|
||||
interface UploadWorkerStart {
|
||||
readonly url: string
|
||||
readonly body: FileUploadBody
|
||||
@@ -187,8 +185,8 @@ export class FileUploadRuntime extends Service implements FileUploadService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store one file under an Agent scope.
|
||||
* @param owner - Agent-scoped Client context that owns the staged receipt.
|
||||
* Store one file for a Session.
|
||||
* @param sessionId - Session that owns the staged receipt.
|
||||
* @param data - browser Blob, exact bytes, or a one-shot byte stream.
|
||||
* @param name - optional display name.
|
||||
* @param signal - optional cancellation for the active upload.
|
||||
@@ -196,13 +194,12 @@ export class FileUploadRuntime extends Service implements FileUploadService {
|
||||
* @returns the staged receipt and durable file reference, or a business error.
|
||||
*/
|
||||
async upload(
|
||||
owner: Context,
|
||||
sessionId: SessionId,
|
||||
data: Blob | Uint8Array | ReadableStream<Uint8Array>,
|
||||
name?: string,
|
||||
signal?: AbortSignal,
|
||||
onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void,
|
||||
): Promise<RemoteResult<FileUploadValue>> {
|
||||
const sessionId = this.sessionId(owner)
|
||||
if (!(data instanceof Uint8Array) && this.available) {
|
||||
const query = new URLSearchParams({ sessionId })
|
||||
if (name !== undefined) query.set('name', name)
|
||||
@@ -222,19 +219,14 @@ export class FileUploadRuntime extends Service implements FileUploadService {
|
||||
throw new Error('stream file upload requires a background carrier')
|
||||
}
|
||||
const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer())
|
||||
return (owner as AgentFileUploadContext).remote.fileUploads.upload({
|
||||
data: bytesToBase64(bytes),
|
||||
...(name === undefined ? {} : { name }),
|
||||
}, signal)
|
||||
}
|
||||
|
||||
private sessionId(owner: Context): string {
|
||||
const typert = this.ctx.get('typert') as FileUploadTypert | undefined
|
||||
const sessionId = typert?.contexts.getClient('agent')?.identity(owner)
|
||||
if (typeof sessionId !== 'string' || sessionId === '') {
|
||||
throw new Error('fileUpload.upload requires an Agent-scoped context')
|
||||
}
|
||||
return sessionId
|
||||
return (this.ctx as FileUploadRemoteContext).remote.fileUploads.upload(
|
||||
sessionId,
|
||||
{
|
||||
data: bytesToBase64(bytes),
|
||||
...(name === undefined ? {} : { name }),
|
||||
},
|
||||
signal,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { admitEncodedFile, isAttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import type {} from '@deepseek-ai/dsh-client-connection'
|
||||
import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { Remote, RemoteError, TypertRemoteService, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { FILE_UPLOAD_PATH } from './protocol.ts'
|
||||
import type { EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue } from './types.ts'
|
||||
@@ -114,7 +113,7 @@ export class FileUploads extends TypertRemoteService {
|
||||
@Remote('upload')
|
||||
upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise<FileUploadValue> {
|
||||
signal.throwIfAborted()
|
||||
return this.commit(agent, async () => admitEncodedFile(this.ctx.attachments, {
|
||||
return this.commit(agent, async () => this.ctx.attachments.admitEncodedFile({
|
||||
data: request.data,
|
||||
...(request.name === undefined ? {} : { name: request.name }),
|
||||
}))
|
||||
@@ -195,7 +194,7 @@ export class FileUploads extends TypertRemoteService {
|
||||
try {
|
||||
file = await save()
|
||||
} catch (error) {
|
||||
if (isAttachmentError(error)) {
|
||||
if (this.ctx.attachments.isAttachmentError(error)) {
|
||||
throw new RemoteError('session/attachment-invalid' as never, error.message, { reason: error.code } as never)
|
||||
}
|
||||
throw new RemoteError(
|
||||
@@ -288,7 +287,7 @@ export async function handleFileUploadHttp(service: FileUploads, request: Reques
|
||||
result = {
|
||||
ok: true,
|
||||
value: await service.uploadStream({
|
||||
sessionId: SessionId(sessionId),
|
||||
sessionId: brandString<SessionId>(sessionId),
|
||||
data: requestBodyChunks(request.body),
|
||||
signal: request.signal,
|
||||
...(name === undefined ? {} : { name }),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { apply } from '../src/client/index.ts'
|
||||
import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts'
|
||||
@@ -378,22 +379,13 @@ describe('file upload service', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent-scoped file upload', () => {
|
||||
describe('Session-addressed file upload', () => {
|
||||
const SESSION_ID = 's1' as SessionId
|
||||
|
||||
async function scopedService(options: {
|
||||
readonly sessionId?: string
|
||||
readonly remote?: ReturnType<typeof vi.fn>
|
||||
} = {}) {
|
||||
const ctx = new Context()
|
||||
ctx.provide('sessions', {
|
||||
scopeOf: (candidate: Context) => Reflect.get(candidate, 'fixtureSessionId') as string | undefined,
|
||||
} as never)
|
||||
ctx.provide('typert', {
|
||||
contexts: {
|
||||
getClient: (kind: string) => kind === 'agent'
|
||||
? { identity: (candidate: Context) => Reflect.get(candidate, 'fixtureSessionId') as string | undefined }
|
||||
: undefined,
|
||||
},
|
||||
} as never)
|
||||
const remote = options.remote ?? vi.fn(() => Promise.resolve({
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -404,10 +396,7 @@ describe('Agent-scoped file upload', () => {
|
||||
ctx.provide('remote', { fileUploads: { upload: remote } } as never)
|
||||
const fiber = ctx.plugin(FileUploadRuntime)
|
||||
await fiber
|
||||
const owner = options.sessionId === undefined
|
||||
? ctx
|
||||
: ctx.extend({ fixtureSessionId: options.sessionId })
|
||||
return { ctx, fiber, owner, remote, service: ctx.fileUpload }
|
||||
return { ctx, fiber, remote, service: ctx.fileUpload }
|
||||
}
|
||||
|
||||
it('assembles the scoped streaming request and parses progress and receipt fields', async () => {
|
||||
@@ -425,11 +414,11 @@ describe('Agent-scoped file upload', () => {
|
||||
}), { status: 200 }))
|
||||
})
|
||||
;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
|
||||
const { fiber, owner, service } = await scopedService({ sessionId: 's1' })
|
||||
const { fiber, service } = await scopedService()
|
||||
const signal = new AbortController().signal
|
||||
const file = new Blob(['data'])
|
||||
|
||||
await expect(service.upload(owner, file, 'notes & refs.pdf', signal, progress)).resolves.toEqual({
|
||||
await expect(service.upload(SESSION_ID, file, 'notes & refs.pdf', signal, progress)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
receiptId: 'receipt-1',
|
||||
@@ -448,7 +437,7 @@ describe('Agent-scoped file upload', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses the scoped Remote fallback for exact bytes and fixture Blob bodies', async () => {
|
||||
it('uses the direct Remote fallback for exact bytes and fixture Blob bodies', async () => {
|
||||
vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' })
|
||||
const remote = vi.fn(() => Promise.resolve({
|
||||
ok: true,
|
||||
@@ -457,28 +446,23 @@ describe('Agent-scoped file upload', () => {
|
||||
file: { attachmentId: 'remote-file', name: 'bytes.bin', bytes: 3 },
|
||||
},
|
||||
}))
|
||||
const { fiber, owner, service } = await scopedService({ sessionId: 's1', remote })
|
||||
await expect(service.upload(owner, Uint8Array.of(0, 0, 0), 'bytes.bin'))
|
||||
const { fiber, service } = await scopedService({ remote })
|
||||
await expect(service.upload(SESSION_ID, Uint8Array.of(0, 0, 0), 'bytes.bin'))
|
||||
.resolves.toMatchObject({ ok: true })
|
||||
await expect(service.upload(owner, new Blob([Uint8Array.of(1)])))
|
||||
await expect(service.upload(SESSION_ID, new Blob([Uint8Array.of(1)])))
|
||||
.resolves.toMatchObject({ ok: true })
|
||||
expect(remote.mock.calls).toEqual([
|
||||
[{ data: 'AAAA', name: 'bytes.bin' }, undefined],
|
||||
[{ data: 'AQ==' }, undefined],
|
||||
[SESSION_ID, { data: 'AAAA', name: 'bytes.bin' }, undefined],
|
||||
[SESSION_ID, { data: 'AQ==' }, undefined],
|
||||
])
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an unscoped call, an unavailable stream, and malformed background results', async () => {
|
||||
it('rejects an unavailable stream and malformed background results', async () => {
|
||||
vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' })
|
||||
const unscoped = await scopedService()
|
||||
await expect(unscoped.service.upload(unscoped.owner, Uint8Array.of(1)))
|
||||
.rejects.toThrow('fileUpload.upload requires an Agent-scoped context')
|
||||
await unscoped.fiber.dispose()
|
||||
|
||||
const fixture = await scopedService({ sessionId: 's1' })
|
||||
const fixture = await scopedService()
|
||||
const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
|
||||
await expect(fixture.service.upload(fixture.owner, stream))
|
||||
await expect(fixture.service.upload(SESSION_ID, stream))
|
||||
.rejects.toThrow('stream file upload requires a background carrier')
|
||||
await fixture.fiber.dispose()
|
||||
|
||||
@@ -486,8 +470,8 @@ describe('Agent-scoped file upload', () => {
|
||||
;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
|
||||
fetch: () => Promise.resolve(new Response(null, { status: 413 })),
|
||||
}
|
||||
const rejected = await scopedService({ sessionId: 's1' })
|
||||
await expect(rejected.service.upload(rejected.owner, new Blob()))
|
||||
const rejected = await scopedService()
|
||||
await expect(rejected.service.upload(SESSION_ID, new Blob()))
|
||||
.rejects.toThrow('file upload transport failed with HTTP 413')
|
||||
await rejected.fiber.dispose()
|
||||
|
||||
@@ -505,8 +489,8 @@ describe('Agent-scoped file upload', () => {
|
||||
;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
|
||||
fetch: () => Promise.resolve(new Response(JSON.stringify(body), { status: 200 })),
|
||||
}
|
||||
const malformed = await scopedService({ sessionId: 's1' })
|
||||
await expect(malformed.service.upload(malformed.owner, new Blob()))
|
||||
const malformed = await scopedService()
|
||||
await expect(malformed.service.upload(SESSION_ID, new Blob()))
|
||||
.rejects.toThrow(/file upload transport returned an invalid/)
|
||||
await malformed.fiber.dispose()
|
||||
}
|
||||
@@ -517,8 +501,8 @@ describe('Agent-scoped file upload', () => {
|
||||
error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
|
||||
}), { status: 200 })),
|
||||
}
|
||||
const failed = await scopedService({ sessionId: 's1' })
|
||||
await expect(failed.service.upload(failed.owner, new Blob())).resolves.toMatchObject({
|
||||
const failed = await scopedService()
|
||||
await expect(failed.service.upload(SESSION_ID, new Blob())).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'session/attachment-invalid',
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
{ "path": "../../util/brand" },
|
||||
{ "path": "../../util/crypto" },
|
||||
{ "path": "../../attachment/attachment" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../typert/protocol" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -363,10 +363,8 @@ export class ConversationController extends Service implements IConversation {
|
||||
try {
|
||||
if (controller.signal.aborted
|
||||
|| this.fileUploadOperations.get(attachment.id)?.controller !== controller) return
|
||||
const owner = this.requireSessions().scope(sessionId)
|
||||
if (owner === undefined) throw new Error(`conversation: session "${sessionId}" resolved no Agent scope`)
|
||||
const result = await this.ctx.fileUpload.upload(
|
||||
owner,
|
||||
sessionId,
|
||||
attachment.file,
|
||||
attachment.file.name === '' ? undefined : attachment.file.name,
|
||||
controller.signal,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -43,10 +42,9 @@ async function bench() {
|
||||
}))
|
||||
const uploads = new Map<SessionId, (...args: unknown[]) => Promise<unknown>>([[ROOT, rootUpload]])
|
||||
runtime.fileUpload.available = true
|
||||
runtime.fileUpload.upload = (owner: Context, ...args: unknown[]) => {
|
||||
const id = runtime.sessions.scopeOf(owner)
|
||||
const upload = id === undefined ? undefined : uploads.get(id)
|
||||
if (upload === undefined) throw new Error('test file upload has no Agent-scope fixture')
|
||||
runtime.fileUpload.upload = (sessionId: SessionId, ...args: unknown[]) => {
|
||||
const upload = uploads.get(sessionId)
|
||||
if (upload === undefined) throw new Error('test file upload has no Session fixture')
|
||||
return upload(...args)
|
||||
}
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-cl
|
||||
import type {
|
||||
BeginSubmissionInput, PendingSubmissionRetirement, QueuedMessage,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
|
||||
import { InputHub } from '../src/client/input/hub.ts'
|
||||
import { ConversationController } from '../src/client/service.ts'
|
||||
@@ -17,11 +18,11 @@ import { zh } from '../src/client/locales.ts'
|
||||
async function bench(maxConcurrentFileUploads = 2) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.fileUpload.available = true
|
||||
runtime.fileUpload.upload = (owner: Context, ...args: unknown[]) => {
|
||||
const session = runtime.sessions.sessionOf(owner) as {
|
||||
runtime.fileUpload.upload = (sessionId: SessionId, ...args: unknown[]) => {
|
||||
const session = runtime.sessions.behavior(sessionId) as {
|
||||
uploadFile?: (...input: unknown[]) => Promise<unknown>
|
||||
} | undefined
|
||||
if (session?.uploadFile === undefined) throw new Error('test file upload has no Session override')
|
||||
}
|
||||
if (session.uploadFile === undefined) throw new Error('test file upload has no Session override')
|
||||
return session.uploadFile(...args)
|
||||
}
|
||||
const prompt = vi.fn((
|
||||
|
||||
@@ -489,6 +489,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
returns: 'admitted prompt parts in the same order as `content`.',
|
||||
throws: ['AttachmentError when the image batch is refused.'],
|
||||
},
|
||||
{
|
||||
signature: 'admitEncodedFile(input: EncodedFileAttachment): Promise<FileAttachmentRef>',
|
||||
description: 'Decode and durably commit one canonical base64 file upload.',
|
||||
parameters: [{ name: 'input', description: 'canonical base64 bytes and optional display name.' }],
|
||||
returns: 'the durable content-addressed file reference.',
|
||||
throws: ['AttachmentError when the encoding or storage operation is refused.'],
|
||||
},
|
||||
{
|
||||
signature: 'isAttachmentError(error: unknown): error is AttachmentError',
|
||||
description: 'Identify a failure emitted by this attachment capability by its stable code.',
|
||||
parameters: [{ name: 'error', description: 'value caught from an attachment operation.' }],
|
||||
returns: 'whether the value is an attachment failure.',
|
||||
},
|
||||
{
|
||||
signature: 'abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>',
|
||||
description: 'Validate and durably commit one image before its owning session event is appended. The returned reference describes the persisted normalized image. When normalization reduces the raster, its `originalDimensions` records the orientation-applied input dimensions.',
|
||||
@@ -3616,6 +3629,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'AttachmentAdmissionPart',
|
||||
declaration: 'export type AttachmentAdmissionPart = PromptContentPart | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};',
|
||||
},
|
||||
{
|
||||
name: 'AttachmentError',
|
||||
declaration: 'export class AttachmentError extends Error {\n readonly code: AttachmentErrorCode;\n constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions);\n}',
|
||||
},
|
||||
{
|
||||
name: 'AttachmentErrorCode',
|
||||
declaration: 'export type AttachmentErrorCode = typeof ATTACHMENT_ERROR_CODES[number];',
|
||||
},
|
||||
{
|
||||
name: 'AttachmentId',
|
||||
declaration: 'export type AttachmentId = Branded<\'AttachmentId\'>;',
|
||||
@@ -4064,6 +4085,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'EditGoalRequest',
|
||||
declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'EncodedFileAttachment',
|
||||
declaration: 'export interface EncodedFileAttachment {\n data: string;\n name?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'EncodedFileUploadRequest',
|
||||
declaration: 'export interface EncodedFileUploadRequest {\n readonly data: string;\n readonly name?: string;\n}',
|
||||
|
||||
@@ -28,6 +28,7 @@ import { createSlotRenderer as createRenderer } from '@deepseek-ai/dsh-client-ui
|
||||
import {
|
||||
apply as applyUiSession, inject as uiSessionInject,
|
||||
} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
ChildrenDecl, ComposedProps, HostObservable, OwnerOf, SlotComponent, SlotMap, SlotRenderer,
|
||||
SlotRendererHost, SnapshotSelectorHook, StoreInstanceLike,
|
||||
@@ -115,7 +116,7 @@ export interface TestFileUpload {
|
||||
/** Availability reported to the feature under test. */
|
||||
available: boolean
|
||||
/** Test-supplied upload behavior; the default rejects every call. */
|
||||
upload: (owner: Context, ...args: unknown[]) => Promise<unknown>
|
||||
upload: (sessionId: SessionId, ...args: unknown[]) => Promise<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('fixture helpers', () => {
|
||||
it('rejects an upload until a suite replaces the default stub', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
expect(runtime.fileUpload.available).toBe(false)
|
||||
await expect(runtime.fileUpload.upload(runtime.ctx)).rejects.toThrow('file upload is not stubbed')
|
||||
await expect(runtime.fileUpload.upload('fixture-session' as SessionId)).rejects.toThrow('file upload is not stubbed')
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
Generated
+7
-7
@@ -1746,12 +1746,9 @@ importers:
|
||||
|
||||
packages/client/file-upload:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-attachment':
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../attachment/attachment
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-typert-protocol':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/protocol
|
||||
@@ -1768,9 +1765,9 @@ importers:
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/remotes
|
||||
'@deepseek-ai/dsh-brand':
|
||||
'@deepseek-ai/dsh-attachment':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
version: link:../../attachment/attachment
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
@@ -1780,6 +1777,9 @@ importers:
|
||||
'@deepseek-ai/dsh-scope':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/scope
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-util-crypto':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/crypto
|
||||
|
||||
@@ -361,6 +361,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
AskUserQuestionRequestEvent: 'user-questions.md',
|
||||
AdmittedPromptContentPart: 'attachment.md',
|
||||
AttachmentAdmissionPart: 'attachment.md',
|
||||
AttachmentError: 'attachment.md',
|
||||
EncodedFileAttachment: 'attachment.md',
|
||||
EncodedImageAttachment: 'attachment.md',
|
||||
FileAttachmentRef: 'attachment.md',
|
||||
|
||||
@@ -41,11 +41,9 @@ const DUPLICATE_SAFE_PACKAGES: readonly string[] = [
|
||||
* Runtime exports whose values remain valid when npm installs another package copy.
|
||||
*/
|
||||
const SAFE_HOST_DEPENDENCY_EXPORTS = {
|
||||
'@deepseek-ai/dsh-attachment': ['admitEncodedFile', 'isAttachmentError'],
|
||||
'@deepseek-ai/dsh-credentials': ['credentialKey'],
|
||||
'@deepseek-ai/dsh-deque': ['Deque'],
|
||||
'@deepseek-ai/dsh-llm': ['BlockAssembler', 'callConfigEquals', 'expandAssistantStream'],
|
||||
'@deepseek-ai/dsh-session': ['SessionId'],
|
||||
'@deepseek-ai/dsh-session-format': ['sessionFormatLogFilename'],
|
||||
'@deepseek-ai/dsh-timeout': ['MAX_TIMER_DELAY_MS'],
|
||||
'@deepseek-ai/schemastery': ['default'],
|
||||
|
||||
Reference in New Issue
Block a user