diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 28ab0da3dd..fbb3f653a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -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/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 200761cc9e648eea80bdae9d7b363246c816e5d1 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 673d0ee4bd0916b20ee74226f50240e3904fe06c +2026-07-25-web-input-machine-and-slash-pipeline.md: 1b9e9d95b5a30efbf297be5fc5f788f9a1ac77c4 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: c9bee217da1dbffaeff69dfe5a2dcf8f0e8e3cb0 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 200761cc9e..1b9e9d95b5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -67,8 +67,8 @@ skill/@subagent references skip the placeholder + occurrence identity chain — - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes. - Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast. -- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text. -- Sending is the literal text (no more `` serialization); on the bubble side MessageItem decorates both shapes (the legacy `` tag + plain-text tokens). +- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace or the draft end — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path and `/plan。` is prose; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text. +- Sending is the literal text (no more `` serialization); on the bubble side `projectUserText` decorates a plain-text `/name` token only when the same step logged a `skill-invocation` injection for that name — ui-chat's `SkillNameProjector` attaches the step's injected names to the direct message Node, the way the recall projector attaches session labels — so `/123` or a stray `/word` stays plain; a command-input bubble (ui-goal) names its executed command the same way and renders the token as a `command` chip; `@name` tokens still decorate by shape. - Decoration reactivity: the shell subscribes to the controller's lexicon store and re-scans the document on each roll change, so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render. ### Per-session provide contributions and the private keyboard surface diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 673d0ee4bd..c9bee217da 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -67,8 +67,8 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引 - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。 - source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。 -- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。 -- 发送即原文(不再 `` 序列化);气泡侧 MessageItem 双形状装饰(legacy `` 标签 + 纯文本 token)。 +- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白或 draft 末尾——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径、`/plan。` 是普通文本;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。 +- 发送即原文(不再 `` 序列化);气泡侧 `projectUserText` 只在同一步骤记录了该名字的 `skill-invocation` 注入时才装饰纯文本 `/name` token——ui-chat 的 `SkillNameProjector` 把该步骤注入的 skill 名挂到直接消息节点上,与 recall 投影挂会话标签的方式相同——因此 `/123` 或随手敲的 `/词` 保持普通文本;指令输入气泡(ui-goal)以同样方式指明其已执行的指令,把 token 渲染为 `command` chip;`@name` token 仍按形状装饰。 - 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。 ### 每会话供数贡献与键盘私面 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 671ca8f796..706794e459 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -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-07-23-web-assistant-markdown.md -2026-07-23-web-assistant-markdown.md: d2b8e30d779656636f70b05524c96796a254b57b -2026-07-23-web-assistant-markdown.zh.md: c1542d75faf1b484160f98b4217164b5df4e4b99 +2026-07-23-web-assistant-markdown.md: 4063ad647c485be295a55087247681a494dbeabf +2026-07-23-web-assistant-markdown.zh.md: 6b062e155c88bc8c3f3cf048ec463f3db0b4f7bc diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index d2b8e30d77..4063ad647c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -10,7 +10,7 @@ The Web conversation preserves assistant Markdown source through session events, ## Decision -`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal. +`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages render through `projectUserText` (inline plain runs plus reference chips) and remain literal. `MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences highlight incrementally: each chunk tokenizes newly completed text from a saved grammar state plus the still-growing last line, excluding the completed prefix from repeated work (the [streaming fence-highlight note](2026-08-20-web-streaming-fence-highlight.md) owns that mechanism). @@ -28,7 +28,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen **Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. *Later reversed on new evidence — incremental streaming parsing needs AST-level input the string-only wrapper cannot provide; the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that decision.* -**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly. +**Render user prompts and steering as Markdown too.** This formats authored input as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly. **Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index c1542d75fa..6b062e155c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -10,7 +10,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd ## 决策 -`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 +`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息经 `projectUserText` 渲染(行内普通片段加引用 chip),并保持按字面渲染。 `MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏增量高亮:每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,不重复处理已完成的前缀([流式围栏高亮 Note](2026-08-20-web-streaming-fence-highlight.zh.md) 拥有该机制)。 @@ -28,7 +28,7 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。 **将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要纯字符串封装无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有。* -**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。 +**把用户提示词与 steering 也按 Markdown 渲染。**这会产生格式化用户输入的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。 **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml index 1e3ada6894..51848d9768 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml @@ -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-04-web-slash-command-fuzzy-discovery.md -2026-08-04-web-slash-command-fuzzy-discovery.md: 8d7fe88f8d19a6edc7b51e63578c468df085c238 -2026-08-04-web-slash-command-fuzzy-discovery.zh.md: a96f9c984e32dd777950b9f9d8594b3a9c8b7c17 +2026-08-04-web-slash-command-fuzzy-discovery.md: 17ba1a1cba4876140bada74e65ba1b10ed5fdd76 +2026-08-04-web-slash-command-fuzzy-discovery.zh.md: fe23dcb5d3c14a492a5882b5bad4c84e256ef064 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md index 8d7fe88f8d..17ba1a1cba 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md @@ -6,13 +6,13 @@ English | [中文](2026-08-04-web-slash-command-fuzzy-discovery.zh.md) ## Problem -The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command. +The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. The skill source of the same `/` menu later kept a case-sensitive prefix filter, so the two groups of one menu answered the same keystrokes differently. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command. ## Decision -The `/` command source fuzzy-matches the typed query against command names as a case-insensitive ordered subsequence. Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host-directory and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking. +The `/` menu's command and skill sources fuzzy-match the typed query against candidate names as a case-insensitive ordered subsequence through one ranker, `rankByName` in ui-primitives, the narrow static owner for shared browser code ([client rules](../../../../packages/client/AGENTS.md)). Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host catalog and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking. -The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, while space and Enter adjudication continue to require an exact command token. +The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, the skill source still lands the literal `/name ` text the host resolves exactly, and space and Enter adjudication continue to require an exact command token. ## Alternatives considered @@ -22,6 +22,8 @@ The scorer uses dynamic programming in `O(query length × name length)` time and **Use a general fuzzy-search dependency.** Rejected because this surface needs one constrained subsequence rule over a small command catalog; a configurable search index would add bundle weight and ranking behavior not used by the product. +**Export the ranker from the command plugin or the trigger pipeline.** Rejected because a feature plugin exports no values beyond what cordis loading needs and never runtime-imports another feature plugin; peer agent products that share one matcher between commands and skills (Claude Code, Pi, Kimi Code) keep it in a shared library for the same reason. + ## Consequences -Users can discover a command from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. Package tests pin each ranking factor and stable ties, while the assembled Web replay snapshot pins `/cpt` resolving to `/compact`. Exact execution semantics are unchanged. +Users can discover a command or a skill from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. ui-primitives tests pin each ranking factor and stable ties, the command and skill sources pin that they rank through the shared ranker, and the assembled Web goldens pin `/cpt` resolving to `/compact` and a subsequence query resolving to one skill. Exact execution semantics are unchanged. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md index a96f9c984e..fe23dcb5d3 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。 +Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。同一 `/` 菜单的 skill source 后来仍保留区分大小写的前缀过滤,同一菜单的两个分组对同样的按键给出不同答案。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。 ## 决策 -`/` 命令 source 将键入的查询作为不区分大小写的有序子序列,与命令名进行模糊匹配。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。 +`/` 菜单的命令 source 与 skill source 将键入的查询作为不区分大小写的有序子序列,与候选名进行模糊匹配,二者共用一个排序器:ui-primitives 中的 `rankByName`,即共享浏览器代码的窄静态归属方([client 规则](../../../../packages/client/AGENTS.md))。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。 -评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。 +评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,skill source 仍落下由宿主精确解析的字面 `/name ` 文本,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。 ## 考虑过的替代方案 @@ -22,6 +22,8 @@ Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字 **使用通用模糊搜索依赖。** 否决,因为该界面只需对小型命令目录使用一种受限的子序列规则;可配置搜索索引会增加 bundle 体积,并引入产品未使用的排名行为。 +**从命令插件或触发管线导出排序器。** 否决,因为特性插件除 cordis 加载所需之外不导出任何值,也绝不运行时导入另一个特性插件;在命令与 skill 之间共用一个匹配器的同行产品(Claude Code、Pi、Kimi Code)出于同样的原因把它放在共享库中。 + ## 后果 -用户可以凭按顺序记得的字母发现命令;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。包测试固定各项排名因素以及同分时的稳定顺序,组装后的 Web 回放快照固定 `/cpt` 解析为 `/compact` 的行为。精确执行语义保持不变。 +用户可以凭按顺序记得的字母发现命令或 skill;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。ui-primitives 的测试固定各项排名因素以及同分时的稳定顺序,命令 source 与 skill source 的测试固定二者经共享排序器排名,组装后的 Web golden 固定 `/cpt` 解析为 `/compact`、以及一个子序列查询解析为唯一 skill 的行为。精确执行语义保持不变。 diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md deleted file mode 100644 index 328a3fffa1..0000000000 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md +++ /dev/null @@ -1,46 +0,0 @@ -# Agent Note: Command image-attachment envelope - -Status: implemented - -English | [中文](2026-08-17-command-image-attachment-envelope.zh.md) - -## Problem - -The Web composer submits one envelope — draft text, attached images, and delivery mode — but the two submission planes consumed it asymmetrically. A plain message rode `defaultSink → conversation.sendSession`, which serialized the images into prompt content and cleared them on success. A claimed slash command rode `claim.submit(args, actx)`, a text-only transaction: `/goal rebuild the cathedral` with four reference photos executed the command, cleared the draft, and silently stranded the images in the composer rail. The model never saw them, and no surface said so. The defect was contract-level, not a missed call site: nothing in the claim, the adjudication, or the host executor modeled attachments, so any command could consume the text half of a submission and drop the rest. - -Merging the two planes was not on the table — the [plugin command registration Agent Note](2026-07-19-plugin-command-registration.md) deliberately keeps human commands out of the model plane, and that separation is correct. The gap was that the envelope fractured at the plane fork. - -## Decision - -The submission envelope is modeled end to end, and every command route either consumes it whole or refuses it loudly. - -**Declaration.** `CommandDefinition.input.images: boolean` (absent = false) declares whether composer images may accompany an invocation. The flag rides the frozen `CommandDescriptor` through `commands/list` to every client, onto the minted `CommandClaim` (`images: true`), and into the input machine's published claim snapshot. - -**Generic identity, image-specific payload.** Browser drafts and durable references already use `DraftAttachmentId` and `AttachmentId`; the command RPC carries encoded bytes rather than an image identifier. The wire remains `EncodedImageAttachment[]`, and the declaration remains `input.images`, while images are the only non-text attachment with defined admission and model-block semantics. - -**Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission goes through the attachment package's `admitEncodedImages` — the shared wire entry that enforces canonical base64 and delegates batch admission (limits, validation, ordered commit) to `AttachmentStore.saveImages` — so both wire endpoints (prompt RPC and command executor) share one sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`. - -**Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan ` folds the images into its steered text message, while bare `/plan` steers an image-only user message because the images may contain the whole task. Producer control forms with no model input (`/goal pause`, `/plan off`) return a direct error and keep the composer's images in place. The plan projection treats `command/run` as a candidate and drops it on a paired `command/done` error, so a rejected image-carrying `/plan off` cannot leave a pending exit. - -**Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine publishes one error notice, which the composer renders through its transient Toast banner with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them. - -## Testing - -Registry executor enforcement, admission failure settlement, and frozen invocation attachments are covered in `packages/interaction/commands/tests/commands.spec.ts`; batch admission ordering and limits in `packages/attachment/attachment/tests/admission.spec.ts`; producer behavior in `packages/goal/command-goal/tests/command-goal.spec.ts` and `packages/plan/plan-mode/tests/plan-mode.spec.ts`; client refusal and consumption paths in the ui-commands, ui-conversation, and ui-input-trigger client suites; and the assembled-application flow in the apps/web keyless lanes. - -## Alternatives considered - -- **Block commands whenever images are attached (no acceptance path)** — rejected: predictable, but `/goal` with reference images is the motivating use case; the user's images would have no route to the model at all. -- **Auto-send stranded images as a follow-up user message after any command** — rejected: surprising for host-state commands (`/model`, `/compact`), and it moves the message contract from the producer to the composer, against the command registry's "producer owns model-visible work" rule. -- **Store attachment references in the goal domain and render them into round prompts** — rejected: requires durable goal schema changes and either duplicates image blocks into every round prompt or adds round-one-only prompt shape; the round-prompt invariant would need attachment state. One ordinary logged user message achieves the same model visibility. -- **Consume images on any command success regardless of grammar** — rejected: `/goal pause` with images attached would silently discard them, recreating the original defect one layer deeper. Consumption is tied to the producer's explicit success, and grammar misfits return errors. -- **Keep enforcement client-side only** — rejected: schema omission is not enforcement; direct RPC callers could bypass the composer. The executor settles the declaration itself. -- **Generalize the command wire to a multimedia identifier** — rejected: the two identifiers are already attachment-generic, while the wire transports bytes and its image-specific fields state the admission rules the Host enforces. Files and videos lack shared admission and model-visible semantics, and an untagged multimedia identifier would not supply them. A second supported attachment kind is the reintroduction condition; the command envelope then widens to a tagged attachment union and commands declare the accepted kinds while retaining `AttachmentId`. - -## Consequences - -- No command route can consume a submission's text and strand its images: the contract forces whole-envelope consumption or a visible refusal, for current and future commands alike. -- The commands package now depends on `dsh-attachment` and `dsh-llm`, and `commands/execute` carries a required `images` wire parameter — every caller states its envelope explicitly. -- `/goal` and `/plan` gain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), including an image-only message for bare `/plan`; all are billed like any image prompt. -- Menu-pick popup flows do not consult the envelope: picking a popup command from the menu while images are attached leaves the images visibly in the rail rather than refusing the interaction. Enter-submission is the enforced envelope boundary. -- "A rejected batch publishes no durable object" covers exactly the pre-admission settlements (declaration, missing store, batch limit). A handler-level grammar rejection (`/goal pause` with images) and a post-admission cancellation settle AFTER the batch committed, leaving content-addressed objects without a referencing session event — harmless under sha256 dedup and the attachment store's deferred reference-aware GC, but not "no object was written". diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md deleted file mode 100644 index 9de197d066..0000000000 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md +++ /dev/null @@ -1,46 +0,0 @@ -# Agent Note: Command image-attachment envelope - -Status: implemented - -[English](2026-08-17-command-image-attachment-envelope.md) | 中文 - -## Problem - -Web composer 的一次提交是一个信封——草稿文本、已附加图片、投递模式——但两条提交平面对它的消费是不对称的。普通消息走 `defaultSink → conversation.sendSession`,图片被序列化进 prompt 内容并在成功后清除。被 claim 的斜杠命令走 `claim.submit(args, actx)`,一个纯文本事务:`/goal rebuild the cathedral` 带四张参考照片时,命令执行、草稿清空,图片却静默滞留在 composer 附件栏。模型从未看到它们,也没有任何界面提示。这个缺陷在契约层面而非某个漏掉的调用点:claim、裁决、宿主执行器都没有建模附件,因此任何命令都可能消费提交的文本一半而丢弃其余部分。 - -合并两个平面从未在考虑范围内——[插件命令注册 Agent Note](2026-07-19-plugin-command-registration.zh.md)刻意让人类命令留在模型平面之外,这个分离是正确的。问题在于信封在平面分叉处被拆散了。 - -## Decision - -提交信封被端到端建模,每条命令路径要么整体消费它,要么响亮拒绝。 - -**声明。**`CommandDefinition.input.images: boolean`(缺省为 false)声明 composer 图片是否可以随调用提交。该标志随冻结的 `CommandDescriptor` 经 `commands/list` 到达每个客户端,进入铸造出的 `CommandClaim`(`images: true`),再进入输入状态机发布的 claim 快照。 - -**通用标识,图片专用载荷。**浏览器草稿与持久化引用已经使用 `DraftAttachmentId` 和 `AttachmentId`;命令 RPC 传输的是编码字节,而非图片标识。图片仍是唯一已经定义准入规则和模型块语义的非文本附件,因此 wire 保持 `EncodedImageAttachment[]`,声明保持 `input.images`。 - -**执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入经由 attachment 包的 `admitEncodedImages`——共享 wire 入口,强制执行规范 base64 并把批量准入(限额、校验、有序提交)委托给 `AttachmentStore.saveImages`——使两个 wire 端点(prompt RPC 与命令执行器)共享同一序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。 - -**模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan ` 把图片并入其 steer 的文本消息;不带参数的 `/plan` 则 steer 一条只含图片的用户消息,因为图片可能包含全部任务内容。不会发送模型输入的控制形式(`/goal pause`、`/plan off`)会直接返回错误,composer 的图片原地保留。plan 投影会把 `command/run` 视为候选选择,并在配对的 `command/done` 报错时丢弃它,因此被拒绝的带图 `/plan off` 不会留下待退出状态。 - -**composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机发布一条错误通知,composer 通过瞬态 Toast 横幅呈现它,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。 - -## Testing - -注册表执行器强制、准入失败结算、冻结的调用附件由 `packages/interaction/commands/tests/commands.spec.ts` 覆盖;批量准入顺序与限额在 `packages/attachment/attachment/tests/admission.spec.ts`;生产方行为在 `packages/goal/command-goal/tests/command-goal.spec.ts` 与 `packages/plan/plan-mode/tests/plan-mode.spec.ts`;客户端拒绝与消费路径在 ui-commands、ui-conversation、ui-input-trigger 客户端套件;组装后应用流程在 apps/web 的 keyless 通道。 - -## Alternatives considered - -- **附加图片时一律拦截命令(没有接受路径)**——被拒绝:可预测,但带参考图的 `/goal` 正是驱动这次修复的用例,用户的图片将完全没有通往模型的路径。 -- **任何命令后把滞留图片自动作为后续用户消息发送**——被拒绝:对宿主状态命令(`/model`、`/compact`)令人意外,且把消息契约从生产方挪到 composer,违反命令注册表「生产方负责模型可见工作」的规则。 -- **在 goal 领域存储附件引用并渲染进 Round 提示词**——被拒绝:需要持久化 goal schema 变更,且要么把图片块复制进每轮提示词,要么引入仅首轮的提示词形态;round 提示词不变量将需要附件状态。一条普通的已记录用户消息达到同样的模型可见性。 -- **只要命令成功就消费图片,不管语法**——被拒绝:`/goal pause` 带图会把图片静默丢弃,在更深一层重演原始缺陷。消费与生产方的显式成功绑定,语法不匹配返回错误。 -- **只在客户端强制**——被拒绝:schema 省略不是强制执行;直接 RPC 调用方可以绕过 composer。执行器自己结算声明。 -- **把命令 wire 泛化成多媒体标识**——被拒绝:两个标识已经是附件通用类型,wire 传输的是字节,其图片专用字段明确表达了 Host 强制执行的准入规则。文件和视频尚无共同的准入规则与模型可见语义,一个不带类型标记的多媒体标识也无法提供这些信息。出现第二种受支持附件时再引入泛化:命令信封扩展为带类型标记的附件联合类型,命令声明接受的类型,`AttachmentId` 保持不变。 - -## Consequences - -- 任何命令路径都不可能消费提交的文本而滞留图片:契约强制整信封消费或可见拒绝,对现有与未来命令一体适用。 -- commands 包新增对 `dsh-attachment` 与 `dsh-llm` 的依赖,`commands/execute` 携带必填的 `images` wire 参数——每个调用方都显式陈述其信封。 -- `/goal` 与 `/plan` 获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),其中不带参数的 `/plan` 会产生只含图片的消息;所有这些输入的计费都与常规图片提示词相同。 -- 菜单点选的弹窗流程不查询信封:附有图片时从菜单点选弹窗命令,图片会可见地留在附件栏,而不是拒绝该交互。回车提交是被强制执行的信封边界。 -- 「被拒绝的批量不发布任何持久化对象」只覆盖准入前的三种结算(声明、存储缺失、批量超限)。handler 级语法拒绝(如 `/goal pause` 带图)与准入后取消发生在批量已提交之后,会留下没有会话事件引用的内容寻址对象——在 sha256 去重与附件存储延后的引用感知 GC 下无害,但并非「未写入任何对象」。 diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml similarity index 55% rename from .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml rename to .agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 6851450147..fa6f76a300 100644 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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-17-command-image-attachment-envelope.md -2026-08-17-command-image-attachment-envelope.md: 328a3fffa1d8db3ac9be42983965ef7f9578dec9 -2026-08-17-command-image-attachment-envelope.zh.md: 9de197d06692523b7b5bca4f7aac36ce9f44b105 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +2026-08-26-generic-file-upload.md: d8643f5f1a3e0dcdfdc7fc8b79cbb156b07f4402 +2026-08-26-generic-file-upload.zh.md: 38de46a424e3f02b7c0293e15835d096b108f071 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md new file mode 100644 index 0000000000..d8643f5f1a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -0,0 +1,50 @@ +# Agent Note: Generic file upload + +Status: implemented + +English | [中文](2026-08-26-generic-file-upload.zh.md) + +## Problem + +Sessions accepted pasted or dropped images as durable attachments, but no other file kind could enter a conversation: the composer refused non-image bytes, and the only file affordance was the `@` path mention, which requires the file to already be readable from the host workspace. A user could not hand the agent a PDF, a data file, or an archive from a browser, and DeepSeek Chat users expect the paperclip-and-file-card upload flow (#2984). + +The command plane had already established a whole-envelope rule for images: a command consumes the text and attachments together or visibly refuses the submission. Its `input.images` declaration and image-only RPC payload made generic files a separate refusal path even after ordinary messages could carry them. Plan and goal entry therefore needed the same attachment vocabulary without weakening executor enforcement or silently clearing a draft. + +## 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//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` 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`; 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. + +## Alternatives considered + +- **Store uploads in the project root** (the OpenHands / ChatGPT `/mnt/data` shape). Rejected: files in the user's tree can be edited, moved, or deleted, which breaks the session log's stable reference, pollutes `git status`, and requires a workspace to exist. `DSH_HOME` keeps frozen read-only bytes with content addressing; execution-world visibility is served by the projected path and can gain a copy-into-workspace step later. +- **A dedicated `read_file` tool.** Rejected: the handle line points at an ordinary path, so the existing `read` tool (and the model's other file tools) already covers it; a second read tool would split model behavior without adding capability. +- **Eager content injection into the prompt.** Rejected for files exactly as it was for `@` mentions ([web file references](2026-07-27-web-file-and-session-references.md)): relevance is unknown at attach time, and lazy tool reads keep every content access logged. +- **Type whitelist and size caps.** Rejected for now: DeepSeek Chat's whitelist exists because its server parses uploads, while this harness only stores bytes and lets the model read them. The streaming carrier and store keep memory proportional to bounded chunks, so a cap would govern disk and transfer policy rather than request safety. +- **A new attachment/file session event.** Rejected: the block-inside-`user/message` pattern images already use satisfies model-visible ⟺ logged without widening the event vocabulary. +- **Block every command while attachments are present.** Rejected: `/goal` objectives and `/plan` entry need reference files, so a global refusal would leave those files with no command route to the model. +- **Automatically send attachments after any successful command.** Rejected: state commands such as `/model` and `/compact` do not imply model-visible input. The command producer owns whether an admitted batch becomes a user message. +- **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 an explicit Session identity and lets browser and Worker-hosted pages replace the carrier without widening Connection or Session. + +## Consequences + +- Uploaded bytes are never deleted and carry no size limit, so `DSH_HOME` growth is unbounded; garbage collection stays deferred with the image-retention question. +- The projected path is execution-environment-local: a sandboxed or remote filesystem that cannot map `DSH_HOME` yields an explicit no-path handle, and the model reports that it cannot read the file. A store-backed read fallback is deferred. +- 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. +- The command registry admits a tagged `CommandSubmitAttachment` union whose file member carries a staged receipt, then hands handlers one frozen ordered `ImageBlock | FileBlock` array. The registry never schedules model input; `/goal` adds one logged follow-up and `/plan` steers the ordered blocks. +- Menu popup selection does not submit the composer envelope. Attachments remain visible until the user submits a command line or removes them. +- Declaration, missing-store, unknown-receipt, and image-limit rejections occur before handler entry. A producer grammar rejection and cancellation after image admission may leave unreferenced content-addressed objects for deferred attachment garbage collection. + +## Testing + +Unit and integration coverage pins verbatim and streamed storage, name sanitization, a declared 2.19 GiB request passing through the streaming bridge without aggregate buffering, raw and RPC wire admission, Blob and transferable `ReadableStream` carriers, bounded background-upload concurrency, progress and cancellation, cross-Session upload residency, command file-receipt submission without a second byte read, native and PTC projection under read-only and workspace-write permissions, upload staging, ordered queue and steer submission, queue-to-steer conversion, pending echo retirement and failure recovery, active plan and goal intake, `/plan` and `/goal` mixed attachments, subagent refusal, composer and Chat attachment layouts, and Trajectory file summaries. The keyless `file-upload-round` snapshot records the browser upload, model file read, and rendered answer. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md new file mode 100644 index 0000000000..38de46a424 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 通用文件上传 + +Status: implemented + +[English](2026-08-26-generic-file-upload.md) | 中文 + +## 问题 + +会话可以把粘贴或拖入的图片作为持久附件接收,但其他任何文件都进不了对话:composer 拒绝非图片字节,唯一与文件相关的入口是 `@` 路径引用,而它要求文件本来就能从宿主工作区读到。用户无法从浏览器把 PDF、数据文件或压缩包交给智能体,而 DeepSeek Chat 用户期待回形针加文件卡的上传交互(#2984)。 + +命令平面已经为图片建立了完整提交规则:命令必须同时消费文本与附件,或明确拒绝整次提交。`input.images` 声明和只支持图片的 RPC 载荷使通用文件在普通消息可用后仍走独立拒绝路径。Plan 和 goal 入口需要使用同一附件词汇,同时保留执行器强制规则,不能静默清除草稿。 + +## 决定 + +文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收所属 Session 标识与 `Blob`、精确字节或只能使用一次的 `ReadableStream`;它使用该标识组装原始路由请求,或直接调用生成的 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 会拒绝收到的任何子会话文件块。 + +## 考虑过的替代方案 + +- **把上传存进项目根目录**(OpenHands / ChatGPT `/mnt/data` 的形态)。拒绝:用户目录里的文件可能被编辑、移动或删除,会打断会话日志的稳定引用,污染 `git status`,还要求工作区必须存在。`DSH_HOME` 用内容寻址保存冻结的只读字节;执行世界可见性由投影路径提供,之后还可以补一步拷贝进工作区。 +- **专用的 `read_file` 工具。** 拒绝:handle 文本指向一条普通路径,现有 `read` 工具(以及模型的其他文件工具)已经覆盖它;再加一个读工具只会分裂模型行为而不增加能力。 +- **把内容急切注入提示词。** 对文件的拒绝理由与当年对 `@` 引用完全相同([web 文件引用](2026-07-27-web-file-and-session-references.zh.md)):附加时相关性未知,惰性工具读取让每次内容访问都留在日志里。 +- **类型白名单与大小上限。** 现阶段拒绝:DeepSeek Chat 的白名单存在是因为其服务端要解析上传,而本 harness 只存字节、让模型自己读。流式载体和存储让内存占用与有界分块大小相关,因此上限属于磁盘与传输策略,不用于保证请求安全。 +- **新增 attachment/file 会话事件。** 拒绝:图片已经采用的「块内联在 `user/message`」模式即可满足模型可见 ⟺ 可日志重建,无需扩大事件词汇。 +- **有附件时阻止所有命令。** 拒绝:`/goal` 目标与 `/plan` 进入需要参考文件,统一拒绝会使这些文件没有进入模型的命令路径。 +- **任何命令成功后自动发送附件。** 拒绝:`/model` 与 `/compact` 等状态命令不表示模型可见输入。附件是否成为用户消息由命令生产方负责。 +- **把目标附件存进 goal 领域。** 拒绝:一条普通的已记录用户消息就能让后续 Goal Round 获得相同历史,无需扩大 goal schema 或把附件复制进 Round 提示词。 +- **任何命令成功都消费附件。** 拒绝:`/goal pause` 或 `/plan off` 会清除无关卡片。只有生产方明确成功才消费附件;无法使用附件的语法形式返回错误。 +- **只在客户端执行声明。** 拒绝:直接 RPC 调用方可以绕过 composer。`CommandRuntime` 在处理器运行前强制检查声明、存储可用性、同一 Session 的文件凭证解析、图片 base64 准入与图片限制。 +- **通过 `ctx.connection` 暴露上传并沿 Session 构造器传递该对象。** 拒绝:Connection 不拥有文件传输,中间对象层也不使用这项依赖。独立上传服务直接接收 Session 标识,并允许普通浏览器与 Worker-hosted 页面替换载体,无需扩大 Connection 或 Session。 + +## 后果 + +- 上传字节永不删除且没有大小上限,`DSH_HOME` 的增长无界;垃圾回收与图片保留问题一同继续挂起。 +- 投影路径只在对应执行环境中有效:无法映射 `DSH_HOME` 的沙箱或远程文件系统会得到显式的无路径 handle,模型会报告无法读取该文件。基于存储的读取兜底被推迟。 +- 暂存上传按 Session 存在 Host 内存里;上传与发送之间 Host 重启会出现 `FILE_NOT_STAGED`,客户端重新添加文件即可。 +- 浏览器到 Host 的传输使用一次不带断点续传偏移的流式 HTTP 请求;连接或 Host 失败后的重试会从第一个字节重新传输整个文件。 +- `ReadableStream` 上传只消费并转移一次 stream;重试必须重新创建 stream,而且在未来 API 允许生产方提供长度前,进度不能报告总量。 +- 浏览器上传 Worker 由自包含函数的字符串形式生成。需要运行时 import 时,必须改用由 tsdown 打包的独立 Worker 入口。 +- 命令必须声明接受附件。命令拒绝时保留文本草稿与全部附件卡,供用户重试。 +- 子代理不独立上传文件。主 agent 委派时传递保存路径;只有同机或继承父级历史且执行环境能映射该路径的子级可以读取。 +- 上传的文件尚未进入 `@` 补全;该联动是计划中的后续阶段。 +- 命令注册表准入带类型标记的 `CommandSubmitAttachment` 联合类型,其中的文件成员携带暂存凭证,再向处理器提供一个冻结的有序 `ImageBlock | FileBlock` 数组。注册表不安排模型输入;`/goal` 增加一条已记录 follow-up,`/plan` steer 有序附件块。 +- 从菜单选择弹窗命令不会提交 composer 的整套内容。附件保持可见,直到用户提交命令行或移除附件。 +- 声明、存储缺失、未知凭证与图片限制拒绝发生在处理器运行前。生产方语法拒绝和图片准入后的取消可能留下无引用的内容寻址对象,由后续附件垃圾回收处理。 + +## 测试 + +单元与集成覆盖原样与流式存储、文件名清洗、声明为 2.19 GiB 的请求通过流式 bridge 且不发生总量缓冲、原始字节与 RPC wire 准入、Blob 与可转移 `ReadableStream` 载体、后台上传并发上限、进度与取消、跨 Session 上传常驻、命令通过文件凭证提交且不再次读取字节、read-only 与 workspace-write 权限下的 native 和 PTC 投影、上传暂存、有序 queue 与 steer 提交、队列转 steer、pending 回显退休与失败恢复、active plan 和 goal 的附件入口、`/plan` 与 `/goal` 混合附件、子代理拒绝、composer 与 Chat 附件布局,以及 Trajectory 文件摘要。keyless `file-upload-round` 快照记录浏览器上传、模型读取文件与答案呈现。 diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 6c2ac3db7b..557cabe74a 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -82,6 +82,9 @@ async function bootWeb( // skills test below proves it reaches preset-composed agents. { id: 'skill-badge', disabled: false }, { id: 'modules', disabled: true }, + // The physical Connection row owns the disabled HTTP server. bootWeb + // supplies only its in-process registries so Host services still prove + // their shipped dependency graph without binding a port. { id: 'connection', disabled: true }, // Export owns a Connection Fetch route, so this Host-only composition // disables it with the transport service above. @@ -139,6 +142,10 @@ async function bootWeb( const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], (bootCtx) => { + bootCtx.provide('connection', { + fetch: { register: () => () => {} }, + rpc: { intercept: () => () => {} }, + } as never) provideCmdline(bootCtx, { args: [], exit: () => {} }) }) } diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 4155fd338e..db33227043 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -44,17 +44,6 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { return page.getByRole('dialog', { name: '设置' }) } - /** Tokenize the lane-owned preset root after general aria normalization. */ - function withPresetRoot(snapshot: string): string { - const rootSuffix = `/${userRoot.split('/').pop()!}` - return snapshot.split('\n').map((line) => { - const rootStart = line.indexOf(rootSuffix) - if (rootStart === -1) return line - const pathStart = line.lastIndexOf(' ', rootStart) + 1 - return `${line.slice(0, pathStart)}{{presetRoot}}${line.slice(rootStart + rootSuffix.length)}` - }).join('\n') - } - beforeAll(async () => { userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-'))) scaffold = await launchWebScaffold({ @@ -147,8 +136,9 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { // The copy dialog is detached, so the settings dialog is the only one // left (it names itself via aria-labelledby, which a CSS attribute // selector cannot address). - const snapshot = withPresetRoot( - await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, { + replacements: [[userRoot, '{{presetRoot}}']], + }) await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE) expect(snapshot).toContain('{{presetRoot}}/my-agent') @@ -197,8 +187,9 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await dialog.getByRole('button', { name: 'Agent 预设' }).click() await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 }) - const snapshot = withPresetRoot( - await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, { + replacements: [[userRoot, '{{presetRoot}}']], + }) await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE) // Both damage shapes surface as marked, unselectable, uncopyable cards // that still carry their metadata and the discovery-reported reason. diff --git a/apps/web/tests/command-image-envelope.expected.e2e.ts b/apps/web/tests/command-image-envelope.expected.e2e.ts index 47fb91069b..f7da8f088a 100644 --- a/apps/web/tests/command-image-envelope.expected.e2e.ts +++ b/apps/web/tests/command-image-envelope.expected.e2e.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom -// The command image-attachment envelope over the BUILT client graph (real +// The command attachment envelope over the BUILT client graph (real // bundles via AppWebEntry, keyless fixture Connection RPC): an enter -// submission carrying composer images resolves only through a command whose -// descriptor declares `input.images`. A non-declaring command refuses with +// submission carrying composer attachments resolves only through a command whose +// descriptor declares `input.attachments`. A non-declaring command refuses with // one composer error banner and everything retained; a declaring command // consumes the images — serialized through the real draft-image chain into // the commands/execute payload — and clears the composer on success, including @@ -47,7 +47,7 @@ async function pasteImage(textarea: HTMLElement, name: string): Promise { }, }) await waitFor(() => { - const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]') if (rail === null) throw new Error('attachment rail missing') expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toContain(name) }, { timeout: 5_000 }) @@ -58,7 +58,7 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft const textarea = await freshComposer() await pasteImage(textarea, 'ref.png') - // /echo is a leadingInput fixture command without `input.images`. + // /echo is a leadingInput fixture command without `input.attachments`. await pasteText(textarea, '/echo hello') fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -66,16 +66,16 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft // failures; session activity remains on its separate status live region. const notice = await waitFor(() => { const el = [...document.querySelectorAll('[role="alert"]')] - .find(candidate => candidate.textContent?.includes('image attachments') ?? false) + .find(candidate => candidate.textContent?.includes('attachments') ?? false) if (el === undefined) throw new Error('composer refusal banner missing') return el }, { timeout: 5_000 }) - expect(notice.textContent).toBe('/echo does not accept image attachments; remove them first') + expect(notice.textContent).toBe('/echo does not accept attachments; remove them first') expect([...document.querySelectorAll('[role="status"]')] - .some(candidate => candidate.textContent?.includes('image attachments') ?? false)).toBe(false) + .some(candidate => candidate.textContent?.includes('attachments') ?? false)).toBe(false) // The whole envelope is retained: draft text and the rail thumbnail. await waitFor(() => { expect(textarea.textContent).toBe('/echo hello') }) - const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]') expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png']) }) @@ -84,14 +84,14 @@ it('consumes images through a declaring command and clears the composer on succe const textarea = await freshComposer() await pasteImage(textarea, 'goal-ref.png') - // /goal declares `input.images` in the fixture catalog; the claim submit + // /goal declares `input.attachments` in the fixture catalog; the claim submit // serializes the pasted bytes and the fixture executor admits them. await pasteText(textarea, '/goal rebuild the cathedral') fireEvent.keyDown(textarea, { key: 'Enter' }) await waitFor(() => { expect(textarea.textContent).toBe('') - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull() }, { timeout: 5_000 }) }) @@ -107,7 +107,7 @@ it('submits a bare /plan with an image as an image-only plan request', async () await waitFor(() => { expect(textarea.textContent).toBe('') - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull() }, { timeout: 5_000 }) expect([...document.querySelectorAll('[role="alert"]')] .some(candidate => candidate.textContent?.includes('/plan') ?? false)).toBe(false) diff --git a/apps/web/tests/expected/file-upload-round/draft.expected.md b/apps/web/tests/expected/file-upload-round/draft.expected.md new file mode 100644 index 0000000000..aa5ef30e82 --- /dev/null +++ b/apps/web/tests/expected/file-upload-round/draft.expected.md @@ -0,0 +1,9 @@ +# Mixed composer attachment rail + +- selection order: poem.txt > reference-1.png > reference-2.png > reference-3.png > reference-4.png > reference-5.png > reference-6.png > reference-7.png > reference-8.png > reference-9.png > reference-10.png +- one attachment group: true +- all cards share one row: true +- every card is 64px high: true +- the file card is wider than an image: true +- overflowing cards scroll horizontally: true +- the rail does not wrap: true diff --git a/apps/web/tests/expected/file-upload-round/history.expected.md b/apps/web/tests/expected/file-upload-round/history.expected.md new file mode 100644 index 0000000000..1427ef8004 --- /dev/null +++ b/apps/web/tests/expected/file-upload-round/history.expected.md @@ -0,0 +1,10 @@ +# Mixed history attachment flow + +- source order: poem.txt > reference-1.png +- one attachment group: true +- file and image share one row: true +- both cards are 64px high: true +- the image is a 64px tile: true +- the file card is wider than the image: true +- the group wraps when needed: true +- the group is right-aligned: true diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 3a29a14b51..e5e246ad19 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -51,6 +51,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current github-webhook-review-test/reply": - text: github-webhook-review-test/reply diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 0a5fe67b90..fa6bf3f25c 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -43,6 +43,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current github-webhook-review-test/reply": - text: github-webhook-review-test/reply diff --git a/apps/web/tests/expected/goal-command-presentation/ui.expected.md b/apps/web/tests/expected/goal-command-presentation/ui.expected.md index c10eef6aac..432a51de4d 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -17,6 +17,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index 2fba524c5d..c9709a6386 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -51,6 +51,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 3843143d1f..f26dfd8214 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -30,6 +30,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index 2d2127cbb2..41424d0ee1 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -42,6 +42,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index fba05ffe71..21023fdc64 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -46,6 +46,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 7cec2eaf08..3ff4bf9c21 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -16,6 +16,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md b/apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md new file mode 100644 index 0000000000..798d9510c4 --- /dev/null +++ b/apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md @@ -0,0 +1,3 @@ +- listbox "Trigger suggestions": + - text: Skills + - option "policy-user-only user-only · Available only to user invocation" [selected] diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 07be7102ff..d55c6b0d35 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -43,6 +43,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index 7ddf071159..545e0c93b4 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index fc8039e282..9354a75fae 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -462,6 +462,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index c3847df394..1456ffa683 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -34,6 +34,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 73e987dfa2..c3399b4739 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -53,6 +53,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index cd48c29075..cf24996c06 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -41,6 +41,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts new file mode 100644 index 0000000000..409c46ca6c --- /dev/null +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -0,0 +1,283 @@ +// Web e2e scenario: generic file upload round trip. A real chromium picks a +// file through the composer paperclip input; the upload RPC stores the exact +// bytes below the scaffold's isolated DSH_HOME, the prompt cites the staged +// reference, request assembly projects the file block to handle text, and the +// model (replayed or live) reads the saved copy with the REAL read tool. The +// content-addressed store makes the saved path identical across record and +// replay once the workspace cwd is tokenized, so the recorded read arguments +// replay verbatim against a freshly re-uploaded object. +// Record: DSH_SNAPSHOT=record rewrites session.v2.jsonl, then a keyless +// DSH_SNAPSHOT=refresh regenerates ui.expected.md. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/file-upload-round', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v2.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url)) +const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url)) +const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url)) +const DRAFT_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/draft.expected.md', import.meta.url)) +const HISTORY_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/history.expected.md', import.meta.url)) +const IMAGE_FIXTURE = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url)) +const MODE = webSnapshotMode() + +/** The uploaded fixture file: constant bytes so record and replay share one content digest. */ +const FILE_NAME = 'poem.txt' +const FILE_TEXT = 'UPLOAD_ROUND_OK\n' +const PROMPT = 'Read the attached file with the read tool, reply with exactly the single word it contains, and stop.' +const IMAGE_NAMES = Array.from({ length: 10 }, (_unused, index) => `reference-${String(index + 1)}.png`) + +/** Browser-measured relations for the mixed composer attachment rail. */ +interface DraftRailGeometry { + readonly order: readonly string[] + readonly oneGroup: boolean + readonly oneRow: boolean + readonly equalHeight: boolean + readonly fileWider: boolean + readonly horizontalOverflow: boolean + readonly noWrap: boolean +} + +/** Render stable relations instead of platform-dependent absolute coordinates. */ +function renderDraftRailGeometry(geometry: DraftRailGeometry): string { + return [ + '# Mixed composer attachment rail', + '', + `- selection order: ${geometry.order.join(' > ')}`, + `- one attachment group: ${String(geometry.oneGroup)}`, + `- all cards share one row: ${String(geometry.oneRow)}`, + `- every card is 64px high: ${String(geometry.equalHeight)}`, + `- the file card is wider than an image: ${String(geometry.fileWider)}`, + `- overflowing cards scroll horizontally: ${String(geometry.horizontalOverflow)}`, + `- the rail does not wrap: ${String(geometry.noWrap)}`, + ].join('\n') +} + +/** Browser-measured relations for one durable mixed-attachment message. */ +interface HistoryAttachmentGeometry { + readonly order: readonly string[] + readonly oneGroup: boolean + readonly oneRow: boolean + readonly equalHeight: boolean + readonly imageIsTile: boolean + readonly fileWider: boolean + readonly wrapsWhenNeeded: boolean + readonly rightAligned: boolean +} + +/** Render stable history-layout relations instead of absolute coordinates. */ +function renderHistoryAttachmentGeometry(geometry: HistoryAttachmentGeometry): string { + return [ + '# Mixed history attachment flow', + '', + `- source order: ${geometry.order.join(' > ')}`, + `- one attachment group: ${String(geometry.oneGroup)}`, + `- file and image share one row: ${String(geometry.oneRow)}`, + `- both cards are 64px high: ${String(geometry.equalHeight)}`, + `- the image is a 64px tile: ${String(geometry.imageIsTile)}`, + `- the file card is wider than the image: ${String(geometry.fileWider)}`, + `- the group wraps when needed: ${String(geometry.wrapsWhenNeeded)}`, + `- the group is right-aligned: ${String(geometry.rightAligned)}`, + ].join('\n') +} + +describe('web e2e: generic file upload through the real assembly', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + compareReplaySession: true, + // The override rescripts the recorded read arguments with a + // `{{fromRequest:…}}` placeholder: the saved-copy path differs per run, + // and the live handle line in the request carries the current one. + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, replayOverride: OVERRIDE, paceMs: 15 }), + }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + await page.setViewportSize({ width: 900, height: 900 }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('uploads on pick, gates send on the staged receipt, and settles the turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-drive')) + if (MODE !== 'record') { + // Drift guard: the committed fixture must carry exactly the drive prompt. + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('[data-composer-input]').first() + await input.waitFor({ timeout: 10_000 }) + const modelTrigger = page.getByRole('button', { name: /^Select model, current/ }) + await modelTrigger.click() + await page.getByRole('menuitem', { name: /^Model\b/ }).click() + await page.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash-Vision-Exp' }).click() + await expect.poll(() => modelTrigger.getAttribute('aria-label'), { timeout: 10_000 }) + .toContain('DeepSeek-V4-Flash-Vision-Exp') + const imageBytes = await readFile(IMAGE_FIXTURE) + // Pick through the composer's hidden file input: the upload RPC runs + // immediately and the pending card appears before any prompt is typed. + await page.locator('input[type="file"]').setInputFiles([ + { name: FILE_NAME, mimeType: 'text/plain', buffer: Buffer.from(FILE_TEXT) }, + ...IMAGE_NAMES.map(name => ({ name, mimeType: 'image/png', buffer: imageBytes })), + ]) + await page.getByTitle(FILE_NAME).waitFor({ timeout: 10_000 }) + const rail = page.getByRole('group', { name: 'Pending attachments' }) + await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 }) + .toBe(IMAGE_NAMES.length + 1) + const geometry = await rail.evaluate((element): DraftRailGeometry => { + const cards = [...element.children] as HTMLElement[] + const boxes = cards.map(card => card.getBoundingClientRect()) + const imageWidth = boxes[cards.findIndex(card => card.querySelector('img') !== null)]?.width ?? 0 + const fileWidth = boxes[cards.findIndex(card => card.querySelector('[title="poem.txt"]') !== null)]?.width ?? 0 + return { + order: cards.map(card => card.querySelector('img')?.getAttribute('alt') + ?? card.querySelector('[title]')?.title ?? ''), + oneGroup: document.querySelectorAll('[role="group"][aria-label="Pending attachments"]').length === 1, + oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5), + equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5), + fileWider: fileWidth > imageWidth, + horizontalOverflow: element.scrollWidth > element.clientWidth, + noWrap: getComputedStyle(element).flexWrap === 'nowrap', + } + }) + await compareOrRefreshGolden(DRAFT_EXPECTED, renderDraftRailGeometry(geometry), MODE) + for (const name of IMAGE_NAMES.slice(1)) { + await page.getByRole('button', { name: `Remove image ${name}` }).click({ force: true }) + } + await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 }).toBe(2) + await input.fill(PROMPT) + // Send unlocks only after the upload receipt lands (the staged file gate). + const send = page.getByRole('button', { name: 'Send message' }) + await send.waitFor({ state: 'visible', timeout: 15_000 }) + await expect.poll(() => send.isEnabled(), { timeout: 15_000 }).toBe(true) + const settled = scaffold.whenTurnSettled() + await send.click() + const restoreDeadline = Date.now() + 10_000 + let retainedCards = 0 + while (retainedCards === 0 + && !sessionEvents.some(event => event.type === 'turn/start') + && Date.now() < restoreDeadline) { + await page.waitForTimeout(100) + retainedCards = await rail.locator(':scope > *').count() + } + if (retainedCards !== 0) { + const feedback = await page.locator('[role="alert"], [role="status"]').allTextContents() + throw new Error(`submission retained ${String(retainedCards)} draft cards; feedback=${JSON.stringify(feedback)}; events=${sessionEvents.map(event => event.type).join(',')}`) + } + const sessionId = await settled + if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE) + }, 200_000) + + it.skipIf(MODE === 'record')('persists the file block and reads the stored copy with the real read tool', () => { + const userMessage = sessionEvents.find( + (event): event is Extract => + event.type === 'user/message' && event.data.source.kind === 'user', + ) + if (userMessage === undefined) throw new Error('the replayed turn recorded no user message') + const fileBlock = userMessage.data.content.find(block => block.type === 'file') + if (fileBlock?.type !== 'file') throw new Error('the user message carries no file block') + expect(fileBlock.attachment.name).toBe(FILE_NAME) + expect(fileBlock.attachment.bytes).toBe(Buffer.byteLength(FILE_TEXT)) + expect(String(fileBlock.attachment.attachmentId)).toMatch(/^sha256:[0-9a-f]{64}$/) + + const readCall = sessionEvents.find( + (event): event is Extract => + event.type === 'tool/call' && event.data.name === 'read', + ) + if (readCall === undefined) throw new Error('the replayed turn did not call the read tool') + expect(readCall.data.arguments).toContain(FILE_NAME) + const readResult = sessionEvents.find( + (event): event is Extract => + event.type === 'tool/result' && event.data.message.source.callId === readCall.data.callId, + ) + if (readResult === undefined) throw new Error('the read call produced no durable result') + const content = readResult.data.message.content[0] + expect(content.isError).toBe(false) + expect(content.content.filter(block => block.type === 'text').map(block => block.text).join('')) + .toContain('UPLOAD_ROUND_OK') + + const turnEnds = sessionEvents.filter(event => event.type === 'turn/end') + expect(turnEnds.length).toBe(1) + expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed') + }) + + it.skipIf(MODE === 'record')('renders the durable file card beside the settled answer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-aria')) + await expect.poll(() => page.getByText('UPLOAD_ROUND_OK', { exact: false }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + await page.getByTitle(FILE_NAME).first().waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('keeps a mixed durable message in one ordered wrapping attachment flow', async () => { + const groups = page.locator('[data-message-attachments]') + await expect.poll(() => groups.count(), { timeout: 10_000 }).toBe(1) + const geometry = await groups.first().evaluate((element): HistoryAttachmentGeometry => { + const cards = [...element.children] as HTMLElement[] + const renderedCards = cards.map((card) => { + const box = card.getBoundingClientRect() + return box.width === 0 && box.height === 0 && card.firstElementChild instanceof HTMLElement + ? card.firstElementChild + : card + }) + const boxes = renderedCards.map(card => card.getBoundingClientRect()) + const imageIndex = cards.findIndex(card => card.querySelector('img') !== null) + const fileIndex = cards.findIndex(card => card.getAttribute('title') === 'poem.txt') + const imageBox = boxes[imageIndex] + const fileBox = boxes[fileIndex] + return { + order: cards.map(card => card.getAttribute('title') ?? card.querySelector('img')?.getAttribute('alt') ?? ''), + oneGroup: document.querySelectorAll('[data-message-attachments]').length === 1, + oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5), + equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5), + imageIsTile: imageBox !== undefined && Math.abs(imageBox.width - 64) < 0.5, + fileWider: fileBox !== undefined && imageBox !== undefined && fileBox.width > imageBox.width, + wrapsWhenNeeded: getComputedStyle(element).flexWrap === 'wrap', + rightAligned: getComputedStyle(element).justifyContent === 'flex-end', + } + }) + await compareOrRefreshGolden(HISTORY_EXPECTED, renderHistoryAttachmentGeometry(geometry), MODE) + }) + + it.skipIf(MODE === 'record')('marks the durable file in Trajectory without copying the Chat card', async () => { + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) + await page.getByRole('row', { name: /Files ×1/ }).waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria( + page, + '[data-trajectory-row-key][aria-label*="Files ×1"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.v2.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md', + ]) + }) +}) diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 1139b878c6..70ad37a799 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -67,17 +67,26 @@ describe('web e2e: /goal human transcript presentation', () => { const typography = await commandInput.evaluate((element) => { const bubble = element.firstElementChild?.firstElementChild if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing') + const chip = bubble.querySelector('[data-ref-chip="command"]') + if (!(chip instanceof HTMLElement)) throw new Error('command chip is missing') const rootStyle = getComputedStyle(element) const bubbleStyle = getComputedStyle(bubble) + const chipStyle = getComputedStyle(chip) return { fontFamily: bubbleStyle.fontFamily, parentFontFamily: rootStyle.fontFamily, fontSize: bubbleStyle.fontSize, lineHeight: bubbleStyle.lineHeight, + chipText: chip.textContent, + chipFontFamily: chipStyle.fontFamily, + chipFontSize: chipStyle.fontSize, } }) - expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' }) - expect(typography.fontFamily).not.toBe(typography.parentFontFamily) + expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px', chipText: '/goal', chipFontSize: '14px' }) + // The bubble reads in the body face like a user bubble; only the command + // chip carries the code face that marks the echoed token as a command. + expect(typography.fontFamily).toBe(typography.parentFontFamily) + expect(typography.chipFontFamily).not.toBe(typography.fontFamily) const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' }) await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1) expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1) diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index 3b5f8d00f2..886421262f 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -106,7 +106,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy // The rail is an accessible group holding the draft thumbnail (queried via // DOM: jsdom's a11y-visibility computation hides the composer subtree). const rail = await waitFor(() => { - const el = document.querySelector('[role="group"][aria-label="Pending images"]') + const el = document.querySelector('[role="group"][aria-label="Pending attachments"]') if (el === null) throw new Error('attachment rail missing') return el }, { timeout: 5_000 }) @@ -137,23 +137,19 @@ it('accepts pasted images into the composer rail in order and removes them', asy if (remove.length !== 2) throw new Error('remove buttons missing') for (const button of remove) fireEvent.click(button) await waitFor(() => { - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull() }) - // An unsupported file announces a transient toast (the inline strip is - // gone) and the banner dismisses itself after its hold-and-fade lifetime. + // A non-image paste follows the generic-file path and remains in the + // composer as a file card. fireEvent.paste(textarea, { clipboardData: { items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'notes.txt', { type: 'text/plain' }) }], getData: () => '', }, }) - const unsupportedMessage = 'Only PNG, JPG, WebP, and GIF images are supported' - const toast = await screen.findByText(unsupportedMessage) - expect(toast.closest('[role="alert"]')).not.toBeNull() - await waitFor(() => { - expect(screen.queryByText(unsupportedMessage)).toBeNull() - }, { timeout: 6_000 }) + const files = await screen.findByRole('group', { name: 'Pending attachments' }) + expect(files.textContent).toContain('notes.txt') }) it('accepts a whole-page drop under the limits-labeled overlay and refuses an over-limit batch at intake', async () => { @@ -178,15 +174,15 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } fireEvent.dragEnter(document.body, { dataTransfer }) const overlay = await screen.findByRole('status') - expect(overlay.textContent).toContain('Drag images here to add them') + expect(overlay.textContent).toContain('Drag files or images here to add them') await waitFor(() => { - expect(overlay.textContent).toContain('Up to 20 images, 5MB each') + expect(overlay.textContent).toContain('Image limit: up to 20 images, 5MB each') }) // Dropping on the transcript area (not the composer card) lands in the rail. fireEvent.drop(document.body, { dataTransfer }) await waitFor(() => { - const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]') if (rail === null) throw new Error('attachment rail missing after page drop') expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['dropped.png']) }, { timeout: 5_000 }) @@ -206,7 +202,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov const limitMessage = 'A message can include up to 20 images' const banner = await screen.findByText(limitMessage) expect(banner.closest('[role="alert"]')).not.toBeNull() - const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]') expect([...(rail?.querySelectorAll('img') ?? [])]).toHaveLength(1) }) @@ -233,7 +229,7 @@ it('renders a host dimension rejection with the projected 2000px limit', async ( }, }) await waitFor(() => { - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).not.toBeNull() }) fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -245,5 +241,5 @@ it('renders a host dimension rejection with the projected 2000px limit', async ( "text": "Image sides must be at most 2000px; downscale it and try again", } `) - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).not.toBeNull() }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index a6217bc5ff..ad59c14dc8 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -192,7 +192,18 @@ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') const REPLAY_PROVIDERS = [{ id: 'deepseek-official', name: 'DeepSeek', - models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }], + models: [ + { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }, + { + id: 'deepseek-v4-flash-vision-exp', + name: 'DeepSeek-V4-Flash-Vision-Exp', + contextWindow: 1_000_000, + inputModalities: ['text', 'image'] as const, + defaultMaxTokens: 256_000, + reasoningEfforts: ['off', 'low', 'high', 'max'], + defaultReasoningEffort: 'high', + }, + ], }] /** @@ -819,6 +830,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { let expected = await readFile(fixturePath, 'utf8') const fixtureDir = dirname(fixturePath) @@ -997,7 +1012,7 @@ async function assertReplaySession( if (sessionCwd === undefined) throw new Error(`${fixturePath}: replayed session has no cwd`) const actual = rawSessionLog(session) if (mode === 'refresh' && writesCurrentSessionFixtures(manifest, mode)) { - expected = stableSessionFixture(session, expected, sessionCwd) + expected = stableSessionFixture(session, expected, sessionCwd, harnessHome) expectedPath = recordedSessionFixturePath(fixturePath, session.header.version) await writeFile(expectedPath, expected) } @@ -1010,8 +1025,11 @@ async function assertReplaySession( sessionIds: typeof expectedHeader.id === 'string' ? [expectedHeader.id] : [], cwd: typeof expectedHeader.cwd === 'string' ? expectedHeader.cwd : '\0no-cwd\0', } - expect(normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0], `${fixturePath}: persisted replay`) - .toBe(normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0]) + const actualSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0] + ?.split(harnessHome).join('{{harnessHome}}') + const expectedSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0] + ?.split(harnessHome).join('{{harnessHome}}') + expect(actualSnapshot, `${fixturePath}: persisted replay`).toBe(expectedSnapshot) if (manifest.header?.pin !== true) return const normalizePrompt = (value: string): string => value @@ -1032,7 +1050,7 @@ async function assertReplaySession( /** * Record-mode fixture write-back: harvest the live session, scrub request - * headers to {{system}}/{{tools}}, tokenize the run-local cwd, redact opaque + * headers to {{system}}/{{tools}}, tokenize the run-local cwd and Harness Home, redact opaque * identities with typed relationship-preserving tokens, and write the fixture. * A manifest-retained historical generation makes the write-back a no-op. * @param scaffold - the record-mode scaffold. @@ -1048,7 +1066,12 @@ export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, const target = recordedSessionFixturePath(fixturePath, agent.session.header.version) const existingPath = existsSync(target) ? target : fixturePath const existing = existsSync(existingPath) ? await readFile(existingPath, 'utf8') : '' - await writeFile(target, stableSessionFixture(agent.session, existing, scaffold.workspaceCwd)) + await writeFile(target, stableSessionFixture( + agent.session, + existing, + scaffold.workspaceCwd, + scaffold.harnessHome, + )) } /** @@ -1078,8 +1101,8 @@ export function fixtureIdentity( /** * Realize a recorded seed fixture against one scaffold: substitute the - * `{{sessionId}}`/`{{cwd}}` placeholders and rewrite the recorded cwd to the - * scaffold's workspace. Idempotent, so a caller may realize early (e.g. to + * `{{sessionId}}`/`{{cwd}}`/`{{harnessHome}}` placeholders and rewrite the + * recorded cwd to the scaffold's workspace. Idempotent, so a caller may realize early (e.g. to * price content exactly as the host will fold it) and still pass the result * through {@link seedSession}. * @param scaffold - the booted scaffold whose workspace the seed targets. @@ -1104,6 +1127,7 @@ export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, i .replace(/\{\{session:([2-9]\d*)\}\}/g, (_token, ordinal: string) => `${id}-child-${ordinal}`) .replace(/\{\{(message|approval|workflow|command|rpc|retry|id):([1-9]\d*)\}\}/g, (_token, kind: string, ordinal: string) => fixtureIdentity(kind as 'message' | 'approval' | 'workflow' | 'command' | 'rpc' | 'retry' | 'id', Number(ordinal))) + .split('{{harnessHome}}').join(scaffold.harnessHome) .split('{{cwd}}').join(scaffold.workspaceCwd) return result }) @@ -1373,20 +1397,30 @@ function normalizeAria(snapshot: string, workspaceCwd: string, age: boolean): st * @param selector - the region locator selector. * @param workspaceCwd - normalization input. * @param options - `normalizeAge` collapses relative-time buckets to `{{age}}` - * for a region whose rows are dated from live wall-clock state. + * for a region whose rows are dated from live wall-clock state; + * `replacements` tokenizes scenario-owned values before generic normalization. * @returns the stable normalized snapshot. */ export async function captureStableAria( page: Page, selector: string, workspaceCwd: string, - options: { normalizeAge?: boolean } = {}, + options: { + normalizeAge?: boolean + replacements?: readonly (readonly [value: string, token: string])[] + } = {}, ): Promise { const region = page.locator(selector).first() const age = options.normalizeAge === true - let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age) + const normalize = (snapshot: string): string => { + for (const [value, token] of options.replacements ?? []) { + snapshot = snapshot.split(value).join(token) + } + return normalizeAria(snapshot, workspaceCwd, age) + } + let previous = normalize(await region.ariaSnapshot()) await expect.poll(async () => { - const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age) + const current = normalize(await region.ariaSnapshot()) const stable = current === previous previous = current return stable diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 2de45d3f9b..73d928b9da 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -18,10 +18,11 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-invocation-policy', import.meta.url)) const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') +const FUZZY_MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu-fuzzy.expected.md') const MODE = webSnapshotMode() interface SeedSkill { @@ -111,8 +112,16 @@ describe('web e2e: skill invocation policy through the real host', () => { const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) + + // Discovery needs no prefix: an in-order subsequence of one skill name + // ranks that skill alone, through the ranker the command group uses. + await writeComposerDraft(page, input, '/plcyusr') + await expect.poll(() => menu.getByRole('option').count(), { timeout: 10_000 }).toBe(1) + expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(1) + const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(FUZZY_MENU_EXPECTED, fuzzySnapshot, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['menu-fuzzy.expected.md', 'menu.expected.md']) }) }) diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index e9b6891b7b..5201f23a96 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -130,6 +130,10 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro await settled const process = page.getByRole('button', { name: 'Thought for a while', exact: true }) await process.waitFor({ state: 'visible', timeout: 10_000 }) + // The chip derives from the step's logged injection, so it must survive + // every later Node rebuild of the Turn (process publication, turn close). + expect(await bubble.count()).toBe(1) + expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) await expandOwningTurnProcess(page, injectionFlow) const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` }) await injectionRow.click() diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index cd696c5411..fec0614de5 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -27,6 +27,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current streaming-fence-highlight-test/streaming-fence": - text: streaming-fence-highlight-test/streaming-fence diff --git a/apps/web/tests/submission-echo.e2e.ts b/apps/web/tests/submission-echo.e2e.ts index a5d2b5503f..7693f3dc87 100644 --- a/apps/web/tests/submission-echo.e2e.ts +++ b/apps/web/tests/submission-echo.e2e.ts @@ -33,7 +33,7 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl }, }) await waitFor(() => { - if (document.querySelector('[role="group"][aria-label="Pending images"] img') === null) { + if (document.querySelector('[role="group"][aria-label="Pending attachments"] img') === null) { throw new Error('attachment rail missing') } }, { timeout: 5_000 }) @@ -53,7 +53,7 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl expect(echo.querySelector('img')?.getAttribute('src')?.split(':')[0]).toBe('blob') expect(composer.textContent).toBe('') expect(composer.getAttribute('contenteditable')).toBe('true') - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull() // The fixture's durable user/message (source.rpcId echoes the prompt // requestId) replaces the echo: one bubble, no marker left, and the image diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 23fada7121..bea0ae1726 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -63,6 +63,7 @@ "tests/composer-draft-scroll.e2e.ts", "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", + "tests/file-upload-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/message-feedback.e2e.ts", "tests/message-feedback-layout.e2e.ts", diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 795a426436..1b3b1a5d5c 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -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/capability-seams.md -capability-seams.md: 83868afe952c5dbc179114ff52228fc1dc8b8fdb -capability-seams.zh.md: 064797bbc3e5d71c0bdb0b2afe09560577a1b029 +capability-seams.md: ecc88e397f41c4d279effa2df9d1b29108756ea5 +capability-seams.zh.md: 105752ac01007d24788bcfbcb2eb22c07cfdd0f5 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 83868afe95..ecc88e397f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -14,6 +14,8 @@ flowchart LR pkg_tool_fs["tool-fs"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_deepseek["llm-deepseek"] + pkg_client_file_upload["client-file-upload"] + svc_fileUploads["ctx.fileUploads
Agent-scoped staged file uploads"] pkg_llm["llm"] svc_llm["ctx.llm
LLM adapter registry"] pkg_llm_replay["llm-replay"] @@ -235,6 +237,7 @@ flowchart LR pkg_authorization --> svc_authorization pkg_bash_local --> svc_shell pkg_bash_sandbox --> svc_shell + pkg_client_file_upload --> svc_fileUploads pkg_client_modules --> svc_clientModules pkg_code_runtime --> svc_codeRuntime pkg_code_runtime_worker_thread --> svc_codeRuntime @@ -364,6 +367,7 @@ flowchart LR svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b svc_fileReferences --> pkg_api_session_controller + svc_fileUploads --> pkg_api_session_controller svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -466,6 +470,7 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | +| `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 064797bbc3..105752ac01 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -16,6 +16,8 @@ flowchart LR pkg_tool_fs["tool-fs"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_deepseek["llm-deepseek"] + pkg_client_file_upload["client-file-upload"] + svc_fileUploads["ctx.fileUploads
Agent-scoped staged file uploads"] pkg_llm["llm"] svc_llm["ctx.llm
LLM adapter registry"] pkg_llm_replay["llm-replay"] @@ -237,6 +239,7 @@ flowchart LR pkg_authorization --> svc_authorization pkg_bash_local --> svc_shell pkg_bash_sandbox --> svc_shell + pkg_client_file_upload --> svc_fileUploads pkg_client_modules --> svc_clientModules pkg_code_runtime --> svc_codeRuntime pkg_code_runtime_worker_thread --> svc_codeRuntime @@ -366,6 +369,7 @@ flowchart LR svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b svc_fileReferences --> pkg_api_session_controller + svc_fileUploads --> pkg_api_session_controller svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -468,6 +472,7 @@ flowchart LR | ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 | | --- | --- | --- | --- | --- | --- | --- | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | +| `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 插件准备彼此独立的顶层字段;官方适配器会合并这些字段,并在 HTTP 接受后提交其交付状态。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 577230f1a8..c7d01d85d5 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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/config-catalog.md -config-catalog.md: 136e42c22f58f4e7102f7fb3b905ab956db55ecd -config-catalog.zh.md: 88a6bca44c8e8b396cb8efaf4be5cd704a6518f8 +config-catalog.md: a0fea049bf6e15ef8c342a329b3a55208e8e29ee +config-catalog.zh.md: ba8fbba2cabe52b150e90110bd150bb9f7f13a5b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 136e42c22f..a0fea049bf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -203,7 +203,7 @@ Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/in ## `@deepseek-ai/dsh-api-session-controller` -Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +Requires: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` ```ts config-catalog /** Session Controller deployment policy. */ @@ -213,7 +213,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts) @@ -262,7 +262,7 @@ export interface Config { } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts) @@ -336,7 +336,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:70`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts) @@ -3325,6 +3325,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) +- `@deepseek-ai/dsh-client-file-upload` — requires `agents` · `attachments` · `commands` · `connection` ([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 88a6bca44c..ba8fbba2ca 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -205,7 +205,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +需要:`agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` ```ts config-catalog /** Session Controller deployment policy. */ @@ -215,7 +215,7 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts) @@ -264,7 +264,7 @@ export interface Config { } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts) @@ -338,7 +338,7 @@ export interface ConnectionConfig { } ``` -来源:[`packages/client/connection/src/index.ts:55`](../packages/client/connection/src/index.ts) +来源:[`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts) @@ -3327,6 +3327,7 @@ export interface Config { - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) +- `@deepseek-ai/dsh-client-file-upload` — 需要 `agents` · `attachments` · `commands` · `connection`([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — 需要 `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 971dc86cb3..0406675292 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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/event-producer-consumer.md -event-producer-consumer.md: 62eba828604ba1e90d9d7ef22743cf10daf84f70 -event-producer-consumer.zh.md: f82c379516d9aec00dade2acdf3f8291a340da44 +event-producer-consumer.md: 1d643f14cabd361e82468cbcec1e2d418e4f079c +event-producer-consumer.zh.md: 80857c699e1a2b2e0956f909c26657754b5d909f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 62eba82860..1d643f14ca 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,14 +22,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:561`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:574`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | -| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -44,11 +44,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:68`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index f82c379516..80857c699e 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,11 +24,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:561`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:574`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -46,11 +46,11 @@ | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:68`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 7eb463168f..a84ece4a7f 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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/module-graph.md -module-graph.md: 6a5e4232f5a4da309b48c2e7c00dadf09e8b308a -module-graph.zh.md: 2388a1d9843e5e2fe308e372f9d7db8150b48160 +module-graph.md: be0bc1db1222ba7b95dff735a339bef680b86cf5 +module-graph.zh.md: e1fdaae73ac398076598316efcfa45822e378155 diff --git a/docs/module-graph.md b/docs/module-graph.md index 6a5e4232f5..be0bc1db12 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -132,6 +132,7 @@ flowchart TD end subgraph group_client["packages/client"] pkg_client_connection["client-connection"] + pkg_client_file_upload["client-file-upload"] pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] @@ -407,6 +408,7 @@ flowchart TD pkg_api_remotes --> pkg_scope pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths + pkg_client_file_upload --> pkg_scope pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants pkg_authorization --> pkg_llm @@ -1052,6 +1054,8 @@ flowchart TD pkg_api_session_controller --> pkg_api_gateway pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_client_file_upload + pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm @@ -1261,6 +1265,7 @@ flowchart TD | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | +| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1391,7 +1396,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 2388a1d984..e1fdaae73a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -134,6 +134,7 @@ flowchart TD end subgraph group_client["packages/client"] pkg_client_connection["client-connection"] + pkg_client_file_upload["client-file-upload"] pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] @@ -409,6 +410,7 @@ flowchart TD pkg_api_remotes --> pkg_scope pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths + pkg_client_file_upload --> pkg_scope pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants pkg_authorization --> pkg_llm @@ -1054,6 +1056,8 @@ flowchart TD pkg_api_session_controller --> pkg_api_gateway pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_client_file_upload + pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm @@ -1263,6 +1267,7 @@ flowchart TD | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | +| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1393,7 +1398,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 1c0965891a..3a281b936a 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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/persistence-catalog.md -persistence-catalog.md: 76d91ea8e8edb625f9733b6ad28d225d2ee059a8 -persistence-catalog.zh.md: 52625069c16094e1b056b9ee9013409fd1690705 +persistence-catalog.md: 0b52801083498542c814d06171942f33d013efe2 +persistence-catalog.zh.md: eda29e00c9dfaff6c5d35afba55f2a8cfb95f075 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 76d91ea8e8..0b52801083 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -268,7 +268,7 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/ } ``` -Source: [`packages/interaction/commands/src/types.ts:104`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:110`](../packages/interaction/commands/src/types.ts) @@ -288,7 +288,7 @@ Source: [`packages/interaction/commands/src/types.ts:104`](../packages/interacti 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/interaction/commands/src/types.ts:97`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) ### `compaction/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 52625069c1..eda29e00c9 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -270,7 +270,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/commands/src/types.ts:104`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:110`](../packages/interaction/commands/src/types.ts) @@ -290,7 +290,7 @@ export type SessionEvent = { 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -来源:[`packages/interaction/commands/src/types.ts:97`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) ### `compaction/*` diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 8e69eaae28..3e121e78c9 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -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: 15daa2b8d541ba847d48f5c43a06c1c537df6d11 -attachment.zh.md: c74a18f6bec63e117afcdb141512be04f8d75f9a +attachment.md: 53b3e51e5b1c87625256178287061386342bc055 +attachment.zh.md: 549c06cc5abe542e5d6feeba10b24ebf6ba8dfd1 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 15daa2b8d5..53b3e51e5b 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -1,10 +1,10 @@ -# Durable Image Attachments +# Durable Attachments English | [中文](attachment.zh.md) -The attachment seam separates binary image ownership from the session log. A producer gives validated encoded bytes to [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam); the service publishes an immutable content-addressed reference only after the object is durable. Session events and model-visible `ImageBlock`s contain that reference and metadata, never a browser object URL, host temporary path, provider URL, or base64 payload. +The attachment seam separates binary image and generic-file ownership from the session log. A producer gives bytes to [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam); the service publishes an immutable content-addressed reference only after the object is durable. Session events and model-visible attachment blocks contain that reference and metadata, never a browser object URL, host temporary path, provider URL, or base64 payload. The independent [`ctx.fileUploads`](#ctxfileuploads--fileuploads) service binds browser file transfers and staged receipts to the receiving Agent. -Unsent browser drafts may stay in memory and native clients may stage them in operating-system temporary storage. Once the host accepts a user message, its images move below `/attachments/v1` before the user event is appended. Structured model image output follows the same persist-before-event rule. +Unsent browser drafts may stay in memory and native clients may stage them in operating-system temporary storage. Browser generic files become durable before they receive a staged prompt receipt. Once the host accepts a user message, its images move below `/attachments/v1` before the user event is appended. Structured model image output follows the same persist-before-event rule. Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts) @@ -62,6 +62,38 @@ The reference records intrinsic dimensions and encoded length so clients can lay ## Commit and verified-read payloads +```ts type-equiv +/** + * Browser-submitted prompt content accepted by Host prompt endpoints; the + * accepting Host promotes image parts to durable references through + * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can + * never cite an attachment it did not upload. + */ +type PromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string + } +``` + +```ts type-equiv +/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +type AttachmentAdmissionPart = + | PromptContentPart + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +``` + +```ts type-equiv +/** Host-admitted prompt content with every attachment represented by its durable reference. */ +type AdmittedPromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +``` + ```ts type-equiv /** Base64-encoded image upload accompanying one wire request. */ interface EncodedImageAttachment { @@ -125,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. `admitEncodedImages()` is the wire entry for base64 uploads 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. @@ -157,6 +189,30 @@ abstract validateImage(input: SaveImageAttachment): Promise */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise +/** + * Admit one Host prompt and replace each uploaded image with its durable reference. + * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. + * @param content - prompt parts in message order after file receipt resolution. + * @returns admitted prompt parts in the same order as `content`. + * @throws AttachmentError when the image batch is refused. + */ +async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise + +/** + * 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 + +/** + * 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 @@ -184,6 +240,43 @@ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise + +/** + * Durably commit one file byte-for-byte from bounded chunks. Providers must + * apply backpressure and must not collect the complete file in memory. + * Backends without streamed verbatim storage keep this default rejection. + * @param input - ordered exact bytes, optional cancellation, and display name. + * @returns the durable content-addressed file reference. + */ +saveFileStream(input: SaveFileStreamAttachment): Promise + +/** + * Read and verify one verbatim stored file as bounded chunks. Providers must + * not collect the complete file in memory. Backends without verbatim file + * reads keep this default rejection. + * @param ref - durable reference from the session log. + * @param signal - optional cancellation for backend reads and verification work. + * @returns exact file bytes in order; integrity failures reject the iteration. + */ +async *readFileStream( ref: FileAttachmentRef, signal?: AbortSignal, ): AsyncIterable + +/** + * Locate the verbatim stored file object in the harness host filesystem. + * @param ref - durable file reference. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. + * @throws an AttachmentError when the durable reference is invalid. + */ +fileHostPath(ref: FileAttachmentRef): string | undefined + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. @@ -195,4 +288,64 @@ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) + + + +### `ctx.fileUploads` — `FileUploads` + +Host service owning upload storage and Agent-scoped staged receipts. + +```ts cordis-catalog +/** + * Register the ordinary-Session resolver used when a raw upload addresses a cold Session. + * @param resolve - resolver that returns the exact live Agent or throws a Remote error. + * @returns disposer removing this resolver. + */ +registerAgentResolver(resolve: AgentResolver): () => void + +/** + * Persist one encoded upload and stage it under the Agent receiver selected by Typert. + * @param agent - receiving Agent resolved from the Remote Agent scope. + * @param request - canonical base64 bytes and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns the staged receipt and durable file reference. + */ +@Remote('upload') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise + +/** + * Persist raw chunks for one Session without aggregating the upload. + * @param request - Session identity, ordered bytes, cancellation, and optional display name. + * @returns the staged receipt and durable file reference. + */ +async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise + +/** + * Resolve one staged receipt inside its receiving Agent scope. + * @param agent - receiving Agent. + * @param receiptId - opaque receipt minted for one completed upload. + * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + */ +resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined + +/** + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. + * @param agent - receiving Agent. + * @param receiptIds - distinct staged receipts referenced by the prompt. + * @param requestId - prompt identity later observed in queue or history. + * @returns binding kept after commit until queue or history observation retires its receipts. + */ +bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding + +/** + * Retire every receipt accepted by one removed queue occurrence. + * @param agent - receiving Agent. + * @param requestId - prompt identity carried by the queue occurrence. + */ +retirePrompt(agent: Agent, requestId: string): void +``` + +Types: [Agent](core.md) · [SessionId](core.md) + +Source: [`packages/client/file-upload/src/index.ts`](../../packages/client/file-upload/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index c74a18f6be..549c06cc5a 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -1,10 +1,10 @@ -# 持久图片附件 +# 持久附件 [English](attachment.md) | 中文 -附件 seam 将二进制图片的所有权与会话日志分离。生产方把经过校验的编码字节交给 [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam);只有对象完成持久化后,该服务才会发布不可变的内容寻址引用。会话事件和模型可见的 `ImageBlock` 包含该引用及其元数据,绝不包含浏览器对象 URL、宿主临时路径、提供方 URL 或 base64 数据。 +附件 seam 将二进制图片和通用文件的所有权与会话日志分离。生产方把字节交给 [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam);只有对象完成持久化后,该服务才会发布不可变的内容寻址引用。会话事件和模型可见的附件块包含该引用及其元数据,绝不包含浏览器对象 URL、宿主临时路径、提供方 URL 或 base64 数据。独立的 [`ctx.fileUploads`](#ctxfileuploads--fileuploads) 服务把浏览器文件传输与暂存凭证绑定到接收方 Agent。 -未发送的浏览器草稿可以保留在内存中,原生客户端也可以将其暂存于操作系统临时存储。宿主接受用户消息后,会先把消息中的图片移到 `/attachments/v1` 下,再追加用户事件。结构化模型图片输出遵循同样的先持久化、后追加事件规则。 +未发送的浏览器草稿可以保留在内存中,原生客户端也可以将其暂存于操作系统临时存储。浏览器通用文件取得暂存 prompt 凭证前会完成持久化。宿主接受用户消息后,会先把消息中的图片移到 `/attachments/v1` 下,再追加用户事件。结构化模型图片输出遵循同样的先持久化、后追加事件规则。 来源:[`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts) @@ -62,6 +62,38 @@ interface ImageAttachmentLimits { ## 提交与经校验读取的数据 +```ts type-equiv +/** + * Browser-submitted prompt content accepted by Host prompt endpoints; the + * accepting Host promotes image parts to durable references through + * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can + * never cite an attachment it did not upload. + */ +type PromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string + } +``` + +```ts type-equiv +/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +type AttachmentAdmissionPart = + | PromptContentPart + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +``` + +```ts type-equiv +/** Host-admitted prompt content with every attachment represented by its durable reference. */ +type AdmittedPromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +``` + ```ts type-equiv /** Base64-encoded image upload accompanying one wire request. */ interface EncodedImageAttachment { @@ -125,7 +157,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 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 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -157,6 +189,30 @@ abstract validateImage(input: SaveImageAttachment): Promise */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise +/** + * Admit one Host prompt and replace each uploaded image with its durable reference. + * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. + * @param content - prompt parts in message order after file receipt resolution. + * @returns admitted prompt parts in the same order as `content`. + * @throws AttachmentError when the image batch is refused. + */ +async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise + +/** + * 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 + +/** + * 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 @@ -184,6 +240,43 @@ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise + +/** + * Durably commit one file byte-for-byte from bounded chunks. Providers must + * apply backpressure and must not collect the complete file in memory. + * Backends without streamed verbatim storage keep this default rejection. + * @param input - ordered exact bytes, optional cancellation, and display name. + * @returns the durable content-addressed file reference. + */ +saveFileStream(input: SaveFileStreamAttachment): Promise + +/** + * Read and verify one verbatim stored file as bounded chunks. Providers must + * not collect the complete file in memory. Backends without verbatim file + * reads keep this default rejection. + * @param ref - durable reference from the session log. + * @param signal - optional cancellation for backend reads and verification work. + * @returns exact file bytes in order; integrity failures reject the iteration. + */ +async *readFileStream( ref: FileAttachmentRef, signal?: AbortSignal, ): AsyncIterable + +/** + * Locate the verbatim stored file object in the harness host filesystem. + * @param ref - durable file reference. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. + * @throws an AttachmentError when the durable reference is invalid. + */ +fileHostPath(ref: FileAttachmentRef): string | undefined + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. @@ -195,4 +288,64 @@ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) + + + +### `ctx.fileUploads` — `FileUploads` + +Host service owning upload storage and Agent-scoped staged receipts. + +```ts cordis-catalog +/** + * Register the ordinary-Session resolver used when a raw upload addresses a cold Session. + * @param resolve - resolver that returns the exact live Agent or throws a Remote error. + * @returns disposer removing this resolver. + */ +registerAgentResolver(resolve: AgentResolver): () => void + +/** + * Persist one encoded upload and stage it under the Agent receiver selected by Typert. + * @param agent - receiving Agent resolved from the Remote Agent scope. + * @param request - canonical base64 bytes and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns the staged receipt and durable file reference. + */ +@Remote('upload') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise + +/** + * Persist raw chunks for one Session without aggregating the upload. + * @param request - Session identity, ordered bytes, cancellation, and optional display name. + * @returns the staged receipt and durable file reference. + */ +async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise + +/** + * Resolve one staged receipt inside its receiving Agent scope. + * @param agent - receiving Agent. + * @param receiptId - opaque receipt minted for one completed upload. + * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + */ +resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined + +/** + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. + * @param agent - receiving Agent. + * @param receiptIds - distinct staged receipts referenced by the prompt. + * @param requestId - prompt identity later observed in queue or history. + * @returns binding kept after commit until queue or history observation retires its receipts. + */ +bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding + +/** + * Retire every receipt accepted by one removed queue occurrence. + * @param agent - receiving Agent. + * @param requestId - prompt identity carried by the queue occurrence. + */ +retirePrompt(agent: Agent, requestId: string): void +``` + +Types: [Agent](core.zh.md) · [SessionId](core.zh.md) + +Source: [`packages/client/file-upload/src/index.ts`](../../packages/client/file-upload/src/index.ts) diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index 50447d69ca..d2a7bfe3b8 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.i18n.yaml @@ -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/commands.md -commands.md: 46d3b736afd3a72d1119c9744cd592a86b9cbc29 -commands.zh.md: db8d6e722e3a1742887aaa4023ab67b9ed914552 +commands.md: 4705048e06796792d8e013450b72035b1f87315b +commands.zh.md: f3af9f6e81745514efd1e073868292a02f90dca8 diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index 46d3b736af..4705048e06 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -8,7 +8,7 @@ Source: [`packages/interaction/commands/src/index.ts`](../../packages/interactio ## Input metadata -The service exposes one optional unstructured-input descriptor: a hint plus an image-acceptance flag. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition. +The service exposes one optional unstructured-input descriptor: a hint plus an attachment-acceptance flag. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition. ```ts type-equiv /** Immutable metadata for a command's optional unstructured input. */ @@ -16,13 +16,13 @@ interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string /** - * Whether composer image attachments may accompany an invocation. Absent or - * false = the executor rejects an invocation carrying images and capable + * Whether composer attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying attachments and capable * composers refuse the submission before dispatch. A declaring command's * handler receives the admitted durable blocks and owns every further * grammar decision, including rejecting sub-commands that cannot use them. */ - readonly images?: boolean + readonly attachments?: boolean } ``` @@ -64,13 +64,13 @@ interface CommandInvocation { /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string /** - * Durably admitted image blocks accompanying this invocation, in submission - * order; empty unless the definition declares `input.images`. The handler + * Durably admitted image and file blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.attachments`. The handler * owns their model-visible use — the registry never schedules them itself — * and a handler whose grammar cannot use them in this invocation returns an * error so the dispatching composer retains the originals. */ - readonly attachments: readonly ImageBlock[] + readonly attachments: readonly (ImageBlock | FileBlock)[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -138,6 +138,13 @@ Human-command registry. Plain-context definitions are global; definitions regist */ register(definition: CommandDefinition): () => void +/** + * Register the sole authority that resolves staged file receipts for command submissions. + * @param resolver - Session-aware receipt resolver. + * @returns disposer that removes this exact resolver. + */ +registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void + /** * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. @@ -166,23 +173,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined * handler-failure path is contained so the handler's own error stays the * reported failure. * - * Image admission is enforced here, not in the composer: images sent to a - * command that does not declare `input.images`, an absent attachment store, - * and an exceeded attachment limit each settle as an error result before - * the handler runs, and a rejected batch publishes no durable object. + * Attachment admission is enforced here, not in the composer: attachments sent to a + * command that does not declare `input.attachments`, an absent attachment store, + * and an exceeded image limit each settle as an error result before + * the handler runs. Validation rejection starts no attachment writes; + * a storage failure can leave only unreachable content-addressed objects + * for deferred collection. * * @param agent - exact receiving agent. * @param line - complete slash-command line. - * @param images - base64-encoded composer images accompanying the line, in - * submission order; empty for a plain invocation. + * @param submittedAttachments - encoded images and staged file receipts accompanying the line, + * in submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ -@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise +@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise ``` -Types: [Agent](core.md) · [EncodedImageAttachment](attachment.md) +Types: [Agent](core.md) Source: [`packages/interaction/commands/src/index.ts`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index db8d6e722e..f3af9f6e81 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -8,7 +8,7 @@ ## 输入元数据 -该服务公开一个可选的非结构化输入描述符:提示文本加图片接受标志。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。 +该服务公开一个可选的非结构化输入描述符:提示文本加附件接受标志。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。 ```ts type-equiv /** Immutable metadata for a command's optional unstructured input. */ @@ -16,13 +16,13 @@ interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string /** - * Whether composer image attachments may accompany an invocation. Absent or - * false = the executor rejects an invocation carrying images and capable + * Whether composer attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying attachments and capable * composers refuse the submission before dispatch. A declaring command's * handler receives the admitted durable blocks and owns every further * grammar decision, including rejecting sub-commands that cannot use them. */ - readonly images?: boolean + readonly attachments?: boolean } ``` @@ -64,13 +64,13 @@ interface CommandInvocation { /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string /** - * Durably admitted image blocks accompanying this invocation, in submission - * order; empty unless the definition declares `input.images`. The handler + * Durably admitted image and file blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.attachments`. The handler * owns their model-visible use — the registry never schedules them itself — * and a handler whose grammar cannot use them in this invocation returns an * error so the dispatching composer retains the originals. */ - readonly attachments: readonly ImageBlock[] + readonly attachments: readonly (ImageBlock | FileBlock)[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -138,6 +138,13 @@ Human-command registry. Plain-context definitions are global; definitions regist */ register(definition: CommandDefinition): () => void +/** + * Register the sole authority that resolves staged file receipts for command submissions. + * @param resolver - Session-aware receipt resolver. + * @returns disposer that removes this exact resolver. + */ +registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void + /** * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. @@ -166,23 +173,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined * handler-failure path is contained so the handler's own error stays the * reported failure. * - * Image admission is enforced here, not in the composer: images sent to a - * command that does not declare `input.images`, an absent attachment store, - * and an exceeded attachment limit each settle as an error result before - * the handler runs, and a rejected batch publishes no durable object. + * Attachment admission is enforced here, not in the composer: attachments sent to a + * command that does not declare `input.attachments`, an absent attachment store, + * and an exceeded image limit each settle as an error result before + * the handler runs. Validation rejection starts no attachment writes; + * a storage failure can leave only unreachable content-addressed objects + * for deferred collection. * * @param agent - exact receiving agent. * @param line - complete slash-command line. - * @param images - base64-encoded composer images accompanying the line, in - * submission order; empty for a plain invocation. + * @param submittedAttachments - encoded images and staged file receipts accompanying the line, + * in submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ -@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise +@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise ``` -Types: [Agent](core.zh.md) · [EncodedImageAttachment](attachment.zh.md) +Types: [Agent](core.zh.md) Source: [`packages/interaction/commands/src/index.ts`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index c0649d5acb..81f5077280 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -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/llm-streaming.md -llm-streaming.md: d0d83897db4924de885f012212a200fa7d88b36e -llm-streaming.zh.md: 962cea4bf6ec5c4db84d7613c7a2317693bf5c47 +llm-streaming.md: e88741c6bb63b819a314f74fc425c89b865b5906 +llm-streaming.zh.md: 4bc7fc6124e77c7e3bbbc474bff9bc79fbd8eb27 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index d0d83897db..e88741c6bb 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -23,12 +23,13 @@ interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock 'image': ImageBlock + 'file': FileBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } ``` -The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. +The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `FileBlock` (a durable verbatim [file attachment](attachment.md) that request assembly projects to handle text for every route), `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. Image access belongs to request serialization rather than the durable attachment or deterministic request-image version. `resolveImageAttachmentAccess()` combines the attachment provider's optional host object path with a mapping supplied by the consumer for the current tool execution filesystem. The result is available only for that request and does not participate in `variantId`. @@ -961,6 +962,14 @@ providerRetryPolicy(provider: string): ResolvedRetryPolicy */ imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined +/** + * Resolve the exact text one durable file occurrence contributes to every + * provider request in the current execution environment. + * @param ref - durable verbatim file reference from model history. + * @returns the same deterministic handle text used at adapter dispatch. + */ +fileRequestText(ref: FileAttachmentRef): string + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -1016,6 +1025,8 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` +Types: [FileAttachmentRef](attachment.md) + Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 962cea4bf6..4bc7fc6124 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -23,12 +23,13 @@ interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock 'image': ImageBlock + 'file': FileBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } ``` -各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 +各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`FileBlock`(一个持久的原样[文件附件](attachment.zh.md),请求组装对每条路由都把它投影为 handle 文本)、`ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 图片访问方式属于请求序列化,不属于持久附件或确定性请求图片版本。`resolveImageAttachmentAccess()` 把附件提供方可选的宿主对象路径,与消费方为当前工具执行文件系统提供的映射组合起来。结果只适用于本次请求,不参与 `variantId`。 @@ -967,6 +968,14 @@ providerRetryPolicy(provider: string): ResolvedRetryPolicy */ imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined +/** + * Resolve the exact text one durable file occurrence contributes to every + * provider request in the current execution environment. + * @param ref - durable verbatim file reference from model history. + * @returns the same deterministic handle text used at adapter dispatch. + */ +fileRequestText(ref: FileAttachmentRef): string + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -1022,6 +1031,8 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` +Types: [FileAttachmentRef](attachment.zh.md) + Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index ad2b50137d..e3ce7a9c78 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -78,8 +78,10 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^" + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "zod": "^4.4.3" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index e7fc7757e9..cc917458dc 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -9,6 +9,7 @@ import llmRemote from '@deepseek-ai/dsh-llm/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' +import fileUploadsRemote from '@deepseek-ai/dsh-client-file-upload/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' @@ -24,6 +25,7 @@ export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-llm/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' +export type {} from '@deepseek-ai/dsh-client-file-upload/remote' export type {} from '@deepseek-ai/dsh-session-reference/remote' export type {} from '@deepseek-ai/dsh-subagent/remote' export type * from '@deepseek-ai/dsh-subagent/client' @@ -145,7 +147,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { try { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, - pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, + pluginInventoryRemote, messageFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, subagentsRemote, sessionRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 352178c781..62a740558b 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -48,6 +48,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const script = ` import { createServer } from 'node:http' import * as cordis from '@deepseek-ai/cordis' + import * as zod from 'zod' const urls = ${JSON.stringify(urls)} const { Context } = cordis @@ -157,6 +158,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id) return handoff.factory(specifier => { if (specifier === '@deepseek-ai/cordis') return cordis + if (specifier === 'zod') return zod throw new Error('unexpected Client external ' + specifier) }) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 56f664d499..2be76de65a 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -20,6 +20,9 @@ { "path": "../../client/connection/tsconfig.client.json" }, + { + "path": "../../client/file-upload/tsconfig.client.json" + }, { "path": "../../credentials/credentials" diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 06be2276ec..41b4271974 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: 1dbbeb34b845273d34b3323b6a4134da277126a0 -README.zh.md: 99c29a9043623b3d84fd12aeff42ec1781db699c +README.md: ec8b70b1724afc462d2d0b5d267603bcab4aceb6 +README.zh.md: 6fc0c92300262bfdf0fc52deaec1d66313d91ebe diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 1dbbeb34b8..ec8b70b172 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, and the adapter for Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session. +`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, and Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session. ## Table of Contents @@ -25,11 +25,11 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. +Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. -The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. +The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. ----- @@ -59,6 +59,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages. - Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. +- The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero. - File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 99c29a9043..6fc0c92300 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现,以及面向 Agent 的文件引用 adapter。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。 +`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现和面向 Agent 的文件引用。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。 ## 目录 @@ -25,11 +25,11 @@ kind: "package-reference" 历史页与 follow opening snapshot 为每个持久 Session event 携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑 stream 内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 +每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 -Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`,即轮次跳转加载器,按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 -Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 ----- @@ -59,6 +59,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 - Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 - follow 恢复失败会对调用方可见,而不会无限重试。 +- 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第一个字节重新传输整个文件。 - 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index e60bfd4517..6fbc1a5199 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -81,6 +81,8 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -119,6 +121,8 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 43c0f2dfd6..35f0ffd2bf 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -7,22 +7,25 @@ * must stub); implementation-internal entry points (history staging, wire-frame * dispatch) stay on the class, invisible out here. */ -import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' -import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts' +import type { PendingSubmissionAttachment, SessionSnapshot } from './snapshot.ts' /** * Why a local submission echo left the snapshot: `observed` when its durable * `user/message` event or host queue occurrence arrived (with the admitted - * image references in prompt order), `failed` when the prompt was rejected, + * attachment references in prompt order), `failed` when the prompt was rejected, * threw, or was aborted before acceptance. */ export type PendingSubmissionRetirement = - | { readonly reason: 'observed'; readonly attachments: readonly ImageAttachmentRef[] } + | { + readonly reason: 'observed' + readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[] + } | { readonly reason: 'failed' } /** Input registering one local submission echo ahead of its prompt call. */ @@ -31,8 +34,8 @@ export interface BeginSubmissionInput { readonly mode: 'queue' | 'steer' /** Prompt text exactly as the upcoming prompt will send it. */ readonly text: string - /** Ordered image previews matching the upcoming prompt's image parts. */ - readonly images: readonly PendingSubmissionImage[] + /** Ordered image previews and durable file metadata matching the upcoming prompt attachments. */ + readonly attachments: readonly PendingSubmissionAttachment[] /** Settlement callback fired exactly once when the echo retires. */ readonly onRetire?: (retirement: PendingSubmissionRetirement) => void } diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index ff08a2fd06..aba257f893 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -1,5 +1,6 @@ /** Session-owned observable state excluding Conversation target data. */ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' @@ -30,6 +31,23 @@ export interface PendingSubmissionImage { readonly height?: number } +/** Image branch of a local submission echo attachment. */ +export interface PendingSubmissionImageAttachment { + readonly type: 'image' + readonly value: PendingSubmissionImage +} + +/** File branch of a local submission echo attachment. */ +export interface PendingSubmissionFileAttachment { + readonly type: 'file' + readonly value: FileAttachmentRef +} + +/** One attachment displayed by a local submission echo, in prompt order. */ +export type PendingSubmissionAttachment = + | PendingSubmissionImageAttachment + | PendingSubmissionFileAttachment + /** Client surface selected when a local submission begins. */ export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering' @@ -48,8 +66,8 @@ export interface PendingSubmission { readonly time: number /** Prompt text exactly as it will be sent (one text block). */ readonly text: string - /** Ordered image previews matching the prompt's image parts. */ - readonly images: readonly PendingSubmissionImage[] + /** Ordered image previews and durable file metadata matching the prompt attachments. */ + readonly attachments: readonly PendingSubmissionAttachment[] } /** History-open lifecycle of a Session event window. */ diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index c7055f1a93..af5cdbb3e1 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -2,6 +2,8 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent/types' +import type {} from '@deepseek-ai/dsh-client-connection/client' +import type {} from '@deepseek-ai/dsh-client-file-upload/client' import { createSessionControlStream } from './transport.ts' import { ClientSessions } from './sessions/service.ts' import type { SessionRemotes } from './sessions/remotes.ts' @@ -62,7 +64,10 @@ export type { export type { OpenState, PendingSubmission, + PendingSubmissionAttachment, + PendingSubmissionFileAttachment, PendingSubmissionImage, + PendingSubmissionImageAttachment, PendingSubmissionPlacement, PromptError, QueuedMessage, @@ -78,6 +83,8 @@ declare module '@deepseek-ai/cordis' { /** Required Remote and Context projection services. */ export const inject = [ + 'connection', + 'fileUpload', 'typert', 'remote', 'remote.commands', diff --git a/packages/api/session-controller/src/client/sessions/queue-mirror.ts b/packages/api/session-controller/src/client/sessions/queue-mirror.ts index 985ad8a841..f0ed979c2b 100644 --- a/packages/api/session-controller/src/client/sessions/queue-mirror.ts +++ b/packages/api/session-controller/src/client/sessions/queue-mirror.ts @@ -5,11 +5,11 @@ import type { QueuedMessage } from '../contract/snapshot.ts' const QUEUE_PREVIEW_CHARS = 200 -// Image blocks are excluded: queue presentation renders them as thumbnails -// from `content`, so the text preview covers only what has no visual form. +// Attachment blocks are excluded: queue presentation renders them from +// `content`, so the text preview covers only what has no visual form. function previewOf(content: readonly ContentBlock[]): string { const flat = content - .filter(block => block.type !== 'image') + .filter(block => block.type !== 'image' && block.type !== 'file') .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) .join(' ').replace(/\s+/g, ' ').trim() const chars = Array.from(flat) diff --git a/packages/api/session-controller/src/client/sessions/remotes.ts b/packages/api/session-controller/src/client/sessions/remotes.ts index bec8709b90..c2e585514b 100644 --- a/packages/api/session-controller/src/client/sessions/remotes.ts +++ b/packages/api/session-controller/src/client/sessions/remotes.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes */ -import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type { CommandSubmitAttachment } from '@deepseek-ai/dsh-commands/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest, @@ -19,7 +19,7 @@ export interface SessionCommandsRemote { execute( agentId: SessionId, line: string, - images: readonly EncodedImageAttachment[], + attachments: readonly CommandSubmitAttachment[], signal?: AbortSignal, ): Promise> } diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index ecc0bdbf67..10f9702aab 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' -import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import { SessionLogOffset, SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' @@ -30,6 +30,7 @@ import type { } from '../contract/events.ts' import { Notifier } from './notifier.ts' import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionRemotes } from './remotes.ts' import { ProjectionValueStore } from './projection-store.ts' @@ -210,7 +211,7 @@ export class Session implements SessionFace { : 'transcript', time: Date.now(), text: input.text, - images: input.images, + attachments: input.attachments, }] this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false }) // The blank → engaging edge flips here, ahead of prompt(): the composer @@ -222,7 +223,7 @@ export class Session implements SessionFace { /** * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. - * @param content - text plus browser-owned temporary image uploads. + * @param content - text, browser-owned temporary image uploads, and staged-file receipts. * @param mode - queue appends after the current turn; steer interrupts it. * @param signal - optional caller cancellation for the complete admission round-trip. * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. @@ -252,13 +253,25 @@ export class Session implements SessionFace { content, clientTimeZone, }, signal) + } else if (content.some(part => part.type === 'file')) { + result = { + ok: false, + error: new RemoteError( + 'subagent/attachment-invalid', + 'subagent continuation does not accept files', + { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, + ), + } } else { + // The preceding branch rejects file parts before the narrower subagent + // wire type is used; this array is not filtered or reordered. + const routedContent = content as Exclude[] const routed = await this.remote.subagents.prompt({ requestId: randomUUID() as SessionRequestId, parentSessionId: this.address.parentSessionId, childSessionId: this.address.childSessionId, mode: 'continuable', - content, + content: routedContent, clientTimeZone: resolvedClientTimeZone(), }, signal) result = routed.ok ? { ok: true, value: { accepted: true } } : routed @@ -717,7 +730,7 @@ export class Session implements SessionFace { const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return - this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content)) + this.scheduleObservedRetirement(source.rpcId as SessionRequestId, attachmentRefsIn(data?.content)) } /** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */ @@ -725,7 +738,7 @@ export class Session implements SessionFace { if (this.submissionSettlements.size === 0) return for (const item of items) { if (item.rpcId !== undefined) { - this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content)) + this.scheduleObservedRetirement(item.rpcId, attachmentRefsIn(item.message.content)) } } } @@ -738,7 +751,7 @@ export class Session implements SessionFace { */ private scheduleObservedRetirement( requestId: SessionRequestId, - attachments: readonly ImageAttachmentRef[], + attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[], ): void { const settlement = this.submissionSettlements.get(requestId) if (settlement === undefined || settlement.retiring) return @@ -816,15 +829,16 @@ function scheduleFrame(fn: () => void): void { else setTimeout(fn, 0) } -/** Image attachment references in one structurally-read content block list, in block order. */ -function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] { +/** Attachment references in one structurally-read content block list, in block order. */ +function attachmentRefsIn(content: unknown): readonly (ImageAttachmentRef | FileAttachmentRef)[] { if (!Array.isArray(content)) return [] - const refs: ImageAttachmentRef[] = [] + const refs: Array = [] for (const block of content) { if (typeof block !== 'object' || block === null) continue const candidate = block as { readonly type?: unknown; readonly attachment?: unknown } - if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) { - refs.push(candidate.attachment as ImageAttachmentRef) + if ((candidate.type === 'image' || candidate.type === 'file') + && typeof candidate.attachment === 'object' && candidate.attachment !== null) { + refs.push(candidate.attachment as ImageAttachmentRef | FileAttachmentRef) } } return refs diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index d9fc23e1f1..8afa4b13d0 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -4,8 +4,12 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' -import { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef, +} from '@deepseek-ai/dsh-attachment' +import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' +import type {} from '@deepseek-ai/dsh-client-file-upload' import { ReasoningEffortId, createUserMessage, expandAssistantStream, freezeMessage, } from '@deepseek-ai/dsh-llm' @@ -44,6 +48,7 @@ import type { SessionSelectModelValue, SessionUpdateQueueRequest, SessionUpdateQueueValue, + SessionRequestId, } from './types.ts' interface SessionReadState { @@ -297,6 +302,7 @@ export class SessionCommandController { ) } const agent = await this.resolveAgent(request.sessionId) + if (hasPromptRequest(agent, request.requestId)) return { accepted: true } const selection = this.agents.selectionFor(agent).current if (!routeServed(this.ctx, selection.provider)) { throw new RemoteError( @@ -324,10 +330,23 @@ export class SessionCommandController { ) } } - const content = await admitPromptContent(this.ctx.attachments, request.content) + const admission = resolvePromptFileReceipts( + request.content, + receiptId => this.ctx.fileUploads.resolve(agent, receiptId), + ) + const content = await this.ctx.attachments.admitPromptContent(admission.content) const message: UserMessage = createUserMessage({ content, source }) + if (this.ctx.agents.get(agent.id) !== agent) { + throw new RemoteError( + 'session/not-found', + `session "${agent.id}" was disposed during prompt admission`, + { sessionId: agent.id }, + ) + } + using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId) if (request.mode === 'steer') agent.steer(message) else agent.followup(message) + binding.commit() } catch (error) { if (remoteErrorOf(error) !== undefined) throw error if (error instanceof AttachmentError) { @@ -421,6 +440,12 @@ export class SessionCommandController { })) } else { agent.inbox.remove(request.itemId) + if (request.action.kind === 'remove') { + const source = message.source + if (source.kind === 'user' && 'rpcId' in source) { + this.ctx.fileUploads.retirePrompt(agent, source.rpcId) + } + } if (request.action.kind === 'steer') agent.steer(message) } return { accepted: true } @@ -497,6 +522,39 @@ export class SessionCommandController { } } +function resolvePromptFileReceipts( + content: SessionPromptRequest['content'], + stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined, +): { readonly content: AttachmentAdmissionPart[]; readonly receiptIds: readonly FileUploadReceiptId[] } { + const receiptIds = new Set() + const resolved = content.map((part): AttachmentAdmissionPart => { + if (part.type !== 'file') return part + const attachment = stagedFile(part.receiptId) + if (attachment === undefined) { + throw new RemoteError( + 'session/attachment-invalid', + 'File was not uploaded for this session.', + { reason: 'FILE_NOT_STAGED' }, + ) + } + receiptIds.add(part.receiptId) + return { type: 'file', attachment } + }) + return { content: resolved, receiptIds: [...receiptIds] } +} + +function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { + const matches = (message: UserMessage): boolean => { + const source = message.source + return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId + } + if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true + return agent.session.snapshotEvents().some((event) => { + if (event.type !== 'user/message') return false + const source = event.data.source + return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId + }) +} function imageBlockIn( content: unknown, match: (ref: ImageAttachmentRef) => boolean, diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index cd24711a83..c058f25a08 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' @@ -84,6 +85,7 @@ export class SessionController extends TypertRemoteService { 'agentDefaultModel', 'agents', 'attachments', + 'fileUploads', 'llm', 'sessions', 'sessionProjections', @@ -115,6 +117,11 @@ export class SessionController extends TypertRemoteService { installModelSelectionProjection(ctx) this.agents = new ApiSessionAgentController(ctx) this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) + ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => { + const result = await this.agents.resolveAgent(sessionId) + if ('error' in result) throw result.error + return result.agent + }), 'session-controller: file-upload Agent resolver') this.controlState = new SessionControlController(ctx) // Registered before history so reverse-order teardown closes every // follower before waiting for already-admitted promotions. diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c932ba5a37..f421660088 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -67,7 +67,11 @@ export interface SessionProjectionBaseline { export type SessionProjectionValues = Partial & Readonly> -/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */ +/** + * Browser-submitted prompt content; the Host promotes image bytes to durable + * references. File parts carry the opaque receipt returned by a preceding + * `uploadFile` call on the same Session. + */ export type PromptContentPart = | { readonly type: 'text'; readonly text: string } | { @@ -76,6 +80,7 @@ export type PromptContentPart = readonly data: string readonly name?: string } + | { readonly type: 'file'; readonly receiptId: Branded<'file-upload-receipt-id'> } /** Complete model selection for one Session. */ export interface ModelSelection { diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 07f88b1a4f..e1c28d8bce 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -65,6 +65,11 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise { registerGenerationSource: () => () => {}, start: () => ({ stop: () => {} }), } + ctx.reflect.provide('connection', connection) + ctx.reflect.provide('fileUpload', { + available: true, + post: () => Promise.reject(new Error('unexpected file upload')), + }) ctx.reflect.provide('remote', { ...remote, $stream: (options: RemoteStreamOptions) => ( diff --git a/packages/api/session-controller/tests/client-contract.client.spec.ts b/packages/api/session-controller/tests/client-contract.client.spec.ts index ab73b2c11e..b2abdc2dc2 100644 --- a/packages/api/session-controller/tests/client-contract.client.spec.ts +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -73,8 +73,9 @@ describe('Client Session contracts', () => { expectTypeOf().toEqualTypeOf() }) - it('keeps its catalog-visible prompt parts identical to attachment intake', () => { - expectTypeOf().toEqualTypeOf() + it('keeps its text and image prompt parts identical to attachment intake', () => { + expectTypeOf>() + .toEqualTypeOf() }) it('publishes exact replace, prepend, and append event-window changes', () => { diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 8591a2a0cb..8fdf62c451 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -112,6 +112,23 @@ describe('Session queue commands', () => { })).toEqual({ accepted: true }) expect(steer).toHaveBeenCalledWith(steered) + const queuedFile = createUserMessage({ + content: [{ + type: 'file', + attachment: { attachmentId: AttachmentId('file-queued'), name: 'queued.txt', bytes: 6 }, + }], + source: { kind: 'user', rpcId: 'file-rpc' as never }, + }) + inbox.append('next-turn', queuedFile) + expect(controller.updateQueue({ + sessionId: agent.id, itemId: queuedFile.id, action: { kind: 'steer' }, + })).toEqual({ accepted: true }) + expect(steer).toHaveBeenLastCalledWith(queuedFile) + expect(queuedFile).toMatchObject({ + source: { kind: 'user', rpcId: 'file-rpc' }, + content: [{ type: 'file', attachment: { name: 'queued.txt', bytes: 6 } }], + }) + await expectFailure(Promise.resolve().then(() => controller.cancel({ sessionId: SessionId('missing'), })), 'session/not-found') diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts new file mode 100644 index 0000000000..4e7565c7ff --- /dev/null +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -0,0 +1,471 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { + FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, +} from '@deepseek-ai/dsh-attachment' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { UserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import CommandRuntime from '@deepseek-ai/dsh-commands' +import { createScope } from '@deepseek-ai/dsh-scope' +import FileUploads from '@deepseek-ai/dsh-client-file-upload' +import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' +import { describe, expect, it, vi } from 'vitest' +import type { ApiSessionAgentController } from '../src/agent.ts' +import { SessionCommandController } from '../src/commands.ts' +import type { SessionRequestId } from '../src/types.ts' + +const SESSION = SessionId('upload-session') + +async function uploadHarness(origin?: 'subagent'): Promise<{ + ctx: Context + controller: SessionCommandController + uploads: FileUploads + agent: Agent + followup: ReturnType + saveFile: ReturnType + saveFileStream: ReturnType + saveImages: ReturnType + disposeAgent: () => void + uploadRoute: (request: Request) => Promise +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandRuntime) + const session = ctx.sessions.create(SESSION, { + meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) }, + }) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const followup = vi.fn() + const agent = { + id: session.id, + session, + inbox, + status: 'idle', + ctx: undefined, + steer: vi.fn(), + followup, + cancel: vi.fn(), + } as unknown as Agent + ;(agent as { ctx: Context }).ctx = createScope(ctx, agent).ctx + const disposeAgent = ctx.agents.register(agent) + const saveFile = vi.fn((input: SaveFileAttachment): Promise => Promise.resolve({ + attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`), + name: input.name ?? 'file', + bytes: input.data.byteLength, + })) + const saveFileStream = vi.fn(async (input: SaveFileStreamAttachment): Promise => { + let bytes = 0 + for await (const chunk of input.data) bytes += chunk.byteLength + return { + attachmentId: AttachmentId(`sha256:${'ef'.repeat(32)}`), + name: input.name ?? 'file', + bytes, + } + }) + const saveImages = vi.fn((): Promise => + Promise.reject(new Error('fixture did not expect image persistence'))) + ctx.provide('attachments', Object.setPrototypeOf( + { saveFile, saveFileStream, saveImages }, + AttachmentStore.prototype, + ) as never) + let uploadRoute: ((request: Request) => Promise) | undefined + ctx.provide('connection', { + fetch: { + register: (route: { readonly fetch: (request: Request) => Promise }) => { + uploadRoute = route.fetch + return () => {} + }, + }, + } as never) + ctx.provide('llm', { + listProviders: () => [{ id: 'fixture', name: 'Fixture' }], + resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }), + } as never) + const selection: ModelSelectionRef = { + current: { provider: 'fixture', model: 'fixture-model' }, + assembled: undefined, + } + const agents = { + resolveAgent: () => Promise.resolve({ agent }), + selectionFor: () => selection, + serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), + } as unknown as ApiSessionAgentController + const uploads = new FileUploads(ctx) + if (uploadRoute === undefined) throw new Error('file upload route was not registered') + return { + ctx, + controller: new SessionCommandController(ctx, agents, '/workspace'), + uploads, + agent, + followup, + saveFile, + saveFileStream, + saveImages, + disposeAgent, + uploadRoute, + } +} + +function promptRequest(content: Parameters[0]['content']) { + return { + requestId: 'req-1' as SessionRequestId, + sessionId: SESSION, + mode: 'queue' as const, + content, + } +} + +describe('Session file uploads', () => { + it('registers an HTTP route bound to the upload service', async () => { + const { uploadRoute } = await uploadHarness() + await expect(uploadRoute(new Request('http://host/upload'))) + .resolves.toMatchObject({ status: 405 }) + }) + + it('stages one verbatim upload and preserves its order with an admitted image', async () => { + const { ctx, controller, uploads, agent, followup, saveFile, saveImages } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA', name: 'notes.pdf' }, new AbortController().signal) + expect(saveFile).toHaveBeenCalledTimes(1) + expect(receipt.file.name).toBe('notes.pdf') + expect(receipt.file.bytes).toBe(3) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + expect(uploads.resolve(agent, 'missing' as FileUploadReceiptId)).toBeUndefined() + const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const })) + ctx.commands.register({ + name: 'files', description: 'Use staged files', input: { hint: '', attachments: true }, + handler: commandHandler, + }) + await ctx.commands.execute( + agent, + '/files inspect', + [{ type: 'file', receiptId: receipt.receiptId }], + new AbortController().signal, + ) + expect(commandHandler.mock.calls[0]?.[0]).toMatchObject({ + attachments: [{ type: 'file', attachment: receipt.file }], + }) + const image: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, + } + saveImages.mockResolvedValueOnce([image]) + await controller.prompt(promptRequest([ + { type: 'file', receiptId: receipt.receiptId }, + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, + { type: 'text', text: 'read it' }, + ])) + expect(followup).toHaveBeenCalledTimes(1) + const message = followup.mock.calls[0]?.[0] as UserMessage + expect(message.content).toEqual([ + { type: 'file', attachment: receipt.file }, + { type: 'image', attachment: image }, + { type: 'text', text: 'read it' }, + ]) + }) + + it('stages a bounded byte stream and forwards cancellation to storage', async () => { + const { controller, uploads, followup, saveFileStream } = await uploadHarness() + const abort = new AbortController() + const receipt = await uploads.uploadStream({ + sessionId: SESSION, + data: (async function* (): AsyncIterable { + yield Uint8Array.of(1, 2) + yield Uint8Array.of(3, 4, 5) + })(), + signal: abort.signal, + name: 'huge.bin', + }) + expect(saveFileStream).toHaveBeenCalledWith(expect.objectContaining({ + signal: abort.signal, + name: 'huge.bin', + })) + expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 }) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([ + { type: 'file', attachment: receipt.file }, + ]) + }) + + it('keeps the stream name optional and maps storage failures through the same error vocabulary', async () => { + const { uploads, saveFileStream } = await uploadHarness() + const stream = (async function* (): AsyncIterable {})() + await expect(uploads.uploadStream({ sessionId: SESSION, data: stream })) + .resolves.toMatchObject({ file: { name: 'file', bytes: 0 } }) + expect(saveFileStream).toHaveBeenLastCalledWith({ data: stream }) + saveFileStream.mockRejectedValueOnce('disk offline') + await expect(uploads.uploadStream({ + sessionId: SESSION, + data: (async function* (): AsyncIterable { yield Uint8Array.of(1) })(), + })) + .rejects.toMatchObject({ + code: 'gateway/internal', message: 'failed to store file upload: disk offline', + }) + }) + + it('rejects a prompt citing a file that was never staged for the session', async () => { + const { controller, followup, saveImages } = await uploadHarness() + await expect(controller.prompt(promptRequest([ + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, + { type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId }, + ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } }) + expect(followup).not.toHaveBeenCalled() + expect(saveImages).not.toHaveBeenCalled() + }) + + it('publishes no receipt when its exact Agent is disposed during storage', async () => { + const { uploads, agent, saveFile, disposeAgent } = await uploadHarness() + const saved = Promise.withResolvers() + saveFile.mockReturnValueOnce(saved.promise) + const uploading = uploads.upload(agent, { data: 'AAAA', name: 'late.bin' }, new AbortController().signal) + await vi.waitFor(() => { expect(saveFile).toHaveBeenCalledOnce() }) + disposeAgent() + saved.resolve({ + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), name: 'late.bin', bytes: 3, + }) + await expect(uploading).rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('resolves a cold ordinary Agent and releases the resolver registration', async () => { + const { ctx, uploads, agent, disposeAgent } = await uploadHarness() + disposeAgent() + const resolveAgent = vi.fn(async () => { + ctx.agents.register(agent) + return agent + }) + const disposeResolver = uploads.registerAgentResolver(resolveAgent) + expect(() => { uploads.registerAgentResolver(resolveAgent) }).toThrow('already registered') + await expect(uploads.uploadStream({ + sessionId: SESSION, + data: (async function* (): AsyncIterable { yield Uint8Array.of(1) })(), + })).resolves.toMatchObject({ file: { bytes: 1 } }) + expect(resolveAgent).toHaveBeenCalledWith(SESSION) + disposeResolver() + const replacement = vi.fn(async () => agent) + const disposeReplacement = uploads.registerAgentResolver(replacement) + disposeResolver() + expect(() => { uploads.registerAgentResolver(replacement) }).toThrow('already registered') + expect(disposeReplacement).toBeTypeOf('function') + disposeReplacement() + }) + + it('rejects a cold upload when no Agent resolver is registered', async () => { + const { uploads, disposeAgent } = await uploadHarness() + disposeAgent() + await expect(uploads.uploadStream({ + sessionId: SESSION, + data: (async function* (): AsyncIterable {})(), + })).rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('rejects subagent uploads and access outside the receiving Agent scope', async () => { + const child = await uploadHarness('subagent') + await expect(child.uploads.upload( + child.agent, + { data: 'AAAA' }, + new AbortController().signal, + )).rejects.toMatchObject({ + code: 'subagent/attachment-invalid', + details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, + }) + expect(child.saveFile).not.toHaveBeenCalled() + + const ordinary = await uploadHarness() + const receipt = await ordinary.uploads.upload( + ordinary.agent, + { data: 'AAAA' }, + new AbortController().signal, + ) + const foreignScope = { ...ordinary.agent, ctx: ordinary.ctx } as Agent + expect(() => ordinary.uploads.resolve(foreignScope, receipt.receiptId)) + .toThrow('operation requires the Agent\'s own scope') + }) + + it('retires accepted receipts after their rpcId becomes observable', async () => { + const { controller, uploads, agent } = await uploadHarness() + uploads.retirePrompt(agent, 'not-staged') + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + uploads.retirePrompt(agent, 'other-request') + expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + uploads.retirePrompt(agent, 'req-1') + expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() + }) + + it('retires observed prompt receipts and drops staged state with the Session', async () => { + const first = await uploadHarness() + const observed = await first.uploads.upload( + first.agent, + { data: 'AAAA' }, + new AbortController().signal, + ) + await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }])) + first.ctx.emit('session/event', first.agent.session, { + type: 'user/message', + data: createUserMessage({ + content: [{ type: 'text', text: 'extension event' }], + source: { kind: 'user', rpcId: 1 } as never, + }), + } as never) + expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual(observed.file) + first.agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'observed' }], + source: { kind: 'user', rpcId: 'req-1' as SessionRequestId }, + }), { surfaceOp: 'append' }) + expect(first.uploads.resolve(first.agent, observed.receiptId)).toBeUndefined() + + const second = await uploadHarness() + const abandoned = await second.uploads.upload( + second.agent, + { data: 'AAAA' }, + new AbortController().signal, + ) + second.ctx.emit('session/disposed', second.agent.session) + expect(second.uploads.resolve(second.agent, abandoned.receiptId)).toBeUndefined() + }) + + it('deduplicates a retried rpcId already present in the Agent inbox', async () => { + const { controller, agent, followup } = await uploadHarness() + const request = promptRequest([{ type: 'text', text: 'once' }]) + await controller.prompt(request) + agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage) + await expect(controller.prompt(request)).resolves.toEqual({ accepted: true }) + expect(followup).toHaveBeenCalledOnce() + }) + + it('deduplicates a retried rpcId already present in the durable log', async () => { + const { controller, agent, followup } = await uploadHarness() + const request = promptRequest([{ type: 'text', text: 'once' }]) + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'unidentified' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'accepted' }], + source: { kind: 'user', rpcId: request.requestId }, + }), { surfaceOp: 'append' }) + + await expect(controller.prompt(request)).resolves.toEqual({ accepted: true }) + expect(followup).not.toHaveBeenCalled() + }) + + it('rejects when the Agent disappears during prompt admission', async () => { + const { controller, saveImages, disposeAgent } = await uploadHarness() + const admitted = Promise.withResolvers() + saveImages.mockReturnValueOnce(admitted.promise) + const prompting = controller.prompt(promptRequest([ + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, + ])) + await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) + disposeAgent() + admitted.resolve([{ + attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1, + }]) + await expect(prompting).rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('rejects when a previously bound receipt retires during image admission', async () => { + const { controller, uploads, agent, saveImages } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + const admitted = Promise.withResolvers() + saveImages.mockReturnValueOnce(admitted.promise) + const prompting = controller.prompt({ + ...promptRequest([ + { type: 'file', receiptId: receipt.receiptId }, + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, + ]), + requestId: 'req-2' as SessionRequestId, + }) + await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) + uploads.retirePrompt(agent, 'req-1') + admitted.resolve([{ + attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1, + }]) + await expect(prompting).rejects.toMatchObject({ + code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' }, + }) + }) + + it('keeps the prior receipt binding when a later prompt attempt fails', async () => { + const { controller, uploads, agent, followup } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + followup.mockImplementationOnce(() => { throw new Error('busy') }) + await expect(controller.prompt({ + ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), + requestId: 'req-2' as SessionRequestId, + })).rejects.toMatchObject({ code: 'session/agent-busy' }) + uploads.retirePrompt(agent, 'req-1') + expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() + }) + + it('restores an unbound receipt after prompt delivery fails', async () => { + const { controller, uploads, agent, followup } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) + followup.mockImplementationOnce(() => { throw new Error('busy') }) + await expect(controller.prompt(promptRequest([ + { type: 'file', receiptId: receipt.receiptId }, + ]))).rejects.toMatchObject({ code: 'session/agent-busy' }) + uploads.retirePrompt(agent, 'req-1') + expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + }) + + it('retires a prompt-bound receipt when its queued occurrence is removed', async () => { + const { controller, uploads, agent, followup } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + const queued = followup.mock.calls[0]?.[0] as UserMessage + agent.inbox.append('next-turn', queued) + expect(controller.updateQueue({ + sessionId: SESSION, + itemId: queued.id, + action: { kind: 'remove' }, + })).toEqual({ accepted: true }) + expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() + }) + + it('keeps separate names for identical bytes uploaded more than once', async () => { + const { controller, uploads, agent, followup } = await uploadHarness() + const first = await uploads.upload(agent, { data: 'AAAA', name: 'first.txt' }, new AbortController().signal) + const second = await uploads.upload(agent, { data: 'AAAA', name: 'second.txt' }, new AbortController().signal) + expect(first.file.attachmentId).toBe(second.file.attachmentId) + expect(first.receiptId).not.toBe(second.receiptId) + + await controller.prompt(promptRequest([ + { type: 'file', receiptId: first.receiptId }, + { type: 'file', receiptId: second.receiptId }, + ])) + + const message = followup.mock.calls[0]?.[0] as UserMessage + expect(message.content).toEqual([ + { type: 'file', attachment: first.file }, + { type: 'file', attachment: second.file }, + ]) + }) + + it('maps a non-canonical payload to the wire attachment error', async () => { + const { uploads, agent, saveFile } = await uploadHarness() + await expect(uploads.upload(agent, { data: 'not base64!!' }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'INVALID_FILE_BASE64' } }) + expect(saveFile).not.toHaveBeenCalled() + }) + + it('maps an unexpected storage failure to the internal wire error', async () => { + const { uploads, agent, saveFile } = await uploadHarness() + saveFile.mockRejectedValueOnce(new Error('disk unavailable')) + await expect(uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)) + .rejects.toMatchObject({ + code: 'gateway/internal', + message: 'failed to store file upload: Error: disk unavailable', + }) + }) +}) diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 354c2aff60..8f303fa6d0 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -42,6 +42,16 @@ describe('SessionController facade', () => { list: () => Promise.resolve([header]), inspect, }) as never) + let uploadResolver: ((sessionId: SessionId) => Promise) | undefined + ctx.provide('fileUploads', { + registerAgentResolver: (resolve: (sessionId: SessionId) => Promise) => { + uploadResolver = resolve + return () => {} + }, + resolve: () => undefined, + bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }), + retirePrompt: () => {}, + } as never) const controller = createSessionTestController(ctx, defaults) const status = vi.fn() const failure = vi.fn() @@ -65,6 +75,17 @@ describe('SessionController facade', () => { ctx, } as Agent ctx.agents.register(agent) + const resolveUploadAgent = (id: SessionId): Promise => { + if (uploadResolver === undefined) throw new Error('file upload resolver was not registered') + return uploadResolver(id) + } + await expect(resolveUploadAgent(sessionId)).resolves.toBe(agent) + const activationError = new RemoteError('session/not-found', 'missing upload session', { sessionId }) + vi.spyOn( + (controller as unknown as { agents: ApiSessionAgentController }).agents, + 'resolveAgent', + ).mockResolvedValueOnce({ error: activationError }) + await expect(resolveUploadAgent(sessionId)).rejects.toBe(activationError) const consumeSelection = vi.spyOn( (controller as unknown as { agents: ApiSessionAgentController }).agents, 'consumeSelection', @@ -83,6 +104,10 @@ describe('SessionController facade', () => { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'browser prompt' }], + source: { kind: 'user', rpcId: 'controller-rpc' as never }, + }), { surfaceOp: 'append' }) expect(status).toHaveBeenCalledWith(sessionId, true) expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure')) expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number)) @@ -108,7 +133,7 @@ describe('SessionController facade', () => { }, abort.signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ done: false, - value: { type: 'snapshot', cursor: 1 }, + value: { type: 'snapshot', cursor: 2 }, }) abort.abort() await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts index 722a06b090..573e4cfa7f 100644 --- a/packages/api/session-controller/tests/queue-store.client.spec.ts +++ b/packages/api/session-controller/tests/queue-store.client.spec.ts @@ -73,21 +73,29 @@ describe('Session queue snapshot intake', () => { ]) }) - it('marks mixed-content messages non-editable and keeps image blocks out of the text preview', () => { + it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', () => { const session = makeSession() session.handleControlFrame(queueFrame([{ id: 'q-image', body: '', - content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], + content: [ + { type: 'text', text: 'hi' }, + { type: 'image', data: 'x' } as never, + { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } } as never, + ], }])) const queue = session.getSnapshot().queue expect(typeof queue[0]?.messageId).toBe('string') expect(queue).toMatchObject([ { id: 'q-image', placement: 'queued', - content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }], - // Image blocks render as thumbnails from `content`, so the preview - // carries only the text; non-image foreign blocks keep their marker. + content: [ + { type: 'text', text: 'hi' }, + { type: 'image', data: 'x' }, + { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } }, + ], + // Attachment blocks render from `content`, so the preview carries + // only text; other foreign blocks keep their marker. preview: 'hi', text: null, }, ]) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 2872d3b6eb..deb58794ca 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -8,14 +8,14 @@ import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek- import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { SessionPersistenceRevision, @@ -42,6 +42,10 @@ function promptRequest( } } +function inboxFor(session: Session): Inbox { + return new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) +} + function header(id: string, createdAt: number, extra: Partial = {}): SessionHeader { return { version: SESSION_FORMAT_VERSION, id: sid(id), createdAt, isSeeded: false, cwd: '/proj', ...extra } } @@ -540,7 +544,9 @@ describe('subagent ownership fence', () => { inheritedEventCount: SessionLogOffset(1), }) const followup = vi.fn() - const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent + const agent = { + id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup, + } as unknown as Agent ctx.agents.register(agent) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) @@ -559,7 +565,9 @@ describe('subagent ownership fence', () => { await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } }) const followup = vi.fn() - const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent + const agent = { + id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup, + } as unknown as Agent ctx.agents.register(agent) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), @@ -679,6 +687,7 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register({ id: session.id, session, + inbox: inboxFor(session), status: 'idle', ctx, followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index cd26dfb39d..9fe2bd28bb 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -665,7 +665,7 @@ describe('Web session model selection', () => { const savedRef = { attachmentId: 'saved-image', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1, } - ctx.provide('attachments', { + ctx.provide('attachments', Object.setPrototypeOf({ saveImages: () => { if (saveMode === 'error') return Promise.reject(new Error('image store offline')) if (saveMode === 'remote') { @@ -673,7 +673,7 @@ describe('Web session model selection', () => { } return Promise.resolve([savedRef]) }, - } as never) + }, AttachmentStore.prototype) as never) const followup = vi.fn() Object.assign(agent, { followup }) const remote = createSessionTestRemote(ctx, { diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index c0bad5dabd..46cfe1c049 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Session } from '../src/client/sessions/session.ts' @@ -31,8 +31,20 @@ function imageRef(id: string): ImageAttachmentRef { } as unknown as ImageAttachmentRef } +function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef { + return { attachmentId: id, name, bytes: 3 } as unknown as FileAttachmentRef +} + +type AttachmentRef = ImageAttachmentRef | FileAttachmentRef + +function attachmentBlock(attachment: AttachmentRef) { + return 'mediaType' in attachment + ? { type: 'image' as const, attachment } + : { type: 'file' as const, attachment } +} + /** A durable browser-prompt user/message whose source echoes `rpcId`. */ -function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent { +function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionEvent { return { seq, time: 1_700_000_000_000 + seq, @@ -40,7 +52,7 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly Im surfaceOp: 'append', data: createUserMessage({ content: [ - ...refs.map(attachment => ({ type: 'image' as const, attachment })), + ...refs.map(attachmentBlock), { type: 'text' as const, text: '发送' }, ], source: { kind: 'user', rpcId }, @@ -48,14 +60,14 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly Im } as unknown as SessionEvent } -function queuedItem(rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionQueuedItem { +function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionQueuedItem { return { id: 'm-queued' as SessionQueuedItem['id'], placement: 'queued', rpcId, message: { id: 'm-queued' as SessionQueuedItem['id'], - content: refs.map(attachment => ({ type: 'image', attachment })) as unknown as SessionQueuedItem['message']['content'], + content: refs.map(attachmentBlock) as unknown as SessionQueuedItem['message']['content'], }, } } @@ -72,23 +84,27 @@ describe('beginSubmission', () => { const handle = session.beginSubmission({ mode: 'queue', text: '你好', - images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + attachments: [{ + type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }, + }], }) expect(session.getSnapshot().promptAttempted).toBe(true) expect(session.getSnapshot().pendingSubmissions).toMatchObject([{ requestId: handle.requestId, placement: 'transcript', text: '你好', - images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + attachments: [{ + type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }, + }], }]) }) it('derives and captures the echo placement from running state and delivery mode', () => { const { session } = makeSession() - session.beginSubmission({ mode: 'queue', text: '空闲', images: [] }) + session.beginSubmission({ mode: 'queue', text: '空闲', attachments: [] }) session.handleRunning(true) - session.beginSubmission({ mode: 'queue', text: '排队', images: [] }) - session.beginSubmission({ mode: 'steer', text: '纠偏', images: [] }) + session.beginSubmission({ mode: 'queue', text: '排队', attachments: [] }) + session.beginSubmission({ mode: 'steer', text: '纠偏', attachments: [] }) session.handleRunning(false) expect(session.getSnapshot().pendingSubmissions.map(({ text, placement }) => ({ text, placement }))).toEqual([ { text: '空闲', placement: 'transcript' }, @@ -103,7 +119,7 @@ describe('beginSubmission', () => { const handle = session.beginSubmission({ mode: 'queue', text: '放弃', - images: [], + attachments: [], onRetire: retirement => retirements.push(retirement), }) handle.abandon() @@ -121,7 +137,7 @@ describe('prompt-coupled retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '失败的', - images: [], + attachments: [], onRetire: retirement => retirements.push(retirement), }) const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId) @@ -133,7 +149,7 @@ describe('prompt-coupled retirement', () => { it('sends the echo identity as the prompt requestId', async () => { const { api, session } = makeSession() - const handle = session.beginSubmission({ mode: 'queue', text: '带 id', images: [] }) + const handle = session.beginSubmission({ mode: 'queue', text: '带 id', attachments: [] }) await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId) expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }]) }) @@ -141,7 +157,7 @@ describe('prompt-coupled retirement', () => { it('an unidentified prompt failure leaves registered echoes alone', async () => { const { api, session } = makeSession() api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) - session.beginSubmission({ mode: 'queue', text: '还在', images: [] }) + session.beginSubmission({ mode: 'queue', text: '还在', attachments: [] }) await session.prompt([{ type: 'text', text: '另一个' }], 'queue') expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) }) @@ -156,7 +172,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '发送', - images: [{ previewUrl: 'blob:p1' }], + attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }], onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-1')] @@ -176,7 +192,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '排队', - images: [{ previewUrl: 'blob:p1' }], + attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }], onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-q')] @@ -188,9 +204,31 @@ describe('observed retirement', () => { expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }]) }) + it('retires a mixed echo with durable references in original selection order', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: PendingSubmissionRetirement[] = [] + const file = fileRef('file-1') + const handle = session.beginSubmission({ + mode: 'queue', + text: 'mixed', + attachments: [ + { type: 'image', value: { previewUrl: 'blob:first' } }, + { type: 'file', value: file }, + { type: 'image', value: { previewUrl: 'blob:last' } }, + ], + onRetire: retirement => retirements.push(retirement), + }) + const refs = [imageRef('image-1'), file, imageRef('image-2')] + await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId, refs) as never }) + await settleFrames() + expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) + }) + it('a full-window install (reconnect resync) retires echoes observed in the window', async () => { const { api, session } = makeSession() - const handle = session.beginSubmission({ mode: 'queue', text: '重连', images: [] }) + const handle = session.beginSubmission({ mode: 'queue', text: '重连', attachments: [] }) api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(SessionSeq(12), handle.requestId)]))) await session.open() await settleFrames() @@ -205,7 +243,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '先观察', - images: [], + attachments: [], onRetire: retirement => retirements.push(retirement), }) await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never }) @@ -222,7 +260,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '同一请求', - images: [], + attachments: [], onRetire: retirement => retirements.push(retirement), }) session.handleControlFrame({ @@ -245,7 +283,7 @@ describe('observed retirement', () => { const { api, session } = makeSession() api.onHistory = () => Promise.resolve(ok(historyValue([]))) await session.open() - const handle = session.beginSubmission({ mode: 'queue', text: '帧', images: [] }) + const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] }) await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never }) expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) expect(frames).toHaveLength(1) @@ -263,13 +301,13 @@ describe('disposal', () => { const observed = session.beginSubmission({ mode: 'queue', text: '已观察', - images: [], + attachments: [], onRetire: retirement => retirements.push({ text: '已观察', retirement }), }) session.beginSubmission({ mode: 'queue', text: '未settle', - images: [], + attachments: [], onRetire: retirement => retirements.push({ text: '未settle', retirement }), }) await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), observed.requestId) as never }) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 72e5031e3f..20893c01e2 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -15,7 +15,6 @@ const PARENT = 'fk-parent' as SessionId afterEach(() => { vi.unstubAllGlobals() }) - function makeSession( api = new FakeApiClient(), options: SessionOptions = {}, @@ -479,6 +478,29 @@ describe('prompt and cancel errors', () => { }) }) + it('rejects staged files instead of dropping them from subagent continuations', async () => { + const api = new FakeApiClient() + const session = new Session(SID, fakeRemote(api), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + await session.open() + + const prompted = await session.prompt([ + { type: 'file', receiptId: 'receipt' as never }, + { type: 'text', text: '继续' }, + ], 'queue') + + expect(prompted).toMatchObject({ + ok: false, + error: { + code: 'subagent/attachment-invalid', + details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, + }, + }) + expect(api.callsOf('subagents.prompt')).toEqual([]) + }) + it('sends a one-shot address to the Host under the continuable marker', async () => { const api = new FakeApiClient() api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError( diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 00dda55bc9..8bdbd9ed43 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -3,6 +3,11 @@ import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Context } from '@deepseek-ai/cordis' import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' +import type { + AdmittedPromptContentPart, + AttachmentAdmissionPart, + ImageAttachmentLimits, +} from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { SessionPersistenceNotFoundError, @@ -91,6 +96,15 @@ export interface TestSessionRemoteDefaults { const installed = new WeakMap() +const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 20, + maxMessageImageBytes: 100 * 1024 * 1024, + maxImagePixels: 40_000_000, + maxImageDimension: 2000, + mediaTypes: Object.freeze(['image/png'] as const), +}) + /** Compact header-and-events point read a persistence double declares per session. */ interface TestSessionInspection { readonly meta: SessionHeader @@ -233,6 +247,29 @@ function installControllers( }, } as never) } + if (ctx.get('attachments') === undefined) { + ctx.provide('attachments', { + imageLimits: TEST_IMAGE_LIMITS, + admitPromptContent: async ( + content: readonly AttachmentAdmissionPart[], + ): Promise => { + const admitted: AdmittedPromptContentPart[] = [] + for (const part of content) { + if (part.type === 'image') throw new Error('test did not configure image persistence') + admitted.push(part) + } + return admitted + }, + } as never) + } + if (ctx.get('fileUploads') === undefined) { + ctx.provide('fileUploads', { + registerAgentResolver: () => () => {}, + resolve: () => undefined, + bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }), + retirePrompt: () => {}, + } as never) + } installSessionReadTestServices(ctx) const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) let controller: SessionController diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 030ed712d1..3bb07893e2 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -15,6 +15,7 @@ { "path": "../gateway/tsconfig.client.json" }, { "path": "../../attachment/attachment" }, { "path": "../../client/connection/tsconfig.client.json" }, + { "path": "../../client/file-upload/tsconfig.client.json" }, { "path": "../../client/store" }, { "path": "../../context/file-reference" }, { "path": "../../core/session" }, diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 28dd075fd1..ad7751119f 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -29,6 +29,7 @@ { "path": "../../core/session" }, { "path": "../../context/file-reference" }, { "path": "../../attachment/attachment" }, + { "path": "../../client/file-upload/tsconfig.host.json" }, { "path": "../../interaction/permission-presets" }, { "path": "../../jobs/jobs" }, { "path": "../../llm/llm" }, diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index a019d28997..9c5f3a1abb 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.i18n.yaml @@ -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-local/README.md -README.md: 5e9bda9aa13939c3945d005ea474c3caf8daa661 -README.zh.md: 88dcbff120c4c61ea776e43fc906923f039ee09b +README.md: 364153b7b56daa725003178b6cfad90e3f94bc04 +README.zh.md: 6ca5c6df8289c9e16bfe608b5b9ae200adf18a6b diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 5e9bda9aa1..364153b7b5 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the local storage and image-processing backend for attachments: source images are validated, oriented, stripped of metadata and color profiles, normalized to 8-bit sRGB/sRGBA, and saved below `DSH_HOME`; route-specific request versions are derived and cached separately. It is what the shipped `dsh` composition uses, so durable image attachments work without configuration. Identical normalized images are stored only once, concurrent reads of one request variant share work, and stored images stay readable after later admission-limit changes. Storage is local to this machine — other hosts cannot read these images — and objects are never deleted automatically. +This package provides the local storage and image-processing backend for attachments: source images are validated, oriented, stripped of metadata and color profiles, normalized to 8-bit sRGB/sRGBA, and saved below `DSH_HOME`; route-specific request versions are derived and cached separately, and generic files are saved byte-for-byte with no admission limits. Streamed file writes and reads use bounded chunks; writes hash into a private staging object before atomic publication, and reads verify the recorded byte length and digest without a whole-file memory copy. It is what the shipped `dsh` composition uses, so durable attachments work without configuration. Identical bytes occupy one canonical object even when uploads use different display names; each model-facing name is a hard link to that object. Concurrent reads of one request variant share work, and stored images stay readable after later admission-limit changes. Storage is local to this machine; other hosts cannot read these objects, and objects are never deleted automatically. ## Table of Contents @@ -25,7 +25,7 @@ This package provides the local storage and image-processing backend for attachm ## Use this package -In the default composition, attach images to a prompt or command and they are stored on this machine automatically. If you compose your own setup, mounting this one plugin gives you durable image attachments. +In the default composition, images and generic files attached to prompts or commands are stored on this machine automatically. If you compose your own setup, mounting this plugin provides durable attachments. ### Minimal configuration @@ -87,12 +87,15 @@ Admission accepts up to 20 images and 200 MiB of source bytes per message; one s Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. +Generic-file bytes have one canonical object at `/attachments/v1/file-objects//`. Each reference path at `/attachments/v1/files///` is a read-only hard link, so different names for equal bytes do not duplicate disk content. `readFileStream` reads the reference path in bounded chunks and verifies the complete digest and recorded byte count before a consumer can finish successfully. A missing, changed, or truncated object fails its consumer instead of producing a complete export with different bytes. + ### Source map | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `LocalAttachmentStore`, `Config` schema, defaults | | [`src/store.ts`](src/store.ts) | Content-addressed write and verified read: staging, hard-link publish, fsync chain, digest verification | +| [`src/file-store.ts`](src/file-store.ts) | Verbatim streamed file writes, verified streamed reads, and safe stored filenames | | [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | Provider-independent normalization and bounded format/quality candidates | | [`src/request-image.ts`](src/request-image.ts) | Route-specific request transforms, cache identity, and singleflight | | [`src/image.ts`](src/image.ts) | Full raster decode and metadata verification | @@ -117,7 +120,7 @@ For the full service contract and payload types, read the subsystem reference; f ## Model Experience -Indirectly, through request descriptors. A mapped execution filesystem lets the model see each image's identity, dimensions, media type, read-only process path, writable-copy extension, and normalization warning alongside the request bytes. +Indirectly, through request descriptors. A mapped execution filesystem lets the model see each image's identity, dimensions, media type, read-only process path, writable-copy extension, and normalization warning alongside the request bytes. Generic files project as text handles naming their identity and read-only process path; when no mapping exists, the handle states that the execution environment cannot read the file. #### KV Cache effect diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 88dcbff120..6ca5c6df82 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供附件的本地存储与图片处理后端:源图经过校验、方向修正、元数据与色彩配置移除,并规范化为 8-bit sRGB/sRGBA 后保存在 `DSH_HOME` 下;路由专用请求版本另行派生并缓存。随附的 `dsh` 组合使用的就是它,因此持久图片附件无需配置即可工作。相同规范化图片只存一份,同一请求变体的并发读取共享工作,即使后来收紧准入限制,已存图片仍然可读。存储仅限本机——其他主机无法读取这些图片——对象也永远不会自动删除。 +本包提供附件的本地存储与图片处理后端:源图经过校验、方向修正、元数据与色彩配置移除,并规范化为 8-bit sRGB/sRGBA 后保存在 `DSH_HOME` 下;路由专用请求版本另行派生并缓存,通用文件则不设准入限制,按字节原样保存。流式文件写入与读取都使用有界分块;写入会在私有暂存对象中计算摘要后原子发布,读取会校验记录的字节长度与摘要,两者都不产生整文件内存副本。随附的 `dsh` 组合使用的就是它,因此持久附件无需配置即可工作。即使使用不同显示名称上传,相同字节也只占用一个规范对象;每条模型可见路径都是指向该对象的硬链接。同一请求变体的并发读取共享工作,即使后来收紧准入限制,已存图片仍然可读。存储仅限本机,其他主机无法读取这些对象,对象也永远不会自动删除。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在默认组合中,把图片附加到提示词或命令,它们会自动保存到本机。自行组合时,挂载这一个插件即可获得持久图片附件。 +在默认组合中,把图片或通用文件附加到提示词或命令,它们会自动保存到本机。自行组合时,挂载这个插件即可获得持久附件。 ### 最小配置 @@ -87,12 +87,15 @@ kind: "package-reference" 请求版本位于 `/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 +通用文件字节的唯一规范对象位于 `/attachments/v1/file-objects//`。每条引用路径 `/attachments/v1/files///` 都是只读硬链接,所以名称不同但字节相同的文件不会重复占用磁盘。`readFileStream` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。 + ### 源码地图 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`LocalAttachmentStore`、`Config` schema、默认值 | | [`src/store.ts`](src/store.ts) | 内容寻址写入与校验读取:暂存、硬链接发布、fsync 链、摘要校验 | +| [`src/file-store.ts`](src/file-store.ts) | 原样文件的流式写入、校验式流式读取与安全存储文件名 | | [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | 提供方无关的规范化与有界格式/质量候选 | | [`src/request-image.ts`](src/request-image.ts) | 路由专用请求变换、缓存身份与 singleflight | | [`src/image.ts`](src/image.ts) | 完整光栅解码与元数据校验 | @@ -117,7 +120,7 @@ kind: "package-reference" ## 模型体验 -本包通过请求描述符间接影响模型。执行文件系统可以映射宿主对象时,模型会随请求字节看到每张图片的身份、尺寸、媒体类型、只读进程路径、可写副本扩展名与规范化警告。 +本包通过请求描述符间接影响模型。执行文件系统可以映射宿主对象时,模型会随请求字节看到每张图片的身份、尺寸、媒体类型、只读进程路径、可写副本扩展名与规范化警告。通用文件会投影为指出文件身份与只读进程路径的文本句柄;无法映射时,句柄会说明当前执行环境不能读取该文件。 #### KV Cache 影响 diff --git a/packages/attachment/attachment-local/src/compression-limiter.ts b/packages/attachment/attachment-local/src/compression-limiter.ts index 3935f262a1..26cc1c6ecb 100644 --- a/packages/attachment/attachment-local/src/compression-limiter.ts +++ b/packages/attachment/attachment-local/src/compression-limiter.ts @@ -1,5 +1,16 @@ /** Instance-owned concurrency bound for native image transformations. */ +/** + * Preserve Error rejections and normalize non-Error native binding values. + * @param reason - rejection reason returned by a compression task. + * @returns an Error suitable for promise rejection. + */ +export function compressionFailure(reason: unknown): Error { + return reason instanceof Error + ? reason + : new Error('Image compression task rejected with a non-Error value.', { cause: reason }) +} + /** FIFO limiter for asynchronous compression work. */ export class CompressionLimiter { private active = 0 @@ -30,9 +41,7 @@ export class CompressionLimiter { }, (error: unknown) => { release() - reject(error instanceof Error - ? error - : new Error('Image compression task rejected with a non-Error value.', { cause: error })) + reject(compressionFailure(error)) }, ) } diff --git a/packages/attachment/attachment-local/src/file-store.ts b/packages/attachment/attachment-local/src/file-store.ts new file mode 100644 index 0000000000..cf4c56edb1 --- /dev/null +++ b/packages/attachment/attachment-local/src/file-store.ts @@ -0,0 +1,181 @@ +/** Verbatim content-addressed local file storage. @module @deepseek-ai/dsh-attachment-local/file-store */ + +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { join } from 'node:path' +import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { + FileAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, +} from '@deepseek-ai/dsh-attachment' +import { + publishImmutableAlias, publishImmutableObject, publishImmutableObjectStream, +} from './store.ts' + +const FILE_ID_PATTERN = /^sha256:([a-f0-9]{64})$/ +const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu + +function isWindowsDeviceName(name: string): boolean { + const dot = name.indexOf('.') + const stem = (dot < 0 ? name : name.slice(0, dot)).replace(/[. ]+$/u, '') + return WINDOWS_DEVICE_NAME.test(stem) +} + +function utf8Prefix(value: string, maxBytes: number): string { + let bytes = 0 + let prefix = '' + for (const character of Buffer.from(value).toString('utf8')) { + const characterBytes = Buffer.byteLength(character) + if (bytes + characterBytes > maxBytes) break + prefix += character + bytes += characterBytes + } + return prefix +} + +/** + * Sanitize one caller display name into a safe stored leaf name. Both + * separator styles are stripped by hand: a POSIX host treats `\` as an + * ordinary character, so path.basename would keep a Windows client's full + * local path and leak it into the reference and the session log. Characters + * Windows refuses in file names become `_` so one reference stays valid on + * every supported host. + * @param value - caller-declared display name, possibly a full client path. + * @returns a non-empty leaf name safe to store on every supported filesystem. + */ +export function fileLeafName(value: string | undefined): string { + if (value === undefined) return 'file' + const leaf = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1) + let clean = leaf + .replace(/[\u0000-\u001f\u007f]/g, '') + .replace(/[<>:"|?*]/g, '_') + .trim() + .replace(/[. ]+$/u, '') + if (isWindowsDeviceName(clean)) clean = `_${clean}` + clean = utf8Prefix(clean, 255).replace(/[. ]+$/u, '') + return clean === '' || clean === '.' || clean === '..' ? 'file' : clean +} + +function ensureFileReference(ref: FileAttachmentRef): string { + const match = FILE_ID_PATTERN.exec(String(ref.attachmentId)) + if (match?.[1] === undefined || ref.name !== fileLeafName(ref.name)) { + throw new AttachmentError('File attachment reference is invalid.', 'INVALID_ATTACHMENT_REF') + } + return match[1] +} + +/** + * Derive the absolute immutable-object path for one stored file. The digest + * names a directory so the sanitized display name stays the stored leaf name, + * giving models and users a path that ends in the real filename. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param ref - durable file reference from the session log or an upload receipt. + * @returns provider-local path without reading the object. + * @throws an AttachmentError when the reference digest or name is invalid. + */ +export function storedFilePath(root: string, ref: FileAttachmentRef): string { + const sha256 = ensureFileReference(ref) + return join(root, 'files', sha256.slice(0, 2), sha256, ref.name) +} + +/** Canonical object path shared by every display name for one digest. */ +function storedFileObjectPath(root: string, sha256: string): string { + return join(root, 'file-objects', sha256.slice(0, 2), sha256) +} + +/** + * Commit one file byte-for-byte below a versioned attachment root. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param input - exact bytes and optional display name. + * @returns the durable content-addressed file reference. + */ +export async function saveFileVerbatim( + root: string, + input: SaveFileAttachment, +): Promise { + const sha256 = createHash('sha256').update(input.data).digest('hex') + const ref: FileAttachmentRef = { + attachmentId: AttachmentId(`sha256:${sha256}`), + name: fileLeafName(input.name), + bytes: input.data.byteLength, + } + const objectPath = storedFileObjectPath(root, sha256) + await publishImmutableObject(root, objectPath, input.data, sha256) + await publishImmutableAlias(root, objectPath, storedFilePath(root, ref), sha256) + return ref +} + +/** + * Commit one file byte-for-byte from bounded chunks below a versioned attachment root. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param input - ordered exact bytes, optional cancellation, and display name. + * @returns the durable content-addressed file reference. + */ +export async function saveFileStreamVerbatim( + root: string, + input: SaveFileStreamAttachment, +): Promise { + const name = fileLeafName(input.name) + const stored = await publishImmutableObjectStream( + root, + input.data, + sha256 => storedFileObjectPath(root, sha256), + input.signal, + ) + const ref: FileAttachmentRef = { + attachmentId: AttachmentId(`sha256:${stored.sha256}`), + name, + bytes: stored.bytes, + } + input.signal?.throwIfAborted() + await publishImmutableAlias( + root, + storedFileObjectPath(root, stored.sha256), + storedFilePath(root, ref), + stored.sha256, + ) + input.signal?.throwIfAborted() + return ref +} + +/** + * Read one stored file in bounded chunks and verify its byte count and digest. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param ref - durable file reference from the session log. + * @param signal - optional cancellation for filesystem reads. + * @returns exact stored bytes in order; integrity failures reject after the final chunk. + */ +export async function* readFileStreamVerbatim( + root: string, + ref: FileAttachmentRef, + signal?: AbortSignal, +): AsyncIterable { + signal?.throwIfAborted() + const sha256 = ensureFileReference(ref) + const stream = createReadStream(storedFilePath(root, ref), { + highWaterMark: 1 << 16, + ...(signal === undefined ? {} : { signal }), + }) + const hash = createHash('sha256') + let bytes = 0 + try { + for await (const chunk of stream) { + signal?.throwIfAborted() + const data = chunk as Buffer + hash.update(data) + bytes += data.byteLength + yield data + } + } catch (error) { + signal?.throwIfAborted() + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + throw new AttachmentError('File attachment object is missing.', 'ATTACHMENT_NOT_FOUND') + } + throw new AttachmentError('Unable to read file attachment.', 'ATTACHMENT_READ_FAILED', { cause: error }) + } finally { + stream.destroy() + } + signal?.throwIfAborted() + if (bytes !== ref.bytes || hash.digest('hex') !== sha256) { + throw new AttachmentError('Stored file attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') + } +} diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 16b963f225..7415bf940f 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -5,17 +5,23 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { + FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, + SaveFileAttachment, + SaveFileStreamAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { NormalizationPolicy } from './normalization.ts' -import { CompressionLimiter } from './compression-limiter.ts' +import { CompressionLimiter, compressionFailure } from './compression-limiter.ts' import { commitPreparedImageFile, normalizedImagePath, prepareImageFile, readImageFile, validateImageFile } from './store.ts' +import { + readFileStreamVerbatim, saveFileStreamVerbatim, saveFileVerbatim, storedFilePath, +} from './file-store.ts' import { readRequestImageFile, requestImageVariantId } from './request-image.ts' export { canPassThroughNormalization, normalizeImage } from './normalization.ts' @@ -124,9 +130,7 @@ class SharedRequest { }, (error: unknown) => { signal.removeEventListener('abort', abort) release(false) - // CompressionLimiter normalizes task rejections before this handler. - // oxlint-disable-next-line typescript/prefer-promise-reject-errors - reject(error) + reject(compressionFailure(error)) }) }) } @@ -222,6 +226,22 @@ export class LocalAttachmentStore extends AttachmentStore { return normalizedImagePath(this.root, ref) } + override async saveFile(input: SaveFileAttachment): Promise { + return saveFileVerbatim(this.root, input) + } + + override async saveFileStream(input: SaveFileStreamAttachment): Promise { + return saveFileStreamVerbatim(this.root, input) + } + + override readFileStream(ref: FileAttachmentRef, signal?: AbortSignal): AsyncIterable { + return readFileStreamVerbatim(this.root, ref, signal) + } + + override fileHostPath(ref: FileAttachmentRef): string { + return storedFilePath(this.root, ref) + } + override async readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index e5a979aec1..7fab679e34 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -1,7 +1,7 @@ /** Content-addressed, owner-private local attachment storage. */ import { createHash, randomUUID } from 'node:crypto' -import { constants } from 'node:fs' +import { constants, createReadStream } from 'node:fs' import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises' import { dirname, join, parse, resolve } from 'node:path' import { @@ -197,60 +197,210 @@ export async function commitPreparedImageFile( if (digest(normalized) !== sha256 || normalized.byteLength !== prepared.ref.bytes) { throw new AttachmentError('Prepared attachment bytes do not match their reference.', 'ATTACHMENT_CORRUPT') } - const bucket = join(root, 'objects', sha256.slice(0, 2)) + await publishImmutableObject(root, normalizedImagePath(root, prepared.ref), normalized, sha256) + return prepared.ref +} + +/** + * Publish one immutable content-addressed object below a versioned attachment + * root: staged write, fsync, hard-link into place, digest-verified EEXIST + * deduplication, read-only mode, and durable directory entries from the + * target's parent up to (excluding) `root`. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param target - absolute final object path below `root`. + * @param data - exact object bytes whose digest is `sha256`. + * @param sha256 - hex digest the stored bytes must match on deduplication. + */ +export async function publishImmutableObject( + root: string, + target: string, + data: Uint8Array, + sha256: string, +): Promise { + const staged = await stageImmutableObject(root, (function* (): Iterable { + yield data + })()) + if (staged.sha256 !== sha256) { + await removeTemporary(staged.path) + throw new AttachmentError('Attachment bytes do not match their publication digest.', 'ATTACHMENT_CORRUPT') + } + await publishStagedObject(root, target, staged) +} + +/** Digest and byte count produced while streaming one immutable object to disk. */ +export interface StreamedImmutableObject { + readonly sha256: string + readonly bytes: number +} + +/** + * Stream one immutable object from bounded chunks into a staging file, then + * publish it at a digest-derived target without collecting the complete object in memory. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param data - exact object bytes in order. + * @param targetFor - derive the final absolute target from the completed digest and byte count. + * @param signal - optional cancellation for source reads and storage writes. + * @returns digest and exact byte count of the published object. + */ +export async function publishImmutableObjectStream( + root: string, + data: AsyncIterable, + targetFor: (sha256: string, bytes: number) => string, + signal?: AbortSignal, +): Promise { + const staged = await stageImmutableObject(root, data, signal) + let target: string + try { + target = targetFor(staged.sha256, staged.bytes) + } catch (error) { + /* v8 ignore start -- The local target callback constructs a validated reference from this function's digest. */ + await removeTemporary(staged.path) + throw error + /* v8 ignore stop */ + } + await publishStagedObject(root, target, staged) + return { sha256: staged.sha256, bytes: staged.bytes } +} + +/** + * Publish another durable hard-link name for an existing immutable object. + * @param root - absolute versioned attachment root. + * @param source - existing content-addressed object below `root`. + * @param target - new alias below `root`. + * @param sha256 - expected object digest for an existing-target race. + */ +export async function publishImmutableAlias( + root: string, + source: string, + target: string, + sha256: string, +): Promise { + const parent = dirname(target) + try { + const boundary = await ensureDurableHome(dirname(dirname(resolve(root)))) + await ensureDurableDirectory(parent, boundary) + try { + await link(source, target) + } catch (error) { + /* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */ + if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error + if (await digestFile(target) !== sha256) { + throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') + } + } + await chmod(target, 0o400) + const stop = resolve(root) + for (let level = parent; level !== stop; level = dirname(level)) { + await syncDirectory(level) + /* v8 ignore next -- filesystem-root guard: targets sit below root, so the walk reaches `stop` first. */ + if (dirname(level) === level) break + } + } catch (error) { + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unable to persist attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) + } +} + +interface StagedImmutableObject extends StreamedImmutableObject { + readonly path: string + readonly boundary: string +} + +async function stageImmutableObject( + root: string, + data: AsyncIterable | Iterable, + signal?: AbortSignal, +): Promise { const staging = join(root, 'tmp') // Establish DSH_HOME itself against the filesystem root once per process. // Every process performs that proof independently, so observing a directory // another process created can never be mistaken for durable publication. const boundary = await ensureDurableHome(dirname(dirname(resolve(root)))) - await ensureDurableDirectory(bucket, boundary) await ensureDurableDirectory(staging, boundary) const temporary = join(staging, randomUUID()) - const target = normalizedImagePath(root, prepared.ref) let handle try { handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) - await handle.writeFile(normalized) + const hash = createHash('sha256') + let bytes = 0 + for await (const chunk of data) { + signal?.throwIfAborted() + await handle.writeFile(chunk) + hash.update(chunk) + bytes += chunk.byteLength + } + signal?.throwIfAborted() await handle.sync() + signal?.throwIfAborted() await handle.close() handle = undefined - try { - await link(temporary, target) - } catch (error) { - /* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */ - if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error - const existing = new Uint8Array(await readFile(target)) - if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') - } - // Windows shares the read-only attribute across hard links and refuses to - // unlink either name once it is set, so discard the staging name first. - await unlink(temporary) - // The target remains the sole link for a new object; this also restores - // read-only mode when the deduplication path observes an existing object. - await chmod(target, 0o400) - // Persist the target entry and close a concurrent bucket-creation window - // before the reference can reach a session checkpoint. The dedup path - // repeats both syncs because it may observe another writer's link before - // that writer reaches its own durability boundary. - await syncDirectory(bucket) - await syncDirectory(join(root, 'objects')) + return { path: temporary, boundary, sha256: hash.digest('hex'), bytes } } catch (error) { - /* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */ + /* v8 ignore next -- A descriptor remains open only when write, sync, or close fails. */ if (handle !== undefined) await handle.close().catch( /* v8 ignore next -- Close failure is superseded by the storage operation that entered cleanup. */ () => {}, ) - await unlink(temporary).catch( - /* v8 ignore next -- The callback requires a second independent staging-unlink failure. */ - (cleanupError: unknown) => { - /* v8 ignore next -- Cleanup is best-effort only for a staging file already removed by a failed operation. */ - if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError - }, - ) - if (error instanceof AttachmentError) throw error - throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) + await removeTemporary(temporary) + if (error instanceof AttachmentError || signal?.aborted === true) throw error + throw new AttachmentError('Unable to persist attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) } - return prepared.ref +} + +async function publishStagedObject( + root: string, + target: string, + staged: StagedImmutableObject, +): Promise { + const parent = dirname(target) + try { + await ensureDurableDirectory(parent, staged.boundary) + try { + await link(staged.path, target) + } catch (error) { + /* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */ + if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error + if (await digestFile(target) !== staged.sha256) { + throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') + } + } + // Windows shares the read-only attribute across hard links and refuses to + // unlink either name once it is set, so discard the staging name first. + await unlink(staged.path) + // The target remains the sole link for a new object; this also restores + // read-only mode when the deduplication path observes an existing object. + await chmod(target, 0o400) + // Persist the target entry and close every concurrent parent-creation + // window before the reference can reach a session checkpoint. The dedup + // path repeats these syncs because it may observe another writer's link + // before that writer reaches its own durability boundary. + const stop = resolve(root) + for (let level = parent; level !== stop; level = dirname(level)) { + await syncDirectory(level) + /* v8 ignore next -- filesystem-root guard: targets sit below root, so the walk reaches `stop` first. */ + if (dirname(level) === level) break + } + } catch (error) { + await removeTemporary(staged.path) + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unable to persist attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) + } +} + +async function digestFile(path: string): Promise { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path) as AsyncIterable) hash.update(chunk) + return hash.digest('hex') +} + +async function removeTemporary(path: string): Promise { + await unlink(path).catch( + /* v8 ignore next -- Cleanup can observe a staging name already removed after successful linking. */ + (cleanupError: unknown) => { + /* v8 ignore next -- Any cleanup failure except an absent staging name must remain visible. */ + if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError + }, + ) } /** diff --git a/packages/attachment/attachment-local/tests/file-store.spec.ts b/packages/attachment/attachment-local/tests/file-store.spec.ts new file mode 100644 index 0000000000..dde6ec2d06 --- /dev/null +++ b/packages/attachment/attachment-local/tests/file-store.spec.ts @@ -0,0 +1,278 @@ +import { createHash } from 'node:crypto' +import { chmod, mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { afterEach, describe, expect, it } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { + fileLeafName, readFileStreamVerbatim, saveFileStreamVerbatim, saveFileVerbatim, storedFilePath, +} from '../src/file-store.ts' +import { publishImmutableAlias } from '../src/store.ts' + +const roots: string[] = [] + +async function makeRoot(): Promise { + const root = join(await mkdtemp(join(tmpdir(), 'dsh-file-store-')), 'attachments', 'v1') + roots.push(root) + return root +} + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(join(root, '..', '..'), { recursive: true, force: true, maxRetries: 3 }) + } +}) + +function sha256(data: Uint8Array): string { + return createHash('sha256').update(data).digest('hex') +} + +async function readStream(stream: AsyncIterable): Promise { + const chunks: Uint8Array[] = [] + for await (const chunk of stream) chunks.push(chunk) + return new Uint8Array(Buffer.concat(chunks)) +} + +describe('fileLeafName', () => { + it('keeps ordinary names and strips client paths of both separator styles', () => { + expect(fileLeafName('notes.pdf')).toBe('notes.pdf') + expect(fileLeafName('/home/user/data.csv')).toBe('data.csv') + expect(fileLeafName('C:\\Users\\me\\report.docx')).toBe('report.docx') + }) + + it('removes control characters, rewrites Windows-invalid characters, and bounds UTF-8 length', () => { + expect(fileLeafName('a\u0000b\u001f.txt')).toBe('ab.txt') + expect(fileLeafName('ac:d"e|f?g*h.txt')).toBe('a_b_c_d_e_f_g_h.txt') + expect(fileLeafName(`${'x'.repeat(300)}.bin`).length).toBe(255) + const multibyte = fileLeafName(`${'文'.repeat(100)}.txt`) + expect(Buffer.byteLength(multibyte)).toBeLessThanOrEqual(255) + expect(multibyte.endsWith('\ufffd')).toBe(false) + expect(fileLeafName(`safe-${'x'.repeat(248)}\ud83d\ude00`)).not.toMatch(/\ud83d$/u) + }) + + it('removes Windows trailing characters and protects reserved device names', () => { + expect(fileLeafName('report. ')).toBe('report') + expect(fileLeafName('CON')).toBe('_CON') + expect(fileLeafName('com1.txt')).toBe('_com1.txt') + expect(fileLeafName('con .txt')).toBe('_con .txt') + expect(fileLeafName('com10.txt')).toBe('com10.txt') + }) + + it('falls back to a stable name for absent, empty, and dot-only inputs', () => { + expect(fileLeafName(undefined)).toBe('file') + expect(fileLeafName('')).toBe('file') + expect(fileLeafName(' ')).toBe('file') + expect(fileLeafName('.')).toBe('file') + expect(fileLeafName('..')).toBe('file') + }) +}) + +describe('saveFileVerbatim', () => { + it('stores the exact bytes read-only at a digest-and-name path', async () => { + const root = await makeRoot() + const data = Uint8Array.from([0, 1, 2, 250, 251, 252]) + const ref = await saveFileVerbatim(root, { data, name: 'blob.bin' }) + expect(ref).toEqual({ + attachmentId: AttachmentId(`sha256:${sha256(data)}`), + name: 'blob.bin', + bytes: data.byteLength, + }) + const path = storedFilePath(root, ref) + expect(path.endsWith(join(sha256(data), 'blob.bin'))).toBe(true) + expect(new Uint8Array(await readFile(path))).toEqual(data) + if (process.platform !== 'win32') { + expect((await stat(path)).mode & 0o777).toBe(0o400) + } + }) + + it('accepts a zero-byte file', async () => { + const root = await makeRoot() + const ref = await saveFileVerbatim(root, { data: new Uint8Array(0), name: 'empty.txt' }) + expect(ref.bytes).toBe(0) + expect((await readFile(storedFilePath(root, ref))).byteLength).toBe(0) + }) + + it('stores sanitized Windows-reserved and multibyte names', async () => { + const root = await makeRoot() + const reserved = await saveFileVerbatim(root, { data: new Uint8Array(0), name: 'NUL.txt' }) + const multibyte = await saveFileVerbatim(root, { data: Uint8Array.of(1), name: '文'.repeat(100) }) + expect(reserved.name).toBe('_NUL.txt') + expect(Buffer.byteLength(multibyte.name)).toBeLessThanOrEqual(255) + await expect(readFile(storedFilePath(root, reserved))).resolves.toHaveLength(0) + await expect(readFile(storedFilePath(root, multibyte))).resolves.toEqual(Buffer.from([1])) + }) + + it('deduplicates identical bytes and stores distinct names beside one digest', async () => { + const root = await makeRoot() + const data = Uint8Array.from([7, 7, 7]) + const first = await saveFileVerbatim(root, { data, name: 'a.txt' }) + const again = await saveFileVerbatim(root, { data, name: 'a.txt' }) + expect(again).toEqual(first) + const renamed = await saveFileVerbatim(root, { data, name: 'b.txt' }) + expect(renamed.attachmentId).toBe(first.attachmentId) + expect((await stat(storedFilePath(root, first))).ino) + .toBe((await stat(storedFilePath(root, renamed))).ino) + const digestDir = join(root, 'files', sha256(data).slice(0, 2), sha256(data)) + expect((await readdir(digestDir)).sort()).toEqual(['a.txt', 'b.txt']) + }) + + it('refuses a stored object whose bytes no longer match the digest', async () => { + const root = await makeRoot() + const data = Uint8Array.from([1, 2, 3]) + const ref = await saveFileVerbatim(root, { data, name: 'c.txt' }) + const path = storedFilePath(root, ref) + await chmod(path, 0o600) + await writeFile(path, Uint8Array.from([9, 9, 9])) + await expect(saveFileVerbatim(root, { data, name: 'c.txt' })) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) + + it('rejects a conflicting display-name alias and wraps alias publication failures', async () => { + const root = await makeRoot() + const data = Uint8Array.of(1, 2, 3) + const ref = await saveFileVerbatim(root, { data, name: 'alias.bin' }) + const alias = storedFilePath(root, ref) + await unlink(alias) + await writeFile(alias, Uint8Array.of(9, 9, 9)) + await expect(saveFileVerbatim(root, { data, name: 'alias.bin' })) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + + await expect(publishImmutableAlias( + root, + join(root, 'missing-object'), + join(root, 'files', 'ff', 'missing', 'alias.bin'), + 'f'.repeat(64), + )).rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) + }) +}) + +describe('saveFileStreamVerbatim', () => { + it('stores ordered chunks without materializing one aggregate byte array', async () => { + const root = await makeRoot() + const ref = await saveFileStreamVerbatim(root, { + data: (async function* (): AsyncIterable { + yield Uint8Array.of(0, 1) + yield Uint8Array.of(2, 250) + yield Uint8Array.of(251, 252) + })(), + name: 'large.bin', + }) + const expected = Uint8Array.of(0, 1, 2, 250, 251, 252) + expect(ref).toEqual({ + attachmentId: AttachmentId(`sha256:${sha256(expected)}`), + name: 'large.bin', + bytes: expected.byteLength, + }) + expect(new Uint8Array(await readFile(storedFilePath(root, ref)))).toEqual(expected) + }) + + it('removes its staging file when cancellation interrupts the source', async () => { + const root = await makeRoot() + const abort = new AbortController() + const reason = new Error('upload cancelled') + await expect(saveFileStreamVerbatim(root, { + data: (async function* (): AsyncIterable { + yield Uint8Array.of(1, 2) + abort.abort(reason) + yield Uint8Array.of(3, 4) + })(), + signal: abort.signal, + name: 'cancelled.bin', + })).rejects.toBe(reason) + expect(await readdir(join(root, 'tmp'))).toEqual([]) + }) + + it('wraps source failures and removes the staging file', async () => { + const root = await makeRoot() + await expect(saveFileStreamVerbatim(root, { + data: (async function* (): AsyncIterable { + yield Uint8Array.of(1, 2) + throw new Error('source failed') + })(), + name: 'failed.bin', + })).rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) + expect(await readdir(join(root, 'tmp'))).toEqual([]) + }) +}) + +describe('readFileStreamVerbatim', () => { + it('returns exact bounded chunks and accepts an empty file', async () => { + const root = await makeRoot() + const data = Uint8Array.from({ length: (1 << 16) + 3 }, (_, index) => index % 251) + const ref = await saveFileVerbatim(root, { data, name: 'large.bin' }) + await expect(readStream(readFileStreamVerbatim(root, ref))).resolves.toEqual(data) + const empty = await saveFileVerbatim(root, { data: new Uint8Array(), name: 'empty.bin' }) + await expect(readStream(readFileStreamVerbatim(root, empty))).resolves.toEqual(new Uint8Array()) + }) + + it('rejects invalid, missing, and unreadable references with storage codes', async () => { + const root = await makeRoot() + const ref: FileAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + name: 'missing.bin', + bytes: 1, + } + await expect(readStream(readFileStreamVerbatim(root, { ...ref, name: '../escape' }))) + .rejects.toMatchObject({ code: 'INVALID_ATTACHMENT_REF' }) + await expect(readStream(readFileStreamVerbatim(root, ref))) + .rejects.toMatchObject({ code: 'ATTACHMENT_NOT_FOUND' }) + + const saved = await saveFileVerbatim(root, { data: Uint8Array.of(1), name: 'unreadable.bin' }) + const path = storedFilePath(root, saved) + await unlink(path) + await mkdir(path) + await expect(readStream(readFileStreamVerbatim(root, saved))) + .rejects.toMatchObject({ code: 'ATTACHMENT_READ_FAILED' }) + }) + + it('detects changed bytes and recorded lengths', async () => { + const root = await makeRoot() + const ref = await saveFileVerbatim(root, { data: Uint8Array.of(1, 2, 3), name: 'data.bin' }) + const path = storedFilePath(root, ref) + await chmod(path, 0o600) + await writeFile(path, Uint8Array.of(3, 2, 1)) + await expect(readStream(readFileStreamVerbatim(root, ref))) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + await writeFile(path, Uint8Array.of(1, 2, 3)) + await expect(readStream(readFileStreamVerbatim(root, { ...ref, bytes: 4 }))) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) + + it('preserves caller cancellation before and during a read', async () => { + const root = await makeRoot() + const ref = await saveFileVerbatim(root, { + data: Uint8Array.from({ length: 1 << 17 }, () => 7), + name: 'cancel.bin', + }) + const before = new AbortController() + const beforeReason = new Error('cancelled before read') + before.abort(beforeReason) + await expect(readStream(readFileStreamVerbatim(root, ref, before.signal))).rejects.toBe(beforeReason) + + const during = new AbortController() + const stream = readFileStreamVerbatim(root, ref, during.signal)[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toMatchObject({ done: false }) + const duringReason = new Error('cancelled during read') + during.abort(duringReason) + await expect(stream.next()).rejects.toBe(duringReason) + }) +}) + +describe('storedFilePath', () => { + it('rejects malformed digests and unsanitized names before deriving a path', () => { + const good: FileAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + name: 'ok.txt', + bytes: 1, + } + expect(() => storedFilePath('/root', { ...good, attachmentId: AttachmentId('sha256:short') })) + .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' }) as Error) + expect(() => storedFilePath('/root', { ...good, name: '../escape.txt' })) + .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' }) as Error) + expect(() => storedFilePath('/root', { ...good, name: 'nested/name.txt' })) + .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' }) as Error) + expect(storedFilePath('/root', good).endsWith(join('a'.repeat(64), 'ok.txt'))).toBe(true) + }) +}) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 4d9d9f350b..5711c6d35d 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -87,6 +87,21 @@ describe('local attachment service', () => { await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data)) const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 }) expect(request).not.toHaveProperty('access') + + const fileData = Uint8Array.of(0, 1, 2, 255) + const fileRef = await service.saveFile({ data: fileData, name: 'notes.bin' }) + const filePath = service.fileHostPath(fileRef) + expect(filePath).toContain(join('files', String(fileRef.attachmentId).slice(7, 9))) + await expect(readFile(filePath)).resolves.toEqual(Buffer.from(fileData)) + + const streamRef = await service.saveFileStream({ + data: (async function* (): AsyncIterable { yield fileData })(), + name: 'stream.bin', + }) + await expect(readFile(service.fileHostPath(streamRef))).resolves.toEqual(Buffer.from(fileData)) + const streamed: Uint8Array[] = [] + for await (const chunk of service.readFileStream(streamRef)) streamed.push(chunk) + expect(Buffer.concat(streamed)).toEqual(Buffer.from(fileData)) } finally { await rm(dshHome, { recursive: true, force: true }) } diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 3ff58eb2da..82c2b9f3ba 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { constants } from 'node:fs' -import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, parse, resolve } from 'node:path' import { mkdtemp, rm } from 'node:fs/promises' @@ -8,7 +8,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import sharp from 'sharp' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import type { NormalizationPolicy } from '../src/normalization.ts' -import { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile } from '../src/store.ts' +import { + commitPreparedImageFile, + prepareImageFile, + publishImmutableObject, + readImageFile, + saveImageFile, +} from '../src/store.ts' const fsControl = vi.hoisted(() => ({ readSignals: [] as AbortSignal[], @@ -89,12 +95,13 @@ describe('local attachment store', () => { // Later directory creation can then stop at that process-proven boundary. expect(fsControl.syncedDirectories).toEqual([ ...parentChainToRoot(base), - // bucket chain: every parent entry between the bucket and the boundary. - objects, + // Staging precedes publication because the streamed digest selects the + // target bucket only after every byte has been written. storageRoot, join(storageRoot, '..'), base, - // staging chain re-walks the shared ancestors after creating tmp. + // bucket chain: every parent entry between the bucket and the boundary. + objects, storageRoot, join(storageRoot, '..'), base, @@ -141,6 +148,14 @@ describe('local attachment store', () => { await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) + it('rejects publication when the supplied digest does not match the staged bytes', async () => { + const storageRoot = await root() + const target = join(storageRoot, 'objects', '00', 'mismatch') + await expect(publishImmutableObject(storageRoot, target, Uint8Array.of(1), '0'.repeat(64))) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + expect(await readdir(join(storageRoot, 'tmp'))).toEqual([]) + }) + it.skipIf(process.platform !== 'win32')('publishes a new object on Windows', async () => { const storageRoot = await root() diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 83ba7dbc72..d92fc02572 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -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: 9561ab4265eb0404f6f60c93c34dab73eed5ffe1 -README.zh.md: 31fcb15c04fadb44c44342e3d1d721d22645732f +README.md: a812182aeff6d09506a1ea2d4fa8d9a44a175936 +README.zh.md: c487f8204c86d8f0bbdfd85280e8fbab6ea14dec diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 9561ab4265..a812182aef 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -1,5 +1,5 @@ --- -description: "Durable image attachments for users and maintainers attaching, reusing, or debugging images in prompts and commands." +description: "Durable image and file attachments for users and maintainers attaching, reusing, or debugging uploads in prompts and commands." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -You can attach images to prompts and commands, and the harness keeps provider-independent normalized versions durably: each source image is admitted and normalized before your message is processed, reappears in conversation history, and is projected to the selected model route in later turns of the same session. The shipped `dsh` composition enables this with no setup. Attached images survive restarts, while browser paths, provider URLs, local storage paths, and base64 never enter durable session events. Only raster formats (PNG, JPEG, WebP, GIF) are accepted, and unsent composer drafts stay in the browser until you submit. Stored images are never deleted automatically, and non-image files, audio, and video are not supported yet. +You can attach images and generic files to prompts, and the harness keeps them durably: each source image is admitted and normalized before your message is processed, while any other file is stored byte-for-byte with no format or size limits, and both reappear in conversation history across restarts of the same session. The shipped `dsh` composition enables this with no setup. Browser paths, provider URLs, local storage paths, and base64 never enter durable session events. Images accept raster formats (PNG, JPEG, WebP, GIF) under deployment limits; files accept anything, and the model reads a stored file on demand from its saved read-only path instead of receiving its bytes. Stored objects are never deleted automatically, and audio and video have no dedicated handling yet. ## Table of Contents @@ -35,9 +35,13 @@ Attach one or more images to a user prompt in the client UI. Each source is chec - name: '@deepseek-ai/dsh-attachment-local' ``` -### Pass images to commands +### Attach any other file to a prompt -Commands that accept image input receive attached images the same way. If a command does not accept images, the harness refuses with an error message instead of silently dropping them. +Any non-image file attaches to a prompt as a generic file: the exact bytes are saved read-only under the harness home, the message records the file name, byte size, and content digest, and the model receives one line naming the saved path so it can read the content with its file tools only when needed. There is no file-type whitelist and no size limit; what you attach is stored verbatim. + +### Pass attachments to commands + +Commands declaring attachment input receive images and generic files in selection order. Commands that do not accept attachments return an error and retain the composer's draft and cards. ### Reuse images across the session @@ -63,11 +67,12 @@ This section explains the design decisions behind the seam and the service opera - **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. - **Verify on read.** Reads check bytes and metadata against the logged reference before returning them, and request projections fully decode cached bytes, so a missing, corrupted, or swapped object fails closed. - **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions with explicit pixel and byte budgets, while execution filesystems may map the immutable host object to a model-readable process path. -- **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers route on `code`, never on the prototype chain. +- **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers use `isAttachmentError` and route on `code`, never on the prototype chain. +- **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route. ### 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. `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 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 @@ -75,7 +80,7 @@ The service family runs one admission-and-storage flow: every entry point enforc |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: abstract `AttachmentStore` service and re-exports | | [`src/types.ts`](src/types.ts) | Durable vocabulary: references, limits, upload and store payloads | -| [`src/admission.ts`](src/admission.ts) | Browser prompt admission: canonical-base64 enforcement, `saveImages` delegation, and durable prompt-part projection | +| [`src/admission.ts`](src/admission.ts) | Canonical-base64 enforcement and store delegation for encoded image and file uploads | | [`src/error.ts`](src/error.ts) | `AttachmentError` class and the `isImageAdmissionError` runtime subset | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` branded opaque identifier | | — | No runtime invariant companion is published; this stateless seam owns types while implementations enforce immutable-store checks. | @@ -98,7 +103,7 @@ For the full service contract and payload types, read the subsystem reference; f ## Model Experience -Indirectly, through the provider adapter, which resolves each durable reference into an exact request version and sends its stable attachment id and actual dimensions beside the image. When the execution filesystem maps the stored object, the descriptor also includes a read-only process path and a matching extension for a writable copy. +Indirectly, through the provider adapter, which resolves each durable image reference into an exact request version and sends its stable attachment id and actual dimensions beside the image. When the execution filesystem maps the stored object, the descriptor also includes a read-only process path and a matching extension for a writable copy. A generic file never reaches the provider as bytes: every route receives one deterministic handle line naming the file, its byte size, its digest prefix, and the saved read-only path to read with file tools. #### KV Cache effect @@ -111,8 +116,8 @@ Adding an image changes the provider request and therefore invalidates the affec These limits describe what image attachments can and cannot do; they are current package constraints, not a task backlog. -- **Raster images only** — PNG, JPEG, WebP, and GIF are accepted; generic files, audio, and video are not supported yet. -- **Images are never deleted** — stored images are retained indefinitely; nothing removes them automatically. +- **Raster image limits apply to images only** — PNG, JPEG, WebP, and GIF are accepted as images under deployment limits; every other file is stored verbatim with no type or size limit, and audio and video have no dedicated handling yet. +- **Attachments are never deleted** — stored images and files are retained indefinitely; nothing removes them automatically. - **Unsent drafts are not saved** — a composer draft stays in the browser until you submit the message. @@ -127,8 +132,8 @@ This Dev Note is working context for maintainers: undecided directions and open Resumed and forked sessions may share immutable objects, so any retention policy needs a reference model that accounts for session lineage before objects can be collected. No decision is recorded yet; the local backend currently retains everything. -#### Future: non-image attachments and assistant-side output +#### Future: audio, video, and assistant-side output -Generic files, audio, and video would need separate lifecycle and provider contracts, and the role-neutral `ImageBlock` leaves assistant-side image output as forward compatibility — current production adapters declare text-only output, so only user content carries images. Both directions are undecided. +Audio and video would need dedicated lifecycle and provider contracts beyond the verbatim file path, and the role-neutral `ImageBlock` leaves assistant-side image output as forward compatibility — current production adapters declare text-only output, so only user content carries images. Both directions are undecided. diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 31fcb15c04..c487f8204c 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -1,5 +1,5 @@ --- -description: "持久图片附件,供用户与维护者在提示词与命令中附加、复用或排查图片。" +description: "持久图片与文件附件,供用户与维护者在提示词与命令中附加、复用或排查上传内容。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -你可以把图片附加到提示词和命令中,harness 会持久保存提供方无关的规范化版本:每张源图都会在你的消息被处理前准入并规范化,重新出现在对话历史中,并在同一会话的后续轮次投影到所选模型路由。随附的 `dsh` 组合无需任何配置即可支持这一点。已附加的图片在重启后依然存在,而浏览器路径、提供方 URL、本地存储路径与 base64 绝不会进入持久会话事件。只接受光栅格式(PNG、JPEG、WebP、GIF),未发送的输入区草稿在提交前仍留在浏览器中。已存储的图片永远不会被自动删除,通用文件、音频和视频暂不支持。 +你可以把图片和通用文件附加到提示词中,harness 会持久保存它们:每张源图都会在你的消息被处理前准入并规范化,而其他任何文件都按字节原样保存、不设格式与大小限制,两者都会在同一会话重启后重新出现在对话历史中。随附的 `dsh` 组合无需任何配置即可支持这一点。浏览器路径、提供方 URL、本地存储路径与 base64 绝不会进入持久会话事件。图片接受部署限额内的光栅格式(PNG、JPEG、WebP、GIF);文件接受任何内容,模型不接收文件字节,而是在需要时从保存的只读路径按需读取。已存储对象永远不会被自动删除,音频和视频暂无专门处理。 ## 目录 @@ -35,9 +35,13 @@ kind: "package-reference" - name: '@deepseek-ai/dsh-attachment-local' ``` -### 把图片传给命令 +### 在提示词中附加任意其他文件 -接受图片输入的命令以相同方式接收附加图片。如果某个命令不接受图片,harness 会以错误消息拒绝,而不是静默丢弃。 +任何非图片文件都以通用文件的形式附加到提示词:确切字节被只读保存在 harness 主目录下,消息记录文件名、字节数与内容摘要,模型收到一行指出保存路径的文本,只在需要时用文件工具读取内容。没有文件类型白名单,也没有大小上限;你附加什么就原样存什么。 + +### 把附件传给命令 + +声明接受附件的命令会按选择顺序接收图片与通用文件。不接受附件的命令会返回错误,并保留 composer 的草稿与附件卡。 ### 在整个会话中复用图片 @@ -63,11 +67,12 @@ kind: "package-reference" - **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。 - **读取时校验。** 读取在返回前把字节和元数据与记录的引用比对,请求投影还会完整解码缓存字节,因此缺失、损坏或被替换的对象都会失败关闭。 - **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器以显式像素与字节预算把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 -- **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方按 `code` 路由,绝不依赖原型链。 +- **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。 +- **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装把它对每条路由都投影成确定性 handle 文本。 ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。`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)。 ### 源码地图 @@ -75,7 +80,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `AttachmentStore` 服务与再导出 | | [`src/types.ts`](src/types.ts) | 持久词汇:引用、限额、上传与存储载荷 | -| [`src/admission.ts`](src/admission.ts) | 浏览器 prompt 准入:强制规范 base64、委托 `saveImages` 并投影持久 prompt part | +| [`src/admission.ts`](src/admission.ts) | 对编码图片和文件上传强制执行规范 base64 并委托存储 | | [`src/error.ts`](src/error.ts) | `AttachmentError` 类与 `isImageAdmissionError` 运行时子集 | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` 带类型标记的不透明标识符 | | — | 不发布运行时不变式伴生入口;实现负责强制不可变存储检查。 | @@ -98,7 +103,7 @@ kind: "package-reference" ## 模型体验 -该包通过提供方适配器间接影响模型;适配器会把每个持久引用解析为确切请求版本,并在图片旁发送稳定附件 id 与实际尺寸。执行文件系统可以映射已存对象时,描述符还会包含只读进程路径,以及可写副本使用的匹配扩展名。 +该包通过提供方适配器间接影响模型;适配器会把每个持久图片引用解析为确切请求版本,并在图片旁发送稳定附件 id 与实际尺寸。执行文件系统可以映射已存对象时,描述符还会包含只读进程路径,以及可写副本使用的匹配扩展名。通用文件的字节永远不会到达提供方:每条路由都收到一行确定性 handle 文本,指出文件名、字节数、摘要前缀,以及供文件工具读取的只读保存路径。 #### KV Cache 影响 @@ -109,10 +114,10 @@ kind: "package-reference" -这些限制描述了图片附件能做什么、不能做什么;它们是当前包约束,而非任务积压。 +这些限制描述了附件能做什么、不能做什么;它们是当前包约束,而非任务积压。 -- **仅支持光栅图片**——接受 PNG、JPEG、WebP 与 GIF;通用文件、音频和视频暂不支持。 -- **图片永远不会被删除**——已存储的图片无限期保留;没有任何机制自动移除它们。 +- **光栅图片限制只作用于图片**——图片接受部署限额内的 PNG、JPEG、WebP 与 GIF;其他任何文件都原样保存、不设类型与大小限制,音频和视频暂无专门处理。 +- **附件永远不会被删除**——已存储的图片与文件无限期保留;没有任何机制自动移除它们。 - **未发送的草稿不会保存**——输入区草稿在提交消息前一直留在浏览器中。 @@ -127,8 +132,8 @@ kind: "package-reference" 恢复和 fork 后的会话可能共享不可变对象,因此任何保留策略都需要一个能考虑会话血缘的引用模型,之后才能回收对象。目前尚未记录任何决定;本地后端当前保留一切。 -#### 未来:非图片附件与助手侧输出 +#### 未来:音频、视频与助手侧输出 -通用文件、音频与视频需要单独的生命周期与提供方契约;角色无关的 `ImageBlock` 也把助手侧图片输出留作前瞻兼容——当前生产适配器声明只输出文本,因此只有用户内容携带图片。两个方向都尚未决定。 +音频与视频需要原样文件路径之外的专门生命周期与提供方契约;角色无关的 `ImageBlock` 也把助手侧图片输出留作前瞻兼容——当前生产适配器声明只输出文本,因此只有用户内容携带图片。两个方向都尚未决定。 diff --git a/packages/attachment/attachment/src/admission.ts b/packages/attachment/attachment/src/admission.ts index 155b3644d8..8a5b5ab41b 100644 --- a/packages/attachment/attachment/src/admission.ts +++ b/packages/attachment/attachment/src/admission.ts @@ -4,22 +4,29 @@ import { Buffer } from 'node:buffer' import { AttachmentError } from './error.ts' import type { AttachmentStore } from './index.ts' import type { - AdmittedPromptContentPart, + EncodedFileAttachment, EncodedImageAttachment, + FileAttachmentRef, ImageAttachmentRef, - PromptContentPart, SaveImageAttachment, } from './types.ts' /** Decode one upload payload while rejecting non-canonical base64 forms. */ -function decodeBase64(data: string): Uint8Array { +function decodeCanonicalBase64(data: string, empty: 'reject' | 'accept', code: 'INVALID_IMAGE_BASE64' | 'INVALID_FILE_BASE64'): Uint8Array { const decoded = Buffer.from(data, 'base64') - if (data.length === 0 || decoded.toString('base64') !== data) { - throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') + if ((data.length === 0 && empty === 'reject') || decoded.toString('base64') !== data) { + throw new AttachmentError( + code === 'INVALID_IMAGE_BASE64' ? 'Image upload is not canonical base64.' : 'File upload is not canonical base64.', + code, + ) } return new Uint8Array(decoded) } +function decodeBase64(data: string): Uint8Array { + return decodeCanonicalBase64(data, 'reject', 'INVALID_IMAGE_BASE64') +} + /** Store input for one decoded upload. */ function saveInput(image: EncodedImageAttachment): SaveImageAttachment { return { @@ -47,24 +54,21 @@ export async function admitEncodedImages( } /** - * Admit one browser prompt and replace each uploaded image with its durable reference. - * Text-only prompts do not access the attachment store. - * @param attachments - the deployment attachment store owning batch policy. - * @param content - browser prompt parts in message order. - * @returns admitted prompt parts in the same order as `content`. - * @throws AttachmentError when the image batch is refused. + * Admit one wire file upload: enforce canonical base64 (an empty file is a + * valid zero-byte payload), then delegate verbatim commit to + * {@link AttachmentStore.saveFile}. The shared entry for every RPC endpoint + * accepting browser file uploads. + * @param attachments - the deployment attachment store. + * @param file - base64-encoded upload and optional display name. + * @returns the durable content-addressed file reference. + * @throws AttachmentError on a non-canonical payload or a storage failure. */ -export async function admitPromptContent( +export async function admitEncodedFile( attachments: AttachmentStore, - content: readonly PromptContentPart[], -): Promise { - if (content.every(part => part.type === 'text')) { - return content.map(part => ({ type: 'text', text: part.text })) - } - const refs = await admitEncodedImages(attachments, content.filter(part => part.type === 'image')) - let next = 0 - return content.map(part => part.type === 'text' - ? { type: 'text', text: part.text } - // admitEncodedImages returns one reference per image part in order. - : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) + file: EncodedFileAttachment, +): Promise { + return attachments.saveFile({ + data: decodeCanonicalBase64(file.data, 'accept', 'INVALID_FILE_BASE64'), + ...file.name === undefined ? {} : { name: file.name }, + }) } diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts index c19229872b..0aed793f35 100644 --- a/packages/attachment/attachment/src/error.ts +++ b/packages/attachment/attachment/src/error.ts @@ -15,18 +15,24 @@ const IMAGE_ADMISSION_ERROR_CODES = [ /** Caller-correctable attachment failure codes raised while admitting image input. */ export type ImageAdmissionErrorCode = typeof IMAGE_ADMISSION_ERROR_CODES[number] +const ATTACHMENT_ERROR_CODES = [ + ...IMAGE_ADMISSION_ERROR_CODES, + 'INVALID_FILE_BASE64', + 'INVALID_ATTACHMENT_REF', + 'ATTACHMENT_CORRUPT', + 'ATTACHMENT_WRITE_FAILED', + 'ATTACHMENT_NOT_FOUND', + 'ATTACHMENT_READ_FAILED', + 'ATTACHMENT_PROJECTION_UNSUPPORTED', + 'ATTACHMENT_FILES_UNSUPPORTED', +] as const + /** Stable attachment failure codes used for protocol error routing. */ -export type AttachmentErrorCode = - | ImageAdmissionErrorCode - | 'INVALID_ATTACHMENT_REF' - | 'ATTACHMENT_CORRUPT' - | 'ATTACHMENT_WRITE_FAILED' - | 'ATTACHMENT_NOT_FOUND' - | 'ATTACHMENT_READ_FAILED' - | 'ATTACHMENT_PROJECTION_UNSUPPORTED' +export type AttachmentErrorCode = typeof ATTACHMENT_ERROR_CODES[number] /** Runtime membership for structurally compatible errors crossing package boundaries. */ const IMAGE_ADMISSION_ERROR_CODE_SET: ReadonlySet = new Set(IMAGE_ADMISSION_ERROR_CODES) +const ATTACHMENT_ERROR_CODE_SET: ReadonlySet = new Set(ATTACHMENT_ERROR_CODES) /** * Stable failures suitable for host RPC error mapping. @@ -53,6 +59,18 @@ export class AttachmentError extends Error { } } +/** + * Identify attachment failures by their stable code across duplicate package installations. + * @param error - failure raised while validating, persisting, or reading an attachment. + * @returns whether the failure carries a recognized attachment error code. + */ +export function isAttachmentError(error: unknown): error is AttachmentError { + return error instanceof Error + && 'code' in error + && typeof error.code === 'string' + && ATTACHMENT_ERROR_CODE_SET.has(error.code) +} + /** * Distinguish caller-correctable image admission failures from storage faults. * @param error - failure raised while validating or persisting an image batch. diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 9dcddeae65..5df42a8bda 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -1,31 +1,43 @@ /** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */ import { Context, Service } from '@deepseek-ai/cordis' -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, ImageRequestPolicy, RequestImageAttachment, + SaveFileAttachment, + SaveFileStreamAttachment, SaveImageAttachment, StoredImageAttachment, } from './types.ts' export { AttachmentId, ImageVariantId } from './brand.ts' -export { AttachmentError, isImageAdmissionError } from './error.ts' +export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' -export { admitEncodedImages, admitPromptContent } from './admission.ts' +export { admitEncodedFile, admitEncodedImages } from './admission.ts' export { requestImageDimensions } from './request-projection.ts' export type { AttachmentId as AttachmentIdType, AdmittedPromptContentPart, + AttachmentAdmissionPart, + EncodedFileAttachment, EncodedImageAttachment, + FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, ImageMediaType, PromptContentPart, RequestImageAttachment, + SaveFileAttachment, + SaveFileStreamAttachment, SaveImageAttachment, StoredImageAttachment, } from './types.ts' @@ -91,6 +103,49 @@ export abstract class AttachmentStore extends Service { return refs } + /** + * Admit one Host prompt and replace each uploaded image with its durable reference. + * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. + * @param content - prompt parts in message order after file receipt resolution. + * @returns admitted prompt parts in the same order as `content`. + * @throws AttachmentError when the image batch is refused. + */ + async admitPromptContent( + content: readonly AttachmentAdmissionPart[], + ): Promise { + if (content.every(part => part.type !== 'image')) { + return content.map(part => part.type === 'text' + ? { type: 'text', text: part.text } + : { type: 'file', attachment: part.attachment }) + } + const refs = await admitEncodedImages(this, content.filter(part => part.type === 'image')) + let next = 0 + return content.map((part) => { + if (part.type === 'text') return { type: 'text', text: part.text } + if (part.type === 'file') return { type: 'file', attachment: part.attachment } + return { type: 'image', attachment: refs[next++] as ImageAttachmentRef } + }) + } + + /** + * 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 { + 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 @@ -121,6 +176,68 @@ export abstract class AttachmentStore extends Service { return undefined } + /** + * Durably commit one file byte-for-byte before its owning session event is + * appended. Files carry no admission limits: any byte content and length is + * accepted, and the stored object is the exact submitted bytes. Backends + * without verbatim file storage keep this default rejection. + * @param input - exact bytes and optional display name. + * @returns the durable content-addressed file reference. + */ + saveFile(input: SaveFileAttachment): Promise { + void input + return Promise.reject(new AttachmentError( + 'The mounted attachment provider cannot store verbatim files.', + 'ATTACHMENT_FILES_UNSUPPORTED', + )) + } + + /** + * Durably commit one file byte-for-byte from bounded chunks. Providers must + * apply backpressure and must not collect the complete file in memory. + * Backends without streamed verbatim storage keep this default rejection. + * @param input - ordered exact bytes, optional cancellation, and display name. + * @returns the durable content-addressed file reference. + */ + saveFileStream(input: SaveFileStreamAttachment): Promise { + void input + return Promise.reject(new AttachmentError( + 'The mounted attachment provider cannot stream verbatim files.', + 'ATTACHMENT_FILES_UNSUPPORTED', + )) + } + + /** + * Read and verify one verbatim stored file as bounded chunks. Providers must + * not collect the complete file in memory. Backends without verbatim file + * reads keep this default rejection. + * @param ref - durable reference from the session log. + * @param signal - optional cancellation for backend reads and verification work. + * @returns exact file bytes in order; integrity failures reject the iteration. + */ + async *readFileStream( + ref: FileAttachmentRef, + signal?: AbortSignal, + ): AsyncIterable { + signal?.throwIfAborted() + void ref + await Promise.reject(new AttachmentError( + 'The mounted attachment provider cannot read verbatim files.', + 'ATTACHMENT_FILES_UNSUPPORTED', + )) + } + + /** + * Locate the verbatim stored file object in the harness host filesystem. + * @param ref - durable file reference. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. + * @throws an AttachmentError when the durable reference is invalid. + */ + fileHostPath(ref: FileAttachmentRef): string | undefined { + void ref + return undefined + } + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 7a55c6a68f..a69b2363dc 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -31,6 +31,45 @@ export interface ImageAttachmentRef { } } +/** + * Durable, serializable reference to one verbatim stored file. Files are + * stored byte-for-byte with no normalization; `attachmentId` is the sha256 + * digest of exactly those bytes. + */ +export interface FileAttachmentRef { + /** Opaque content-addressed storage identifier; never a filesystem path or bearer URL. */ + attachmentId: AttachmentId + /** Sanitized display filename, also the stored object's leaf name. */ + name: string + /** Exact byte length. */ + bytes: number +} + +/** Base64-encoded file upload accompanying one wire request. */ +export interface EncodedFileAttachment { + /** Canonical base64 encoding of the file bytes. */ + data: string + /** Optional display name; it is never interpreted as a path. */ + name?: string +} + +/** Request to durably commit one file verbatim. */ +export interface SaveFileAttachment { + data: Uint8Array + /** Optional browser/provider display name; it is never interpreted as a path. */ + name?: string +} + +/** Request to durably commit one file from bounded byte chunks. */ +export interface SaveFileStreamAttachment { + /** Exact file bytes in order; providers must not retain the complete sequence in memory. */ + data: AsyncIterable + /** Optional cancellation for source reads and storage writes. */ + signal?: AbortSignal + /** Optional browser/provider display name; it is never interpreted as a path. */ + name?: string +} + /** Deployment-resolved limits used by upload admission and request buffering. */ export interface ImageAttachmentLimits { maxImageBytes: number @@ -55,7 +94,7 @@ export interface EncodedImageAttachment { /** * Browser-submitted prompt content accepted by Host prompt endpoints; the * accepting Host promotes image parts to durable references through - * `admitPromptContent` before any message is created, so a wire caller can + * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can * never cite an attachment it did not upload. */ export type PromptContentPart = @@ -67,10 +106,16 @@ export type PromptContentPart = readonly name?: string } -/** Host-admitted prompt content with each uploaded image replaced by its durable reference. */ +/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +export type AttachmentAdmissionPart = + | PromptContentPart + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + +/** Host-admitted prompt content with every attachment represented by its durable reference. */ export type AdmittedPromptContentPart = | { readonly type: 'text'; readonly text: string } | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } /** Request to validate and durably commit one image. */ export interface SaveImageAttachment { diff --git a/packages/attachment/attachment/tests/admission.spec.ts b/packages/attachment/attachment/tests/admission.spec.ts index ba24d8fa20..71c56c0fed 100644 --- a/packages/attachment/attachment/tests/admission.spec.ts +++ b/packages/attachment/attachment/tests/admission.spec.ts @@ -1,13 +1,19 @@ import { describe, expect, it, vi } from 'vitest' -import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import { admitEncodedImages, admitPromptContent } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment/types' +import AttachmentStore, { admitEncodedFile, admitEncodedImages } from '@deepseek-ai/dsh-attachment' +import type { + FileAttachmentRef, ImageAttachmentRef, SaveImageAttachment, +} from '@deepseek-ai/dsh-attachment/types' const PNG = 'AAAA' // canonical base64, 3 bytes +const FILE_REF: FileAttachmentRef = { + attachmentId: 'file-1' as FileAttachmentRef['attachmentId'], + name: 'notes.md', + bytes: 12, +} /** Delegation double: records the exact saveImages batch and answers ordered refs. */ function storeOf() { - const store = { + const mocks = { saveImages: vi.fn((inputs: readonly SaveImageAttachment[]) => Promise.resolve(inputs.map((input, index): ImageAttachmentRef => ({ attachmentId: `att-${index + 1}` as ImageAttachmentRef['attachmentId'], mediaType: input.mediaType, @@ -17,7 +23,8 @@ function storeOf() { ...input.name === undefined ? {} : { name: input.name }, })))), } - return { store: store as unknown as AttachmentStore, mocks: store } + const store = Object.setPrototypeOf(mocks, AttachmentStore.prototype) as AttachmentStore + return { store, mocks } } describe('admitEncodedImages', () => { @@ -65,22 +72,68 @@ describe('admitEncodedImages', () => { }) }) -describe('admitPromptContent', () => { - it('converts text-only prompts without touching the attachment store', async () => { - const store = { saveImages: () => { throw new Error('text-only prompts must not reach the store') } } - await expect(admitPromptContent(store as unknown as AttachmentStore, [ - { type: 'text', text: 'hello' }, - ])).resolves.toEqual([{ type: 'text', text: 'hello' }]) +describe('admitEncodedFile', () => { + /** Delegation double: records the exact saveFile input and answers a fixed ref. */ + function fileStoreOf() { + const store = { + saveFile: vi.fn((input: { data: Uint8Array; name?: string }) => Promise.resolve({ + attachmentId: 'file-1' as never, + name: input.name ?? 'file', + bytes: input.data.byteLength, + })), + } + return { store: store as unknown as AttachmentStore, mocks: store } + } + + it('decodes canonical base64 and delegates verbatim commit to saveFile', async () => { + const { store, mocks } = fileStoreOf() + const ref = await admitEncodedFile(store, { data: 'AAAA', name: 'blob.bin' }) + expect(mocks.saveFile).toHaveBeenCalledTimes(1) + const input = mocks.saveFile.mock.calls[0]?.[0] as { data: Uint8Array; name?: string } + expect([input.name, input.data.byteLength]).toEqual(['blob.bin', 3]) + expect(ref.bytes).toBe(3) }) - it('replaces image parts with admitted references in part order', async () => { + it('accepts an empty payload as a zero-byte file and omits an absent name', async () => { + const { store, mocks } = fileStoreOf() + const ref = await admitEncodedFile(store, { data: '' }) + const input = mocks.saveFile.mock.calls[0]?.[0] as object + expect('name' in input).toBe(false) + expect(ref.bytes).toBe(0) + }) + + it('rejects non-canonical base64 without touching the store', async () => { + const { store, mocks } = fileStoreOf() + await expect(admitEncodedFile(store, { data: 'not base64!!' })) + .rejects.toMatchObject({ code: 'INVALID_FILE_BASE64' }) + expect(mocks.saveFile).not.toHaveBeenCalled() + }) +}) + +describe('AttachmentStore.admitPromptContent', () => { + it('passes through text and durable files without touching image storage', async () => { + const store = Object.setPrototypeOf({ + saveImages: () => { throw new Error('prompts without image uploads must not reach the store') }, + }, AttachmentStore.prototype) as AttachmentStore + await expect(store.admitPromptContent([ + { type: 'text', text: 'hello' }, + { type: 'file', attachment: FILE_REF }, + ])).resolves.toEqual([ + { type: 'text', text: 'hello' }, + { type: 'file', attachment: FILE_REF }, + ]) + }) + + it('replaces images and passes through files in part order', async () => { const { store } = storeOf() - await expect(admitPromptContent(store, [ + await expect(store.admitPromptContent([ { type: 'image', mediaType: 'image/png', data: 'AQ==' }, + { type: 'file', attachment: FILE_REF }, { type: 'text', text: 'between' }, { type: 'image', mediaType: 'image/png', data: 'Ag==' }, ])).resolves.toEqual([ { type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'file', attachment: FILE_REF }, { type: 'text', text: 'between' }, { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, ]) diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 8675d45404..923c1a4360 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -4,11 +4,13 @@ import AttachmentStore, { AttachmentError, AttachmentId, ImageVariantId, + isAttachmentError, isImageAdmissionError, type ImageAttachmentRef, type ImageMediaType, type ImageRequestPolicy, type RequestImageAttachment, + type SaveFileAttachment, type SaveImageAttachment, type StoredImageAttachment, } from '../src/index.ts' @@ -88,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` } } @@ -147,10 +162,47 @@ describe('AttachmentStore.readImageRequest', () => { expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) }) - it('exposes no provider-owned host path by default', async () => { + it('rejects generic-file storage and exposes no provider-owned host path by default', async () => { const store = new RecordingStore(new Context()) const ref = await store.saveImage(image(1)) expect(store.imageHostPath(ref)).toBeUndefined() + await expect(store.saveFile({ data: Uint8Array.of(1), name: 'notes.txt' })) + .rejects.toMatchObject({ code: 'ATTACHMENT_FILES_UNSUPPORTED' }) + await expect(store.saveFileStream({ + data: (async function* (): AsyncIterable { yield Uint8Array.of(1) })(), + name: 'notes.txt', + })).rejects.toMatchObject({ code: 'ATTACHMENT_FILES_UNSUPPORTED' }) + const fileRef = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + name: 'notes.txt', + bytes: 1, + } + expect(store.fileHostPath(fileRef)).toBeUndefined() + const read = async (signal?: AbortSignal): Promise => { + for await (const chunk of store.readFileStream(fileRef, signal)) { + void chunk + throw new Error('unsupported store yielded a chunk') + } + } + await expect(read()).rejects.toMatchObject({ code: 'ATTACHMENT_FILES_UNSUPPORTED' }) + const controller = new AbortController() + const reason = new Error('cancel unsupported file read') + controller.abort(reason) + await expect(read(controller.signal)).rejects.toBe(reason) + }) +}) + +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) }) }) @@ -165,3 +217,14 @@ describe('isImageAdmissionError', () => { expect(isImageAdmissionError(new Error('unknown failure'))).toBe(false) }) }) + +describe('isAttachmentError', () => { + it('recognizes attachment failures from another package installation by code', () => { + expect(isAttachmentError(new AttachmentError('bad base64', 'INVALID_FILE_BASE64'))).toBe(true) + expect(isAttachmentError(Object.assign(new Error('foreign storage error'), { + code: 'ATTACHMENT_WRITE_FAILED', + }))).toBe(true) + expect(isAttachmentError(Object.assign(new Error('other failure'), { code: 'OTHER' }))).toBe(false) + expect(isAttachmentError({ code: 'ATTACHMENT_WRITE_FAILED' })).toBe(false) + }) +}) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 2c9bd25990..ee0217066d 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -168,6 +168,11 @@ # its literals, for example: ['app.internal', ...ctx.webRuntime.trustedHosts]. trustedHosts: !!js ctx.webRuntime.trustedHosts + # Raw Blob and ReadableStream uploads run independently of Connection's + # RPC and generation service. + - id: file-upload + name: '@deepseek-ai/dsh-client-file-upload' + - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 46e1ebe45c..07bf341b4e 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 3f826371d5..e6c2329963 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -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/README.md -README.md: 2358d94f6db0455aaf8fce094a616a273d4b3bab -README.zh.md: 4fbeacf33dc609102a78418c3679a43f06914bdf +README.md: 27fa0abba847d99dd5553a36e25077e4fac3b56c +README.zh.md: 51a2d40a1ed2ea9a7bfe2ffe9ce96d23a4d27e73 diff --git a/packages/client/README.md b/packages/client/README.md index 2358d94f6d..27fa0abba8 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -29,6 +29,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`web/`](web/README.md) | Boots the browser shell | — | | [`modules/`](modules/README.md) | Loads browser-side client modules | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery | `ctx.connection` | +| [`file-upload/`](file-upload/README.md) | Sends raw Blob and byte-stream request bodies outside the page thread | `ctx.fileUpload` | | [`store/`](store/README.md) | Provides React-free observable and snapshot-store primitives | — | | [`hmr/`](hmr/README.md) | Refreshes client plugins during development | — | | [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries | `ctx.locale` | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 4fbeacf33d..51a2d40a1e 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -29,6 +29,7 @@ kind: "package-group" | [`web/`](web/README.zh.md) | 启动浏览器外壳 | — | | [`modules/`](modules/README.zh.md) | 加载浏览器侧客户端模块 | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.zh.md) | 维护浏览器与宿主之间的 RPC 通信与事件投递 | `ctx.connection` | +| [`file-upload/`](file-upload/README.zh.md) | 在页面线程之外发送原始 Blob 与字节流请求体 | `ctx.fileUpload` | | [`store/`](store/README.zh.md) | 提供不依赖 React 的 observable 与 snapshot-store 原语 | — | | [`hmr/`](hmr/README.zh.md) | 在开发期间刷新客户端插件 | — | | [`locale/`](locale/README.zh.md) | 提供本地化偏好与消息词典 | `ctx.locale` | diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 91e07fd366..1a246f67d2 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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/connection/README.md -README.md: 9c00e3b119ee63d056a53e422a14dbe2b8426428 -README.zh.md: d75e7ce58b68fd39d3753fa4b6ade35ada741e85 +README.md: 63e17c3b39e4e72aea82fa6541c646706c929d84 +README.zh.md: d84205df365d237ebbc8f0afb40617d99bb072ad diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 9c00e3b119..63e17c3b39 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, a generic RPC carrier, the active generation and its Host facts, observable recovery state, an immediate reconnect command, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` applies its retry policy. +The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, generic RPC, the active generation and its Host facts, observable recovery state, an immediate reconnect command, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` applies its retry policy. ## Table of Contents @@ -25,7 +25,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con ## Use this package -The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD` registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). ----- @@ -58,7 +58,7 @@ None; this package neither assembles nor sends a provider request. -- **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits. +- **Buffered `/api` routes retain each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) bounds ordinary image and RPC envelopes. Opt-in streaming routes receive backpressured chunks and bypass the aggregate cap; route implementations own persistence, cancellation, and any storage quota. - **The browser cookie is not marked `Secure`** — loopback HTTP is the shipped transport, so exposing the same authority over plaintext networking can expose the bearer cookie in transit. - **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record and restarting `dsh` revokes every session. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index d75e7ce58b..d84205df36 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC carrier、当前 generation 及其 Host 信息、可观察的恢复状态、立即重连命令,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 执行重试策略。 +本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC、当前 generation 及其 Host 信息、可观察的恢复状态、立即重连命令,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 执行重试策略。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD` 注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。 +浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 ----- @@ -58,7 +58,7 @@ API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation -- **`/api` 桥把每个请求体整体缓冲在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。 +- **缓冲型 `/api` 路由会把每个请求体保留在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)限制普通图片与 RPC 信封。显式启用的流式路由接收带背压的分块并绕过总量上限;路由实现负责持久化、取消与存储配额。 - **浏览器 cookie 不带 `Secure`**:随附载体是 loopback HTTP;若部署经明文网络暴露同一 authority,bearer cookie 可能在传输中泄露。 - **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录并重启 `dsh` 会撤销全部会话。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index d04eebcf47..932ec1da3e 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2208,13 +2208,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { value: [ { name: 'compact', description: 'fixture:压缩当前会话上下文' }, { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, - { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '', images: true } }, + { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '', attachments: true } }, { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', images: true } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, ], } }, - execute(id: SessionId, line: string, images: readonly unknown[] = []): RpcResult { + execute(id: SessionId, line: string, attachments: readonly unknown[] = []): RpcResult { const missing = requireGoalSession(id) if (missing !== undefined) return missing // Structured split mirroring the Host parser: name + verbatim rawInput @@ -2225,17 +2225,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // Mirror the Host image policy AFTER command resolution, matching the // executor's order (an unknown name answers undefined and logs no // lifecycle): the declaration rejection covers every known command - // without `input.images`, and the two producer grammar rejections cover + // without `input.attachments`, and the two producer grammar rejections cover // the declaring commands' control-only lines. The fixture stores no // bytes, so an accepted batch is acknowledged and dropped. const known = ['permission', 'goal', 'compact', 'echo', 'plan'] - if (images.length > 0 && name !== undefined && known.includes(name)) { + if (attachments.length > 0 && name !== undefined && known.includes(name)) { const rejection = name !== 'goal' && name !== 'plan' - ? `/${name} does not accept image attachments` + ? `/${name} does not accept attachments` : name === 'goal' && args.trim() === '' - ? 'Image attachments only accompany a goal objective: /goal or /goal edit .' + ? 'Attachments only accompany a goal objective: /goal or /goal edit .' : name === 'plan' && args.trim() === 'off' - ? 'Image attachments cannot accompany /plan off.' + ? 'Attachments cannot accompany /plan off.' : undefined if (rejection !== undefined) { const commandId = `fx-cmd-${logOf(id).length}` as CommandId diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 482ef2db91..ad568f55bf 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -1,7 +1,4 @@ -/** - * Browser wire client. The plugin selects fixture or HTTP transport, provides - * the shared API client, and lets API Gateway own the connection loop. - */ +/** Browser wire client: Remote transport and connection generations. */ import type { Context } from '@deepseek-ai/cordis' import { ConnectionController, @@ -107,9 +104,8 @@ interface ClientTransportGlobal { } /** - * The ctx.connection service API: the API client plus a one-shot controller - * starter. API Gateway supplies generation readiness and reset callbacks; - * Connection stays independent of downstream domain state. + * The ctx.connection service API. API Gateway supplies generation readiness + * and reset callbacks; Connection stays independent of downstream domain state. */ export interface ConnectionHandle { /** @@ -178,7 +174,7 @@ function watchBrowserNetwork(controller: ConnectionController): () => void { } /** - * Client plugin body: pick the api by page mode and provide ctx.connection. + * Client plugin body: pick physical carriers by page mode and provide ctx.connection. * @param ctx - client cordis context. */ export function apply(ctx: Context): void { diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index b404e65103..ff6042d117 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -4,6 +4,8 @@ */ import type { IncomingMessage, ServerResponse } from 'node:http' +import { Readable } from 'node:stream' +import type { ConnectionFetchHandler } from './rpc.ts' /** Default carrier cap for all HTTP RPC bodies: sized for the default * aggregate image limit (200 MiB) after base64 expansion plus envelope @@ -11,28 +13,18 @@ import type { IncomingMessage, ServerResponse } from 'node:http' * each body in memory, so this cap is also the per-request resident bound. */ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024 -/** Transport-independent request handler consumed by the Host HTTP bridge. */ -export interface FetchHandler { - /** - * Handle one standard Fetch request. - * @param request - request produced by the active transport bridge. - * @returns complete or streaming Fetch response. - */ - fetch(request: Request): Promise -} - /** * Bridge one node:http request to the fetch-shaped handler (client close * aborts; response bodies stream out chunk by chunk). - * @param req - incoming node:http request (fully read before dispatch). + * @param req - incoming node:http request. * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. - * @param maxRequestBodyBytes - maximum body bytes buffered before dispatch. + * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route. */ export async function bridge( req: IncomingMessage, res: ServerResponse, - apiHandler: FetchHandler, + apiHandler: ConnectionFetchHandler, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES, ): Promise { const abort = new AbortController() @@ -44,38 +36,57 @@ export async function bridge( res.on('close', () => { if (!res.writableEnded) abort.abort() }) - const declaredLength = req.headers['content-length'] - if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { - res.writeHead(413, { connection: 'close' }) - res.end() - req.destroy() - return - } - const chunks: Buffer[] = [] - let received = 0 - for await (const chunk of req) { - const buffer = chunk as Buffer - received += buffer.byteLength - if (received > maxRequestBodyBytes) { + /* v8 ignore next 2 -- node:http always sets url/method on server requests. */ + const url = new URL(req.url ?? '/', 'http://dsh.internal') + const method = req.method ?? 'GET' + const headers = Object.fromEntries( + Object.entries(req.headers).filter(([, value]) => typeof value === 'string') as [string, string][], + ) + const bodyMode = apiHandler.requestBodyMode({ method, url }) + let request: Request + if (bodyMode === 'buffered') { + const declaredLength = req.headers['content-length'] + if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { res.writeHead(413, { connection: 'close' }) res.end() req.destroy() return } - chunks.push(buffer) + const chunks: Buffer[] = [] + let received = 0 + for await (const chunk of req) { + const buffer = chunk as Buffer + received += buffer.byteLength + if (received > maxRequestBodyBytes) { + res.writeHead(413, { connection: 'close' }) + res.end() + req.destroy() + return + } + chunks.push(buffer) + } + request = new Request(url, { + method, + headers, + ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, + signal: abort.signal, + }) + } else { + request = new Request(url, { + method, + headers, + body: Readable.toWeb(req) as ReadableStream, + signal: abort.signal, + duplex: 'half', + } as RequestInit & { duplex: 'half' }) } - /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server - requests; the fields are only optional on the client-side IncomingMessage type */ - const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), { - method: req.method ?? 'GET', - headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]), - ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, - signal: abort.signal, - }) const response = await apiHandler.fetch(request) - res.writeHead(response.status, Object.fromEntries(response.headers.entries())) + const requestUnread = bodyMode === 'streaming' && !req.readableEnded + const responseHeaders = Object.fromEntries(response.headers.entries()) + res.writeHead(response.status, requestUnread ? { ...responseHeaders, connection: 'close' } : responseHeaders) if (response.body === null) { res.end() + if (requestUnread) req.destroy() return } for await (const chunk of response.body) { @@ -96,4 +107,5 @@ export async function bridge( } } res.end() + if (requestUnread) req.destroy() } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 44ed9d6302..a4e044d171 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -22,6 +22,7 @@ export type { ConnectionRpcHandler, ConnectionRequestRejection, ConnectionRpcResult, + ConnectionRequestBodyMode, ConnectionTrustRequest, ClientRequest, HostConnectionHandle, diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index a9bbc72954..d74734e132 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -8,7 +8,7 @@ import { type RpcId as RpcIdType, } from './rpc.ts' import { clientRequestSchema } from './rpc-schema.ts' -import { bridge, type FetchHandler } from './http-bridge.ts' +import { bridge } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' import type { BrowserAuth } from './browser-auth.ts' @@ -34,11 +34,12 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ interface ConnectionRpcInterceptor { readonly matches: ConnectionRpcEndpointMatcher - readonly fetchHandler: FetchHandler + readonly fetchHandler: ConnectionFetchHandler } interface RegisteredFetchRoute { readonly methods: ReadonlySet + readonly requestBody: ConnectionFetchRoute['requestBody'] readonly fetch: ConnectionFetchRoute['fetch'] } @@ -117,6 +118,10 @@ export class HostConnectionService extends Service implements HostConnectionHand channel: '/api', ): ConnectionFetchHandler { return { + requestBodyMode: ({ method, url }) => { + const route = this.fetchRoutes.get(url.pathname) + return route?.methods.has(method) === true ? route.requestBody : 'buffered' + }, fetch: (request) => { const pathname = new URL(request.url).pathname const route = this.fetchRoutes.get(pathname) @@ -138,6 +143,7 @@ export class HostConnectionService extends Service implements HostConnectionHand assertFetchRoute(route) const registered: RegisteredFetchRoute = { methods: new Set(route.methods), + requestBody: route.requestBody, fetch: route.fetch, } return owner.effect(() => { @@ -203,8 +209,9 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, -): FetchHandler { +): ConnectionFetchHandler { return { + requestBodyMode: () => 'buffered', async fetch(request: Request): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) if (request.method !== 'POST' || endpoint === undefined) { diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 12c8f198be..9474d72c36 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -107,7 +107,10 @@ export type ConnectionRpcHandler = ( export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean /** HTTP methods supported by exact Fetch routes on the shared API channel. */ -export type ConnectionFetchMethod = 'GET' | 'HEAD' +export type ConnectionFetchMethod = 'GET' | 'HEAD' | 'POST' + +/** How the node:http bridge presents one request body to its Fetch route. */ +export type ConnectionRequestBodyMode = 'buffered' | 'streaming' /** One exact, transport-independent Fetch route owned by a Host feature. */ export interface ConnectionFetchRoute { @@ -115,6 +118,8 @@ export interface ConnectionFetchRoute { readonly path: string /** Methods this route owns. Other methods continue through normal shared-channel dispatch. */ readonly methods: readonly ConnectionFetchMethod[] + /** Buffered requests obey the configured JSON cap; streaming requests arrive with backpressure and no aggregate cap. */ + readonly requestBody: ConnectionRequestBodyMode /** Handle one request after the physical carrier has applied its trust and authentication policy. */ readonly fetch: (request: Request) => Promise } @@ -196,6 +201,13 @@ export interface HostConnectionHandle { /** Transport-independent Fetch handler used by HTTP and worker carriers. */ export interface ConnectionFetchHandler { + /** + * Resolve body handling before the bridge reads any request bytes. + * @param request - request method and URL available from node:http headers. + * @returns the registered route's body handling mode. + */ + requestBodyMode(request: { readonly method: string; readonly url: URL }): ConnectionRequestBodyMode + /** * Dispatch one already-authenticated request. * @param request - Fetch request below the shared channel. diff --git a/packages/client/connection/tests/fetch-routes.host.spec.ts b/packages/client/connection/tests/fetch-routes.host.spec.ts index d5dbee979e..ddb9b3b264 100644 --- a/packages/client/connection/tests/fetch-routes.host.spec.ts +++ b/packages/client/connection/tests/fetch-routes.host.spec.ts @@ -25,7 +25,8 @@ describe('Connection exact Fetch routes', () => { Response.json({ query: new URL(request.url).searchParams.get('sessionId') })) const dispose = connection.fetch.register({ path: '/api/session.export', - methods: ['GET', 'HEAD'], + methods: ['GET', 'HEAD', 'POST'], + requestBody: 'streaming', fetch: route, }) const shared = connection.createSharedFetchHandler('/api') @@ -33,11 +34,18 @@ describe('Connection exact Fetch routes', () => { const response = await shared.fetch(new Request( 'http://host/api/session.export?sessionId=session-1', )) + expect(shared.requestBodyMode({ + method: 'POST', url: new URL('http://host/api/session.export'), + })).toBe('streaming') + expect(shared.requestBodyMode({ + method: 'DELETE', url: new URL('http://host/api/session.export'), + })).toBe('buffered') expect(response.status).toBe(200) expect(await response.json()).toEqual({ query: 'session-1' }) expect(route).toHaveBeenCalledOnce() const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' })) - expect(post.status).toBe(404) + expect(post.status).toBe(200) + expect(route).toHaveBeenCalledTimes(2) await dispose() const withdrawn = await shared.fetch(new Request('http://host/api/session.export')) @@ -49,22 +57,26 @@ describe('Connection exact Fetch routes', () => { const { connection, dispose: disposeFiber } = await mounted() const fetch = async (): Promise => new Response() - expect(() => connection.fetch.register({ path: '/outside', methods: ['GET'], fetch })) + expect(() => connection.fetch.register({ path: '/outside', methods: ['GET'], requestBody: 'buffered', fetch })) .toThrow('invalid exact Fetch route') - expect(() => connection.fetch.register({ path: '/api/session.export', methods: [], fetch })) + expect(() => connection.fetch.register({ path: '/api/session.export', methods: [], requestBody: 'buffered', fetch })) .toThrow('declares no methods') expect(() => connection.fetch.register({ path: '/api/session.export', methods: ['GET', 'GET'], fetch, + requestBody: 'buffered', })).toThrow('repeats a method') const dispose = connection.fetch.register({ path: '/api/session.export', methods: ['GET'], fetch, + requestBody: 'buffered', }) expect(() => connection.fetch.register({ path: '/api/session.export', methods: ['HEAD'], fetch, + requestBody: 'buffered', })).toThrow('already registered') await dispose() expect(() => connection.fetch.register({ path: '/api/session.export', methods: ['HEAD'], fetch, + requestBody: 'buffered', })).not.toThrow() await disposeFiber() }) diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index ed7eee91b3..103a9d9266 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -22,7 +22,7 @@ const sid = (id: string): SessionId => id as SessionId describe('createFixtureApi commands/skills', () => { it('serves the addressed session catalog', async () => { const { rpc } = createFixtureFaces() - const commands = await callRemote<{ name: string; input?: { hint: string; images?: boolean } }[]>( + const commands = await callRemote<{ name: string; input?: { hint: string; attachments?: boolean } }[]>( rpc, 'commands/list', { agentId: sid('fx-alpha') }) expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan']) // input hint rides only the commands declaring it. @@ -30,7 +30,7 @@ describe('createFixtureApi commands/skills', () => { expect(echo?.input?.hint).toBeTruthy() expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined() // Image acceptance is declared per descriptor; only goal and plan carry it. - expect(commands.filter(c => c.input?.images === true).map(c => c.name)).toEqual(['goal', 'plan']) + expect(commands.filter(c => c.input?.attachments === true).map(c => c.name)).toEqual(['goal', 'plan']) }) it('rejects a catalog request for an unknown session', async () => { @@ -97,14 +97,14 @@ describe('createFixtureApi commands/skills', () => { const refused = await callRemote<{ commandId: string; result: { kind: string; text?: string } } | undefined>( rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hi', images: [png] }) expect(refused?.commandId).toBeTruthy() - expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept image attachments' }) + expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept attachments' }) await pump const events = frames .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'event') .map(f => f.event) expect(events).toMatchObject([ { type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } }, - { type: 'command/done', data: { kind: 'error', text: '/echo does not accept image attachments' } }, + { type: 'command/done', data: { kind: 'error', text: '/echo does not accept attachments' } }, ]) }) @@ -129,13 +129,13 @@ describe('createFixtureApi commands/skills', () => { rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal', images: [png] }) expect(bareGoal?.result).toEqual({ kind: 'error', - text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + text: 'Attachments only accompany a goal objective: /goal or /goal edit .', }) const refused = await callRemote<{ result: { kind: string; text?: string } } | undefined>( rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan off', images: [png] }) expect(refused?.result).toEqual({ kind: 'error', - text: 'Image attachments cannot accompany /plan off.', + text: 'Attachments cannot accompany /plan off.', }) }) diff --git a/packages/client/connection/tests/http-bridge.host.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts index 2e267ac966..c3d2eb6cd7 100644 --- a/packages/client/connection/tests/http-bridge.host.spec.ts +++ b/packages/client/connection/tests/http-bridge.host.spec.ts @@ -24,6 +24,7 @@ describe('HTTP bridge abort', () => { }) as unknown as ServerResponse await bridge(request, response, { + requestBodyMode: () => 'buffered', fetch: () => { throw new Error('a rejected request must never reach the handler') }, }, 1000) // The socket must not stay parked draining a body the client can trickle @@ -55,6 +56,7 @@ describe('HTTP bridge abort', () => { const started = new Promise((resolve) => { resolveStarted = resolve }) let carrierSignal: AbortSignal | undefined const pending = bridge(request, response, { + requestBodyMode: () => 'buffered', fetch: async (input) => { const fetchRequest = input carrierSignal = fetchRequest.signal @@ -72,4 +74,74 @@ describe('HTTP bridge abort', () => { await pending expect(carrierSignal?.aborted).toBe(true) }) + + it('streams a declared 2.19 GiB request before the body ends and bypasses the JSON buffer cap', async () => { + const request = new Readable({ read() {} }) as unknown as IncomingMessage + Object.assign(request, { + url: '/api/session/uploadFileBinary?sessionId=s1', + method: 'POST', + headers: { + 'content-type': 'application/octet-stream', + 'content-length': String(Math.ceil(2.19 * 1024 ** 3)), + }, + }) + let status: number | undefined + const responseBytes: Uint8Array[] = [] + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead(code: number) { status = code; return this }, + write(chunk: Uint8Array) { responseBytes.push(chunk); return true }, + end(this: { writableEnded: boolean }) { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + + let resolveStarted!: () => void + const started = new Promise((resolve) => { resolveStarted = resolve }) + const received: Uint8Array[] = [] + const pending = bridge(request, response, { + requestBodyMode: () => 'streaming', + fetch: async (input) => { + resolveStarted() + if (input.body === null) throw new Error('streaming request lost its body') + for await (const chunk of input.body) received.push(chunk) + return new Response('stored') + }, + }, 1) + + await started + expect(received).toEqual([]) + request.push(Buffer.from([1, 2])) + request.push(Buffer.from([3, 4])) + request.push(null) + await pending + expect(status).toBe(200) + expect(received).toEqual([Uint8Array.of(1, 2), Uint8Array.of(3, 4)]) + expect(Buffer.concat(responseBytes).toString()).toBe('stored') + }) + + it('closes an unread streaming request after returning an early validation response', async () => { + const destroyed: true[] = [] + const request = new Readable({ read() {} }) as unknown as IncomingMessage + Object.assign(request, { + url: '/api/session/uploadFileBinary', + method: 'POST', + headers: { 'content-type': 'application/json' }, + destroy: () => { destroyed.push(true) }, + }) + let status: number | undefined + let headers: unknown + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead(code: number, values?: unknown) { status = code; headers = values; return this }, + write() { return true }, + end(this: { writableEnded: boolean }) { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + + await bridge(request, response, { + requestBodyMode: () => 'streaming', + fetch: () => Promise.resolve(new Response(null, { status: 415 })), + }, 1) + expect(status).toBe(415) + expect(headers).toMatchObject({ connection: 'close' }) + expect(destroyed).toEqual([true]) + }) }) diff --git a/packages/client/file-upload/README.i18n.yaml b/packages/client/file-upload/README.i18n.yaml new file mode 100644 index 0000000000..e6b4cb5fcb --- /dev/null +++ b/packages/client/file-upload/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 2c19e1a564d7b58226e6ccd3cb5b7c81e1b1c14e +README.zh.md: d1553d40d953dd6378a954c43a9b7a36b7334953 diff --git a/packages/client/file-upload/README.md b/packages/client/file-upload/README.md new file mode 100644 index 0000000000..2c19e1a564 --- /dev/null +++ b/packages/client/file-upload/README.md @@ -0,0 +1,101 @@ +--- +description: "Session-addressed browser file uploads with streaming intake, progress, cancellation, and staged receipts for later prompts." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-file-upload + +English | [中文](README.zh.md) + +## Summary + +This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream` 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 + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +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 generated Remote. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +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, 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, 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 | +| [`src/client/index.ts`](src/client/index.ts) | Client plugin registration and `ctx.fileUpload` declaration | + +
+ +**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. + +----- + + +## Further Exploration + +- [Connection](../connection/README.md) — authenticated RPC, exact Host routes, and connection generations. +- [Session Controller](../../api/session-controller/README.md) — prompt admission that consumes staged receipts. +- [Web Worker runtime](../../experimental/webworker-runtime/README.md) — the page-to-Host Worker request tunnel. +- [Client group map](../README.md) — browser services and UI feature packages. + +----- + + +## Model Experience + +None, as this package transfers browser request bodies and contributes no model input. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +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. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/file-upload/README.zh.md b/packages/client/file-upload/README.zh.md new file mode 100644 index 0000000000..d1553d40d9 --- /dev/null +++ b/packages/client/file-upload/README.zh.md @@ -0,0 +1,101 @@ +--- +description: "按 Session 寻址上传浏览器文件,提供流式接收、进度、取消和供后续 prompt 使用的暂存凭证。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-file-upload + +[English](README.md) | 中文 + +## 概述 + +本包让浏览器功能为一个 Session 存储 `Blob`、精确字节或 `ReadableStream`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面通过生成的 Remote 处理可重放的 Blob 与精确字节输入。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +在注入 `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 输入使用生成的 Remote。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法接收所属 Session 标识,组装原始路由请求,并为可重放输入调用生成的 Remote 兜底。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个非 fixture 原始请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。 + +Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、命令凭证解析器与暂存凭证生命周期;编码准入、附件错误识别与字节存储仍由 `ctx.attachments` 提供。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。 + +| 文件 | 职责 | +|---|---| +| [`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 与页面自有载体实现 | +| [`src/client/index.ts`](src/client/index.ts) | Client 插件注册与 `ctx.fileUpload` 声明 | + +
+ +**运行时不变式:** 不发布伴生入口。每个上传凭证只属于一个准确的 Session,每个请求只使用一个已选定载体。载体不支持的 stream 会在发送请求体前失败。 + +----- + + +## 进一步探索 + +- [Connection](../connection/README.zh.md)——认证 RPC、Host 精确路由与 connection generation。 +- [Session Controller](../../api/session-controller/README.zh.md)——消费暂存凭证的 prompt 准入。 +- [Web Worker runtime](../../experimental/webworker-runtime/README.zh.md)——页面到 Host Worker 的请求隧道。 +- [客户端组地图](../README.zh.md)——浏览器服务与 UI 功能包。 + +----- + + +## 模型体验 + +无。本包只传输浏览器请求体,不提供模型输入。 + +#### KV Cache 影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + + + +以下限制适用于传输操作本身。 + +- **上传不能断点续传**:失败或取消后的重试会从第一个字节开始。 +- **stream 请求体只能使用一次**:转移 `ReadableStream` 会锁定调用方的对象,因此重试必须重新创建 stream。 +- **stream 进度没有总量**:stream API 不携带字节长度,因此调用方只能收到已消费字节数。 +- **浏览器 Worker 必须自包含**:其源代码由函数字符串生成。如果实现需要运行时 import,就必须迁移为由 tsdown 打包的独立 Worker 入口。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json new file mode 100644 index 0000000000..64390dc56e --- /dev/null +++ b/packages/client/file-upload/package.json @@ -0,0 +1,84 @@ +{ + "name": "@deepseek-ai/dsh-client-file-upload", + "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", + "version": "0.1.2-rc.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/file-upload" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web", + "immediately": true + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "dependencies": { + "zod": "^4.4.3", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "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-session": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^" + } +} diff --git a/packages/client/file-upload/src/client/contract.ts b/packages/client/file-upload/src/client/contract.ts new file mode 100644 index 0000000000..998a71b2c3 --- /dev/null +++ b/packages/client/file-upload/src/client/contract.ts @@ -0,0 +1,35 @@ +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 + +/** Monotone byte progress reported while a browser body is consumed. */ +export interface FileUploadProgress { + readonly loaded: number + readonly total?: number +} + +/** 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 for a Session. Blob and stream bodies use + * the background carrier; exact bytes and fixture fallbacks use Remote. + * @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. + * @param onProgress - optional byte-progress observer for background bodies. + * @returns the staged receipt and durable file reference, or a business error. + */ + upload( + sessionId: SessionId, + data: Blob | Uint8Array | ReadableStream, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: FileUploadProgress) => void, + ): Promise> +} diff --git a/packages/client/file-upload/src/client/index.ts b/packages/client/file-upload/src/client/index.ts new file mode 100644 index 0000000000..bf6696a423 --- /dev/null +++ b/packages/client/file-upload/src/client/index.ts @@ -0,0 +1,26 @@ +/** Browser background-upload Cordis service. */ + +import type { Context } from '@deepseek-ai/cordis' +import { FileUploadRuntime } from './runtime.ts' +import type { FileUploadService } from './contract.ts' + +export type { FileUploadProgress, FileUploadService } from './contract.ts' +export type { FileUploadReceiptId, FileUploadValue } from '../types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Session-addressed browser service for staged file uploads. */ + fileUpload: FileUploadService + } +} + +/** The upload service uses the generated Remote fallback. */ +export const inject = ['remote'] + +/** + * Provide the browser background-upload service. + * @param ctx - Client plugin context. + */ +export function apply(ctx: Context): void { + ctx.plugin(FileUploadRuntime) +} diff --git a/packages/client/file-upload/src/client/runtime.ts b/packages/client/file-upload/src/client/runtime.ts new file mode 100644 index 0000000000..a54e31121c --- /dev/null +++ b/packages/client/file-upload/src/client/runtime.ts @@ -0,0 +1,359 @@ +/** Background browser upload implementation for Blob and byte-stream bodies. */ + +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 } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { FILE_UPLOAD_PATH } from '../protocol.ts' +import type { + ClientFileUploadHooks, EncodedFileUploadRequest, FileUploadFetch, FileUploadValue, +} from '../types.ts' +import type { FileUploadBody, FileUploadService } from './contract.ts' + +interface FileUploadRequest { + readonly path: string + readonly body: FileUploadBody + readonly headers?: Readonly> + readonly signal?: AbortSignal + readonly onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void +} + +interface FileUploadResponse { + readonly status: number + readonly body: string +} + +interface FileUploadRemoteContext extends Context { + readonly remote: { + readonly fileUploads: { + upload( + sessionId: SessionId, + request: EncodedFileUploadRequest, + signal?: AbortSignal, + ): Promise> + } + } +} + +interface UploadWorkerStart { + readonly url: string + readonly body: FileUploadBody + readonly headers: Readonly> +} + +type UploadWorkerOutput = + | { readonly kind: 'progress'; readonly loaded: number; readonly total?: number } + | { readonly kind: 'complete'; readonly status: number; readonly body: string } + | { readonly kind: 'error'; readonly message: string } + +interface UploadWorkerScope { + onmessage: ((event: MessageEvent) => void) | null + postMessage(message: UploadWorkerOutput): void +} + +interface UploadXhr { + readonly upload: { onprogress: ((event: ProgressEvent) => void) | null } + status: number + responseText: string + withCredentials: boolean + onload: ((event: ProgressEvent) => void) | null + onerror: ((event: ProgressEvent) => void) | null + open(method: string, url: string): void + setRequestHeader(name: string, value: string): void + send(body: Blob): void +} + +type UploadWorkerFetch = ( + input: string, + init: RequestInit & { readonly duplex: 'half' }, +) => Promise + +/** + * Self-contained Worker body; its string form becomes the Blob Worker source. + * @param scope - Worker global used for requests and progress messages. + * @param createXhr - XMLHttpRequest factory used for Blob progress. + * @param doFetch - Fetch carrier used for one-shot ReadableStream bodies. + */ +export function fileUploadWorker( + scope: UploadWorkerScope = self, + createXhr: () => UploadXhr = () => new XMLHttpRequest(), + doFetch: UploadWorkerFetch = (input, init) => fetch(input, init), +): void { + scope.onmessage = (event: MessageEvent) => { + const request = event.data + if (request.body instanceof Blob) { + const xhr = createXhr() + xhr.open('POST', request.url) + xhr.withCredentials = true + for (const [name, value] of Object.entries(request.headers)) xhr.setRequestHeader(name, value) + xhr.upload.onprogress = (progress) => { + scope.postMessage({ + kind: 'progress', + loaded: progress.loaded, + ...(progress.lengthComputable ? { total: progress.total } : {}), + } satisfies UploadWorkerOutput) + } + xhr.onload = () => { + scope.postMessage({ kind: 'complete', status: xhr.status, body: xhr.responseText } satisfies UploadWorkerOutput) + } + xhr.onerror = () => { + scope.postMessage({ kind: 'error', message: 'background upload transport failed' } satisfies UploadWorkerOutput) + } + xhr.send(request.body) + return + } + if (!(request.body instanceof ReadableStream)) { + scope.postMessage({ kind: 'error', message: 'background upload worker received an invalid body' }) + return + } + const source = request.body + void (async () => { + const reader = source.getReader() + let loaded = 0 + const body = new ReadableStream({ + async pull(controller) { + const item = await reader.read() + if (item.done) { + controller.close() + return + } + if (!(item.value instanceof Uint8Array)) { + throw new TypeError('background upload stream produced a non-Uint8Array chunk') + } + loaded += item.value.byteLength + scope.postMessage({ kind: 'progress', loaded }) + controller.enqueue(item.value) + }, + async cancel(reason) { + await reader.cancel(reason) + }, + }) + const response = await doFetch(request.url, { + method: 'POST', + headers: request.headers, + credentials: 'include', + body, + duplex: 'half', + }) + scope.postMessage({ + kind: 'complete', + status: response.status, + body: await response.text(), + }) + })().catch((error: unknown) => { + scope.postMessage({ + kind: 'error', + message: error instanceof Error ? error.message : String(error), + }) + }) + } +} + +interface ClientFileUploadGlobal { + __DSH_FILE_UPLOAD__?: ClientFileUploadHooks +} + +interface FileUploadTransport { + post(request: FileUploadRequest): Promise +} + +/** Cordis service that owns one background carrier per upload operation. */ +export class FileUploadRuntime extends Service implements FileUploadService { + readonly available: boolean + private readonly transport: FileUploadTransport + + /** @param ctx - providing Client context. */ + constructor(ctx: Context) { + super(ctx, 'fileUpload') + const hook = (globalThis as ClientFileUploadGlobal).__DSH_FILE_UPLOAD__ + this.available = hook !== undefined || !isFixturePage() + this.transport = hook === undefined ? workerTransport() : customTransport(hook.fetch) + } + + /** + * Post one body with the carrier selected before Cordis boot. + * @param request - target, body, cancellation, and progress observer. + * @returns the response status and text body. + */ + post(request: FileUploadRequest): Promise { + if (!this.available) return Promise.reject(new Error('background upload is unavailable in fixture mode')) + return this.transport.post(request) + } + + /** + * 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. + * @param onProgress - optional byte-progress observer for background bodies. + * @returns the staged receipt and durable file reference, or a business error. + */ + async upload( + sessionId: SessionId, + data: Blob | Uint8Array | ReadableStream, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void, + ): Promise> { + if (!(data instanceof Uint8Array) && this.available) { + const query = new URLSearchParams({ sessionId }) + if (name !== undefined) query.set('name', name) + const response = await this.post({ + path: `${FILE_UPLOAD_PATH}?${query.toString()}`, + body: data, + headers: { 'content-type': 'application/octet-stream' }, + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }) + if (response.status !== 200) { + throw new Error(`file upload transport failed with HTTP ${String(response.status)}`) + } + return parseFileUploadResult(response.body) + } + if (!(data instanceof Uint8Array) && !(data instanceof Blob)) { + throw new Error('stream file upload requires a background carrier') + } + const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer()) + return (this.ctx as FileUploadRemoteContext).remote.fileUploads.upload( + sessionId, + { + data: bytesToBase64(bytes), + ...(name === undefined ? {} : { name }), + }, + signal, + ) + } +} + +function customTransport(customFetch: FileUploadFetch): FileUploadTransport { + return { + async post(request) { + const init: RequestInit & { duplex?: 'half' } = { + method: 'POST', + ...(request.headers === undefined ? {} : { headers: request.headers }), + body: request.body, + ...(request.body instanceof ReadableStream ? { duplex: 'half' as const } : {}), + ...(request.signal === undefined ? {} : { signal: request.signal }), + } + const response = await customFetch(resolveUrl(request.path), init) + return { status: response.status, body: await response.text() } + }, + } +} + +function workerTransport(): FileUploadTransport { + return { + post(request) { + if (typeof Worker !== 'function') { + return Promise.reject(new Error('background upload requires Web Worker support')) + } + const workerUrl = URL.createObjectURL(new Blob([ + `(${fileUploadWorker.toString()})()`, + ], { type: 'text/javascript' })) + const worker = new Worker(workerUrl, { name: 'dsh-file-upload' }) + URL.revokeObjectURL(workerUrl) + return new Promise((resolve, reject) => { + let settled = false + const abort = (): void => { + settled = true + worker.terminate() + request.signal?.removeEventListener('abort', abort) + reject(new DOMException('The operation was aborted.', 'AbortError')) + } + const finish = (settle: () => void): void => { + if (settled) return + settled = true + request.signal?.removeEventListener('abort', abort) + worker.terminate() + settle() + } + worker.onmessage = (event: MessageEvent) => { + const output = event.data + if (output.kind === 'progress') { + request.onProgress?.({ + loaded: output.loaded, + ...(output.total === undefined ? {} : { total: output.total }), + }) + } else if (output.kind === 'complete') { + finish(() => { resolve({ status: output.status, body: output.body }) }) + } else { + finish(() => { reject(new Error(output.message)) }) + } + } + worker.onerror = (event) => { + finish(() => { reject(new Error(event.message || 'background upload worker failed')) }) + } + if (request.signal?.aborted === true) { + abort() + return + } + request.signal?.addEventListener('abort', abort, { once: true }) + const message: UploadWorkerStart = { + url: resolveUrl(request.path).href, + body: request.body, + headers: request.headers ?? {}, + } + if (request.body instanceof ReadableStream) worker.postMessage(message, [request.body]) + else worker.postMessage(message) + }) + }, + } +} + +function resolveUrl(path: string): URL { + const pageLocation = Reflect.get(globalThis, 'location') as unknown + const origin = typeof pageLocation === 'object' && pageLocation !== null + && 'origin' in pageLocation && typeof pageLocation.origin === 'string' + ? pageLocation.origin + : undefined + return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin) +} + +function isFixturePage(): boolean { + const pageLocation = Reflect.get(globalThis, 'location') as unknown + return typeof pageLocation === 'object' && pageLocation !== null + && 'search' in pageLocation && typeof pageLocation.search === 'string' + && new URLSearchParams(pageLocation.search).has('fixture') +} + +function parseFileUploadResult(body: string): RemoteResult { + const value = JSON.parse(body) as unknown + if (!isRecord(value) || typeof value.ok !== 'boolean') { + throw new TypeError('file upload transport returned an invalid result') + } + if (!value.ok) { + const error = value.error + if (!isRecord(error) || typeof error.code !== 'string' + || typeof error.message !== 'string' || !isRecord(error.details)) { + throw new TypeError('file upload transport returned an invalid failure') + } + return { + ok: false, + error: new RemoteError(error.code as never, error.message, error.details as never), + } + } + const result = value.value + const file = isRecord(result) ? result.file : undefined + if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(file) + || typeof file.attachmentId !== 'string' || typeof file.name !== 'string' + || typeof file.bytes !== 'number' || !Number.isSafeInteger(file.bytes) || file.bytes < 0) { + throw new TypeError('file upload transport returned an invalid receipt') + } + return { + ok: true, + value: { + receiptId: result.receiptId as FileUploadValue['receiptId'], + file: { + attachmentId: file.attachmentId as FileUploadValue['file']['attachmentId'], + name: file.name, + bytes: file.bytes, + }, + }, + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/client/file-upload/src/http-route.ts b/packages/client/file-upload/src/http-route.ts new file mode 100644 index 0000000000..0f5539324e --- /dev/null +++ b/packages/client/file-upload/src/http-route.ts @@ -0,0 +1,81 @@ +/** Authenticated raw-byte upload route registered on the Connection fetch registry. */ + +import { brandString } from '@deepseek-ai/dsh-brand' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import type { FileUploads } from './index.ts' +import type { FileUploadValue } from './types.ts' + +type FileUploadHttpResult = + | { readonly ok: true; readonly value: FileUploadValue } + | { + readonly ok: false + readonly error: { readonly code: string; readonly message: string; readonly details: object } + } + +/** + * Handle one authenticated raw-byte upload. + * @param service - Host upload service receiving streamed bytes. + * @param request - authenticated HTTP request from Connection. + * @returns JSON result using HTTP status 200 after request validation. + */ +export async function handleFileUploadHttp(service: FileUploads, request: Request): Promise { + if (request.method !== 'POST') { + return new Response(null, { status: 405, headers: { allow: 'POST' } }) + } + const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/octet-stream') { + return new Response('content type must be application/octet-stream', { status: 415 }) + } + const url = new URL(request.url) + const sessionId = url.searchParams.get('sessionId') + if (sessionId === null || sessionId === '') { + return new Response('sessionId is required', { status: 400 }) + } + const name = url.searchParams.get('name') ?? undefined + let result: FileUploadHttpResult + try { + result = { + ok: true, + value: await service.uploadStream({ + sessionId: brandString(sessionId), + data: requestBodyChunks(request.body), + signal: request.signal, + ...(name === undefined ? {} : { name }), + }), + } + } catch (error) { + const failure = remoteErrorOf(error) + result = { + ok: false, + error: failure !== undefined + ? { code: failure.code, message: failure.message, details: failure.details } + : { + code: 'gateway/internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } + } + return new Response(JSON.stringify(result), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) +} + +async function* requestBodyChunks(body: ReadableStream | null): AsyncIterable { + if (body === null) return + const reader = body.getReader() + try { + while (true) { + const chunk = await reader.read() + if (chunk.done) return + yield chunk.value + } + } finally { + reader.releaseLock() + } +} diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts new file mode 100644 index 0000000000..2520ba1225 --- /dev/null +++ b/packages/client/file-upload/src/index.ts @@ -0,0 +1,265 @@ +/** Host file-upload service: streamed intake and Agent-scoped staged receipts. */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type {} from '@deepseek-ai/dsh-client-connection' +import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands' +import { scopeOf } from '@deepseek-ai/dsh-scope' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { handleFileUploadHttp } from './http-route.ts' +import { FILE_UPLOAD_PATH } from './protocol.ts' +import type { EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue } from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host storage and staged-receipt service for browser file uploads. */ + fileUploads: FileUploads + } +} + +interface StagedFileUpload { + readonly file: FileAttachmentRef + /** Prompt that accepted this receipt; absent until successful admission. */ + requestId?: string +} + +/** Resolve or resume the ordinary Agent that owns one Session identity. */ +export type AgentResolver = (sessionId: SessionId) => Promise + +/** Prompt receipt binding that restores its previous owners unless delivery commits it. */ +export interface PromptFileBinding extends Disposable { + /** Keep the receipt bindings until queue or history observation retires them. */ + commit(): void +} + +class PromptFileBindingGuard implements PromptFileBinding { + private settled = false + + constructor(private readonly rollback: () => void) {} + + commit(): void { + this.settled = true + } + + [Symbol.dispose](): void { + if (this.settled) return + this.settled = true + this.rollback() + } +} + +/** Host service owning upload storage and Agent-scoped staged receipts. */ +export class FileUploads extends TypertRemoteService { + static inject = ['agents', 'attachments', 'commands', 'connection'] + + private readonly stagedFiles = new WeakMap>() + private agentResolver: AgentResolver | undefined + + /** @param ctx - Host context carrying Agent, attachment, command, and Connection services. */ + constructor(ctx: Context) { + super(ctx, 'fileUploads') + const resolve: CommandFileReceiptResolver = (agent, receiptId) => + this.resolve(agent, receiptId as FileUploadReceiptId) + ctx.effect( + () => ctx.commands.registerFileReceiptResolver(resolve), + 'file-upload: command file receipt resolver', + ) + ctx.effect( + () => ctx.connection.fetch.register({ + path: FILE_UPLOAD_PATH, + methods: ['POST'], + requestBody: 'streaming', + fetch: request => handleFileUploadHttp(this, request), + }), + 'file-upload: streaming route', + ) + ctx.on('session/event', (session, event) => { this.observeSessionEvent(session, event) }) + ctx.on('session/disposed', (session) => { this.stagedFiles.delete(session) }) + } + + /** + * Register the ordinary-Session resolver used when a raw upload addresses a cold Session. + * @param resolve - resolver that returns the exact live Agent or throws a Remote error. + * @returns disposer removing this resolver. + */ + registerAgentResolver(resolve: AgentResolver): () => void { + if (this.agentResolver !== undefined) throw new Error('file-upload: Agent resolver is already registered') + this.agentResolver = resolve + return () => { + if (this.agentResolver === resolve) this.agentResolver = undefined + } + } + + /** + * Persist one encoded upload and stage it under the Agent receiver selected by Typert. + * @param agent - receiving Agent resolved from the Remote Agent scope. + * @param request - canonical base64 bytes and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns the staged receipt and durable file reference. + */ + @Remote('upload') + upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise { + signal.throwIfAborted() + return this.commit(agent, async () => this.ctx.attachments.admitEncodedFile({ + data: request.data, + ...(request.name === undefined ? {} : { name: request.name }), + })) + } + + /** + * Persist raw chunks for one Session without aggregating the upload. + * @param request - Session identity, ordered bytes, cancellation, and optional display name. + * @returns the staged receipt and durable file reference. + */ + async uploadStream(request: { + readonly sessionId: SessionId + readonly data: AsyncIterable + readonly signal?: AbortSignal + readonly name?: string + }): Promise { + const agent = await this.resolveAgent(request.sessionId) + return this.commit(agent, async () => this.ctx.attachments.saveFileStream({ + data: request.data, + ...(request.signal === undefined ? {} : { signal: request.signal }), + ...(request.name === undefined ? {} : { name: request.name }), + })) + } + + /** + * Resolve one staged receipt inside its receiving Agent scope. + * @param agent - receiving Agent. + * @param receiptId - opaque receipt minted for one completed upload. + * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + */ + resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined { + this.assertAgentScope(agent) + return this.stagedFiles.get(agent.session)?.get(receiptId)?.file + } + + /** + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. + * @param agent - receiving Agent. + * @param receiptIds - distinct staged receipts referenced by the prompt. + * @param requestId - prompt identity later observed in queue or history. + * @returns binding kept after commit until queue or history observation retires its receipts. + */ + bindPrompt( + agent: Agent, + receiptIds: readonly FileUploadReceiptId[], + requestId: string, + ): PromptFileBinding { + this.assertAgentScope(agent) + const staged = this.stagedFiles.get(agent.session) + const bound = receiptIds.map((receiptId) => { + const upload = staged?.get(receiptId) + if (upload === undefined) throw fileNotStaged() + return { upload, previous: upload.requestId } + }) + for (const { upload } of bound) upload.requestId = requestId + return new PromptFileBindingGuard(() => { + for (const { upload, previous } of bound) { + if (previous === undefined) delete upload.requestId + else upload.requestId = previous + } + }) + } + + /** + * Retire every receipt accepted by one removed queue occurrence. + * @param agent - receiving Agent. + * @param requestId - prompt identity carried by the queue occurrence. + */ + retirePrompt(agent: Agent, requestId: string): void { + this.assertAgentScope(agent) + this.retire(agent.session, requestId) + } + + private async commit(agent: Agent, save: () => Promise): Promise { + this.assertOrdinaryAgent(agent) + let file: FileAttachmentRef + try { + file = await save() + } catch (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( + 'gateway/internal', + `failed to store file upload: ${String(error)}`, + {}, + { cause: error }, + ) + } + if (this.ctx.agents.get(agent.id) !== agent) { + throw new RemoteError( + 'session/not-found', + `session "${agent.id}" was disposed before its file upload completed`, + { sessionId: agent.id }, + ) + } + let staged = this.stagedFiles.get(agent.session) + if (staged === undefined) { + staged = new Map() + this.stagedFiles.set(agent.session, staged) + } + const receiptId = randomUUID() as FileUploadReceiptId + staged.set(receiptId, { file }) + return { receiptId, file } + } + + private async resolveAgent(sessionId: SessionId): Promise { + const live = this.ctx.agents.get(sessionId) + if (live !== undefined) return live + const resolver = this.agentResolver + if (resolver === undefined) { + throw new RemoteError('session/not-found', `session "${sessionId}" is not attached`, { sessionId }) + } + return resolver(sessionId) + } + + private assertAgentScope(agent: Agent): void { + if (scopeOf(agent.ctx) !== agent) throw new Error('file-upload: operation requires the Agent\'s own scope') + } + + private assertOrdinaryAgent(agent: Agent): void { + this.assertAgentScope(agent) + if (agent.session.header.origin === 'subagent') { + throw new RemoteError( + 'subagent/attachment-invalid' as never, + 'subagent conversations do not accept file uploads', + { reason: 'SUBAGENT_FILE_UNSUPPORTED' } as never, + ) + } + } + + private observeSessionEvent(session: Session, event: SessionEvent): void { + if (event.type !== 'user/message' || event.data.source.kind !== 'user' + || !('rpcId' in event.data.source)) return + if (typeof event.data.source.rpcId === 'string') this.retire(session, event.data.source.rpcId) + } + + private retire(session: Session, requestId: string): void { + const staged = this.stagedFiles.get(session) + if (staged === undefined) return + for (const [receiptId, upload] of staged) { + if (upload.requestId === requestId) staged.delete(receiptId) + } + if (staged.size === 0) this.stagedFiles.delete(session) + } +} + +function fileNotStaged(): RemoteError { + return new RemoteError( + 'session/attachment-invalid' as never, + 'File was not uploaded for this session.', + { reason: 'FILE_NOT_STAGED' } as never, + ) +} + +export default FileUploads diff --git a/packages/client/file-upload/src/protocol.ts b/packages/client/file-upload/src/protocol.ts new file mode 100644 index 0000000000..67ec32811a --- /dev/null +++ b/packages/client/file-upload/src/protocol.ts @@ -0,0 +1,2 @@ +/** Authenticated raw-byte route owned by the file-upload service. */ +export const FILE_UPLOAD_PATH = '/api/session/uploadFileBinary' diff --git a/packages/client/file-upload/src/types.ts b/packages/client/file-upload/src/types.ts new file mode 100644 index 0000000000..b63b2452bf --- /dev/null +++ b/packages/client/file-upload/src/types.ts @@ -0,0 +1,35 @@ +/** Browser-safe request and receipt types for staged file uploads. */ + +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment/types' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Canonical encoded upload accepted by the Remote fallback. */ +export interface EncodedFileUploadRequest { + /** Canonical base64 encoding of the exact file bytes. */ + readonly data: string + /** Optional display name; the Host sanitizes it into the stored leaf name. */ + readonly name?: string +} + +/** Durable receipt for one staged file upload. */ +export interface FileUploadValue { + /** Per-upload authority accepted only inside the receiving Agent scope. */ + readonly receiptId: FileUploadReceiptId + readonly file: FileAttachmentRef +} + +/** Host-minted authority for one staged file upload in one Agent scope. */ +export type FileUploadReceiptId = Branded<'file-upload-receipt-id'> + +/** + * Fetch-shaped carrier installed by a page that owns its Host transport. + * @param input - absolute same-origin upload URL. + * @param init - raw request body, headers, and cancellation signal. + * @returns the Host response. + */ +export type FileUploadFetch = (input: URL, init: RequestInit) => Promise + +/** Pre-Cordis hook supplied by a page whose Host runs in another execution context. */ +export interface ClientFileUploadHooks { + readonly fetch: FileUploadFetch +} diff --git a/packages/client/file-upload/tests/file-upload-http.host.spec.ts b/packages/client/file-upload/tests/file-upload-http.host.spec.ts new file mode 100644 index 0000000000..cd137857ce --- /dev/null +++ b/packages/client/file-upload/tests/file-upload-http.host.spec.ts @@ -0,0 +1,126 @@ +import { runInNewContext } from 'node:vm' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { describe, expect, it, vi } from 'vitest' +import type { Mock } from 'vitest' +import { handleFileUploadHttp } from '../src/http-route.ts' +import type { FileUploads } from '../src/index.ts' + +function request(input: { + method?: string + sessionId?: string + name?: string + contentType?: string + body?: Uint8Array +} = {}): Request { + const query = new URLSearchParams() + if (input.sessionId !== undefined) query.set('sessionId', input.sessionId) + if (input.name !== undefined) query.set('name', input.name) + const suffix = query.size === 0 ? '' : `?${query.toString()}` + return new Request(`http://host/api/session/uploadFileBinary${suffix}`, { + method: input.method ?? 'POST', + headers: input.contentType === undefined ? {} : { 'content-type': input.contentType }, + ...(input.body === undefined ? {} : { body: new Blob([Uint8Array.from(input.body).buffer]) }), + }) +} + +function uploads(result: unknown): FileUploads & { + uploadStream: Mock + uploadedChunks: Uint8Array[] +} { + const uploadedChunks: Uint8Array[] = [] + const uploadStream = vi.fn(async (input) => { + for await (const chunk of input.data) uploadedChunks.push(chunk) + return await result as Awaited> + }) + return { + uploadedChunks, + uploadStream, + } as unknown as FileUploads & { + uploadStream: Mock + uploadedChunks: Uint8Array[] + } +} + +describe('background file upload Fetch route', () => { + it('accepts one authenticated streaming POST request', async () => { + const service = uploads(Promise.resolve({})) + expect((await handleFileUploadHttp(service, request({ + sessionId: 's1', contentType: 'application/octet-stream', + }))).status).toBe(200) + }) + + it('rejects the wrong method, media type, and missing Session id without storing', async () => { + const service = uploads(Promise.resolve({})) + const wrongMethod = await handleFileUploadHttp(service, request({ method: 'GET' })) + expect(wrongMethod.status).toBe(405) + expect(wrongMethod.headers.get('allow')).toBe('POST') + + const wrongType = await handleFileUploadHttp(service, request({ contentType: 'application/json' })) + expect(wrongType.status).toBe(415) + expect(await wrongType.text()).toBe('content type must be application/octet-stream') + + const missingSession = await handleFileUploadHttp( + service, + request({ contentType: 'application/octet-stream' }), + ) + expect(missingSession.status).toBe(400) + expect(await missingSession.text()).toBe('sessionId is required') + expect(service.uploadStream).not.toHaveBeenCalled() + }) + + it('stores the request bytes and returns the staged receipt', async () => { + const value = { + receiptId: 'receipt-1', + file: { attachmentId: 'file-1', name: 'large & final.bin', bytes: 4 }, + } + const service = uploads(Promise.resolve(value)) + const response = await handleFileUploadHttp(service, request({ + sessionId: 's1', + name: 'large & final.bin', + contentType: 'application/octet-stream; charset=binary', + body: Uint8Array.of(1, 2, 3, 4), + })) + expect(service.uploadStream).toHaveBeenCalledOnce() + const upload = service.uploadStream.mock.calls[0]?.[0] + expect(upload).toMatchObject({ sessionId: 's1', name: 'large & final.bin' }) + expect(upload?.signal).toBeInstanceOf(AbortSignal) + expect(service.uploadedChunks).toEqual([Uint8Array.of(1, 2, 3, 4)]) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(await response.json()).toEqual({ ok: true, value }) + }) + + it('returns business and internal storage failures and keeps an absent name absent', async () => { + const business = uploads(Promise.reject(new RemoteError( + 'session/attachment-invalid', 'denied', { reason: 'NOPE' }, + ))) + const businessResponse = await handleFileUploadHttp(business, request({ + sessionId: 's1', contentType: 'application/octet-stream', + })) + expect(business.uploadStream).toHaveBeenCalledOnce() + const upload = business.uploadStream.mock.calls[0]?.[0] + expect(upload).toMatchObject({ sessionId: 's1' }) + expect(upload?.signal).toBeInstanceOf(AbortSignal) + expect(business.uploadedChunks).toEqual([]) + expect(await businessResponse.json()).toEqual({ + ok: false, + error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, + }) + + const internal = uploads(Promise.reject(new Error('disk offline'))) + expect(await (await handleFileUploadHttp(internal, request({ + sessionId: 's1', contentType: 'application/octet-stream', + }))).json()).toEqual({ + ok: false, error: { code: 'gateway/internal', message: 'disk offline', details: {} }, + }) + + const foreignError = runInNewContext('new Error("disk exception")') as unknown as Error + const exception = uploads(Promise.reject(foreignError)) + expect(await (await handleFileUploadHttp(exception, request({ + sessionId: 's1', contentType: 'application/octet-stream', + }))).json()).toEqual({ + ok: false, error: { code: 'gateway/internal', message: 'Error: disk exception', details: {} }, + }) + }) +}) diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts new file mode 100644 index 0000000000..66b27b102a --- /dev/null +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -0,0 +1,516 @@ +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' +import type { FileUploadBody } from '../src/client/contract.ts' +import type { ClientFileUploadHooks } from '../src/types.ts' + +interface UploadGlobal { + __DSH_FILE_UPLOAD__?: ClientFileUploadHooks +} + +afterEach(() => { + delete (globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('file upload worker body', () => { + it('sends a Blob with credentials and reports progress, completion, and failure', () => { + const posted: unknown[] = [] + const scope: { + onmessage: ((event: MessageEvent<{ + url: string + body: FileUploadBody + headers: Readonly> + }>) => void) | null + postMessage(message: unknown): void + } = { onmessage: null, postMessage: (message: unknown) => { posted.push(message) } } + const xhr = { + upload: { onprogress: null as ((event: ProgressEvent) => void) | null }, + status: 201, + responseText: '{"ok":true}', + withCredentials: false, + onload: null as ((event: ProgressEvent) => void) | null, + onerror: null as ((event: ProgressEvent) => void) | null, + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(), + } + fileUploadWorker(scope, () => xhr) + const body = new Blob(['large']) + scope.onmessage?.({ + data: { url: 'https://harness.test/upload', body, headers: { 'content-type': 'application/octet-stream' } }, + } as never) + expect(xhr.open).toHaveBeenCalledWith('POST', 'https://harness.test/upload') + expect(xhr.withCredentials).toBe(true) + expect(xhr.setRequestHeader).toHaveBeenCalledWith('content-type', 'application/octet-stream') + expect(xhr.send).toHaveBeenCalledWith(body) + + xhr.upload.onprogress?.({ loaded: 2, total: 4, lengthComputable: true } as ProgressEvent) + xhr.upload.onprogress?.({ loaded: 3, total: 0, lengthComputable: false } as ProgressEvent) + xhr.onload?.({} as ProgressEvent) + xhr.onerror?.({} as ProgressEvent) + expect(posted).toEqual([ + { kind: 'progress', loaded: 2, total: 4 }, + { kind: 'progress', loaded: 3 }, + { kind: 'complete', status: 201, body: '{"ok":true}' }, + { kind: 'error', message: 'background upload transport failed' }, + ]) + }) + + it('streams Uint8Array chunks through fetch and reports consumed bytes', async () => { + const posted: unknown[] = [] + const scope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: (message: unknown) => { posted.push(message) }, + } + const fetch = vi.fn(async (_url: string, init: RequestInit & { readonly duplex: 'half' }) => { + const chunks: number[][] = [] + for await (const chunk of init.body as ReadableStream) chunks.push([...chunk]) + expect(chunks).toEqual([[1, 2], [3]]) + expect(init).toMatchObject({ + method: 'POST', + headers: { 'x-test': 'yes' }, + credentials: 'include', + duplex: 'half', + }) + return new Response('stored', { status: 202 }) + }) + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(1, 2)) + controller.enqueue(Uint8Array.of(3)) + controller.close() + }, + }) + fileUploadWorker(scope, () => { throw new Error('XHR must not handle streams') }, fetch) + scope.onmessage?.({ data: { url: 'https://harness.test/upload', body, headers: { 'x-test': 'yes' } } } as never) + await vi.waitFor(() => { + expect(posted).toEqual([ + { kind: 'progress', loaded: 2 }, + { kind: 'progress', loaded: 3 }, + { kind: 'complete', status: 202, body: 'stored' }, + ]) + }) + }) + + it('propagates cancellation from the fetch body to the source stream', async () => { + const posted: unknown[] = [] + const scope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: (message: unknown) => { posted.push(message) }, + } + const cancel = vi.fn() + const source = new ReadableStream({ cancel }) + fileUploadWorker( + scope, + () => { throw new Error('unused') }, + async (_url, init) => { + await (init.body as ReadableStream).cancel('fetch stopped') + return new Response('cancelled') + }, + ) + scope.onmessage?.({ data: { url: '/upload', body: source, headers: {} } } as never) + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledWith('fetch stopped') + expect(posted.at(-1)).toEqual({ kind: 'complete', status: 200, body: 'cancelled' }) + }) + }) + + it('reports invalid bodies, stream chunks, and fetch failures', async () => { + const posted: unknown[] = [] + const scope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: (message: unknown) => { posted.push(message) }, + } + fileUploadWorker(scope, () => { throw new Error('unused') }) + scope.onmessage?.({ data: { url: '/upload', body: 'bad', headers: {} } } as never) + expect(posted).toEqual([{ kind: 'error', message: 'background upload worker received an invalid body' }]) + + const badChunk = new ReadableStream({ start(controller) { controller.enqueue('bad'); controller.close() } }) + fileUploadWorker( + scope, + () => { throw new Error('unused') }, + async (_url, init) => { + await new Response(init.body).arrayBuffer() + return new Response() + }, + ) + scope.onmessage?.({ data: { url: '/upload', body: badChunk, headers: {} } } as never) + await vi.waitFor(() => { + expect(posted.at(-1)).toEqual({ + kind: 'error', message: 'background upload stream produced a non-Uint8Array chunk', + }) + }) + + const body = new ReadableStream({ start(controller) { controller.close() } }) + fileUploadWorker( + scope, + () => { throw new Error('unused') }, + () => Promise.reject(new Error('offline')), + ) + scope.onmessage?.({ data: { url: '/upload', body, headers: {} } } as never) + await vi.waitFor(() => { + expect(posted.at(-1)).toEqual({ kind: 'error', message: 'offline' }) + }) + + const failedSource = new ReadableStream({ + start(controller) { controller.error('source failed') }, + }) + fileUploadWorker( + scope, + () => { throw new Error('unused') }, + async (_url, init) => { + await (init.body as ReadableStream).getReader().read() + return new Response() + }, + ) + scope.onmessage?.({ data: { url: '/upload', body: failedSource, headers: {} } } as never) + await vi.waitFor(() => { + expect(posted.at(-1)).toEqual({ kind: 'error', message: 'source failed' }) + }) + }) + + it('uses Worker globals when the emitted body supplies no test seams', async () => { + const posted: unknown[] = [] + const scope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: (message: unknown) => { posted.push(message) }, + } + const xhr = { + upload: { onprogress: null }, + status: 204, + responseText: '', + withCredentials: false, + onload: null, + onerror: null, + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(), + } + vi.stubGlobal('self', scope) + vi.stubGlobal('XMLHttpRequest', vi.fn(function () { return xhr })) + fileUploadWorker() + scope.onmessage?.({ data: { url: '/upload', body: new Blob(), headers: {} } } as MessageEvent) + expect(xhr.send).toHaveBeenCalledOnce() + + const fetch = vi.fn(async (_url: string, init: RequestInit) => { + await new Response(init.body).arrayBuffer() + return new Response(null, { status: 204 }) + }) + vi.stubGlobal('fetch', fetch) + fileUploadWorker() + const stream = new ReadableStream({ start(controller) { controller.close() } }) + scope.onmessage?.({ data: { url: '/stream', body: stream, headers: {} } } as MessageEvent) + await vi.waitFor(() => { + expect(fetch).toHaveBeenCalledOnce() + expect(posted.at(-1)).toEqual({ kind: 'complete', status: 204, body: '' }) + }) + }) +}) + +describe('file upload service', () => { + it('uses a page-owned Host fetch for Blob and ReadableStream bodies', async () => { + vi.stubGlobal('location', { origin: 'https://preview.test' }) + const fetch = vi.fn((_url: URL, _init?: RequestInit) => + Promise.resolve(new Response('accepted', { status: 202 }))) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + const blob = new Blob(['opaque']) + const signal = new AbortController().signal + await expect((ctx.fileUpload as FileUploadRuntime).post({ + path: '/api/upload', body: blob, headers: { 'x-test': 'yes' }, signal, + })).resolves.toEqual({ status: 202, body: 'accepted' }) + expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/api/upload'), { + method: 'POST', headers: { 'x-test': 'yes' }, body: blob, signal, + }) + + const stream = new ReadableStream({ start(controller) { controller.close() } }) + await (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream }) + expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/stream'), { + method: 'POST', body: stream, duplex: 'half', + }) + await fiber.dispose() + }) + + it('mounts through the plugin entry and resolves non-browser URLs', async () => { + vi.stubGlobal('location', { origin: 'null' }) + const fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await fiber + const body = new Blob() + await (ctx.fileUpload as FileUploadRuntime).post({ path: '/fallback', body }) + expect(fetch).toHaveBeenCalledWith(new URL('http://dsh.internal/fallback'), { + method: 'POST', body, + }) + await fiber.dispose() + }) + + it('leaves the fixture on its generated Remote fallback', async () => { + vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' }) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + expect(ctx.fileUpload.available).toBe(false) + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })) + .rejects.toThrow('background upload is unavailable in fixture mode') + await fiber.dispose() + }) + + it('fails loud when a served browser has no Worker implementation', async () => { + vi.stubGlobal('Worker', undefined) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })) + .rejects.toThrow('background upload requires Web Worker support') + await fiber.dispose() + }) + + it('forwards progress and completion from a dedicated Worker and then terminates it', async () => { + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + class FakeWorker { + static last: FakeWorker | undefined + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + readonly postMessage = vi.fn() + readonly terminate = vi.fn() + constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this } + } + vi.stubGlobal('Worker', FakeWorker) + vi.stubGlobal('location', { origin: 'https://harness.test' }) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + const progress = vi.fn() + const blob = new Blob(['bytes']) + const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/api/upload', body: blob, onProgress: progress }) + const worker = FakeWorker.last + if (worker === undefined) throw new Error('worker missing') + expect(created).toHaveBeenCalledOnce() + expect(revoked).toHaveBeenCalledWith('blob:worker') + expect(worker.postMessage).toHaveBeenCalledWith({ + url: 'https://harness.test/api/upload', body: blob, headers: {}, + }) + worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent) + worker.onmessage?.({ data: { kind: 'progress', loaded: 6 } } as MessageEvent) + worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent) + worker.onmessage?.({ data: { kind: 'complete', status: 500, body: 'late' } } as MessageEvent) + await expect(pending).resolves.toEqual({ status: 200, body: 'done' }) + expect(progress.mock.calls).toEqual([ + [{ loaded: 4, total: 5 }], + [{ loaded: 6 }], + ]) + expect(worker.terminate).toHaveBeenCalledOnce() + await fiber.dispose() + }) + + it('transfers stream ownership to the dedicated Worker', async () => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + class FakeWorker { + static last: FakeWorker | undefined + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + readonly postMessage = vi.fn() + readonly terminate = vi.fn() + constructor() { FakeWorker.last = this } + } + vi.stubGlobal('Worker', FakeWorker) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + const stream = new ReadableStream({ start(controller) { controller.close() } }) + const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream }) + const worker = FakeWorker.last + if (worker === undefined) throw new Error('worker missing') + expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ body: stream }), [stream]) + worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent) + await expect(pending).resolves.toEqual({ status: 200, body: 'done' }) + await fiber.dispose() + }) + + it('rejects worker messages, worker errors, and caller cancellation', async () => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + class FakeWorker { + static all: FakeWorker[] = [] + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + readonly postMessage = vi.fn() + readonly terminate = vi.fn() + constructor() { FakeWorker.all.push(this) } + } + vi.stubGlobal('Worker', FakeWorker) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + + const reported = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) + FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent) + await expect(reported).rejects.toThrow('network failed') + + const errored = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) + FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent) + await expect(errored).rejects.toThrow('worker crashed') + + const unnamed = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) + FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent) + await expect(unnamed).rejects.toThrow('background upload worker failed') + + const controller = new AbortController() + const aborted = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: controller.signal }) + controller.abort() + await expect(aborted).rejects.toMatchObject({ name: 'AbortError' }) + expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce() + + const already = new AbortController() + already.abort() + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: already.signal })) + .rejects.toMatchObject({ name: 'AbortError' }) + expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled() + await fiber.dispose() + }) +}) + +describe('Session-addressed file upload', () => { + const SESSION_ID = 's1' as SessionId + + async function scopedService(options: { + readonly remote?: ReturnType + } = {}) { + const ctx = new Context() + const remote = options.remote ?? vi.fn(() => Promise.resolve({ + ok: true, + value: { + receiptId: 'remote-receipt', + file: { attachmentId: 'remote-file', name: 'file', bytes: 3 }, + }, + })) + ctx.provide('remote', { fileUploads: { upload: remote } } as never) + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + return { ctx, fiber, remote, service: ctx.fileUpload } + } + + it('assembles the scoped streaming request and parses progress and receipt fields', async () => { + vi.stubGlobal('location', { origin: 'https://preview.test' }) + const progress = vi.fn() + const fetch = vi.fn((_url: URL, init: RequestInit) => { + expect(init.body).toBeInstanceOf(Blob) + progress({ loaded: 2, total: 4 }) + return Promise.resolve(new Response(JSON.stringify({ + ok: true, + value: { + receiptId: 'receipt-1', + file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 }, + }, + }), { status: 200 })) + }) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } + const { fiber, service } = await scopedService() + const signal = new AbortController().signal + const file = new Blob(['data']) + + await expect(service.upload(SESSION_ID, file, 'notes & refs.pdf', signal, progress)).resolves.toEqual({ + ok: true, + value: { + receiptId: 'receipt-1', + file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 }, + }, + }) + expect(fetch).toHaveBeenCalledWith( + new URL('https://preview.test/api/session/uploadFileBinary?sessionId=s1&name=notes+%26+refs.pdf'), + expect.objectContaining({ + method: 'POST', + headers: { 'content-type': 'application/octet-stream' }, + body: file, + signal, + }), + ) + await fiber.dispose() + }) + + 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, + value: { + receiptId: 'remote-receipt', + file: { attachmentId: 'remote-file', name: 'bytes.bin', bytes: 3 }, + }, + })) + 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(SESSION_ID, new Blob([Uint8Array.of(1)]))) + .resolves.toMatchObject({ ok: true }) + expect(remote.mock.calls).toEqual([ + [SESSION_ID, { data: 'AAAA', name: 'bytes.bin' }, undefined], + [SESSION_ID, { data: 'AQ==' }, undefined], + ]) + await fiber.dispose() + }) + + it('rejects an unavailable stream and malformed background results', async () => { + vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' }) + const fixture = await scopedService() + const stream = new ReadableStream({ start(controller) { controller.close() } }) + await expect(fixture.service.upload(SESSION_ID, stream)) + .rejects.toThrow('stream file upload requires a background carrier') + await fixture.fiber.dispose() + + vi.stubGlobal('location', { origin: 'https://preview.test' }) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: () => Promise.resolve(new Response(null, { status: 413 })), + } + 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() + + const bodies: unknown[] = [ + null, + { ok: 'yes' }, + { ok: false, error: null }, + { ok: false, error: { code: 1, message: 'denied', details: {} } }, + { ok: false, error: { code: 'denied', message: 1, details: {} } }, + { ok: false, error: { code: 'denied', message: 'denied', details: null } }, + { ok: true, value: null }, + { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } }, + ] + for (const body of bodies) { + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: () => Promise.resolve(new Response(JSON.stringify(body), { status: 200 })), + } + 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() + } + + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: () => Promise.resolve(new Response(JSON.stringify({ + ok: false, + error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, + }), { status: 200 })), + } + const failed = await scopedService() + await expect(failed.service.upload(SESSION_ID, new Blob())).resolves.toMatchObject({ + ok: false, + error: { + code: 'session/attachment-invalid', + message: 'denied', + details: { reason: 'NOPE' }, + }, + }) + await failed.fiber.dispose() + }) +}) diff --git a/packages/client/file-upload/tsconfig.client.json b/packages/client/file-upload/tsconfig.client.json new file mode 100644 index 0000000000..dc25e3845d --- /dev/null +++ b/packages/client/file-upload/tsconfig.client.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/contract.ts", + "src/client/index.ts", + "src/client/runtime.ts", + "src/protocol.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, + { "path": "../../util/crypto" }, + { "path": "../../attachment/attachment" }, + { "path": "../../core/session" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/client/file-upload/tsconfig.host.json b/packages/client/file-upload/tsconfig.host.json new file mode 100644 index 0000000000..6c8c29bc15 --- /dev/null +++ b/packages/client/file-upload/tsconfig.host.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": ["src/http-route.ts", "src/index.ts", "src/protocol.ts", "src/types.ts"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, + { "path": "../../core/agent" }, + { "path": "../../core/scope" }, + { "path": "../../core/session" }, + { "path": "../../attachment/attachment" }, + { "path": "../../interaction/commands" }, + { "path": "../../typert/protocol" }, + { "path": "../connection/tsconfig.host.json" } + ] +} diff --git a/packages/client/file-upload/tsconfig.json b/packages/client/file-upload/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/client/file-upload/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/client/file-upload/tsdown.config.ts b/packages/client/file-upload/tsdown.config.ts new file mode 100644 index 0000000000..97292cd3cc --- /dev/null +++ b/packages/client/file-upload/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-file-upload', ['lib/types/index.js']) diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts index 545260b9ae..99296e0c92 100644 --- a/packages/client/ui-approval/src/client/contract/slots.ts +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -68,7 +68,7 @@ let nextApprovalKey = 0 /** One answerable Client presentation of a pending Host waterfall. */ export class PendingApproval { /** Domain discriminator used by Session pending-interaction consumers. */ - readonly kind = 'approval' as const + readonly kind: 'approval' /** Opaque render identity and one-shot remount axis. */ readonly key: string /** Tool requesting the decision. */ @@ -92,6 +92,7 @@ export class PendingApproval { * @param request - Host approval request projected through the Remote Event. */ constructor(readonly sessionId: SessionId, request: ApprovalPresentationRequest) { + this.kind = 'approval' nextApprovalKey += 1 this.key = `approval:${String(nextApprovalKey)}` this.toolName = request.toolName diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index aab45ae0a5..16c1ad7c69 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -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/ui-attachment/README.md -README.md: e6945b6cc902cdf8b9a7ef19f849b92de8b0e2cb -README.zh.md: d7410e1179bd773cdda213423bb4b2ad04ef7b5a +README.md: 4a271c134d25bac536e525adbe469f772ea74c09 +README.zh.md: 499ffcb36462df99924c962f4feecf88dd6ee3dd diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index e6945b6cc9..4a271c134d 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -1,5 +1,5 @@ --- -description: "Attachment presentation for the conversation UI: draft-image rail, document drop target, history-image gallery, and original-image lightbox; for users and maintainers of the Web attachment experience." +description: "Attachment presentation for the conversation UI: mixed draft-attachment rail, document drop target, history-image gallery, and original-image lightbox; for users and maintainers of the Web attachment experience." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package renders everything the conversation UI shows about attachments: pending draft images under the composer, a full-viewport drop invitation, durable images in Chat, Trajectory, and Tool results, and a lightbox for the original image. It is a pure presentation layer — attachment data, image loading, and callbacks come from the conversation package through declared slots. Choose it for the DeepSeek Chat-style image experience; non-image files have no surface here. +This package renders everything the conversation UI shows about attachments: one ordered draft rail under the composer, a full-viewport drop invitation, durable images in Chat, Trajectory, and Tool results, and a lightbox for the original image. Attachment data, upload state, image loading, and callbacks come from the declared slot owners. Choose it for the DeepSeek Chat-style attachment experience. ## Table of Contents @@ -25,15 +25,15 @@ This package renders everything the conversation UI shows about attachments: pen ## Use this package -Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) (and [`ui-tool`](../ui-tool/README.md) for the tool-result gallery); it waits for the conversation package's slot declarations and registers its surfaces into them. Users then see the draft-image rail with per-image remove and click-to-open, the drop overlay with its limits line, message images sized by count, the tool card's gallery, and the Escape/mask/close lightbox. +Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) and [`ui-tool`](../ui-tool/README.md) when tool results need an image gallery. It waits for their slot declarations and registers its components into them. Users then see the mixed draft-attachment rail, DeepSeek Web file cards with upload controls, the drop overlay with its limits line, message images sized by count, the tool card's gallery, and the Escape/mask/close lightbox. -### Draft images +### Draft attachments -A draft image shows as a fixed 64px thumbnail in one horizontally scrolling row; edge arrows page the rail when overflow hides items, and the scrollbar stays hidden. A newly added item is revealed at the rail's end, removal keeps the scroll position, and a single click opens the original through the owner's `onOpen`. +Images and generic files retain pick order in one non-wrapping horizontal rail. Every item is 64px high: an image is a 64px square thumbnail, while a generic file is a 240px-wide DeepSeek Web card with a 16px radius, blue gradient document glyph, filename, and uppercase extension plus byte size. Edge arrows page hidden overflow, the scrollbar stays hidden, and a newly added item is revealed at the rail's end. Uploading replaces a file glyph with a spinner and shows byte progress when the carrier reports it, with an indeterminate bar before the first report; failure shows retry, and removal controls appear on hover or keyboard focus while remaining visible on touch devices. Clicking an image opens the original. ### Message images and the lightbox -A message's lone image renders at 240px on its longer edge (aspect clamped to [0.25, 4], never upscaled); images among several render as fixed 64px squares. A loaded image opens the document-level lightbox on click; a failed load shows a retry control instead. The lightbox closes on Escape, a mask press, or its close control, and restores focus to its opener. +In Chat, one user message presents files and images in a right-aligned wrapping flow that preserves source order. A lone image without another attachment renders at 240px on its longer edge (aspect clamped to [0.25, 4], never upscaled); when the message has more than one attachment, each image is a fixed 64px square beside 240×64px file cards. A loaded image opens the document-level lightbox on click; a failed load shows a retry control instead. The lightbox closes on Escape, a mask press, or its close control, and restores focus to its opener. ### Drop overlay @@ -51,8 +51,8 @@ The plugin waits for `conversation.input.attachments`, `conversation.message.ima | File | Role | |---|---| -| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | Draft-image rail + drop overlay assembly | -| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Scrolling thumbnail rail, wheel translation, edge arrows | +| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | Ordered image/file rail + drop overlay assembly | +| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Horizontal attachment overflow, wheel translation, edge arrows | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | Per-message gallery + lightbox assembly | | [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open; local submission-echo previews render their object URL directly | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | Document-level modal preview over the shared mask | @@ -89,7 +89,6 @@ None; this package neither assembles nor sends a provider request. These limits define the current attachment surface. They are package constraints, not a general image-viewer comparison or a task backlog. -- **Images only** — non-image files have no rail card or history renderer yet; DeepSeek Chat-style file cards and upload progress wait until the composer accepts non-image attachments. - **No zoom or download in the lightbox** — the preview renders the original at fit-to-viewport size only. - **The lightbox does not trap focus** — it sets `aria-modal` and restores focus on close, but Tab can reach the page behind it. diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index d7410e1179..499ffcb364 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -1,5 +1,5 @@ --- -description: "对话 UI 的附件呈现:草稿图片栏、文档拖放目标、历史图片画廊与原图灯箱;供 Web 附件体验的用户与维护者阅读。" +description: "对话 UI 的附件呈现:混合草稿附件栏、文档拖放目标、历史图片画廊与原图灯箱;供 Web 附件体验的用户与维护者阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包渲染对话 UI 中与附件相关的一切:composer 下的待发送草稿图片、全视口拖放邀请层、Chat、Trajectory 与工具结果中的持久图片,以及查看原图的灯箱。它是纯呈现层——附件数据、图片加载与回调都经声明槽位来自 conversation 包。需要 DeepSeek Chat 风格的图片体验时选择它;非图片文件在此没有任何表面。 +本包渲染对话 UI 中与附件相关的一切:composer 下的一条有序草稿附件栏、全视口拖放邀请层、Chat、Trajectory 与工具结果中的持久图片,以及查看原图的灯箱。附件数据、上传状态、图片加载与回调来自声明这些槽位的持有方。需要 DeepSeek Chat 风格的附件体验时选择它。 ## 目录 @@ -25,15 +25,15 @@ kind: "package-reference" ## 使用本包 -与 [`ui-conversation`](../ui-conversation/README.zh.md)(以及工具结果图库所需的 [`ui-tool`](../ui-tool/README.zh.md))一起挂载本插件;它等待 conversation 包的槽位声明,并把自身表面注册进这些槽位。用户随即看到:带逐图删除与点击打开的草稿图片栏、带上限说明的拖放遮罩、按数量定尺寸的消息图片、工具卡片的图库,以及支持 Escape/遮罩/关闭按钮的灯箱。 +与 [`ui-conversation`](../ui-conversation/README.zh.md) 一起挂载本插件,工具结果需要图片图库时也要挂载 [`ui-tool`](../ui-tool/README.zh.md)。插件等待这些槽位的声明,并把组件注册进去。用户会看到混合草稿附件栏、带上传控件的 DeepSeek Web 文件卡、带上限说明的拖放遮罩、按数量定尺寸的消息图片、工具卡片图库,以及支持 Escape、遮罩和关闭按钮的灯箱。 -### 草稿图片 +### 草稿附件 -草稿图片以固定 64px 缩略图呈现在一行横向滚动条中;溢出隐藏时由边缘箭头翻页,滚动条保持隐藏。新增条目滚动到栏尾展示,删除保持原位,单击经持有方的 `onOpen` 打开原图。 +图片与通用文件按选择顺序进入同一条不换行的横向附件栏。所有条目均为 64px 高:图片是 64px 方形缩略图,通用文件是 240px 宽的 DeepSeek Web 卡片,带 16px 圆角、蓝色渐变文档图标、文件名,以及大写扩展名与字节大小。溢出隐藏时由边缘箭头翻页,滚动条保持隐藏,新增条目会滚动到栏尾展示。文件上传时图标位置显示 spinner,传输层报告字节时显示进度,首次报告前使用不定态进度条;失败时显示重试。移除按钮在悬停或键盘聚焦时出现,在触摸设备上保持可见。单击图片会打开原图。 ### 消息图片与灯箱 -一条消息仅有的一张图按长边 240px 渲染(宽高比钳制在 [0.25, 4],从不放大);多图中的一张渲染为固定 64px 方块。加载完成的图片单击打开文档级灯箱;加载失败则显示重试控件。灯箱按 Escape、按下遮罩或点关闭按钮关闭,并把焦点还给打开者。 +Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行的排列中,并保持来源顺序。消息仅有一张图片且没有其他附件时,图片按长边 240px 渲染(宽高比钳制在 [0.25, 4],从不放大);消息有多个附件时,每张图片显示为固定 64px 方块,与 240×64px 文件卡同排。加载完成的图片单击打开文档级灯箱;加载失败则显示重试控件。灯箱按 Escape、按下遮罩或点关闭按钮关闭,并把焦点还给打开者。 ### 拖放遮罩 @@ -51,8 +51,8 @@ kind: "package-reference" | 文件 | 职责 | |---|---| -| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | 草稿图片栏+拖放遮罩的组装 | -| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 滚动缩略图栏、滚轮转换、边缘箭头 | +| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | 有序图片/文件栏+拖放遮罩的组装 | +| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 附件横向溢出、滚轮转换、边缘箭头 | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | 每消息画廊+灯箱的组装 | | [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开;本地提交回显预览直接显示其 object URL | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | 铺在共享遮罩上的文档级模态预览 | @@ -89,7 +89,6 @@ kind: "package-reference" 这些限制界定了当前附件表面。它们是包约束,不是通用图片查看器对比或任务积压。 -- **仅支持图片**——非图片文件尚无附件栏卡片与历史渲染;DeepSeek Chat 风格的文件卡片和上传进度等输入框接受非图片附件后再做。 - **灯箱无缩放与下载**——预览仅以适配视口的尺寸渲染原图。 - **灯箱不锁定焦点**——它设置 `aria-modal` 并在关闭时归还焦点,但 Tab 仍可移动到背后的页面。 diff --git a/packages/client/ui-attachment/src/AttachmentRail.module.css b/packages/client/ui-attachment/src/AttachmentRail.module.css index eb06d8d1df..53acfde4a7 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.module.css +++ b/packages/client/ui-attachment/src/AttachmentRail.module.css @@ -5,6 +5,7 @@ .rail { display: flex; + align-items: stretch; gap: 10px; overflow-x: auto; overflow-y: hidden; @@ -22,9 +23,7 @@ } .item { - position: relative; - flex: 0 0 64px; - width: 64px; + flex: none; height: 64px; } @@ -83,7 +82,6 @@ transition: none; } } - .arrow { position: absolute; top: 50%; diff --git a/packages/client/ui-attachment/src/AttachmentRail.tsx b/packages/client/ui-attachment/src/AttachmentRail.tsx index 65e2df109b..e2e8f97321 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.tsx +++ b/packages/client/ui-attachment/src/AttachmentRail.tsx @@ -1,31 +1,23 @@ -/** Draft-attachment thumbnail rail: scrollbar-less horizontal overflow paged - * by edge arrows, hover-revealed per-item remove, single-click open. */ +/** Draft-attachment rail: scrollbar-less horizontal overflow paged by edge arrows. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { ReactNode } from 'react' import clsx from 'clsx' import { - IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14, + IconChevronLeftOutline14, IconChevronRightOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' import css from './AttachmentRail.module.css' -/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */ +/** One ordered draft attachment rendered by the rail owner. */ export interface AttachmentRailItem { /** Stable identity for the React key. */ id: string - /** Object or data URL rendered as the thumbnail. */ - previewUrl: string - /** Image alt text (display name with the owner's fallback applied). */ - alt: string - /** Accessible label of the item's remove control. */ - removeLabel: string } /** Rail-level strings the owner resolves from its own locale namespace. */ export interface AttachmentRailLabels { /** Accessible name of the rail group. */ group: string - /** Thumbnail tooltip inviting the original-image preview. */ - open: string /** Accessible label of the left paging arrow. */ scrollLeft: string /** Accessible label of the right paging arrow. */ @@ -40,12 +32,12 @@ const WHEEL_LINE_PX = 16 function pageBehavior(): ScrollBehavior { // jsdom (the unit lane) implements no matchMedia despite lib.dom's // non-optional typing; the optional call keeps that lane on the default. - // oxlint-disable-next-line typescript/no-unnecessary-condition - return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' + const matchMedia = (window as unknown as { matchMedia?: Window['matchMedia'] }).matchMedia + return matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' } /** - * Horizontal thumbnail rail over the caller's draft attachments. + * Horizontal rail over the caller's ordered draft attachments. * * The rail scrolls with its scrollbar hidden; overflow is announced by edge * arrows recomputed from scroll geometry on scroll, item-count changes, and @@ -53,22 +45,18 @@ function pageBehavior(): ScrollBehavior { * panel resizes count, not only window resizes). A vertical wheel pans the * rail horizontally and is consumed exclusively (non-passive listener), a * newly added item is revealed at the rail's end while a rail that mounts - * over an existing draft keeps its start position, and each thumbnail opens - * on a single click while its remove control sits inside the card and - * reveals on hover or focus. The owner decides mounting; it renders the rail - * only while items exist. + * over an existing draft keeps its start position. The owner renders each + * item and decides mounting; it renders the rail only while items exist. * - * @param props.items - resolved thumbnails in draft order. - * @param props.labels - rail-level strings (group name, open tooltip, arrows). - * @param props.onOpen - single-click open of one item's original image. - * @param props.onRemove - remove one item from the draft. + * @param props.items - attachments in draft order. + * @param props.labels - rail-level strings (group name and paging arrows). + * @param props.renderItem - render one attachment card in draft order. * @returns the rail group with its paging arrows. */ -export function AttachmentRail({ items, labels, onOpen, onRemove }: { +export function AttachmentRail({ items, labels, renderItem }: { items: readonly T[] labels: AttachmentRailLabels - onOpen: (item: T) => void - onRemove: (item: T) => void + renderItem: (item: T) => ReactNode }) { const railRef = useRef(null) // null marks the first layout pass: a rail that MOUNTS over an existing @@ -165,24 +153,7 @@ export function AttachmentRail({ items, labels, on onScroll={updateEdges} > {items.map(item => ( -
- - -
+
{renderItem(item)}
))} {edges.right && ( diff --git a/packages/client/ui-attachment/src/FileCard.module.css b/packages/client/ui-attachment/src/FileCard.module.css new file mode 100644 index 0000000000..71de9ec4bf --- /dev/null +++ b/packages/client/ui-attachment/src/FileCard.module.css @@ -0,0 +1,153 @@ +.card { + position: relative; + display: inline-flex; + align-items: center; + gap: 10px; + width: 240px; + height: 64px; + padding: 0 12px; + border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12)); + border-radius: 16px; + background: var(--dsw-specific-input-major, transparent); + box-sizing: border-box; + text-align: left; +} + +.failed { + border-color: var(--dsw-alias-state-error-primary, #d54941); +} + +.icon { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + corner-shape: round; + animation: file-card-spin 0.8s linear infinite; +} + +@keyframes file-card-spin { + to { transform: rotate(360deg); } +} + +.body { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + padding: 8px 0; +} + +.retry { + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--dsw-alias-label-primary); + font-size: 14px; + font-weight: 500; + line-height: 22px; +} + +.meta { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45)); + font-size: 12px; + line-height: 15px; +} + +.metaFailed { + color: var(--dsw-alias-state-error-primary, #d54941); +} + +.remove { + position: absolute; + top: 6px; + right: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 50%; + corner-shape: round; + background: var(--dsw-alias-button-contrast-fill, rgba(0, 0, 0, 0.72)); + color: var(--dsw-alias-label-primary-inverted, #fff); + opacity: 0; + cursor: pointer; + transition: opacity 0.2s ease-in-out; +} + +.card:hover .remove, +.remove:focus-visible { + opacity: 1; +} + +.card:hover .name, +.card:focus-within .name { + padding-right: 18px; +} + +.removeFailed { + background: var(--dsw-alias-state-error-primary, #d54941); + color: #fff; + opacity: 1; +} + +.progressTrack { + position: absolute; + right: 12px; + bottom: 5px; + left: 12px; + overflow: hidden; + height: 2px; + border-radius: 1px; + background: var(--dsw-alias-fill-tertiary, rgba(0, 0, 0, 0.08)); +} + +.progressBar { + display: block; + width: 35%; + height: 100%; + border-radius: inherit; + background: var(--dsw-alias-brand-primary, #4d6bfe); + animation: file-card-progress 1.2s ease-in-out infinite alternate; +} + +.progressBar[style] { + animation: none; +} + +@keyframes file-card-progress { + from { transform: translateX(-70%); } + to { transform: translateX(220%); } +} + +@media (pointer: coarse) { + .remove { + opacity: 1; + } +} diff --git a/packages/client/ui-attachment/src/FileCard.tsx b/packages/client/ui-attachment/src/FileCard.tsx new file mode 100644 index 0000000000..d5634b7d9a --- /dev/null +++ b/packages/client/ui-attachment/src/FileCard.tsx @@ -0,0 +1,89 @@ +import { DocumentFileIcon, fileSizeText } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './FileCard.module.css' + +/** Localized strings consumed by one pending-file card. */ +export interface FileCardLabels { + /** Card body announcement, e.g. "Pending file {name}". */ + readonly label: string + /** Remove-button label. */ + readonly remove: string + /** Status line while the upload is in flight. */ + readonly uploading: string + /** Status line and retry affordance after a failed upload. */ + readonly failed: string + /** Retry-button label. */ + readonly retry: string +} + +/** Upload display state resolved by the owner. */ +export type FileCardState = 'uploading' | 'ready' | 'error' + +function extensionOf(name: string): string { + const dot = name.lastIndexOf('.') + if (dot <= 0 || dot === name.length - 1) return '' + return name.slice(dot + 1).toUpperCase().slice(0, 8) +} + +/** One pending generic-file card: name, size or upload status, remove, retry. */ +export function FileCard({ + name, bytes, state, progress, labels, onRemove, onRetry, +}: { + name: string + bytes: number + state: FileCardState + progress?: number + labels: FileCardLabels + onRemove: () => void + onRetry: () => void +}) { + const extension = extensionOf(name) + const meta = state === 'uploading' + ? labels.uploading + : state === 'error' + ? labels.failed + : [extension, fileSizeText(bytes)].filter(part => part !== '').join(' ') + const retryable = state === 'error' + return ( +
+ + {state === 'uploading' + ? + : } + + {retryable + ? ( + + ) + : ( + + {name} + {meta} + + )} + + {state === 'uploading' && ( + + + + )} +
+ ) +} diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 61554fe93f..3cf3b6c51e 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -139,15 +139,16 @@ export function MessageImage({ image, load, variant, labels }: { } /** Wrapping image group shared by user and assistant history: a lone image - * renders large, several render as 64px square tiles (DeepSeek Chat rule). */ -export function ImageGallery({ images, load, align, labels }: { + * renders large unless its owning mixed-attachment row requests compact tiles. */ +export function ImageGallery({ images, load, align, compact = false, labels }: { images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' + compact?: boolean labels: MessageImageLabels }) { if (images.length === 0) return null - const variant = images.length === 1 ? 'single' : 'tile' + const variant = compact || images.length > 1 ? 'tile' : 'single' return (
{images.map((image, index) => ( diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css index 770a64cef5..b4a8eba014 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -2,3 +2,64 @@ min-width: 0; padding: 4px 12px 0; } + +.imageItem { + position: relative; + width: 64px; + height: 64px; +} + +.thumbnail { + width: 64px; + height: 64px; + padding: 0; + overflow: hidden; + border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 16px; + background: var(--dsw-alias-interactive-bg-hover); + cursor: zoom-in; +} + +.thumbnail img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.remove { + position: absolute; + top: 4px; + right: 4px; + z-index: 1; + display: grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 50%; + corner-shape: round; + background: var(--dsw-alias-button-contrast-fill); + color: var(--dsw-alias-label-primary-inverted); + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +.imageItem:hover .remove, +.remove:focus-visible { + opacity: 1; +} + +@media (pointer: coarse) { + .remove { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .remove { + transition: none; + } +} diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index 1e45dc6230..fd469195bf 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -1,12 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { - ComposerAttachment, ComposerAttachmentsProps, + ComposerAttachment, ComposerAttachmentsProps, ComposerImageAttachment, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' import { AttachmentRail } from '../AttachmentRail.tsx' import type { AttachmentRailItem } from '../AttachmentRail.tsx' import { DropOverlay } from '../DropOverlay.tsx' +import { FileCard } from '../FileCard.tsx' import { ImageLightbox } from '../ImageLightbox.tsx' -import { attachmentRailLabels, dropOverlayLabels, lightboxLabels } from './labels.ts' +import { attachmentRailLabels, dropOverlayLabels, fileCardLabels, lightboxLabels } from './labels.ts' import css from './ComposerAttachments.module.css' /** Rail item retaining its browser-owned attachment for callbacks. */ @@ -14,15 +16,14 @@ interface ComposerRailItem extends AttachmentRailItem { attachment: ComposerAttachment } -/** Draft-image rail, document drop target, and original-image preview slot entry. */ +/** Draft image previews, pending-file cards, drop target, and original-image preview. */ export function ComposerAttachments({ - attachments, canAcceptDrop, onAddImages, onRemoveImage, dropLimits, t, + attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, uploads, onRetryFile, dropLimits, t, }: ComposerAttachmentsProps) { - const [preview, setPreview] = useState(null) + const [preview, setPreview] = useState(null) const [dragActive, setDragActive] = useState(false) const dragDepth = useRef(0) const closePreview = useCallback(() => { setPreview(null) }, []) - useEffect(() => { if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null) }, [attachments, preview]) @@ -62,7 +63,7 @@ export function ComposerAttachments({ if (dataTransfer === null) return event.preventDefault() reset() - if (canAcceptDrop) onAddImages([...dataTransfer.files]) + if (canAcceptDrop) onAddFiles([...dataTransfer.files]) } document.addEventListener('dragenter', onDragEnter) document.addEventListener('dragover', onDragOver) @@ -76,15 +77,12 @@ export function ComposerAttachments({ document.removeEventListener('drop', onDrop) window.removeEventListener('dragend', reset) } - }, [canAcceptDrop, onAddImages]) + }, [canAcceptDrop, onAddFiles]) const railItems = useMemo(() => attachments.map(attachment => ({ id: attachment.id, - previewUrl: attachment.previewUrl, - alt: attachment.file.name || t('image.pending'), - removeLabel: t('image.remove', { name: attachment.file.name }), attachment, - })), [attachments, t]) + })), [attachments]) return ( <> @@ -99,8 +97,47 @@ export function ComposerAttachments({ { setPreview(item.attachment) }} - onRemove={(item) => { onRemoveImage(item.attachment.id) }} + renderItem={(item) => { + const attachment = item.attachment + if (attachment.kind === 'file') { + const upload = uploads[attachment.id] + return ( + 0 + ? { progress: upload.loaded / upload.total } + : {}} + labels={fileCardLabels(t, attachment.file.name)} + onRemove={() => { onRemoveAttachment(attachment.id) }} + onRetry={() => { onRetryFile(attachment.id) }} + /> + ) + } + return ( +
+ + +
+ ) + }} />
)} diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index c84914db54..92cdaa455b 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -3,6 +3,14 @@ import { ImageGallery } from '../MessageImage.tsx' import { messageImageLabels } from './labels.ts' /** Historical message-image slot entry. */ -export function MessageImages({ images, loadImage, align, t }: MessageImagesProps) { - return +export function MessageImages({ images, loadImage, align, compact = false, t }: MessageImagesProps) { + return ( + + ) } diff --git a/packages/client/ui-attachment/src/client/labels.ts b/packages/client/ui-attachment/src/client/labels.ts index 4dcc3567db..990f8959c7 100644 --- a/packages/client/ui-attachment/src/client/labels.ts +++ b/packages/client/ui-attachment/src/client/labels.ts @@ -1,6 +1,7 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { AttachmentRailLabels } from '../AttachmentRail.tsx' import type { DropOverlayLabels } from '../DropOverlay.tsx' +import type { FileCardLabels } from '../FileCard.tsx' import type { ImageLightboxLabels } from '../ImageLightbox.tsx' import type { MessageImageLabels } from '../MessageImage.tsx' @@ -41,23 +42,38 @@ export function dropOverlayLabels( accepting: boolean, limits?: { readonly count: number; readonly size: string }, ): DropOverlayLabels { - if (!accepting) return { title: t('image.dropBlocked') } + if (!accepting) return { title: t('attachment.dropBlocked') } return { - title: t('image.dropTitle'), - desc: limits === undefined ? undefined : t('image.dropDesc', limits), + title: t('attachment.dropTitle'), + desc: limits === undefined ? undefined : t('attachment.dropDesc', limits), } } /** - * Resolve draft-image rail strings from the conversation namespace. + * Resolve pending-file card strings from the conversation namespace. + * @param t - conversation namespace translator. + * @param name - browser file name interpolated into remove/retry labels. + * @returns translated file-card labels. + */ +export function fileCardLabels(t: TranslateNS<'conversation'>, name: string): FileCardLabels { + return { + label: t('file.pending'), + remove: t('file.remove', { name }), + uploading: t('file.uploading'), + failed: t('file.uploadFailed'), + retry: t('file.retry', { name }), + } +} + +/** + * Resolve the mixed draft-attachment rail strings from the conversation namespace. * @param t - conversation namespace translator. * @returns translated attachment-rail labels. */ export function attachmentRailLabels(t: TranslateNS<'conversation'>): AttachmentRailLabels { return { - group: t('image.pending'), - open: t('image.openOriginal'), - scrollLeft: t('image.scrollLeft'), - scrollRight: t('image.scrollRight'), + group: t('attachment.pending'), + scrollLeft: t('attachment.scrollLeft'), + scrollRight: t('attachment.scrollRight'), } } diff --git a/packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx b/packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx index cc08a78d1c..089a16672a 100644 --- a/packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx +++ b/packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx @@ -26,15 +26,16 @@ afterEach(() => { vi.unstubAllGlobals() }) const labels: AttachmentRailLabels = { group: '待发送图片', - open: '查看原图', scrollLeft: '向左滚动图片', scrollRight: '向右滚动图片', } function item(id: string): AttachmentRailItem { - return { id, previewUrl: `blob:${id}`, alt: `${id}.png`, removeLabel: `移除图片 ${id}.png` } + return { id } } +const renderItem = (entry: AttachmentRailItem) => {entry.id} + /** Stub the rail's scroll geometry (jsdom reports 0 for every metric). */ function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) { Object.defineProperty(rail, 'scrollWidth', { value: scrollWidth, configurable: true }) @@ -53,22 +54,16 @@ function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollW } describe('AttachmentRail', () => { - it('renders thumbnails in order and routes open and remove clicks', () => { - const onOpen = vi.fn() - const onRemove = vi.fn() + it('renders owner-provided attachment cards in order', () => { const items = [item('a'), item('b')] - const view = render() + const view = render() const rail = view.getByRole('group', { name: '待发送图片' }) - expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['a.png', 'b.png']) - fireEvent.click(view.getAllByTitle('查看原图')[0]!) - expect(onOpen).toHaveBeenCalledWith(items[0]) - fireEvent.click(view.getByRole('button', { name: '移除图片 b.png' })) - expect(onRemove).toHaveBeenCalledWith(items[1]) + expect([...rail.children].map(child => child.textContent)).toEqual(['a', 'b']) }) it('shows edge arrows from scroll geometry and pages a viewport at a time', () => { const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) @@ -94,7 +89,7 @@ describe('AttachmentRail', () => { it('shows both arrows mid-scroll and recomputes when the rail itself resizes', () => { const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) @@ -110,7 +105,7 @@ describe('AttachmentRail', () => { it('keeps scrolling available when ResizeObserver is unavailable', () => { vi.stubGlobal('ResizeObserver', undefined) const view = render( - , + , ) expect(view.getByRole('group', { name: '待发送图片' })).toBeTruthy() view.unmount() @@ -118,7 +113,7 @@ describe('AttachmentRail', () => { it('pans horizontally on a vertical wheel, consuming the event, with clamped normalized travel', () => { const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) @@ -148,7 +143,7 @@ describe('AttachmentRail', () => { for (const [matches, behavior] of [[true, 'auto'], [false, 'smooth']] as const) { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList)) const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) @@ -162,16 +157,16 @@ describe('AttachmentRail', () => { it('reveals the rail end when an item is added, not when one is removed', () => { const first = [item('a'), item('b')] const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) view.rerender( - , + , ) expect(rail.scrollLeft).toBe(200) view.rerender( - , + , ) // Removal keeps the position; only growth jumps to the end. expect(rail.scrollLeft).toBe(200) diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index ef77068f7e..8e6f01474e 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -22,24 +22,37 @@ afterEach(() => { const t = ((key: string, params?: Readonly>): string => { const messages: Record = { + 'attachment.pending': '待发送附件', + 'attachment.scrollLeft': '向左滚动附件', + 'attachment.scrollRight': '向右滚动附件', + 'file.pending': '待发送文件', + 'file.uploading': '上传中…', + 'file.uploadFailed': '上传失败,点击重试', + 'file.label': '文件', 'image.pending': '待发送图片', 'image.original': '原图', 'image.preview': '原图预览', 'image.closePreview': '关闭原图预览', 'image.openOriginal': '查看原图', - 'image.scrollLeft': '向左滚动图片', - 'image.scrollRight': '向右滚动图片', - 'image.dropBlocked': '当前无法添加图片', - 'image.dropTitle': '图片拖动到此处即可添加', + 'attachment.dropBlocked': '当前无法添加文件或图片', + 'attachment.dropTitle': '文件或图片拖动到此处即可添加', + } + if (key === 'file.remove') { + const name = params?.name + return `移除文件 ${typeof name === 'string' ? name : ''}` + } + if (key === 'file.retry') { + const name = params?.name + return `重试上传 ${typeof name === 'string' ? name : ''}` } if (key === 'image.remove') { const name = params?.name return `移除图片 ${typeof name === 'string' ? name : ''}` } - if (key === 'image.dropDesc') { + if (key === 'attachment.dropDesc') { const count = params?.count const size = params?.size - return `最多 ${typeof count === 'number' ? String(count) : ''} 张,每张 ${typeof size === 'string' ? size : ''}` + return `图片限制:最多 ${typeof count === 'number' ? String(count) : ''} 张,每张 ${typeof size === 'string' ? size : ''}` } return messages[key] ?? key }) as ComposerAttachmentsProps['t'] @@ -53,12 +66,22 @@ function attachment(id: string, name = `${id}.png`): ComposerAttachment { } } +function fileDraft(id: string, name = `${id}.pdf`): ComposerAttachment { + return { + kind: 'file', + id: id as ComposerAttachment['id'], + file: new File([Uint8Array.of(1, 2, 3)], name, { type: 'application/pdf' }), + } +} + function props(overrides: Partial = {}): ComposerAttachmentsProps { return { attachments: [], canAcceptDrop: true, - onAddImages: () => {}, - onRemoveImage: () => {}, + onAddFiles: () => {}, + onRemoveAttachment: () => {}, + uploads: {}, + onRetryFile: () => {}, t, ...overrides, } as unknown as ComposerAttachmentsProps @@ -66,9 +89,9 @@ function props(overrides: Partial = {}): Composer describe('ComposerAttachments', () => { it('accepts file drops anywhere on the document and keeps non-file drags native', () => { - const onAddImages = vi.fn() + const onAddFiles = vi.fn() const view = render() @@ -82,12 +105,12 @@ describe('ComposerAttachments', () => { const image = attachment('dropped').file const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false) - expect(view.getByRole('status').textContent).toContain('图片拖动到此处即可添加') - expect(view.getByRole('status').textContent).toContain('最多 20 张,每张 5MB') + expect(view.getByRole('status').textContent).toContain('文件或图片拖动到此处即可添加') + expect(view.getByRole('status').textContent).toContain('图片限制:最多 20 张,每张 5MB') expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false) expect(dataTransfer.dropEffect).toBe('copy') expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false) - expect(onAddImages).toHaveBeenCalledWith([image]) + expect(onAddFiles).toHaveBeenCalledWith([image]) expect(view.queryByRole('status')).toBeNull() }) @@ -118,30 +141,30 @@ describe('ComposerAttachments', () => { }) it('shows a blocked drop without forwarding its files', () => { - const onAddImages = vi.fn() - const view = render() + const onAddFiles = vi.fn() + const view = render() const image = attachment('blocked').file const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' } fireEvent.dragEnter(document.body, { dataTransfer }) - expect(view.getByRole('status').textContent).toBe('当前无法添加图片') + expect(view.getByRole('status').textContent).toBe('当前无法添加文件或图片') fireEvent.dragOver(document.body, { dataTransfer }) expect(dataTransfer.dropEffect).toBe('none') fireEvent.drop(document.body, { dataTransfer }) - expect(onAddImages).not.toHaveBeenCalled() + expect(onAddFiles).not.toHaveBeenCalled() expect(view.queryByRole('status')).toBeNull() }) it('routes rail removal and closes previews on Escape or attachment removal', () => { - const onRemoveImage = vi.fn() + const onRemoveAttachment = vi.fn() const image = attachment('draft-1', 'pixel.png') - const initial = props({ attachments: [image], onRemoveImage }) + const initial = props({ attachments: [image], onRemoveAttachment }) const view = render() fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' })) - expect(onRemoveImage).toHaveBeenCalledWith(image.id) + expect(onRemoveAttachment).toHaveBeenCalledWith(image.id) fireEvent.click(view.getByTitle('查看原图')) expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() - view.rerender() + view.rerender() expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() view.rerender() @@ -150,6 +173,24 @@ describe('ComposerAttachments', () => { expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() }) + it('keeps images and files in pick order inside one attachment rail', () => { + const view = render() + const rail = view.getByRole('group', { name: '待发送附件' }) + expect([...rail.children].map((child) => { + const image = child.querySelector('img') + return image?.getAttribute('alt') ?? child.querySelector('[title]')?.getAttribute('title') + })).toEqual(['first.png', 'middle.pdf', 'last.png']) + expect(view.queryByRole('group', { name: '待发送文件' })).toBeNull() + }) + it('labels an unnamed attachment and its original-image preview', () => { const image = attachment('unnamed', '') const view = render() @@ -158,3 +199,68 @@ describe('ComposerAttachments', () => { expect(view.getByAltText('原图')).toBeTruthy() }) }) + +describe('ComposerAttachments file drafts', () => { + it('renders uploading, ready, and failed cards with remove and retry affordances', () => { + const onRemoveAttachment = vi.fn() + const onRetryFile = vi.fn() + const view = render() + const group = view.getByRole('group', { name: '待发送附件' }) + expect(group.textContent).toContain('上传中…') + expect(view.container.querySelector('[style="width: 25%;"]')).toBeTruthy() + expect(group.textContent).toContain('ok.pdf') + expect(group.textContent).toContain('PDF 3B') + expect(group.textContent).toContain('上传失败,点击重试') + fireEvent.click(view.getByRole('button', { name: '重试上传 bad.pdf' })) + expect(onRetryFile).toHaveBeenCalledWith('bad') + fireEvent.click(view.getByRole('button', { name: '移除文件 ok.pdf' })) + expect(onRemoveAttachment).toHaveBeenCalledWith('ok') + }) + + it('treats a draft without upload state as uploading and keeps retry separate from remove', () => { + const onRetryFile = vi.fn() + const onRemoveAttachment = vi.fn() + const view = render() + expect(view.getByRole('group', { name: '待发送附件' }).textContent).toContain('上传中…') + const retry = view.getByRole('button', { name: '重试上传 bad.pdf' }) + const remove = view.getByRole('button', { name: '移除文件 bad.pdf' }) + expect(retry.contains(remove)).toBe(false) + fireEvent.click(remove) + expect(onRemoveAttachment).toHaveBeenCalledWith('bad') + expect(onRetryFile).not.toHaveBeenCalled() + fireEvent.click(retry) + expect(onRetryFile).toHaveBeenCalledWith('bad') + }) + + it('uses the localized file label when the browser supplies no name', () => { + const view = render() + const group = view.getByRole('group', { name: '待发送附件' }) + expect(group.textContent).toContain('文件') + expect(group.textContent).toContain('3B') + }) +}) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 76a720b983..595f20cb1c 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -219,11 +219,16 @@ describe('ImageGallery', () => { expect(view.getByAltText('echo.png')).toBeTruthy() }) - it('renders a lone image large and several images as square tiles', () => { + it('renders a lone image large and compact or grouped images as square tiles', () => { const load = vi.fn(() => new Promise(() => {})) const lone = render() expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1) lone.unmount() + const compact = render( + , + ) + expect(compact.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(1) + compact.unmount() const several = render( , ) @@ -272,9 +277,9 @@ describe('ImageGallery', () => { useInput, inputActions: { setDraft: vi.fn(), - addImages: vi.fn(() => true), - removeImage: vi.fn(), - pruneImages: vi.fn(), + addAttachments: vi.fn(() => true), + removeAttachment: vi.fn(), + pruneAttachments: vi.fn(), submit: vi.fn(), }, images: [{ attachment }], diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index 7c616f4a0e..2f44408b33 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -292,3 +292,57 @@ animation: none; } } + +.attachmentRow { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + max-width: 100%; + gap: 8px; +} + +.fileCard { + display: inline-flex; + flex: 0 0 240px; + align-items: center; + gap: 10px; + width: 240px; + min-height: 64px; + padding: 8px 12px; + border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12)); + border-radius: 16px; + background: var(--dsw-specific-input-major, transparent); + box-sizing: border-box; +} + +.fileIcon { + flex: none; + width: 24px; + height: 28px; +} + +.fileContent { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.fileName { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--dsw-alias-label-primary); + font-size: 14px; + font-weight: 500; + line-height: 22px; +} + +.fileMeta { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45)); + font-size: 12px; + line-height: 15px; +} diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 07e9fb32a0..fc4f257048 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,8 +1,8 @@ -import { memo, useEffect, useMemo, useState } from 'react' +import { Fragment, memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { DocumentFileIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' @@ -11,24 +11,37 @@ import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' type UserImage = Extract +type UserFile = Extract +type PresentedAttachment = + | { readonly type: 'image'; readonly image: MessageImageSource } + | { readonly type: 'file'; readonly file: UserFile['attachment'] } + +function extensionOf(name: string): string { + const dot = name.lastIndexOf('.') + if (dot <= 0 || dot === name.length - 1) return '' + return name.slice(dot + 1).toUpperCase().slice(0, 8) +} function contentParts(content: readonly unknown[]): { text: string - images: { attachment: UserImage['attachment'] }[] + attachments: PresentedAttachment[] rest: unknown[] } { const texts: string[] = [] - const images: { attachment: UserImage['attachment'] }[] = [] + const attachments: PresentedAttachment[] = [] const rest: unknown[] = [] for (const block of content) { const b = block as { type?: string; text?: string; attachment?: unknown } if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text) else if (b.type === 'image' && b.attachment !== undefined) { - images.push({ attachment: (b as UserImage).attachment }) + attachments.push({ type: 'image', image: { attachment: (b as UserImage).attachment } }) + } + else if (b.type === 'file' && b.attachment !== undefined) { + attachments.push({ type: 'file', file: (b as UserFile).attachment }) } else rest.push(block) } - return { text: texts.join(''), images, rest } + return { text: texts.join(''), attachments, rest } } function retrySeconds(milliseconds: number): number { @@ -148,7 +161,8 @@ function TurnMaxTokensItem({ t }: { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t, + content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], skillNames = [], + previewAttachments, t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -160,12 +174,15 @@ function UserStyleBubble({ echo?: boolean /** Exact session mention labels associated by the adjacent recall node. */ referenceLabels?: readonly string[] - /** Local submission-echo previews replacing the content-derived image group. */ - previewImages?: readonly MessageImageSource[] + /** Skill names the step's `skill-invocation` injections loaded for this message. */ + skillNames?: readonly string[] + /** Local submission-echo attachments replacing the content-derived attachment sequence. */ + previewAttachments?: readonly PresentedAttachment[] t: ChatViewSlotProps['t'] }): ReactNode { - const { text, images: contentImages, rest } = contentParts(content) - const images = previewImages ?? contentImages + const { text, attachments: contentAttachments, rest } = contentParts(content) + const attachments = previewAttachments ?? contentAttachments + const compactImages = attachments.length > 1 const truncated = (total: number): string => t('json.truncated', { total }) const showBubble = text !== '' || rest.length > 0 return ( @@ -175,9 +192,34 @@ function UserStyleBubble({ data-submission-echo={echo || undefined} >
- {renderMessageImages({ images, align: 'end' })} + {attachments.length > 0 && ( +
+ {attachments.map((attachment, index) => attachment.type === 'image' + ? ( + + {renderMessageImages({ + images: [attachment.image], + align: 'end', + compact: compactImages, + })} + + ) + : ( + + + + {attachment.file.name} + + {[extensionOf(attachment.file.name), fileSizeText(attachment.file.bytes)] + .filter(Boolean).join(' ')} + + + + ))} +
+ )} {showBubble &&
- {projectUserText(text, referenceLabels)} + {projectUserText(text, referenceLabels, skillNames)} {rest.map((block, i) => )}
} {referenceLabels.length > 0 && ( @@ -237,21 +279,26 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }: () => (submission.text === '' ? [] : [{ type: 'text', text: submission.text }]), [submission.text], ) - const previewImages = useMemo( - () => submission.images.map(image => ({ - preview: { - url: image.previewUrl, - ...(image.name === undefined ? {} : { name: image.name }), - ...(image.width === undefined ? {} : { width: image.width }), - ...(image.height === undefined ? {} : { height: image.height }), - }, - })), - [submission.images], + const previewAttachments = useMemo( + () => submission.attachments.map(attachment => attachment.type === 'image' + ? { + type: 'image', + image: { + preview: { + url: attachment.value.previewUrl, + ...(attachment.value.name === undefined ? {} : { name: attachment.value.name }), + ...(attachment.value.width === undefined ? {} : { width: attachment.value.width }), + ...(attachment.value.height === undefined ? {} : { height: attachment.value.height }), + }, + }, + } + : { type: 'file', file: attachment.value }), + [submission.attachments], ) return ( ( 0)) return node + const data: Record = { ...candidate.data } + if (names.length === 0) delete data.skillNames + else data.skillNames = names + return { ...candidate, data } +} + +/** What skill-name batches are made of; every other Node is transparent. */ +type SlashEntryKind = 'message' | 'skill' | 'boundary' + +interface SlashEntry { + readonly key: string + readonly seq: number + readonly kind: SlashEntryKind + /** The injected skill name of a `skill` entry. */ + readonly name: string | null +} + +/** + * Classify one Node for batching: a direct message, a `skill-invocation` + * context, or a boundary of any other kind. A context that injects no skill + * (workspace rules, the catalog, a recall) is transparent and yields null. + */ +function slashEntryOf(node: ChatConversationViewNode): SlashEntry | null { + const candidate = node as ChatNode + if (candidate.kind === 'user' || candidate.kind === 'steering') { + return { key: node.key, seq: node.anchorSeq, kind: 'message', name: null } + } + if (candidate.kind === 'context') { + const name = skillInvocationName(candidate.data.source) + return name === null ? null : { key: node.key, seq: node.anchorSeq, kind: 'skill', name } + } + return { key: node.key, seq: node.anchorSeq, kind: 'boundary', name: null } +} + +function sameSlashEntry(left: SlashEntry, right: SlashEntry): boolean { + return left.seq === right.seq && left.kind === right.kind && left.name === right.name +} + +/** + * Attaches each direct message's step-loaded skill names to its Node. + * + * A step's `skill-invocation` injections follow the direct messages the host + * scanned for `/name` gestures and precede the step's first Node of any other + * kind, so every non-message, non-context Node closes a batch. Every ended + * Turn publishes its `turn-tail` Node on `turn/end` whatever the reason, so a + * batch never spans Turns, and `step/start` precedes the direct message in + * the log, so no boundary separates a message from its injections. Names + * attach to every direct message of the batch: the bubble decorates only the + * tokens its own text carries. + * + * The index holds only messages, skill injections, and boundaries, ordered by + * `anchorSeq`. An apply re-reads just the batches around the Nodes whose + * classification changed and never scans the store, so an assistant + * streaming frame costs nothing here (the append hot path never scans the + * Chat Nodes). + */ +export class SkillNameProjector { + private readonly entries = new Map() + /** Every indexed entry in `anchorSeq` order. */ + private sorted: SlashEntry[] = [] + + /** + * Rebuild the index from a whole Node set and attach names to its messages. + * @param nodes - every materialized Chat Node, in any order. + * @returns the same Nodes, direct messages carrying their batch's names. + */ + replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] { + this.entries.clear() + this.sorted = [] + for (const node of nodes) { + const entry = slashEntryOf(node) + if (entry === null) continue + this.entries.set(entry.key, entry) + this.sorted.push(entry) + } + this.sorted.sort((left, right) => left.seq - right.seq) + const names = new Map() + for (let index = 0; index < this.sorted.length; index++) { + if (this.sorted[index]?.kind === 'boundary') continue + const end = this.runEnd(index) + this.assignRun(index, end, names) + index = end + } + return nodes.map(node => withSkillNames(node, names.get(node.key) ?? EMPTY_KEYS)) + } + + /** + * Fold one incremental upsert set: re-read only the batches around the + * Nodes whose classification changed. + * @param upserts - the changed Nodes. + * @param store - the resident Nodes, read by key for the messages of an affected batch. + * @returns the upserts plus any resident message whose names changed. + */ + apply( + upserts: readonly ChatConversationViewNode[], + store: ChatNodeStore, + ): readonly ChatConversationViewNode[] { + const dirty: number[] = [] + for (const node of upserts) { + const next = slashEntryOf(node) + const previous = this.entries.get(node.key) + if (previous !== undefined) { + if (next !== null && sameSlashEntry(previous, next)) { + // A rebuilt message Node carries Definition state only, never the + // names a previous apply attached: re-read its batch. + if (next.kind === 'message') dirty.push(next.seq) + continue + } + this.remove(previous) + dirty.push(previous.seq) + } + if (next === null) continue + this.insert(next) + dirty.push(next.seq) + } + if (dirty.length === 0) return upserts + const names = new Map() + for (const seq of dirty) this.collectAround(seq, names) + const byKey = new Map(upserts.map(node => [node.key, node])) + for (const [key, list] of names) { + const node = byKey.get(key) ?? store.get(key) + if (node === undefined) continue + const next = withSkillNames(node, list) + if (next !== node || byKey.has(key)) byKey.set(key, next) + } + return [...byKey.values()] + } + + private insert(entry: SlashEntry): void { + this.sorted.splice(this.lowerBound(entry.seq), 0, entry) + this.entries.set(entry.key, entry) + } + + private remove(entry: SlashEntry): void { + this.sorted.splice(this.sorted.indexOf(entry), 1) + this.entries.delete(entry.key) + } + + /** First index whose seq is at least `seq`. */ + private lowerBound(seq: number): number { + let low = 0 + let high = this.sorted.length + while (low < high) { + const middle = (low + high) >>> 1 + if ((this.sorted[middle]?.seq ?? Number.POSITIVE_INFINITY) < seq) low = middle + 1 + else high = middle + } + return low + } + + /** Last index of the boundary-free run containing `index`. */ + private runEnd(index: number): number { + let end = index + while (end + 1 < this.sorted.length && this.sorted[end + 1]?.kind !== 'boundary') end++ + return end + } + + /** First index of the boundary-free run containing `index`. */ + private runStart(index: number): number { + let start = index + while (start - 1 >= 0 && this.sorted[start - 1]?.kind !== 'boundary') start-- + return start + } + + /** Record the names every message of the run `[start, end]` carries. */ + private assignRun(start: number, end: number, names: Map): void { + const list: string[] = [] + for (let index = start; index <= end; index++) { + const entry = this.sorted[index] + if (entry?.kind === 'skill' && entry.name !== null && !list.includes(entry.name)) list.push(entry.name) + } + for (let index = start; index <= end; index++) { + const entry = this.sorted[index] + if (entry?.kind === 'message') names.set(entry.key, list) + } + } + + /** + * Re-read the run(s) around one changed seq: the run holding a message or + * skill entry, or — for a boundary, or a seq that left the index — the runs + * on both sides of that position. + */ + private collectAround(seq: number, names: Map): void { + const at = this.lowerBound(seq) + const here = this.sorted[at] + if (here !== undefined && here.seq === seq && here.kind !== 'boundary') { + this.assignRun(this.runStart(at), this.runEnd(at), names) + return + } + if (at - 1 >= 0 && this.sorted[at - 1]?.kind !== 'boundary') { + this.assignRun(this.runStart(at - 1), at - 1, names) + } + const right = here !== undefined && here.seq === seq ? at + 1 : at + if (right < this.sorted.length && this.sorted[right]?.kind !== 'boundary') { + this.assignRun(right, this.runEnd(right), names) + } + } +} + interface LegacyContribution { readonly anchorSeq: number readonly nodes: readonly ConversationNode[] @@ -756,6 +963,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.locations.rebuild(this.order, this.store) @@ -785,7 +993,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder() let structural = false const contentOnly: ChatConversationViewNode[] = [] diff --git a/packages/client/ui-chat/src/client/conversation-nodes/command.ts b/packages/client/ui-chat/src/client/conversation-nodes/command.ts index b4723d78fa..e21f50c5f5 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/command.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/command.ts @@ -1,13 +1,12 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/checkpoint' import type {} from '@deepseek-ai/dsh-compaction/types' import type {} from '@deepseek-ai/dsh-commands/types' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { ManualCompactionChatData } from '../contract/chat-nodes.ts' -import type { CommandNode, CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts index 5874c7bd61..9612f08741 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts @@ -1,9 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-compaction/types' -import type { CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { compactSource, compactSummary, updateCompactionState } from './command.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts b/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts index 0d909cdcdd..1695a467fe 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts @@ -88,6 +88,17 @@ export function sessionRecallLabels(source: unknown): string[] { return collect(record, 'references', 'label') } +/** + * Read the skill name a durable skill-invocation injection loaded. + * @param source - Logged `user/message` source. + * @returns The skill name, or null for every other source. + */ +export function skillInvocationName(source: unknown): string | null { + const record = asRecord(source) + if (record === null || readString(record, 'kind') !== 'skill-invocation') return null + return readString(record, 'name') +} + /** * Classify finalized Assistant content for Chat rendering. * @param content - Core content blocks. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts index adba1ef867..dbfb160b9d 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts @@ -1,7 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' -import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationNodeDefinition, UnknownSurfaceNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { UnknownSurfaceNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 1927a468c5..3cd6538bd5 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -1,7 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' -import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { ContextMessageNode, SteeringMessageNode, UserMessageNode } from '../contract/snapshot.ts' import type { InboxState } from './inbox.ts' import { chatNode } from './common.ts' import { contextForm, contextProvenance } from './event-projection.ts' @@ -9,11 +10,15 @@ import { contextForm, contextProvenance } from './event-projection.ts' interface ReferencedUserMessageNode extends UserMessageNode { /** Labels cited by the immediately following session-reference context. */ readonly referenceLabels?: readonly string[] + /** Skill names the same step's `skill-invocation` injections loaded. */ + readonly skillNames?: readonly string[] } interface ReferencedSteeringMessageNode extends SteeringMessageNode { /** Labels cited by the immediately following session-reference context. */ readonly referenceLabels?: readonly string[] + /** Skill names the same step's `skill-invocation` injections loaded. */ + readonly skillNames?: readonly string[] } type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode diff --git a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts index 278927c6ae..ea73fae9f6 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts @@ -1,5 +1,7 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm/types' -import type { AssistantBlock, PartialAssistant } from '../contract/snapshot.ts' +import type { + AssistantBlock, PartialAssistant, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { emptyAssistantBlock, toAssistantBlock } from './event-projection.ts' /** diff --git a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts index eeaf8d461b..d721821f22 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts @@ -1,10 +1,9 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationLocation, ConversationMatch, ConversationNodeDefinition, + ConversationLocation, ConversationMatch, ConversationNodeDefinition, ModelRetryNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { RetryChatData } from '../contract/chat-nodes.ts' -import type { ModelRetryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index b1f675c4e4..acfcb4bca8 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -1,11 +1,11 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, + ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type {} from '@deepseek-ai/dsh-tools/types' import type { ToolChatData } from '../contract/chat-nodes.ts' -import type { RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index e3b41095ce..941f23ac43 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -1,8 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { TurnErrorNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { displayFailure } from './event-projection.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts index 31508ab77c..746d00bc95 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts @@ -1,8 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { TurnMaxTokensNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/model/conversation-context.ts b/packages/client/ui-chat/src/client/model/conversation-context.ts index 7f8116ffcc..442c455435 100644 --- a/packages/client/ui-chat/src/client/model/conversation-context.ts +++ b/packages/client/ui-chat/src/client/model/conversation-context.ts @@ -1,5 +1,6 @@ -import type { ConversationNode } from '../contract/snapshot.ts' -import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationNode, ConversationPromptSnapshot, +} from '@deepseek-ai/dsh-client-ui-conversation/client' /** Operation that started a new append-only model context. */ export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index ce311f5291..878a78e63a 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -2,7 +2,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-tools/types' import type { ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, -} from '../contract/snapshot.ts' +} from '@deepseek-ai/dsh-client-ui-conversation/client' interface ProjectedBlock { source: ToolCallBlock diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 7bcd9373b2..d9a77dd63b 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -48,10 +48,11 @@ interface MessageItemProps { readonly node: ConversationNode readonly t: ChatNodeViewProps['t'] readonly referenceLabels?: readonly string[] + readonly skillNames?: readonly string[] } /** Legacy-node fixture adapter for the independently registered renderers. */ -function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps) { +function MessageItem({ node, t: translate, referenceLabels, skillNames }: MessageItemProps) { const kind = node.kind === 'assistant' ? 'assistant-step' : node.kind const viewNode: ChatConversationViewNode = { key: `fixture:${node.kind}:${node.seq}`, @@ -63,8 +64,12 @@ function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps) visibility: 'visible', data: node.kind === 'model-retry' ? { attempts: [node], current: node } - : (node.kind === 'user' || node.kind === 'steering') && referenceLabels !== undefined - ? { ...node, referenceLabels } + : (node.kind === 'user' || node.kind === 'steering') && (referenceLabels !== undefined || skillNames !== undefined) + ? { + ...node, + ...(referenceLabels === undefined ? {} : { referenceLabels }), + ...(skillNames === undefined ? {} : { skillNames }), + } : node, } const props = { node: viewNode, t: translate, renderMessageImages, useChat: useDetachedChat } as ChatNodeViewProps @@ -141,6 +146,22 @@ describe('MessageItem arms', () => { expect(view.container.textContent).toContain('README.md, please.') }) + it('decorates a slash token as a skill chip only when the step resolved that skill', () => { + const message = { + kind: 'user' as const, + seq: 1, + time: 1_000, + content: [{ type: 'text', text: '/123 then /demo-skill go' }] as never, + source: null, + } + const plain = render() + expect(plain.container.querySelectorAll('[data-ref-chip]').length).toBe(0) + const resolved = render() + const chips = [...resolved.container.querySelectorAll('[data-ref-chip="skill"]')] + expect(chips.map(chip => chip.textContent)).toEqual(['/demo-skill']) + expect(resolved.container.textContent).toContain('/123 then ') + }) + it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { @@ -1041,3 +1062,29 @@ describe('small branch tails', () => { expect(view.container.textContent).toBe('1 轮 · 1 步| 输入 0 tok · 输出 10 tok') }) }) + +describe('user file attachments', () => { + it('renders one card per durable file block with its name and compact size', () => { + const view = render( + , + ) + expect(view.getByTitle('notes.pdf').textContent).toContain('3.2MB') + expect(view.getByTitle('tiny.txt').textContent).toContain('12B') + expect(view.getByTitle('mid.csv').textContent).toContain('500KB') + expect(view.getByText('summarize these')).toBeTruthy() + }) +}) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 9e0818f37c..2c8fe220ea 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -379,9 +379,9 @@ function makeHarness( useInput: (() => { throw new Error('unused') }), inputActions: { setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }, useStore: bindSnapshotSelector(chat), @@ -1041,7 +1041,7 @@ describe('ChatView', () => { pendingSubmissions: [ { requestId: 'req-1' as never, placement: 'transcript', - time: 5_000, text: '即发即显', images: [], + time: 5_000, text: '即发即显', attachments: [], }, ], }, @@ -1081,7 +1081,9 @@ describe('ChatView', () => { placement: 'steering', time: 5_500, text: '带图纠偏', - images: [{ previewUrl: 'blob:steer-preview', name: 'steer.png' }], + attachments: [{ + type: 'image', value: { previewUrl: 'blob:steer-preview', name: 'steer.png' }, + }], }], }, ) @@ -1115,7 +1117,7 @@ describe('ChatView', () => { pendingSubmissions: [ { requestId: 'req-q' as never, placement: 'queued', - time: 6_000, text: '排队中', images: [], + time: 6_000, text: '排队中', attachments: [], }, ], }, @@ -1149,9 +1151,11 @@ describe('ChatView', () => { placement: 'transcript', time: 7_000, text: '', - images: [ - { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, - { previewUrl: 'blob:echo-b' }, + attachments: [ + { + type: 'image', value: { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, + }, + { type: 'image', value: { previewUrl: 'blob:echo-b' } }, ], }], }, @@ -1159,17 +1163,74 @@ describe('ChatView', () => { const baseRenderSlot = h.props.renderSlot const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never) - const images = (owner as { images: readonly unknown[] }).images - return
+ const { images, compact } = owner as { images: readonly unknown[]; compact?: boolean } + return ( +
+ ) }) as unknown as ChatViewSlotProps['renderSlot'] const view = render() - const gallery = view.getByTestId('echo-images') - expect(gallery.getAttribute('data-count')).toBe('2') - expect(JSON.parse(gallery.getAttribute('data-first') ?? '{}')).toEqual({ + const images = view.getAllByTestId('echo-image') + expect(images).toHaveLength(2) + expect(images.every(image => image.getAttribute('data-count') === '1')).toBe(true) + expect(images.every(image => image.getAttribute('data-compact') === 'true')).toBe(true) + expect(images[0]?.parentElement).toBe(images[1]?.parentElement) + expect(JSON.parse(images[0]?.getAttribute('data-first') ?? '{}')).toEqual({ preview: { url: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, }) }) + it('a mixed echo renders the Web file card between its selected images', () => { + const h = makeHarness( + { nodes: [] }, + { + pendingSubmissions: [{ + requestId: 'req-mixed' as never, + placement: 'transcript', + time: 7_500, + text: '', + attachments: [ + { type: 'image', value: { previewUrl: 'blob:first', name: 'first.png' } }, + { + type: 'file', + value: { attachmentId: 'file-1' as never, name: 'notes.txt', bytes: 23 }, + }, + { type: 'image', value: { previewUrl: 'blob:last', name: 'last.png' } }, + ], + }], + }, + ) + const baseRenderSlot = h.props.renderSlot + const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { + if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never) + const { images, compact } = owner as { + images: ReadonlyArray<{ preview?: { name?: string } }> + compact?: boolean + } + return ( +
+ ) + }) as unknown as ChatViewSlotProps['renderSlot'] + const view = render() + const first = view.getByTestId('images-first.png') + const file = view.getByTitle('notes.txt') + const last = view.getByTestId('images-last.png') + expect(first.getAttribute('data-compact')).toBe('true') + expect(last.getAttribute('data-compact')).toBe('true') + expect(first.parentElement).toBe(file.parentElement) + expect(file.parentElement).toBe(last.parentElement) + expect(first.compareDocumentPosition(file) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + expect(file.compareDocumentPosition(last) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + expect(view.getByText('TXT 23B')).toBeTruthy() + }) + it('animates only the latest unresolved model retry', () => { const retryNode = retry(2) const nextRetry = { ...retry(3), turn: 2, retry: 2 } diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index f401455a32..50bcb83297 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1766,6 +1766,58 @@ describe('built-in conversation node Definitions', () => { }) }) + it('associates a direct message with the skill invocations injected for its step', () => { + const skillInvocation = (id: string) => ({ + ...textMessage(id, 'instructions'), + source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' }, + }) + const instructions = (id: string) => ({ + ...textMessage(id, 'workspace rules'), + source: { kind: 'agent-instructions', changes: [{ path: 'AGENTS.md' }] }, + }) + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }), + at(3, 'step/start', { turn: 1, step: 1 }), + at(4, 'user/message', instructions('rules-1'), { surfaceOp: 'append' }), + at(5, 'user/message', skillInvocation('skill-body'), { surfaceOp: 'append' }), + at(6, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('answer-1', 'done'), + }, { surfaceOp: 'append' }), + at(7, 'step/end', { turn: 1, step: 1 }), + at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + at(9, 'turn/start', { turn: 2 }), + at(10, 'user/message', textMessage('later', '/demo-skill again?'), { surfaceOp: 'append' }), + at(11, 'step/start', { turn: 2, step: 1 }), + at(12, 'user/message', instructions('rules-2'), { surfaceOp: 'append' }), + ]) + + const users = [...snapshot(value).nodes.values()].filter(candidate => candidate.kind === 'user') + expect(users).toHaveLength(2) + expect(users[0]?.data).toMatchObject({ skillNames: ['demo-skill'] }) + expect(users[1]?.data).not.toHaveProperty('skillNames') + }) + + it('updates an already published direct node when its skill injection arrives', () => { + const value = assembler([ + at(1, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }), + ]) + const before = node(snapshot(value), 'user') + expect(before?.data).not.toHaveProperty('skillNames') + + value.append(at(2, 'user/message', { + ...textMessage('skill-body', 'instructions'), + source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' }, + }, { surfaceOp: 'append' })) + value.flush() + + const after = node(snapshot(value), 'user') + expect(after?.key).toBe(before?.key) + expect(after?.data).toMatchObject({ skillNames: ['demo-skill'] }) + }) + it('keeps replacement copies out of Chat business nodes', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index 57afd2abd8..9f7d3dd062 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -153,9 +153,9 @@ describe('render branch tails', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useStore={bindSnapshotSelector(chat)} @@ -215,9 +215,9 @@ describe('render branch tails', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useStore={bindSnapshotSelector(chat)} diff --git a/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts new file mode 100644 index 0000000000..6e1f375adb --- /dev/null +++ b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts @@ -0,0 +1,101 @@ +/** + * SkillNameProjector: the step's `skill-invocation` injections attach to the + * direct messages of the same batch, incrementally — an assistant-only apply + * must neither scan the store nor re-emit message Nodes. + */ +import { describe, expect, it } from 'vitest' +import type { ChatConversationViewNode } from '../src/client/contract/chat-nodes.ts' +import type { ChatNodeStore } from '../src/client/contract/snapshot.ts' +import { SkillNameProjector } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' + +function viewNode(kind: string, seq: number, data: Record): ChatConversationViewNode { + return { + key: `${kind}:${seq}`, + kind, + id: String(seq), + target: 'chat', + anchorSeq: seq, + location: { kind: 'session' }, + visibility: 'visible', + data: { kind, seq, time: seq, ...data }, + } as unknown as ChatConversationViewNode +} +const user = (seq: number, text: string) => + viewNode('user', seq, { content: [{ type: 'text', text }], source: { kind: 'user' } }) +const skill = (seq: number, name: string) => + viewNode('context', seq, { content: [], source: { kind: 'skill-invocation', name, form: 'instructions' } }) +const instructions = (seq: number) => + viewNode('context', seq, { content: [], source: { kind: 'agent-instructions', changes: [] } }) +const assistant = (seq: number, text = 'answer') => viewNode('assistant-step', seq, { text }) + +function storeOf(nodes: readonly ChatConversationViewNode[]): ChatNodeStore & { valuesCalls: number } { + const byKey = new Map(nodes.map(node => [node.key, node])) + const store = { + valuesCalls: 0, + get: (key: string) => byKey.get(key), + values: () => { + store.valuesCalls += 1 + return [...byKey.values()] + }, + source: () => { throw new Error('unused') }, + processSource: () => { throw new Error('unused') }, + } + return store +} + +const names = (node: ChatConversationViewNode | undefined) => + (node?.data as { skillNames?: readonly string[] } | undefined)?.skillNames + +describe('SkillNameProjector', () => { + it('attaches a batch\'s injected skill names on replace and leaves other Nodes untouched', () => { + const projector = new SkillNameProjector() + const answer = assistant(6) + const out = projector.replace([user(2, '/demo go'), instructions(4), skill(5, 'demo'), answer, user(10, '/demo later?')]) + expect(names(out[0])).toEqual(['demo']) + expect(names(out[4])).toBeUndefined() + expect(out[3]).toBe(answer) + }) + + it('an assistant-only apply neither scans the store nor re-emits message Nodes', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), skill(5, 'demo'), assistant(6)]) + const store = storeOf(replaced) + const frame = assistant(6, 'answer grows') + const out = projector.apply([frame], store) + expect(out).toEqual([frame]) + expect(store.valuesCalls).toBe(0) + }) + + it('a late skill injection updates only the direct messages of its batch', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), assistant(6), user(10, 'unrelated')]) + const store = storeOf(replaced) + const injection = skill(3, 'demo') + const out = projector.apply([injection], store) + expect(out.map(node => node.key).sort()).toEqual(['context:3', 'user:2']) + expect(names(out.find(node => node.key === 'user:2'))).toEqual(['demo']) + }) + + it('re-decorates a direct message the assembler re-emits without names', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), skill(5, 'demo'), assistant(6)]) + expect(names(replaced[0])).toEqual(['demo']) + const store = storeOf(replaced) + // A rebuilt message Node carries Definition state only: no skillNames. + const rebuilt = user(2, '/demo go') + const out = projector.apply([rebuilt], store) + expect(out.map(node => node.key)).toEqual(['user:2']) + expect(names(out[0])).toEqual(['demo']) + expect(store.valuesCalls).toBe(0) + }) + + it('a boundary arriving inside a batch splits it and drops the names past it', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), skill(4, 'demo'), user(8, '/demo again')]) + expect(names(replaced[2])).toEqual(['demo']) + const store = storeOf(replaced) + const out = projector.apply([assistant(6)], store) + expect(out.map(node => node.key).sort()).toEqual(['assistant-step:6', 'user:8']) + expect(names(out.find(node => node.key === 'user:8'))).toBeUndefined() + }) +}) diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index be9c1f8b43..e87a9b0483 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -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/ui-commands/README.md -README.md: b27c795eaf9a7954925961bde81872307b9f3d97 -README.zh.md: 65d31d0f390fce0ea341bf3391903539d3e0d0f7 +README.md: 045364d43e27d8be07ce5847af5d02e461d10aa6 +README.zh.md: aa9c5d8b0729202098f9323892da710f54d0f9a9 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index b27c795eaf..045364d43e 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -31,9 +31,9 @@ Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` so A contribution is a client-owned command — a host-name collision fails loud. A decoration adds a bare-invocation popup to an EXISTING host command: the host command keeps its catalog row, its argument claim, and its lifecycle logging, and a decorated name with no host row in the session's directory never fires. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names; prefixes rank first. -### Image-carrying submissions +### Attachment-carrying submissions -When the composer submits with image attachments, only a host command declaring `input.images` proceeds; every other command route throws the localized `imagesUnsupported` refusal, which renders as a transient toast while the draft and images stay in place — a command can never consume the text and strand the images. +When the composer submits with images or generic files, only a host command declaring `input.attachments` proceeds. Every other command route throws the localized `attachmentsUnsupported` refusal, rendered as a transient toast while the draft and attachment cards stay in place. Handler errors preserve the same draft state for retry. ----- diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 65d31d0f39..aa9c5d8b07 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -31,9 +31,9 @@ kind: "package-reference" 贡献项是客户端自有命令——与宿主命令同名会明确报错。装饰为**已存在的**宿主命令添加裸调用弹窗:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。菜单查询按顺序且不区分大小写地模糊匹配命令名的子序列;前缀排名最高。 -### 带图提交 +### 带附件提交 -composer 携带图片附件提交时,只有声明了 `input.images` 的宿主命令继续;其余每条命令路径都会抛出本地化的 `imagesUnsupported` 拒绝,以瞬态 toast 呈现,草稿与图片保持原位——命令绝不消费文本却抛下图片。 +composer 携带图片或通用文件提交时,只有声明了 `input.attachments` 的宿主命令继续。其余命令路径都会抛出本地化的 `attachmentsUnsupported` 拒绝,以瞬态 toast 呈现,草稿与附件卡保持原位。处理器返回错误时保留相同草稿状态供用户重试。 ----- diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index 1d04fe7eb7..384597a9b2 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -9,7 +9,7 @@ export const zh = { 'status.empty': '无选项', 'overlay.aria': '/{command} 选项', 'listbox.aria': '/{command} 匹配项', - 'notice.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片', + 'notice.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件', } satisfies Record /** The command namespace key union. */ @@ -24,5 +24,5 @@ export const en = { 'status.empty': 'No options', 'overlay.aria': '/{command} options', 'listbox.aria': '/{command} matches', - 'notice.imagesUnsupported': '/{command} does not accept image attachments; remove them first', + 'notice.attachmentsUnsupported': '/{command} does not accept attachments; remove them first', } satisfies Record diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index 4a606fe607..ae7d350e46 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -2,8 +2,9 @@ * CommandUiRuntime (`ctx.commandUi`): the '/' command source over the * session-keyed directory, the client-contribution registry, and the * per-session popupSelect controllers. Candidate synthesis merges the host - * catalog with contributions by availability, then fuzzy query/position - * filtering; a host/contribution name collision fails loud. Every execute + * catalog with contributions by availability, then position filtering and + * the `/` menu's shared name ranking (ui-primitives `rankByName`); a + * host/contribution name collision fails loud. Every execute * addresses the session's agent by sessionId — sessions are always * agent-backed. */ @@ -17,9 +18,10 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick, - SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, + SubmitAttachment, SubmitEnvelope, SubmitOutcome, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts' import type { CommandDescriptor } from './directory.ts' @@ -56,69 +58,6 @@ interface LiveState { readonly popups: Map> } -/** One fuzzy match with its stable source position. */ -interface RankedCandidate { - readonly candidate: InputTriggerCandidate - readonly index: number - readonly prefix: boolean - readonly score: number -} - -/** Extra weight for command-name starts and separator boundaries. */ -function boundaryBonus(name: string, index: number): number { - return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0 -} - -/** - * Score the strongest ordered-subsequence alignment in O(name × query). - * Boundary and adjacent matches earn weight; skipped and leading characters - * cost weight. - */ -function fuzzyScore(name: string, query: string): number | undefined { - if (query === '') return 0 - if (query.length > name.length) return undefined - const noMatch = Number.NEGATIVE_INFINITY - let previous = Array(name.length).fill(noMatch) - for (let index = 0; index < name.length; index++) { - if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index - } - for (let queryIndex = 1; queryIndex < query.length; queryIndex++) { - const current = Array(name.length).fill(noMatch) - let bestGapped = noMatch - for (let index = 0; index < name.length; index++) { - const gappedIndex = index - 2 - if (gappedIndex >= 0) { - const prior = previous[gappedIndex] ?? noMatch - if (prior !== noMatch) bestGapped = Math.max(bestGapped, prior + gappedIndex) - } - if (name.charAt(index) !== query.charAt(queryIndex)) continue - const bonus = 1 + boundaryBonus(name, index) - const adjacent = index > 0 ? previous[index - 1] ?? noMatch : noMatch - if (adjacent !== noMatch) current[index] = adjacent + bonus + 4 - if (bestGapped !== noMatch) current[index] = Math.max(current[index] ?? noMatch, bestGapped + bonus + 1 - index) - } - previous = current - } - let best = noMatch - for (const score of previous) best = Math.max(best, score) - return best === noMatch ? undefined : best -} - -/** Case-insensitive fuzzy filtering with stable ordering for equal matches. */ -function fuzzyCandidates(candidates: readonly InputTriggerCandidate[], rawQuery: string): readonly InputTriggerCandidate[] { - const query = rawQuery.toLowerCase() - if (query === '') return candidates - const ranked: RankedCandidate[] = [] - candidates.forEach((candidate, index) => { - const name = candidate.name.toLowerCase() - const score = fuzzyScore(name, query) - if (score !== undefined) ranked.push({ candidate, index, prefix: name.startsWith(query), score }) - }) - ranked.sort((left, right) => - Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index) - return ranked.map(match => match.candidate) -} - /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ export class CommandUiRuntime extends Service implements CommandUiContract { static inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands'] @@ -246,7 +185,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { } } - /** Menu candidates: host catalog + contribution availability, then position filtering and fuzzy name ranking. */ + /** Menu candidates: host catalog + contribution availability, then position filtering and the shared name ranking. */ private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise { const list = await this.directory.ensureReady(session.sessionId, req.signal) const rows: InputTriggerCandidate[] = [] @@ -262,7 +201,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { } rows.push({ name: contribution.name, description: contribution.description }) } - return fuzzyCandidates( + return rankByName( rows.filter(c => req.position === 'leading' || c.hint === undefined), req.query, ) @@ -310,10 +249,10 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * bare host commands act on the bare token only; leadingInput claims * args-tolerant. * - * Envelope policy: an enter submission carrying images resolves only - * through a command declaring image acceptance. Every other command route — + * Envelope policy: an enter submission carrying attachments resolves only + * through a command declaring attachment acceptance. Every other command route — * popup, non-accepting claim, bare detached execute — throws the refusal - * so the machine surfaces one composer notice and the draft and images + * so the machine surfaces one composer notice and the draft and attachments * stay in place; nothing executes and nothing is dropped. */ private async matchEnter( @@ -329,13 +268,13 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const bare = ws === -1 const name = token.slice(1) if (name === '') return undefined - const refuseImages = (): never => { - throw new Error(this.t('notice.imagesUnsupported', { command: name })) + const refuseAttachments = (): never => { + throw new Error(this.t('notice.attachmentsUnsupported', { command: name })) } const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(session)) { if (!bare) return undefined - if (envelope.images > 0) refuseImages() + if (envelope.attachments > 0) refuseAttachments() this.openPopup(name, contribution.ui, session, { via: 'enter', token }) return 'handled' } @@ -347,17 +286,17 @@ export class CommandUiRuntime extends Service implements CommandUiContract { if (bare) { const decoration = this.live.decorations.get(name) if (decoration !== undefined && decoration.available(session)) { - if (envelope.images > 0) refuseImages() + if (envelope.attachments > 0) refuseAttachments() this.openPopup(name, decoration.ui, session, { via: 'enter', token }) return 'handled' } } if (desc.input !== undefined) { - if (envelope.images > 0 && desc.input.images !== true) refuseImages() + if (envelope.attachments > 0 && desc.input.attachments !== true) refuseAttachments() return { claim: this.leadingClaim(desc, session) } } if (!bare) return undefined - if (envelope.images > 0) refuseImages() + if (envelope.attachments > 0) refuseAttachments() this.consumeVia(session.sessionId, { via: 'enter', token }) this.runDetached(desc, session, trimmed) return 'handled' @@ -381,8 +320,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract { return { token, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), - ...(desc.input?.images === true ? { images: true } : {}), - submit: (args, _actx, images) => this.execute(session, token + args, images), + ...(desc.input?.attachments === true ? { attachments: true } : {}), + submit: (args, _actx, attachments) => this.execute(session, token + args, attachments), } } @@ -394,21 +333,21 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * executor durably logged the lifecycle (`command/run`/`command/done`) and * the outcome renders as a persistent flow node — the composer never * echoes it. A handler error result reports an error outcome so the - * composer keeps the submission (draft and images) for correction. + * composer keeps the draft and attachments for correction. * A refused call throws. */ private async execute( session: ClientSessionContext, line: string, - images: readonly SubmitImageAttachment[] = [], + attachments: readonly SubmitAttachment[] = [], ): Promise { - const result = await this.ctx.remote.commands.execute(session.sessionId, line, images) + const result = await this.ctx.remote.commands.execute(session.sessionId, line, attachments) if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`) if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` } this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result) - // An image-carrying submission consumed its images only on handler - // success; an error outcome keeps draft and images in the composer. - if (images.length > 0 && result.value.result.kind === 'error') { + // A submission consumes its attachments only after handler success; an + // error outcome keeps the draft and attachments in the composer. + if (attachments.length > 0 && result.value.result.kind === 'error') { return { kind: 'error', text: result.value.result.text } } return { kind: 'success' } diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index ae8ef7a884..23ce7ae505 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -13,7 +13,7 @@ import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' import { CommandUiRuntime } from '../src/client/service.ts' @@ -64,7 +64,7 @@ async function bench(opts: BenchOptions = {}) { const ctx = new Context() const registered = new Map() const listCalls: Array<{ sessionId: SessionId }> = [] - const executeCalls: Array<{ sessionId: SessionId; line: string; images: readonly SubmitImageAttachment[] }> = [] + const executeCalls: Array<{ sessionId: SessionId; line: string; images: readonly SubmitAttachment[] }> = [] // The service reads the generated commands Remote, which delivers the // carrier's outcome, so a programmed failure answers the error branch. const commandsRemote = { @@ -77,7 +77,7 @@ async function bench(opts: BenchOptions = {}) { return value.commands }) }, - execute: async (sessionId: SessionId, line: string, images: readonly SubmitImageAttachment[] = []) => { + execute: async (sessionId: SessionId, line: string, images: readonly SubmitAttachment[] = []) => { executeCalls.push({ sessionId, line, images }) return await carried(async () => { const fallback = (): Promise => Promise.resolve({ matched: true }) @@ -209,24 +209,15 @@ describe('candidates', () => { expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }]) }) - it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', async () => { + it('ranks rows through the shared name ranker: prefixes first, then alignment, then source order', async () => { const commands: CommandDescriptor[] = [ - { name: 'q-xylophone', description: '' }, - { name: 'qx-long', description: '' }, - { name: 'fabulous', description: '' }, - { name: 'foo-bar', description: '' }, - { name: 'zuv', description: '' }, - { name: 'zu1v', description: '' }, - { name: 'yu1v', description: '' }, - { name: 'zu12v', description: '' }, + { name: 'z_a_b', description: '' }, + { name: 'abc', description: '' }, ] const { source } = await bench({ commands: () => Promise.resolve({ commands }) }) const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name) - await expect(names('QX')).resolves.toEqual(['qx-long', 'q-xylophone']) - await expect(names('fb')).resolves.toEqual(['foo-bar', 'fabulous']) - await expect(names('uv')).resolves.toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v']) + await expect(names('AB')).resolves.toEqual(['abc', 'z_a_b']) await expect(names('zzz')).resolves.toEqual([]) - await expect(names('query-longer-than-every-name')).resolves.toEqual([]) }) it('catalogs are per session: another session pulls its own key', async () => { @@ -288,9 +279,9 @@ describe('decorations (bare-invocation UI on host commands)', () => { command.decorate(goalDecoration()) const scope = mint('s1') await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal, { images: 0 })).toBe('handled') + expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal, { attachments: 0 })).toBe('handled') expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' }) - const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { images: 0 }) + const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { attachments: 0 }) if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim') expect(argued.claim.token).toBe('/goal ') }) @@ -309,7 +300,7 @@ describe('decorations (bare-invocation UI on host commands)', () => { command.decorate(goalDecoration({ name: 'phantom' })) const scope = mint('s1') await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal, { images: 0 })).toBeUndefined() + expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal, { attachments: 0 })).toBeUndefined() expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined() expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) }) @@ -318,7 +309,7 @@ describe('decorations (bare-invocation UI on host commands)', () => { const { command, source, warm, executeCalls } = await bench() command.decorate(goalDecoration({ name: 'plan', available: () => false })) await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 })).toBe('handled') + expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 0 })).toBe('handled') expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }]) }) @@ -431,7 +422,7 @@ describe('matchEnter (enter column)', () => { const { source } = await bench({ commands: () => new Promise((resolve) => { release = resolve }), }) - const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { images: 0 }) + const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { attachments: 0 }) release({ commands: S1_CMDS }) const outcome = await wait if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') @@ -442,14 +433,14 @@ describe('matchEnter (enter column)', () => { const { source } = await bench({ commands: () => Promise.reject(new Error('warmup boom')), }) - await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { images: 0 })).rejects.toThrow('warmup boom') + await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { attachments: 0 })).rejects.toThrow('warmup boom') }) it('leadingInput claims args-tolerant (bare and with trailing text)', async () => { const { source, warm } = await bench() await warm(proj('s1')) for (const line of ['/goal', '/goal refactor the loop']) { - const outcome = await source.matchEnter!(proj('s1'), line, signal(), { images: 0 }) + const outcome = await source.matchEnter!(proj('s1'), line, signal(), { attachments: 0 }) if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') expect(outcome.claim.token).toBe('/goal ') } @@ -464,7 +455,7 @@ describe('matchEnter (enter column)', () => { return true }) await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 0 })).resolves.toBe('handled') + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 0 })).resolves.toBe('handled') expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }]) await Promise.resolve() expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }]) @@ -473,7 +464,7 @@ describe('matchEnter (enter column)', () => { it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => { const { source, warm, executeCalls } = await bench() await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/plan now', signal(), { images: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/plan now', signal(), { attachments: 0 })).resolves.toBeUndefined() expect(executeCalls).toEqual([]) }) @@ -481,18 +472,18 @@ describe('matchEnter (enter column)', () => { const { command, source, mint, listCalls } = await bench() command.register(themeContribution()) const scope = mint('s1') - await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 0 })).resolves.toBe('handled') + await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { attachments: 0 })).resolves.toBe('handled') expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true) expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady - await expect(source.matchEnter!(proj('s1'), '/theme dark', signal(), { images: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/theme dark', signal(), { attachments: 0 })).resolves.toBeUndefined() }) it('unknown name, bare "/", and non-slash lines → undefined', async () => { const { source, warm } = await bench() await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/nope', signal(), { images: 0 })).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), '/', signal(), { images: 0 })).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), 'plain text', signal(), { images: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/nope', signal(), { attachments: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/', signal(), { attachments: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), 'plain text', signal(), { attachments: 0 })).resolves.toBeUndefined() }) }) @@ -500,19 +491,19 @@ describe('matchEnter envelope policy (images)', () => { const signal = () => new AbortController().signal const IMG_CMDS: CommandDescriptor[] = [ ...S1_CMDS, - { name: 'vision', description: 'image-accepting leadingInput', input: { hint: 'describe', images: true } }, + { name: 'vision', description: 'image-accepting leadingInput', input: { hint: 'describe', attachments: true } }, ] - const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } + const png: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' } it('a leadingInput command not declaring acceptance refuses; a declaring one claims with images minted', async () => { const { source, warm } = await bench({ commands: () => Promise.resolve({ commands: IMG_CMDS }) }) await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/goal ship', signal(), { images: 1 })) - .rejects.toThrow('command:notice.imagesUnsupported{"command":"goal"}') - const outcome = await source.matchEnter!(proj('s1'), '/vision what is this', signal(), { images: 1 }) + await expect(source.matchEnter!(proj('s1'), '/goal ship', signal(), { attachments: 1 })) + .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"goal"}') + const outcome = await source.matchEnter!(proj('s1'), '/vision what is this', signal(), { attachments: 1 }) if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') expect(outcome.claim.token).toBe('/vision ') - expect(outcome.claim.images).toBe(true) + expect(outcome.claim.attachments).toBe(true) }) it('bare popup routes refuse images: contribution and decorated host both stay closed', async () => { @@ -521,18 +512,18 @@ describe('matchEnter envelope policy (images)', () => { command.decorate({ name: 'plan', available: () => true, ui: themeUi() }) const scope = mint('s1') await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 1 })) - .rejects.toThrow('command:notice.imagesUnsupported{"command":"theme"}') - await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 2 })) - .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}') + await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { attachments: 1 })) + .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"theme"}') + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 2 })) + .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"plan"}') expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) }) it('bare host detached execute refuses images before any RPC', async () => { const { source, warm, executeCalls } = await bench() await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 1 })) - .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}') + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 1 })) + .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"plan"}') expect(executeCalls).toEqual([]) }) @@ -543,7 +534,7 @@ describe('matchEnter envelope policy (images)', () => { execute: () => Promise.resolve({ matched: true, result }), }) await warm(proj('s1')) - const outcome = await source.matchEnter!(proj('s1'), '/vision x', signal(), { images: 1 }) + const outcome = await source.matchEnter!(proj('s1'), '/vision x', signal(), { attachments: 1 }) if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') // Handler error: the error outcome keeps draft and images in the composer. await expect(outcome.claim.submit('x', new Context(), [png])) @@ -643,7 +634,7 @@ describe('detached admission notices', () => { // Admission miss (matched:false): immediate composer feedback stays. mode = 'miss' - await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 }) + await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 0 }) await flush() expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }]) @@ -722,7 +713,7 @@ describe('popupFor', () => { consumes.push(r) return true }) - await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 }) + await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { attachments: 0 }) const popup = command.popupFor(scope.ctx) await Promise.resolve() await popup.select(0) @@ -733,7 +724,7 @@ describe('popupFor', () => { const { command, source, mint } = await bench() command.register(themeContribution()) const scope = mint('s1') - await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 }) + await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { attachments: 0 }) const popup = command.popupFor(scope.ctx) expect(popup.state.getSnapshot().open).toBe(true) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 0471a9e271..16c22d55b6 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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/ui-conversation/README.md -README.md: dabe5afda6554a62f5ca038952e5a02e5a2f712e -README.zh.md: c38ea211c5d8d0ea384cfa39eba2608571166c89 +README.md: 9b81858adee21d3b8d8a158fe5ffa239b6d4ffe3 +README.zh.md: 40c02c469032e98191cbaa2a0203cc1f5a5a889e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index dabe5afda6..9b81858ade 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -42,11 +42,11 @@ View selection is deterministic: a registered persisted selection wins, otherwis The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated. -The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local image previews or durable image parts as thumbnails, while an edit exposes the literal sent text. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. -Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint and encodes images through the browser's native `FileReader` data-URL path. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. -While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). +While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Plan mode and active goals do not change attachment intake. Continuable subagents keep separate Send and Stop actions but expose no paperclip, paste, or drop intake ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). ## Temporary composer entries diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c38ea211c5..40c02c4690 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -42,11 +42,11 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并把本地图片预览或持久化图片部分显示为缩略图,编辑态则展示字面发送文本。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 -默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`);Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。prompt 复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation service 在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 -普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 +普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。Plan Mode 与 active goal 不改变附件入口。continuable 子代理保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 ## 临时 composer entry diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 89b8706b37..e9ee73095d 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -29,6 +29,7 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-file-upload", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", @@ -64,6 +65,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 4c8527c1ee..9cc8f95758 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,5 +1,6 @@ /** Registers the target-neutral Conversation assembly, shell, input, and docks. */ import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -13,7 +14,7 @@ import { UiConversation } from './conversation/assembly.ts' import type { ViewTab } from './contract/views.ts' import type { ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected, - ConversationSessionInjected, + ConversationSessionInjected, DraftFileUploads, } from './contract/slots.ts' import type { InputNotice } from './contract/input.ts' import { createConversationStore, readConversationViewPreference } from './stores.ts' @@ -43,9 +44,20 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Services required by the Conversation plugin. */ export const inject = [ - 'slots', 'sessions', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope', + 'slots', 'sessions', 'fileUpload', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope', ] +/** Conversation runtime configuration. */ +export interface Config { + /** Maximum generic-file uploads allowed to run concurrently in browser Workers. */ + maxConcurrentFileUploads?: number +} + +/** Validated Conversation runtime configuration. */ +export const Config: z = z.object({ + maxConcurrentFileUploads: z.natural().min(1).default(2), +}) + // Stable no-session sources keep the renderer's observable-hook cache and // hook order unchanged across current-Session transitions. const ABSENT_NOTICES = { @@ -65,6 +77,11 @@ const ABSENT_MENU_LAUNCHER = { getSnapshot: (): string | null => null, subscribe: () => () => {}, } +const EMPTY_FILE_UPLOADS: DraftFileUploads = {} +const ABSENT_FILE_UPLOADS = { + getSnapshot: () => EMPTY_FILE_UPLOADS, + subscribe: () => () => {}, +} interface WorkspaceNavigation { connectWorkspace( @@ -94,9 +111,11 @@ function concreteConversation(ctx: Context): ConversationController { * Mount the Conversation core and target-neutral presentation. * @param ctx - Client root context. */ -export function apply(ctx: Context): void { +export function apply(ctx: Context, config: Config = Config({})): void { const sessions = ctx.sessions const slots = ctx.slots + // Schemastery's field default is materialized before Cordis calls apply. + const maxConcurrentFileUploads = config.maxConcurrentFileUploads as number const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation const uiConversation = new UiConversation(ctx, sessions) @@ -216,15 +235,19 @@ export function apply(ctx: Context): void { if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) const draft = from.snapshot.draft - const imageIds = from.snapshot.imageIds + const attachmentIds = from.snapshot.attachmentIds const next = inputHub.shell(nextId) - if (imageIds.length === 0 || next.addImages(imageIds)) { + if (attachmentIds.length === 0 || next.addAttachments(attachmentIds)) { + if (sessions.binding(nextId) === undefined) { + throw new Error(`ui-conversation: session "${nextId}" resolved no binding`) + } + concreteConversation(ctx).rebindDraftFiles(nextId, attachmentIds) if (draft !== '') { next.setDraft(draft) from.setDraft('') } - if (imageIds.length > 0) { - for (const id of imageIds) from.removeImage(id) + if (attachmentIds.length > 0) { + for (const id of attachmentIds) from.removeAttachment(id) } } } @@ -284,15 +307,17 @@ export function apply(ctx: Context): void { if (sessionId === undefined) { return { keyboard: undefined, - addImages: undefined, - removeImage: undefined, - draftImages: undefined, + addFiles: undefined, + removeAttachment: undefined, + resolveDraftAttachments: undefined, + retryFileUpload: undefined, resolveSubmitMode: (running, gesture, steeringAvailable) => submissionPolicy.resolve(running, gesture, steeringAvailable), toggleCommandMenu: undefined, stop: undefined, command: undefined, hooks: { + fileUploads: ABSENT_FILE_UPLOADS, notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER, @@ -304,11 +329,12 @@ export function apply(ctx: Context): void { const inputTriggers = inputHub.inputTriggers(sessionId) return { keyboard: shell, - addImages: (files) => { + addFiles: (files) => { + if (sessions.binding(sessionId) === undefined) return t('file.sessionUnavailable') try { - const images = conversation.createDraftImages(files) - if (!shell.addImages(images.map(image => image.id))) { - conversation.releaseDraftImages(images) + const drafts = conversation.createDrafts(sessionId, files) + if (!shell.addAttachments(drafts.map(draft => draft.id))) { + conversation.releaseDraftAttachments(drafts) } return null } catch (error: unknown) { @@ -316,11 +342,13 @@ export function apply(ctx: Context): void { return error instanceof Error ? error.message : String(error) } }, - removeImage: (id) => { - conversation.releaseDraftImage(id) - shell.removeImage(id) + removeAttachment: (id) => { + if (shell.removeAttachment(id)) conversation.releaseDraftAttachment(id) + }, + resolveDraftAttachments: ids => conversation.resolveDraftAttachments(ids), + retryFileUpload: (id) => { + if (sessions.binding(sessionId) !== undefined) conversation.retryFileUpload(sessionId, id) }, - draftImages: ids => conversation.draftImages(ids), resolveSubmitMode: (running, gesture, steeringAvailable) => submissionPolicy.resolve(running, gesture, steeringAvailable), toggleCommandMenu: inputTriggers === undefined @@ -348,6 +376,7 @@ export function apply(ctx: Context): void { return result.ok && result.value.matched }, hooks: { + fileUploads: conversation.fileUploads, notices: shell.notices, lexicon: shell.lexicon, menuLauncher: inputTriggers?.launcher ?? ABSENT_MENU_LAUNCHER, @@ -363,7 +392,11 @@ export function apply(ctx: Context): void { yield registerComposerBar() }) - ctx.plugin(ConversationController, { input: inputHub, blocks: composerBlocks }) + ctx.plugin(ConversationController, { + input: inputHub, + blocks: composerBlocks, + maxConcurrentFileUploads, + }) ctx.plugin(todoDockEntry) ctx.plugin(queueDockEntry) } diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index d6a0741200..9d65d84f1f 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -20,11 +20,19 @@ export interface TokenSpan { readonly draftRev: number } -/** Base64 image payload passed to a claimed command submission. */ -export interface SubmitImageAttachment { - readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' - readonly data: string - readonly name?: string +/** Attachment payload passed to a claimed command submission. */ +export type SubmitAttachment = + | { + readonly type: 'image' + readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + readonly data: string + readonly name?: string + } + | { readonly type: 'file'; readonly receiptId: string } + +/** Command serialization result for one ordered attachment draft. */ +export interface DraftAttachmentSerializationResult { + readonly attachments: readonly SubmitAttachment[] } /** Settled result of a command or default composer submission. */ @@ -37,15 +45,15 @@ export interface SubmitOutcome { export interface CommandClaim { readonly token: string readonly hint?: string - readonly images?: boolean + readonly attachments?: boolean /** * Submit the claimed command. * @param args - command text after the claimed token. * @param actx - current Session scope. - * @param images - serialized draft images accepted by the claim. + * @param attachments - serialized draft attachments accepted by the claim. * @returns command settlement. */ - submit(args: string, actx: Context, images: readonly SubmitImageAttachment[]): Promise + submit(args: string, actx: Context, attachments: readonly SubmitAttachment[]): Promise } /** Structured reference inserted by an input-trigger source. */ @@ -127,7 +135,7 @@ export interface InputTriggerController { adjudicate( line: string, signal: AbortSignal, - envelope: { readonly images: number }, + envelope: { readonly attachments: number }, ): Promise /** @param source - source name. @param hit - synthetic trigger hit. */ toggleSource(source: string, hit: InputTriggerHit): void @@ -162,7 +170,7 @@ declare module '@deepseek-ai/cordis' { } } -/** Browser-runtime identity of one unsent image draft. */ +/** Browser-runtime identity of one unsent attachment draft. */ export type DraftAttachmentId = Branded<'DraftAttachmentId'> /** @@ -181,12 +189,12 @@ export interface InputTarget { export interface SessionInput extends InputTarget { /** Replace the whole draft (persisted-draft seed and programmatic writes). */ setDraft(text: string): void - /** Append ordered browser-owned image ids; busy admission phases refuse. */ - addImages(ids: readonly DraftAttachmentId[]): boolean - /** Remove one browser-owned image id; busy admission phases refuse. */ - removeImage(id: DraftAttachmentId): void + /** Append ordered browser-owned attachment ids; busy admission phases refuse. */ + addAttachments(ids: readonly DraftAttachmentId[]): boolean + /** Remove one browser-owned attachment id; busy admission phases refuse. @returns whether the id was removed. */ + removeAttachment(id: DraftAttachmentId): boolean /** Drop ids whose browser-owned objects no longer exist. */ - pruneImages(ids: readonly DraftAttachmentId[]): void + pruneAttachments(ids: readonly DraftAttachmentId[]): void /** * THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. * @param mode - delivery intent retained through asynchronous adjudication and serialization. @@ -221,12 +229,12 @@ export interface SessionInputResolver { export interface InputActions { /** Replace the whole draft (persisted-draft seed and programmatic writes). */ setDraft(text: string): void - /** Append ordered browser-owned image ids; busy admission phases refuse. */ - addImages(ids: readonly DraftAttachmentId[]): boolean - /** Remove one browser-owned image id; busy admission phases refuse. */ - removeImage(id: DraftAttachmentId): void + /** Append ordered browser-owned attachment ids; busy admission phases refuse. */ + addAttachments(ids: readonly DraftAttachmentId[]): boolean + /** Remove one browser-owned attachment id; busy admission phases refuse. */ + removeAttachment(id: DraftAttachmentId): void /** Drop ids whose browser-owned objects no longer exist. */ - pruneImages(ids: readonly DraftAttachmentId[]): void + pruneAttachments(ids: readonly DraftAttachmentId[]): void /** Enter submission (adjudication / claim transaction / default sink inside). */ submit(): void } @@ -321,13 +329,13 @@ export interface Occurrence { export interface InputState { /** Clipboard-text projection of the editor document (chips expanded to their clipboard form). */ readonly draft: string - /** Ordered runtime-only image ids; bytes and URLs stay in ConversationController. */ - readonly imageIds: readonly DraftAttachmentId[] + /** Ordered runtime-only attachment ids; browser objects stay in ConversationController. */ + readonly attachmentIds: readonly DraftAttachmentId[] /** Monotonic editor revision (span CAS compares against this). */ readonly draftRev: number readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ - readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean } + readonly claim?: { readonly token: string; readonly hint?: string; readonly attachments?: boolean } /** Reference occurrence view of the editor's chips, sorted by offset. */ readonly occurrences: readonly Occurrence[] /** Read-only transient inbox projection from Session control, including pending steering. */ @@ -369,7 +377,7 @@ export type InputEvent = | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly draft: string; readonly outcome?: SubmitOutcome; readonly message?: string } /** Settlement of one optimistic default send, independent of the frozen command slot. */ | { readonly type: 'sink-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } - /** Commit an image-only send whose empty draft did not need an attempt. */ + /** Commit an attachment-only send whose empty draft did not need an attempt. */ | { readonly type: 'send-committed' } | { readonly type: 'release' } @@ -392,7 +400,7 @@ export type InputEffect = * Clear the committed draft in the editor and cut undo history. A string * snapshot keeps a pure suffix typed during the Host round-trip (content * appended after the sent snapshot survives; interleaved edits cannot be - * separated and clear whole); null clears unconditionally (image-only + * separated and clear whole); null clears unconditionally (attachment-only * sends have no draft to retain). */ | { readonly type: 'commit-draft'; readonly retainSuffixOf: string | null } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 5107c319bf..272d84b8e3 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,7 +1,8 @@ /** Target-neutral Conversation slot declarations and composed component props. */ import type { ReactNode, RefObject } from 'react' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { MaybeSnapshotSelectorHook, ObservableSnapshot, SnapshotSelectorHook, @@ -22,8 +23,11 @@ import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submissi import type { ConversationSnapshot } from './snapshot.ts' import type { ViewTab } from './views.ts' -/** Browser-owned image that has not crossed the durable Host boundary. */ -export interface ComposerAttachment { +/** Browser-owned draft attachment that has not crossed the durable Host boundary. */ +export type ComposerAttachment = ComposerImageAttachment | ComposerFileAttachment + +/** Browser-owned image, base64-encoded into the prompt at send time. */ +export interface ComposerImageAttachment { kind: 'image' id: DraftAttachmentId file: File @@ -34,16 +38,36 @@ export interface ComposerAttachment { height?: number } +/** Browser-owned generic file whose bytes upload to the Host as soon as it is picked. */ +export interface ComposerFileAttachment { + kind: 'file' + id: DraftAttachmentId + file: File +} + +/** Upload lifecycle of one picked file draft (files upload on pick, not on send). */ +export type DraftFileUpload = + | { readonly status: 'uploading'; readonly loaded: number; readonly total?: number } + | { readonly status: 'ready'; readonly receiptId: FileUploadReceiptId; readonly file: FileAttachmentRef } + | { readonly status: 'error'; readonly message: string } + +/** Per-draft upload states keyed by draft attachment id. */ +export type DraftFileUploads = Readonly> + /** Input state handed to the optional attachment presentation plugin. */ export interface ComposerAttachmentsOwnerProps { - /** Browser-owned draft images in input order. */ + /** Browser-owned draft attachments in input order. */ attachments: readonly ComposerAttachment[] - /** Whether a document-level file drop may add images now. */ + /** Whether a document-level file drop may add attachments now. */ canAcceptDrop: boolean /** Add one dropped batch through the composer's validation path. */ - onAddImages: (files: readonly File[]) => void - /** Remove one draft image through the Conversation service. */ - onRemoveImage: (id: DraftAttachmentId) => void + onAddFiles: (files: readonly File[]) => void + /** Remove one draft attachment through the Conversation service. */ + onRemoveAttachment: (id: DraftAttachmentId) => void + /** Current per-draft upload states for file-kind attachments. */ + uploads: DraftFileUploads + /** Restart one failed file upload. */ + onRetryFile: (id: DraftAttachmentId) => void /** Display-ready limits for the drop invitation. */ dropLimits?: { readonly count: number; readonly size: string } | undefined } @@ -79,6 +103,8 @@ export interface MessageImagesOwnerProps { loadImage: MessageImageLoader /** Horizontal placement inside the owning record. */ align: 'start' | 'end' + /** Force every image into the compact message-attachment tile size. */ + compact?: boolean } /** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */ @@ -135,7 +161,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.input.right': { kind: 'list'; scope: 'session' } /** Resident composer body, including the no-Session inert state. */ 'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps } - /** Optional draft-image rail and drop target. */ + /** Optional draft-attachment rail and drop target. */ 'conversation.input.attachments': { kind: 'single' scope: 'session-maybe' @@ -260,9 +286,11 @@ export interface ComposerBarOwnerProps { /** Package-private operations injected into the resident composer bar. */ export interface ComposerBarInjected { keyboard: ComposerKeyboard | undefined - addImages: ((files: readonly File[]) => string | null) | undefined - removeImage: ((id: DraftAttachmentId) => void) | undefined - draftImages: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined + addFiles: ((files: readonly File[]) => string | null) | undefined + removeAttachment: ((id: DraftAttachmentId) => void) | undefined + resolveDraftAttachments: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined + /** Restart one failed file upload; absent without a session. */ + retryFileUpload: ((id: DraftAttachmentId) => void) | undefined resolveSubmitMode: ( running: boolean, gesture: ComposerSubmitGesture, @@ -272,6 +300,8 @@ export interface ComposerBarInjected { stop: (() => void) | undefined command: ((line: string) => Promise) | undefined hooks: { + /** Live per-draft upload states for file-kind drafts. */ + fileUploads: ObservableSnapshot notices: ObservableSnapshot lexicon: ObservableSnapshot> menuLauncher: ObservableSnapshot @@ -350,7 +380,7 @@ export type ConversationSessionHeaderSlotProps = & InjectFace & PropsLocale<'conversation'> -/** Full props of the draft-image attachment renderer. */ +/** Full props of the draft-attachment renderer. */ export type ComposerAttachmentsProps = PropsRuntime<'conversation.input.attachments'> & PropsLocale<'conversation'> diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts index a783d4f840..feca691412 100644 --- a/packages/client/ui-conversation/src/client/image-labels.ts +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -32,6 +32,9 @@ export function attachmentErrorText( ): string { switch (reason) { case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported') + // A prompt cited a file the Host has no staged upload for (expired + // process, foreign id): solvable by re-adding the file. + case 'FILE_NOT_STAGED': return t('file.notStaged') case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels') case 'IMAGE_DIMENSION_TOO_LARGE': if (limits !== undefined) return t('image.dimensionTooLarge', { size: limits.maxImageDimension }) diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 7b23cfa5bd..cad5ccfb44 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -1,5 +1,6 @@ /** Browser Conversation assemble core, React adapter, shell, and input plugin. */ -export { apply, inject } from './apply.ts' +export { apply, Config, inject } from './apply.ts' +export type { Config as ConversationConfig } from './apply.ts' export { UiConversation } from './conversation/assembly.ts' export type { ConversationBinding } from './conversation/assembly.ts' export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' @@ -48,6 +49,7 @@ export { ConversationViewRegistry } from './conversation/view-registry.ts' export type { ConversationKey } from './locales.ts' export type { ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps, + ComposerFileAttachment, ComposerImageAttachment, DraftFileUpload, DraftFileUploads, ComposerBarInjected, ComposerBarOwnerProps, ComposerBarProps, ComposerChainProps, ConversationHeaderActionOwnerProps, ConversationHeaderLineageOwnerProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionHeaderSlotProps, @@ -60,7 +62,7 @@ export type { export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, InputActions, InputState, InsertReferenceRequest, InsertTextRequest, - PickOutcome, ReferenceInsert, SessionInput, SessionInputResolver, SubmitImageAttachment, + PickOutcome, ReferenceInsert, SessionInput, SessionInputResolver, SubmitAttachment, SubmitOutcome, TokenSpan, } from './contract/input.ts' export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts' diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index e20a006dac..e221abf64b 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -23,12 +23,19 @@ export interface TextRefRange { /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g +/** + * What may follow a `/name` token: whitespace or the draft end, the boundary + * the host skill gesture (`dsh-tool-skill`) requires, so `/nfs-hg/xxx`, + * `/plan.md`, and `/plan。` are prose, never a reference. + */ +const SLASH_TOKEN_END_RE = /^(?:\s|$)/ /** * Scan the draft for plain-text reference tokens against the hot lexicons. * Word-boundary discipline: the trigger must sit at the draft * start or after whitespace ('x/name' never matches); the name must be an - * exact lexicon member. + * exact lexicon member; a `/name` token must end at whitespace or the draft + * end ('/name/x' is a path, '/name。' is prose). * @param draft - draft text. * @param lexicon - per-trigger name lists (a missing trigger scans nothing). * @returns matched ranges in draft order. @@ -44,6 +51,7 @@ export function scanTextRefs( while ((m = TEXT_REF_RE.exec(draft)) !== null) { const trigger = m[2] as '/' | '@' const name = m[3] ?? '' + if (trigger === '/' && !SLASH_TOKEN_END_RE.test(draft.slice(m.index + m[0].length))) continue if (lexicon.get(trigger)?.includes(name)) { const start = m.index + (m[1]?.length ?? 0) out.push({ start, end: start + 1 + name.length, trigger }) diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index e2fb81f09b..1cacd83812 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -23,7 +23,7 @@ import { mergeRegister } from '@lexical/utils' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome, - Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment, + Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome, TokenSpan, } from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -63,17 +63,17 @@ export interface SessionInputDeps { /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ defaultSink( text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: InputSubmitMode, signal: AbortSignal, ): Promise - /** Command-plane image plumbing (the hub owns the conversation face and the copy). */ - commandImages: { + /** Command-plane attachment plumbing (the hub owns the conversation face and the copy). */ + commandAttachments: { /** Resolve ordered draft ids to wire payloads without sending them; rejects when an id no longer resolves. */ - serialize(ids: readonly DraftAttachmentId[]): Promise - /** Free consumed draft images after a successful command submit. */ + serialize(ids: readonly DraftAttachmentId[]): Promise + /** Free consumed draft attachments after a successful command submit. */ release(ids: readonly DraftAttachmentId[]): void - /** Localized composer notice for a claimed command that does not accept images. */ + /** Localized composer notice for a claimed command that does not accept attachments. */ unsupportedNotice(token: string): string } } @@ -117,7 +117,7 @@ const HISTORY_MERGE_DELAY_MS = 1000 interface DetachedDraft { readonly draft: string readonly occurrences: readonly Occurrence[] - readonly imageIds: readonly DraftAttachmentId[] + readonly attachmentIds: readonly DraftAttachmentId[] } /** @@ -135,9 +135,9 @@ export class SessionInputShell implements SessionInput { /** The public provide-channel action face (one stable identity per session). */ readonly actions: InputActions = { setDraft: (text) => { this.setDraft(text) }, - addImages: ids => this.addImages(ids), - removeImage: (id) => { this.removeImage(id) }, - pruneImages: (ids) => { this.pruneImages(ids) }, + addAttachments: ids => this.addAttachments(ids), + removeAttachment: (id) => { this.removeAttachment(id) }, + pruneAttachments: (ids) => { this.pruneAttachments(ids) }, submit: () => { this.submit('queue') }, } @@ -150,24 +150,24 @@ export class SessionInputShell implements SessionInput { private readonly unregister: () => void private noticeSeq = 0 private lastMirroredDraft = '' - private imageIds: readonly DraftAttachmentId[] = [] + private attachmentIds: readonly DraftAttachmentId[] = [] private disposed = false /** Draft persistence mirror (Conversation store write; receives the clipboard projection). */ private mirrorFn: ((text: string) => void) | undefined /** Live lexicon subscription disposer; undefined until the controller resolves. */ private lexiconOff: (() => void) | undefined - /** Default sends retained until admission settles or scope disposal releases their images. */ + /** Default sends retained until admission settles or scope disposal releases their attachments. */ private readonly detachedDrafts = new Map() /** Failed default sends waiting to be restored together in submission order. */ private readonly failedDetached = new Map() /** Revision of the last automatic failure restoration. */ private failedRestoreRev: number | undefined private restoringFailures = false - private imageFlightSeq = 0 - /** Image-only sends retained until admission settles or scope disposal releases their images. */ - private readonly imageFlights = new Map() constructor(private readonly deps: SessionInputDeps) { @@ -281,37 +281,38 @@ export class SessionInputShell implements SessionInput { }, { discrete: true, tag: HISTORY_MERGE_TAG }) } - /** Append ordered image ids unless an admission transaction is locked. */ - addImages(ids: readonly DraftAttachmentId[]): boolean { + /** Append ordered attachment ids unless an admission transaction is locked. */ + addAttachments(ids: readonly DraftAttachmentId[]): boolean { if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false if (ids.length === 0) return true - this.imageIds = [...this.imageIds, ...ids] + this.attachmentIds = [...this.attachmentIds, ...ids] this.publish() return true } /** - * Remove one image id from this draft. Busy admission phases refuse, like - * {@link addImages}: a removal landing while a command submit serializes + * Remove one attachment id from this draft. Busy admission phases refuse, like + * {@link addAttachments}: a removal landing while a command submit serializes * would otherwise vanish from the rail yet still ride the in-flight send. */ - removeImage(id: DraftAttachmentId): void { - if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return - const next = this.imageIds.filter(candidate => candidate !== id) - if (next.length === this.imageIds.length) return - this.imageIds = next + removeAttachment(id: DraftAttachmentId): boolean { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false + const next = this.attachmentIds.filter(candidate => candidate !== id) + if (next.length === this.attachmentIds.length) return false + this.attachmentIds = next this.publish() + return true } /** - * Keep only image ids that still resolve in the browser attachment registry. + * Keep only ids that still resolve in the browser attachment registry. * @param available - live registry ids. */ - pruneImages(available: readonly DraftAttachmentId[]): void { + pruneAttachments(available: readonly DraftAttachmentId[]): void { const keep = new Set(available) - const next = this.imageIds.filter(id => keep.has(id)) - if (next.length === this.imageIds.length) return - this.imageIds = next + const next = this.attachmentIds.filter(id => keep.has(id)) + if (next.length === this.attachmentIds.length) return + this.attachmentIds = next this.publish() } @@ -319,11 +320,11 @@ export class SessionInputShell implements SessionInput { * Clear the draft as a successful-send commit: the editor empties (no undo * unit) and the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent * content (the command path gets the same discipline from submit-settled). - * @param imageIds - admitted image ids to remove from this draft. + * @param attachmentIds - admitted attachment ids to remove from this draft. */ - commitSend(imageIds: readonly DraftAttachmentId[]): void { - const submitted = new Set(imageIds) - this.imageIds = this.imageIds.filter(id => !submitted.has(id)) + commitSend(attachmentIds: readonly DraftAttachmentId[]): void { + const submitted = new Set(attachmentIds) + this.attachmentIds = this.attachmentIds.filter(id => !submitted.has(id)) this.dispatchRun(({ type: 'send-committed' })) } @@ -358,34 +359,34 @@ export class SessionInputShell implements SessionInput { * dismisses and the menu tracks frozen. */ submit(mode: InputSubmitMode = 'queue'): void { - if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { + if (this.snapshot.draft.trim() === '' && this.attachmentIds.length > 0) { if (this.snapshot.phase === 'plain') { - const imageIds = [...this.imageIds] + const attachmentIds = [...this.attachmentIds] const controller = new AbortController() - this.imageFlightSeq += 1 - const flight = this.imageFlightSeq - this.imageFlights.set(flight, { controller, imageIds }) - this.commitSend(imageIds) - void this.deps.defaultSink('', imageIds, mode, controller.signal).then((outcome) => { - if (this.disposed || !this.imageFlights.delete(flight)) return + this.attachmentFlightSeq += 1 + const flight = this.attachmentFlightSeq + this.attachmentFlights.set(flight, { controller, attachmentIds }) + this.commitSend(attachmentIds) + void this.deps.defaultSink('', attachmentIds, mode, controller.signal).then((outcome) => { + if (this.disposed || !this.attachmentFlights.delete(flight)) return if (outcome.kind === 'success') return - this.restoreImages(imageIds) + this.restoreAttachments(attachmentIds) if (outcome.text !== undefined) this.notify('error', outcome.text) }, (error: unknown) => { - if (this.disposed || !this.imageFlights.delete(flight)) return - this.restoreImages(imageIds) + if (this.disposed || !this.attachmentFlights.delete(flight)) return + this.restoreAttachments(attachmentIds) this.notify('error', error instanceof Error ? error.message : String(error)) }) } return } - // Claimed pre-gate: a claim that does not declare image acceptance never - // submits while images are attached — one notice, everything retained. + // Claimed pre-gate: a claim that does not declare attachment acceptance never + // submits while attachments are present — one notice, everything retained. // Enter-time adjudication applies the same policy for unclaimed lines // inside the command source itself. const before = this.snapshot - if (before.phase === 'claimed' && this.imageIds.length > 0 && before.claim?.images !== true) { - this.notify('error', this.deps.commandImages.unsupportedNotice(before.claim?.token ?? before.draft)) + if (before.phase === 'claimed' && this.attachmentIds.length > 0 && before.claim?.attachments !== true) { + this.notify('error', this.deps.commandAttachments.unsupportedNotice(before.claim?.token ?? before.draft)) return } this.dispatchRun(({ type: 'enter', mode, draft: this.projection.clipboardText })) @@ -562,18 +563,18 @@ export class SessionInputShell implements SessionInput { // ---- wiring-layer extras (not on the frozen SessionInput face) ---- /** - * Teardown the shell and return every browser-owned image still retained by + * Teardown the shell and return every browser-owned attachment still retained by * the draft or an unsettled default send. - * @returns image ids the scope disposer must release. + * @returns attachment ids the scope disposer must release. */ dispose(): readonly DraftAttachmentId[] { if (this.disposed) return [] - const retained = new Set(this.imageIds) + const retained = new Set(this.attachmentIds) for (const record of this.detachedDrafts.values()) { - for (const imageId of record.imageIds) retained.add(imageId) + for (const attachmentId of record.attachmentIds) retained.add(attachmentId) } - for (const flight of this.imageFlights.values()) { - for (const imageId of flight.imageIds) retained.add(imageId) + for (const flight of this.attachmentFlights.values()) { + for (const attachmentId of flight.attachmentIds) retained.add(attachmentId) flight.controller.abort() } this.disposed = true @@ -582,7 +583,7 @@ export class SessionInputShell implements SessionInput { this.editor.setRootElement(null) this.detachedDrafts.clear() this.failedDetached.clear() - this.imageFlights.clear() + this.attachmentFlights.clear() return [...retained] } @@ -687,17 +688,17 @@ export class SessionInputShell implements SessionInput { draft: string, mode: InputSubmitMode, ): void { - const imageIds = [...this.imageIds] - this.imageIds = [] + const attachmentIds = [...this.attachmentIds] + this.attachmentIds = [] const occurrences = this.projection.occurrences - const record = { draft, occurrences, imageIds } + const record = { draft, occurrences, attachmentIds } this.detachedDrafts.set(attempt.seq, record) if (this.failedRestoreRev === this.rev) { this.failedDetached.clear() this.failedRestoreRev = undefined } if (occurrences.length === 0) { - this.settleSink(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal)) + this.settleSink(attempt, this.deps.defaultSink(draft.trim(), attachmentIds, mode, attempt.signal)) return } const inputTriggers = this.deps.inputTriggers?.() @@ -721,7 +722,7 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + part.length } out += draft.slice(cursor) - this.settleSink(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal)) + this.settleSink(attempt, this.deps.defaultSink(out.trim(), attachmentIds, mode, attempt.signal)) }, (error: unknown) => { if (this.dead(attempt)) return @@ -758,7 +759,7 @@ export class SessionInputShell implements SessionInput { const record = this.detachedDrafts.get(attempt.seq) if (record === undefined) return this.detachedDrafts.delete(attempt.seq) - this.restoreImages(record.imageIds) + this.restoreAttachments(record.attachmentIds) this.failedDetached.set(attempt.seq, record) if (this.projection.clipboardText === '' || this.failedRestoreRev === this.rev) { this.restoreFailedDrafts() @@ -821,13 +822,13 @@ export class SessionInputShell implements SessionInput { } } - /** Return failed-send images to the head of the rail (ids still resolve — release happens only after success). */ - private restoreImages(imageIds: readonly DraftAttachmentId[]): void { - if (imageIds.length === 0) return - const current = new Set(this.imageIds) - const restored = imageIds.filter(id => !current.has(id)) + /** Return failed-send attachments to the head of the rail; release happens only after success. */ + private restoreAttachments(attachmentIds: readonly DraftAttachmentId[]): void { + if (attachmentIds.length === 0) return + const current = new Set(this.attachmentIds) + const restored = attachmentIds.filter(id => !current.has(id)) if (restored.length === 0) return - this.imageIds = [...restored, ...this.imageIds] + this.attachmentIds = [...restored, ...this.attachmentIds] this.publish() } @@ -839,7 +840,7 @@ export class SessionInputShell implements SessionInput { this.dispatchRun(({ type: 'adjudicated', attempt, outcome: undefined })) return } - inputTriggers.adjudicate(draft.trim(), attempt.signal, { images: this.imageIds.length }).then( + inputTriggers.adjudicate(draft.trim(), attempt.signal, { attachments: this.attachmentIds.length }).then( (outcome: PickOutcome) => { if (this.dead(attempt)) return this.dispatchRun(({ type: 'adjudicated', attempt, outcome })) @@ -855,27 +856,27 @@ export class SessionInputShell implements SessionInput { /** * The submit transaction: claim.submit against the session scope; ok maps * from the outcome kind. An accepting claim receives the serialized draft - * images, which are cleared and released only on a success outcome; a - * failure (serialize, transport, or handler error) keeps draft and images + * attachments, which are cleared and released only on a success outcome; a + * failure (serialize, transport, or handler error) keeps draft and attachments * for correction. */ private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void { - const imageIds = claim.images === true ? [...this.imageIds] : [] + const attachmentIds = claim.attachments === true ? [...this.attachmentIds] : [] Promise.resolve() .then(async () => { - const images = imageIds.length > 0 ? await this.deps.commandImages.serialize(imageIds) : [] + const attachments = attachmentIds.length > 0 ? await this.deps.commandAttachments.serialize(attachmentIds) : [] // Serialization may outlive the attempt (large files, session // teardown); a dead attempt must not reach the Host executor. if (this.dead(attempt)) return undefined - return claim.submit(args, this.deps.actx, images) + return claim.submit(args, this.deps.actx, attachments) }) .then( (outcome) => { if (outcome === undefined || this.dead(attempt)) return - if (outcome.kind === 'success' && imageIds.length > 0) { - const submitted = new Set(imageIds) - this.imageIds = this.imageIds.filter(id => !submitted.has(id)) - this.deps.commandImages.release(imageIds) + if (outcome.kind === 'success' && attachmentIds.length > 0) { + const submitted = new Set(attachmentIds) + this.attachmentIds = this.attachmentIds.filter(id => !submitted.has(id)) + this.deps.commandAttachments.release(attachmentIds) } this.dispatchRun(({ type: 'submit-settled', attempt, ok: outcome.kind === 'success', @@ -903,7 +904,7 @@ export class SessionInputShell implements SessionInput { const core = this.core.state return { draft: this.projection.clipboardText, - imageIds: this.imageIds, + attachmentIds: this.attachmentIds, draftRev: this.rev, phase: core.phase, ...(core.claim !== undefined ? { claim: core.claim } : {}), diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 6c7132425e..00730b15f7 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -15,8 +15,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { queueReadFaceOf } from './queue-store.ts' import type { - ComposerKeyboard, DraftAttachmentId, InputTriggerController, SessionInputResolver, SessionInput, - SubmitImageAttachment, SubmitOutcome, + ComposerKeyboard, DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController, + SessionInputResolver, SessionInput, SubmitOutcome, } from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { PopupDismissFace } from './facade.ts' @@ -38,12 +38,12 @@ interface ConversationAttachmentFace { sendSession( session: SessionFace, text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: InputSubmitMode, signal?: AbortSignal, ): Promise - serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise - releaseDraftImage(id: DraftAttachmentId): void + serializeDraftAttachments(attachmentIds: readonly DraftAttachmentId[]): Promise + releaseDraftAttachment(id: DraftAttachmentId): void } /** Session-addressed input facade registry (SessionInputResolver face + composer-layer extras). */ @@ -88,19 +88,22 @@ export class InputHub implements SessionInputResolver { inputTriggers: () => this.controller(actx), popup: () => this.popup(actx), queue: queueReadFaceOf(session), - defaultSink: (text, imageIds, mode, signal) => this.sink(session, text, imageIds, mode, signal), + defaultSink: (text, attachmentIds, mode, signal) => this.sink(session, text, attachmentIds, mode, signal), steerQueue: () => { void this.steerQueue(session, shell) }, - commandImages: { - serialize: ids => this.conversation().serializeDraftImages(ids), + commandAttachments: { + serialize: async (ids) => { + const result = await this.conversation().serializeDraftAttachments(ids) + return result.attachments + }, // Asymmetric with serialize on purpose: release settles AFTER the // submit RPC, where session teardown may already have unloaded the // conversation service (the same tolerance as the scope disposer // above); leaked preview URLs then die with the document. release: (ids) => { const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined - for (const imageId of ids) conversation?.releaseDraftImage(imageId) + for (const attachmentId of ids) conversation?.releaseDraftAttachment(attachmentId) }, - unsupportedNotice: token => this.t('command.imagesUnsupported', { + unsupportedNotice: token => this.t('command.attachmentsUnsupported', { command: token.trim().replace(/^\//u, ''), }), }, @@ -124,7 +127,7 @@ export class InputHub implements SessionInputResolver { const drafts = shell.dispose() this.shells.delete(id) const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined - for (const imageId of drafts) conversation?.releaseDraftImage(imageId) + for (const attachmentId of drafts) conversation?.releaseDraftAttachment(attachmentId) } }, 'conversation.input: session shell') return shell @@ -175,12 +178,12 @@ export class InputHub implements SessionInputResolver { private sink( session: SessionFace, text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: InputSubmitMode, signal: AbortSignal, ): Promise { - if (text === '' && imageIds.length === 0) return Promise.resolve({ kind: 'success' }) - return this.conversation().sendSession(session, text, imageIds, mode, signal) + if (text === '' && attachmentIds.length === 0) return Promise.resolve({ kind: 'success' }) + return this.conversation().sendSession(session, text, attachmentIds, mode, signal) } /** diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index cfc757d63b..899da6f996 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -55,7 +55,7 @@ export class SubmitMachine { claim: { token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}), - ...(c.images === true ? { images: true } : {}), + ...(c.attachments === true ? { attachments: true } : {}), }, } : {}), @@ -216,7 +216,7 @@ export class SubmitMachine { return [{ type: 'notice', level: ev.ok && ev.outcome?.kind !== 'error' ? 'info' : 'error', text }] } - /** Clear after an accepted image-only send; it has no text suffix to retain. */ + /** Clear after an accepted attachment-only send; it has no text suffix to retain. */ private onSendCommitted(): readonly InputEffect[] { if (this.phase !== 'plain') return [] this.claim = undefined diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 3faaf8f009..3b9df01503 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -23,15 +23,16 @@ export const zh = { 'input.stop': '停止生成', 'input.send': '发送消息', 'input.accessMode': '访问模式,当前:{name}', - 'image.dropTitle': '图片拖动到此处即可添加', - 'image.dropDesc': '最多 {count} 张,每张 {size}', - 'image.dropBlocked': '当前无法添加图片', + 'attachment.pending': '待发送附件', + 'attachment.scrollLeft': '向左滚动附件', + 'attachment.scrollRight': '向右滚动附件', + 'attachment.dropTitle': '文件或图片拖动到此处即可添加', + 'attachment.dropDesc': '图片限制:最多 {count} 张,每张 {size}', + 'attachment.dropBlocked': '当前无法添加文件或图片', 'image.pending': '待发送图片', 'image.openOriginal': '查看原图', 'image.openOriginalLabel': '{label},点击查看原图', 'image.remove': '移除图片 {name}', - 'image.scrollLeft': '向左滚动图片', - 'image.scrollRight': '向右滚动图片', 'image.original': '原图', 'image.label': '图片', 'image.loadFailed': '图片加载失败,点击重试', @@ -46,6 +47,16 @@ export const zh = { 'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试', 'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型', 'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试', + 'file.attach': '添加附件', + 'file.pending': '待发送文件', + 'file.remove': '移除文件 {name}', + 'file.uploading': '上传中…', + 'file.uploadFailed': '上传失败,点击重试', + 'file.retry': '重试上传 {name}', + 'file.stillUploading': '文件还在上传,请等待上传完成后发送', + 'file.sessionUnavailable': '会话不可用,无法上传文件', + 'file.notStaged': '文件尚未上传成功,请重新添加后再试', + 'file.label': '文件', 'context.aria': '上下文已用 {percent}', 'context.used': '上下文已用', 'context.system': '系统提示词', @@ -73,7 +84,7 @@ export const zh = { 'todo.progress.pending': '{pending} 待处理', 'todo.rowTitle': '更新任务清单', 'todo.completed': '{done}/{total} 已完成', - 'command.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片', + 'command.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件', 'ask.rowTitle': '提问', 'ask.waiting': '等待回答', 'ask.cancelled': '已取消', @@ -132,6 +143,7 @@ export const zh = { 'details.running': '运行中…', 'queue.count': '{n} 条排队消息', 'queue.image': '排队消息图片', + 'queue.file': '排队文件 {name}', 'queue.edit': '编辑排队消息', 'queue.edit.unsupported': '包含非文本内容,暂不支持编辑', 'queue.save': '保存排队消息', @@ -174,15 +186,16 @@ export const en = { 'input.stop': 'Stop generating', 'input.send': 'Send message', 'input.accessMode': 'Access mode, current: {name}', - 'image.dropTitle': 'Drag images here to add them', - 'image.dropDesc': 'Up to {count} images, {size} each', - 'image.dropBlocked': 'Images cannot be added right now', + 'attachment.pending': 'Pending attachments', + 'attachment.scrollLeft': 'Scroll attachments left', + 'attachment.scrollRight': 'Scroll attachments right', + 'attachment.dropTitle': 'Drag files or images here to add them', + 'attachment.dropDesc': 'Image limit: up to {count} images, {size} each', + 'attachment.dropBlocked': 'Files and images cannot be added right now', 'image.pending': 'Pending images', 'image.openOriginal': 'View original', 'image.openOriginalLabel': '{label}, click to view original', 'image.remove': 'Remove image {name}', - 'image.scrollLeft': 'Scroll images left', - 'image.scrollRight': 'Scroll images right', 'image.original': 'Original image', 'image.label': 'Image', 'image.loadFailed': 'Image failed to load; click to retry', @@ -197,6 +210,16 @@ export const en = { 'image.dimensionTooLarge': 'Image sides must be at most {size}px; downscale it and try again', 'image.modelUnsupported': 'The current model does not support images; switch to a model that does', 'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again', + 'file.attach': 'Add attachment', + 'file.pending': 'Pending files', + 'file.remove': 'Remove file {name}', + 'file.uploading': 'Uploading…', + 'file.uploadFailed': 'Upload failed; click to retry', + 'file.retry': 'Retry uploading {name}', + 'file.stillUploading': 'Files are still uploading; send after they finish', + 'file.sessionUnavailable': 'Session unavailable; files cannot be uploaded', + 'file.notStaged': 'The file has not finished uploading; re-add it and try again', + 'file.label': 'File', 'context.aria': '{percent} of context used', 'context.used': 'of context used', 'context.system': 'System prompt', @@ -224,7 +247,7 @@ export const en = { 'todo.progress.pending': '{pending} pending', 'todo.rowTitle': 'Update to-do list', 'todo.completed': '{done}/{total} completed', - 'command.imagesUnsupported': '/{command} does not accept image attachments; remove them first', + 'command.attachmentsUnsupported': '/{command} does not accept attachments; remove them first', 'ask.rowTitle': 'Ask question', 'ask.waiting': 'waiting', 'ask.cancelled': 'cancelled', @@ -283,6 +306,7 @@ export const en = { 'details.running': 'Running…', 'queue.count': '{n} queued messages', 'queue.image': 'Queued message image', + 'queue.file': 'Queued file {name}', 'queue.edit': 'Edit queued message', 'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet', 'queue.save': 'Save queued message', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index dd2cbc73ce..a328956ba3 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -122,10 +122,51 @@ box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1); } -.thumbs { +.attachments { display: flex; flex: none; gap: 4px; + min-width: 0; + max-width: 55%; + overflow: hidden; +} + +.file { + display: inline-flex; + flex: 0 1 180px; + align-items: center; + gap: 4px; + min-width: 74px; + height: 24px; + padding: 0 6px; + overflow: hidden; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: 6px; + background: var(--dsw-alias-bg-base); + box-sizing: border-box; +} + +.fileIcon { + display: inline-flex; + flex: none; + width: 16px; + height: 16px; +} + +.fileName { + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-primary-dimmed); + font: var(--dsw-font-xs-13); + text-overflow: ellipsis; + white-space: nowrap; +} + +.fileSize { + flex: none; + color: var(--dsw-alias-label-tertiary); + font-size: 10px; + white-space: nowrap; } .thumb { diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 2840268034..2d0d5d876e 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -1,11 +1,12 @@ import type { Context } from '@deepseek-ai/cordis' import { useEffect, useId, useMemo, useState } from 'react' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, - IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip, + DocumentFileIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, + IconTrashOutline16, projectUserText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId, QueueRow } from '../contract/queue.ts' import { NS } from '../locales.ts' @@ -26,12 +27,36 @@ export interface QueueDockInjected { * @param content - the row's wire content blocks. * @returns the row's durable image references in block order. */ -function queueImageRefs(content: QueueRow['content']): ImageAttachmentRef[] { - return content.flatMap((block) => { - if (block.type !== 'image') return [] - const { attachment } = block as { attachment?: ImageAttachmentRef } - return attachment === undefined ? [] : [attachment] - }) +function queueAttachments(content: QueueRow['content']): Array< + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +> { + const attachments: Array< + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + > = [] + for (const block of content) { + if (block.type === 'image') { + const { attachment } = block as { attachment?: ImageAttachmentRef } + if (attachment !== undefined) attachments.push({ type: 'image', attachment }) + } + if (block.type === 'file') { + const { attachment } = block as { attachment?: FileAttachmentRef } + if (attachment !== undefined) attachments.push({ type: 'file', attachment }) + } + } + return attachments +} + +/** Compact file identity used beside queue thumbnails. */ +function QueueFile({ attachment, label }: { attachment: FileAttachmentRef; label: string }) { + return ( + + + {attachment.name} + {fileSizeText(attachment.bytes)} + + ) } /** One durable queued image as a fixed-size thumbnail; a load failure keeps the empty placeholder. */ @@ -137,7 +162,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que )}
diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 27a4c3e629..29023e1b92 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -16,13 +16,18 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { ISessions, PendingSubmissionRetirement, SessionFace, } from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-client-file-upload/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' -import type { ComposerAttachment } from './contract/slots.ts' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { + ComposerAttachment, ComposerFileAttachment, ComposerImageAttachment, DraftFileUpload, +} from './contract/slots.ts' import type { QueueAction, QueueItemId } from './contract/queue.ts' import type { ComposerBlocks } from './contract/composer-blocks.ts' import type { - DraftAttachmentId, SessionInputResolver, SubmitImageAttachment, SubmitOutcome, + DraftAttachmentId, DraftAttachmentSerializationResult, SessionInputResolver, SubmitAttachment, SubmitOutcome, } from './contract/input.ts' import type { InputSubmitMode } from './contract/composer-submission.ts' @@ -64,8 +69,8 @@ export interface IConversation { loadOlder(): Promise } -/** Create one browser-only draft descriptor; only its id enters input state. */ -function browserDraftAttachment(file: File): ComposerAttachment { +/** Create one browser-only image draft descriptor; only its id enters input state. */ +function browserDraftAttachment(file: File): ComposerImageAttachment { return { kind: 'image', id: randomUUID() as DraftAttachmentId, @@ -82,7 +87,7 @@ function browserDraftAttachment(file: File): ComposerAttachment { * reads the dimensions into an immutable echo snapshot, so this late write * does not require a store notification. */ -function probeDimensions(attachment: ComposerAttachment): void { +function probeDimensions(attachment: ComposerImageAttachment): void { if (typeof Image !== 'function') return const probe = new Image() probe.onload = () => { @@ -115,8 +120,8 @@ function nextPaint(): Promise { }) } -/** Native canonical base64 of one browser file (FileReader data-URL encode; no main-thread byte loop). */ -function base64Of(file: File): Promise { +/** Native canonical base64 of one browser image (FileReader data-URL encode; no main-thread byte loop). */ +function base64ImageOf(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { @@ -149,7 +154,20 @@ export class ConversationController extends Service implements IConversation { readonly input: SessionInputResolver /** The per-session composer-block registry. */ readonly blocks: ComposerBlocks + /** Live upload state per file-kind draft; images never appear here. */ + readonly fileUploads: SnapshotStore> = createSnapshotStore>({}) private readonly draftAttachments = new Map() + private readonly fileUploadOperations = new Map + }>() + private readonly pendingFileUploads = new Set>() + private readonly fileUploadQueue: Array<{ + readonly run: () => Promise + readonly settle: () => void + }> = [] + private activeFileUploads = 0 + private readonly maxConcurrentFileUploads: number /** * @param ctx - owning root context (the plugin apply context; the service @@ -158,15 +176,26 @@ export class ConversationController extends Service implements IConversation { * constructed by the plugin apply (the same instances the slot inject * factories close over). */ - constructor(ctx: Context, config: { input: SessionInputResolver; blocks: ComposerBlocks }) { + constructor(ctx: Context, config: { + input: SessionInputResolver + blocks: ComposerBlocks + maxConcurrentFileUploads: number + }) { super(ctx, 'conversation') this.input = config.input this.blocks = config.blocks - ctx.effect(() => () => { + this.maxConcurrentFileUploads = config.maxConcurrentFileUploads + ctx.effect(() => async () => { + const operations = [...this.fileUploadOperations.values()] + for (const operation of operations) operation.controller.abort() + await Promise.allSettled([...this.pendingFileUploads]) + this.fileUploadOperations.clear() + this.fileUploadQueue.length = 0 for (const attachment of this.draftAttachments.values()) { - revokePreview(attachment.previewUrl) + if (attachment.kind === 'image') revokePreview(attachment.previewUrl) } this.draftAttachments.clear() + this.fileUploads.set({}) }, 'conversation draft attachments') } @@ -183,15 +212,15 @@ export class ConversationController extends Service implements IConversation { } /** - * Submit ordered draft images with text through one host admission. A local + * Submit ordered draft attachments with text through one host admission. A local * submission echo enters the session snapshot synchronously; serialization * and the prompt round-trip start after the browser can paint it. On the - * echo's observed retirement the draft images hand their preview URLs to - * the durable image cache and leave the registry; on failure they stay - * registered so the composer can restore them. + * echo's observed retirement seeds admitted image previews into the durable + * cache and removes every attachment from the draft registry. On failure, + * every attachment remains registered so the composer can restore it. * @param session - target session. * @param text - serialized prompt text. - * @param imageIds - ordered draft-local attachment ids. + * @param attachmentIds - ordered draft-local attachment ids. * @param mode - queue or steer delivery selected by composer policy. * @param signal - optional cancellation for the complete Host admission. * @returns the Host admission outcome; local attachment preparation failures reject. @@ -199,17 +228,41 @@ export class ConversationController extends Service implements IConversation { async sendSession( session: SessionFace, text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: InputSubmitMode, signal?: AbortSignal, ): Promise { - const attachments = this.draftImages(imageIds) - if (attachments.length !== imageIds.length) { - throw new Error('conversation.sendSession: one or more draft images are no longer available') + const attachments = this.resolveDraftAttachments(attachmentIds) + if (attachments.length !== attachmentIds.length) { + throw new Error('conversation.sendSession: one or more draft attachments are no longer available') } + const uploads = this.fileUploads.getSnapshot() + const uploadFor = (attachment: ComposerFileAttachment): Extract => { + const upload = uploads[attachment.id] + if (upload === undefined || upload.status !== 'ready') { + throw new Error('conversation.sendSession: one or more files have not finished uploading') + } + return upload + } + const pendingAttachments = attachments.map(attachment => attachment.kind === 'image' + ? { + type: 'image' as const, + value: { + previewUrl: attachment.previewUrl, + ...(attachment.file.name === '' ? {} : { name: attachment.file.name }), + ...(attachment.width === undefined ? {} : { width: attachment.width }), + ...(attachment.height === undefined ? {} : { height: attachment.height }), + }, + } + : { type: 'file' as const, value: uploadFor(attachment).file }) + const serializeAttachments = (): Promise[0]> => Promise.all( + attachments.map(async attachment => attachment.kind === 'image' + ? { type: 'image' as const, ...await this.encodeImage(attachment.file) } + : { type: 'file' as const, receiptId: uploadFor(attachment).receiptId }), + ) const snapshot = session.getSnapshot() if (snapshot.subagent !== null) { - const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) + const uploaded = await serializeAttachments() const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] const result = await session.prompt(content, mode, signal) return result.ok ? { kind: 'success' } : { kind: 'error' } @@ -221,21 +274,16 @@ export class ConversationController extends Service implements IConversation { const submission = session.beginSubmission({ mode, text, - images: attachments.map(attachment => ({ - previewUrl: attachment.previewUrl, - ...(attachment.file.name === '' ? {} : { name: attachment.file.name }), - ...(attachment.width === undefined ? {} : { width: attachment.width }), - ...(attachment.height === undefined ? {} : { height: attachment.height }), - })), + attachments: pendingAttachments, onRetire: (settlement) => { - this.settleSubmittedImages(session.sessionId, attachments, settlement) + this.settleSubmittedAttachments(session.sessionId, attachments, settlement) finishRetirement?.(settlement) }, }) let content: Parameters[0] try { await nextPaint() - const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) + const uploaded = await serializeAttachments() content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] } catch (error) { submission.abandon() @@ -248,26 +296,136 @@ export class ConversationController extends Service implements IConversation { } /** - * Create runtime-only draft images and their object URLs. - * @param files - browser files to register after MIME validation. + * Create runtime-only draft attachments. Files whose browser MIME is an + * accepted image type become image drafts (object URL preview, bytes sent + * with the prompt); every other file becomes a file draft whose background + * upload starts immediately and remains owned by this service across Session + * navigation until completion or explicit removal. + * @param sessionId - target Agent-scope identity. + * @param files - browser files to register. * @returns ordered draft descriptors. */ - createDraftImages(files: readonly File[]): readonly ComposerAttachment[] { - for (const file of files) imageMediaType(file.type) + createDrafts(sessionId: SessionId, files: readonly File[]): readonly ComposerAttachment[] { return files.map((file) => { - const attachment = browserDraftAttachment(file) + if (isImageMediaType(file.type)) { + const attachment = browserDraftAttachment(file) + this.draftAttachments.set(attachment.id, attachment) + probeDimensions(attachment) + return attachment + } + const attachment: ComposerFileAttachment = { + kind: 'file', + id: randomUUID() as DraftAttachmentId, + file, + } this.draftAttachments.set(attachment.id, attachment) - probeDimensions(attachment) + this.beginFileUpload(sessionId, attachment) return attachment }) } /** - * Resolve ordered input-state ids to runtime-owned draft images. + * Restart one failed file upload. + * @param sessionId - target Agent-scope identity. + * @param id - draft attachment id whose upload previously failed. + */ + retryFileUpload(sessionId: SessionId, id: DraftAttachmentId): void { + const attachment = this.draftAttachments.get(id) + if (attachment === undefined || attachment.kind !== 'file') return + if (this.fileUploads.getSnapshot()[id]?.status !== 'error') return + this.beginFileUpload(sessionId, attachment) + } + + /** + * Stage carried file drafts again for a new Session. + * @param sessionId - target Agent-scope identity after a Workspace switch. + * @param ids - carried draft attachment ids. + */ + rebindDraftFiles(sessionId: SessionId, ids: readonly DraftAttachmentId[]): void { + for (const id of ids) { + const attachment = this.draftAttachments.get(id) + if (attachment?.kind === 'file') this.beginFileUpload(sessionId, attachment) + } + } + + private beginFileUpload(sessionId: SessionId, attachment: ComposerFileAttachment): void { + this.fileUploadOperations.get(attachment.id)?.controller.abort() + const controller = new AbortController() + this.fileUploads.update((draft) => { + draft[attachment.id] = { status: 'uploading', loaded: 0 } + }) + let settle!: () => void + const done = new Promise((resolve) => { settle = resolve }) + this.fileUploadOperations.set(attachment.id, { controller, done }) + this.pendingFileUploads.add(done) + void done.then(() => { this.pendingFileUploads.delete(done) }) + const run = async (): Promise => { + try { + if (controller.signal.aborted + || this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + const result = await this.ctx.fileUpload.upload( + sessionId, + attachment.file, + attachment.file.name === '' ? undefined : attachment.file.name, + controller.signal, + (progress) => { + if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + this.fileUploads.update((draft) => { + if (!(attachment.id in draft)) return + draft[attachment.id] = { + status: 'uploading', + loaded: progress.loaded, + ...(progress.total === undefined ? {} : { total: progress.total }), + } + }) + }, + ) + if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + this.fileUploads.update((draft) => { + if (!(attachment.id in draft)) return + draft[attachment.id] = result.ok + ? { status: 'ready', receiptId: result.value.receiptId, file: result.value.file } + : { status: 'error', message: result.error.message } + }) + } catch (error) { + if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + this.fileUploads.update((draft) => { + if (!(attachment.id in draft)) return + draft[attachment.id] = { + status: 'error', + message: error instanceof Error ? error.message : String(error), + } + }) + } finally { + if (this.fileUploadOperations.get(attachment.id)?.controller === controller) { + this.fileUploadOperations.delete(attachment.id) + } + } + } + this.fileUploadQueue.push({ run, settle }) + this.pumpFileUploads() + } + + /** Start queued upload Workers until the configured concurrency is occupied. */ + private pumpFileUploads(): void { + while (this.activeFileUploads < this.maxConcurrentFileUploads) { + const task = this.fileUploadQueue.shift() + if (task === undefined) return + this.activeFileUploads += 1 + void task.run().finally(() => { + this.activeFileUploads -= 1 + task.settle() + this.pumpFileUploads() + }) + } + } + + /** + * Resolve ordered input-state ids to runtime-owned draft attachments. * @param ids - draft attachment ids. * @returns descriptors that remain live, in requested order. */ - draftImages(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] { + resolveDraftAttachments(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] { const attachments: ComposerAttachment[] = [] for (const id of ids) { const attachment = this.draftAttachments.get(id) @@ -277,37 +435,59 @@ export class ConversationController extends Service implements IConversation { } /** - * Serialize ordered draft images to command-submit wire payloads without - * sending or releasing them (the composer releases only after the command - * settles successfully). - * @param imageIds - ordered draft-local attachment ids. - * @returns base64 payloads in id order. + * Serialize ordered draft attachments to command-submit wire payloads without + * sending or releasing them. Images are encoded; generic files cite receipts + * from their completed background uploads and never reread browser bytes. + * @param attachmentIds - ordered draft-local attachment ids. + * @returns wire payloads in id order. */ - async serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise { - const attachments = this.draftImages(imageIds) - if (attachments.length !== imageIds.length) { - throw new Error('conversation.serializeDraftImages: one or more draft images are no longer available') + async serializeDraftAttachments( + attachmentIds: readonly DraftAttachmentId[], + ): Promise { + const attachments = this.resolveDraftAttachments(attachmentIds) + if (attachments.length !== attachmentIds.length) { + throw new Error('conversation.serializeDraftAttachments: one or more draft attachments are no longer available') + } + const uploads = this.fileUploads.getSnapshot() + return { + attachments: await Promise.all(attachments.map(async (attachment) => { + if (attachment.kind === 'image') return { type: 'image' as const, ...await this.encodeImage(attachment.file) } + const upload = uploads[attachment.id] + if (upload === undefined || upload.status !== 'ready') { + throw new Error('conversation.serializeDraftAttachments: one or more files have not finished uploading') + } + return { type: 'file' as const, receiptId: upload.receiptId } + })), } - return Promise.all(attachments.map(attachment => this.encodeImage(attachment.file))) } /** - * Release one browser-owned draft image and preview URL. + * Release one browser-owned draft attachment, aborting its active upload. * @param id - draft attachment id. */ - releaseDraftImage(id: DraftAttachmentId): void { + releaseDraftAttachment(id: DraftAttachmentId): void { const attachment = this.draftAttachments.get(id) if (attachment === undefined) return + const operation = this.fileUploadOperations.get(id) + this.fileUploadOperations.delete(id) + operation?.controller.abort() this.draftAttachments.delete(id) - revokePreview(attachment.previewUrl) + if (attachment.kind === 'image') { + revokePreview(attachment.previewUrl) + return + } + // The stored Host object stays durable; only the draft's upload state ends. + this.fileUploads.set(Object.fromEntries( + Object.entries(this.fileUploads.getSnapshot()).filter(([key]) => key !== id), + )) } /** - * Release a set of browser-owned draft images. + * Release a set of browser-owned draft attachments. * @param attachments - descriptors to release. */ - releaseDraftImages(attachments: readonly ComposerAttachment[]): void { - for (const attachment of attachments) this.releaseDraftImage(attachment.id) + releaseDraftAttachments(attachments: readonly ComposerAttachment[]): void { + for (const attachment of attachments) this.releaseDraftAttachment(attachment.id) } /** Apply one operation to a pending queue occurrence. */ @@ -361,40 +541,41 @@ export class ConversationController extends Service implements IConversation { } /** - * Settle one submission's draft images when its echo retires. Observed: + * Settle one submission's draft attachments when its echo retires. Observed: * each image leaves the registry, handing its preview URL to the durable * image cache (seeded under the admitted reference so the transcript node * renders immediately while the cache reads canonical bytes) or revoking it * when the cache already holds that reference. Failed: nothing changes; * the ids stay registered for the composer's rail restore. */ - private settleSubmittedImages( + private settleSubmittedAttachments( sessionId: SessionId, attachments: readonly ComposerAttachment[], retirement: PendingSubmissionRetirement, ): void { if (retirement.reason !== 'observed') return const uiConversation = this.ctx.get('uiConversation') - attachments.forEach((attachment, index) => { + let observedIndex = 0 + for (const attachment of attachments) { const live = this.draftAttachments.get(attachment.id) - if (live === undefined) return + const ref = retirement.attachments[observedIndex++] + if (live === undefined) continue + if (attachment.kind === 'file') { + this.releaseDraftAttachment(attachment.id) + continue + } this.draftAttachments.delete(attachment.id) - const ref = retirement.attachments[index] - if (ref !== undefined && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) return + if (ref !== undefined && 'mediaType' in ref + && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) continue revokePreview(attachment.previewUrl) - }) - } - - /** Convert browser files to canonical base64 prompt parts. */ - private serializeImages(images: readonly File[]): Promise[0]> { - return Promise.all(images.map(async file => ({ type: 'image' as const, ...await this.encodeImage(file) }))) + } } /** Canonical base64 wire form of one browser image file. */ - private async encodeImage(file: File): Promise { + private async encodeImage(file: File): Promise, 'type'>> { return { mediaType: imageMediaType(file.type), - data: await base64Of(file), + data: await base64ImageOf(file), ...(file.name === '' ? {} : { name: file.name }), } } @@ -412,6 +593,11 @@ function imageMediaType(value: string): ImageMediaType { } } +/** Whether a browser-declared MIME selects the image draft path (all other files upload verbatim). */ +function isImageMediaType(value: string): boolean { + return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif' +} + function revokePreview(url: string): void { if (url.startsWith('blob:')) URL.revokeObjectURL(url) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index c223459c51..8747f70104 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -14,10 +14,10 @@ */ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react' +import type { ChangeEvent, CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { - IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, + IconPaperclipOutline16, IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). @@ -40,9 +40,10 @@ import css from './InputBar.module.css' export type InputBarProps = ComposerBarProps export const InputBar = memo(function InputBar({ - useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages, + useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, + retryFileUpload, resolveSubmitMode, toggleCommandMenu, stop, command, t, - renderSlot, useNotices, useLexicon, useMenuLauncher, + renderSlot, useFileUploads, useNotices, useLexicon, useMenuLauncher, useProjection, sessionId, variant, disabled: inert = false, blocked, workspacePickerOpen = false, onRequestWorkspace, placeholder, accessory, @@ -66,10 +67,16 @@ export const InputBar = memo(function InputBar({ const draft = input?.draft ?? '' const editor = keyboard?.editor ?? null const attachments = useMemo( - () => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds), - [draftImages, input?.imageIds], + () => input === undefined || resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds), + [resolveDraftAttachments, input?.attachmentIds], ) const empty = draft.trim() === '' && attachments.length === 0 + const uploads = useFileUploads(snapshot => snapshot) + // Send waits for every picked file: uploading and failed drafts both hold + // the gate (a failed upload is retried or removed, never silently dropped). + const uploadsPending = attachments.some( + attachment => attachment.kind === 'file' && uploads[attachment.id]?.status !== 'ready', + ) // Transient error banner (machine notices, image-intake rejections, and // prompt failures): the seq keys the Toast so an identical repeated message // restarts the hold-then-fade cycle instead of reusing the faded one. @@ -136,10 +143,10 @@ export const InputBar = memo(function InputBar({ useEffect(() => { if (input === undefined || inputActions === undefined) return - if (attachments.length !== input.imageIds.length) { - inputActions.pruneImages(attachments.map(attachment => attachment.id)) + if (attachments.length !== input.attachmentIds.length) { + inputActions.pruneAttachments(attachments.map(attachment => attachment.id)) } - }, [attachments, input?.imageIds, inputActions]) + }, [attachments, input?.attachmentIds, inputActions]) // Scroll the draft scrollport the minimum that brings the selection focus // into view — the browser's own behavior for typing, performed for the @@ -212,46 +219,56 @@ export const InputBar = memo(function InputBar({ return () => { el.removeEventListener('wheel', onWheel) } }, []) - // Intake pre-check: an addition that would break - // a projected limit is refused as a whole batch, announced immediately, and - // never enters the rail — no more submit-time failure rolling the rail - // back. The host enforces the same limits at submit for callers that bypass + // Intake pre-check: an addition that would break a projected image limit is + // refused as a whole batch, announced immediately, and never enters the + // rail. Only the image subset is limit-checked: generic files carry no + // client-side size or count limit and upload as soon as they are picked. + // The host enforces the same image limits at submit for callers that bypass // this composer. - const intakeImages = useCallback((files: readonly File[]): void => { - if (addImages === undefined || files.length === 0) return + const intakeFiles = useCallback((files: readonly File[]): void => { + if (subagent !== null || addFiles === undefined || files.length === 0) return const rejected = ((): string | null => { if (imageLimits !== undefined) { - // Format precedes limits: a batch with - // a non-image must announce the format problem, not a count or size - // it could never pass anyway — addImages rejects it authoritatively. - if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) { - return addImages(files) - } - if (attachments.length + files.length > imageLimits.maxImagesPerMessage) { + const mediaTypes = imageLimits.mediaTypes as readonly string[] + const images = files.filter(file => mediaTypes.includes(file.type)) + const imageAttachments = attachments.filter(attachment => attachment.kind === 'image') + if (imageAttachments.length + images.length > imageLimits.maxImagesPerMessage) { return t('image.tooMany', { count: imageLimits.maxImagesPerMessage }) } - if (files.some(file => file.size > imageLimits.maxImageBytes)) { + if (images.some(file => file.size > imageLimits.maxImageBytes)) { return t('image.fileTooLarge', { size: imageSizeText(imageLimits.maxImageBytes) }) } - const total = attachments.reduce((sum, attachment) => sum + attachment.file.size, 0) - + files.reduce((sum, file) => sum + file.size, 0) + const total = imageAttachments.reduce((sum, attachment) => sum + attachment.file.size, 0) + + images.reduce((sum, file) => sum + file.size, 0) if (total > imageLimits.maxMessageImageBytes) { return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) }) } } - return addImages(files) + return addFiles(files) })() if (rejected !== null) showToast(rejected) - }, [addImages, attachments, imageLimits, showToast, t]) + }, [subagent, addFiles, attachments, imageLimits, showToast, t]) - const canAcceptDrop = !locked && !machineBusy && addImages !== undefined + const canAcceptDrop = subagent === null && !locked && !machineBusy && addFiles !== undefined + + const fileInputRef = useRef(null) + const onPickFiles = (e: ChangeEvent): void => { + const picked = e.target.files === null ? [] : [...e.target.files] + // Reset so picking the same file again re-fires the change event. + e.target.value = '' + if (picked.length > 0) intakeFiles(picked) + } // The keymap handlers read live bar state through this ref so the editor // registration survives re-renders without re-arming per keystroke. const gate = useRef({ - locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages, + locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, + intakeFiles, uploadsPending, showToast, t, }) - gate.current = { locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages } + gate.current = { + locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, + intakeFiles, uploadsPending, showToast, t, + } useEffect(() => { if (editor === null || keyboard === undefined) return @@ -272,13 +289,17 @@ export const InputBar = memo(function InputBar({ keyboard.steerQueue() return } + if (g.uploadsPending) { + g.showToast(g.t('file.stillUploading')) + return + } keyboard.submit(g.resolveSubmitMode( g.running, accelerated ? 'accelerated' : 'enter', g.subagent === null, )) }, - intakeFiles: (files) => { gate.current.intakeImages(files) }, + intakeFiles: (files) => { gate.current.intakeFiles(files) }, pasteText: (text) => { if (gate.current.machineBusy || gate.current.locked) return keyboard.paste(text) @@ -321,8 +342,8 @@ export const InputBar = memo(function InputBar({ return } if (inputActions === undefined) return // absent machine: the button is disabled - /* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */ - if (!empty && !disabled && !machineBusy) inputActions.submit() + /* v8 ignore next -- defensive: the primary button is disabled for empty, disabled, and pending-upload states. */ + if (!empty && !disabled && !machineBusy && !uploadsPending) inputActions.submit() } // The Access seat: the projection-fed permission chip (renders nothing @@ -398,8 +419,10 @@ export const InputBar = memo(function InputBar({ {renderSlot('conversation.input.attachments', { attachments, canAcceptDrop, - onAddImages: intakeImages, - onRemoveImage: (id) => { removeImage?.(id) }, + onAddFiles: intakeFiles, + onRemoveAttachment: (id) => { removeAttachment?.(id) }, + uploads, + onRetryFile: (id) => { retryFileUpload?.(id) }, dropLimits: imageLimits === undefined ? undefined : { count: imageLimits.maxImagesPerMessage, size: imageSizeText(imageLimits.maxImageBytes), @@ -452,6 +475,26 @@ export const InputBar = memo(function InputBar({ + + + +
{accessSelect} {sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })} @@ -487,7 +530,7 @@ export const InputBar = memo(function InputBar({ type="button" className={css.primary} aria-label={primaryLabel} - disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy} + disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending} onMouseDown={keepFocus} onClick={onPrimary} > diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index a767f54708..2dde9ce7b3 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -33,6 +33,20 @@ function sessionFakeFor() { async function bench() { const runtime = await SlotTestRuntime.create() + const rootUpload = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'root-receipt' as never, + file: { attachmentId: 'root-file' as never, name: 'draft.pdf', bytes: 1 }, + }, + })) + const uploads = new Map Promise>([[ROOT, rootUpload]]) + runtime.fileUpload.available = true + 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) const connectWorkspace = vi.fn(async () => ROOT) runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never) @@ -87,7 +101,7 @@ async function bench() { conversationApi(id).injected.hooks.conversationViews return { runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi, - inputApi, viewSource, sessionFake, connectWorkspace, + inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads, } } @@ -213,6 +227,31 @@ describe('Conversation inject API', () => { await b.runtime.dispose() }) + it('releases a draft attachment only after the input shell accepts its removal', async () => { + const b = await bench() + const composer = b.composerApi(ROOT) + expect(composer.addFiles?.([ + new File([Uint8Array.of(1)], 'draft.pdf', { type: 'application/pdf' }), + ])).toBeNull() + const controller = b.runtime.ctx.get('conversation') as unknown as { + releaseDraftAttachment(id: string): void + } + const release = vi.spyOn(controller, 'releaseDraftAttachment') + const input = b.inputApi(ROOT).actions + const remove = vi.spyOn(input, 'removeAttachment').mockReturnValue(false) + const draft = composer.resolveDraftAttachments?.( + b.inputApi(ROOT).state.getSnapshot().attachmentIds, + )[0] + if (draft === undefined) throw new Error('missing draft attachment') + + composer.removeAttachment?.(draft.id) + expect(release).not.toHaveBeenCalled() + remove.mockReturnValueOnce(true) + composer.removeAttachment?.(draft.id) + expect(release).toHaveBeenCalledWith(draft.id) + await b.runtime.dispose() + }) + it('fails loud for an unknown binding or an unloaded scoped service', async () => { const b = await bench() const entry = b.entryOf('conversation.composer.bar') @@ -240,6 +279,10 @@ describe('Conversation inject API', () => { const resident = b.residentApi(ROOT) const { state, actions } = b.inputApi(ROOT) actions.setDraft('carry me') + expect(b.composerApi(ROOT).addFiles?.([ + new File([Uint8Array.of(1)], 'draft.pdf', { type: 'application/pdf' }), + ])).toBeNull() + await vi.waitFor(() => { expect(b.rootUpload).toHaveBeenCalledOnce() }) b.connectWorkspace.mockResolvedValueOnce(ROOT) await resident.selectWorkspace('workspace-1' as WorkspaceId) @@ -247,12 +290,22 @@ describe('Conversation inject API', () => { expect(state.getSnapshot().draft).toBe('carry me') const other = 'other-1' as SessionId - await b.runtime.sessions.add({ id: other }, { current: false }) + const targetUpload = vi.fn(() => Promise.resolve({ + ok: true, + value: { + receiptId: 'target-receipt' as never, + file: { attachmentId: 'target-file' as never, name: 'draft.pdf', bytes: 1 }, + }, + })) + b.uploads.set(other, targetUpload) + await b.runtime.sessions.add({ id: other, session: {} }, { current: false }) b.connectWorkspace.mockResolvedValueOnce(other) await resident.selectWorkspace('workspace-2' as WorkspaceId) expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [other] }) expect(state.getSnapshot().draft).toBe('') expect(b.inputApi(other).state.getSnapshot().draft).toBe('carry me') + await vi.waitFor(() => { expect(targetUpload).toHaveBeenCalledOnce() }) + expect(b.inputApi(other).state.getSnapshot().attachmentIds).toHaveLength(1) await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 0edc8b9ef6..78d53353ce 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -51,6 +51,8 @@ interface BenchOptions { planEntry?: React.ReactNode /** The `plan` projection value the standard-kit useProjection serves. */ plan?: { active: boolean; pending: boolean } + /** The `goal` projection value used only to prove attachment intake remains ordinary. */ + goal?: { phase: 'active'; objective: string } modelEntry?: React.ReactNode /** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */ lexicon?: ReadonlyMap<'/' | '@', readonly string[]> @@ -87,7 +89,7 @@ interface BenchOptions { rightItems?: React.ReactNode footer?: React.ReactNode attachments?: readonly ComposerAttachment[] - addImages?: (files: readonly File[]) => string | null + addFiles?: (files: readonly File[]) => string | null commandMenuOpen?: boolean busyEnter?: 'queue' | 'steer' toggleCommandMenu?: (selection: { start: number; end: number }) => void @@ -105,7 +107,7 @@ function row(id: string): SessionSnapshot['queue'][number] { function bench(over?: BenchOptions) { const sink = vi.fn<( text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: 'queue' | 'steer', signal: AbortSignal, ) => Promise>(() => Promise.resolve({ kind: 'success' })) @@ -121,7 +123,7 @@ function bench(over?: BenchOptions) { const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, - commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` }, + commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` }, queue: { getSnapshot: () => session.getSnapshot().queue, subscribe: fn => session.subscribe(fn), @@ -139,9 +141,9 @@ function bench(over?: BenchOptions) { : {}), }) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) - if (over?.attachments !== undefined) shell.addImages(over.attachments.map(attachment => attachment.id)) + if (over?.attachments !== undefined) shell.addAttachments(over.attachments.map(attachment => attachment.id)) const stop = vi.fn() - const removeImage = vi.fn((id: DraftAttachmentId) => { shell.removeImage(id) }) + const removeAttachment = vi.fn((id: DraftAttachmentId) => { shell.removeAttachment(id) }) const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null) const slotCalls: { key: string; owner: unknown }[] = [] const renderSlot = ((key: string, owner: object) => { @@ -170,13 +172,17 @@ function bench(over?: BenchOptions) { useProjection: ((key: string, selector?: (v: unknown) => unknown) => (selector ?? (v => v))(key === 'permissions' ? over?.permissions - : key === 'plan' ? over?.plan : key === 'imageLimits' ? over?.imageLimits : undefined)), + : key === 'plan' ? over?.plan + : key === 'goal' ? over?.goal + : key === 'imageLimits' ? over?.imageLimits : undefined)), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, - addImages: over?.addImages ?? (() => null), - removeImage, - draftImages: ids => ids.flatMap((id) => { + addFiles: over?.addFiles ?? (() => null), + useFileUploads: bindSnapshotSelector(createSnapshotStore({})), + retryFileUpload: undefined, + removeAttachment, + resolveDraftAttachments: ids => ids.flatMap((id) => { const attachment = over?.attachments?.find(candidate => candidate.id === id) return attachment === undefined ? [] : [attachment] }), @@ -212,7 +218,7 @@ function bench(over?: BenchOptions) { )! const interruptButton = view.container.querySelector('button[aria-label="停止生成"]') return { - view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls, + view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeAttachment, slotCalls, menuLauncher, steerQueue: over?.steerQueue, get placeholder() { return placeholderOf(view.container) }, @@ -245,8 +251,8 @@ function writeDraft(shell: SessionInputShell, text: string): void { describe('image draft rail', () => { it('collects clipboard files while preserving text from a mixed paste', async () => { - const addImages = vi.fn(() => null) - const { textarea, shell } = bench({ addImages }) + const addFiles = vi.fn(() => null) + const { textarea, shell } = bench({ addFiles }) const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' }) fireEvent.paste(textarea, { clipboardData: { @@ -257,7 +263,7 @@ describe('image draft rail', () => { getData: () => '同时粘贴的文字', }, }) - expect(addImages).toHaveBeenCalledWith([image]) + expect(addFiles).toHaveBeenCalledWith([image]) // The paste lands inside the PASTE_COMMAND update; its commit is a microtask away. await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('同时粘贴的文字') }) }) @@ -273,40 +279,40 @@ describe('image draft rail', () => { } const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' }) const intake = (result: ReturnType, files: File[]) => { - act(() => { attachmentOwner(result.slotCalls).onAddImages(files) }) + act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) }) } // Count: three at once over a two-image limit → the whole batch refused. - const overCount = bench({ addImages: vi.fn(() => null), imageLimits: limits }) + const overCount = bench({ addFiles: vi.fn(() => null), imageLimits: limits }) intake(overCount, [png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')]) expect(overCount.view.getByRole('alert').textContent).toContain('一条消息最多添加 2 张图片') - expect(overCount.props.addImages).not.toHaveBeenCalled() + expect(overCount.props.addFiles).not.toHaveBeenCalled() cleanup() // Per-file bytes. - const overFile = bench({ addImages: vi.fn(() => null), imageLimits: limits }) + const overFile = bench({ addFiles: vi.fn(() => null), imageLimits: limits }) intake(overFile, [png(1024 * 1024 + 1, 'big.png')]) expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB') - expect(overFile.props.addImages).not.toHaveBeenCalled() + expect(overFile.props.addFiles).not.toHaveBeenCalled() cleanup() // Aggregate bytes across the existing rail plus the new batch. const held = new File([new ArrayBuffer(1024 * 1024 * 1.5)], 'held.png', { type: 'image/png' }) const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file: held, previewUrl: 'blob:held' } - const overTotal = bench({ addImages: vi.fn(() => null), imageLimits: limits, attachments: [attachment] }) + const overTotal = bench({ addFiles: vi.fn(() => null), imageLimits: limits, attachments: [attachment] }) intake(overTotal, [png(1024 * 1024, 'more.png')]) expect(overTotal.view.getByRole('alert').textContent).toContain('图片总大小超过 2MB') - expect(overTotal.props.addImages).not.toHaveBeenCalled() + expect(overTotal.props.addFiles).not.toHaveBeenCalled() cleanup() - // Within every limit: the batch passes through to addImages. - const within = bench({ addImages: vi.fn(() => null), imageLimits: limits }) + // Within every limit: the batch passes through to addFiles. + const within = bench({ addFiles: vi.fn(() => null), imageLimits: limits }) const fits = png(16, 'fits.png') intake(within, [fits]) - expect(within.props.addImages).toHaveBeenCalledWith([fits]) + expect(within.props.addFiles).toHaveBeenCalledWith([fits]) expect(within.view.queryByRole('alert')).toBeNull() }) it('announces the format problem before any limit when the batch holds a non-image', () => { - const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') + const addFiles = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') const result = bench({ - addImages, + addFiles, imageLimits: { maxImageBytes: 8, maxImagesPerMessage: 1, @@ -321,14 +327,14 @@ describe('image draft rail', () => { new File([new ArrayBuffer(64)], 'a.pdf', { type: 'application/pdf' }), new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }), ] - act(() => { attachmentOwner(result.slotCalls).onAddImages(files) }) - expect(addImages).toHaveBeenCalledWith(files) + act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) }) + expect(addFiles).toHaveBeenCalledWith(files) expect(result.view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片') }) it('projects display-ready limits into the attachment slot', () => { const result = bench({ - addImages: vi.fn(() => null), + addFiles: vi.fn(() => null), imageLimits: { maxImageBytes: 5 * 1024 * 1024, maxImagesPerMessage: 20, @@ -370,7 +376,7 @@ describe('image draft rail', () => { }) it('marks the attachment slot unavailable while the composer is locked', () => { - const result = bench({ addImages: vi.fn(() => null), inert: true }) + const result = bench({ addFiles: vi.fn(() => null), inert: true }) expect(attachmentOwner(result.slotCalls).canAcceptDrop).toBe(false) }) @@ -382,11 +388,11 @@ describe('image draft rail', () => { { kind: 'image' as const, id: 'draft-2' as DraftAttachmentId, file: extra, previewUrl: 'blob:draft-2' }, ] const result = bench({ attachments }) - const { view, textarea, sink, removeImage } = result + const { view, textarea, sink, removeAttachment } = result expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false) const owner = attachmentOwner(result.slotCalls) - act(() => { owner.onRemoveImage('draft-2' as DraftAttachmentId) }) - expect(removeImage).toHaveBeenCalledWith('draft-2') + act(() => { owner.onRemoveAttachment('draft-2' as DraftAttachmentId) }) + expect(removeAttachment).toHaveBeenCalledWith('draft-2') let settle!: (outcome: SubmitOutcome) => void sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -418,8 +424,8 @@ describe('image draft rail', () => { it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => { vi.useFakeTimers() try { - const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') - const { view, textarea } = bench({ addImages }) + const addFiles = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') + const { view, textarea } = bench({ addFiles }) const paste = () => { fireEvent.paste(textarea, { clipboardData: { @@ -441,10 +447,10 @@ describe('image draft rail', () => { }) it('announces a rejected attachment-slot intake through the same toast', () => { - const addImages = vi.fn(() => '图片读取服务不可用') - const result = bench({ addImages }) + const addFiles = vi.fn(() => '图片读取服务不可用') + const result = bench({ addFiles }) act(() => { - attachmentOwner(result.slotCalls).onAddImages([ + attachmentOwner(result.slotCalls).onAddFiles([ new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' }), ]) }) @@ -777,7 +783,7 @@ describe('running and lock semantics', () => { }) it('running continuable subagent keeps Send beside an independent Stop', () => { - const { button, interruptButton, textarea, sink, stop } = bench({ + const { button, interruptButton, textarea, sink, stop, view, slotCalls } = bench({ running: true, draft: '后续消息', subagent: { @@ -792,12 +798,26 @@ describe('running and lock semantics', () => { expect(button.getAttribute('aria-label')).toBe('发送消息') expect(interruptButton).not.toBeNull() expect(textarea.getAttribute('aria-disabled')).not.toBe('true') + expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true) + expect(view.container.querySelector('input[type="file"]')?.disabled).toBe(true) + expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false) fireEvent.click(button) expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal)) fireEvent.click(interruptButton!) expect(stop).toHaveBeenCalledTimes(1) }) + it.each([ + ['active plan', { plan: { active: true, pending: false } }], + ['active goal', { goal: { phase: 'active' as const, objective: 'inspect files' } }], + ])('%s keeps ordinary generic-file intake enabled', (_name, projection) => { + const added = vi.fn(() => null) + const { view, slotCalls } = bench({ ...projection, addFiles: added }) + expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(false) + expect(view.container.querySelector('input[type="file"]')?.disabled).toBe(false) + expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true) + }) + it('parent-offline running continuable locks Send but keeps independent Stop usable', () => { const { button, interruptButton, textarea, stop, view } = bench({ running: true, diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index bdb878672b..5224973369 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -15,7 +15,7 @@ import { } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SubmitImageAttachment, SubmitOutcome } from '../src/client/contract/input.ts' +import type { SubmitAttachment, SubmitOutcome } from '../src/client/contract/input.ts' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { DraftAttachmentId } from '../src/client/contract/input.ts' @@ -63,10 +63,12 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, - addImages: () => null, - removeImage: () => {}, + addFiles: () => null, + useFileUploads: bindSnapshotSelector(createSnapshotStore({})), + retryFileUpload: undefined, + removeAttachment: () => {}, // Every id resolves so the bar's registry prune never drops a test image. - draftImages: ids => ids.map(id => ({ + resolveDraftAttachments: ids => ids.map(id => ({ kind: 'image' as const, id, file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }), previewUrl: `blob:${id}`, @@ -89,22 +91,22 @@ function bench(over?: { running?: boolean disabled?: boolean submit?: (args: string) => Promise - serialize?: (ids: readonly DraftAttachmentId[]) => Promise + serialize?: (ids: readonly DraftAttachmentId[]) => Promise }) { const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) - const serialize = vi.fn(over?.serialize ?? (() => Promise.resolve([]))) + const serialize = vi.fn(over?.serialize ?? (() => Promise.resolve([]))) const release = vi.fn() - const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) + const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandAttachments: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } }) const wiring = shell const view = mountBar(shell, over) const textarea = view.container.querySelector('[data-composer-input]')! - const claim = (token = '/goal ', hint = '目标', images?: true) => { + const claim = (token = '/goal ', hint = '目标', attachments?: true) => { act(() => { shell.setDraft(token) shell.beginCommand( { token, hint, - ...(images === true ? { images: true } : {}), + ...(attachments === true ? { attachments: true } : {}), submit: over?.submit ?? (() => Promise.resolve({ kind: 'success' as const, source: 'command', name: 'goal' })), }, { start: 0, end: token.length, draftRev: shell.snapshot.draftRev }, @@ -168,50 +170,52 @@ describe('matrix row: claimed', () => { }) }) -describe('matrix row: claimed with images', () => { +describe('matrix row: claimed with attachments', () => { const img = 'img-1' as DraftAttachmentId - it('a claim without image acceptance blocks enter: one notice, draft/images/claim retained', async () => { + it('a claim without attachment acceptance blocks enter: one notice and the full draft retained', async () => { const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const { view, textarea, shell, sink, claim } = bench({ submit }) claim() - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await Promise.resolve() expect(shell.snapshot.phase).toBe('claimed') expect(submit).not.toHaveBeenCalled() expect(sink).not.toHaveBeenCalled() - expect(view.getByText('/goal images-unsupported')).toBeTruthy() - expect(shell.snapshot.imageIds).toEqual([img]) + expect(view.getByText('/goal attachments-unsupported')).toBeTruthy() + expect(shell.snapshot.attachmentIds).toEqual([img]) expect(shell.snapshot.draft).toBe('/goal ') }) - it('an accepting claim serializes and forwards the images; success consumes and clears', async () => { + it('an accepting claim serializes and forwards a mixed batch; success consumes and clears', async () => { const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) - const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } - const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png]) }) + const png: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' } + const file: SubmitAttachment = { type: 'file', receiptId: 'receipt-x' } + const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png, file]) }) claim('/goal ', '目标', true) // The claim currency carries the acceptance flag the pre-gate reads. - expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标', images: true }) - act(() => { shell.addImages([img]) }) + expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标', attachments: true }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png]) }) + await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png, file]) }) expect(serialize).toHaveBeenCalledWith([img]) await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('') }) expect(release).toHaveBeenCalledWith([img]) - expect(shell.snapshot.imageIds).toEqual([]) + expect(shell.snapshot.attachmentIds).toEqual([]) expect(shell.snapshot.phase).toBe('plain') }) - it('a handler error outcome keeps the images unreleased beside the notice and the draft', async () => { + it('a handler error keeps a serialized file unreleased beside the notice and draft', async () => { const submit = vi.fn(() => Promise.resolve({ kind: 'error' as const, text: '处理失败' })) - const { view, textarea, shell, claim, release } = bench({ submit }) + const file: SubmitAttachment = { type: 'file', receiptId: 'receipt-x' } + const { view, textarea, shell, claim, release } = bench({ submit, serialize: () => Promise.resolve([file]) }) claim('/goal ', '目标', true) - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(view.getByText('处理失败')).toBeTruthy() }) expect(shell.snapshot.phase).toBe('claimed') - expect(shell.snapshot.imageIds).toEqual([img]) + expect(shell.snapshot.attachmentIds).toEqual([img]) expect(release).not.toHaveBeenCalled() expect(shell.snapshot.draft).toBe('/goal ') }) @@ -220,28 +224,28 @@ describe('matrix row: claimed with images', () => { const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const { view, textarea, shell, claim, release } = bench({ submit, serialize: () => Promise.reject(new Error('附件已失效')) }) claim('/goal ', '目标', true) - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(view.getByText('附件已失效')).toBeTruthy() }) expect(submit).not.toHaveBeenCalled() - expect(shell.snapshot.imageIds).toEqual([img]) + expect(shell.snapshot.attachmentIds).toEqual([img]) expect(release).not.toHaveBeenCalled() expect(shell.snapshot.phase).toBe('claimed') }) it('a disposed shell never lets a pending serialization reach claim.submit', async () => { const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) - let resolveSerialize!: (images: readonly SubmitImageAttachment[]) => void + let resolveSerialize!: (images: readonly SubmitAttachment[]) => void const { shell, textarea, claim } = bench({ submit, serialize: () => new Promise((resolve) => { resolveSerialize = resolve }), }) claim('/goal ', '目标', true) - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(resolveSerialize).toBeDefined() }) shell.dispose() - resolveSerialize([{ mediaType: 'image/png', data: 'AA==' }]) + resolveSerialize([{ type: 'image', mediaType: 'image/png', data: 'AA==' }]) await Promise.resolve() await Promise.resolve() expect(submit).not.toHaveBeenCalled() @@ -251,11 +255,13 @@ describe('matrix row: claimed with images', () => { const submit = vi.fn(() => new Promise(() => {})) // never settles const { shell, textarea, claim } = bench({ submit, serialize: () => Promise.resolve([]) }) claim('/goal ', '目标', true) - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(shell.snapshot.phase).toBe('submitting') - act(() => { shell.removeImage(img) }) - expect(shell.snapshot.imageIds).toEqual([img]) + let removed = true + act(() => { removed = shell.removeAttachment(img) }) + expect(removed).toBe(false) + expect(shell.snapshot.attachmentIds).toEqual([img]) }) }) diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index c0319e9e36..57f10879de 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -11,10 +11,10 @@ import type { DraftAttachmentId } from '../src/client/contract/input.ts' const mention = '@[Research](dsh-session:InNvdXJjZSI)' const spacedMention = '@[Research notes](dsh-session:InNvdXJjZSI)' -const commandImages = { +const commandAttachments = { serialize: () => Promise.resolve([]), release: () => {}, - unsupportedNotice: (token: string) => `${token.trim()} images-unsupported`, + unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported`, } function chip(shell: SessionInputShell): void { @@ -38,7 +38,7 @@ describe('reference submission', () => { const first = new SessionInputShell({ actx: {} as Context, defaultSink: vi.fn(), - commandImages, + commandAttachments, }) first.bindMirror(mirror) first.setDraft('@res') @@ -62,7 +62,7 @@ describe('reference submission', () => { const restored = new SessionInputShell({ actx: {} as Context, defaultSink: sink, - commandImages, + commandAttachments, }) restored.setDraft(mirror.mock.calls.at(-1)?.[0] as string) restored.submit() @@ -90,7 +90,7 @@ describe('reference submission', () => { actx: {} as Context, inputTriggers: () => inputTriggers, defaultSink: sink, - commandImages, + commandAttachments, }) chip(shell) expect(shell.snapshot).toMatchObject({ @@ -136,7 +136,7 @@ describe('reference submission', () => { actx: {} as Context, inputTriggers: () => inputTriggers, defaultSink: sink, - commandImages, + commandAttachments, }) chip(shell) shell.submit() @@ -160,7 +160,7 @@ describe('reference submission', () => { signal = received return new Promise(() => {}) }, - commandImages, + commandAttachments, }) shell.setDraft('send this') shell.submit() @@ -177,7 +177,7 @@ describe('reference submission', () => { const shell = new SessionInputShell({ actx: {} as Context, defaultSink: () => Promise.resolve({ kind: 'error' }), - commandImages, + commandAttachments, }) shell.setDraft('retry this') shell.submit() @@ -193,7 +193,7 @@ describe('reference submission', () => { const shell = new SessionInputShell({ actx: {} as Context, defaultSink: () => new Promise((resolve) => { settlements.push(resolve) }), - commandImages, + commandAttachments, }) shell.setDraft('first') shell.submit() @@ -215,18 +215,18 @@ describe('submit transaction hardening', () => { const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, - commandImages, + commandAttachments, }) - expect(shell.addImages(['img-1' as DraftAttachmentId])).toBe(true) + expect(shell.addAttachments(['img-1' as DraftAttachmentId])).toBe(true) shell.submit('queue') shell.submit('queue') expect(sink).toHaveBeenCalledTimes(1) settle({ kind: 'success' }) await vi.waitFor(() => { - expect(shell.snapshot.imageIds).toEqual([]) + expect(shell.snapshot.attachmentIds).toEqual([]) }) - expect(shell.addImages(['img-2' as DraftAttachmentId])).toBe(true) + expect(shell.addAttachments(['img-2' as DraftAttachmentId])).toBe(true) shell.submit('queue') expect(sink).toHaveBeenCalledTimes(2) }) @@ -236,14 +236,14 @@ describe('submit transaction hardening', () => { const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, - commandImages, + commandAttachments, }) const imageId = 'img-1' as DraftAttachmentId - shell.addImages([imageId]) + shell.addAttachments([imageId]) shell.submit() await Promise.resolve() await Promise.resolve() - expect(shell.snapshot.imageIds).toEqual([imageId]) + expect(shell.snapshot.attachmentIds).toEqual([imageId]) expect(shell.notices.getSnapshot()).toBeNull() }) @@ -256,9 +256,9 @@ describe('submit transaction hardening', () => { signal = received return new Promise(() => {}) }, - commandImages, + commandAttachments, }) - shell.addImages([imageId]) + shell.addAttachments([imageId]) shell.submit() expect(signal?.aborted).toBe(false) expect(shell.dispose()).toEqual([imageId]) @@ -272,7 +272,7 @@ describe('submit transaction hardening', () => { actx: {} as Context, inputTriggers: () => ({ track, lexicon } as unknown as InputTriggerController), defaultSink: vi.fn(), - commandImages, + commandAttachments, }) shell.setDraft('@sr') const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index ec7f586683..108385664f 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -17,7 +17,7 @@ import type { ClientSessionContext, SubmitEnvelope, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { - CommandClaim, PickOutcome, SubmitImageAttachment, SubmitOutcome, + CommandClaim, PickOutcome, SubmitAttachment, SubmitOutcome, } from '../src/client/contract/input.ts' import { bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime, @@ -44,19 +44,19 @@ afterEach(cleanup) interface FakeCommand { name: string description: string - input?: { hint: string; images?: boolean } + input?: { hint: string; attachments?: boolean } } /** Decision-table source over an in-memory directory (menu/space/enter columns for leadingInput + execute). */ function commandSource( commands: FakeCommand[], - execute: (line: string, images?: readonly SubmitImageAttachment[]) => Promise, + execute: (line: string, images?: readonly SubmitAttachment[]) => Promise, ) { const resolve = (name: string): FakeCommand | undefined => commands.find(c => c.name === name) const leadingClaim = (desc: FakeCommand): CommandClaim => ({ token: `/${desc.name} `, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), - ...(desc.input?.images === true ? { images: true } : {}), + ...(desc.input?.attachments === true ? { attachments: true } : {}), submit: (args, _actx, images) => execute(`/${desc.name} ${args}`, images), }) const executed: string[] = [] @@ -105,10 +105,10 @@ function commandSource( const COMMANDS: FakeCommand[] = [ { name: 'goal', description: '设定目标', input: { hint: '目标内容' } }, { name: 'compact', description: '压缩上下文' }, - { name: 'vision', description: '识别图片', input: { hint: '想问什么', images: true } }, + { name: 'vision', description: '识别图片', input: { hint: '想问什么', attachments: true } }, ] -const PNG: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } +const PNG: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' } /** Real scope bench: a Controller-owned Session scope + InputTriggerController + shell listeners. */ async function scopedBench(register?: (inputTriggers: InputTriggerService) => void) { @@ -125,7 +125,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const serialize = vi.fn((ids: readonly DraftAttachmentId[]) => Promise.resolve(ids.map(() => PNG))) const release = vi.fn() - const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) + const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink, commandAttachments: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } }) // The hub's listener wiring, verbatim. actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined) actx.on('slash/input-insert-reference', req => shell.insertReference(req.reference, req.span) ? true : undefined) @@ -152,10 +152,12 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, - addImages: () => null, - removeImage: () => {}, + addFiles: () => null, + useFileUploads: bindSnapshotSelector(createSnapshotStore({})), + retryFileUpload: undefined, + removeAttachment: () => {}, // Every id resolves so the bar's registry prune never drops a test image. - draftImages: ids => ids.map(id => ({ + resolveDraftAttachments: ids => ids.map(id => ({ kind: 'image' as const, id, file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }), previewUrl: `blob:${id}`, @@ -272,16 +274,16 @@ describe('scenario D: execute-kind /compact', () => { describe('scenario: images ride an accepting command through the real pipeline', () => { it('adjudication reports the image count; the claim chain serializes, submits, and consumes', async () => { const b = await bench() - act(() => { b.shell.addImages(['img-1' as DraftAttachmentId]) }) + act(() => { b.shell.addAttachments(['img-1' as DraftAttachmentId]) }) act(() => { b.shell.setDraft('/vision 这张图是什么') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/vision 这张图是什么', [PNG]) }) // The envelope the controller forwarded to matchEnter carried the count. - expect(b.envelopes).toEqual([{ images: 1 }]) + expect(b.envelopes).toEqual([{ attachments: 1 }]) expect(b.serialize).toHaveBeenCalledWith(['img-1']) await vi.waitFor(() => { expect(b.shell.snapshot.draft).toBe('') }) expect(b.release).toHaveBeenCalledWith(['img-1']) - expect(b.shell.snapshot.imageIds).toEqual([]) + expect(b.shell.snapshot.attachmentIds).toEqual([]) expect(b.sink).not.toHaveBeenCalled() }) @@ -290,7 +292,7 @@ describe('scenario: images ride an accepting command through the real pipeline', act(() => { b.shell.setDraft('/goal 发布') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布', []) }) - expect(b.envelopes).toEqual([{ images: 0 }]) + expect(b.envelopes).toEqual([{ attachments: 0 }]) expect(b.serialize).not.toHaveBeenCalled() expect(b.release).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 492bc16151..98225cb44d 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -65,7 +65,7 @@ function liveSession(initial: SessionSnapshot) { } } -const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] } +const INPUT_STATE: InputState = { draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] } const t: QueueDockProps['t'] = makeTranslate(zh, commonZh) @@ -124,13 +124,27 @@ describe('QueueDock', () => { placement: 'queued' as const, time: 1, text: '等待上传', - images: [{ previewUrl: 'blob:queue-preview', name: 'queue.png' }], + attachments: [ + { + type: 'image' as const, + value: { previewUrl: 'blob:queue-preview', name: 'queue.png' }, + }, + { + type: 'file' as const, + value: { + attachmentId: 'file-local' as never, + name: 'notes.txt', + bytes: 2447 * 1024 * 1024, + }, + }, + ], }], } const source = liveSession(pending) const view = render() expect(view.getByText('等待上传').closest('[data-submission-echo]')).not.toBeNull() expect(view.getByRole('img', { name: '排队消息图片' }).getAttribute('src')).toBe('blob:queue-preview') + expect(view.getByLabelText('排队文件 notes.txt').textContent).toContain('2.4GB') act(() => { source.push({ @@ -282,6 +296,35 @@ describe('QueueDock', () => { expect(container.querySelector('li')?.textContent).toBe('带图消息') }) + it('renders durable files and images in their original queue order', async () => { + const loadImage = vi.fn(() => Promise.resolve('blob:mixed')) + const mixed: QueuedMessage = { + id: iid('i-mixed'), messageId: 'message-i-mixed' as never, placement: 'queued', + content: [ + { + type: 'file', + attachment: { attachmentId: 'file-durable' as never, name: 'report.csv', bytes: 427 }, + }, + { + type: 'image', + attachment: { + attachmentId: 'image-durable' as never, + mediaType: 'image/png', bytes: 1, width: 1, height: 1, + }, + }, + ], + preview: '', text: null, + } + const snap = snapshotWith([mixed]) + const source = liveSession(snap) + const view = render() + await waitFor(() => { expect(view.container.querySelector('img')).not.toBeNull() }) + const group = view.getByLabelText('排队文件 report.csv').parentElement + expect(group?.children).toHaveLength(2) + expect(group?.children[0]?.getAttribute('aria-label')).toBe('排队文件 report.csv') + expect(group?.children[1]?.tagName).toBe('IMG') + }) + it('keeps the empty thumbnail placeholder when the image read fails', async () => { const loadImage = vi.fn(() => Promise.reject(new Error('read denied'))) const snap = snapshotWith([imageRow('i-broken', 'att-x')]) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 7ff7924133..ff46ef3680 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -6,15 +6,28 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import type { QueuedMessage } from '@deepseek-ai/dsh-api-session-controller/client' +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, UnsupportedImageMediaTypeError } from '../src/client/service.ts' +import { ConversationController } from '../src/client/service.ts' import { zh } from '../src/client/locales.ts' -async function bench() { +async function bench(maxConcurrentFileUploads = 2) { const runtime = await SlotTestRuntime.create() - const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) + runtime.fileUpload.available = true + runtime.fileUpload.upload = (sessionId: SessionId, ...args: unknown[]) => { + const session = runtime.sessions.behavior(sessionId) as { + uploadFile?: (...input: unknown[]) => Promise + } + if (session.uploadFile === undefined) throw new Error('test file upload has no Session override') + return session.uploadFile(...args) + } + const prompt = vi.fn(( + _content?: unknown, _mode?: unknown, _signal?: AbortSignal, _rpcId?: string, + ) => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const loadOlder = vi.fn(() => Promise.resolve()) @@ -28,6 +41,7 @@ async function bench() { const fiber = runtime.ctx.plugin(ConversationController, { input: hub, blocks: new ComposerBlockRegistry(), + maxConcurrentFileUploads, }) await fiber.await() const root = runtime.ctx.get('conversation') as ConversationController @@ -87,13 +101,13 @@ describe('ConversationController', () => { const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1') const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) try { - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }), ]) if (attachment === undefined) throw new Error('draft attachment missing') - b.root.input.for(b.runtime.sessions.scope('s1')!).addImages([attachment.id]) + b.root.input.for(b.runtime.sessions.scope('s1')!).addAttachments([attachment.id]) await b.runtime.sessions.remove('s1') - expect(b.root.draftImages([attachment.id])).toEqual([]) + expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([]) expect(revoked).toHaveBeenCalledWith('blob:draft-1') } finally { created.mockRestore() @@ -107,15 +121,15 @@ describe('ConversationController', () => { const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:detached') const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) try { - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'detached.png', { type: 'image/png' }), ]) if (attachment === undefined) throw new Error('draft attachment missing') - b.shell.addImages([attachment.id]) + b.shell.addAttachments([attachment.id]) b.shell.submit() - expect(b.shell.snapshot.imageIds).toEqual([]) + expect(b.shell.snapshot.attachmentIds).toEqual([]) await b.runtime.sessions.remove('s1') - expect(b.root.draftImages([attachment.id])).toEqual([]) + expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([]) expect(revoked).toHaveBeenCalledWith('blob:detached') } finally { created.mockRestore() @@ -124,18 +138,311 @@ describe('ConversationController', () => { await b.runtime.dispose() }) - it('validates every MIME type before allocating previews', async () => { + it('classifies image MIME drafts as images and every other file as an uploading file draft', async () => { const b = await bench() const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview') - expect(() => b.root.createDraftImages([ + const session = b.runtime.sessions.binding('s1')!.session + const uploadFile = vi.fn((_data: Blob | Uint8Array) => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'receipt-1' as never, + file: { attachmentId: 'sha256:1' as never, name: 'notes.pdf', bytes: 2 }, + }, + })) + ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const drafts = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }), - new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }), - ])).toThrow(UnsupportedImageMediaTypeError) - expect(created).not.toHaveBeenCalled() + new File([Uint8Array.of(2)], 'notes.pdf', { type: 'application/pdf' }), + ]) + expect(drafts.map(draft => draft.kind)).toEqual(['image', 'file']) + expect(created).toHaveBeenCalledTimes(1) + const fileDraft = drafts[1]! + expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('uploading') + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('ready') + }) + expect(uploadFile).toHaveBeenCalledOnce() + expect(uploadFile.mock.calls[0]?.[0]).toBe((fileDraft as { file: File }).file) created.mockRestore() await b.runtime.dispose() }) + it('serializes command files as staged receipts without reading their bytes again', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn((_file: Blob | Uint8Array, name?: string) => + Promise.resolve({ + ok: true as const, + value: { + receiptId: `receipt-${name}` as never, + file: { attachmentId: `file-${name}` as never, name: name ?? 'file', bytes: 1 }, + }, + })) + const drafts = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(1)], 'one.txt', { type: 'text/plain' }), + new File([Uint8Array.of(2)], 'two.txt', { type: 'text/plain' }), + ]) + await vi.waitFor(() => { + expect(drafts.every(draft => b.root.fileUploads.getSnapshot()[draft.id]?.status === 'ready')).toBe(true) + }) + function RejectingFileReader(): never { + throw new Error('generic file bytes were reread') + } + vi.stubGlobal('FileReader', RejectingFileReader) + try { + await expect(b.root.serializeDraftAttachments(drafts.map(draft => draft.id))).resolves.toEqual({ + attachments: [ + { type: 'file', receiptId: 'receipt-one.txt' }, + { type: 'file', receiptId: 'receipt-two.txt' }, + ], + }) + } finally { + vi.unstubAllGlobals() + } + await b.runtime.dispose() + }) + + it('bounds upload Workers, advances on settlement, and skips a queued file removed by the user', async () => { + const b = await bench(2) + const session = b.runtime.sessions.binding('s1')!.session + type UploadResult = { + ok: true + value: { receiptId: never; file: { attachmentId: never; name: string; bytes: number } } + } + const gates = new Map void }>() + let active = 0 + let maxActive = 0 + const uploadFile = vi.fn((_file: Blob | Uint8Array, name?: string) => { + const fileName = name ?? 'unnamed' + const gate = Promise.withResolvers() + gates.set(fileName, gate) + active += 1 + maxActive = Math.max(maxActive, active) + return gate.promise.finally(() => { active -= 1 }) + }) + ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const drafts = b.root.createDrafts(session.sessionId, ['one', 'two', 'three', 'four', 'removed'].map(name => + new File([Uint8Array.of(1)], `${name}.txt`, { type: 'text/plain' }))) + + expect(uploadFile.mock.calls.map(call => call[1])).toEqual(['one.txt', 'two.txt']) + await expect(b.root.serializeDraftAttachments([drafts[2]!.id])) + .rejects.toThrow('one or more files have not finished uploading') + b.root.releaseDraftAttachment(drafts[4]!.id) + + const complete = (name: string) => gates.get(name)?.resolve({ + ok: true, + value: { + receiptId: `receipt-${name}` as never, + file: { attachmentId: `file-${name}` as never, name, bytes: 1 }, + }, + }) + complete('one.txt') + await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledTimes(3) }) + complete('two.txt') + await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledTimes(4) }) + complete('three.txt') + complete('four.txt') + await vi.waitFor(() => { + expect(drafts.slice(0, 4).every(draft => + b.root.fileUploads.getSnapshot()[draft.id]?.status === 'ready')).toBe(true) + }) + expect(maxActive).toBe(2) + expect(uploadFile.mock.calls.map(call => call[1])).not.toContain('removed.txt') + expect(b.root.fileUploads.getSnapshot()[drafts[4]!.id]).toBeUndefined() + await b.runtime.dispose() + }) + + it('keeps one upload alive and observable while another Session is open', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + const settled = Promise.withResolvers<{ + ok: true + value: { receiptId: never; file: { attachmentId: never; name: string; bytes: number } } + }>() + let reportProgress: ((progress: { loaded: number; total?: number }) => void) | undefined + const uploadFile = vi.fn(( + _file: Blob | Uint8Array, + _name?: string, + _signal?: AbortSignal, + onProgress?: (progress: { loaded: number; total?: number }) => void, + ) => { + reportProgress = onProgress + return settled.promise + }) + ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const [attachment] = b.root.createDrafts(session.sessionId, [ + new File([new Uint8Array(8)], 'background.bin', { type: 'application/octet-stream' }), + ]) + if (attachment === undefined) throw new Error('file draft missing') + b.shell.addAttachments([attachment.id]) + await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() }) + + await b.runtime.sessions.add({ + id: 's2', + session: { + prompt: b.prompt, updateQueue: b.updateQueue, cancel: b.cancel, loadOlder: b.loadOlder, + }, + }) + b.runtime.sessions.open('s2' as never) + reportProgress?.({ loaded: 3, total: 8 }) + expect(b.root.fileUploads.getSnapshot()[attachment.id]).toEqual({ + status: 'uploading', loaded: 3, total: 8, + }) + expect(b.shell.snapshot.attachmentIds).toEqual([attachment.id]) + + b.runtime.sessions.open('s1' as never) + settled.resolve({ + ok: true, + value: { + receiptId: 'background-receipt' as never, + file: { attachmentId: 'background-file' as never, name: 'background.bin', bytes: 8 }, + }, + }) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[attachment.id]?.status).toBe('ready') + }) + expect(b.shell.snapshot.attachmentIds).toEqual([attachment.id]) + await b.runtime.dispose() + }) + + it('cancels a superseded upload and stages the carried draft on the target Session', async () => { + const b = await bench() + const source = b.runtime.sessions.binding('s1')!.session + let sourceSignal: AbortSignal | undefined + const sourceUpload = vi.fn((_data: Blob | Uint8Array, _name?: string, signal?: AbortSignal) => { + sourceSignal = signal + return new Promise((resolve) => { + signal?.addEventListener('abort', () => { + resolve({ ok: false, error: { message: 'aborted' } }) + }, { once: true }) + }) + }) + ;(source as { uploadFile?: unknown }).uploadFile = sourceUpload + const [attachment] = b.root.createDrafts(source.sessionId, [ + new File([Uint8Array.of(4)], 'carry.pdf', { type: 'application/pdf' }), + ]) + if (attachment === undefined) throw new Error('file draft missing') + await vi.waitFor(() => { expect(sourceUpload).toHaveBeenCalledOnce() }) + + const target = { + uploadFile: vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'target-receipt' as never, + file: { attachmentId: 'target-file' as never, name: 'carry.pdf', bytes: 1 }, + }, + })), + } + await b.runtime.sessions.add({ id: 's2', session: target }) + b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [attachment.id]) + + expect(sourceSignal?.aborted).toBe(true) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[attachment.id]).toEqual({ + status: 'ready', receiptId: 'target-receipt', + file: { attachmentId: 'target-file', name: 'carry.pdf', bytes: 1 }, + }) + }) + await b.runtime.dispose() + }) + + it('cancels a file upload when its draft is removed', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + let uploadSignal: AbortSignal | undefined + const uploadFile = vi.fn((_data: Blob | Uint8Array, _name?: string, signal?: AbortSignal) => { + uploadSignal = signal + return new Promise((resolve) => { + signal?.addEventListener('abort', () => { + resolve({ ok: false, error: { message: 'aborted' } }) + }, { once: true }) + }) + }) + ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const [attachment] = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(5)], 'removed.pdf', { type: 'application/pdf' }), + ]) + if (attachment === undefined) throw new Error('file draft missing') + await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() }) + + b.root.releaseDraftAttachment(attachment.id) + + expect(uploadSignal?.aborted).toBe(true) + expect(b.root.fileUploads.getSnapshot()[attachment.id]).toBeUndefined() + await b.runtime.dispose() + }) + + it('keeps the accepted file draft until its rpcId appears in the Host queue', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + let retire: ((retirement: unknown) => void) | undefined + ;(session as unknown as { beginSubmission: (input: { onRetire?: (retirement: unknown) => void }) => unknown }) + .beginSubmission = (input) => { + retire = input.onRetire + return { requestId: 'file-rpc-id', abandon: vi.fn() } + } + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'send-receipt' as never, + file: { attachmentId: 'send-file' as never, name: 'sent.pdf', bytes: 1 }, + }, + })) + const [attachment] = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(6)], 'sent.pdf', { type: 'application/pdf' }), + ]) + if (attachment === undefined) throw new Error('file draft missing') + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[attachment.id]?.status).toBe('ready') + }) + + const sending = b.root.sendSession(session, 'read', [attachment.id], 'queue') + await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) + + expect(b.prompt).toHaveBeenCalledWith([ + { type: 'file', receiptId: 'send-receipt' }, + { type: 'text', text: 'read' }, + ], 'queue', undefined, expect.any(String)) + expect(b.root.resolveDraftAttachments([attachment.id])).toHaveLength(1) + expect(b.prompt.mock.calls[0]?.[3]).toBe('file-rpc-id') + retire?.({ + reason: 'observed', + attachments: [{ attachmentId: 'send-file', name: 'sent.pdf', bytes: 1 }], + }) + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([]) + await b.runtime.dispose() + }) + + it('awaits an aborted upload before disposing the service', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + let uploadSignal: AbortSignal | undefined + let finishUpload: (() => void) | undefined + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn( + (_data: Uint8Array, _name?: string, signal?: AbortSignal) => { + uploadSignal = signal + return new Promise((resolve) => { + finishUpload = () => { resolve({ ok: false, error: { message: 'aborted' } }) } + }) + }, + ) + b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(7)], 'dispose.pdf', { type: 'application/pdf' }), + ]) + await vi.waitFor(() => { expect(uploadSignal).toBeDefined() }) + + let disposed = false + const disposal = b.fiber.dispose().then(() => { disposed = true }) + await vi.waitFor(() => { expect(uploadSignal?.aborted).toBe(true) }) + await Promise.resolve() + expect(disposed).toBe(false) + finishUpload?.() + await disposal + expect(disposed).toBe(true) + await b.runtime.dispose() + }) + it('fails loudly from the root scope, on an unbound session, or without Client Sessions', async () => { const b = await bench() await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/) @@ -147,6 +454,7 @@ describe('ConversationController', () => { await bare.plugin(ConversationController, { input: new InputHub(bare, makeTranslate(zh, {})), blocks: new ComposerBlockRegistry(), + maxConcurrentFileUploads: 2, }).await() const orphan = bare.get('conversation') as ConversationController await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/) @@ -157,9 +465,9 @@ describe('sendSession submission echo', () => { /** Bench with an observable beginSubmission on the session face. */ async function echoBench() { const b = await bench() - const retire: { onRetire?: ((retirement: unknown) => void) | undefined } = {} + const retire: { onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined } = {} const abandon = vi.fn() - const beginSubmission = vi.fn((input: { onRetire?: (retirement: unknown) => void }) => { + const beginSubmission = vi.fn((input: BeginSubmissionInput) => { retire.onRetire = input.onRetire return { requestId: 'req-echo' as never, abandon } }) @@ -178,17 +486,18 @@ describe('sendSession submission echo', () => { it('registers the echo before serialization and prompts with its identity', async () => { const b = await echoBench() try { - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }), ]) const session = b.runtime.sessions.binding('s1')!.session const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue') // Synchronous: the echo is registered before any encoding starts. - expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ - mode: 'queue', - text: '带图', - images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })], - })) + const echo = b.beginSubmission.mock.calls[0]?.[0] + expect(echo?.mode).toBe('queue') + expect(echo?.text).toBe('带图') + expect(echo?.attachments).toHaveLength(1) + expect(echo?.attachments[0]?.type).toBe('image') + expect(echo?.attachments[0]?.value).toMatchObject({ previewUrl: 'blob:echo-1', name: 'a.png' }) expect(b.prompt).not.toHaveBeenCalled() await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) expect(b.prompt).toHaveBeenCalledWith( @@ -201,10 +510,10 @@ describe('sendSession submission echo', () => { 'req-echo', ) // The draft stays registered until the echo's observed retirement. - expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + expect(b.root.resolveDraftAttachments([attachment!.id])).toHaveLength(1) b.retire.onRetire?.({ reason: 'observed', attachments: [] }) await expect(sending).resolves.toEqual({ kind: 'success' }) - expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.root.resolveDraftAttachments([attachment!.id])).toEqual([]) expect(b.revoked).toHaveBeenCalledWith('blob:echo-1') } finally { b.restore() @@ -212,6 +521,70 @@ describe('sendSession submission echo', () => { await b.runtime.dispose() }) + it('preserves mixed image/file selection order through echo, prompt, and observed retirement', async () => { + const b = await echoBench() + try { + const session = b.runtime.sessions.binding('s1')!.session + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'mixed-file-receipt' as never, + file: { attachmentId: 'mixed-file' as never, name: 'notes.txt', bytes: 1 }, + }, + })) + const drafts = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }), + new File([Uint8Array.of(2)], 'notes.txt', { type: 'text/plain' }), + new File([Uint8Array.of(3)], 'last.png', { type: 'image/png' }), + ]) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[drafts[1]!.id]?.status).toBe('ready') + }) + const sending = b.root.sendSession(session, 'ordered', drafts.map(draft => draft.id), 'steer') + const echo = b.beginSubmission.mock.calls[0]?.[0] + expect(echo?.mode).toBe('steer') + expect(echo?.attachments.map(attachment => attachment.type === 'image' + ? { type: attachment.type, name: attachment.value.name } + : { type: attachment.type, value: attachment.value })).toEqual([ + { type: 'image', name: 'first.png' }, + { type: 'file', value: { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 } }, + { type: 'image', name: 'last.png' }, + ]) + await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) + expect(b.prompt.mock.calls[0]?.[0]).toEqual([ + { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'first.png' }, + { type: 'file', receiptId: 'mixed-file-receipt' }, + { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'last.png' }, + { type: 'text', text: 'ordered' }, + ]) + b.retire.onRetire?.({ + reason: 'observed', + attachments: [ + { + attachmentId: 'image-first' as never, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + { attachmentId: 'mixed-file' as never, name: 'notes.txt', bytes: 1 }, + { + attachmentId: 'image-last' as never, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + ], + }) + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.root.resolveDraftAttachments(drafts.map(draft => draft.id))).toEqual([]) + } finally { + b.restore() + } + await b.runtime.dispose() + }) + it('passes each delivery mode before image serialization', async () => { const b = await echoBench() try { @@ -241,17 +614,23 @@ describe('sendSession submission echo', () => { const seedImageUrl = vi.fn(() => true) b.runtime.ctx.provide('uiConversation') b.runtime.ctx.set('uiConversation', { seedImageUrl }) - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }), ]) const session = b.runtime.sessions.binding('s1')!.session const sending = b.root.sendSession(session, '', [attachment!.id], 'queue') await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) - const ref = { attachmentId: 'att-1' } + const ref = { + attachmentId: 'att-1' as never, + mediaType: 'image/png' as const, + bytes: 1, + width: 1, + height: 1, + } b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) await expect(sending).resolves.toEqual({ kind: 'success' }) expect(seedImageUrl).toHaveBeenCalledWith('s1', ref, 'blob:echo-1') - expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.root.resolveDraftAttachments([attachment!.id])).toEqual([]) expect(b.revoked).not.toHaveBeenCalled() // Failed retirement keeps nothing to do; a second retire of released ids is a no-op. b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) @@ -261,20 +640,31 @@ describe('sendSession submission echo', () => { await b.runtime.dispose() }) - it('keeps the drafts registered when the echo retires as failed (composer restore path)', async () => { + it('keeps mixed image and file drafts when the echo retires as failed', async () => { const b = await echoBench() try { + const session = b.runtime.sessions.binding('s1')!.session + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'kept-file-receipt' as never, + file: { attachmentId: 'kept-file' as never, name: 'kept.txt', bytes: 1 }, + }, + })) b.prompt.mockResolvedValueOnce({ ok: false, error: new RemoteError('session/attachment-invalid', 'nope', { reason: 'nope' }), } as never) - const [attachment] = b.root.createDraftImages([ + const attachments = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }), + new File([Uint8Array.of(8)], 'kept.txt', { type: 'text/plain' }), ]) - const session = b.runtime.sessions.binding('s1')!.session - await expect(b.root.sendSession(session, '失败', [attachment!.id], 'queue')) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[attachments[1]!.id]?.status).toBe('ready') + }) + await expect(b.root.sendSession(session, '失败', attachments.map(attachment => attachment.id), 'queue')) .resolves.toEqual({ kind: 'error' }) b.retire.onRetire?.({ reason: 'failed' }) - expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + expect(b.root.resolveDraftAttachments(attachments.map(attachment => attachment.id))).toHaveLength(2) expect(b.revoked).not.toHaveBeenCalled() } finally { b.restore() @@ -294,7 +684,7 @@ describe('sendSession submission echo', () => { } vi.stubGlobal('FileReader', FailingReader) try { - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }), ]) const session = b.runtime.sessions.binding('s1')!.session @@ -374,15 +764,17 @@ describe('draft image dimension probe', () => { } vi.stubGlobal('Image', InstantImage) try { - const [probed] = b.root.createDraftImages([ + const [probed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }), ]) expect(probed).toMatchObject({ width: 640, height: 480 }) vi.stubGlobal('Image', undefined) - const [unprobed] = b.root.createDraftImages([ + const [unprobed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }), ]) - expect(unprobed?.width).toBeUndefined() + expect(unprobed?.kind).toBe('image') + if (unprobed?.kind !== 'image') throw new Error('image draft missing') + expect(unprobed.width).toBeUndefined() } finally { vi.unstubAllGlobals() created.mockRestore() diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index b431a51ff8..2c20f198c5 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -40,7 +40,7 @@ Range.prototype.getBoundingClientRect = () => ({ function fakeWiring() { const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) - const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) + const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } }) return { wiring: shell, sink, shell } } @@ -249,9 +249,11 @@ function mount( useInput={useInput} inputActions={inputActions} keyboard={wiring} - addImages={() => null} - removeImage={() => {}} - draftImages={() => []} + addFiles={() => null} + useFileUploads={bindSnapshotSelector(createSnapshotStore({}))} + retryFileUpload={undefined} + removeAttachment={() => {}} + resolveDraftAttachments={() => []} resolveSubmitMode={() => 'queue'} toggleCommandMenu={vi.fn()} useNotices={bindSnapshotSelector(wiring.notices)} diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index af1e72f47e..73d16d0235 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -170,9 +170,9 @@ describe('submit-machine: adjudication outcomes', () => { describe('submit-machine: claimed lifecycle', () => { it('the claim event enters claimed and snapshots hint and images bits', () => { const m = new SubmitMachine() - m.dispatch({ type: 'claim', claim: { ...claimOf('goal', 'set a goal'), images: true } }) + m.dispatch({ type: 'claim', claim: { ...claimOf('goal', 'set a goal'), attachments: true } }) expect(m.state.phase).toBe('claimed') - expect(m.state.claim).toMatchObject({ token: '/goal ', hint: 'set a goal', images: true }) + expect(m.state.claim).toMatchObject({ token: '/goal ', hint: 'set a goal', attachments: true }) }) it('claimed overwrites in place — no stack', () => { @@ -347,6 +347,14 @@ describe('decorations: scanTextRefs', () => { expect(scanTextRefs('/research @goal', lexicon)).toEqual([]) }) + it('a "/" token continued by a path never matches, even when the name is on the lexicon', () => { + expect(scanTextRefs('/goal/x /goal/ /goal.md', lexicon)).toEqual([]) + }) + + it('a "/" token glued to punctuation is not a reference: the host gesture is whitespace-bounded', () => { + expect(scanTextRefs('/goal。 then /goal, now', lexicon)).toEqual([]) + }) + it('word boundary: a trigger glued to text never matches', () => { expect(scanTextRefs('x/goal y@research', lexicon)).toEqual([]) }) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 096dec8b0d..1290b0416d 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -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/ui-goal/README.md -README.md: 1a2c65759f5983fbece83f538ee9ee09902dd020 -README.zh.md: feebeaff0b5e7e9258f15637d0a78a897e0f24c1 +README.md: 8556575b00e1d3073f251d83f3dee0bafb4c8969 +README.zh.md: 22d0417bc2d37719150ec46aad849ad2bce8b460 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 1a2c65759f..8556575b00 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and the goal domain package; the s ### The command-input bubble -Each durable `/goal` run projects as a right-aligned monospace user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. +Each durable `/goal` run projects as a right-aligned user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row; the leading `/goal` token renders as a command reference chip in the code face through ui-primitives `projectUserText`, and the objective stays plain body text. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. ### Failures diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index feebeaff0b..22d0417bc2 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 指令输入气泡 -每条持久的 `/goal` 运行都投影为一个右对齐的等宽用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 +每条持久的 `/goal` 运行都投影为一个右对齐的用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前;开头的 `/goal` token 经 ui-primitives 的 `projectUserText` 以等宽代码字体渲染为指令引用 chip,目标文本保持正文字体。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 ### 失败 diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.module.css b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css index f403730eff..42fff6430e 100644 --- a/packages/client/ui-goal/src/client/GoalCommandInputView.module.css +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css @@ -23,10 +23,10 @@ border-radius: 22px; background: var(--dsw-specific-bubble); color: var(--dsw-alias-label-primary); - font: var(--dsw-font-markdown-code); - /* The command echo is a user message, not dense code-in-prose: keep the - code family from the token but read at the body size, riding the - Settings font-size axis like the user bubble (14/22 at the default). */ + /* The command echo is a user message: it inherits the body face like the + user bubble and rides the same Settings font-size axis (14/22 at the + default); the `/goal` command chip, set in the code face by the shared + projection, is what marks the line as a command. */ font-size: var(--dsh-content-font-size, 14px); line-height: calc(22px + var(--dsh-content-font-delta, 0px)); white-space: pre-wrap; diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx index 6d2345042b..8d9742d45d 100644 --- a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx @@ -1,18 +1,28 @@ import { memo } from 'react' -import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { projectUserText } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { GoalCommandInputData } from './goal-command-input.ts' +import { GOAL_COMMAND, type GoalCommandInputData } from './goal-command-input.ts' import css from './GoalCommandInputView.module.css' type GoalCommandInputViewProps = PropsRuntime<'conversation.chat.node', 'command-input'> & PropsLocale<'goal'> -/** Right-aligned `/goal` input bubble without ordinary message actions. */ +/** + * Right-aligned `/goal` input bubble without ordinary message actions. The + * echoed line decorates its leading `/goal` token as a command chip — the run + * this Node projects is the fact that that token was a command — and keeps + * the objective, `/goal` mentions included, as plain text. + */ export const GoalCommandInputView = memo(function GoalCommandInputView({ node, t, }: GoalCommandInputViewProps) { const data: GoalCommandInputData = node.data + // Only the leading token is the executed command; the rest of the line is + // the objective, where a further `/goal` is prose. + const split = data.text.search(/\s/u) + const head = split === -1 ? data.text : data.text.slice(0, split) + const rest = split === -1 ? '' : data.text.slice(split) return (
- + {projectUserText(head, [], [GOAL_COMMAND], 'command')} + {rest !== '' && projectUserText(rest, [])}
diff --git a/packages/client/ui-goal/src/client/goal-command-input.ts b/packages/client/ui-goal/src/client/goal-command-input.ts index 526dc0958d..742959a349 100644 --- a/packages/client/ui-goal/src/client/goal-command-input.ts +++ b/packages/client/ui-goal/src/client/goal-command-input.ts @@ -5,6 +5,9 @@ import type { ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' +/** The command name whose runs this projection owns. */ +export const GOAL_COMMAND = 'goal' + /** Goal-owned human command input projected independently of model messages. */ export interface GoalCommandInputData { readonly commandId: CommandId @@ -36,7 +39,7 @@ export function goalCommandText(event: SessionEvent<'command/run'>): string { export const goalCommandInputDefinition: ConversationNodeDefinition = { kind: 'goal-command-input', target: 'chat', - match: event => event.type === 'command/run' && event.data.name === 'goal' + match: event => event.type === 'command/run' && event.data.name === GOAL_COMMAND ? { id: String(event.data.commandId), role: 'start' } : null, start: (_context, match) => { diff --git a/packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts b/packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts new file mode 100644 index 0000000000..41b8fcaa4f --- /dev/null +++ b/packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts @@ -0,0 +1,31 @@ +/** + * The command-input bubble's typography as CSS text. jsdom has no layout, so + * this reads the declarations that make the bubble share the user bubble's + * face and size axis: the shared projection sets the `/goal` chip in the code + * face, so the bubble itself must not pin a different family. + */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync( + fileURLToPath(new URL('../src/client/GoalCommandInputView.module.css', import.meta.url)), + 'utf8', +).replace(/\/\*[\s\S]*?\*\//g, ' ') + +function declarations(selector: string): string[] { + const rule = new RegExp(`(?:^|\\})\\s*${selector.replace(/[.[\]():*+^$\\]/g, '\\$&')}\\s*\\{([^{}]*)\\}`).exec(css) + if (rule === null) throw new Error(`GoalCommandInputView.module.css has no \`${selector}\` rule`) + return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) +} + +describe('GoalCommandInputView.module.css typography', () => { + it('types the command bubble like the user bubble: no family override, same size axis', () => { + const bubble = declarations('.bubble') + expect(bubble.some(declaration => /^font(-family)?:/.test(declaration))).toBe(false) + expect(bubble).toEqual(expect.arrayContaining([ + 'font-size: var(--dsh-content-font-size, 14px)', + 'line-height: calc(22px + var(--dsh-content-font-delta, 0px))', + ])) + }) +}) diff --git a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx index d149cd7f4f..ad03572d4c 100644 --- a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx +++ b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx @@ -133,5 +133,39 @@ describe('goal command input projection', () => { expect(bubble.textContent).toBe('/goal ship it') expect(within(bubble).queryByRole('button')).toBeNull() + // The executed command token reads as a reference chip; the objective stays plain text. + const chips = [...bubble.querySelectorAll('[data-ref-chip]')] + expect(chips.map(chip => [chip.getAttribute('data-ref-chip'), chip.textContent])).toEqual([['command', '/goal']]) + }) + + it('renders a bare /goal as one command chip and nothing else', () => { + const t = makeTranslate(zh, commonZh) + const props = { + node: { + key: 'goal-command-input:bare', + data: { commandId: 'command-goal', text: '/goal', time: 1_700_000_000_000 }, + }, + t, + } as unknown as Parameters[0] + const view = render() + const bubble = view.getByRole('group', { name: '指令输入' }) + expect(bubble.textContent).toBe('/goal') + expect([...bubble.querySelectorAll('[data-ref-chip]')].map(chip => chip.textContent)).toEqual(['/goal']) + }) + + it('decorates only the leading command token: a /goal inside the objective stays plain', () => { + const t = makeTranslate(zh, commonZh) + const props = { + node: { + key: 'goal-command-input:two', + data: { commandId: 'command-goal', text: '/goal 检查 /goal 的语法', time: 1_700_000_000_000 }, + }, + t, + } as unknown as Parameters[0] + const view = render() + const bubble = view.getByRole('group', { name: '指令输入' }) + expect(bubble.textContent).toBe('/goal 检查 /goal 的语法') + const chips = [...bubble.querySelectorAll('[data-ref-chip]')] + expect(chips.map(chip => chip.textContent)).toEqual(['/goal']) }) }) diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index 4860b5ce9d..9141210c0e 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -25,7 +25,7 @@ export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CandidateRequest, ClientSessionContext, CommandClaim, ConsumeTokenRequest, HeaderRequest, InsertReferenceRequest, PickOutcome, PickVia, ReferenceCodec, ReferenceInsert, InputTriggerCandidate, InputTriggerCrumb, InputTriggerPick, - InputTriggerSource, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, TokenSpan, TriggerChar, + InputTriggerSource, SubmitAttachment, SubmitEnvelope, SubmitOutcome, TokenSpan, TriggerChar, TriggerGuard, TriggerPosition, } from '../types.ts' export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts' diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index bd862a71a1..acbe2a65a3 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -14,7 +14,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, - InsertReferenceRequest, InsertTextRequest, PickOutcome, ReferenceInsert, SubmitImageAttachment, + InsertReferenceRequest, InsertTextRequest, PickOutcome, ReferenceInsert, SubmitAttachment, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -95,8 +95,8 @@ export interface HeaderRequest { * presence to accept or refuse a whole submission. */ export interface SubmitEnvelope { - /** Number of image attachments accompanying the draft. */ - readonly images: number + /** Number of attachments accompanying the draft. */ + readonly attachments: number } /** Candidate request passed to a source. The signal is superseded on query change / menu close. */ diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index 0d081edffc..a1243a7513 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -1074,7 +1074,7 @@ describe('adjudicate', () => { return Promise.resolve('handled') }), ]) - const result = await controller.adjudicate('/goal make it fast', new AbortController().signal, { images: 0 }) + const result = await controller.adjudicate('/goal make it fast', new AbortController().signal, { attachments: 0 }) expect(result).toEqual({ claim }) expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast']) }) @@ -1085,7 +1085,7 @@ describe('adjudicate', () => { enterSource('@', 'subagent', atHook), enterSource('/', 'command', () => Promise.resolve(undefined)), ]) - await expect(controller.adjudicate('/xyz', new AbortController().signal, { images: 0 })).resolves.toBeUndefined() + await expect(controller.adjudicate('/xyz', new AbortController().signal, { attachments: 0 })).resolves.toBeUndefined() expect(atHook).not.toHaveBeenCalled() }) @@ -1101,7 +1101,7 @@ describe('adjudicate', () => { return Promise.resolve('handled') }), ]) - const envelope = { images: 2 } + const envelope = { attachments: 2 } await controller.adjudicate('/goal', new AbortController().signal, envelope) expect(envelopes).toEqual([envelope, envelope]) expect(envelopes[0]).toBe(envelope) @@ -1112,7 +1112,7 @@ describe('adjudicate', () => { enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))), enterSource('/', 'late', () => Promise.resolve('handled')), ]) - await expect(controller.adjudicate('/goal x', new AbortController().signal, { images: 0 })) + await expect(controller.adjudicate('/goal x', new AbortController().signal, { attachments: 0 })) .rejects.toThrow('warmup failed') }) @@ -1121,7 +1121,7 @@ describe('adjudicate', () => { const { controller } = controllerBench([enterSource('/', 'command', hook)]) const abort = new AbortController() abort.abort(new Error('attempt released')) - await expect(controller.adjudicate('/goal', abort.signal, { images: 0 })).rejects.toThrow('attempt released') + await expect(controller.adjudicate('/goal', abort.signal, { attachments: 0 })).rejects.toThrow('attempt released') expect(hook).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 9b7c3a7285..0036b684de 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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/ui-primitives/README.md -README.md: 78cb6caec665687feb1c18c65e1afe47c6e01ea1 -README.zh.md: 1450de83f88e4e33be4616ebd1f70588dd19e8d2 +README.md: 758132bc9c193594a9407a105820633c75e58880 +README.zh.md: 8b3fffa9e73464d526c3a2f11b212797c2707c69 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 78cb6caec6..758132bc9c 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -29,11 +29,11 @@ Compose feature UI from these atoms whenever the web client needs a standard con ### Controls and icons -`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. +`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order ([ranking decision](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)). ### Rendering agent output -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `MessageText` remains the literal-text primitive for user-authored content. +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. ### Localizing copy diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 1450de83f8..8b3fffa9e7 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -29,11 +29,11 @@ kind: "package-library" ### 控件与图标 -`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 +`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序([排名决策](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md))。 ### 渲染 agent 输出 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`MessageText` 仍是用户创作内容的字面文本原语。 +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 ### 本地化文案 diff --git a/packages/client/ui-primitives/src/DocumentFileIcon.tsx b/packages/client/ui-primitives/src/DocumentFileIcon.tsx new file mode 100644 index 0000000000..743a01a3ff --- /dev/null +++ b/packages/client/ui-primitives/src/DocumentFileIcon.tsx @@ -0,0 +1,41 @@ +import { useId } from 'react' + +/** + * Render the DeepSeek Web document glyph used by generic-file cards. + * @param props - optional CSS class for sizing and placement. + * @returns a decorative document SVG with an instance-safe gradient id. + */ +export function DocumentFileIcon({ className }: { readonly className?: string | undefined }) { + const gradientId = useId() + return ( + + + + + + + + + + + + ) +} diff --git a/packages/client/ui-primitives/src/file-size.ts b/packages/client/ui-primitives/src/file-size.ts new file mode 100644 index 0000000000..16d5ecacf9 --- /dev/null +++ b/packages/client/ui-primitives/src/file-size.ts @@ -0,0 +1,16 @@ +/** Compact human-readable byte counts shared by attachment presenters. @module @deepseek-ai/dsh-client-ui-primitives/file-size */ + +/** + * Byte count as compact user-facing size text (`312B`, `4.2KB`, `1.5MB`, `2.4GB`). + * @param bytes - exact byte count. + * @returns whole-unit text with one decimal below ten of the chosen unit. + */ +export function fileSizeText(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + const kb = bytes / 1024 + if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)}KB` + const mb = kb / 1024 + if (mb < 1024) return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)}MB` + const gb = mb / 1024 + return `${gb < 10 ? gb.toFixed(1) : Math.round(gb)}GB` +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index c5f88e5eee..3f74a17cdb 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -32,8 +32,11 @@ export { projectUserText } from './user-text.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' export { Toast } from './Toast.tsx' +export { fileSizeText } from './file-size.ts' +export { DocumentFileIcon } from './DocumentFileIcon.tsx' export { writeClipboard } from './clipboard.ts' export { relativeTime } from './relative-time.ts' +export { rankByName } from './rank-by-name.ts' export type { RelativeTime, RelativeTimeUnit } from './relative-time.ts' export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' @@ -57,7 +60,6 @@ export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './markdown/MarkdownText.tsx' -export { MessageText } from './markdown/MessageText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts' export * from './icons/index.tsx' diff --git a/packages/client/ui-primitives/src/markdown/MessageText.module.css b/packages/client/ui-primitives/src/markdown/MessageText.module.css deleted file mode 100644 index c6a9c9b245..0000000000 --- a/packages/client/ui-primitives/src/markdown/MessageText.module.css +++ /dev/null @@ -1,9 +0,0 @@ -.text { - white-space: pre-wrap; - word-break: break-word; - /* Font metrics inherit from the consumer's container (bubble 16/24, - assistant flow 16/28) — a generic text primitive must not pin its own - size, or every consumer's line grid breaks (44px bubble spec regression). */ - font-size: inherit; - line-height: inherit; -} diff --git a/packages/client/ui-primitives/src/markdown/MessageText.tsx b/packages/client/ui-primitives/src/markdown/MessageText.tsx deleted file mode 100644 index cafe9ab3c0..0000000000 --- a/packages/client/ui-primitives/src/markdown/MessageText.tsx +++ /dev/null @@ -1,7 +0,0 @@ -// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText. - -import css from './MessageText.module.css' - -export function MessageText({ text }: { text: string }) { - return
{text}
-} diff --git a/packages/client/ui-primitives/src/rank-by-name.ts b/packages/client/ui-primitives/src/rank-by-name.ts new file mode 100644 index 0000000000..97d6903728 --- /dev/null +++ b/packages/client/ui-primitives/src/rank-by-name.ts @@ -0,0 +1,79 @@ +/** + * Shared ranking for `/` menu candidates: the query must be a + * case-insensitive ordered subsequence of the candidate name. Prefix hits + * rank first, then the strongest alignment score, then the source order of + * the input. Decision record: + * .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md + */ + +/** One match with its stable source position. */ +interface Ranked { + readonly item: T + readonly index: number + readonly prefix: boolean + readonly score: number +} + +/** Extra weight for name starts and separator boundaries. */ +function boundaryBonus(name: string, index: number): number { + return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0 +} + +/** + * Score the strongest ordered-subsequence alignment in O(name × query). + * Boundary and adjacent matches earn weight; skipped and leading characters + * cost weight. Undefined when the query is not a subsequence of the name. + */ +function alignmentScore(name: string, query: string): number | undefined { + if (query.length > name.length) return undefined + const noMatch = Number.NEGATIVE_INFINITY + let previous = Array(name.length).fill(noMatch) + for (let index = 0; index < name.length; index++) { + if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index + } + for (let queryIndex = 1; queryIndex < query.length; queryIndex++) { + const current = Array(name.length).fill(noMatch) + // Sweep the previous row once: `left` is its score one character back + // (the adjacent continuation), `leftLeft` two back (the earliest gapped one). + let left = noMatch + let leftLeft = noMatch + let bestGapped = noMatch + for (const [index, prior] of previous.entries()) { + if (leftLeft !== noMatch) bestGapped = Math.max(bestGapped, leftLeft + index - 2) + if (name.charAt(index) === query.charAt(queryIndex)) { + const bonus = 1 + boundaryBonus(name, index) + let score = noMatch + if (left !== noMatch) score = left + bonus + 4 + if (bestGapped !== noMatch) score = Math.max(score, bestGapped + bonus + 1 - index) + current[index] = score + } + leftLeft = left + left = prior + } + previous = current + } + let best = noMatch + for (const score of previous) best = Math.max(best, score) + return best === noMatch ? undefined : best +} + +/** + * Rank named items by a menu query. + * @param items - candidates in source order (a host catalog, then client contributions). + * @param rawQuery - the text typed after the trigger, matched case-insensitively. + * @returns the matching items: prefix hits first, then by alignment score, + * then in source order. The input list itself for an empty query. + */ +export function rankByName(items: readonly T[], rawQuery: string): readonly T[] { + const query = rawQuery.toLowerCase() + if (query === '') return items + const ranked: Ranked[] = [] + items.forEach((item, index) => { + const name = item.name.toLowerCase() + const score = alignmentScore(name, query) + if (score !== undefined) ranked.push({ item, index, prefix: name.startsWith(query), score }) + }) + ranked.sort((left, right) => + Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index) + return ranked.map(match => match.item) +} diff --git a/packages/client/ui-primitives/src/user-text.module.css b/packages/client/ui-primitives/src/user-text.module.css index 3e6f8ca4f0..3654d6f990 100644 --- a/packages/client/ui-primitives/src/user-text.module.css +++ b/packages/client/ui-primitives/src/user-text.module.css @@ -20,6 +20,14 @@ white-space: nowrap; } +/* Skill and command tokens read as code: the theme's code family at the + consumer's own size and line height, so the chip sits on the bubble line + like the plain runs around it. `@` chips keep the body face — a file or + session name is a label, not code. */ +.slashChip { + font-family: var(--dsw-font-markdown-code-font-family); +} + /* Inline reference glyphs (always ReferenceIcon svgs) ride the consumer's own font: 1em keeps the glyph at the text's size in the bubble (14px + user setting), the queue preview's fixed 13px line, and any future consumer — a diff --git a/packages/client/ui-primitives/src/user-text.tsx b/packages/client/ui-primitives/src/user-text.tsx index 494f49a868..f0b89cfbb4 100644 --- a/packages/client/ui-primitives/src/user-text.tsx +++ b/packages/client/ui-primitives/src/user-text.tsx @@ -2,19 +2,29 @@ * Display projection of reference forms in sent user text (bubble and queue * rows). The logged model text remains the single truth; this is presentation * only, and every part renders inline so a single-line message never breaks - * across lines. Three decoration sources, by precedence: the wire session form + * across lines. Four decoration sources, by precedence: the wire session form * `@[label](dsh-session:...)` folds to its label; exact session labels - * supplied by an adjacent recall decorate their bare `@label` mention; and - * plain `/name` / `@name` word-boundary tokens decorate by shape alone (sent - * tokens were validated at compose time). + * supplied by an adjacent recall decorate their bare `@label` mention; plain + * `@name` word-boundary tokens decorate by shape alone; and a plain `/name` + * token decorates only when the caller names it — a skill the host actually + * loaded for that message (ui-chat reads the step's `skill-invocation` + * injections) or the command a command-input bubble echoes — so `/123` or a + * stray `/word` stays plain text. A `/name` token is whitespace-bounded like + * the host skill gesture (`dsh-tool-skill`): it ends at whitespace or the + * text end, so slash paths (`/nfs-hg/xxx`, `/plan.md`) and punctuation-glued + * tokens (`/plan。`) stay plain even for a loaded name. */ import type { ReactNode } from 'react' +import clsx from 'clsx' import { ReferenceIcon } from './ReferenceIcon.tsx' import css from './user-text.module.css' /** The wire form a session chip serializes to; label is the display text. */ const SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu +/** Sentence punctuation a bare `@name` token may carry without being part of the reference. */ +const TRAILING_PUNCTUATION_RE = /[.,;:!?,。;:!?]+$/u + interface DecorationRange { readonly start: number readonly end: number @@ -29,9 +39,18 @@ interface DecorationRange { * Split one sent text into inline plain runs and reference chips. * @param text - the logged model text of the message or queue row. * @param sessionLabels - exact session mention labels associated by an adjacent recall. + * @param slashNames - names a `/name` token may decorate as: the skills the + * host loaded for this message, or the command a command bubble echoes + * (unsent queue rows pass none). + * @param slashKind - the chip kind those tokens render as. * @returns inline nodes covering the whole text. */ -export function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode { +export function projectUserText( + text: string, + sessionLabels: readonly string[], + slashNames: readonly string[] = [], + slashKind: 'skill' | 'command' = 'skill', +): ReactNode { const ranges: DecorationRange[] = [] SESSION_WIRE_RE.lastIndex = 0 let wire: RegExpExecArray | null @@ -52,15 +71,18 @@ export function projectUserText(text: string, sessionLabels: readonly string[]): start = text.indexOf(label, start + label.length) } } - const re = /(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/gu + // A `/` token ends at whitespace or the text end like the host skill + // gesture; only `@` tokens shed sentence punctuation below. + const re = /(^|\s)(\/[\w-]+(?=\s|$)|@"[^"\n]+"|@[^\s]+)/gu let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { const tokenStart = m.index + (m[1] as string).length // (^|\s) captures '' at line start const rawLabel = m[2] as string // non-optional alternation capture const label = rawLabel.startsWith('@"') ? rawLabel - : rawLabel.replace(/[.,;:!?,。;:!?]+$/gu, '') + : rawLabel.replace(TRAILING_PUNCTUATION_RE, '') if (label.length <= 1) continue + if (label.startsWith('/') && !slashNames.includes(label.slice(1))) continue ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' }) } const rankOf = (range: DecorationRange): number => range.kind === 'session' ? 0 : 1 @@ -88,8 +110,8 @@ export function projectUserText(text: string, sessionLabels: readonly string[]): parts.push( {referenceKind !== undefined && ( diff --git a/packages/client/ui-primitives/tests/file-size.client.spec.ts b/packages/client/ui-primitives/tests/file-size.client.spec.ts new file mode 100644 index 0000000000..cdfcb44876 --- /dev/null +++ b/packages/client/ui-primitives/tests/file-size.client.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { fileSizeText } from '../src/file-size.ts' + +describe('fileSizeText', () => { + it('formats bytes and both sides of the KB, MB, and GB rounding thresholds', () => { + expect(fileSizeText(312)).toBe('312B') + expect(fileSizeText(4.25 * 1024)).toBe('4.3KB') + expect(fileSizeText(12.5 * 1024)).toBe('13KB') + expect(fileSizeText(1.5 * 1024 * 1024)).toBe('1.5MB') + expect(fileSizeText(12.5 * 1024 * 1024)).toBe('13MB') + expect(fileSizeText(2447 * 1024 * 1024)).toBe('2.4GB') + expect(fileSizeText(12.5 * 1024 * 1024 * 1024)).toBe('13GB') + }) +}) diff --git a/packages/client/ui-primitives/tests/markdown.client.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx index eb6b450924..a16d0464cb 100644 --- a/packages/client/ui-primitives/tests/markdown.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.client.spec.tsx @@ -1,21 +1,12 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives' import { JsonBlock, MarkdownText } from './markdown-test-components.tsx' import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts' import { mathCompatibility } from '../src/markdown/mathCompatibility.ts' afterEach(cleanup) -describe('MessageText', () => { - it('renders the text verbatim', () => { - const { container } = render() - expect(container.textContent).toBe('# line1\n`line2`') - expect(container.querySelector('h1')).toBeNull() - }) -}) - describe('MarkdownText', () => { it('renders CommonMark and GFM elements as semantic DOM', () => { const markdown = [ diff --git a/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts b/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts new file mode 100644 index 0000000000..89ed5c7733 --- /dev/null +++ b/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts @@ -0,0 +1,41 @@ +/** + * Shared `/` menu ranker: case-insensitive ordered-subsequence matching, + * prefix hits first, alignment score next, source order for ties. + */ +import { describe, expect, it } from 'vitest' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' + +const named = (...names: string[]) => names.map(name => ({ name })) +const names = (items: readonly { name: string }[]) => items.map(item => item.name) + +describe('rankByName', () => { + it('returns the input list itself for an empty query', () => { + const items = named('b', 'a') + expect(rankByName(items, '')).toBe(items) + }) + + it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', () => { + const items = named('q-xylophone', 'qx-long', 'fabulous', 'foo-bar', 'zuv', 'zu1v', 'yu1v', 'zu12v') + expect(names(rankByName(items, 'QX'))).toEqual(['qx-long', 'q-xylophone']) + expect(names(rankByName(items, 'fb'))).toEqual(['foo-bar', 'fabulous']) + expect(names(rankByName(items, 'uv'))).toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v']) + expect(names(rankByName(items, 'zzz'))).toEqual([]) + expect(names(rankByName(items, 'query-longer-than-every-name'))).toEqual([]) + }) + + it('a prefix hit outranks a stronger non-prefix alignment', () => { + // 'z_a_b' aligns both characters on separator boundaries and outscores + // every non-prefix rival; a name that starts with the query still wins. + expect(names(rankByName(named('z_a_b', 'xabc'), 'ab'))).toEqual(['z_a_b', 'xabc']) + expect(names(rankByName(named('z_a_b', 'abc'), 'ab'))).toEqual(['abc', 'z_a_b']) + }) + + it('takes the stronger of an adjacent and a gapped alignment for the same character', () => { + expect(names(rankByName(named('aab', 'ab'), 'ab'))).toEqual(['ab', 'aab']) + }) + + it('returns the ranked items with their payload intact', () => { + const items = [{ name: 'goal', description: 'g' }, { name: 'plan', description: 'p' }] + expect(rankByName(items, 'pl')).toEqual([{ name: 'plan', description: 'p' }]) + }) +}) diff --git a/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts b/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts index d80d4f5c13..575b705f12 100644 --- a/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts +++ b/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts @@ -24,4 +24,13 @@ describe('user-text.module.css font-size axis', () => { 'height: 1em', ])) }) + + it('sets slash chips in the code face at the consumer size', () => { + // Skill and command tokens read as code — the family the theme publishes + // for code — while the size and line height stay the consumer's, so the + // chip rides the bubble line like the plain runs around it. + const slashChip = declarations('.slashChip') + expect(slashChip.some(declaration => /^font-family: var\(--dsw-font-/.test(declaration))).toBe(true) + expect(slashChip.some(declaration => /^(font|font-size|line-height):/.test(declaration))).toBe(false) + }) }) diff --git a/packages/client/ui-primitives/tests/user-text.client.spec.tsx b/packages/client/ui-primitives/tests/user-text.client.spec.tsx index 6e1c2e309f..b65bb8f43d 100644 --- a/packages/client/ui-primitives/tests/user-text.client.spec.tsx +++ b/packages/client/ui-primitives/tests/user-text.client.spec.tsx @@ -8,12 +8,17 @@ import { describe, expect, it } from 'vitest' import { render } from '@testing-library/react' import { projectUserText } from '../src/user-text.tsx' -const project = (text: string, labels: readonly string[] = []) => - render(
{projectUserText(text, labels)}
).container.querySelector('[data-host]')! +const project = ( + text: string, + labels: readonly string[] = [], + slashNames: readonly string[] = [], + slashKind: 'skill' | 'command' = 'skill', +) => + render(
{projectUserText(text, labels, slashNames, slashKind)}
).container.querySelector('[data-host]')! describe('projectUserText', () => { it('keeps a decorated single-line message on one line: every part is inline', () => { - const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试']) + const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试'], ['dsh-acp-test']) expect(host.querySelectorAll('div').length).toBe(0) expect(host.textContent).toBe('反反复复 /dsh-acp-test 执行几个命令测试') const chips = host.querySelectorAll('[data-ref-chip]') @@ -55,13 +60,39 @@ describe('projectUserText', () => { expect(host.querySelectorAll('[data-ref-chip="session"]').length).toBe(2) }) - it('strips trailing punctuation and skips degenerate tokens', () => { - const host = project('用 /plan。 试试 @。') - const chips = [...host.querySelectorAll('[data-ref-chip]')] - expect(chips.map(c => c.textContent)).toEqual(['/plan']) + it('keeps a punctuation-glued slash token plain and skips degenerate tokens', () => { + // The host skill gesture ends at whitespace or the text end, so `/plan。` + // never loads a skill; the bubble must not suggest otherwise. + const host = project('用 /plan。 试试 @。', [], ['plan']) + expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0) expect(host.textContent).toBe('用 /plan。 试试 @。') }) + it('decorates a slash token only when the host resolved it as a skill in that step', () => { + const bare = project('/123') + expect(bare.querySelectorAll('[data-ref-chip]').length).toBe(0) + expect(bare.textContent).toBe('/123') + const unresolved = project('用 /plan 看看') + expect(unresolved.querySelectorAll('[data-ref-chip]').length).toBe(0) + const resolved = project('用 /plan 看看', [], ['plan']) + expect([...resolved.querySelectorAll('[data-ref-chip]')].map(c => [c.getAttribute('data-ref-chip'), c.textContent])) + .toEqual([['skill', '/plan']]) + }) + + it('marks a resolved slash token as a command chip when the caller says so', () => { + const host = project('/goal ship it\nsecond line', [], ['goal'], 'command') + const chips = [...host.querySelectorAll('[data-ref-chip]')] + expect(chips.map(c => [c.getAttribute('data-ref-chip'), c.textContent])).toEqual([['command', '/goal']]) + expect(host.textContent).toBe('/goal ship it\nsecond line') + }) + + it('leaves slash paths undecorated even for a resolved name: a /name token ends at whitespace', () => { + const text = '测试一下ui,不用管我:\n/nfs-hg/xxx/yyy 与 /root-dir/ 和 /plan.md' + const host = project(text, [], ['nfs-hg', 'root-dir', 'plan']) + expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0) + expect(host.textContent).toBe(text) + }) + it('prefers the longer recall label when one nests inside another', () => { const host = project('@会话一 收尾', ['会话', '会话一']) const chips = [...host.querySelectorAll('[data-ref-chip="session"]')] diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 001cfc22ab..fbda419e61 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -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/ui-skill/README.md -README.md: 6f5bd109a66b061db3ab5069b15bb4f101039e73 -README.zh.md: 61941ef1b5966d9155d724873b25e03d38d214e1 +README.md: a98194f7e590eb8a68bd329495896e9f2df1b506 +README.zh.md: c4f444f1c749f0697e8883166a0f4ea8ca2009ba diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 6f5bd109a6..a98194f7e5 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -29,7 +29,7 @@ Type `/` in the composer and pick a skill from the suggestions, or type `/name` ### What the source offers -Ordinary-session candidates come from the `skills/list` Remote; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results filter by `startsWith(query)`. A failed `skills/list` call is logged and folded into a silent menu-group drop — the menu shows only pending/ready states. +Ordinary-session candidates come from the `skills/list` Remote; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results rank through the `/` menu's shared name ranker, `rankByName` from ui-primitives: the query matches a case-insensitive ordered subsequence of the skill name, prefix hits rank first, and ties keep the host order ([ranking decision](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)). A failed `skills/list` call is logged and folded into a silent menu-group drop — the menu shows only pending/ready states. ### The skill tool row diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 61941ef1b5..c4f444f1c7 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### source 提供什么 -普通会话的候选来自 `skills/list` Remote;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果按 `startsWith(query)` 过滤。`skills/list` 调用失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。 +普通会话的候选来自 `skills/list` Remote;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果经 `/` 菜单共享的名字排序器(ui-primitives 的 `rankByName`)排名:查询作为不区分大小写的有序子序列匹配 skill 名,前缀命中排最前,同分保持宿主顺序([排名决策](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md))。`skills/list` 调用失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。 ### skill 工具行 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 44712b7286..e15a748ca1 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -35,6 +35,7 @@ import type { SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). @@ -142,8 +143,9 @@ export function apply(ctx: ClientContext): void { const skills = await fetchCatalog(session.sessionId) // Superseded keystroke: the shared fetch stays warm, this caller yields. if (signal.aborted) return [] - return skills - .filter(skill => skill.name.startsWith(query)) + // The same ranking as the command group of this menu: case-insensitive + // ordered subsequence, prefix hits first. + return rankByName(skills, query) .map(skill => ({ name: skill.name, // The user-only marker rides the description (the menu's only diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 1c812e460e..82ca97029c 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -5,7 +5,7 @@ * the source behavior contract driven directly on the captured source with * real ClientSessionContext projections — sessionId addressing, the * session-keyed catalog cache (single-flight per key, scope-birth warm - * prewarm, connection/reset clear), startsWith filtering, RPC-failure + * prewarm, connection/reset clear), shared fuzzy name ranking, RPC-failure * rejection, pick → plain-text outcome (the plain-text-reference decision: * .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md), * the synchronous @@ -167,7 +167,7 @@ describe('apply', () => { }) describe('candidates: sessionId addressing', () => { - it('lists via {sessionId} and filters by startsWith(query)', async () => { + it('lists via {sessionId} and ranks case-insensitive subsequence matches with prefixes first', async () => { const { list, payloads } = countingList() const { source } = await bench(list) const items = await source.candidates(proj('s1'), req('co')) @@ -177,6 +177,11 @@ describe('candidates: sessionId addressing', () => { { name: 'commit-helper', description: 'commit flow' }, { name: 'code-review', description: 'review flow' }, ]) + const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name) + // 'de' prefixes deploy and is a subsequence of code-review: the prefix ranks first. + await expect(names('de')).resolves.toEqual(['deploy', 'code-review']) + await expect(names('REV')).resolves.toEqual(['code-review']) + await expect(names('zzz')).resolves.toEqual([]) }) it('rejects on a failed result (the slash shell owns the menu-side fold)', async () => { diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index ed386b8321..be039beb46 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -391,9 +391,9 @@ describe('DetailsPanel diff Output section', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useProjection={(() => undefined)} diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 092a7ce506..5f172891c0 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -312,9 +312,9 @@ describe('DetailsPanel Output section (read)', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useProjection={(() => undefined)} diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index bc5213d02e..963bf18e49 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -396,9 +396,9 @@ describe('DetailsPanel Output section (search)', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useProjection={(() => undefined)} diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 064c25d4ec..03ec730732 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -525,7 +525,10 @@ describe('DetailsPanel Output section', () => { useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} + inputActions={{ + setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, + }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} @@ -713,7 +716,10 @@ describe('DetailsPanel Output section', () => { useChat={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })} useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} + inputActions={{ + setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, + }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index f510ae050f..473ac1c7a4 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -243,9 +243,9 @@ describe('DetailsPanel web Output section', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useProjection={(() => undefined)} diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 93b134a4ee..2fe93a711e 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -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/ui-trajectory/README.md -README.md: d61a100842f9457579dc014b94f541e9809e5a2d -README.zh.md: abc778af44203550c1fb8a9343ad9735ba2a84a2 +README.md: 3f4509c4bcd282e2bc06ca3c00de5308783501b8 +README.zh.md: 75d00e677109c7fa60563d6fc60bac47a13ee006 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index d61a100842..3f4509c4bc 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-trajectory` is the Trajectory view of the dsh web client: it renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records, plus an interactive timing overview. Thick rules mark Turn boundaries, compact inline markers identify Steps, and selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images from user, assistant, or tool content. The view is a pure consumer: it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `conversation.view` slot ring, and provides no service and declares no Context merge. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. Long ledgers open at the current tail, page older history on demand, and mount only the visible row window. +`dsh-client-ui-trajectory` is the Trajectory view of the dsh web client: it renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records, plus an interactive timing overview. Thick rules mark Turn boundaries, compact inline markers identify Steps, and selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, durable images, and file-attachment summaries from user, assistant, or tool content. The view is a pure consumer: it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `conversation.view` slot ring, and provides no service and declares no Context merge. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. Long ledgers open at the current tail, page older history on demand, and mount only the visible row window. ## Table of Contents @@ -29,7 +29,7 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit ### Inspecting records -Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A record without text labels its row with the image count. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. +Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. ### The timing overview diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index abc778af44..75d00e6771 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-trajectory` 是 dsh Web 客户端的 Trajectory 视图:它渲染按轮次组织的事件记录表,其中可选择用户、助手、工具与嵌套子工具记录,并带交互式时间概览。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤;选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时,以及用户、助手或工具内容中的持久图片。该视图是纯消费方:它注册 target 专属 Event Definition、Trajectory view builder 以及对话 `conversation.view` slot 环中的一个视图标签页,不提供 service,也不声明 Context 合并。带类型的 `trajectory` locale namespace 拥有所有产品编写的 ledger、timeline、inspector、tooltip 与无障碍文案;事件内容、工具名称、标识符与 provider 诊断保持原始数据。长记录表打开时定位于当前尾部、按需加载更早历史,并且只挂载可见行窗口。 +`dsh-client-ui-trajectory` 是 dsh Web 客户端的 Trajectory 视图:它渲染按轮次组织的事件记录表,其中可选择用户、助手、工具与嵌套子工具记录,并带交互式时间概览。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤;选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时,以及用户、助手或工具内容中的持久图片与文件附件摘要。该视图是纯消费方:它注册 target 专属 Event Definition、Trajectory view builder 以及对话 `conversation.view` slot 环中的一个视图标签页,不提供 service,也不声明 Context 合并。带类型的 `trajectory` locale namespace 拥有所有产品编写的 ledger、timeline、inspector、tooltip 与无障碍文案;事件内容、工具名称、标识符与 provider 诊断保持原始数据。长记录表打开时定位于当前尾部、按需加载更早历史,并且只挂载可见行窗口。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 检查记录 -选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留 snapshot。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。没有文本的记录会用图片数量标记其行。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 +选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留 snapshot。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 ### 时间概览 diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index a811e915f2..d8076b5797 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -125,10 +125,15 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick< const preview = previewContent(node.content) const previewMarkdown = preview === '' ? undefined : preview const images = imageBlockCount(node.content) - return { - text: previewMarkdown === undefined && images > 0 + const files = fileBlockCount(node.content) + const attachmentSummary = [ + previewMarkdown === undefined && images > 0 ? t('layout.imageOnly', { count: images }) - : '', + : undefined, + files > 0 ? t('layout.fileAttachments', { count: files }) : undefined, + ].filter((value): value is string => value !== undefined).join(' · ') + return { + text: attachmentSummary, ...(previewMarkdown === undefined ? {} : { previewMarkdown }), sourceSeq: node.seq, messageSource: node.source, @@ -848,6 +853,10 @@ function imageBlockCount(content: readonly { type: string }[]): number { return content.filter(block => block.type === 'image').length } +function fileBlockCount(content: readonly { type: string }[]): number { + return content.filter(block => block.type === 'file').length +} + function stringifySourceValue(value: unknown): string { const json = JSON.stringify(value, null, 2) return json || String(value) diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index ddc5cea7c7..6bdc621413 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -174,6 +174,7 @@ export const zh = { 'layout.compacted': '上下文已压缩', 'layout.toolCallOnly': '仅工具调用', 'layout.imageOnly': '图片 ×{count}', + 'layout.fileAttachments': '文件 ×{count}', 'layout.initialSystemPrompt': '初始系统提示词', 'layout.systemPromptUpdated': '系统提示词已更新', 'layout.toolsUpdated': '工具已更新', @@ -366,6 +367,7 @@ export const en: Record = { 'layout.compacted': 'Context compacted', 'layout.toolCallOnly': 'Tool call only', 'layout.imageOnly': 'Images ×{count}', + 'layout.fileAttachments': 'Files ×{count}', 'layout.initialSystemPrompt': 'Initial System Prompt', 'layout.systemPromptUpdated': 'System Prompt Updated', 'layout.toolsUpdated': 'Tools Updated', diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index 4b75d04d1e..f9130ab4f7 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -669,3 +669,46 @@ describe('durable image attachments', () => { expect(block?.content).toContain('https://example.com/a.png') }) }) + +describe('durable file attachments', () => { + const attachment = { + attachmentId: `sha256:${'d'.repeat(64)}`, + name: 'notes.pdf', + bytes: 2447 * 1024 * 1024, + } + + it('labels file-only and mixed-text user records without rendering file cards', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'file', attachment }, { type: 'file', attachment }], + }, + { + kind: 'user', seq: 2, time: 2_000, source: null, + content: [{ type: 'text', text: 'review these' }, { type: 'file', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const users = turns.flatMap(turn => turn.groups).flatMap(group => group.cells) + .filter(cell => cell.kind === 'user') + expect(users[0]?.text).toBe('Files ×2') + expect(users[0]?.previewMarkdown).toBeUndefined() + expect(users[1]).toMatchObject({ text: 'Files ×1', previewMarkdown: 'review these' }) + }) + + it('keeps image and file counts in a file-only attachment summary', () => { + const image = { + attachmentId: `sha256:${'e'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 640, + height: 320, + } + const nodes = [{ + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'image', attachment: image }, { type: 'file', attachment }], + }] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns[0]?.groups[0]?.cells[0]?.text).toBe('Images ×1 · Files ×1') + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index fba1a7b77e..e8288cb0fa 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -206,13 +206,13 @@ function standaloneProps( ): StandaloneBaseProps { const trajectory = historySnapshot(nodes) const input = createSnapshotStore({ - draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], + draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], }) const inputActions: InputActions = { setDraft: () => {}, - addImages: () => false, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => false, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, } return { @@ -328,13 +328,13 @@ function mount(fixture: Awaited>) { createSnapshotStore(tabsOf(slots)), ) const useInput = bindSnapshotSelector(createSnapshotStore({ - draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], + draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], })) const inputActions: InputActions = { setDraft: vi.fn(), - addImages: vi.fn(() => false), - removeImage: vi.fn(), - pruneImages: vi.fn(), + addAttachments: vi.fn(() => false), + removeAttachment: vi.fn(), + pruneAttachments: vi.fn(), submit: vi.fn(), } const standardProps = { diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 35575aeb4d..e303ae5aa8 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -94,7 +94,7 @@ const trajectoryState: TrajectoryState = { } const inputState: InputState = { draft: '', - imageIds: [], + attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], @@ -119,9 +119,9 @@ const kit: Omit = { useInput: selector => selector(inputState), inputActions: { setDraft: () => { throw new Error('unused') }, - addImages: () => { throw new Error('unused') }, - removeImage: () => { throw new Error('unused') }, - pruneImages: () => { throw new Error('unused') }, + addAttachments: () => { throw new Error('unused') }, + removeAttachment: () => { throw new Error('unused') }, + pruneAttachments: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') }, }, useStore: selector => selector(questionDraftStore.getSnapshot()), diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index e44f492b10..42313fc802 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -93,7 +93,7 @@ const trajectoryState: TrajectoryState = { } const inputState: InputState = { draft: '', - imageIds: [], + attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], @@ -118,9 +118,9 @@ const kitBase: Omit = useInput: selector => selector(inputState), inputActions: { setDraft: () => { throw new Error('unused') }, - addImages: () => { throw new Error('unused') }, - removeImage: () => { throw new Error('unused') }, - pruneImages: () => { throw new Error('unused') }, + addAttachments: () => { throw new Error('unused') }, + removeAttachment: () => { throw new Error('unused') }, + pruneAttachments: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') }, }, // The seat's key domain is question ∪ common. diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 772c90a571..123d08ef84 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -318,9 +318,9 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi useInput: () => { throw new Error('unused') }, inputActions: { setDraft: () => {}, - addImages: () => false, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => false, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }, useWorkspaces: selector => selector(panelWorkspace), diff --git a/packages/experimental/inspector/tests/port-selection.host.spec.ts b/packages/experimental/inspector/tests/port-selection.host.spec.ts index 3b400b29f3..1a4f72f6f4 100644 --- a/packages/experimental/inspector/tests/port-selection.host.spec.ts +++ b/packages/experimental/inspector/tests/port-selection.host.spec.ts @@ -1,9 +1,52 @@ /** Host Worker port-selection behavior. */ import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' import { afterEach, describe, expect, it } from 'vitest' import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' +async function closeServer(server: Server): Promise { + if (!server.listening) return + await new Promise((resolve) => { server.close(() => { resolve() }) }) +} + +async function listen(server: Server, port: number): Promise { + return await new Promise((resolve, reject) => { + const onError = (error: Error): void => { reject(error) } + server.once('error', onError) + server.listen(port, '127.0.0.1', () => { + server.off('error', onError) + const address = server.address() + if (address === null || typeof address === 'string') { + reject(new Error('test server did not bind a TCP port')) + return + } + resolve(address) + }) + }) +} + +async function bindWithAvailableSuccessor(): Promise { + for (let attempt = 0; attempt < 32; attempt += 1) { + const candidate = createServer() + const address = await listen(candidate, 0) + if (address.port === 65_535) { + await closeServer(candidate) + continue + } + const probe = createServer() + try { + await listen(probe, address.port + 1) + return candidate + } catch { + await closeServer(candidate) + } finally { + await closeServer(probe) + } + } + throw new Error('test could not reserve an occupied port with a bindable successor') +} + describe('Inspector endpoint port selection', () => { let blocker: Server | undefined let inspector: InspectorHandle | undefined @@ -11,21 +54,12 @@ describe('Inspector endpoint port selection', () => { afterEach(async () => { await inspector?.close() inspector = undefined - if (blocker?.listening === true) { - await new Promise((resolve) => { blocker!.close(() => { resolve() }) }) - } + if (blocker !== undefined) await closeServer(blocker) blocker = undefined }) it('advances from an occupied starting port and publishes the selected port', async () => { - blocker = createServer() - await new Promise((resolve, reject) => { - blocker!.once('error', reject) - blocker!.listen(0, '127.0.0.1', () => { - blocker!.off('error', reject) - resolve() - }) - }) + blocker = await bindWithAvailableSuccessor() const occupiedAddress = blocker.address() if (occupiedAddress === null || typeof occupiedAddress === 'string') { throw new Error('test server did not bind a TCP port') diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 74f6ba36d3..10a2ecc5d2 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -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/experimental/webworker-runtime/README.md -README.md: 87d53937bee73d7e771ad957ea6c5d953965169f -README.zh.md: d11c074ca02bab12a9f72be17b035389507e05cb +README.md: b5c9370bbff33e4385e08c299fc9c1a7669f8e5e +README.zh.md: 04eee0bfb133bf020fc8989ddb98e54c1dfadf34 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 87d53937be..b5c9370bbf 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -28,7 +28,7 @@ Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. - **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. -- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport and the API client. +- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index d11c074ca0..04eee0bfb1 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -28,7 +28,7 @@ kind: "package-library" - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 - **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 -- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形传输与 API 客户端。 +- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。 diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 90e78feb89..9e0bbb079e 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index 5bf98cc640..6afb527705 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -119,10 +119,14 @@ async function localizeSourceMap(source: string, bundleUrl: string, fetch: Tunne } } -/** Normalize a RequestInit body to a transferable ArrayBuffer. */ -function toBodyBuffer(body: RequestInit['body']): ArrayBuffer | undefined { +/** Keep opaque Blobs and transferable streams intact; normalize other bodies to bytes. */ +function toTunnelBody( + body: RequestInit['body'], +): ArrayBuffer | Blob | ReadableStream | undefined { if (body === undefined || body === null) return undefined if (typeof body === 'string') return encoder.encode(body).buffer + if (body instanceof Blob) return body + if (body instanceof ReadableStream) return body as ReadableStream if (body instanceof ArrayBuffer) return body if (ArrayBuffer.isView(body)) { return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) @@ -197,21 +201,21 @@ export class WorkerTunnel { // must not reach the worker, where a write-shaped route would still run. if (signal?.aborted === true) throw new DOMException('The operation was aborted.', 'AbortError') const id = this.nextId++ + const body = init?.body === undefined || init.body === null ? undefined : toTunnelBody(init.body) const frame: RequestFrame = { t: 'req', id, method: init?.method ?? 'GET', url: new URL(input, globalThis.location.origin).toString(), headers: Object.fromEntries(new Headers(init?.headers).entries()), - ...(init?.body === undefined || init.body === null - ? {} - : { body: toBodyBuffer(init.body) }), + ...(body === undefined ? {} : { body }), } const response = new Promise((resolve, reject) => { this.unary.set(id, { resolve, reject }) }) this.inFlight.set(id, `${frame.method} ${frame.url}`) - this.worker.postMessage(frame) + if (body instanceof ReadableStream) this.worker.postMessage(frame, [body]) + else this.worker.postMessage(frame) if (signal === undefined || signal === null) return await response const raced = this.rejectOnAbort(id, signal) try { diff --git a/packages/experimental/webworker-runtime/src/client/index.ts b/packages/experimental/webworker-runtime/src/client/index.ts index 7f4af6e1a9..467f915902 100644 --- a/packages/experimental/webworker-runtime/src/client/index.ts +++ b/packages/experimental/webworker-runtime/src/client/index.ts @@ -9,6 +9,7 @@ * @module @deepseek-ai/dsh-experimental-webworker-runtime/client */ import { IMAGE_FILE_NAME } from '../image-layout.ts' +import type { ClientFileUploadHooks } from '@deepseek-ai/dsh-client-file-upload/types' import { PREVIEW_FIXTURE_MANIFEST_FILE } from '../fixture-manifest.ts' import { WorkerTunnel, type TunnelFetch } from './client.ts' import { applyIndexInjections } from './apply-injections.ts' @@ -33,6 +34,11 @@ interface ClientTransportGlobal { } } +/** Upload hook consumed by the independent Client file-upload service. */ +interface ClientFileUploadGlobal { + __DSH_FILE_UPLOAD__?: ClientFileUploadHooks +} + /** Inputs for {@link connectWorkerHost}. */ export interface WorkerHostConnectOptions { /** @@ -151,6 +157,9 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn // the privileged surface stays reachable off loopback authorities. ownsHost: true, } + ;(globalThis as ClientFileUploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: (input, init) => tunnel.fetch(input, init), + } await applyIndexInjections(payload.injections, src => tunnel.loadBundle(src)) ready.resolve() return { worker, tunnel, loadBundle: (url: string) => tunnel.loadBundle(url) } diff --git a/packages/experimental/webworker-runtime/src/transport/frames.ts b/packages/experimental/webworker-runtime/src/transport/frames.ts index a04a2cec5a..01d399d4e8 100644 --- a/packages/experimental/webworker-runtime/src/transport/frames.ts +++ b/packages/experimental/webworker-runtime/src/transport/frames.ts @@ -7,14 +7,14 @@ /** Request identifier minted by the page. */ export type TunnelRequestId = string | number -/** One request; `body` carries the raw bytes for methods that have one. */ +/** One request; `body` carries cloneable or transferable raw bytes. */ export interface TunnelRequestFrame { readonly t: 'req' readonly id: TunnelRequestId readonly method: string readonly url: string readonly headers: Readonly> - readonly body?: ArrayBuffer | undefined + readonly body?: ArrayBuffer | Blob | ReadableStream | undefined } /** Open one Gateway Remote stream over the worker-local carrier. */ @@ -171,8 +171,11 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame { if (typeof value === 'string') headers[key.toLowerCase()] = value } const body = frame.body - if (body !== undefined && !(body instanceof ArrayBuffer)) { - throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer`) + if (body !== undefined + && !(body instanceof ArrayBuffer) + && !(body instanceof Blob) + && !(body instanceof ReadableStream)) { + throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer, Blob, or ReadableStream`) } return { t: 'req', id, method: frame.method, url: frame.url, headers, body } } diff --git a/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts b/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts index a10634e493..a45b4164d8 100644 --- a/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts +++ b/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts @@ -66,7 +66,25 @@ export function createSyntheticExchange(frame: TunnelRequestFrame, sink: Respons headers: frame.headers, destroy: (): void => { aborted = true }, async *[Symbol.asyncIterator](): AsyncGenerator { - if (frame.body === undefined || frame.body.byteLength === 0) return + if (frame.body === undefined) return + if (frame.body instanceof Blob) { + for await (const chunk of frame.body.stream()) { + if (aborted) return + if (chunk.byteLength > 0) yield chunk + } + return + } + if (frame.body instanceof ReadableStream) { + for await (const chunk of frame.body) { + if (aborted) return + if (!(chunk instanceof Uint8Array)) { + throw new TypeError('webworker tunnel: request stream produced a non-Uint8Array chunk') + } + if (chunk.byteLength > 0) yield chunk + } + return + } + if (aborted || frame.body.byteLength === 0) return yield new Uint8Array(frame.body) }, } @@ -133,6 +151,7 @@ export function createSyntheticExchange(frame: TunnelRequestFrame, sink: Respons if (finished) return aborted = true finished = true + emit('aborted') emit('close') }, } diff --git a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts index 9519962309..4113bfd25b 100644 --- a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts @@ -20,3 +20,23 @@ describe('tunnel init frame', () => { .toThrow(/array of string overlay urls/) }) }) + +describe('tunnel request bodies', () => { + it('accepts ArrayBuffer, Blob, and ReadableStream bodies and rejects other values', () => { + const bytes = Uint8Array.of(1, 2).buffer + const blob = new Blob(['large']) + expect(parseInboundFrame({ + t: 'req', id: 1, method: 'POST', url: '/bytes', headers: {}, body: bytes, + })).toMatchObject({ body: bytes }) + expect(parseInboundFrame({ + t: 'req', id: 2, method: 'POST', url: '/blob', headers: {}, body: blob, + })).toMatchObject({ body: blob }) + const stream = new ReadableStream() + expect(parseInboundFrame({ + t: 'req', id: 3, method: 'POST', url: '/stream', headers: {}, body: stream, + })).toMatchObject({ body: stream }) + expect(() => parseInboundFrame({ + t: 'req', id: 4, method: 'POST', url: '/bad', headers: {}, body: 'large', + })).toThrow('body must be an ArrayBuffer, Blob, or ReadableStream') + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts index bf97dbffa3..5f23d51653 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts @@ -33,27 +33,43 @@ type StubListener = (event: { data?: unknown; message?: string }) => void function stubWorker(): { worker: Worker sent: { t: string; id: number }[] + transfers: Transferable[][] deliver: (frame: unknown) => void fail: (message: string) => void } { const listeners: StubListener[] = [] const errorListeners: StubListener[] = [] const sent: { t: string; id: number }[] = [] + const transfers: Transferable[][] = [] const worker = { addEventListener: (type: string, listener: StubListener) => { if (type === 'message') listeners.push(listener) if (type === 'error') errorListeners.push(listener) }, - postMessage: (frame: unknown) => { sent.push(frame as { t: string; id: number }) }, + postMessage: (frame: unknown, transfer?: Transferable[]) => { + sent.push(frame as { t: string; id: number }) + transfers.push(transfer ?? []) + }, } as unknown as Worker return { worker, sent, + transfers, deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) }, fail: (message) => { for (const listener of errorListeners) listener({ message }) }, } } +// ReadableStream request bodies transfer ownership instead of copying chunks on the page. +{ + const { worker, sent, transfers } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const body = new ReadableStream({ start(controller) { controller.close() } }) + void tunnel.fetch('/upload', { method: 'POST', body, duplex: 'half' } as RequestInit) + check('a stream request body stays intact', (sent[0] as unknown as { body: unknown }).body, body) + check('a stream request body is transferred', transfers[0], [body]) +} + // The opening frame preserves overlay order for deterministic pre-boot mounts. { const { worker, sent } = stubWorker() @@ -100,6 +116,15 @@ function stubWorker(): { check('204 resolves with a null body', (await response).body, null) } +// Blob bodies remain Blob-backed across the page-to-Host Worker handoff. +{ + const { worker, sent } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const body = new Blob(['large']) + void tunnel.fetch('/upload', { method: 'POST', body }) + check('a Blob request body stays opaque', (sent[0] as unknown as { body: unknown }).body, body) +} + // A streamed reply reassembles in order and closes. { const { worker, deliver } = stubWorker() diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts index 0a32e77753..7ca4ba6260 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import type { TunnelOutboundFrame } from '../../src/transport/frames.ts' import { TunnelServer, type TunnelSeams } from '../../src/transport/tunnel.ts' +function bytes(...values: number[]): Uint8Array { + const data = new Uint8Array(new ArrayBuffer(values.length)) + data.set(values) + return data +} + function harness(): { server: TunnelServer; frames: TunnelOutboundFrame[] } { const frames: TunnelOutboundFrame[] = [] const server = new TunnelServer({ @@ -60,6 +66,68 @@ describe('worker tunnel unary authentication', () => { }) }) +describe('worker tunnel Blob requests', () => { + it('streams an opaque Blob in bounded chunks inside the Host Worker route', async () => { + const frames: TunnelOutboundFrame[] = [] + const seen: Uint8Array[] = [] + const body = new Blob(['unused']) + body.arrayBuffer = () => Promise.reject(new Error('route must not aggregate the Blob')) + body.stream = () => new ReadableStream>({ + start(controller) { + controller.enqueue(bytes(108, 97, 114)) + controller.enqueue(bytes(103, 101)) + controller.close() + }, + }) + const server = new TunnelServer({ + port: { postMessage: (frame) => { frames.push(frame) } }, + requestListener: () => Promise.resolve(async (request, response) => { + for await (const chunk of request as AsyncIterable) seen.push(chunk) + const res = response as { writeHead(status: number): void; end(body: string): void } + res.writeHead(200) + res.end('stored') + }), + }) + server.serve(seams(async () => (async function *(): AsyncGenerator { yield undefined })())) + server.handleMessage({ + t: 'req', id: 9, method: 'POST', url: 'http://localhost/upload', headers: {}, body, + }) + await vi.waitFor(() => { expect(frames).toHaveLength(1) }) + expect(seen.map(chunk => new TextDecoder().decode(chunk))).toEqual(['lar', 'ge']) + expect(frames[0]).toMatchObject({ t: 'res', id: 9, status: 200 }) + }) +}) + +describe('worker tunnel ReadableStream requests', () => { + it('streams transferred chunks through the Host Worker route', async () => { + const frames: TunnelOutboundFrame[] = [] + const seen: Uint8Array[] = [] + const body = new ReadableStream({ + start(controller) { + controller.enqueue(bytes(111, 110, 101)) + controller.enqueue(bytes(116, 119, 111)) + controller.close() + }, + }) + const server = new TunnelServer({ + port: { postMessage: (frame) => { frames.push(frame) } }, + requestListener: () => Promise.resolve(async (request, response) => { + for await (const chunk of request as AsyncIterable) seen.push(chunk) + const res = response as { writeHead(status: number): void; end(body: string): void } + res.writeHead(200) + res.end('stored') + }), + }) + server.serve(seams(async () => (async function *(): AsyncGenerator { yield undefined })())) + server.handleMessage({ + t: 'req', id: 10, method: 'POST', url: 'http://localhost/upload', headers: {}, body, + }) + await vi.waitFor(() => { expect(frames).toHaveLength(1) }) + expect(seen.map(chunk => new TextDecoder().decode(chunk))).toEqual(['one', 'two']) + expect(frames[0]).toMatchObject({ t: 'res', id: 10, status: 200 }) + }) +}) + describe('worker tunnel logical streams', () => { it('drains a pre-boot open through the worker-local Gateway seam', async () => { const { server, frames } = harness() diff --git a/packages/experimental/webworker-runtime/tsconfig.json b/packages/experimental/webworker-runtime/tsconfig.json index f28341d750..6e76c8b1fc 100644 --- a/packages/experimental/webworker-runtime/tsconfig.json +++ b/packages/experimental/webworker-runtime/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../client/connection/tsconfig.host.json" }, + { + "path": "../../client/file-upload/tsconfig.host.json" + }, { "path": "../../host/webserver" }, diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index bee111f642..df710cc1cc 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -443,7 +443,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BeginSubmissionInput', - declaration: 'export interface BeginSubmissionInput {\n readonly mode: \'queue\' | \'steer\';\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}', + declaration: 'export interface BeginSubmissionInput {\n readonly mode: \'queue\' | \'steer\';\n readonly text: string;\n readonly attachments: readonly PendingSubmissionAttachment[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}', }, { name: 'BoundActions', @@ -627,19 +627,31 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PendingSubmission', - declaration: 'export interface PendingSubmission {\n readonly requestId: SessionRequestId;\n readonly placement: PendingSubmissionPlacement;\n readonly time: number;\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n}', + declaration: 'export interface PendingSubmission {\n readonly requestId: SessionRequestId;\n readonly placement: PendingSubmissionPlacement;\n readonly time: number;\n readonly text: string;\n readonly attachments: readonly PendingSubmissionAttachment[];\n}', + }, + { + name: 'PendingSubmissionAttachment', + declaration: 'export type PendingSubmissionAttachment = PendingSubmissionImageAttachment | PendingSubmissionFileAttachment;', + }, + { + name: 'PendingSubmissionFileAttachment', + declaration: 'export interface PendingSubmissionFileAttachment {\n readonly type: \'file\';\n readonly value: FileAttachmentRef;\n}', }, { name: 'PendingSubmissionImage', declaration: 'export interface PendingSubmissionImage {\n readonly previewUrl: string;\n readonly name?: string;\n readonly width?: number;\n readonly height?: number;\n}', }, + { + name: 'PendingSubmissionImageAttachment', + declaration: 'export interface PendingSubmissionImageAttachment {\n readonly type: \'image\';\n readonly value: PendingSubmissionImage;\n}', + }, { name: 'PendingSubmissionPlacement', declaration: 'export type PendingSubmissionPlacement = \'transcript\' | \'queued\' | \'steering\';', }, { name: 'PendingSubmissionRetirement', - declaration: 'export type PendingSubmissionRetirement = {\n readonly reason: \'observed\';\n readonly attachments: readonly ImageAttachmentRef[];\n} | {\n readonly reason: \'failed\';\n};', + declaration: 'export type PendingSubmissionRetirement = {\n readonly reason: \'observed\';\n readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[];\n} | {\n readonly reason: \'failed\';\n};', }, { name: 'ProjectionsFace', @@ -647,7 +659,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptContentPart', - declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n};', + declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n} | {\n readonly type: \'file\';\n readonly receiptId: Branded<\'file-upload-receipt-id\'>;\n};', }, { name: 'PromptError', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 300857356d..78b9f15ce0 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:228', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:227', }, { key: 'conversation.chat.commandview', @@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:216', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:215', }, { key: 'conversation.chat.node', @@ -314,7 +314,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:197', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:196', }, { key: 'conversation.chat.turnTail', @@ -359,7 +359,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:222', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:221', }, { key: 'conversation.composer', @@ -408,7 +408,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:119', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145', }, { key: 'conversation.composer.bar', @@ -444,7 +444,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163', }, { key: 'conversation.composer.dock', @@ -497,7 +497,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157', }, { key: 'conversation.details.tool', @@ -533,7 +533,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:234', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:233', }, { key: 'conversation.hero.agentPreset', @@ -561,7 +561,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:151', }, { key: 'conversation.hero.brand.mark', @@ -587,7 +587,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149', }, { key: 'conversation.hero.workspace', @@ -617,7 +617,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:147', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -652,15 +652,16 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ key: 'conversation.input.attachments', kind: 'single', scope: 'session-maybe', - summary: 'Optional draft-image rail and drop target.', - doc: 'Optional draft-image rail and drop target.', + summary: 'Optional draft-attachment rail and drop target.', + doc: 'Optional draft-attachment rail and drop target.', registerOptions: [], ownerProps: [ - '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft images in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add images now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddImages: (files: readonly File[]) => void\n /** Remove one draft image through the Conversation service. */\n onRemoveImage: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', + '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft attachments in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add attachments now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddFiles: (files: readonly File[]) => void\n /** Remove one draft attachment through the Conversation service. */\n onRemoveAttachment: (id: DraftAttachmentId) => void\n /** Current per-draft upload states for file-kind attachments. */\n uploads: DraftFileUploads\n /** Restart one failed file upload. */\n onRetryFile: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', ], ownerPropsReferences: [ 'ComposerAttachment', 'DraftAttachmentId', + 'DraftFileUploads', ], standardProps: [ 'useWorkspaces: SnapshotSelectorHook', @@ -683,7 +684,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165', }, { key: 'conversation.input.dock', @@ -743,7 +744,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:153', }, { key: 'conversation.input.left', @@ -794,7 +795,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159', }, { key: 'conversation.input.model', @@ -830,7 +831,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:147', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', }, { key: 'conversation.input.overlay', @@ -884,7 +885,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:129', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:155', }, { key: 'conversation.input.plan', @@ -920,7 +921,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', }, { key: 'conversation.input.right', @@ -971,7 +972,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:135', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161', }, { key: 'conversation.message.images', @@ -981,7 +982,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1011,7 +1012,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:210', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:209', }, { key: 'conversation.session', @@ -1045,7 +1046,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:95', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', }, { key: 'conversation.session.header', @@ -1079,7 +1080,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:97', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', }, { key: 'conversation.session.header.actions', @@ -1137,7 +1138,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:105', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', }, { key: 'conversation.session.header.lineage', @@ -1175,7 +1176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:99', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', }, { key: 'conversation.session.header.utilities', @@ -1230,7 +1231,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:111', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', }, { key: 'conversation.trajectory.images', @@ -1240,7 +1241,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1328,7 +1329,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:117', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:143', }, { key: 'details', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 2687ae1903..1fae7a8565 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -482,6 +482,26 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'inputs', description: 'encoded images in owning-message order.' }], returns: 'durable normalized attachment references in the same order after every member succeeds.', }, + { + signature: 'async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise', + description: 'Admit one Host prompt and replace each uploaded image with its durable reference. Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation.', + parameters: [{ name: 'content', description: 'prompt parts in message order after file receipt resolution.' }], + returns: 'admitted prompt parts in the same order as `content`.', + throws: ['AttachmentError when the image batch is refused.'], + }, + { + signature: 'admitEncodedFile(input: EncodedFileAttachment): Promise', + 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', 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.', @@ -502,6 +522,31 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'an absolute host path, or undefined when this backend is not host-file-backed.', throws: ['an AttachmentError when the durable reference is invalid.'], }, + { + signature: 'saveFile(input: SaveFileAttachment): Promise', + description: 'Durably commit one file byte-for-byte before its owning session event is appended. Files carry no admission limits: any byte content and length is accepted, and the stored object is the exact submitted bytes. Backends without verbatim file storage keep this default rejection.', + parameters: [{ name: 'input', description: 'exact bytes and optional display name.' }], + returns: 'the durable content-addressed file reference.', + }, + { + signature: 'saveFileStream(input: SaveFileStreamAttachment): Promise', + description: 'Durably commit one file byte-for-byte from bounded chunks. Providers must apply backpressure and must not collect the complete file in memory. Backends without streamed verbatim storage keep this default rejection.', + parameters: [{ name: 'input', description: 'ordered exact bytes, optional cancellation, and display name.' }], + returns: 'the durable content-addressed file reference.', + }, + { + signature: 'async *readFileStream( ref: FileAttachmentRef, signal?: AbortSignal, ): AsyncIterable', + description: 'Read and verify one verbatim stored file as bounded chunks. Providers must not collect the complete file in memory. Backends without verbatim file reads keep this default rejection.', + parameters: [{ name: 'ref', description: 'durable reference from the session log.' }, { name: 'signal', description: 'optional cancellation for backend reads and verification work.' }], + returns: 'exact file bytes in order; integrity failures reject the iteration.', + }, + { + signature: 'fileHostPath(ref: FileAttachmentRef): string | undefined', + description: 'Locate the verbatim stored file object in the harness host filesystem.', + parameters: [{ name: 'ref', description: 'durable file reference.' }], + returns: 'an absolute host path, or undefined when this backend is not host-file-backed.', + throws: ['an AttachmentError when the durable reference is invalid.'], + }, { signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', description: 'Generate or read one deterministic model-request version from the stored normalized image.', @@ -631,6 +676,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'definition', description: 'discovery metadata and direct UI handler.' }], returns: 'the exact effect disposer that unregisters this definition.', }, + { + signature: 'registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void', + description: 'Register the sole authority that resolves staged file receipts for command submissions.', + parameters: [{ name: 'resolver', description: 'Session-aware receipt resolver.' }], + returns: 'disposer that removes this exact resolver.', + }, { signature: '@Remote list(agent: Agent): readonly CommandDescriptor[]', description: 'List the effective immutable command descriptors for one agent.', @@ -644,9 +695,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the scoped shadow or global definition.', }, { - signature: '@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise', - description: 'Parse and execute a known command without sending it to the model.\n\nA resolved command\'s lifecycle is logged: `command/run` is appended before the handler is invoked and `command/done` after settlement (a thrown or aborted handler settles as `kind: \'error\'`). Both are direct log-only appends — no turn wraps them, and persistence drains them at ordinary checkpoints. Admission misses (syntax or unknown name) log nothing — they never entered a handler. A `command/run` append failure fails the execution loud; a `command/done` append failure on the handler-failure path is contained so the handler\'s own error stays the reported failure.\n\nImage admission is enforced here, not in the composer: images sent to a command that does not declare `input.images`, an absent attachment store, and an exceeded attachment limit each settle as an error result before the handler runs, and a rejected batch publishes no durable object.', - parameters: [{ name: 'agent', description: 'exact receiving agent.' }, { name: 'line', description: 'complete slash-command line.' }, { name: 'images', description: 'base64-encoded composer images accompanying the line, in submission order; empty for a plain invocation.' }, { name: 'signal', description: 'cancellation signal owned by the UI request.' }], + signature: '@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise', + description: 'Parse and execute a known command without sending it to the model.\n\nA resolved command\'s lifecycle is logged: `command/run` is appended before the handler is invoked and `command/done` after settlement (a thrown or aborted handler settles as `kind: \'error\'`). Both are direct log-only appends — no turn wraps them, and persistence drains them at ordinary checkpoints. Admission misses (syntax or unknown name) log nothing — they never entered a handler. A `command/run` append failure fails the execution loud; a `command/done` append failure on the handler-failure path is contained so the handler\'s own error stays the reported failure.\n\nAttachment admission is enforced here, not in the composer: attachments sent to a command that does not declare `input.attachments`, an absent attachment store, and an exceeded image limit each settle as an error result before the handler runs. Validation rejection starts no attachment writes; a storage failure can leave only unreachable content-addressed objects for deferred collection.', + parameters: [{ name: 'agent', description: 'exact receiving agent.' }, { name: 'line', description: 'complete slash-command line.' }, { name: 'submittedAttachments', description: 'encoded images and staged file receipts accompanying the line, in submission order; empty for a plain invocation.' }, { name: 'signal', description: 'cancellation signal owned by the UI request.' }], returns: 'the settled execution (result + lifecycle pairing id), or `undefined` when syntax or name does not resolve.', }, ], @@ -856,6 +907,48 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'fileUploads', + summary: 'Host service owning upload storage and Agent-scoped staged receipts.', + description: 'Host service owning upload storage and Agent-scoped staged receipts.', + methods: [ + { + signature: 'registerAgentResolver(resolve: AgentResolver): () => void', + description: 'Register the ordinary-Session resolver used when a raw upload addresses a cold Session.', + parameters: [{ name: 'resolve', description: 'resolver that returns the exact live Agent or throws a Remote error.' }], + returns: 'disposer removing this resolver.', + }, + { + signature: '@Remote(\'upload\') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise', + description: 'Persist one encoded upload and stage it under the Agent receiver selected by Typert.', + parameters: [{ name: 'agent', description: 'receiving Agent resolved from the Remote Agent scope.' }, { name: 'request', description: 'canonical base64 bytes and optional display name.' }, { name: 'signal', description: 'caller cancellation before storage begins.' }], + returns: 'the staged receipt and durable file reference.', + }, + { + signature: 'async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise', + description: 'Persist raw chunks for one Session without aggregating the upload.', + parameters: [{ name: 'request', description: 'Session identity, ordered bytes, cancellation, and optional display name.' }], + returns: 'the staged receipt and durable file reference.', + }, + { + signature: 'resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined', + description: 'Resolve one staged receipt inside its receiving Agent scope.', + parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'receiptId', description: 'opaque receipt minted for one completed upload.' }], + returns: 'durable file reference, or `undefined` for an unknown or foreign receipt.', + }, + { + signature: 'bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding', + description: 'Bind receipts while one prompt enters an Agent inbox. Disposal restores every prior binding unless the caller commits successful delivery.', + parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'receiptIds', description: 'distinct staged receipts referenced by the prompt.' }, { name: 'requestId', description: 'prompt identity later observed in queue or history.' }], + returns: 'binding kept after commit until queue or history observation retires its receipts.', + }, + { + signature: 'retirePrompt(agent: Agent, requestId: string): void', + description: 'Retire every receipt accepted by one removed queue occurrence.', + parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'requestId', description: 'prompt identity carried by the queue occurrence.' }], + }, + ], + }, { key: 'fs', summary: 'Abstract filesystem provider.', @@ -1160,6 +1253,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'provider', description: 'provider route named by a request header.' }, { name: 'model', description: 'exact model id named by the same header.' }], returns: 'the owning adapter\'s image pricing for the route, when declared.', }, + { + signature: 'fileRequestText(ref: FileAttachmentRef): string', + description: 'Resolve the exact text one durable file occurrence contributes to every provider request in the current execution environment.', + parameters: [{ name: 'ref', description: 'durable verbatim file reference from model history.' }], + returns: 'the same deterministic handle text used at adapter dispatch.', + }, { signature: 'async listModels(provider: string): Promise', description: 'Discover models advertised by one registered provider. Catalog membership is advisory and never changes routing or request validation.', @@ -3380,6 +3479,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AdapterRegistrationHandle', declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', }, + { + name: 'AdmittedPromptContentPart', + declaration: 'export type AdmittedPromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly attachment: ImageAttachmentRef;\n} | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};', + }, { name: 'Agent', declaration: 'export interface Agent {\n readonly id: SessionId;\n}', @@ -3428,6 +3531,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentPresetRow', declaration: 'export interface AgentPresetRow {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly isDefault: boolean;\n readonly name?: string;\n readonly description?: string;\n readonly broken?: string;\n}', }, + { + name: 'AgentResolver', + declaration: 'export type AgentResolver = (sessionId: SessionId) => Promise;', + }, { name: 'AgentSetup', declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise | void;', @@ -3524,6 +3631,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AssistantStreamRecord', declaration: 'export type AssistantStreamRecord = {\n readonly type: \'text-chunks\';\n readonly time0: number;\n readonly index: number;\n readonly dt: readonly number[];\n readonly texts: readonly string[];\n} | {\n readonly type: \'reasoning-chunks\';\n readonly time0: number;\n readonly index: number;\n readonly dt: readonly number[];\n readonly texts: readonly string[];\n} | {\n readonly type: \'tool-call-chunks\';\n readonly time0: number;\n readonly index: number;\n readonly dt: readonly number[];\n readonly id: ToolCallId;\n readonly name?: string;\n readonly args: readonly string[];\n} | {\n readonly type: \'chunk\';\n readonly time: number;\n readonly chunk: StreamChunk;\n};', }, + { + 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\'>;', @@ -3648,22 +3767,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CommandExecution', declaration: 'export interface CommandExecution {\n readonly commandId: CommandId;\n readonly result: CommandResult;\n}', }, + { + name: 'CommandFileReceiptResolver', + declaration: 'export type CommandFileReceiptResolver = (agent: Agent, receiptId: string) => FileAttachmentRef | undefined;', + }, { name: 'CommandId', declaration: 'export type CommandId = Branded<\'CommandId\'>;', }, { name: 'CommandInputDescriptor', - declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n readonly images?: boolean;\n}', + declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n readonly attachments?: boolean;\n}', }, { name: 'CommandInvocation', - declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly attachments: readonly ImageBlock[];\n readonly signal: AbortSignal;\n}', + declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly attachments: readonly (ImageBlock | FileBlock)[];\n readonly signal: AbortSignal;\n}', }, { name: 'CommandResult', declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n readonly sourceEventSeq?: SessionSeq;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', }, + { + name: 'CommandSubmitAttachment', + declaration: 'export type CommandSubmitAttachment = ({\n readonly type: \'image\';\n} & EncodedImageAttachment) | {\n readonly type: \'file\';\n readonly receiptId: string;\n};', + }, { name: 'CompactionAgentContext', declaration: 'export interface CompactionAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}', @@ -3694,7 +3821,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ContentBlockMap', - declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'image\': ImageBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}', + declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'image\': ImageBlock;\n \'file\': FileBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}', }, { name: 'ContentBlockType', @@ -3964,6 +4091,14 @@ 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}', + }, { name: 'EncodedImageAttachment', declaration: 'export interface EncodedImageAttachment {\n mediaType: ImageMediaType;\n data: string;\n name?: string;\n}', @@ -3976,6 +4111,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'FiberState', declaration: 'export type FiberState = FiberStateEnum;', }, + { + name: 'FileAttachmentRef', + declaration: 'export interface FileAttachmentRef {\n attachmentId: AttachmentId;\n name: string;\n bytes: number;\n}', + }, + { + name: 'FileBlock', + declaration: 'export interface FileBlock {\n type: \'file\';\n attachment: FileAttachmentRef;\n}', + }, { name: 'FileDiff', declaration: 'export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n}', @@ -3988,6 +4131,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'FileReferenceCandidate', declaration: 'export interface FileReferenceCandidate {\n path: string;\n kind: \'file\' | \'directory\';\n}', }, + { + name: 'FileUploadReceiptId', + declaration: 'export type FileUploadReceiptId = Branded<\'file-upload-receipt-id\'>;', + }, + { + name: 'FileUploadValue', + declaration: 'export interface FileUploadValue {\n readonly receiptId: FileUploadReceiptId;\n readonly file: FileAttachmentRef;\n}', + }, { name: 'FinishReason', declaration: 'export type FinishReason = FinishReasonMap[keyof FinishReasonMap];', @@ -4310,7 +4461,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmRuntime', - declaration: 'export class LlmRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n @Remote\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n @Remote\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal): Promise;\n @Remote(\'discoverModels\')\n async remoteDiscoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export class LlmRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n @Remote\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n @Remote\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal): Promise;\n @Remote(\'discoverModels\')\n async remoteDiscoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined;\n fileRequestText(ref: FileAttachmentRef): string;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions) /* …truncated — full shape in source */', }, { name: 'LspHover', @@ -4580,6 +4731,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PromptContextOrderName', declaration: 'export type PromptContextOrderName = keyof typeof CONTEXT_ORDERS;', }, + { + name: 'PromptFileBinding', + declaration: 'export interface PromptFileBinding extends Disposable {\n commit(): void;\n}', + }, { name: 'PromptSection', declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly complete?: boolean;\n}', @@ -4720,6 +4875,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SandboxPolicyRequest', declaration: 'export interface SandboxPolicyRequest {\n session?: Session;\n mode?: SandboxMode;\n}', }, + { + name: 'SaveFileAttachment', + declaration: 'export interface SaveFileAttachment {\n data: Uint8Array;\n name?: string;\n}', + }, + { + name: 'SaveFileStreamAttachment', + declaration: 'export interface SaveFileStreamAttachment {\n data: AsyncIterable;\n signal?: AbortSignal;\n name?: string;\n}', + }, { name: 'SaveImageAttachment', declaration: 'export interface SaveImageAttachment {\n data: Uint8Array;\n mediaType: ImageMediaType;\n name?: string;\n}', diff --git a/packages/goal/command-goal/README.i18n.yaml b/packages/goal/command-goal/README.i18n.yaml index 82e85ba55b..2b7e6db63f 100644 --- a/packages/goal/command-goal/README.i18n.yaml +++ b/packages/goal/command-goal/README.i18n.yaml @@ -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/goal/command-goal/README.md -README.md: e602035323eae61df74df06569b934a641140ec0 -README.zh.md: 10438236c40693f97433ce554ad061df5bf47ea3 +README.md: d8f49882d404bfe68e203c7445001cb648d9282a +README.zh.md: 00db532e97a39175579c5299c7ea598b07be0440 diff --git a/packages/goal/command-goal/README.md b/packages/goal/command-goal/README.md index e602035323..d8f49882d4 100644 --- a/packages/goal/command-goal/README.md +++ b/packages/goal/command-goal/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-command-goal` gives the human `/goal` command over the persisted goal service: a user can create, edit, pause, resume, clear, and inspect the current goal directly from the UI, without involving the model. The command registers in its Cordis scope, so command adapters reading that scope discover and execute it, while command text and output stay in the UI — they never enter model requests. Every accepted mutation persists through the goal service's durable `goal/change` event. Image attachments may accompany a create or edit and are submitted as one ordinary user message so later goal rounds see them. Choose it for interactive deployments with a command adapter; headless and automation apps without one do not need it. +`dsh-command-goal` gives the human `/goal` command over the persisted goal service: a user can create, edit, pause, resume, clear, and inspect the current goal directly from the UI, without involving the model. The command registers in its Cordis scope, so command adapters reading that scope discover and execute it, while command text and output stay in the UI — they never enter model requests. Every accepted mutation persists through the goal service's durable `goal/change` event. Ordered image and file attachments may accompany a create or edit and are submitted as one ordinary user message so later goal rounds see them. Choose it for interactive deployments with a command adapter; headless and automation apps without one do not need it. ## Table of Contents @@ -44,9 +44,9 @@ Every sub-command runs against the current goal of the invoking agent; a bare `/ Control words (`clear`, `pause`, `resume`, `edit`) are recognized only when they occupy the complete input; any other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. `edit` takes its replacement inline and refuses to replace an unfinished goal directly. Expected domain rejections become stable, direct command errors without exposing branded ids or revisions; unexpected implementation failures still fail dispatch so adapters can report them as command failures. -### Image attachments +### Attachments -`/goal` declares image support, so a composer may attach images to an invocation. Attachments only accompany an objective: on a successful create or edit the command submits one user followup carrying the admitted image blocks plus the fixed text `Reference images for the goal objective.`, so later goal rounds read them from ordinary session history without the goal domain storing attachment state. Every other sub-command, and any refused create or edit, returns a direct error and submits nothing, so the dispatching composer keeps the images. +`/goal` declares attachment support. Attachments accompany only an objective: after a successful create or edit, the command submits one user followup carrying the admitted image and file blocks in selection order plus the fixed text `Reference attachments for the goal objective.` Later goal rounds read that ordinary session history; the goal domain stores no attachment state. Every other sub-command, and any refused create or edit, returns a direct error before a domain mutation and leaves the dispatching composer's draft and cards intact. ### Compose it @@ -77,7 +77,7 @@ This section explains how the command parses input and renders output; the obser - **Grammar, not free text.** The parser recognizes only the exact control words (`clear`, `pause`, `resume`, `edit`) when they fill the whole input; every other non-empty suffix is an objective. `edit` alone is invalid, and `edit` refuses to replace an unfinished goal directly. - **Domain rejections become stable errors.** `GoalError` outcomes are converted into direct command errors with a fixed message; unexpected failures rethrow so adapters report a command failure rather than a domain result. Rendered output never exposes branded ids or revisions. -- **Attachments ride the objective.** On a successful create or edit the command submits one user followup carrying the admitted image blocks plus the fixed text `Reference images for the goal objective.`; every other path submits nothing, so the dispatching composer keeps the images. +- **Attachments accompany the objective.** On a successful create or edit, the command submits one user followup carrying the admitted image and file blocks in selection order plus the fixed text `Reference attachments for the goal objective.` Every other path submits nothing, so the dispatching composer keeps the draft and cards. ### Source map @@ -108,11 +108,11 @@ The command is a thin adapter over the goal domain; read these pages for the sta #### What the model sees -The slash input, mutation, and direct status/error output are absent from model requests. The goal domain records the mutation as `goal/change`; an enabled same-session driver may expose the resulting state in a later continuation prompt. Presentation text is never logged. When a create or edit carries image attachments, the model sees one ordinary user message: the image blocks followed by the text `Reference images for the goal objective.`; it precedes the next goal round in session history. +The slash input, mutation, and direct status/error output are absent from model requests. The goal domain records the mutation as `goal/change`; an enabled same-session driver may expose the resulting state in a later continuation prompt. Presentation text is never logged. When a create or edit carries attachments, the model sees one ordinary user message: the ordered image and file blocks followed by the text `Reference attachments for the goal objective.` It precedes the next goal round in session history. #### Token effect -Reading status, mutating a goal, or receiving a direct command error adds no model tokens. An enabled same-session driver may add later goal-round prompts. An objective's image attachments add one user message billed like any image prompt. +Reading status, mutating a goal, or receiving a direct command error adds no model tokens. An enabled same-session driver may add later goal-round prompts. An objective's attachments add one ordinary user message with the normal text, image, and file-handle costs. #### KV Cache effect diff --git a/packages/goal/command-goal/README.zh.md b/packages/goal/command-goal/README.zh.md index 10438236c4..00db532e97 100644 --- a/packages/goal/command-goal/README.zh.md +++ b/packages/goal/command-goal/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-command-goal` 为用户提供基于持久 goal 服务的 `/goal` 命令:用户可以直接在 UI 中创建、编辑、暂停、恢复、清除并查看当前 goal,无需模型参与。命令在其 Cordis scope 中注册,因此读取该 scope 的命令适配器能发现并执行它;命令文本与输出都留在 UI 中——绝不进入模型请求。每项被接受的变更都会通过 goal 服务的持久 `goal/change` 事件落盘。图片附件可以随 create 或 edit 一起提交,并以一条普通用户消息发出,供后续 Goal Round 读取。为挂载了命令适配器的交互式部署选择它;没有适配器的无头与自动化应用不需要它。 +`dsh-command-goal` 为用户提供基于持久 goal 服务的 `/goal` 命令:用户可以直接在 UI 中创建、编辑、暂停、恢复、清除并查看当前 goal,无需模型参与。命令在其 Cordis scope 中注册,因此读取该 scope 的命令适配器能发现并执行它;命令文本与输出都留在 UI 中,绝不进入模型请求。每项被接受的变更都会通过 goal 服务的持久 `goal/change` 事件落盘。有序的图片与文件附件可以随 create 或 edit 一起提交,并以一条普通用户消息发出,供后续 Goal Round 读取。为挂载了命令适配器的交互式部署选择它;没有适配器的无头与自动化应用不需要它。 ## 目录 @@ -44,9 +44,9 @@ kind: "package-reference" 只有控制词(`clear`、`pause`、`resume`、`edit`)占据完整输入时才被识别;其他任何非空后缀都是目标,因此 `/goal pause after verification` 会创建该字面目标。`edit` 内联接收替换内容,并拒绝直接替换未完成的 goal。可预期的领域拒绝会变成稳定的直接命令错误,不暴露带品牌类型的 id 或 revision;意外实现失败仍会让分发失败,使适配器能将其报告为命令失败。 -### 图片附件 +### 附件 -`/goal` 声明了图片支持,因此 composer 可以随调用附加图片。附件只随目标本身:create 或 edit 成功时,命令提交一条用户 followup 消息,携带已准入的图片块加固定文本 `Reference images for the goal objective.`,后续 Goal Round 从普通会话历史读取它们,goal 领域不存储附件状态。其他任何子命令、以及被拒绝的 create 或 edit,都直接返回错误且不提交任何消息,分发方 composer 保留图片。 +`/goal` 声明附件支持。附件只随目标本身:create 或 edit 成功后,命令提交一条用户 followup 消息,按选择顺序携带已准入的图片块与文件块,再附加固定文本 `Reference attachments for the goal objective.`。后续 Goal Round 从普通会话历史读取这些内容,goal 领域不存储附件状态。其他任何子命令以及被拒绝的 create 或 edit,都会在领域变更前返回直接错误,并保留 composer 的草稿和附件卡。 ### 组合方式 @@ -77,7 +77,7 @@ kind: "package-reference" - **语法,而非自由文本。** 解析器只在控制词(`clear`、`pause`、`resume`、`edit`)填满整个输入时识别它们;其他任何非空后缀都是目标。单独的 `edit` 无效,且 `edit` 拒绝直接替换未完成的 goal。 - **领域拒绝变成稳定错误。** `GoalError` 结果会转换为带固定消息的直接命令错误;意外失败会重新抛出,使适配器报告命令失败而非领域结果。渲染输出绝不暴露带品牌类型的 id 或 revision。 -- **附件随目标而行。** create 或 edit 成功时,命令提交一条用户 followup 消息,携带已准入的图片块加固定文本 `Reference images for the goal objective.`;其他路径都不提交任何消息,因此分发方 composer 保留图片。 +- **附件随目标提交。** create 或 edit 成功时,命令提交一条用户 followup 消息,按选择顺序携带已准入的图片块与文件块,再附加固定文本 `Reference attachments for the goal objective.`。其他路径不提交消息,因此分发方 composer 保留草稿和附件卡。 ### 源码地图 @@ -108,11 +108,11 @@ kind: "package-reference" #### 模型看到的内容 -斜杠输入、变更以及直接状态/错误输出不会进入模型请求。goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续续行提示词中暴露结果状态。呈现文本绝不会记录到日志中。当 create 或 edit 携带图片附件时,模型会看到一条普通用户消息:图片块后跟文本 `Reference images for the goal objective.`,在会话历史中位于下一个 Goal Round 之前。 +斜杠输入、变更以及直接状态或错误输出不会进入模型请求。goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续续行提示词中暴露结果状态。呈现文本不会记录到日志中。当 create 或 edit 携带附件时,模型会看到一条普通用户消息:有序的图片块与文件块后跟文本 `Reference attachments for the goal objective.`。它在会话历史中位于下一个 Goal Round 之前。 #### Token 影响 -读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。目标携带的图片附件会增加一条用户消息,其计费与任何图片提示词相同。 +读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。目标携带的附件会增加一条普通用户消息,产生常规文本、图片和文件句柄成本。 #### KV Cache 影响 diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts index e1b6e79eed..b9e64ee7f2 100644 --- a/packages/goal/command-goal/src/index.ts +++ b/packages/goal/command-goal/src/index.ts @@ -108,15 +108,15 @@ function missingGoal(action: string): CommandResult { } /** - * Submit the invocation's admitted composer images as one model-visible user - * message ahead of the goal's next round. The images precede a fixed text + * Submit the invocation's admitted composer attachments as one model-visible user + * message ahead of the goal's next round. The attachments precede a fixed text * block naming their role, so a later goal round reads them from ordinary * session history without the goal domain storing attachment state. */ function submitObjectiveAttachments(invocation: CommandInvocation): void { if (invocation.attachments.length === 0) return invocation.agent.followup(createUserMessage({ - content: [...invocation.attachments, { type: 'text', text: 'Reference images for the goal objective.' }], + content: [...invocation.attachments, { type: 'text', text: 'Reference attachments for the goal objective.' }], source: { kind: 'user' }, })) } @@ -127,7 +127,7 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman if (invocation.attachments.length > 0 && command.kind !== 'create' && command.kind !== 'edit') { return { kind: 'error', - text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + text: 'Attachments only accompany a goal objective: /goal or /goal edit .', } } try { @@ -190,7 +190,7 @@ export function apply(ctx: Context): void { ctx.commands.register({ name: 'goal', description: 'set or view the goal for a long-running task', - input: { hint: '[|clear|edit |pause|resume]', images: true }, + input: { hint: '[|clear|edit |pause|resume]', attachments: true }, handler: invocation => executeGoalCommand(ctx, invocation), }) } diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 1f1cdf0753..ed53189f7d 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -99,7 +99,7 @@ describe('@deepseek-ai/dsh-command-goal registration', () => { expect(test.ctx.commands.list(test.agent)).toContainEqual({ name: 'goal', description: 'set or view the goal for a long-running task', - input: { hint: '[|clear|edit |pause|resume]', images: true }, + input: { hint: '[|clear|edit |pause|resume]', attachments: true }, }) expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined() @@ -236,7 +236,7 @@ describe('/goal human command', () => { }) }) -describe('/goal image attachments', () => { +describe('/goal attachments', () => { const PNG = 'AAAA' /** Wire the fake store the executor admits through (once per harness). */ @@ -261,25 +261,35 @@ describe('/goal image attachments', () => { for (const input of inputs) refs.push(await saveImage(input)) return refs }, + saveFile(input: { data: Uint8Array; name?: string }) { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, bytes: input.data.byteLength, name: input.name ?? 'attachment', + }) + }, }) + test.ctx.commands.registerFileReceiptResolver((_agent, receiptId) => receiptId === 'receipt-notes' + ? { attachmentId: 'file-notes' as never, bytes: 5, name: 'notes.txt' } + : undefined) } - /** Run /goal with `count` composer images through the executor boundary. */ - async function runWithImages(test: Harness, suffix: string, count: number) { - const images = Array.from({ length: count }, (_, index) => ({ - mediaType: 'image/png' as const, data: PNG, name: `ref-${index + 1}.png`, - })) - const execution = await test.ctx.commands.execute(test.agent, `/goal${suffix}`, images, new AbortController().signal) + /** Run /goal with a mixed composer batch through the executor boundary. */ + async function runWithAttachments(test: Harness, suffix: string, includeFile = true) { + const attachments = [ + { type: 'image' as const, mediaType: 'image/png' as const, data: PNG, name: 'ref.png' }, + ...(includeFile ? [{ type: 'file' as const, receiptId: 'receipt-notes' }] : []), + ] + const execution = await test.ctx.commands.execute(test.agent, `/goal${suffix}`, attachments, new AbortController().signal) if (execution === undefined) throw new Error('goal command was not registered') return execution.result } - it('submits one user followup carrying the admitted images ahead of the round prompt', async () => { + it('submits one user followup carrying mixed attachments ahead of the round prompt', async () => { const test = await harness() provideStore(test) const followup = vi.fn() ;(test.agent as unknown as { followup: typeof followup }).followup = followup - const result = await runWithImages(test, ' rebuild the cathedral', 2) + const result = await runWithAttachments(test, ' rebuild the cathedral') expect(result.kind).toBe('success') expect(followup).toHaveBeenCalledTimes(1) const message = followup.mock.calls[0]?.[0] as { @@ -287,9 +297,10 @@ describe('/goal image attachments', () => { source: { kind: string } } expect(message.source).toEqual({ kind: 'user' }) - expect(message.content.map(block => block.type)).toEqual(['image', 'image', 'text']) - expect(message.content.at(-1)).toEqual({ type: 'text', text: 'Reference images for the goal objective.' }) - expect((message.content[0] as { attachment: { name: string } }).attachment.name).toBe('ref-1.png') + expect(message.content.map(block => block.type)).toEqual(['image', 'file', 'text']) + expect(message.content.at(-1)).toEqual({ type: 'text', text: 'Reference attachments for the goal objective.' }) + expect((message.content[0] as { attachment: { name: string } }).attachment.name).toBe('ref.png') + expect((message.content[1] as { attachment: { name: string } }).attachment.name).toBe('notes.txt') }) it('accompanies an edit and a post-complete recreate the same way', async () => { @@ -298,7 +309,7 @@ describe('/goal image attachments', () => { const followup = vi.fn() ;(test.agent as unknown as { followup: typeof followup }).followup = followup test.ctx.goals.create(test.agent, { objective: 'initial objective' }) - const result = await runWithImages(test, ' edit refined objective', 1) + const result = await runWithAttachments(test, ' edit refined objective') expect(result.kind).toBe('success') expect(followup).toHaveBeenCalledTimes(1) }) @@ -310,10 +321,10 @@ describe('/goal image attachments', () => { ;(test.agent as unknown as { followup: typeof followup }).followup = followup test.ctx.goals.create(test.agent, { objective: 'active objective' }) for (const suffix of [' pause', '', ' clear']) { - const result = await runWithImages(test, suffix, 1) + const result = await runWithAttachments(test, suffix, false) expect(result).toEqual({ kind: 'error', - text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + text: 'Attachments only accompany a goal objective: /goal or /goal edit .', }) } expect(followup).not.toHaveBeenCalled() @@ -326,7 +337,7 @@ describe('/goal image attachments', () => { const followup = vi.fn() ;(test.agent as unknown as { followup: typeof followup }).followup = followup test.ctx.goals.create(test.agent, { objective: 'existing objective' }) - const result = await runWithImages(test, ' replacement objective', 1) + const result = await runWithAttachments(test, ' replacement objective') expect(result.kind).toBe('error') expect(followup).not.toHaveBeenCalled() }) diff --git a/packages/interaction/commands/README.i18n.yaml b/packages/interaction/commands/README.i18n.yaml index c5737c521f..90922a259a 100644 --- a/packages/interaction/commands/README.i18n.yaml +++ b/packages/interaction/commands/README.i18n.yaml @@ -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/interaction/commands/README.md -README.md: 4f7a1f4475bf7edeb8e3bae6eec8b8a04b411c3d -README.zh.md: af9a23adf6a88a0b046760ece6e3cf1cdaac1fd6 +README.md: cad2e7c3fc9a17a21679376b1d1a921efb72918a +README.zh.md: abb538d6ad4988e3ad105b822e19365340fdfd1c diff --git a/packages/interaction/commands/README.md b/packages/interaction/commands/README.md index 4f7a1f4475..cad2e7c3fc 100644 --- a/packages/interaction/commands/README.md +++ b/packages/interaction/commands/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-commands` lets a user type `/command [input]` in an interactive Harness UI and run it directly against the receiving agent without creating a model message. Plugins register commands with a name, description, optional input hint and image-acceptance flag, and an abortable handler; interactive adapters discover and dispatch them per agent. A command-producing plugin mounted under an agent's context can register an exact agent-scoped command that shadows the global one of the same name. Each command run is recorded in the session log, and its result is rendered by the adapter, never entering model history. Slash commands ship with the `dsh` CLI and the Web client. +`dsh-commands` lets a user type `/command [input]` in an interactive Harness UI and run it directly against the receiving agent without creating a model message. Plugins register commands with a name, description, optional input hint and attachment-acceptance flag, and an abortable handler; interactive adapters discover and dispatch them per agent. A command-producing plugin mounted under an agent's context can register an exact agent-scoped command that shadows the global one of the same name. Each command run is recorded in the session log, and its result is rendered by the adapter, never entering model history. Slash commands ship with the `dsh` CLI and the Web client. ## Table of Contents @@ -53,13 +53,13 @@ A command line starts with a slash at byte zero, a lowercase name containing let A plain registration is global. A command-producing plugin mounted beneath an agent's own context declares its `commands` injection and registers an exact agent-scoped command, which shadows the global definition of the same name for that agent only. -### Image attachments +### Attachments -A command may declare `input.images` to accept composer image attachments. The executor enforces the declaration: images sent to a non-declaring command, an absent attachment store, or an over-limit batch each settle as an error before the handler runs. Admitted images reach the handler as frozen ordered `ImageBlock`s on `invocation.attachments`, and the handler owns their model-visible use — the registry never schedules them itself. +A command may declare `input.attachments` to accept composer images and generic files. The executor enforces the declaration: attachments sent to a non-declaring command, an absent attachment store, an unknown Session-scoped file-upload receipt, or an over-limit image batch each settle as an error before the handler runs. Images cross the command wire as base64 input; generic files cite receipts from their completed background uploads, so command submission never reads their bytes again. Admitted `ImageBlock`s and `FileBlock`s reach the handler as one frozen `invocation.attachments` array in the user's selection order, and the handler owns their model-visible use. ### Dispatching from an adapter -An interactive adapter calls `execute(agent, line, images, signal)` with the exact receiving agent, the full command line, and the submission's images. It returns the settled `CommandExecution` — the normalized result plus its lifecycle `commandId` — or `undefined` for invalid syntax or an unknown name. `list(agent)` and `find(agent, name)` serve discovery after agent-scoped shadowing. +An interactive adapter calls `execute(agent, line, attachments, signal)` with the exact receiving agent, the full command line, and the submission's ordered attachments. It returns the settled `CommandExecution` — the normalized result plus its lifecycle `commandId` — or `undefined` for invalid syntax or an unknown name. `list(agent)` and `find(agent, name)` serve discovery after agent-scoped shadowing. ### Cancellation @@ -92,9 +92,9 @@ The observable behavior is covered in [Use this package](#use-this-package); thi Registrations live in global and agent-scoped layers merged per agent via `ScopedLayers`. The child-injection shape — a command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection — preserves agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the mutation or starve later observers. -### Image admission +### Attachment admission -Image enforcement happens in the executor, not the composer: an admitted batch is committed through `admitEncodedImages` against the `attachments` store, a rejected batch publishes no durable object, and cancellation is honored before the handler runs so a retrying caller never duplicates state. Handlers that cannot use the images return an error, so the dispatching composer keeps the originals. +Attachment enforcement happens in the executor: images are committed through `admitEncodedImages`, files are resolved through the single Session-aware receipt provider, and the executor restores their original mixed order before calling the handler. Validation rejection starts no attachment writes. An image-storage failure can leave unreachable content-addressed objects for deferred collection, but publishes no model-visible message. Cancellation is honored before the handler runs. Command errors leave the dispatching composer's draft and attachment cards intact. @@ -119,7 +119,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions) submits the optional message in `/plan [message]` after selecting plan mode. Image attachments follow the same rule: the executor only admits them into durable attachment objects, and a declaring producer decides whether and how they become model-visible message content. +The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions) submits the optional message and ordered attachments in `/plan [message]` after selecting plan mode. The executor only admits attachments into durable objects; the declaring producer decides whether and how they become model-visible message content. #### Token effect diff --git a/packages/interaction/commands/README.zh.md b/packages/interaction/commands/README.zh.md index af9a23adf6..abb538d6ad 100644 --- a/packages/interaction/commands/README.zh.md +++ b/packages/interaction/commands/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-commands` 让用户能在交互式 Harness UI 中输入 `/command [input]`,并直接针对接收命令的 agent(智能体)执行,不产生模型消息。插件注册命令时提供名称、描述、可选的输入提示与图片接受标志,以及可中止的处理器;交互式适配器按 agent 发现并分派这些命令。挂载在 agent 上下文之下的命令生产插件可以注册精确限定到该 agent 的命令,它会遮蔽同名的全局定义。每次命令执行都会记录在接收 agent 的会话日志中,结果由适配器渲染,绝不进入模型历史。斜杠命令随 `dsh` CLI 与 Web 客户端一起提供。 +`dsh-commands` 让用户能在交互式 Harness UI 中输入 `/command [input]`,并直接针对接收命令的 agent(智能体)执行,不产生模型消息。插件注册命令时提供名称、描述、可选的输入提示与附件接受标志,以及可中止的处理器;交互式适配器按 agent 发现并分派这些命令。挂载在 agent 上下文之下的命令生产插件可以注册精确限定到该 agent 的命令,它会遮蔽同名的全局定义。每次命令执行都会记录在接收 agent 的会话日志中,结果由适配器渲染,绝不进入模型历史。斜杠命令随 `dsh` CLI 与 Web 客户端一起提供。 ## 目录 @@ -53,13 +53,13 @@ ctx.commands.register({ 普通注册全局生效。挂载在 agent 自身上下文之下的命令生产插件会声明 `commands` 注入,并注册精确限定到该 agent 的命令;该定义只对这个 agent 遮蔽同名的全局定义。 -### 图片附件 +### 附件 -命令可以声明 `input.images` 以接受 composer 图片附件。执行器负责声明的强制执行:把图片发给未声明的命令、附件存储缺失或批量超出限制,都会在处理器运行前以错误结果结算。通过准入的图片以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器,其模型可见用途由处理器负责——注册表本身绝不定时调度它们。 +命令可以声明 `input.attachments` 以接受 composer 图片与通用文件。执行器负责强制执行声明:把附件发给未声明的命令、附件存储缺失、Session 范围内的文件上传凭证未知或图片批量超出限制,都会在处理器运行前以错误结果结算。图片以 base64 输入通过命令 wire,通用文件则引用后台上传完成后得到的凭证,因此命令提交不会再次读取文件字节。通过准入的 `ImageBlock` 与 `FileBlock` 按用户选择顺序组成冻结的 `invocation.attachments` 数组,其模型可见用途由处理器负责。 ### 从适配器分派 -交互式适配器调用 `execute(agent, line, images, signal)`,传入确切的接收 agent、完整命令行与本次提交的图片。它返回已结算的 `CommandExecution`——规范化结果加生命周期配对 `commandId`——语法无效或名称未知时返回 `undefined`。`list(agent)` 与 `find(agent, name)` 在应用 agent 作用域遮蔽后服务发现。 +交互式适配器调用 `execute(agent, line, attachments, signal)`,传入确切的接收 agent、完整命令行与本次提交的有序附件。它返回已结算的 `CommandExecution`——规范化结果加生命周期配对 `commandId`——语法无效或名称未知时返回 `undefined`。`list(agent)` 与 `find(agent, name)` 在应用 agent 作用域遮蔽后服务发现。 ### 取消 @@ -92,9 +92,9 @@ ctx.commands.register({ 注册表通过 `ScopedLayers` 维护全局层与按 agent 的作用域层,并按 agent 合并视图。子级注入形态——挂载在 `agent.ctx` 之下的命令生产插件声明自身的 `commands` 注入——保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层内的名称重复会在注册时失败;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 -### 图片准入 +### 附件准入 -图片强制执行发生在执行器而非 composer 中:通过准入的批量经 `admitEncodedImages` 提交给 `attachments` 存储,被拒绝的批量不发布任何持久化对象;取消会在处理器运行前被处理,因此重试的调用方绝不会重复状态。无法使用图片的处理器会返回错误,使分发方 composer 保留原件。 +附件强制执行发生在执行器中:图片经 `admitEncodedImages` 提交,文件通过唯一的 Session 感知凭证提供方解析,执行器在调用处理器前恢复原始混合顺序。验证拒绝不会开始写入附件。图片存储失败可能留下等待清理且无法引用的内容寻址对象,但不会发布模型可见消息。取消会在处理器运行前生效。命令返回错误时,分发方 composer 的草稿与附件卡保持原位。 @@ -119,7 +119,7 @@ ctx.commands.register({ #### 模型看到的内容 -注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.zh.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。图片附件遵循同一规则:执行器只负责把它们准入为持久化附件对象,是否以及如何成为模型可见的消息内容由声明接受的生产方决定。 +注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.zh.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息与有序附件。执行器只负责把附件准入为持久化对象,是否以及如何成为模型可见消息由声明接受的生产方决定。 #### Token 影响 diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index b7cdeb0b4a..0e94998066 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -7,8 +7,8 @@ import { Context } from '@deepseek-ai/cordis' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { Agent } from '@deepseek-ai/dsh-agent' import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' -import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' -import type { ImageBlock } from '@deepseek-ai/dsh-llm' +import type { EncodedImageAttachment, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment/types' +import type { FileBlock, ImageBlock } from '@deepseek-ai/dsh-llm' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import { SessionSeq } from '@deepseek-ai/dsh-session' @@ -20,6 +20,7 @@ import type { CommandExecution, CommandInputDescriptor, CommandResult, + CommandSubmitAttachment, } from './types.ts' export { CommandId } from './brand.ts' @@ -29,8 +30,11 @@ export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u -/** Shared frozen attachments value for image-free invocations. */ -const NO_ATTACHMENTS: readonly ImageBlock[] = Object.freeze([]) +/** Shared frozen attachments value for attachment-free invocations. */ +const NO_ATTACHMENTS: readonly (ImageBlock | FileBlock)[] = Object.freeze([]) + +/** Host resolver for Session-scoped staged file-upload receipts. */ +export type CommandFileReceiptResolver = (agent: Agent, receiptId: string) => FileAttachmentRef | undefined /** Invocation passed to one registered command handler. */ export interface CommandInvocation { @@ -41,13 +45,13 @@ export interface CommandInvocation { /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string /** - * Durably admitted image blocks accompanying this invocation, in submission - * order; empty unless the definition declares `input.images`. The handler + * Durably admitted image and file blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.attachments`. The handler * owns their model-visible use — the registry never schedules them itself — * and a handler whose grammar cannot use them in this invocation returns an * error so the dispatching composer retains the originals. */ - readonly attachments: readonly ImageBlock[] + readonly attachments: readonly (ImageBlock | FileBlock)[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -192,12 +196,12 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { if (rawInput.hint.trim().length === 0) { throw new TypeError(`command "${definition.name}" input hint must not be empty`) } - if ('images' in rawInput && rawInput.images !== undefined && typeof rawInput.images !== 'boolean') { - throw new TypeError(`command "${definition.name}" input images flag must be a boolean`) + if ('attachments' in rawInput && rawInput.attachments !== undefined && typeof rawInput.attachments !== 'boolean') { + throw new TypeError(`command "${definition.name}" input attachments flag must be a boolean`) } input = Object.freeze({ hint: rawInput.hint, - ...('images' in rawInput && rawInput.images === true) ? { images: true } : {}, + ...('attachments' in rawInput && rawInput.attachments === true) ? { attachments: true } : {}, }) } const normalized = Object.freeze({ @@ -261,6 +265,8 @@ export class CommandRuntime extends TypertRemoteService { private commandSeq = 0 /** Instance token keeping minted ids unique across process restarts over one resumed log. */ private readonly instanceToken = randomUUID().slice(0, 8) + /** Optional provider installed by the Session upload owner. */ + private readonly fileReceipts: { resolver: CommandFileReceiptResolver | undefined } = { resolver: undefined } constructor(ctx: Context) { super(ctx, 'commands') @@ -280,6 +286,21 @@ export class CommandRuntime extends TypertRemoteService { ) } + /** + * Register the sole authority that resolves staged file receipts for command submissions. + * @param resolver - Session-aware receipt resolver. + * @returns disposer that removes this exact resolver. + */ + registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void { + if (this.fileReceipts.resolver !== undefined) { + throw new Error('commands: a file receipt resolver is already registered') + } + this.fileReceipts.resolver = resolver + return () => { + if (this.fileReceipts.resolver === resolver) this.fileReceipts.resolver = undefined + } + } + /** * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. @@ -316,15 +337,17 @@ export class CommandRuntime extends TypertRemoteService { * handler-failure path is contained so the handler's own error stays the * reported failure. * - * Image admission is enforced here, not in the composer: images sent to a - * command that does not declare `input.images`, an absent attachment store, - * and an exceeded attachment limit each settle as an error result before - * the handler runs, and a rejected batch publishes no durable object. + * Attachment admission is enforced here, not in the composer: attachments sent to a + * command that does not declare `input.attachments`, an absent attachment store, + * and an exceeded image limit each settle as an error result before + * the handler runs. Validation rejection starts no attachment writes; + * a storage failure can leave only unreachable content-addressed objects + * for deferred collection. * * @param agent - exact receiving agent. * @param line - complete slash-command line. - * @param images - base64-encoded composer images accompanying the line, in - * submission order; empty for a plain invocation. + * @param submittedAttachments - encoded images and staged file receipts accompanying the line, + * in submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. @@ -333,7 +356,7 @@ export class CommandRuntime extends TypertRemoteService { async execute( agent: Agent, line: string, - images: readonly EncodedImageAttachment[], + submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise { const parsed = parseCommand(line) @@ -358,18 +381,21 @@ export class CommandRuntime extends TypertRemoteService { }) return Object.freeze({ commandId, result: Object.freeze(result) }) } - let attachments: readonly ImageBlock[] = NO_ATTACHMENTS - if (images.length > 0) { - if (command.definition.input?.images !== true) { - return settle({ kind: 'error', text: `/${parsed.name} does not accept image attachments` }) + let attachments: readonly (ImageBlock | FileBlock)[] = NO_ATTACHMENTS + if (submittedAttachments.length > 0) { + if (command.definition.input?.attachments !== true) { + return settle({ kind: 'error', text: `/${parsed.name} does not accept attachments` }) } const store = this.ctx.get('attachments') if (store === undefined) { - return settle({ kind: 'error', text: `/${parsed.name}: image attachments are unavailable because no attachment store is composed` }) + return settle({ kind: 'error', text: `/${parsed.name}: attachments are unavailable because no attachment store is composed` }) } try { - const refs = await admitEncodedImages(store, images) - attachments = Object.freeze(refs.map(ref => Object.freeze({ type: 'image' as const, attachment: ref }))) + attachments = await admitCommandAttachments( + store, + submittedAttachments, + receiptId => this.fileReceipts.resolver?.(agent, receiptId), + ) } catch (error: unknown) { if (error instanceof AttachmentError) { return settle({ kind: 'error', text: error.message }) @@ -458,4 +484,45 @@ export class CommandRuntime extends TypertRemoteService { } } +/** Admit a mixed command batch and restore its original image/file order. */ +async function admitCommandAttachments( + store: Parameters[0], + attachments: readonly CommandSubmitAttachment[], + resolveFileReceipt: (receiptId: string) => FileAttachmentRef | undefined, +): Promise { + const files = new Map() + for (const attachment of attachments) { + if (attachment.type !== 'file' || files.has(attachment.receiptId)) continue + const file = resolveFileReceipt(attachment.receiptId) + if (file === undefined) { + throw new AttachmentError('File upload receipt is unknown for this session.', 'ATTACHMENT_NOT_FOUND') + } + files.set(attachment.receiptId, file) + } + const images: EncodedImageAttachment[] = [] + for (const attachment of attachments) { + if (attachment.type !== 'image') continue + images.push({ + mediaType: attachment.mediaType, + data: attachment.data, + ...(attachment.name === undefined ? {} : { name: attachment.name }), + }) + } + const imageRefs = images.length === 0 ? [] : await admitEncodedImages(store, images) + let imageIndex = 0 + const blocks: Array = [] + for (const attachment of attachments) { + if (attachment.type === 'image') { + const ref = imageRefs[imageIndex] as ImageAttachmentRef + imageIndex += 1 + blocks.push(Object.freeze({ type: 'image', attachment: ref })) + continue + } + blocks.push(Object.freeze({ + type: 'file', attachment: files.get(attachment.receiptId) as FileAttachmentRef, + })) + } + return Object.freeze(blocks) +} + export default CommandRuntime diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index 0ca87c778d..7277766871 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -9,19 +9,25 @@ import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { CommandId } from './brand.ts' +import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' + +/** One browser-submitted command attachment: encoded image input or a staged file receipt. */ +export type CommandSubmitAttachment = + | ({ readonly type: 'image' } & EncodedImageAttachment) + | { readonly type: 'file'; readonly receiptId: string } /** Immutable metadata for a command's optional unstructured input. */ export interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string /** - * Whether composer image attachments may accompany an invocation. Absent or - * false = the executor rejects an invocation carrying images and capable + * Whether composer attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying attachments and capable * composers refuse the submission before dispatch. A declaring command's * handler receives the admitted durable blocks and owns every further * grammar decision, including rejecting sub-commands that cannot use them. */ - readonly images?: boolean + readonly attachments?: boolean } /** Expected command outcome rendered directly by the dispatching UI. */ diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 01a31e205c..92a12d921d 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -466,7 +466,7 @@ describe('CommandRuntime', () => { }) }) -describe('image attachments', () => { +describe('command attachments', () => { const PNG = 'AAAA' function storeOf() { @@ -484,6 +484,12 @@ describe('image attachments', () => { ...input.name === undefined ? {} : { name: input.name }, }) }), + saveFile: vi.fn((input: { data: Uint8Array; name?: string }) => { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, bytes: input.data.byteLength, name: input.name ?? 'attachment', + }) + }), validateImageBatch(inputs: readonly unknown[]) { const validate = AttachmentStore.prototype as unknown as { validateImageBatch(this: unknown, batch: readonly unknown[]): void @@ -501,8 +507,8 @@ describe('image attachments', () => { function accepting(handler: CommandDefinition['handler']): CommandDefinition { return { name: 'vision', - description: 'accepts images', - input: { hint: '', images: true }, + description: 'accepts attachments', + input: { hint: '', attachments: true }, handler, } } @@ -511,32 +517,32 @@ describe('image attachments', () => { const ctx = await mount() expect(() => ctx.commands.register({ ...command('flag-type'), - input: { hint: 'x', images: 'yes' }, - } as unknown as CommandDefinition)).toThrow('command "flag-type" input images flag must be a boolean') + input: { hint: 'x', attachments: 'yes' }, + } as unknown as CommandDefinition)).toThrow('command "flag-type" input attachments flag must be a boolean') }) - it('lists images acceptance on the descriptor and omits a false flag', async () => { + it('lists attachment acceptance on the descriptor and omits a false flag', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(accepting(() => ({ kind: 'success' }))) - ctx.commands.register({ ...command('plain-input'), input: { hint: 'x', images: false } }) + ctx.commands.register({ ...command('plain-input'), input: { hint: 'x', attachments: false } }) const byName = new Map(ctx.commands.list(agent).map(descriptor => [descriptor.name, descriptor])) - expect(byName.get('vision')?.input).toEqual({ hint: '', images: true }) + expect(byName.get('vision')?.input).toEqual({ hint: '', attachments: true }) expect(byName.get('plain-input')?.input).toEqual({ hint: 'x' }) }) - it('settles images sent to a non-declaring command as a logged error before the handler', async () => { + it('settles attachments sent to a non-declaring command as a logged error before the handler', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') const handler = vi.fn(() => ({ kind: 'success' as const })) ctx.commands.register({ ...command('deploy'), handler }) const execution = await ctx.commands.execute( - agent, '/deploy now', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal) - expect(execution?.result).toEqual({ kind: 'error', text: '/deploy does not accept image attachments' }) + agent, '/deploy now', [{ type: 'image', mediaType: 'image/png', data: PNG }], new AbortController().signal) + expect(execution?.result).toEqual({ kind: 'error', text: '/deploy does not accept attachments' }) expect(handler).not.toHaveBeenCalled() expect(lifecycleOf(agent)).toMatchObject([ { type: 'command/run', data: { name: 'deploy' } }, - { type: 'command/done', data: { kind: 'error', text: '/deploy does not accept image attachments' } }, + { type: 'command/done', data: { kind: 'error', text: '/deploy does not accept attachments' } }, ]) }) @@ -545,41 +551,79 @@ describe('image attachments', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(accepting(() => ({ kind: 'success' }))) const execution = await ctx.commands.execute( - agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal) + agent, '/vision x', [{ type: 'image', mediaType: 'image/png', data: PNG }], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'error', - text: '/vision: image attachments are unavailable because no attachment store is composed', + text: '/vision: attachments are unavailable because no attachment store is composed', }) }) - it('admits and hands the handler frozen ordered image blocks; plain invocations stay empty', async () => { + it('admits a mixed batch in selection order and keeps plain invocations empty', async () => { const ctx = await mount() ctx.provide('attachments', storeOf()) const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.registerFileReceiptResolver((_receivingAgent, receiptId) => receiptId === 'receipt-notes' + ? { attachmentId: 'file-notes' as never, name: 'notes.txt', bytes: 5 } + : undefined) const seen = vi.fn((invocation: { attachments: readonly unknown[] }) => { expect(Object.isFrozen(invocation.attachments)).toBe(true) return { kind: 'success' as const } }) ctx.commands.register(accepting(seen)) await ctx.commands.execute(agent, '/vision x', [ - { mediaType: 'image/png', data: PNG, name: 'a.png' }, - { mediaType: 'image/png', data: PNG, name: 'b.png' }, + { type: 'image', mediaType: 'image/png', data: PNG, name: 'a.png' }, + { type: 'file', receiptId: 'receipt-notes' }, + { type: 'image', mediaType: 'image/png', data: PNG, name: 'b.png' }, ], new AbortController().signal) const invocation = seen.mock.calls[0]?.[0] as { attachments: ReadonlyArray<{ type: string; attachment: { name?: string } }> } expect(invocation.attachments.map(block => [block.type, block.attachment.name])).toEqual([ - ['image', 'a.png'], ['image', 'b.png'], + ['image', 'a.png'], ['file', 'notes.txt'], ['image', 'b.png'], ]) await ctx.commands.execute(agent, '/vision y', [], new AbortController().signal) expect((seen.mock.calls[1]?.[0] as { attachments: readonly unknown[] }).attachments).toEqual([]) }) + it('requires a same-session file receipt resolver and keeps its registration single-owner', async () => { + const ctx = await mount() + const store = storeOf() + ctx.provide('attachments', store) + const { agent } = await mintAgentScope(ctx, 'a') + const handler = vi.fn(() => ({ kind: 'success' as const })) + ctx.commands.register(accepting(handler)) + const missing = await ctx.commands.execute( + agent, '/vision x', [{ type: 'file', receiptId: 'missing' }], new AbortController().signal) + expect(missing?.result).toEqual({ + kind: 'error', text: 'File upload receipt is unknown for this session.', + }) + expect(handler).not.toHaveBeenCalled() + const mixed = await ctx.commands.execute(agent, '/vision x', [ + { type: 'image', mediaType: 'image/png', data: PNG }, + { type: 'file', receiptId: 'missing' }, + ], new AbortController().signal) + expect(mixed?.result).toEqual({ + kind: 'error', text: 'File upload receipt is unknown for this session.', + }) + expect(store.saveImage).not.toHaveBeenCalled() + + const first = vi.fn(() => undefined) + const disposeFirst = ctx.commands.registerFileReceiptResolver(first) + expect(() => ctx.commands.registerFileReceiptResolver(() => undefined)) + .toThrow('commands: a file receipt resolver is already registered') + disposeFirst() + const disposeSecond = ctx.commands.registerFileReceiptResolver(() => undefined) + disposeFirst() + expect(() => ctx.commands.registerFileReceiptResolver(() => undefined)) + .toThrow('commands: a file receipt resolver is already registered') + disposeSecond() + }) + it('settles an admission limit failure as a logged error result', async () => { const ctx = await mount() ctx.provide('attachments', storeOf()) const { agent } = await mintAgentScope(ctx, 'a') const handler = vi.fn(() => ({ kind: 'success' as const })) ctx.commands.register(accepting(handler)) - const three = [1, 2, 3].map(() => ({ mediaType: 'image/png' as const, data: PNG })) + const three = [1, 2, 3].map(() => ({ type: 'image' as const, mediaType: 'image/png' as const, data: PNG })) const execution = await ctx.commands.execute(agent, '/vision x', three, new AbortController().signal) expect(execution?.result).toEqual({ kind: 'error', text: 'Image batch exceeds the configured image-count limit.' }) expect(handler).not.toHaveBeenCalled() @@ -601,7 +645,7 @@ describe('image attachments', () => { const handler = vi.fn(() => ({ kind: 'success' as const })) ctx.commands.register(accepting(handler)) await expect(ctx.commands.execute( - agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], controller.signal, + agent, '/vision x', [{ type: 'image', mediaType: 'image/png', data: PNG }], controller.signal, )).rejects.toThrow('operator cancelled during admission') expect(handler).not.toHaveBeenCalled() expect(lifecycleOf(agent).at(-1)).toMatchObject({ @@ -618,7 +662,7 @@ describe('image attachments', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(accepting(() => ({ kind: 'success' }))) await expect(ctx.commands.execute( - agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal, + agent, '/vision x', [{ type: 'image', mediaType: 'image/png', data: PNG }], new AbortController().signal, )).rejects.toThrow('disk gone') expect(lifecycleOf(agent).at(-1)).toMatchObject({ type: 'command/done', diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 647c1a8e2b..ad3a18102c 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -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/llm/llm/README.md -README.md: 28ffe082b35715b4c013a5365c741fb49af12a4b -README.zh.md: eb2aba3bb3457949b429c347b93988637f7b4eb8 +README.md: 59b27eaa56769c2b7915e86c88140be59795e3ef +README.zh.md: f517154fe335aca1054deb9b2c30694c3fab5d44 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 28ffe082b3..59b27eaa56 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -100,7 +100,7 @@ The service is built on one separation: **the logical contract is provider-neutr ### Main flow -A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. `offloadRequestImagesWithPolicy()` removes oldest images deterministically by raw or base64 size and count or byte quanta; the pure `offloadedImagePrefixCount()` exposes that decision so route-owned request pricing can reproduce it without building the projection. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. +A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. `offloadRequestImagesWithPolicy()` removes oldest images deterministically by raw or base64 size and count or byte quanta; the pure `offloadedImagePrefixCount()` exposes that decision so route-owned request pricing can reproduce it without building the projection. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. ### Invariants @@ -108,6 +108,7 @@ A request is validated against its exact model's capability — context window, - **Replay state travels only within one adapter** — assistant replay state rides along only when the same adapter instance owns the historical and target routes; otherwise it is dropped before dispatch. - **Prepared calls are one-shot** — a prepared call can be dispatched exactly once, and its call-config fields must match the prepared config. - **Image projection follows the captured route** — durable `ImageBlock` references become route-specific request versions only for image-capable models; text-only models receive stable placeholders. +- **File projection is unconditional** — no provider receives file bytes; every route gets one deterministic handle line per `FileBlock`, and the model reads the saved copy with its file tools on demand. - **Protocol ordering** — `usage` precedes `finish`, tool arguments stay raw JSON strings, and nothing follows the terminal `finish`. - **Registry mutations are atomic** — route and directory registration validates the whole candidate set before anything moves, so a refused change leaves the previous state serving. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index eb2aba3bb3..f517154fe3 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -100,7 +100,7 @@ for await (const chunk of ctx.llm.stream({ ### 主流程 -请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。`offloadRequestImagesWithPolicy()` 按原始字节或 base64 大小以及图片数或字节步长,确定性地从最旧图片开始移除;纯函数 `offloadedImagePrefixCount()` 公开同一决策,使路由所属的请求定价无需构建投影即可复现它。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 +请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。`offloadRequestImagesWithPolicy()` 按原始字节或 base64 大小以及图片数或字节步长,确定性地从最旧图片开始移除;纯函数 `offloadedImagePrefixCount()` 公开同一决策,使路由所属的请求定价无需构建投影即可复现它。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 ### 不变式 @@ -108,6 +108,7 @@ for await (const chunk of ctx.llm.stream({ - **回放状态只在同一适配器内流动**——仅当同一适配器实例同时拥有历史路由与目标路由时,assistant 回放状态才会随行;否则在分发前被丢弃。 - **已准备调用是一次性的**——已准备调用只能分发一次,且其调用配置字段必须与准备好的配置一致。 - **图片投影遵循捕获的路由**——只有支持图片的模型会把持久 `ImageBlock` 引用转换为路由专用请求版本;纯文本模型接收稳定占位符。 +- **文件投影无条件进行**——没有任何提供方会收到文件字节;每条路由对每个 `FileBlock` 都得到一行确定性 handle 文本,模型在需要时用文件工具读取保存的副本。 - **协议顺序**——`usage` 先于 `finish`,工具参数保持原始 JSON 字符串,终止 `finish` 之后不再有任何内容。 - **注册表变更具有原子性**——路由与目录注册会在任何变动前整体校验候选集合,因此被拒绝的变更会让此前状态继续服务。 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 5880f82c98..8ec0c14d35 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -2,7 +2,9 @@ import type { ContentBlock } from './types.ts' import type { Message } from './message.ts' -import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentStore, FileAttachmentRef, ImageAttachmentRef, ImageMediaType, RequestImageAttachment, +} from '@deepseek-ai/dsh-attachment' import { assertNever } from '@deepseek-ai/dsh-util-values' /** Execution-world path that model tools can use to read one normalized attachment. */ @@ -126,6 +128,78 @@ export function contentHasImage(content: readonly ContentBlock[]): boolean { || (block.type === 'tool-result' && contentHasImage(block.content))) } +/** + * True when typed model content contains a file block, walking nested + * tool-result content on the same recursion every file policy shares. + * @param content - typed model content blocks. + * @returns whether any nested block is a file. + */ +export function contentHasFile(content: readonly ContentBlock[]): boolean { + return content.some(block => block.type === 'file' + || (block.type === 'tool-result' && contentHasFile(block.content))) +} + +/** + * Stable model-facing handle for one durable file reference: the address of + * the verbatim stored copy and the instruction to read it on demand. This is + * the only representation a provider ever receives for a file. + * @param ref - durable verbatim file reference. + * @param readonlyPath - execution-world path of the stored copy, when resolvable. + * @returns deterministic handle text naming the file, its size, and its address. + */ +export function fileHandleText(ref: FileAttachmentRef, readonlyPath: string | undefined): string { + const digest = String(ref.attachmentId).slice('sha256:'.length, 'sha256:'.length + 8) + const identity = `File ${quoted(ref.name)} (${ref.bytes} bytes, sha256:${digest})` + if (readonlyPath === undefined) { + return `[${identity} was uploaded, but the current execution environment cannot access a readable path. Report that limitation if its contents are needed; do not claim to have read it.]` + } + return `[${identity}: verbatim read-only copy saved at ${quoted(readonlyPath)}. Read that path with your file tools when its contents are needed; copy it to a writable location before modifying it. When delegating file work, include this saved path in the delegation prompt; only subagents sharing this execution environment can read it.]` +} + +/** Replace every file occurrence, including nested tool results, with handle text. */ +function replaceFilesWithHandles( + blocks: readonly ContentBlock[], + resolvePath: (ref: FileAttachmentRef) => string | undefined, +): ContentBlock[] { + let next: ContentBlock[] | undefined + for (const [index, block] of blocks.entries()) { + if (block.type === 'file') { + next ??= blocks.slice(0, index) + next.push({ type: 'text', text: fileHandleText(block.attachment, resolvePath(block.attachment)) }) + continue + } + if (block.type === 'tool-result') { + const content = replaceFilesWithHandles(block.content, resolvePath) + if (content !== block.content) { + next ??= blocks.slice(0, index) + next.push({ ...block, content }) + continue + } + } + next?.push(block) + } + return next ?? blocks as ContentBlock[] +} + +/** + * Project durable file history into deterministic handle text for every model + * route. Unlike images, no provider receives file blocks natively, so this + * projection is unconditional in request assembly. + * @param messages - complete request history. + * @param resolvePath - resolve one reference's current execution-world read path. + * @returns the original list without files, otherwise shallow message copies with handle text. + */ +export function projectFilesToText( + messages: readonly Message[], + resolvePath: (ref: FileAttachmentRef) => string | undefined, +): readonly Message[] { + if (!messages.some(message => contentHasFile(message.content))) return messages + return messages.map((message) => { + const content = replaceFilesWithHandles(message.content, resolvePath) + return content === message.content ? message : { ...message, content } + }) +} + /** Base64 length of raw image bytes, including padding. */ function base64Length(bytes: number): number { return Math.ceil(bytes / 3) * 4 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index f5c4da4a32..6a3cc5de71 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -32,7 +32,10 @@ import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config. import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts' import { normalizeLlmFailure } from './adapter-failure.ts' import { normalizeApiKey } from './api-key.ts' -import { contentHasImage, projectImagesForTextModel } from './content.ts' +import { + contentHasFile, contentHasImage, fileHandleText, projectFilesToText, projectImagesForTextModel, +} from './content.ts' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' export * from './attribution.ts' export * from './brand.ts' @@ -661,6 +664,16 @@ export class LlmRuntime extends TypertRemoteService { return this.adapters.get(provider)?.adapter.imageRequestPricing(provider, model) } + /** + * Resolve the exact text one durable file occurrence contributes to every + * provider request in the current execution environment. + * @param ref - durable verbatim file reference from model history. + * @returns the same deterministic handle text used at adapter dispatch. + */ + fileRequestText(ref: FileAttachmentRef): string { + return fileHandleText(ref, this.fileReadPath(ref)) + } + /** Detach typed adapter-owned modality metadata. */ private detachedModalities(modalities: readonly ModelModality[] | undefined): ModelModality[] | undefined { return modalities === undefined ? undefined : [...modalities] @@ -957,6 +970,26 @@ export class LlmRuntime extends TypertRemoteService { return Object.isFrozen(options) ? deepFreeze(filtered) : filtered } + /** + * Resolve the current execution-world read path of one durable file + * reference through the mounted attachment and filesystem providers. + */ + private fileReadPath(ref: FileAttachmentRef): string | undefined { + let hostPath: string | undefined + try { + hostPath = this.ctx.get('attachments')?.fileHostPath(ref) + } catch { + // A malformed durable reference degrades this occurrence to the no-path + // handle instead of failing every later request over the same log. + return undefined + } + if (hostPath === undefined) return undefined + // Structural face: dsh-llm cannot depend on the filesystem package, and + // only this one mapping method is consumed. + const fs = this.ctx.get('fs') as { processPathFromHostPath(hostPath: string): string | undefined } | undefined + return fs?.processPathFromHostPath(hostPath) + } + /** * Final adapter boundary. Adapter selection, dispatch, iterator construction, * and iteration failures become one terminal failure chunk. Middleware and @@ -994,13 +1027,21 @@ export class LlmRuntime extends TypertRemoteService { : Object.isFrozen(options) ? deepFreeze({ ...options, ...resolvedConfig }) : { ...options, ...resolvedConfig } - const projectedOptions = modelInfo.inputModalities !== undefined + // Files are never dispatched natively: every route receives handle text. + let projectedMessages: readonly Message[] = resolvedOptions.messages + if (projectedMessages.some(message => contentHasFile(message.content))) { + projectedMessages = projectFilesToText(projectedMessages, ref => this.fileReadPath(ref)) + } + if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image') - && resolvedOptions.messages.some(message => contentHasImage(message.content)) - ? Object.isFrozen(resolvedOptions) - ? deepFreeze({ ...resolvedOptions, messages: projectImagesForTextModel(resolvedOptions.messages) as Message[] }) - : { ...resolvedOptions, messages: projectImagesForTextModel(resolvedOptions.messages) as Message[] } - : resolvedOptions + && projectedMessages.some(message => contentHasImage(message.content))) { + projectedMessages = projectImagesForTextModel(projectedMessages) + } + const projectedOptions = projectedMessages === resolvedOptions.messages + ? resolvedOptions + : Object.isFrozen(resolvedOptions) + ? deepFreeze({ ...resolvedOptions, messages: projectedMessages as Message[] }) + : { ...resolvedOptions, messages: projectedMessages as Message[] } const stream = dispatch(this.forAdapter(projectedOptions, adapter)) iterator = stream[Symbol.asyncIterator]() } catch (error: unknown) { diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 4303538892..c480f57b89 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -5,7 +5,7 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ToolCallId, ProviderRequestId, ReasoningEffortId } from './brand.ts' import type { Message } from './message.ts' @@ -74,6 +74,19 @@ export interface ImageBlock { attachment: ImageAttachmentRef } +/** + * A durable verbatim file reference, valid in user content. Files never reach + * a provider natively: request assembly projects every occurrence to + * deterministic handle text (name, byte size, and the read-only saved path), + * so adapters and providers see text in its place while the durable log keeps + * the structured reference for presentation and authorization. + */ +export interface FileBlock { + type: 'file' + /** Immutable verbatim bytes and display metadata owned by the attachment service. */ + attachment: FileAttachmentRef +} + /** A tool invocation requested by the model. */ export interface ToolCallBlock { type: 'tool-call' @@ -100,6 +113,7 @@ export interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock 'image': ImageBlock + 'file': FileBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 748e20f412..af114958b1 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -3,7 +3,10 @@ import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageMediaType } from '@deepseek-ai/dsh-attachment' import { ToolCallId, + contentHasFile, createUserMessage, + fileHandleText, + projectFilesToText, offloadedImageText, offloadedImagePrefixCount, offloadRequestImagesWithPolicy, @@ -360,3 +363,80 @@ describe('projectImagesForTextModel', () => { ]) }) }) + +describe('file projection', () => { + function fileBlock(name: string): Extract { + return { + type: 'file', + attachment: { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + name, + bytes: 42, + }, + } + } + + it('detects file blocks at the top level and inside nested tool results', () => { + expect(contentHasFile([{ type: 'text', text: 'x' }])).toBe(false) + expect(contentHasFile([fileBlock('a.txt')])).toBe(true) + expect(contentHasFile([{ + type: 'tool-result', + toolCallId: ToolCallId('call-1'), + content: [{ + type: 'tool-result', + toolCallId: ToolCallId('call-2'), + content: [fileBlock('deep.txt')], + }], + }])).toBe(true) + }) + + it('renders the handle with the read path or the explicit no-path fallback', () => { + const withPath = fileHandleText(fileBlock('notes.pdf').attachment, '/home/.dsh/attachments/v1/files/ab/x/notes.pdf') + expect(withPath).toContain('"notes.pdf"') + expect(withPath).toContain('42 bytes') + expect(withPath).toContain('sha256:abababab') + expect(withPath).toContain('"/home/.dsh/attachments/v1/files/ab/x/notes.pdf"') + expect(withPath).toContain('include this saved path in the delegation prompt') + expect(withPath).toContain('only subagents sharing this execution environment can read it') + const withoutPath = fileHandleText(fileBlock('notes.pdf').attachment, undefined) + expect(withoutPath).toContain('current execution environment cannot access a readable path') + expect(withoutPath).toContain('do not claim to have read it') + }) + + it('replaces every file occurrence with handle text and keeps file-free history identical', () => { + const plain = [createUserMessage({ content: [{ type: 'text', text: 'hi' }], source })] + expect(projectFilesToText(plain, () => '/p')).toBe(plain) + const unchangedTool = { + type: 'tool-result' as const, + toolCallId: ToolCallId('call-plain'), + content: [{ type: 'text' as const, text: 'unchanged result' }], + } + const messages = [plain[0]!, createUserMessage({ + content: [ + fileBlock('top.csv'), + { type: 'text', text: 'keep' }, + unchangedTool, + { + type: 'tool-result', + toolCallId: ToolCallId('call-3'), + content: [fileBlock('nested.csv')], + }, + ], + source, + })] + const projected = projectFilesToText(messages, ref => `/copies/${ref.name}`) + expect(projected).not.toBe(messages) + expect(projected[0]).toBe(messages[0]) + const content = projected[1]!.content + expect(content[0]).toEqual({ type: 'text', text: fileHandleText(fileBlock('top.csv').attachment, '/copies/top.csv') }) + expect(content[1]).toEqual({ type: 'text', text: 'keep' }) + expect(content[2]).toBe(messages[1]!.content[2]) + const nested = content[3] as Extract + expect(nested.content[0]).toEqual({ + type: 'text', + text: fileHandleText(fileBlock('nested.csv').attachment, '/copies/nested.csv'), + }) + // The durable message is untouched: projection returns shallow copies. + expect(messages[1]!.content[0]!.type).toBe('file') + }) +}) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 47309daea1..2bfcd0f486 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -205,6 +205,73 @@ describe('LlmRuntime', () => { expect(adapter.lastOptions?.messages[0]).toBe(message) }) + it('projects file blocks through every host-path availability outcome', async () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + name: 'notes.txt', + bytes: 3, + } + const cases = [ + { + name: 'native tools under read-only permission receive the mapped read path', + attachments: { fileHostPath: () => '/host/notes.txt' }, + fs: { processPathFromHostPath: () => '/sandbox/notes.txt' }, + expected: '"/sandbox/notes.txt"', + }, + { + name: 'Code Mode under workspace-write permission receives the same mapped read path', + attachments: { fileHostPath: () => '/host/notes.txt' }, + fs: { processPathFromHostPath: () => '/code-sandbox/notes.txt' }, + expected: '"/code-sandbox/notes.txt"', + }, + { + name: 'missing attachment service', + expected: 'current execution environment cannot access a readable path', + }, + { + name: 'provider without a host path', + attachments: { fileHostPath: () => undefined }, + expected: 'current execution environment cannot access a readable path', + }, + { + name: 'invalid durable reference', + attachments: { fileHostPath: () => { throw new Error('invalid ref') } }, + expected: 'current execution environment cannot access a readable path', + }, + { + name: 'missing filesystem mapping', + attachments: { fileHostPath: () => '/host/notes.txt' }, + expected: 'current execution environment cannot access a readable path', + }, + ] + + for (const fixture of cases) { + const ctx = new Context() + if (fixture.attachments !== undefined) ctx.provide('attachments', fixture.attachments as never) + if (fixture.fs !== undefined) ctx.provide('fs', fixture.fs as never) + await ctx.plugin(LlmRuntime) + const adapter = new RecordingAdapter(SCRIPT) + ctx.llm.registerAdapter(['test-provider'], adapter) + + await collect(ctx.llm.stream({ + provider: 'test-provider', + model: 'test-model', + messages: [createUserMessage({ + content: [{ type: 'file', attachment }], + source: { kind: 'user' }, + })], + })) + + const projected = adapter.lastOptions?.messages[0]?.content[0] + expect(projected, fixture.name).toMatchObject({ type: 'text' }) + if (projected?.type !== 'text') throw new Error(`expected projected text for ${fixture.name}`) + expect(projected.text, fixture.name).toContain(fixture.expected) + if (fixture.fs !== undefined) { + expect(projected.text, fixture.name).toContain('include this saved path in the delegation prompt') + } + } + }) + it('captures provider-owned retry policy at registration and defaults omission', async () => { const configured = resolveRetryPolicy({ mode: 'always' }, 'test retryPolicy') const adapter = new class extends ScriptedAdapter { diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index bb885f0bfc..6413c0ba99 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -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/llm/token-meter/README.md -README.md: f2a4f7f7c70de4a08fd824e6e8001622ddb456b5 -README.zh.md: 3b163198279d4b254cd78638ebcad479d063630f +README.md: e23274f99adccf64d9ea832def9ad88682dbde1d +README.zh.md: cdb5f6728ec364524cadc9dfa6b78f3e9bd2d01d diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index f2a4f7f7c7..e23274f99a 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-token-meter` is the replay-aware token measurement service: `ctx.tokenMeter` advances one isolated fold per session from the durable event log, so compaction and other pressure-sensitive plugins share one accounting without depending on the compaction engine. With it you can measure current request and context pressure, price a single message, and — when the session-projection seam is mounted — read the `tokenUsage`, `contextPressure`, and `contextBreakdown` projections. It uses a fixed heuristic for text and routes without image pricing, applies adapter-declared visual-token pricing when available, and reuses provider-reported usage only when the request envelope matches exactly. It adds no prompt, message, schema, or tool of its own, and it never makes decisions for the loop. +`@deepseek-ai/dsh-token-meter` is the replay-aware token measurement service: `ctx.tokenMeter` advances one isolated fold per session from the durable event log, so compaction and other pressure-sensitive plugins share one accounting without depending on the compaction engine. With it you can measure current request and context pressure, price a single message, and read the `tokenUsage`, `contextPressure`, and `contextBreakdown` projections when the session-projection seam is mounted. It uses a fixed heuristic for text and routes without image pricing, applies adapter-declared visual-token pricing when available, prices files as the handle text request assembly sends, and reuses provider-reported usage only when the request envelope matches exactly. It adds no prompt, message, schema, or tool of its own, and it never makes decisions for the loop. ## Table of Contents @@ -40,13 +40,13 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session) const price = ctx.tokenMeter.estimateMessage(message) ``` -Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing; other routes keep the fixed heuristic. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements. +Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing; other routes keep the fixed heuristic. File occurrences use the exact route-independent handle text that the same `llm` service resolves for adapter dispatch, including its current execution-world path or explicit no-path message. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements. ### Session projections When the composition provides `ctx.sessionProjections`, token-meter registers three projection units. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. A final assistant-message sample replaces streaming usage from the same attempt; `llm/retry-started` ends that replacement scope, so a retry in the same step contributes another billed attempt. `contextPressure` carries optional `pressureTokens` (the newest provider-reported prompt size), optional `projectedTokens` (what the next request's prompt would cost), and optional `contextWindow` from the newest `request/context` record. `contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition, not its provider-billed size. Unloading the plugin removes all three keys. -`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays the same O(1) shadow-price fold as `contextPressure`, so on fully metered logs it equals the sum of `measure().nodes[].heuristicTokens` at every event boundary and compaction shrinks it by its logged shadow price. The route-priced `measure().surfaceTokens` diverges when the routed model reprices images. A replacement without an adjacent shadow-price claim leaves this bounded projection unchanged because it cannot reconstruct the replaced range. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total. +`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens`, which describe the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays the same O(1) shadow-price fold as `contextPressure`, so on fully metered logs it equals the sum of `measure().nodes[].heuristicTokens` at every event boundary and compaction shrinks it by its logged shadow price. The request-priced `measure().surfaceTokens` diverges when the routed model reprices images or request assembly projects files to handle text. A replacement without an adjacent shadow-price claim leaves this bounded projection unchanged because it cannot reconstruct the replaced range. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error in the composition rows, including the severe CJK text and JSON schema underpricing from the four-characters-per-token rule. Present them as an approximate composition, never as a total. `deriveTurnTokenUsage(events)` folds one complete Turn into exact per-attempt and total usage for browser consumers. Missing lifecycle evidence, unsafe counts, or contradictory exact totals return no result; optional cache, reasoning, and route aggregates appear only when every contributing attempt reports them. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index 3b16319827..cdb5f6728e 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-token-meter` 是具备回放感知的 token 计量服务:`ctx.tokenMeter` 从持久事件日志为每个会话推进一个隔离 fold,因此压缩(compaction)与其他压力敏感插件可以共享同一份计量,无需依赖压缩引擎。借助它,你可以测量当前请求与上下文压力、为单条消息计价,并且在挂载会话投影 seam 时读取 `tokenUsage`、`contextPressure` 与 `contextBreakdown` 投影。文本和未声明图片定价的路由使用固定启发式规则,存在时则应用适配器声明的视觉 token 定价;只有请求 envelope 完全匹配时才复用提供方报告的用量。它不添加任何自己的提示词、消息、schema 或工具,也绝不为 loop 做决定。 +`@deepseek-ai/dsh-token-meter` 是具备回放感知的 token 计量服务:`ctx.tokenMeter` 从持久事件日志为每个会话推进一个隔离 fold,因此压缩(compaction)与其他压力敏感插件可以共享同一份计量,无需依赖压缩引擎。借助它,你可以测量当前请求与上下文压力、为单条消息计价,并在挂载会话投影 seam 时读取 `tokenUsage`、`contextPressure` 与 `contextBreakdown` 投影。文本和未声明图片定价的路由使用固定启发式规则,存在时应用适配器声明的视觉 token 定价,文件则按请求组装实际发送的 handle 文本计价;只有请求 envelope 完全匹配时才复用提供方报告的用量。它不添加任何自己的提示词、消息、schema 或工具,也绝不为 loop 做决定。 ## 目录 @@ -40,13 +40,13 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session) const price = ctx.tokenMeter.estimateMessage(message) ``` -每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本;其他路由保持固定启发式规则。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。 +每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本;其他路由保持固定启发式规则。文件出现处使用同一个 `llm` 服务为适配器分发解析的确切、与路由无关的 handle 文本,其中包含当前执行世界路径或明确的无路径说明。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。 ### 会话投影 当组合提供 `ctx.sessionProjections` 时,token-meter 注册三个投影单元。`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 与 `cacheWriteTokens`。最终 assistant 消息样本会替换同一次尝试的流式用量;`llm/retry-started` 会结束该替换范围,因此同一步骤中的重试会贡献另一次计费用量。`contextPressure` 携带可选 `pressureTokens`(提供方报告的最新提示词规模)、可选 `projectedTokens`(下一个请求的提示词将花费多少)与来自最新一条 `request/context` 记录的可选 `contextWindow`。`contextBreakdown` 携带启发式 `systemTokens`、`toolsTokens` 与 `messageTokens`——上下文的构成,而非提供方计费规模。卸载插件会移除全部三个键。 -`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放与 `contextPressure` 相同的 O(1) 影子价折叠,因此在完整计量的日志上,它在每个事件边界都等于 `measure().nodes[].heuristicTokens` 之和,压缩会按记录的影子价缩小该值。路由定价的 `measure().surfaceTokens` 在路由模型重新为图片计价时会与该值不同。若替换前没有紧邻的影子价声明,这个有界投影会保持不变,因为它无法重建被替换区间。三个数字都使用测量服务的固定启发式规则,属于估算值。它们加起来不等于 `projectedTokens`,后者的提供方锚点体现了这些明细行仍然带有的误差(按「4 字符 ≈ 1 token」计价时,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。 +`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放与 `contextPressure` 相同的 O(1) 影子价折叠,因此在完整计量的日志上,它在每个事件边界都等于 `measure().nodes[].heuristicTokens` 之和,压缩会按记录的影子价缩小该值。请求定价的 `measure().surfaceTokens` 在路由模型重新为图片计价或请求组装把文件投影成 handle 文本时会与该值不同。若替换前没有紧邻的影子价声明,这个有界投影会保持不变,因为它无法重建被替换区间。三个数字都使用测量服务的固定启发式规则,属于估算值。它们加起来不等于 `projectedTokens`,后者的提供方锚点包含组成数据仍有的误差,其中按「4 字符约等于 1 token」计价会严重低估 CJK 文本与 JSON schema。请把它们当作近似的**组成**呈现,不要当作总量。 `deriveTurnTokenUsage(events)` 为浏览器消费方把一个完整 Turn 折叠为精确的逐次尝试与整轮用量。生命周期证据缺失、计数不安全或精确总量矛盾时不返回结果;只有每次参与的尝试都报告可选缓存、推理或路由值时,相应汇总才会出现。 diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index f4976c5cc7..fc30652cd8 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -7,7 +7,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm' -import type { LlmImageRequestPricing, Message, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { LlmImageRequestPricing, LlmRuntime, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-util-values' import type { EpochHeader, @@ -147,7 +147,8 @@ export class TokenMeter extends Service { ? state.header : canonicalHeader(requestHeader) const pricing = this._routeImagePricing(header) - const surface = priceSurface(state.surface, pricing) + const fileText = this._fileRequestText() + const surface = priceSurface(state.surface, pricing, fileText) const anchor = state.anchor let baseline: TokenMeasurementBaseline @@ -156,7 +157,7 @@ export class TokenMeter extends Service { // Matching headers share one route, so the anchored snapshot reprices // under the same pricing as the current surface and the signed delta // compares like with like. - const anchorSurfaceTokens = priceSurface(anchor.nodes, pricing).surfaceTokens + const anchorSurfaceTokens = priceSurface(anchor.nodes, pricing, fileText).surfaceTokens + anchor.assistantTokens const estimatedAnchorTokens = estimateHeader(header) + anchorSurfaceTokens const usage = anchor.usage @@ -194,6 +195,14 @@ export class TokenMeter extends Service { return this.ctx.get('llm')?.imageRequestPricing(config.provider, config.model) } + /** Resolve request-time file projection when an LLM service is mounted. */ + private _fileRequestText(): ( + (ref: Parameters[0]) => string + ) | undefined { + const llm = this.ctx.get('llm') + return llm === undefined ? undefined : ref => llm.fileRequestText(ref) + } + /** * Heuristically price one model-visible message (instance face of the pure * `estimateMessage` export from `estimate.ts`). diff --git a/packages/llm/token-meter/src/route-pricing.ts b/packages/llm/token-meter/src/route-pricing.ts index 402db18ac0..03a1c519c3 100644 --- a/packages/llm/token-meter/src/route-pricing.ts +++ b/packages/llm/token-meter/src/route-pricing.ts @@ -1,18 +1,17 @@ /** - * Route-aware surface pricing: projects the fold's fixed-heuristic nodes onto - * the routed model's request, replacing every image occurrence's structural - * price with the route's declared visual tokens plus the model-visible text it - * actually sends. Without declared pricing every node keeps its fixed - * heuristic price, so provider-neutral behavior is unchanged. + * Request-projected surface pricing: replaces attachment-block heuristics with + * the image and file representations sent to the routed model. * * @module @deepseek-ai/dsh-token-meter/route-pricing */ -import type { LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' import { estimateContent } from './estimate.ts' import type { MeterSurfaceNode } from './surface-fold.ts' import type { TokenSurfaceNode } from './types.ts' +type FileAttachmentRef = Extract['attachment'] + /** One surface priced for a request route: public nodes plus their total. */ export interface PricedSurface { /** Positional nodes carrying both the route price and the fixed-heuristic price. */ @@ -22,9 +21,10 @@ export interface PricedSurface { } /** - * Price one ordered surface under a route's request-image pricing. + * Price one ordered surface under its model-request attachment projection. * @param nodes - the fold's current or snapshotted surface, in model-visible order. * @param pricing - the routed model's image pricing, or undefined to keep the fixed heuristic. + * @param fileText - exact file handle projection used by the mounted LLM service. * @returns detached public nodes and their route-priced total. * @throws when the pricing answers a different occurrence count than it was * asked — misalignment would silently misprice nodes, so it must fail loud. @@ -32,9 +32,11 @@ export interface PricedSurface { export function priceSurface( nodes: readonly MeterSurfaceNode[], pricing: LlmImageRequestPricing | undefined, + fileText?: (ref: FileAttachmentRef) => string, ): PricedSurface { const images = pricing === undefined ? [] : nodes.flatMap(node => node.images) - if (pricing === undefined || images.length === 0) { + const hasFiles = fileText !== undefined && nodes.some(node => node.files.length > 0) + if ((pricing === undefined || images.length === 0) && !hasFiles) { let surfaceTokens = 0 const publicNodes = nodes.map((node) => { surfaceTokens += node.heuristicTokens @@ -42,8 +44,8 @@ export function priceSurface( }) return { nodes: publicNodes, surfaceTokens } } - const prices = pricing.priceImages(images) - if (prices.length !== images.length) { + const prices = pricing === undefined ? [] : pricing.priceImages(images) + if (pricing !== undefined && prices.length !== images.length) { throw new Error( `token meter: route image pricing answered ${prices.length} prices for ${images.length} occurrences`, ) @@ -52,8 +54,14 @@ export function priceSurface( let surfaceTokens = 0 const publicNodes = nodes.map((node) => { let tokens = node.heuristicTokens - if (node.images.length > 0) { - tokens = node.imageFreeTokens + if (fileText !== undefined && node.files.length > 0) { + tokens -= node.fileStructuralTokens + for (const file of node.files) { + tokens += estimateContent([{ type: 'text', text: fileText(file) }]) + } + } + if (pricing !== undefined && node.images.length > 0) { + tokens -= node.imageStructuralTokens for (let occurrence = 0; occurrence < node.images.length; occurrence += 1) { // oxlint-disable-next-line typescript/no-non-null-assertion -- length equality is asserted above const price = prices[cursor]! diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index ae293a7f57..b8478ae30d 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -10,8 +10,8 @@ * fallible step read-only and {@link commitSurfaceTokens} mutates in place, * so a throw leaves the caller's state untouched and the same malformed * event fails identically on every retry. - * Nodes also carry their durable image occurrences and image-free heuristic - * price, so `measure()` can reprice image content for the routed model. + * Nodes also carry durable attachment occurrences and their structural prices, + * so `measure()` can price the request representation sent to the model. * * @module @deepseek-ai/dsh-token-meter/surface-fold */ @@ -22,16 +22,22 @@ import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { estimateMessage, estimateStructuralBlock } from './estimate.ts' +type FileAttachmentRef = Extract['attachment'] + /** One priced surface node with the image occurrences route pricing replaces. */ export interface MeterSurfaceNode { /** Durable sequence number of the surface event. */ readonly seq: SessionSeq /** Fixed-heuristic price of the node's exact message. */ readonly heuristicTokens: number - /** Fixed-heuristic price with every image occurrence's structural price removed. */ - readonly imageFreeTokens: number + /** Structural JSON price replaced when the routed request projects images. */ + readonly imageStructuralTokens: number + /** Structural JSON price replaced when request assembly projects files to text. */ + readonly fileStructuralTokens: number /** Durable image occurrences in message order; empty for image-free nodes. */ readonly images: readonly ImageAttachmentRef[] + /** Durable file occurrences in message order; empty for file-free nodes. */ + readonly files: readonly FileAttachmentRef[] } /** One validated surface transition that has not mutated the priced surface yet. */ @@ -46,31 +52,53 @@ export interface SurfaceTokenPlan { readonly target: 'append' | { readonly startIdx: number; readonly endIdx: number } } -/** Collect image occurrences recursively and total their structural prices. */ -function collectImages(blocks: readonly ContentBlock[], images: ImageAttachmentRef[]): number { - let structuralTokens = 0 +/** Collect projected attachment occurrences and their structural prices. */ +function collectProjectedAttachments( + blocks: readonly ContentBlock[], + images: ImageAttachmentRef[], + files: FileAttachmentRef[], +): { readonly imageTokens: number; readonly fileTokens: number } { + let imageTokens = 0 + let fileTokens = 0 for (const block of blocks) { if (block.type === 'image') { images.push(block.attachment) - structuralTokens += estimateStructuralBlock(block) + imageTokens += estimateStructuralBlock(block) + } else if (block.type === 'file') { + files.push(block.attachment) + fileTokens += estimateStructuralBlock(block) } else if (block.type === 'tool-result') { - structuralTokens += collectImages(block.content, images) + const nested = collectProjectedAttachments(block.content, images, files) + imageTokens += nested.imageTokens + fileTokens += nested.fileTokens } } - return structuralTokens + return { imageTokens, fileTokens } } /** Build one priced node from a surface event's derived message. */ function analyzeNode(seq: SessionSeq, message: Message | null): MeterSurfaceNode { - if (message === null) return { seq, heuristicTokens: 0, imageFreeTokens: 0, images: [] } + if (message === null) { + return { + seq, + heuristicTokens: 0, + imageStructuralTokens: 0, + fileStructuralTokens: 0, + images: [], + files: [], + } + } const heuristicTokens = estimateMessage(message) const images: ImageAttachmentRef[] = [] - const imageStructuralTokens = collectImages(message.content, images) + const files: FileAttachmentRef[] = [] + const structural = collectProjectedAttachments(message.content, images, files) return { seq, heuristicTokens, - imageFreeTokens: heuristicTokens - imageStructuralTokens, + imageStructuralTokens: structural.imageTokens, + fileStructuralTokens: structural.fileTokens, images, + files, } } diff --git a/packages/llm/token-meter/tests/route-pricing.spec.ts b/packages/llm/token-meter/tests/route-pricing.spec.ts index 5dcec59d0f..174d76a922 100644 --- a/packages/llm/token-meter/tests/route-pricing.spec.ts +++ b/packages/llm/token-meter/tests/route-pricing.spec.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { LlmRuntime, LlmAdapter, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { + LlmRuntime, LlmAdapter, createMessage, createUserMessage, projectFilesToText, +} from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmImageRequestPricing, Message, StreamChunk, TokenUsage, UserMessage } from '@deepseek-ai/dsh-llm' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' import type { EpochHeader } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -53,6 +55,14 @@ function imageMessage(name: string, text = 'look at this'): UserMessage { }) } +function fileRef(name: string): FileAttachmentRef { + return { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + name, + bytes: 2_447_000_000, + } +} + function header(model: string): EpochHeader { return canonicalHeader({ config: { provider: 'mock', model } }) } @@ -65,6 +75,12 @@ interface Harness { async function harness(pricing: (model: string) => LlmImageRequestPricing | undefined): Promise { const ctx = new Context() new SessionProjectionRegistry(ctx) + ctx.provide('attachments', { + fileHostPath: (ref: FileAttachmentRef) => `/host/${ref.name}`, + } as never) + ctx.provide('fs', { + processPathFromHostPath: (path: string) => path.replace('/host/', '/sandbox/'), + } as never) const llm = new LlmRuntime(ctx) llm.registerAdapter(['mock'], new PricingAdapter(pricing)) const meter = new TokenMeter(ctx) @@ -97,7 +113,23 @@ function appendSuccessfulCall(session: Session, value: EpochHeader, usage?: Toke session.append('step/end', { turn: 1, step: 1 }) } -describe('route-aware image pricing', () => { +describe('request projection pricing', () => { + it('prices file blocks as the exact handle text dispatched to the provider', async () => { + const { meter, session } = await harness(() => undefined) + const ref = fileRef('archive.zip') + const message = createUserMessage({ + content: [{ type: 'file', attachment: ref }], + source: { kind: 'user' }, + }) + session.append('user/message', message, { surfaceOp: 'append' }) + + const measurement = meter.measure(session) + const projected = projectFilesToText([message], file => `/sandbox/${file.name}`)[0] + if (projected === undefined) throw new Error('missing projected file message') + expect(measurement.nodes[0]?.tokens).toBe(estimateMessage(projected)) + expect(measurement.nodes[0]?.tokens).toBeGreaterThan(estimateMessage(message)) + }) + it('prices a first multimodal request estimate with the routed visual tokens', async () => { const { meter, session } = await harness(() => fixedPricing) const message = imageMessage('photo') diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 9dd9a5e938..788b81ba04 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -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/plan/plan-mode/README.md -README.md: e768a19c67b2973ff32aa0fe29e51d1b90ea96c9 -README.zh.md: 9d2b1ed6e1eebee6f3e9cd27fcc8ebad2c626a89 +README.md: b9a86fe530923956365b17dbe5e0ececa28b3416 +README.zh.md: 5c1e21bac520dcd973c181d70139969abcff8b1b diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index e768a19c67..b9a86fe530 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-plan-mode` adds plan mode to the agent: while it is active, the agent explores and designs before executing, guided by instructions the deployment writes, and presents the finished plan for your approval before carrying it out. You enter plan mode with `/plan` (optionally with a message or images) and leave it with `/plan off`; the finished plan arrives as a review where you can approve it or send the agent back to keep planning. Plan mode is guidance, not enforcement: every tool stays available, so sandbox mode and approval prompts remain the way to impose limits. Choose it when the agent should think before acting, and plan mode carries over when a session resumes or forks. +`dsh-plan-mode` adds plan mode to the agent: while it is active, the agent explores and designs before executing, guided by instructions the deployment writes, and presents the finished plan for your approval before carrying it out. You enter plan mode with `/plan`, optionally carrying a message and ordered image or file attachments, and leave it with `/plan off`; the finished plan arrives as a review where you can approve it or send the agent back to keep planning. Plan mode is guidance, not enforcement: every tool stays available, so sandbox mode and approval prompts remain the way to impose limits. Choose it when the agent should think before acting, and plan mode carries over when a session resumes or forks. ## Table of Contents @@ -54,7 +54,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Type `/plan` to enter plan mode, or `/plan ` to enter with an instruction — the message becomes your next request under plan guidance. Type `/plan off` to leave plan mode directly; it also cancels a plan-mode entry that has not taken effect yet. -You can attach images to a `/plan` message, and they are included with your instruction. `/plan off` with images is rejected so the images are not lost. The `/plan` command is available wherever slash commands are supported, such as the Web client. +You can attach images and generic files to a `/plan` message, and they are included with your instruction in selection order. `/plan off` with attachments is rejected before the mode changes, so the draft and cards remain available. The `/plan` command is available wherever slash commands are supported, such as the Web client. ### The reviewed exit @@ -86,7 +86,7 @@ The package persists one log-only whole-value event, `plan/mode`, and the last l ### The `/plan` command -The command child activates only when a commands service is composed. It maps bare `/plan` to active, the exact argument `off` to inactive without model input, and any other non-empty argument to active plus the trimmed text submitted through `agent.steer()` as the next step's ordinary logged user message; image attachments ride the steered message, and `/plan off` with images fails before any mode change. Entry points other than the command may drive `ctx.planMode` directly; the exact branch handling is in [`src/index.ts`](src/index.ts). +The command child activates only when a commands service is composed. It maps bare `/plan` to active, the exact argument `off` to inactive without model input, and any other non-empty argument to active plus the trimmed text submitted through `agent.steer()` as the next step's ordinary logged user message. Admitted image and file blocks keep their selection order in that message; `/plan off` with attachments fails before any mode change. Entry points other than the command may drive `ctx.planMode` directly; the exact branch handling is in [`src/index.ts`](src/index.ts). ### The exit tool @@ -149,11 +149,11 @@ The section is stable within plan mode, but entering or leaving changes the syst #### What the model sees -`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one user message through `agent.steer()` after plan mode is selected: any admitted image attachments as leading image blocks, then the trimmed text block. Bare `/plan` with admitted images steers one user message containing only those image blocks. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it. +`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one user message through `agent.steer()` after plan mode is selected: admitted image and file blocks in selection order, then the trimmed text block. Bare `/plan` with admitted attachments steers one user message containing only those blocks. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it. #### Token effect -The optional message costs the same history tokens as submitting that content separately. Bare `/plan` without images and `/plan off` add none; bare `/plan` with images has the normal image-prompt cost. A narrated active exit adds the small retained switch notice. +The optional message costs the same history tokens as submitting that content separately. Bare `/plan` without attachments and `/plan off` add none; bare `/plan` with attachments has the normal image and file-handle cost. A narrated active exit adds the small retained switch notice. #### KV Cache effect diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 9d2b1ed6e1..5c1e21bac5 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-plan-mode` 为 agent(智能体)提供计划模式:激活期间,agent 先探索和设计再执行,遵循你的部署所写的引导行事,并在执行前把完成的计划呈交你批准。你可以用 `/plan`(可附带消息或图片)进入计划模式,用 `/plan off` 离开;完成的计划会以评审形式呈现,你可以批准它,或让 agent 回去继续规划。计划模式是引导而非强制:每个工具仍然可用,因此沙箱模式与审批提示仍是施加限制的方式。当希望 agent 先思考再行动时选择它;会话恢复或 fork 后计划模式依然保持。 +`dsh-plan-mode` 为 agent(智能体)提供计划模式:激活期间,agent 先探索和设计再执行,遵循你的部署所写的引导行事,并在执行前把完成的计划呈交你批准。你可以用 `/plan` 进入计划模式,并随命令附带消息及有序的图片或文件附件;用 `/plan off` 离开。完成的计划会以评审形式呈现,你可以批准它,或让 agent 回去继续规划。计划模式是引导而非强制:每个工具仍然可用,因此沙箱模式与审批提示仍是施加限制的方式。当希望 agent 先思考再行动时选择它;会话恢复或 fork 后计划模式依然保持。 ## 目录 @@ -54,7 +54,7 @@ kind: "package-reference" 输入 `/plan` 进入计划模式,或输入 `/plan ` 连同一条指令一起进入——该消息会成为你在计划引导下的下一条请求。输入 `/plan off` 直接离开计划模式;它还会取消尚未生效的计划模式进入。 -你可以在 `/plan` 消息中附带图片,图片会随你的指令一起提交。带图片的 `/plan off` 会被拒绝,因此图片不会丢失。`/plan` 命令在支持斜杠命令的界面中可用,例如 Web 客户端。 +你可以在 `/plan` 消息中附带图片与通用文件,它们按选择顺序随指令一起提交。带附件的 `/plan off` 会在模式变更前被拒绝,因此草稿和附件卡仍可继续使用。`/plan` 命令在支持斜杠命令的界面中可用,例如 Web 客户端。 ### 经评审的退出 @@ -86,7 +86,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并 ### `/plan` 命令 -命令子插件只在组合了命令服务时激活。它把不带参数的 `/plan` 映射为激活,把恰好为 `off` 的参数映射为未激活且不发送模型输入,把其他非空参数映射为激活并把去除首尾空白的文本通过 `agent.steer()` 作为下一步骤的普通已记录用户消息提交;图片附件随被 steer 的消息一起提交,带图片的 `/plan off` 会在任何模式变更前失败。命令以外的入口可以直接驱动 `ctx.planMode`;确切的分支处理见 [`src/index.ts`](src/index.ts)。 +命令子插件只在组合了命令服务时激活。它把不带参数的 `/plan` 映射为激活,把恰好为 `off` 的参数映射为未激活且不发送模型输入,把其他非空参数映射为激活并把去除首尾空白的文本通过 `agent.steer()` 作为下一步骤的普通已记录用户消息提交。已准入的图片块与文件块按选择顺序进入这条消息;带附件的 `/plan off` 会在模式变更前失败。命令以外的入口可以直接驱动 `ctx.planMode`;确切的分支处理见 [`src/index.ts`](src/index.ts)。 ### 退出工具 @@ -149,11 +149,11 @@ You are in plan mode. Explore and design before presenting the complete plan thr #### 模型看到什么 -`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择计划模式后,通过 `agent.steer()` 成为一条用户消息:任何已准入的图片附件作为前置图片块,之后是去除首尾空白的文本块。不带参数的 `/plan` 若带有已准入图片,会 steer 一条只含这些图片块的用户消息。计划模式已激活时选择 `/plan off`,只会在最后记录的请求头描述了计划模式的情况下追加标准的已记录用户切换通知;取消待生效条目不贡献通知,因为没有请求观测到它。 +`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择计划模式后,通过 `agent.steer()` 成为一条用户消息:已准入的图片块与文件块按选择顺序排列,之后是去除首尾空白的文本块。不带参数的 `/plan` 若带有已准入附件,会 steer 一条只含这些块的用户消息。计划模式已激活时选择 `/plan off`,只会在最后记录的请求头描述了计划模式的情况下追加标准的已记录用户切换通知;取消待生效条目不贡献通知,因为没有请求观测到它。 #### Token 影响 -可选消息的历史 token 成本与单独提交该内容相同。不带图片的 `/plan` 与 `/plan off` 不增加 token;带图片的 `/plan` 产生常规图片提示词成本。一次带叙述的激活状态退出会追加一条简短且会保留的切换通知。 +可选消息的历史 token 成本与单独提交该内容相同。不带附件的 `/plan` 与 `/plan off` 不增加 token;带附件的 `/plan` 产生常规图片与文件句柄成本。一次带叙述的激活状态退出会追加一条简短且会保留的切换通知。 #### KV Cache 影响 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 1372a90f50..392f9193e7 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -225,11 +225,11 @@ export class PlanModeController extends Service { commandCtx.commands.register({ name: 'plan', description: 'Enter or leave plan mode', - input: { hint: '[off|message]', images: true }, + input: { hint: '[off|message]', attachments: true }, handler: ({ agent, rawInput, attachments }) => { const message = rawInput.trim() if (message === 'off' && attachments.length > 0) { - return { kind: 'error', text: 'Image attachments cannot accompany /plan off.' } + return { kind: 'error', text: 'Attachments cannot accompany /plan off.' } } if (message === 'off') { switch (this.set(agent, false)) { diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index cd53724e34..0d2dded443 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -620,7 +620,7 @@ describe('/plan', () => { const plainSteer = vi.fn() ;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer expect(ctx.commands.list(plainAgent)).toEqual([ - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', images: true } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, ]) const signal = new AbortController().signal @@ -704,7 +704,7 @@ describe('/plan', () => { expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) }) - it('steers image attachments with or without text and refuses them on /plan off', async () => { + it('steers mixed attachments with or without text and refuses them on /plan off', async () => { const ctx = await setup() await ctx.plugin(CommandRuntime) await new Promise(resolve => setImmediate(resolve)) @@ -727,21 +727,34 @@ describe('/plan', () => { for (const input of inputs) refs.push(await saveImage(input)) return refs }, + saveFile(input: { data: Uint8Array; name?: string }) { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, bytes: input.data.byteLength, name: input.name ?? 'attachment', + }) + }, }) + ctx.commands.registerFileReceiptResolver((agent, receiptId) => receiptId === 'receipt-notes' + ? { attachmentId: `file-${agent.id}` as never, bytes: 5, name: 'notes.txt' } + : undefined) const signal = new AbortController().signal - const images = [{ mediaType: 'image/png' as const, data: 'AAAA' }] + const attachments = [ + { type: 'image' as const, mediaType: 'image/png' as const, data: 'AAAA', name: 'diagram.png' }, + { type: 'file' as const, receiptId: 'receipt-notes' }, + ] const agent = await agentWithSession(ctx, 'imaged-plan-command') openTurn(agent.session) const steer = vi.fn() ;(agent as unknown as { steer: typeof steer }).steer = steer - const withMessage = await ctx.commands.execute(agent, '/plan sketch the layout', images, signal) + const withMessage = await ctx.commands.execute(agent, '/plan sketch the layout', attachments, signal) expect(withMessage?.result.kind).toBe('success') expect(steer).toHaveBeenCalledExactlyOnceWith({ id: expect.any(String) as unknown, role: 'user', content: [ { type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-1' }) as unknown }, + { type: 'file', attachment: expect.objectContaining({ attachmentId: 'file-imaged-plan-command', name: 'notes.txt' }) as unknown }, { type: 'text', text: 'sketch the layout' }, ], source: { kind: 'user' }, @@ -751,12 +764,15 @@ describe('/plan', () => { openTurn(bareAgent.session) const bareSteer = vi.fn() ;(bareAgent as unknown as { steer: typeof bareSteer }).steer = bareSteer - expect((await ctx.commands.execute(bareAgent, '/plan', images, signal))?.result) + expect((await ctx.commands.execute(bareAgent, '/plan', attachments, signal))?.result) .toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step). Use /plan off to leave.' }) expect(bareSteer).toHaveBeenCalledExactlyOnceWith({ id: expect.any(String) as unknown, role: 'user', - content: [{ type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-2' }) as unknown }], + content: [ + { type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-2' }) as unknown }, + { type: 'file', attachment: expect.objectContaining({ attachmentId: 'file-imaged-bare-plan-command', name: 'notes.txt' }) as unknown }, + ], source: { kind: 'user' }, }) expect(ctx.planMode.get(bareAgent)).toEqual({ active: false, pending: true }) @@ -764,8 +780,8 @@ describe('/plan', () => { const activeAgent = await agentWithSession(ctx, 'imaged-off-plan-command', { active: true }) const offSteer = vi.fn() ;(activeAgent as unknown as { steer: typeof offSteer }).steer = offSteer - expect((await ctx.commands.execute(activeAgent, '/plan off', images, signal))?.result) - .toEqual({ kind: 'error', text: 'Image attachments cannot accompany /plan off.' }) + expect((await ctx.commands.execute(activeAgent, '/plan off', attachments, signal))?.result) + .toEqual({ kind: 'error', text: 'Attachments cannot accompany /plan off.' }) expect(offSteer).not.toHaveBeenCalled() expect(ctx.planMode.get(activeAgent)).toEqual({ active: true }) }) diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index eff3190c30..d6e597b312 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -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/session-query/session-log-export/README.md -README.md: 283d9e5ca8ede40ddc2f1e57f4c0cd34eecd1018 -README.zh.md: d350a21908bc36c4495e4be8fec0c110b0d36828 +README.md: db26d9c405b3408cc24f27e5947c94ccaa2393e8 +README.zh.md: 4e2eb00de94dd6520af8c1836155a00d187ef99f diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 283d9e5ca8..db26d9c405 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -55,7 +55,7 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u ### What to expect -The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. +The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. Images use `media/.`, and generic files use `files///`. Generic-file bytes are read and compressed as bounded chunks, so exporting a large upload does not buffer it in full. ### Failures diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index d350a21908..4e2eb00de9 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -55,7 +55,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 预期行为 -弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后完成时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;冷持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。 +弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后完成时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;冷持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。图片使用 `media/.`,通用文件使用 `files///`。通用文件以有界分块读取并压缩,因此导出大文件时不会把它完整缓冲进内存。 ### 失败 diff --git a/packages/session-query/session-log-export/src/archive.ts b/packages/session-query/session-log-export/src/archive.ts index 2cb087c0a7..5c64a2eec2 100644 --- a/packages/session-query/session-log-export/src/archive.ts +++ b/packages/session-query/session-log-export/src/archive.ts @@ -1,18 +1,18 @@ /** * Host-side session-log download: streams one ZIP archive whose files are the - * sessions' logical session logs plus every referenced media object. Each log + * sessions' logical session logs plus every referenced attachment. Each log * is read through a persistence read handle and serialized here as canonical * JSONL — one header line, then one line per validated event — so every * backend (JSONL, SQLite, future) exports identically. The root log uses the * current generation's canonical `session[.vN].jsonl` name; each subagent * descendant uses `subagents//session[.vN].jsonl`; each image referenced by any included log * under `media/.` (content-addressed, so one archive never - * duplicates a shared image). No manifest is written — every file is - * self-describing through its own header line or media type. Before each live - * session's log read, the SessionStore flush barrier makes the current - * in-memory log durable; cold sessions need no barrier. Request abort and - * response-consumer cancellation share one producer signal and terminate the - * active compressor. + * duplicates a shared image); each generic file sits under + * `files///` and streams from the attachment store. No + * manifest is written. Before each live session's log read, the SessionStore + * flush barrier makes the current in-memory log durable; cold sessions need no + * barrier. Request abort and response-consumer cancellation share one producer + * signal and terminate the active compressor. * Compression runs on the host with fflate's streaming Zip API, so the archive * bytes are produced incrementally and the host never holds the whole archive * in one buffer; production waits for consumer pull whenever the response queue @@ -23,7 +23,9 @@ import { Zip, ZipDeflate } from 'fflate' import type { Context } from '@deepseek-ai/cordis' -import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentStore, FileAttachmentRef, ImageAttachmentRef, +} from '@deepseek-ai/dsh-attachment' import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import { sessionFormatLogFilename } from '@deepseek-ai/dsh-session-format' @@ -89,10 +91,11 @@ export async function flushLiveSessionLog( signal?.throwIfAborted() } -/** One exported file: a serialized session log or one referenced media object. */ +/** One exported file: a serialized session log or one referenced attachment object. */ export type SessionLogZipEntry = | { readonly path: string; readonly content: string } | { readonly path: string; readonly data: Uint8Array } + | { readonly path: string; readonly chunks: AsyncIterable } /** The current generation's canonical base filename for every exported session log. */ export const SESSION_LOG_FILENAME = sessionFormatLogFilename(SESSION_FORMAT_VERSION) @@ -184,13 +187,26 @@ function mediaEntryPath(ref: ImageAttachmentRef): string { return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}` } +/** Archive path that preserves one stored file reference's digest and name. */ +function fileEntryPath(ref: FileAttachmentRef): string { + const digest = String(ref.attachmentId).replace(/^sha256:/u, '') + const name = ref.name.replace(/[\\/\u0000-\u001f\u007f]/gu, '_') + const safeName = name === '.' || name === '..' || name === '' ? 'file' : name + return `files/${digest.slice(0, 2)}/${digest}/${safeName}` +} + /** - * Collect every image reference inside one content array, descending into + * Collect every attachment reference inside one content array, descending into * nested tool results the way the live attachment route does. * @param content - an event content array (or nested tool-result content). - * @param refs - the dedupe map being filled (keyed by attachment id). + * @param images - image dedupe map keyed by attachment id. + * @param files - file dedupe map keyed by attachment id and stored name. */ -function collectImageRefs(content: unknown, refs: Map): void { +function collectAttachmentRefs( + content: unknown, + images: Map, + files: Map, +): void { if (!Array.isArray(content)) return const pending: unknown[] = [] for (const item of content) pending.push(item) @@ -200,7 +216,11 @@ function collectImageRefs(content: unknown, refs: Map): void { +function collectEventAttachmentRefs( + event: unknown, + images: Map, + files: Map, +): void { const data = (event as { data?: unknown }).data if (typeof data !== 'object' || data === null) return const carrier = data as { @@ -224,29 +249,33 @@ function collectEventImageRefs(event: unknown, refs: Map stream?: Array<{ type?: unknown; chunk?: { type?: unknown; block?: unknown } }> } - collectImageRefs(carrier.content, refs) - if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs) + collectAttachmentRefs(carrier.content, images, files) + if (carrier.message !== undefined) collectAttachmentRefs(carrier.message.content, images, files) if (carrier.inserted !== undefined) { - for (const message of carrier.inserted) collectImageRefs(message.content, refs) + for (const message of carrier.inserted) collectAttachmentRefs(message.content, images, files) } if (carrier.stream !== undefined) { for (const record of carrier.stream) { if (record.type === 'chunk' && record.chunk?.type === 'block-end') { - collectImageRefs([record.chunk.block], refs) + collectAttachmentRefs([record.chunk.block], images, files) } } } } /** - * Collect the distinct media references one canonical Session-log text names. - * Lines that fail to parse cannot reference media and are skipped (the - * log text itself is exported regardless). - * @param content - the canonical Session-log text. - * @returns the dedupe map keyed by attachment id. + * Collect the distinct attachment references one stored artifact text names. + * Lines that fail to parse cannot reference attachments and are skipped (the + * artifact text itself is exported verbatim regardless). + * @param content - the stored artifact text. + * @returns image and file dedupe maps. */ -function imageRefsInArtifact(content: string): Map { - const refs = new Map() +function attachmentRefsInArtifact(content: string): { + readonly images: Map + readonly files: Map +} { + const images = new Map() + const files = new Map() for (const line of content.split('\n')) { if (line === '') continue let event: unknown @@ -255,9 +284,9 @@ function imageRefsInArtifact(content: string): Map { } catch { continue } - collectEventImageRefs(event, refs) + collectEventAttachmentRefs(event, images, files) } - return refs + return { images, files } } /** @@ -286,10 +315,10 @@ export function sessionLogZipFilename(sessionId: string): string { * Yield the export entries in zip order: the preloaded root log first, then * every subagent descendant in lineage order (each flushed when live, read * through a persistence read handle right before it is yielded, and dropped - * after the consumer moves on), then every distinct media object referenced by any of - * the included logs (read and verified from the attachment store, one archive - * entry per attachment id). The host holds at most one descendant's log text - * and one media object at a time beyond the root. + * after the consumer moves on), then every distinct attachment referenced by + * the included logs. Images are read and verified as bounded stored objects; + * generic files remain streamed through the ZIP writer. The host holds at most + * one descendant log, one image, and one file chunk beyond the root. * @param deps - the mounted export services (the caller answered 500 before this runs). * @param rootContent - the already-serialized root log (read by the caller so * the missing-session path can answer cleanly before streaming starts). @@ -306,10 +335,13 @@ export async function* sessionLogZipEntries( signal?: AbortSignal, ): AsyncGenerator { const media = new Map() - const rememberMedia = (content: string): void => { - for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref) + const files = new Map() + const rememberAttachments = (content: string): void => { + const refs = attachmentRefsInArtifact(content) + for (const [id, ref] of refs.images) media.set(id, ref) + for (const [id, ref] of refs.files) files.set(id, ref) } - rememberMedia(rootContent) + rememberAttachments(rootContent) yield { path: SESSION_LOG_FILENAME, content: rootContent } if (includeDescendants) { const seen = new Set([sessionId]) @@ -327,7 +359,7 @@ export async function* sessionLogZipEntries( if (content === undefined) { throw new Error(`subagent "${id}" has no stored log`) } - rememberMedia(content) + rememberAttachments(content) yield { path: `subagents/${safeSessionIdSegment(id)}/${SESSION_LOG_FILENAME}`, content, @@ -345,6 +377,13 @@ export async function* sessionLogZipEntries( signal?.throwIfAborted() yield { path: mediaEntryPath(ref), data: stored.data } } + for (const ref of files.values()) { + signal?.throwIfAborted() + yield { + path: fileEntryPath(ref), + chunks: deps.attachments.readFileStream(ref, signal), + } + } } /** How many code units of Session-log text one zip push carries (bounded encode memory). */ @@ -416,6 +455,25 @@ async function pushBinaryChunks( } while (offset < data.byteLength) } +/** Push one streamed file entry without retaining its complete byte sequence. */ +async function pushStreamChunks( + deflate: ZipDeflate, + chunks: AsyncIterable, + controller: ReadableStreamDefaultController, + capacity: ResponseCapacityGate, + signal: AbortSignal, +): Promise { + for await (const chunk of chunks) { + signal.throwIfAborted() + if (chunk.byteLength === 0) continue + deflate.push(chunk, false) + await capacity.wait(controller, signal) + } + signal.throwIfAborted() + deflate.push(new Uint8Array(), true) + await capacity.wait(controller, signal) +} + /** * Push one artifact's text into a deflate stream in bounded chunks, never * splitting a surrogate pair across a chunk boundary (a lone high surrogate @@ -509,8 +567,10 @@ export function streamSessionLogZip( archive.add(deflate) if ('content' in entry) { await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal) - } else { + } else if ('data' in entry) { await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal) + } else { + await pushStreamChunks(deflate, entry.chunks, controller, capacity, producerSignal) } } archive.end() diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 0ec33bc2e5..abc4932b10 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -58,6 +58,7 @@ interface SessionLogConnection { register(route: { readonly path: string readonly methods: readonly ('GET' | 'HEAD')[] + readonly requestBody: 'buffered' readonly fetch: (request: Request) => Promise }): () => Promise } @@ -84,6 +85,7 @@ export function apply(ctx: Context, config: Config = {}): void { connectionOf(ctx).fetch.register({ path: SESSION_LOG_EXPORT_PATH, methods: ['GET', 'HEAD'], + requestBody: 'buffered', fetch: async (request) => { const response = await sessionLogExportResponse( ctx, diff --git a/packages/session-query/session-log-export/tests/archive.host.spec.ts b/packages/session-query/session-log-export/tests/archive.host.spec.ts index 9f676d02d1..f26a128ccc 100644 --- a/packages/session-query/session-log-export/tests/archive.host.spec.ts +++ b/packages/session-query/session-log-export/tests/archive.host.spec.ts @@ -11,7 +11,7 @@ import { randomBytes } from 'node:crypto' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { unzipSync, strFromU8 } from 'fflate' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence' @@ -88,6 +88,14 @@ function readHandle(stored: StoredLog): SessionHandle { } as unknown as SessionHandle } +/** A user/message event carrying one generic-file reference. */ +function fileEvent(id: string, name = 'notes.txt', bytes = 5, seq = SessionSeq(1)): SessionEvent { + return { + type: 'user/message', seq, time: 1000, + data: { content: [{ type: 'file', attachment: { attachmentId: id, name, bytes } }] }, + } as unknown as SessionEvent +} + async function buildApi( logs: Record, descendants: SessionLineageNode[] = [], @@ -95,6 +103,7 @@ async function buildApi( query?: boolean persistence?: boolean | 'throw' attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise>) + readFileStream?: (ref: FileAttachmentRef, signal?: AbortSignal) => AsyncIterable sessions?: { get(id: SessionId): { readonly id: SessionId } | undefined flush(session: { readonly id: SessionId }): Promise @@ -150,6 +159,9 @@ async function buildApi( validateImage: async () => {}, saveImage: async () => { throw new Error('export never saves images') }, readImage, + readFileStream: services.readFileStream ?? (async function* () { + throw new Error('fixture has no files') + }), } as never) } if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never) @@ -740,6 +752,51 @@ describe('session.export download endpoint', () => { expect(files['media/img-1.png']).toEqual(storedImage('img-1').data) }) + it('streams generic files under their content-addressed archive paths', async () => { + const digest = 'a'.repeat(64) + const id = `sha256:${digest}` + const fallbackDigest = 'c'.repeat(64) + const fallbackId = `sha256:${fallbackDigest}` + const root = log('session-root', undefined, [ + fileEvent(id, 'notes.txt', 5), + fileEvent(fallbackId, '.', 5, SessionSeq(2)), + ]) + const reads: Array<{ ref: FileAttachmentRef; signal: AbortSignal | undefined }> = [] + const api = await buildApi({ 'session-root': root }, [], { + readFileStream: (ref, signal) => (async function* (): AsyncIterable { + reads.push({ ref, signal }) + yield new Uint8Array() + yield Uint8Array.of(1, 2) + yield Uint8Array.of(3, 4, 5) + })(), + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(files[`files/aa/${digest}/notes.txt`]).toEqual(Uint8Array.of(1, 2, 3, 4, 5)) + expect(files[`files/cc/${fallbackDigest}/file`]).toEqual(Uint8Array.of(1, 2, 3, 4, 5)) + expect(reads).toHaveLength(2) + expect(reads[0]?.ref).toMatchObject({ attachmentId: id, name: 'notes.txt', bytes: 5 }) + expect(reads[0]?.signal).toBeInstanceOf(AbortSignal) + }) + + it('fails the whole export when a referenced file stream fails', async () => { + const digest = 'b'.repeat(64) + const id = `sha256:${digest}` + const root = log('session-root', undefined, [fileEvent(id)]) + const api = await buildApi({ 'session-root': root }, [], { + readFileStream: () => (async function* (): AsyncIterable { + yield Uint8Array.of(1) + throw new Error('file bytes missing') + })(), + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + await expect(response.arrayBuffer()).rejects.toThrow('file bytes missing') + }) + it('collects media referenced from nested tool results', async () => { const nested = { type: 'assistant/message', seq: SessionSeq(2), time: 2000, diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 4d542a8bc4..120b0c5304 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -53,7 +53,7 @@ afterEach(async () => { for (const root of roots.splice(0)) { await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) } -}) +}, 30_000) interface RealHarness { readonly ctx: Context diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 4e82493c1c..96f989bb50 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -30,7 +30,7 @@ */ import { Context } from '@deepseek-ai/cordis' -import { admitPromptContent } from '@deepseek-ai/dsh-attachment' +import type {} from '@deepseek-ai/dsh-attachment' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' @@ -445,7 +445,7 @@ export class SubagentRuntime extends TypertRemoteService { } else { const attachments = this.ctx.get('attachments') if (attachments === undefined) throw new Error('subagent image prompt requires an attachment store') - content = await admitPromptContent(attachments, request.content) + content = await attachments.admitPromptContent(request.content) } return { messageId: await this[deliverSubagentPrompt]( diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index 733564032b..22db177cc3 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import AttachmentStore, { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import SubagentRuntime, { @@ -169,7 +169,7 @@ describe('subagent prompt Remote', () => { const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) const saveImages = vi.fn(async (inputs: readonly { mediaType: string }[]) => inputs.map((input, index) => ({ ...IMAGE_REF, attachmentId: `att-${index}`, mediaType: input.mediaType }))) - ctx.provide('attachments', { saveImages } as never) + ctx.provide('attachments', Object.setPrototypeOf({ saveImages }, AttachmentStore.prototype) as never) const delivery = promptDelivery(subagents).mockResolvedValue('m-content' as MessageId) const content = [ { type: 'text' as const, text: 'before' }, @@ -188,11 +188,11 @@ describe('subagent prompt Remote', () => { it('maps a refused image batch to subagent/attachment-invalid and delivers nothing', async () => { const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) - ctx.provide('attachments', { + ctx.provide('attachments', Object.setPrototypeOf({ saveImages: async () => { throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') }, - } as never) + }, AttachmentStore.prototype) as never) const delivery = promptDelivery(subagents) await expect(subagents.prompt({ @@ -207,7 +207,7 @@ describe('subagent prompt Remote', () => { it('maps non-canonical base64 to subagent/attachment-invalid without touching the store', async () => { const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) const saveImages = vi.fn() - ctx.provide('attachments', { saveImages } as never) + ctx.provide('attachments', Object.setPrototypeOf({ saveImages }, AttachmentStore.prototype) as never) const delivery = promptDelivery(subagents) await expect(subagents.prompt({ diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index e3a08dda63..4437930d49 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -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/test-support/client-runtime/README.md -README.md: 3cc916343c25144720bfd599b87da7b5a2bb6b5d -README.zh.md: cd8a935f16bee9946edad8df41dab37c3b72af69 +README.md: 4ab70ecb6db7ae7d1844d8141b34daf7bd19a3dc +README.zh.md: c3265d7cdcadaf55c0744b13267ac5e64d6b47a6 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 3cc916343c..4ab70ecb6d 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-test-runtime` gives a browser feature spec a real jsdom test bench: it assembles a Cordis context, the renderer-owned slot registry, and the production `UiSession` adapter around typed Session and Workspace Controller doubles. Feature suites exercise declaration, registration, scoping, stores, injection, rendering, updates, and disposal without copying production renderer or adapter logic. Suites publish Session lifecycle state, Workspace state, projection values, and Conversation events through typed fixtures, then use local DOM snapshot roots, scoped Testing Library queries, and fail-loud service checks. It is not part of the product plugin graph (no `dsh.client`); feature packages depend on it in `devDependencies` only. +`dsh-client-test-runtime` gives a browser feature spec a real jsdom test bench: it assembles a Cordis context, the renderer-owned slot registry, and the production `UiSession` adapter around typed Session and Workspace Controller doubles. A default file-upload stub satisfies features that declare the service and rejects if a test starts an upload without replacing it. Feature suites exercise declaration, registration, scoping, stores, injection, rendering, updates, and disposal without copying production renderer or adapter logic. Suites publish Session lifecycle state, Workspace state, projection values, and Conversation events through typed fixtures, then use local DOM snapshot roots, scoped Testing Library queries, and fail-loud service checks. It is not part of the product plugin graph (no `dsh.client`); feature packages depend on it in `devDependencies` only. ## Table of Contents @@ -40,7 +40,7 @@ expect(view.container).toMatchSnapshot() await runtime.dispose() ``` -`mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions. +`mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. The runtime provides an unavailable `fileUpload` stub so assemblies can mount; replace `runtime.fileUpload.upload` before mounting when a test exercises upload behavior. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions. ### Local DOM snapshots @@ -101,7 +101,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p ### Lifecycle -`create()` builds a fresh context, mounts the slot and conversation registries, installs the renderer, and provides the session/workspace doubles. `mount` checks every declared injection against the context before starting the fiber, so a missing provider fails loud instead of suspending forever. `dispose()` unmounts React trees first, then disposes feature fibers, releases the root registration, disposes minted session scopes, and clears persisted store state; every public mutator is act-wrapped, so tests never handle SlotCore microtask batching or React `act` themselves. +`create()` builds a fresh context, mounts the slot and conversation registries, installs the renderer, and provides the session/workspace doubles plus the fail-loud file-upload stub. `mount` checks every declared injection against the context before starting the fiber, so a missing provider fails loud instead of suspending forever. `dispose()` unmounts React trees first, then disposes feature fibers, releases the root registration, disposes minted session scopes, and clears persisted store state; every public mutator is act-wrapped, so tests never handle SlotCore microtask batching or React `act` themselves. diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index cd8a935f16..c3265d7cdc 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-client-test-runtime` 让浏览器功能测试拥有真实的 jsdom 测试台:它把 Cordis 上下文、渲染器拥有的 slot 注册表与生产 `UiSession` 适配器组装在带类型的 Session 和 Workspace Controller 替身周围。功能套件无需复制生产渲染器或适配器逻辑,即可检验声明、注册、作用域、store、注入、渲染、更新与销毁。套件通过带类型 fixture 发布 Session 生命周期状态、Workspace 状态、projection 值与 Conversation 事件,再使用局部 DOM 快照根、限定范围的 Testing Library 查询与自明的服务缺失检查。它不属于产品插件图(无 `dsh.client`);feature 包仅以 `devDependencies` 依赖之。 +`dsh-client-test-runtime` 让浏览器功能测试拥有真实的 jsdom 测试台:它把 Cordis 上下文、渲染器拥有的 slot 注册表与生产 `UiSession` 适配器组装在带类型的 Session 和 Workspace Controller 替身周围。默认文件上传替身可满足声明该服务的功能;测试若没有替换它却发起上传,就会明确失败。功能套件无需复制生产渲染器或适配器逻辑,即可检验声明、注册、作用域、store、注入、渲染、更新与销毁。套件通过带类型 fixture 发布 Session 生命周期状态、Workspace 状态、projection 值与 Conversation 事件,再使用局部 DOM 快照根、限定范围的 Testing Library 查询与自明的服务缺失检查。它不属于产品插件图(无 `dsh.client`);feature 包仅以 `devDependencies` 依赖之。 ## 目录 @@ -40,7 +40,7 @@ expect(view.container).toMatchSnapshot() await runtime.dispose() ``` -`mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时 store 实例,用于身份与动作驱动写入断言。 +`mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。运行时会提供不可用的 `fileUpload` 替身,使装配可以挂载;测试上传行为时,需要在挂载前替换 `runtime.fileUpload.upload`。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时 store 实例,用于身份与动作驱动写入断言。 ### 局部 DOM 快照 @@ -101,7 +101,7 @@ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') ### 生命周期 -`create()` 构建全新上下文,挂载 slot 与会话注册表,安装渲染器,并提供 session/workspace 替身。`mount` 在启动 fiber 前对照上下文检查每个已声明注入,使缺失提供方自明报错而非永久挂起。`dispose()` 先卸载 React 树,再 dispose feature fiber、释放根注册、dispose 已铸 session scope 并清除持久化 store 状态;每个公共修改器都包裹在 act 中,因此测试无需自行处理 SlotCore 微任务批处理或 React `act`。 +`create()` 构建全新上下文,挂载 slot 与会话注册表,安装渲染器,并提供 session/workspace 替身和明确失败的文件上传替身。`mount` 在启动 fiber 前对照上下文检查每个已声明注入,使缺失提供方自明报错而非永久挂起。`dispose()` 先卸载 React 树,再 dispose feature fiber、释放根注册、dispose 已铸 session scope 并清除持久化 store 状态;每个公共修改器都包裹在 act 中,因此测试无需自行处理 SlotCore 微任务批处理或 React `act`。 diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 60c5c078eb..7d0d37c9cb 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -1,7 +1,7 @@ /** * jsdom slot test runtime: a real small runtime — Cordis `Context`, the * renderer-owned `SlotRegistry`, the `ui-session` adapter, and the UI renderer — assembled around - * test-owned session/workspace doubles, so feature specs exercise + * test-owned session/workspace doubles and a fail-loud file-upload stub, so feature specs exercise * declaration, registration, scope, store, inject, rendering, updates, and * disposal without hand-building the machinery per suite. * @@ -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, @@ -110,6 +111,14 @@ export interface FeatureHandle { dispose(): Promise } +/** Mutable fail-loud file-upload stub installed by {@link SlotTestRuntime}. */ +export interface TestFileUpload { + /** Availability reported to the feature under test. */ + available: boolean + /** Test-supplied upload behavior; the default rejects every call. */ + upload: (sessionId: SessionId, ...args: unknown[]) => Promise +} + /** * Owner-props cell behind the auto frame: one external store the frame * subscribes to, so {@link SlotTestRuntime.renderSlot} and @@ -208,6 +217,8 @@ export class SlotTestRuntime { readonly sessions: TestSessions /** Workspaces double (list observable, recorded intent actions). */ readonly workspaces: TestWorkspaces + /** Mutable file-upload stub; replace `upload` in suites that exercise the capability. */ + readonly fileUpload: TestFileUpload private readonly stabilizer: Stabilizer = async (fn) => { await act(async () => { await fn() }) @@ -229,8 +240,13 @@ export class SlotTestRuntime { this.root = new TestRoot(slots, this.stabilizer) this.sessions = new TestSessions(this.stabilizer, ctx) this.workspaces = new TestWorkspaces(this.stabilizer) + this.fileUpload = { + available: false, + upload: () => Promise.reject(new Error('client test runtime: file upload is not stubbed')), + } ctx.provide('sessions', this.sessions) ctx.provide('workspaces', this.workspaces) + ctx.provide('fileUpload', this.fileUpload as never) this.disposeWorkspaceSource = slots.provideRoot({ hooks: { workspaces: this.workspaces.list } }) // Capturing install: the production renderer does the rendering; the // wrapper only takes the host face for storeOf (no machinery copied). diff --git a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx index 2fb223150a..c3904623ba 100644 --- a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx @@ -38,6 +38,13 @@ function entry(seq: number): SessionLiveEventEntry { } 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('fixture-session' as SessionId)).rejects.toThrow('file upload is not stubbed') + await runtime.dispose() + }) + it('builds independent Conversation and Chat snapshots with optional overrides', () => { const conversation = conversationSnapshot() expect(conversation).toEqual(EMPTY_CONVERSATION_SNAPSHOT) diff --git a/packages/test-support/session-snapshot/tests/harness.spec.ts b/packages/test-support/session-snapshot/tests/harness.spec.ts index 889ccefb67..508fd2b635 100644 --- a/packages/test-support/session-snapshot/tests/harness.spec.ts +++ b/packages/test-support/session-snapshot/tests/harness.spec.ts @@ -895,9 +895,9 @@ describe('runScenario', () => { it('waitForTurnEnd times out for a missing log and an open logged turn', { timeout: 20_000 }, async () => { const missing = await scenario({}) await expect(runScenario( - { steps: [...boot, { op: 'waitForTurnEnd', timeoutMs: 20 }] }, + { steps: [...boot, { op: 'waitForTurnEnd', timeoutMs: 200 }] }, { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile }, - )).rejects.toThrow(/did not persist turn\/end within 20ms/) + )).rejects.toThrow(/did not persist turn\/end within 200ms/) const open = await scenario({ prompt: 'hang-until-cancel', @@ -915,11 +915,11 @@ describe('runScenario', () => { steps: [ ...boot, { op: 'promptAndCancel', text: 'hang' }, - { op: 'waitForTurnEnd', timeoutMs: 20 }, + { op: 'waitForTurnEnd', timeoutMs: 200 }, ], }, { agent: AGENT, mode: 'replay', fixtureFile: open.fixtureFile }, - )).rejects.toThrow(/did not persist turn\/end within 20ms/) + )).rejects.toThrow(/did not persist turn\/end within 200ms/) }) it('waitForGoalPhase requires the requested durable goal phase', { timeout: 20_000 }, async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39d4e202d3..37fec1d349 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -811,6 +811,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../../client/file-upload '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -856,6 +859,9 @@ importers: '@deepseek-ai/dsh-user-questions': specifier: workspace:^ version: link:../../interaction/user-questions + zod: + specifier: ^4.4.3 + version: 4.4.3 packages/api/session-controller: dependencies: @@ -893,9 +899,15 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../../client/file-upload '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference @@ -1577,6 +1589,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../../client/file-upload '@deepseek-ai/dsh-client-hmr': specifier: workspace:^ version: link:../../client/hmr @@ -1835,6 +1850,46 @@ importers: specifier: workspace:^ version: link:../../util/values + packages/client/file-upload: + dependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands + '@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 + packages/client/hmr: dependencies: '@deepseek-ai/schemastery': @@ -2306,6 +2361,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../file-upload '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -5001,6 +5059,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../../client/file-upload '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 398cdb25f2..5c7ea574f6 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -75,6 +75,7 @@ export const SERVICE_PAGE: Record = { deepseekLlmApiExtensions: 'llm-streaming.md', dynamicCordisRunner: 'extensions.md', e2b: 'subprocess.md', + fileUploads: 'attachment.md', fileReferences: 'session-reference.md', fs: 'filesystem.md', goals: 'goal.md', @@ -153,6 +154,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract', launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract', connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API', + fileUpload: 'client-side browser upload service — packages/client/file-upload/README.md owns the API', uiRenderer: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', uiSession: 'client-side Session source adapter — packages/client/ui-session/README.md owns the API', uiConversation: 'client-side Conversation registries and assembler — packages/client/ui-conversation/README.md owns the API', @@ -343,6 +345,11 @@ export const LINK_MAP: Readonly> = { SessionSummary: 'session.md', SessionUpdateQueueRequest: 'session.md', SessionUpdateQueueValue: 'session.md', + EncodedFileUploadRequest: 'attachment.md', + AgentResolver: 'attachment.md', + PromptFileBinding: 'attachment.md', + FileUploadReceiptId: 'attachment.md', + FileUploadValue: 'attachment.md', SessionStartSource: 'core.md', SessionLogSnapshot: 'session-query.md', SessionSurfaceSnapshot: 'session-query.md', @@ -352,10 +359,18 @@ export const LINK_MAP: Readonly> = { ApprovalRequestEvent: 'approval.md', ApprovalService: 'approval.md', AskUserQuestionRequestEvent: 'user-questions.md', + AdmittedPromptContentPart: 'attachment.md', + AttachmentAdmissionPart: 'attachment.md', + AttachmentError: 'attachment.md', + EncodedFileAttachment: 'attachment.md', EncodedImageAttachment: 'attachment.md', + FileAttachmentRef: 'attachment.md', + SaveFileAttachment: 'attachment.md', + SaveFileStreamAttachment: 'attachment.md', ImageAttachmentAccess: 'llm-streaming.md', ImageAttachmentRef: 'attachment.md', ImageRequestPolicy: 'attachment.md', + PromptContentPart: 'attachment.md', RequestImageAttachment: 'attachment.md', SaveImageAttachment: 'attachment.md', StoredImageAttachment: 'attachment.md', @@ -397,8 +412,10 @@ export const LINK_MAP: Readonly> = { CreateGoalResult: 'goal.md', CommandDefinition: 'commands.md', CommandDescriptor: 'commands.md', + CommandFileReceiptResolver: 'commands.md', CommandId: 'commands.md', CommandResult: 'commands.md', + CommandSubmitAttachment: 'commands.md', CommandSurface: 'commands.md', LspProvider: 'lsp.md', LspQueryRequest: 'lsp.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index d8216809e6..cd74be4272 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -107,6 +107,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['api-session-controller', 'tool-fs', 'llm-pi-ai', 'llm-deepseek'], note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.', }, + { + key: 'fileUploads', + pkg: 'client-file-upload', + title: 'Agent-scoped staged file uploads', + mode: 'core', + consumers: ['api-session-controller'], + note: 'Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions.', + }, { key: 'llm', pkg: 'llm', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 4930c80174..ecbfecc038 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -951,6 +951,21 @@ "symbol": "ImageAttachmentLimits", "source": "packages/attachment/attachment/src/types.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "PromptContentPart", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "AttachmentAdmissionPart", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "AdmittedPromptContentPart", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/subsystems/attachment.md", "symbol": "EncodedImageAttachment", diff --git a/scripts/verify-client-ui-i18n.ts b/scripts/verify-client-ui-i18n.ts index 621a0be0be..ea09388a45 100644 --- a/scripts/verify-client-ui-i18n.ts +++ b/scripts/verify-client-ui-i18n.ts @@ -35,8 +35,11 @@ const COPY_ATTRIBUTE_SUFFIX = /(?:Aria|Copy|Description|Heading|Label|Message|Pl const COPY_NAME = /(?:^|_)(?:aria|copy|description|empty|heading|label|message|placeholder|summary|text|title|tooltip)(?:s|_.*)?$/i const COPY_SUFFIX = /(?:aria|copy|description|empty|heading|label|labels|message|placeholder|summary|text|title|tooltip|tabs)$/i const IMMUTABLE_LANGUAGE_TOKENS = new Set([ + 'B', 'Function', + 'GB', 'K', + 'KB', 'M', 'MB', 'Symbol', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 565693d35f..f514ba8c7e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -78,6 +78,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-renderer': { kind: 'none', reason: 'Browser-side render assembly; registers nothing model-facing.' }, 'packages/client/ui-session': { kind: 'none', reason: 'Browser-side Session adapter; registers nothing model-facing.' }, 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/file-upload': { kind: 'none', reason: 'Browser-side request-body transport; registers nothing model-facing.' }, 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and forwarded events; selected services own any model-visible effect.' }, 'packages/experimental/agent-team-web-profile': { kind: 'indirect', reason: 'The private Web layer delegates model composition to the Host-side Agent Teams profile.' }, 'packages/experimental/client-ui-agent-team': { kind: 'none', reason: 'The browser projection and task controls register no model-facing input.' }, diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index e633f1ca1c..fb3437d266 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -29,6 +29,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Full access"': Full access - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index 78605cc160..07b054429a 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -117,6 +117,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index 5b20a1e993..4202ac6ef7 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -47,6 +47,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index 89f5b6f42d..9d1a40d048 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -39,6 +39,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index 5b753960a5..4f88197939 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -47,6 +47,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 057d13cd9c..ed35656abe 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -39,6 +39,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/file-upload-round/replay.override.json b/snapshots/web/file-upload-round/replay.override.json new file mode 100644 index 0000000000..388ed168eb --- /dev/null +++ b/snapshots/web/file-upload-round/replay.override.json @@ -0,0 +1,43 @@ +{ + "patches": [ + { + "at": 0, + "entry": { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "call_00_ET_x83IHPfS8ouwILXe8u729956", + "name": "read", + "arguments": "{\"file_path\": \"{{fromRequest:saved at \\\"([^\\\"]+)\\\"}}\"}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 8243, + "outputTokens": 147, + "totalTokens": 8390, + "cacheReadTokens": 0, + "reasoningTokens": 0 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + ] + } + } + ] +} diff --git a/snapshots/web/file-upload-round/session.v2.jsonl b/snapshots/web/file-upload-round/session.v2.jsonl new file mode 100644 index 0000000000..0a2dc6ed92 --- /dev/null +++ b/snapshots/web/file-upload-round/session.v2.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":2,"id":"{{session:1}}","createdAt":1787887026413,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"standard"} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"model/selection","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","reasoningEffort":"high"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"file","attachment":{"attachmentId":"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60","name":"poem.txt","bytes":16}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69,"name":"reference-1.png"}},{"type":"text","text":"Read the attached file with the read tool, reply with exactly the single word it contains, and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"user/message","data":{"content":[{"type":"file","attachment":{"attachmentId":"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60","name":"poem.txt","bytes":16}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69,"name":"reference-1.png"}},{"type":"text","text":"Read the attached file with the read tool, reply with exactly the single word it contains, and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Read the attached file with","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","contextWindow":1000000}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":8243,"outputTokens":147,"totalTokens":8390,"cacheReadTokens":0,"reasoningTokens":0},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":8243,"outputTokens":147,"totalTokens":8390,"cacheReadTokens":0,"reasoningTokens":0}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_ET_x83IHPfS8ouwILXe8u729956"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_x83IHPfS8ouwILXe8u729956","content":[{"type":"text","text":"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\nfile\n\n1: UPLOAD_ROUND_OK\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"{{message:4}}"},"meta":{"path":"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt","offset":1,"lines":[{"number":1,"text":"UPLOAD_ROUND_OK"}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UPLOAD_ROUND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:5}}"},"usage":{"inputTokens":230,"outputTokens":8,"totalTokens":8942,"cacheReadTokens":8704,"reasoningTokens":0},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[17,17,15,17,15,17],"texts":["U","PL","O","AD","_RO","UND","_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UPLOAD_ROUND_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":230,"outputTokens":8,"totalTokens":8942,"cacheReadTokens":8704,"reasoningTokens":0}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/file-upload-round/snapshot.yml b/snapshots/web/file-upload-round/snapshot.yml new file mode 100644 index 0000000000..b0e3a4a6e3 --- /dev/null +++ b/snapshots/web/file-upload-round/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: file-upload-round +profile: web +composition: web-default +recording: live +header: + class: web-default +replay: + override: true diff --git a/snapshots/web/file-upload-round/trajectory.expected.md b/snapshots/web/file-upload-round/trajectory.expected.md new file mode 100644 index 0000000000..54cde508ec --- /dev/null +++ b/snapshots/web/file-upload-round/trajectory.expected.md @@ -0,0 +1,3 @@ +- row "USER, Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop.": + - cell "Turn 1 USER": USER + - cell "Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop." diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md new file mode 100644 index 0000000000..2541ec1c4a --- /dev/null +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -0,0 +1,52 @@ +- banner: + - navigation "Session hierarchy": + - button "Read the attached file with" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- text: poem.txt TXT 16B +- button "reference-1.png, click to view original": + - img "reference-1.png" +- text: Read the attached file with the read tool, reply with exactly the single word it contains, and stop. {{clock}} +- button "Copy": + - img +- button "1 tool call": + - text: 1 tool call + - img +- paragraph: UPLOAD_ROUND_OK +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- button "Usage 17.3K tok": + - img + - text: Usage 17.3K tok +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- button "Add attachment": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash-Vision-Exp, reasoning effort high": + - text: DeepSeek-V4-Flash-Vision-Exp high + - img +- button "1% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 51% Input 17.2K tok · Output 155 tok diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index 108ee4050c..b65f5d2ae3 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -51,6 +51,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 9a83925d23..f0449b34f2 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index 700f499256..aeb9ecd7a9 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -229,6 +229,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index fd874dc327..eeaf7d1cca 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -117,6 +117,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index 2d9f315f43..a4694918d4 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -33,6 +33,8 @@ - paragraph - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index 5f53d73cfd..e886dd913e 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -32,6 +32,8 @@ - textbox "Describe what you want to build... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Plan mode on, press to turn off": Plan - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index 00d271cff3..6cde9ddcba 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -43,6 +43,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index bc5558ab03..dff102b7cb 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md index b61cd9e51a..635ddf6d9f 100644 --- a/snapshots/web/live-interactions/cancel-expanded.expected.md +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -40,6 +40,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index 4dbfb09b4a..84320a45d0 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -36,6 +36,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index ae854c557c..e5b3c6ca64 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -26,6 +26,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 4307a62de2..a16ba7d1ee 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -25,6 +25,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index 7dc6c9f613..237543a043 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -28,6 +28,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index 1c6a87f6d1..3919ce3dfc 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -45,6 +45,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 93107fe00b..1cfe5382f5 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index 6f8f2cd629..fa61237f73 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -26,6 +26,8 @@ - paragraph: Queue this follow-up while the current turn is running. - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index b8ea35966b..292d2406f6 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -69,6 +69,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index e26d5cdb11..91a71e8e57 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -40,6 +40,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 0233cfa370..58abbd47bd 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -61,6 +61,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 14ba8a416e..cc7b07f86d 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -41,6 +41,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index 80b8866505..aa4301ad2b 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -56,6 +56,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index b2f7ba6294..34c5652e9a 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -53,6 +53,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index a6dc5b28fd..1a879c04c5 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index 0c18353ee1..d6f6be3bba 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -32,6 +32,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index 9ce8b0be96..0b3e7b65b0 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -26,6 +26,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index 4c64c771f0..f32a37c548 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -42,6 +42,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index 666cb935b7..0e0835f2bb 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -42,6 +42,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md index 5a5143fc2c..041650fb7b 100644 --- a/snapshots/web/queue-actions/preserved-expanded.expected.md +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -59,6 +59,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index 9798e5da4f..e9a74f7b05 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -55,6 +55,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index 48c85e44f4..e314229b31 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -35,6 +35,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index b5dc457a4e..23fad54c88 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -79,6 +79,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md index 462b6d03d1..457affb944 100644 --- a/snapshots/web/queued-image/queued.expected.md +++ b/snapshots/web/queued-image/queued.expected.md @@ -35,6 +35,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 3f4ed25eb3..3e9691ff71 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -61,6 +61,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 1265a3e50e..57505c708b 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -65,6 +65,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index 2d18d8ec62..70c6204338 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -59,6 +59,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 4711ff7304..7ea48e69f6 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -41,6 +41,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index cff07a3aae..74903e7e6e 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -54,6 +54,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Full access"': Full access - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index bfd48e7ed9..5c2738c768 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -46,6 +46,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 0d190d89e9..237bab0dfc 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -38,6 +38,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 2cbe5b1fa8..716f7f6d47 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -78,6 +78,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment" [disabled]: + - img - 'button "Access mode, current: Custom"': Custom - button "6% of context used" - button "Send message" [disabled] diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 38b587e257..746d68c658 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -66,6 +66,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment" [disabled]: + - img - 'button "Access mode, current: Custom"': Custom - button "6% of context used" - button "Send message" [disabled] diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index 4455ad4ad6..7c00b9e2c8 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -27,6 +27,8 @@ - textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled] - button "Commands" [disabled]: - img +- button "Add attachment" [disabled]: + - img - 'button "Access mode, current: Custom" [disabled]': Custom - button "Stop generating" - button "Send message" [disabled] diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index 637055a274..ed25b8d556 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -38,6 +38,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index 94870d19c8..0c2c806aad 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -51,6 +51,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index d79602b47c..502147691d 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 26bd7a6d83..5aff82d1a1 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -37,6 +37,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 43d984fc53..a01ee4fd78 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -38,6 +38,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 0968ee2b75..5ab42e660c 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -43,6 +43,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/tsconfig.base.json b/tsconfig.base.json index 4f7c66624a..e0749acbb4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -158,6 +158,8 @@ "@deepseek-ai/dsh-client-ui-session": ["./packages/client/ui-session/src"], "@deepseek-ai/dsh-client-ui-session/client": ["./packages/client/ui-session/src/client"], "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], + "@deepseek-ai/dsh-client-file-upload": ["./packages/client/file-upload/src"], + "@deepseek-ai/dsh-client-file-upload/client": ["./packages/client/file-upload/src/client/index.ts"], "@deepseek-ai/dsh-api-remotes": ["./packages/api/remotes/src"], "@deepseek-ai/dsh-api-remotes/client": ["./packages/api/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 2634de2cdd..c186cfbc47 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -54,6 +54,7 @@ { "path": "./packages/experimental/inspector/tsconfig.client.json" }, { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection/tsconfig.client.json" }, + { "path": "./packages/client/file-upload/tsconfig.client.json" }, { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/session-controller/tsconfig.client.json" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 67e0783286..24608c3308 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -51,6 +51,7 @@ "apps/web/tests/composer-draft-scroll.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/web-search-round.e2e.ts", + "apps/web/tests/file-upload-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", "apps/web/tests/message-feedback.e2e.ts", "apps/web/tests/message-feedback-layout.e2e.ts", @@ -145,6 +146,7 @@ { "path": "./packages/util/atomic-write" }, { "path": "./packages/attachment/attachment" }, { "path": "./packages/attachment/attachment-local" }, + { "path": "./packages/client/file-upload/tsconfig.host.json" }, { "path": "./packages/llm/llm" }, { "path": "./packages/llm/deepseek-llm-api-extensions" }, { "path": "./packages/llm/token-meter" },