Merge pull request #919 from deepseek-harness/worktree/pr555-simplify
refactor: simplify Web image input v1
This commit is contained in:
53 files changed
+471
-226
No files matched your search
+2
-2
@@ -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-22-web-multimodal-image-input-and-durable-attachments.md
|
||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.md: c1959ecc5d3a89af57bade3a58c3a8edc2679e0d
|
||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: b61053de3b83ee2b4f27907b9deb059b9e531177
|
||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 8116049ef95ab288bfc037eebfd5bab1509fe51d
|
||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 551f3413e5420610812bcbc63fca739d5f3ba248
|
||||
+6
-6
@@ -112,13 +112,13 @@ type PromptInputPart =
|
||||
}
|
||||
```
|
||||
|
||||
Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, magic-byte MIME, intrinsic dimensions, and decoded-pixel count — the complete batch, through the seam's storage-free `validateImage`, before persisting any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Only after every image succeeds does it call the agent with normalized text and durable image blocks. A failure appends no user event and exposes no attachment path or raw bytes.
|
||||
Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, magic-byte MIME, intrinsic dimensions, and decoded-pixel count: it validates the complete batch through the seam's storage-free `validateImage` before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Only after every image succeeds does it call the agent with normalized text and durable image blocks in the submitted order. A failure appends no user event and exposes no attachment path or raw bytes.
|
||||
|
||||
`session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and invalidates late loads so an unmounted session cannot repopulate the cache.
|
||||
|
||||
### Model capabilities and provider behavior
|
||||
|
||||
Model catalog entries gain optional merge-extensible input and output modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability.
|
||||
Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability.
|
||||
|
||||
The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip.
|
||||
|
||||
@@ -126,7 +126,7 @@ The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments`
|
||||
|
||||
Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically.
|
||||
|
||||
Token estimation accounts for image dimensions without counting base64 or attachment locators as text. Provider-reported usage remains authoritative. ACP renders an explicit image marker until that protocol surface gains native image support rather than silently omitting the block.
|
||||
Provider-neutral token estimation does not guess visual pricing from image dimensions; provider-reported usage remains authoritative. ACP renders an explicit image marker until that protocol surface gains native image support rather than silently omitting the block.
|
||||
|
||||
Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route resolves those references through its adapter; a text-only route fails explicitly instead of silently dropping the visual context. The synthesized checkpoint remains text-only, and `compact-basic` rejects image summary output with `UNSUPPORTED_CONTENT`.
|
||||
|
||||
@@ -148,7 +148,7 @@ Malformed base64, unsupported or mismatched media, truncated headers, excess byt
|
||||
| --- | --- |
|
||||
| `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and `ctx.attachments` service. |
|
||||
| `packages/attachment/attachment-local` | Private content-addressed storage, image-header validation, integrity verification, and configuration. |
|
||||
| `packages/llm/llm` and `packages/llm/token-meter` | Role-neutral `ImageBlock`, modality metadata, and image cost estimation. |
|
||||
| `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. |
|
||||
| `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. |
|
||||
| `packages/llm/llm-deepseek` | Reject image content explicitly. |
|
||||
| `packages/compact/compact-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. |
|
||||
@@ -161,7 +161,7 @@ The attachment packages form the interface/implementation side of one capability
|
||||
|
||||
### Implementation
|
||||
|
||||
The implemented slice includes the attachment seam, role-neutral image block, image-aware token estimation, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, host-capability projection, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, compaction handling, and keyless assembled Web coverage.
|
||||
The implemented slice includes the attachment seam, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, compaction handling, and keyless assembled Web coverage.
|
||||
|
||||
No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice.
|
||||
|
||||
@@ -198,7 +198,7 @@ UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalog
|
||||
- Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, gallery and lightbox rendering, and draft/session-scope/application object-URL cleanup — behavior lives in the package suites per the one-smoke assembled-lane design. The built-bundle boot smoke's journey (`apps/web/tests/built-boot.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) additionally reaches durable image content: the history gallery resolves fixture bytes over the authorized attachment route into an object URL through the built bundles.
|
||||
- Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, nested tool-result images, preserved summary input, and explicit image-output rejection.
|
||||
- A credentialed real-API test sends a PNG through the Anthropic `claude-opus-4-8` route and requires the model to identify its QR code.
|
||||
- The current production adapter set declares text-only output; output-provider certification remains outside version one.
|
||||
- The current production adapter set has no certified image-output route; output-provider certification remains outside version one.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
+6
-6
@@ -112,13 +112,13 @@ type PromptInputPart =
|
||||
}
|
||||
```
|
||||
|
||||
Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数——并在持久化任何成员之前,通过服务边界上不触碰存储的 `validateImage` 校验完整批次,因此一张畸形图片不会把批次中的有效成员留成无引用的孤儿对象。只有每张图片都成功后,宿主才会用规范化文本和持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。
|
||||
Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数:它会在保存任何成员之前,通过服务边界上不触碰存储的 `validateImage` 校验完整批次,因此一张畸形图片不会把批次中的有效成员留成无引用对象。只有每张图片都成功后,宿主才会用规范化文本和按提交顺序排列的持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。
|
||||
|
||||
`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并使延迟完成的加载失效,以免已卸载的会话重新写入缓存。
|
||||
|
||||
### 模型能力与提供方行为
|
||||
|
||||
模型目录项增加可选且可合并扩展的输入与输出模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。
|
||||
模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。
|
||||
|
||||
宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。
|
||||
|
||||
@@ -126,7 +126,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme
|
||||
|
||||
核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。
|
||||
|
||||
token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为文本计数。提供方返回的用量仍是权威值。在 ACP(Agent Client Protocol)接口原生支持图片前,ACP 会渲染明确的图片标记,而不是静默省略该块。
|
||||
提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。在 ACP(Agent Client Protocol)接口原生支持图片前,ACP 会渲染明确的图片标记,而不是静默省略该块。
|
||||
|
||||
压缩(compaction)会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compact-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。
|
||||
|
||||
@@ -148,7 +148,7 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为
|
||||
| --- | --- |
|
||||
| `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误和 `ctx.attachments` 服务。 |
|
||||
| `packages/attachment/attachment-local` | 私有内容寻址存储、图片头校验、完整性校验和配置。 |
|
||||
| `packages/llm/llm` 和 `packages/llm/token-meter` | 角色无关的 `ImageBlock`、模态元数据和图片成本估算。 |
|
||||
| `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 |
|
||||
| `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 |
|
||||
| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 |
|
||||
| `packages/compact/compact-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 |
|
||||
@@ -161,7 +161,7 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为
|
||||
|
||||
### 实现
|
||||
|
||||
已实现的范围包括附件服务边界、角色无关的图片块、图片感知的 token 估算、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、宿主能力投影、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、双击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。
|
||||
已实现的范围包括附件服务边界、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、双击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。
|
||||
|
||||
预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。
|
||||
|
||||
@@ -198,7 +198,7 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模
|
||||
- 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、画廊与 lightbox 渲染,以及草稿、会话作用域和应用层级的对象 URL 清理——按"单一 smoke 组装通道"设计,行为断言归各包套件。构建产物 boot smoke 的 journey(`apps/web/tests/built-boot.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)额外抵达持久图片内容:历史画廊经授权附件路由把 fixture 字节解析为 object URL,走完构建后 bundle 的线上往返。
|
||||
- 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、嵌套工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。
|
||||
- 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。
|
||||
- 当前生产适配器集合声明仅支持文本输出;输出提供方认证不在第一版范围内。
|
||||
- 当前生产适配器集合没有经过认证的图片输出路由;输出提供方认证仍不在第一版范围内。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
+6
@@ -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 .agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md
|
||||
2026-07-29-simplify-web-image-input-v1.md: e7847dc2ae18f48146cb2b686dcda64beebf2350
|
||||
2026-07-29-simplify-web-image-input-v1.zh.md: c9e0a3cc5dc07b70be4b0c1244921ccac567d9af
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Simplify Web image input version one
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-29-simplify-web-image-input-v1.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The first durable Web image-input slice introduced required ordered multi-image intake alongside speculative surfaces for arbitrary CLI provider mounting, output-modality discovery, alternative text, provider-neutral visual token pricing, and browser lifecycle APIs with no cross-package consumer. Keeping the speculative surfaces would turn unchosen future behavior into public contracts and make the initial capability harder to review and maintain.
|
||||
|
||||
## Decision
|
||||
|
||||
Version one accepts ordered image batches bounded by configurable per-message count and aggregate-byte limits plus per-image byte and pixel limits. The browser rejects unsupported declared formats before preview allocation, while the host authoritatively decodes the complete batch, checks current deployment bounds, validates every image without storage writes, and only then saves every image while preserving submitted order in the resulting durable blocks. Request buffering derives directly from the attachment service's aggregate image-byte limit. This preserves validation atomicity without adding a batch transaction, rollback protocol, or policy snapshot in `host.describe`.
|
||||
|
||||
Provider/model selection remains configuration and profile state. The boot composition registers the shipped DeepSeek, OpenAI, and Anthropic routes; the CLI does not add image-specific selection flags, inspect the yml provider roster, or dynamically mount an adapter.
|
||||
|
||||
Exact-model metadata carries only the input modalities that current admission decisions consume. `ImageBlock` carries the durable attachment reference; its optional display name supplies accessible UI text, so the core block has no separate alternative-text field. Provider-neutral token estimation does not apply one provider's visual pricing formula to other routes.
|
||||
|
||||
The attachment seam exposes its limits plus storage-free `validateImage`, `saveImage`, and `readImage`. The host depends on that seam rather than implementation re-exports. Browser draft and historical-image operations remain concrete conversation-plugin internals; the public `IConversation` face contains only the input registry and the scoped send, cancel, and history verbs used across package boundaries.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Accept only one image.** Comparing or combining several images is a current product requirement. Count and aggregate-byte bounds keep that path finite without reducing it to a single image.
|
||||
|
||||
**Add a storage transaction or rollback protocol.** Storage-free validation prevents malformed later members from leaving earlier valid members unreferenced. Stronger all-or-nothing storage across independent content-addressed objects would require ownership or reclamation semantics that the current product path does not need.
|
||||
|
||||
**Keep future-facing fields and methods as placeholders.** Output modalities, block alternative text, and active-model handshake data had no current decision consumer. Adding them later with their first consumer preserves freedom to choose the correct contract.
|
||||
|
||||
**Estimate every image with one tile formula.** Visual pricing varies by provider, model, detail mode, and preprocessing. A hard-coded provider-neutral estimate would look authoritative while being wrong; provider usage is the authoritative accounting source.
|
||||
|
||||
**Add CLI provider/model selection or dynamic mounting.** Configuration already owns route selection, plugin composition, and credentials. Duplicating those choices in image-input flags would require parsing or mutating the config tree outside the loader.
|
||||
|
||||
## Consequences
|
||||
|
||||
The feature retains the two batch limits and one storage-free validation method required by multi-image prompts, while removing unrelated public fields, lifecycle operations, policy snapshots, and route-assembly branches. Provider/model selection remains composition or profile configuration. Pre-request token pressure may undercount visual input until a provider-aware estimator is designed, while reported usage remains exact.
|
||||
|
||||
Reintroducing any removed surface requires a concrete consumer and its failure, lifecycle, replay, and testing contract rather than compatibility with this pre-release shape.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: 简化 Web 图片输入第一版
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-29-simplify-web-image-input-v1.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
首个持久化 Web 图片输入切片在引入按顺序接收多张图片的必需能力时,也引入了由 CLI(命令行界面)挂载任意提供方、输出模态发现、替代文本、提供方无关的视觉 token 定价,以及没有跨包(package)消费方的浏览器生命周期 API 等推测性表面。保留这些推测性表面会把尚未选择的未来行为变成公共契约,并使初始能力更难评审和维护。
|
||||
|
||||
## 决策
|
||||
|
||||
第一版接受有序图片批次,并以可配置的每条消息图片数量与图片总字节数上限,以及单张图片字节数与像素上限约束批次。浏览器会在分配预览前拒绝声明不支持的格式,而宿主会以权威方式解码完整批次、检查当前部署上限、在不写入存储的情况下校验每张图片,之后才保存每张图片,并在生成的持久图片块中保留提交顺序。请求缓冲上限直接由附件服务的图片总字节数上限派生。这样无需添加批次事务、回滚协议或 `host.describe` 中的策略快照,就能保持校验的原子性。
|
||||
|
||||
提供方/模型选择仍属于配置和 profile 状态。启动组合会注册交付的 DeepSeek、OpenAI 和 Anthropic 路由;CLI 不会添加图片专用选择标志、检查 yml 提供方清单或动态挂载适配器。
|
||||
|
||||
确切模型元数据只携带当前准入决策会消费的输入模态。`ImageBlock` 携带持久附件引用;其可选显示名称提供无障碍 UI 文本,因此核心块没有单独的替代文本字段。与提供方无关的 token 估算不会把某一提供方的视觉定价公式应用于其他路由。
|
||||
|
||||
附件服务边界公开其限制、不触碰存储的 `validateImage`,以及 `saveImage` 和 `readImage`。宿主依赖该服务边界,而不是实现层重新导出的内容。浏览器草稿和历史图片操作仍是具体会话插件的内部实现;公开的 `IConversation` 表面只包含输入注册表,以及跨包边界使用的按作用域发送、取消和历史记录操作。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**只接受一张图片。** 比较或组合多张图片是当前产品要求。图片数量和总字节数上限使这条路径保持有界,而无需将其缩减为单张图片。
|
||||
|
||||
**添加存储事务或回滚协议。** 不触碰存储的校验能防止后面的畸形成员使前面有效的成员成为无引用对象。若要在独立的内容寻址对象之间实现更强的全有或全无存储保证,就需要当前产品路径并不需要的所有权或回收语义。
|
||||
|
||||
**将面向未来的字段和方法保留为占位符。** 输出模态、块替代文本和活跃模型握手数据目前都没有决策消费方。等到第一个消费方出现时再加入这些内容,可以保留选择正确契约的自由。
|
||||
|
||||
**使用一种图块公式估算每张图片。** 视觉定价因提供方、模型、细节模式和预处理而异。一项硬编码且与提供方无关的估算会看似权威,实际却是错误的;提供方用量才是权威核算来源。
|
||||
|
||||
**添加 CLI 提供方/模型选择或动态挂载。** 配置已经负责路由选择、插件组合和凭据。在图片输入标志中重复这些选择,会要求在 loader 之外解析或修改配置树。
|
||||
|
||||
## 后果
|
||||
|
||||
该功能保留了多图片提示词所需的两个批次上限和一个不触碰存储的校验方法,同时移除了无关的公开字段、生命周期操作、策略快照和路由组装分支。提供方/模型选择仍属于组合或 profile 配置。在设计出提供方感知型估算器之前,请求前的 token 压力计算可能少计视觉输入,而上报的用量仍保持精确。
|
||||
|
||||
重新引入任何已移除表面时,都必须有具体消费方,并为其定义失败、生命周期、回放和测试契约,而不是为了兼容这一预发布形态。
|
||||
@@ -138,7 +138,6 @@ export class AppCLIEntry {
|
||||
lanAddresses: readonly string[] = []
|
||||
|
||||
private patches: PatchOptions[] = []
|
||||
|
||||
constructor(private readonly options: AppCLIEntryOptions) {}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// @vitest-environment jsdom
|
||||
// Multimodal image surfaces over the BUILT client graph (the code-mode-fixture
|
||||
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
|
||||
// Opens the fixture history session whose turn 65 carries an image in BOTH a
|
||||
// user message and an assistant message, and pins the product surfaces: the
|
||||
// history ImageGallery loading real fixture bytes through the authorized
|
||||
// sessions.attachment route, the double-click ImageLightbox, and the composer
|
||||
// intake chain (paste → ordered thumbnail rail → image-only send enablement → remove).
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Boot the complete built client graph against one fixture branch. */
|
||||
function boot(search = '?fixture'): void {
|
||||
history.replaceState(null, '', `/${search}`)
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
/** Open the fixture history session (the alpha log carrying the turn-65 image pair) and wait for its gallery. */
|
||||
async function openFixtureSession(): Promise<void> {
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
const group = (await within(tree).findAllByText('fixture'))
|
||||
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
|
||||
.find(el => el?.getAttribute('aria-expanded') !== null)
|
||||
if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
|
||||
if (group.getAttribute('aria-expanded') === 'false') {
|
||||
fireEvent.click(within(group).getByText('fixture'))
|
||||
await waitFor(() => {
|
||||
expect(group.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
}
|
||||
const session = await within(tree).findByText('Fixture 历史会话')
|
||||
fireEvent.click(session)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelectorAll('[data-align] img').length).toBeGreaterThan(0)
|
||||
}, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
it('renders the history image pair through the authorized attachment route and opens the lightbox', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
// Both the user-side (align=end) and assistant-side (align=start) galleries
|
||||
// load real fixture bytes over sessions.attachment. jsdom provides
|
||||
// createObjectURL, so this environment MUST take the object-URL path — a
|
||||
// data: src here would mean the fallback ran where it should not.
|
||||
await waitFor(() => {
|
||||
if (document.querySelector('[data-align="end"] img') === null
|
||||
|| document.querySelector('[data-align="start"] img') === null) {
|
||||
throw new Error('history image galleries missing')
|
||||
}
|
||||
}, { timeout: 10_000 })
|
||||
const galleryShape = (align: string) => [...document.querySelectorAll(`[data-align="${align}"] img`)]
|
||||
.map(img => ({ alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0] }))
|
||||
expect({ user: galleryShape('end'), assistant: galleryShape('start') }).toMatchInlineSnapshot(`
|
||||
{
|
||||
"assistant": [
|
||||
{
|
||||
"alt": "fixture-image.png",
|
||||
"scheme": "blob",
|
||||
},
|
||||
],
|
||||
"user": [
|
||||
{
|
||||
"alt": "fixture-image.png",
|
||||
"scheme": "blob",
|
||||
},
|
||||
],
|
||||
}
|
||||
`)
|
||||
const userImage = document.querySelector<HTMLElement>('[data-align="end"] img')!
|
||||
|
||||
// Double-click opens the original-size lightbox; Escape/close dismisses it.
|
||||
const frame = userImage.closest('button')
|
||||
if (frame === null) throw new Error('image frame button missing')
|
||||
fireEvent.doubleClick(frame)
|
||||
const lightbox = await screen.findByRole('dialog')
|
||||
expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob')
|
||||
fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts pasted images into the composer rail in order and removes them', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
await screen.findByPlaceholderText('Choose a workspace to start', {}, { timeout: 10_000 })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' })
|
||||
.find(el => el.getAttribute('aria-haspopup') === 'menu')!)
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
|
||||
target: { value: 'image-input' },
|
||||
})
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
|
||||
|
||||
// Image-only send arming is pinned at package level (input-bar.spec.tsx);
|
||||
// this assembled lane pins the intake chain over the built graph.
|
||||
const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' })
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }],
|
||||
getData: () => '',
|
||||
},
|
||||
})
|
||||
|
||||
// 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="待发送图片"]')
|
||||
if (el === null) throw new Error('attachment rail missing')
|
||||
return el
|
||||
}, { timeout: 5_000 })
|
||||
expect([...rail.querySelectorAll('img')].map(img => ({
|
||||
alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0],
|
||||
}))).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"alt": "pasted.png",
|
||||
"scheme": "blob",
|
||||
},
|
||||
]
|
||||
`)
|
||||
|
||||
const second = new File([new Uint8Array([137, 80, 78, 71])], 'second.png', { type: 'image/png' })
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [{ kind: 'file', type: 'image/png', getAsFile: () => second }],
|
||||
getData: () => '',
|
||||
},
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt')))
|
||||
.toEqual(['pasted.png', 'second.png'])
|
||||
})
|
||||
|
||||
const remove = [...rail.querySelectorAll('button[aria-label^="移除图片"]')]
|
||||
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="待发送图片"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -205,7 +205,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/attachment/attachment-local/src/index.ts:26`](../packages/attachment/attachment-local/src/index.ts)
|
||||
Source: [`packages/attachment/attachment-local/src/index.ts:24`](../packages/attachment/attachment-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-bash-local`
|
||||
|
||||
|
||||
@@ -261,9 +261,8 @@ Immutable binary attachment service. Implementations validate bytes before publi
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Validate one image against the deployment policy without persisting anything.
|
||||
* Callers persisting a multi-image batch validate every member first so a
|
||||
* malformed member cannot leave earlier members as unreferenced objects.
|
||||
* Validate one image without persisting it.
|
||||
* Batch callers validate every member before saving any member.
|
||||
* @param input - encoded bytes, declared media type, and optional display name.
|
||||
*/
|
||||
abstract validateImage(input: SaveImageAttachment): void
|
||||
@@ -2086,7 +2085,7 @@ estimateMessage(message: Message): number
|
||||
|
||||
Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md)
|
||||
|
||||
Source: [`packages/llm/token-meter/src/index.ts:87`](../../packages/llm/token-meter/src/index.ts)
|
||||
Source: [`packages/llm/token-meter/src/index.ts:82`](../../packages/llm/token-meter/src/index.ts)
|
||||
|
||||
## `ctx.toolResultPrune` — `ToolResultPruneService`
|
||||
|
||||
|
||||
@@ -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/core-data-structures/attachment.md
|
||||
attachment.md: c4c974b075300025868e90f9905193d62d68c4ee
|
||||
attachment.zh.md: 12802a414018d081460459ad96ee4aa91801da62
|
||||
attachment.md: 566f0198ce6e797e270b63e3cfd255a0025d7135
|
||||
attachment.zh.md: 95d755407b3b2ea7ab5b109f2e78edea16ccdd9c
|
||||
@@ -69,4 +69,4 @@ interface StoredImageAttachment {
|
||||
}
|
||||
```
|
||||
|
||||
`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks (magic bytes, declared media type, size and pixel limits) without persisting anything — batch callers MUST validate every member through it before persisting any, so a rejected batch leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion.
|
||||
`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so admission rejection leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion.
|
||||
@@ -69,4 +69,4 @@ interface StoredImageAttachment {
|
||||
}
|
||||
```
|
||||
|
||||
`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行同一套准入检查(magic bytes、声明的媒体类型、大小与像素上限)但不落任何持久化——批量调用方必须先对每个成员通过它校验、再持久化任何一个,从而保证被拒绝的批次不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。
|
||||
`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此准入拒绝不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。
|
||||
@@ -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/core-data-structures/core.md
|
||||
core.md: c59f66290a35347e252d2ef07759bb9fb5d6019e
|
||||
core.zh.md: f5210c83c893d6a13ec8672d9ed4f3cd5d0c3abc
|
||||
core.md: b1fd258ba1dedc7f2b7cbe57b2f78b01329ed4e5
|
||||
core.zh.md: 0a18b19415bee610187a1a7ffd6905cf12dbfcb5
|
||||
@@ -116,7 +116,7 @@ interface ContentBlockMap {
|
||||
}
|
||||
```
|
||||
|
||||
The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md) plus optional alternative text), `ToolCallBlock` (`id: CallId`, `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)), `ToolCallBlock` (`id: CallId`, `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.
|
||||
|
||||
Source: [`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts)
|
||||
|
||||
@@ -206,8 +206,6 @@ interface LlmModelInfo {
|
||||
description?: string
|
||||
/** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */
|
||||
inputModalities?: readonly ModelModality[]
|
||||
/** Structured response modalities; absent means unknown, while an explicit omission is negative capability. */
|
||||
outputModalities?: readonly ModelModality[]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ interface ContentBlockMap {
|
||||
}
|
||||
```
|
||||
|
||||
各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图像附件](attachment.md),外加可选的替代文本)、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。
|
||||
各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图像附件](attachment.md))、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。
|
||||
|
||||
源码:[`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts)
|
||||
|
||||
@@ -212,8 +212,6 @@ interface LlmModelInfo {
|
||||
description?: string
|
||||
/** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */
|
||||
inputModalities?: readonly ModelModality[]
|
||||
/** Structured response modalities; absent means unknown, while an explicit omission is negative capability. */
|
||||
outputModalities?: readonly ModelModality[]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long.
@@ -10,8 +10,6 @@ import { readImageFile, saveImageFile, validateImageFile } from './store.ts'
|
||||
|
||||
export { detectImage } from './image.ts'
|
||||
export { readImageFile, saveImageFile, validateImageFile } from './store.ts'
|
||||
export { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
export type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
|
||||
/** Default maximum encoded bytes for one image. */
|
||||
export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
@@ -49,7 +49,6 @@ describe('local attachment service', () => {
|
||||
'base64',
|
||||
))
|
||||
expect(() => { service.validateImage({ data: valid, mediaType: 'image/png' }) }).not.toThrow()
|
||||
// Validation is storage-free: nothing below the root may exist yet.
|
||||
expect(existsSync(service.root)).toBe(false)
|
||||
} finally {
|
||||
await rm(dshHome, { recursive: true, force: true })
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md
|
||||
README.md: b8cee33b21caf5851f5c571ee7385fbd1b81dc97
|
||||
README.zh.md: 53107bd0f57b73043073295a9698df927e457365
|
||||
README.md: 4f450316294e554396adb9a8454051a08d9befd3
|
||||
README.zh.md: fe51b0003cdf1659c7c56106b97c6f3139ebe890
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
|
||||
|
||||
Unsent composer images remain browser-owned temporary drafts. `saveImage` is called only at message submission or while committing structured provider output, before any model-visible session event is published. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so one malformed member cannot strand earlier members as unreferenced objects (there is no garbage collection). `readImage` verifies the content-addressed object against its logged metadata.
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
|
||||
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。只有在提交消息或提交结构化提供方输出时,才会调用 `saveImage`,并且必须先于任何模型可见的会话事件发布。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象(系统不提供垃圾回收)。`readImage` 根据已记录的元数据校验内容寻址对象。
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -34,9 +34,8 @@ export abstract class AttachmentStore extends Service {
|
||||
abstract readonly imageLimits: ImageAttachmentLimits
|
||||
|
||||
/**
|
||||
* Validate one image against the deployment policy without persisting anything.
|
||||
* Callers persisting a multi-image batch validate every member first so a
|
||||
* malformed member cannot leave earlier members as unreferenced objects.
|
||||
* Validate one image without persisting it.
|
||||
* Batch callers validate every member before saving any member.
|
||||
* @param input - encoded bytes, declared media type, and optional display name.
|
||||
*/
|
||||
abstract validateImage(input: SaveImageAttachment): void
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface AssistantProvenanceView {
|
||||
export type AssistantBlock =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'reasoning'; text: string }
|
||||
| { kind: 'image'; attachment: ImageAttachmentRef; alt?: string }
|
||||
| { kind: 'image'; attachment: ImageAttachmentRef }
|
||||
| { kind: 'tool-call'; callId: string; name: string; argsRaw: string }
|
||||
| { kind: 'other'; block: unknown }
|
||||
|
||||
@@ -58,11 +58,7 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock {
|
||||
switch (block.type) {
|
||||
case 'text': return { kind: 'text', text: block.text }
|
||||
case 'reasoning': return { kind: 'reasoning', text: block.text }
|
||||
case 'image': return {
|
||||
kind: 'image',
|
||||
attachment: block.attachment,
|
||||
...block.alt === undefined ? {} : { alt: block.alt },
|
||||
}
|
||||
case 'image': return { kind: 'image', attachment: block.attachment }
|
||||
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
|
||||
default: return { kind: 'other', block }
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
return conversation
|
||||
}
|
||||
|
||||
/** Resolve package-internal attachment operations from the public service registration. */
|
||||
function concreteConversation(ctx: Context): ConversationService {
|
||||
const conversation = ctx.get('conversation') as ConversationService | undefined
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||
return conversation
|
||||
}
|
||||
|
||||
/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */
|
||||
function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null {
|
||||
return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null
|
||||
@@ -157,8 +164,7 @@ export function apply(ctx: Context): void {
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => {
|
||||
const conversation = ctx.get('conversation')
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||
const conversation = concreteConversation(ctx)
|
||||
return {
|
||||
views: {
|
||||
list: viewTabs,
|
||||
@@ -186,8 +192,7 @@ export function apply(ctx: Context): void {
|
||||
'conversation.input.model': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
inject: (sessionId: SessionId): ComposerBarInjected => {
|
||||
const conversation = ctx.get('conversation')
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||
const conversation = concreteConversation(ctx)
|
||||
const shell = inputHub.shell(sessionId)
|
||||
return {
|
||||
keyboard: shell,
|
||||
@@ -248,8 +253,7 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
|
||||
const conversation = ctx.get('conversation')
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||
const conversation = concreteConversation(ctx)
|
||||
const scoped = scopedConversation(sessions, sessionId)
|
||||
return {
|
||||
openDetails: (target) => {
|
||||
|
||||
@@ -7,9 +7,8 @@ import css from './MessageImage.module.css'
|
||||
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
|
||||
|
||||
/** Compact history renderer with retryable loading and double-click original preview. */
|
||||
export function MessageImage({ attachment, alt, load }: {
|
||||
export function MessageImage({ attachment, load }: {
|
||||
attachment: ImageAttachmentRef
|
||||
alt?: string
|
||||
load: ImageLoader
|
||||
}) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
@@ -34,7 +33,7 @@ export function MessageImage({ attachment, alt, load }: {
|
||||
return () => { live = false }
|
||||
}, [attachment, load])
|
||||
|
||||
const label = alt ?? attachment.name ?? '图片'
|
||||
const label = attachment.name ?? '图片'
|
||||
if (error) return <button type="button" className={css.error} onClick={request}>图片加载失败,点击重试</button>
|
||||
return (
|
||||
<>
|
||||
@@ -55,7 +54,7 @@ export function MessageImage({ attachment, alt, load }: {
|
||||
|
||||
/** Wrapping image group shared by user and assistant history. */
|
||||
export function ImageGallery({ images, load, align }: {
|
||||
images: readonly { attachment: ImageAttachmentRef; alt?: string }[]
|
||||
images: readonly { attachment: ImageAttachmentRef }[]
|
||||
load: ImageLoader
|
||||
align: 'start' | 'end'
|
||||
}) {
|
||||
|
||||
@@ -23,18 +23,17 @@ type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
|
||||
|
||||
function contentParts(content: readonly unknown[]): {
|
||||
text: string
|
||||
images: { attachment: UserImage['attachment']; alt?: string }[]
|
||||
images: { attachment: UserImage['attachment'] }[]
|
||||
rest: unknown[]
|
||||
} {
|
||||
const texts: string[] = []
|
||||
const images: { attachment: UserImage['attachment']; alt?: string }[] = []
|
||||
const images: { attachment: UserImage['attachment'] }[] = []
|
||||
const rest: unknown[] = []
|
||||
for (const block of content) {
|
||||
const b = block as { type?: string; text?: string; attachment?: unknown; alt?: string }
|
||||
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) {
|
||||
const image = b as UserImage
|
||||
images.push({ attachment: image.attachment, ...image.alt === undefined ? {} : { alt: image.alt } })
|
||||
images.push({ attachment: (b as UserImage).attachment })
|
||||
}
|
||||
else rest.push(block)
|
||||
}
|
||||
|
||||
@@ -151,7 +151,12 @@ export class InputHub implements InputService {
|
||||
// see them — release the drafts here instead of resurrecting them onto
|
||||
// a dead instance where they would leak for the page lifetime.
|
||||
if (this.shells.get(session.sessionId) === shell) {
|
||||
shell?.restoreImages(imageIds)
|
||||
if (shell?.snapshot.imageIds.length === 0) {
|
||||
shell.restoreImages(imageIds)
|
||||
} else {
|
||||
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
|
||||
for (const id of imageIds) conversation?.releaseDraftImage(id)
|
||||
}
|
||||
if (shell?.snapshot.draft === '') shell.setDraft(text)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -29,10 +29,9 @@ export interface IConversation {
|
||||
* Send a prompt into the caller scope's session.
|
||||
* @param text - prompt text, sent verbatim as one text block.
|
||||
* @param mode - queue after the current turn, or steer into it.
|
||||
* @param images - browser-owned temporary images promoted by the host during this call.
|
||||
* @returns completion; business failures reject (and land in promptError).
|
||||
*/
|
||||
send(text: string, mode: 'queue' | 'steer', images?: readonly File[]): Promise<void>
|
||||
send(text: string, mode: 'queue' | 'steer'): Promise<void>
|
||||
/**
|
||||
* Cancel the scoped session's in-flight turn.
|
||||
* @returns completion; failures reject as in send.
|
||||
@@ -43,53 +42,11 @@ export interface IConversation {
|
||||
* @returns completion of the page pull.
|
||||
*/
|
||||
loadOlder(): Promise<void>
|
||||
/**
|
||||
* Create runtime-only draft attachments and preview URLs.
|
||||
* @param files - browser-owned image files.
|
||||
* @returns ordered descriptors for the input state.
|
||||
*/
|
||||
createDraftImages(files: readonly File[]): readonly ComposerAttachment[]
|
||||
/**
|
||||
* Resolve ordered draft ids to runtime-owned attachments.
|
||||
* @param ids - ordered composer attachment ids.
|
||||
* @returns attachments still available in this browser runtime.
|
||||
*/
|
||||
draftImages(ids: readonly string[]): readonly ComposerAttachment[]
|
||||
/**
|
||||
* Release one draft attachment and its preview URL.
|
||||
* @param id - draft-local attachment id.
|
||||
*/
|
||||
releaseDraftImage(id: string): void
|
||||
/**
|
||||
* Resolve a session-authorized historical image to an object URL.
|
||||
* @param sessionId - session whose durable log grants the read.
|
||||
* @param attachment - durable image reference from that log.
|
||||
* @returns browser URL for inline and original-size rendering.
|
||||
*/
|
||||
resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string>
|
||||
/**
|
||||
* Release every historical image URL owned by one rendered session.
|
||||
* @param sessionId - session whose rendered image scope is ending.
|
||||
*/
|
||||
releaseSessionImages(sessionId: SessionId): void
|
||||
}
|
||||
|
||||
/** Opaque wrapper keeps browser `File` internals outside persisted store state. */
|
||||
class BrowserDraftAttachment implements ComposerAttachment {
|
||||
readonly kind = 'image' as const
|
||||
readonly id: string
|
||||
readonly previewUrl: string
|
||||
readonly #file: File
|
||||
|
||||
constructor(file: File) {
|
||||
this.id = crypto.randomUUID()
|
||||
this.previewUrl = URL.createObjectURL(file)
|
||||
this.#file = file
|
||||
}
|
||||
|
||||
get file(): File {
|
||||
return this.#file
|
||||
}
|
||||
/** Create one browser-only draft descriptor; only its id enters input state. */
|
||||
function browserDraftAttachment(file: File): ComposerAttachment {
|
||||
return { kind: 'image', id: crypto.randomUUID(), previewUrl: URL.createObjectURL(file), file }
|
||||
}
|
||||
|
||||
interface ImageUrlEntry {
|
||||
@@ -102,7 +59,7 @@ interface ImageUrlEntry {
|
||||
export class ConversationService extends Service implements IConversation {
|
||||
/** The per-session input machine registry (InputService face, design §5.2). */
|
||||
readonly input: InputService
|
||||
private readonly draftAttachments = new Map<string, BrowserDraftAttachment>()
|
||||
private readonly draftAttachments = new Map<string, ComposerAttachment>()
|
||||
private readonly imageUrls = new Map<string, ImageUrlEntry>()
|
||||
private readonly imageGenerations = new Map<SessionId, number>()
|
||||
private readonly createdImageUrls = new Set<string>()
|
||||
@@ -131,11 +88,10 @@ export class ConversationService extends Service implements IConversation {
|
||||
* exists for caller choreography (the composer restores the draft on it).
|
||||
* @param text - prompt text, sent verbatim as one text block when non-empty.
|
||||
* @param mode - queue after the current turn, or steer into it.
|
||||
* @param images - browser-owned temporary images promoted by the host during this call.
|
||||
*/
|
||||
async send(text: string, mode: 'queue' | 'steer', images: readonly File[] = []): Promise<void> {
|
||||
async send(text: string, mode: 'queue' | 'steer'): Promise<void> {
|
||||
const session = this.scopedSession('send')
|
||||
await this.sendFiles(session, text, mode, images)
|
||||
await this.sendFiles(session, text, mode, [])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,7 +137,7 @@ export class ConversationService extends Service implements IConversation {
|
||||
createDraftImages(files: readonly File[]): readonly ComposerAttachment[] {
|
||||
for (const file of files) imageMediaType(file.type)
|
||||
return files.map((file) => {
|
||||
const attachment = new BrowserDraftAttachment(file)
|
||||
const attachment = browserDraftAttachment(file)
|
||||
this.draftAttachments.set(attachment.id, attachment)
|
||||
this.createdImageUrls.add(attachment.previewUrl)
|
||||
return attachment
|
||||
|
||||
@@ -48,14 +48,14 @@ describe('MessageImage', () => {
|
||||
<AssistantMarkdown
|
||||
blocks={[
|
||||
{ kind: 'text', text: 'before' },
|
||||
{ kind: 'image', attachment, alt: 'middle' },
|
||||
{ kind: 'image', attachment },
|
||||
{ kind: 'text', text: 'after' },
|
||||
]}
|
||||
streaming={false}
|
||||
loadImage={() => Promise.resolve('blob:middle')}
|
||||
/>,
|
||||
)
|
||||
const image = await view.findByAltText('middle')
|
||||
const image = await view.findByAltText('history.png')
|
||||
const before = view.getByText('before')
|
||||
const after = view.getByText('after')
|
||||
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
|
||||
@@ -644,7 +644,6 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
|
||||
case 'image': return {
|
||||
type: 'image',
|
||||
content: stringifySourceValue(block.attachment),
|
||||
...(block.alt !== undefined ? { imageAlt: block.alt } : {}),
|
||||
}
|
||||
case 'other': return sourceBlock(block.block)
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
methods: [
|
||||
{
|
||||
signature: 'abstract validateImage(input: SaveImageAttachment): void',
|
||||
jsDoc: '/**\n * Validate one image against the deployment policy without persisting anything.\n * Callers persisting a multi-image batch validate every member first so a\n * malformed member cannot leave earlier members as unreferenced objects.\n * @param input - encoded bytes, declared media type, and optional display name.\n */',
|
||||
jsDoc: '/**\n * Validate one image without persisting it.\n * Batch callers validate every member before saving any member.\n * @param input - encoded bytes, declared media type, and optional display name.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>',
|
||||
@@ -1876,7 +1876,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ImageBlock',
|
||||
declaration: 'export interface ImageBlock {\n type: \'image\';\n attachment: ImageAttachmentRef;\n alt?: string;\n}',
|
||||
declaration: 'export interface ImageBlock {\n type: \'image\';\n attachment: ImageAttachmentRef;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ImageMediaType',
|
||||
@@ -1936,7 +1936,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'LlmModelInfo',
|
||||
declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n inputModalities?: readonly ModelModality[];\n outputModalities?: readonly ModelModality[];\n}',
|
||||
declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n inputModalities?: readonly ModelModality[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmModelReasoningInfo',
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
|
||||
@@ -11,8 +11,8 @@ import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment-local'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment-local'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
@@ -80,20 +80,17 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten
|
||||
return content.map(part => ({ type: 'text', text: part.text }))
|
||||
}
|
||||
const limits = ctx.attachments.imageLimits
|
||||
if (content.filter(part => part.type === 'image').length > limits.maxImagesPerMessage) {
|
||||
throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
|
||||
}
|
||||
const prepared = content.map(part => part.type === 'text'
|
||||
? part
|
||||
: { part, data: decodeBase64(part.data) })
|
||||
const images = prepared.filter((part): part is Extract<typeof part, { data: Uint8Array }> => 'data' in part)
|
||||
if (images.length > limits.maxImagesPerMessage) {
|
||||
throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
|
||||
}
|
||||
const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0)
|
||||
if (totalBytes > limits.maxMessageImageBytes) {
|
||||
throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
|
||||
}
|
||||
// Validate the complete batch before persisting any member: the store has no
|
||||
// garbage collection, so one malformed image must not leave the batch's
|
||||
// valid members as published objects no message event will ever reference.
|
||||
for (const image of images) {
|
||||
ctx.attachments.validateImage({
|
||||
data: image.data,
|
||||
@@ -108,7 +105,7 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten
|
||||
mediaType: item.part.mediaType,
|
||||
...item.part.name === undefined ? {} : { name: item.part.name },
|
||||
})
|
||||
return { type: 'image', attachment, ...item.part.alt === undefined ? {} : { alt: item.part.alt } }
|
||||
return { type: 'image', attachment }
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1222,8 +1219,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const target = targetFor(agent).current
|
||||
const provider = target.provider
|
||||
const model = target.model
|
||||
const activeModel = await ctx.llm.resolveModelInfo(provider, model)
|
||||
if (activeModel.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) {
|
||||
const modelInfo = await ctx.llm.resolveModelInfo(provider, model)
|
||||
if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: `Model "${model}" does not support image input.`,
|
||||
|
||||
@@ -210,7 +210,7 @@ export const imageMediaTypeSchema = z.union([
|
||||
/** Prompt wire content is intentionally narrower than merge-extensible durable core content. */
|
||||
export const promptContentPartSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('text'), text: z.string() }),
|
||||
z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional(), alt: z.string().optional() }),
|
||||
z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional() }),
|
||||
])
|
||||
|
||||
/** session.prompt request payload. */
|
||||
|
||||
@@ -162,7 +162,7 @@ export interface SessionSummary {
|
||||
/** Browser-submitted prompt content; image bytes are promoted to durable references by the host. */
|
||||
export type PromptContentPart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image'; mediaType: ImageMediaType; data: string; name?: string; alt?: string }
|
||||
| { type: 'image'; mediaType: ImageMediaType; data: string; name?: string }
|
||||
|
||||
/** Session-domain unary methods (the map keys session.* of RpcMethodMap). */
|
||||
export interface SessionsApi {
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* models, and the prompt-assembly boundary for a running selection change.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
|
||||
LlmResolvedModelInfo, StreamChunk,
|
||||
LlmResolvedModelInfo, StreamChunk, UserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -118,6 +118,69 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
|
||||
}
|
||||
|
||||
describe('Web session model selection', () => {
|
||||
it('accepts ordered multi-image prompts and rejects configured batch-limit excess before persistence', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
const validateImage = vi.fn((_input: { data: Uint8Array }): void => {})
|
||||
const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => {
|
||||
return Promise.resolve({
|
||||
attachmentId: `att-${String(input.data[0])}`,
|
||||
mediaType: input.mediaType,
|
||||
bytes: input.data.byteLength,
|
||||
width: 1,
|
||||
height: 1,
|
||||
...(input.name === undefined ? {} : { name: input.name }),
|
||||
})
|
||||
})
|
||||
const followup = vi.fn((_message: UserMessage): void => {})
|
||||
Object.assign(agent, { followup })
|
||||
ctx.provide('attachments', {
|
||||
imageLimits: { maxImageBytes: 4, maxImagesPerMessage: 2, maxMessageImageBytes: 4, maxImagePixels: 4, mediaTypes: ['image/png'] },
|
||||
validateImage,
|
||||
saveImage,
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const first = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' }
|
||||
const second = { type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==', name: 'second.png' }
|
||||
const accepted = await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [first, { type: 'text' as const, text: 'compare' }, second],
|
||||
}))
|
||||
expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
|
||||
expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
|
||||
expect(followup.mock.calls[0]?.[0].content).toEqual([
|
||||
{ type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png' } },
|
||||
{ type: 'text', text: 'compare' },
|
||||
{ type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'second.png' } },
|
||||
])
|
||||
|
||||
const tooMany = await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [first, second, first],
|
||||
}))
|
||||
expect(tooMany.result).toMatchObject({
|
||||
ok: false, error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } },
|
||||
})
|
||||
const tooLarge = await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [
|
||||
{ ...first, data: 'AQID' },
|
||||
{ ...second, data: 'BAUG' },
|
||||
],
|
||||
}))
|
||||
expect(tooLarge.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'attachment-error', details: { reason: 'IMAGES_TOO_LARGE' } },
|
||||
})
|
||||
expect(validateImage).toHaveBeenCalledTimes(2)
|
||||
expect(saveImage).toHaveBeenCalledTimes(2)
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
|
||||
const { ctx, sessionId } = await harness({
|
||||
provider: 'deepseek',
|
||||
|
||||
@@ -7,12 +7,6 @@ import { RpcId } from '../src/api/rpc.ts'
|
||||
import { toFetchHandler } from '../src/fetch/handler.ts'
|
||||
import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface ModelModalityMap {
|
||||
audio: 'audio'
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal in-memory ApiProxy: echoes rpcIds, scripts one frame per stream. */
|
||||
function fakeApi(overrides: Partial<{
|
||||
muxFrames: MuxFrame[]
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
{
|
||||
"path": "../../attachment/attachment"
|
||||
},
|
||||
{
|
||||
"path": "../../attachment/attachment-local"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
@@ -74,7 +74,6 @@ function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo
|
||||
name: model.name ?? model.id,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
inputModalities: ['text'],
|
||||
outputModalities: ['text'],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +170,7 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
// capability — "unknown" here would let the host accept and persist
|
||||
// images the serializer must then reject.
|
||||
...configured === undefined
|
||||
? { provider, id: model, name: model, inputModalities: ['text' as const], outputModalities: ['text' as const] }
|
||||
? { provider, id: model, name: model, inputModalities: ['text' as const] }
|
||||
: modelInfo(provider, configured),
|
||||
...contextWindow === undefined ? {} : { context: { contextWindow } },
|
||||
...this.options.defaults?.thinking === 'disabled'
|
||||
|
||||
@@ -658,8 +658,8 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'], outputModalities: ['text'] },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'], outputModalities: ['text'] },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] },
|
||||
])
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
|
||||
.resolves.toMatchObject({
|
||||
@@ -762,8 +762,8 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'], outputModalities: ['text'] },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'], outputModalities: ['text'] },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -784,8 +784,8 @@ describe('plugin registration and config', () => {
|
||||
],
|
||||
})
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'private-fast', name: 'private-fast', inputModalities: ['text'], outputModalities: ['text'] },
|
||||
{ provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'], outputModalities: ['text'] },
|
||||
{ provider: 'deepseek', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] },
|
||||
{ provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'] },
|
||||
])
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'private-fast'))
|
||||
.resolves.toMatchObject({ context: { contextWindow: 32_000 } })
|
||||
|
||||
@@ -130,7 +130,6 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
inputModalities: [...model.input],
|
||||
outputModalities: ['text'],
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -155,7 +154,6 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
id: model,
|
||||
name: resolvedModel.name,
|
||||
inputModalities: [...resolvedModel.input],
|
||||
outputModalities: ['text'],
|
||||
context: { contextWindow: resolvedModel.contextWindow },
|
||||
reasoning: {
|
||||
efforts: levels.map(level => ({
|
||||
|
||||
@@ -440,7 +440,7 @@ describe('provider profile lifecycle', () => {
|
||||
const models = await ctx.llm.listModels('openai')
|
||||
expect(models.find(model => model.id === 'gpt-4.1')).toEqual({
|
||||
provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1',
|
||||
inputModalities: ['text', 'image'], outputModalities: ['text'],
|
||||
inputModalities: ['text', 'image'],
|
||||
})
|
||||
expect(models.every(model => model.provider === 'openai')).toBe(true)
|
||||
const info = await ctx.llm.resolveModelInfo('openai', 'gpt-4.1')
|
||||
|
||||
@@ -228,7 +228,7 @@ for (const profile of providerCases) {
|
||||
type: 'text',
|
||||
text: 'What type of machine-readable symbol is shown in the attached image? Reply with exactly: QR code',
|
||||
},
|
||||
{ type: 'image', attachment: ref, alt: 'machine-readable symbol' },
|
||||
{ type: 'image', attachment: ref },
|
||||
],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})],
|
||||
|
||||
@@ -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: ecd6da304a894a687153608f5b468f867ad32e6b
|
||||
README.zh.md: c43da45bf0c573a78c194d731bff1d9765b1eed6
|
||||
README.md: f6bf6ed88cc3aa21c57b5a08249848bdfff2f0c1
|
||||
README.zh.md: e466568de5a57b36dcc9fb0bd876ec2216a6c5f3
|
||||
@@ -23,7 +23,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
|
||||
|
||||
Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity or modality metadata fails with `INVALID_MODEL_INFO`, and invalid context or reasoning metadata with `INVALID_MODEL_CONTEXT` or `INVALID_MODEL_REASONING`.
|
||||
Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity metadata fails with `INVALID_MODEL_INFO`, and invalid context or reasoning metadata with `INVALID_MODEL_CONTEXT` or `INVALID_MODEL_REASONING`.
|
||||
|
||||
Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。
|
||||
|
||||
确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份或模态元数据会以 `INVALID_MODEL_INFO` 失败,无效的上下文或推理元数据则以 `INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。
|
||||
确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份元数据会以 `INVALID_MODEL_INFO` 失败,无效的上下文或推理元数据则以 `INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。
|
||||
|
||||
推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速结束。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
|
||||
|
||||
|
||||
@@ -254,26 +254,9 @@ export class LlmService extends Service {
|
||||
return this.registration(provider).retryPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate adapter-owned modality arrays and detach them. One rule for the
|
||||
* advisory catalog and exact resolution: both validate, both copy — two
|
||||
* readings of the same adapter field with different trust or detachment
|
||||
* would be an unexplained asymmetry.
|
||||
* @param provider - provider route (diagnostic context).
|
||||
* @param code - error code matching the calling surface.
|
||||
* @param modalities - adapter-owned array, or undefined for unknown.
|
||||
* @returns a detached copy, or undefined when absent.
|
||||
*/
|
||||
private detachedModalities(
|
||||
provider: string,
|
||||
code: 'INVALID_CATALOG' | 'INVALID_MODEL_INFO',
|
||||
modalities: readonly unknown[] | undefined,
|
||||
): ModelModality[] | undefined {
|
||||
if (modalities === undefined) return undefined
|
||||
if (!Array.isArray(modalities) || modalities.some(entry => typeof entry !== 'string')) {
|
||||
throw new LlmError(`adapter returned invalid modality metadata for provider "${provider}"`, code)
|
||||
}
|
||||
return [...(modalities as readonly ModelModality[])]
|
||||
/** Detach typed adapter-owned modality metadata. */
|
||||
private detachedModalities(modalities: readonly ModelModality[] | undefined): ModelModality[] | undefined {
|
||||
return modalities === undefined ? undefined : [...modalities]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,15 +283,13 @@ export class LlmService extends Service {
|
||||
throw new LlmError(`adapter returned invalid or duplicate model metadata for provider "${provider}"`, 'INVALID_CATALOG')
|
||||
}
|
||||
seen.add(model.id)
|
||||
const inputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.inputModalities)
|
||||
const outputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.outputModalities)
|
||||
const inputModalities = this.detachedModalities(model.inputModalities)
|
||||
return {
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
...inputModalities === undefined ? {} : { inputModalities },
|
||||
...outputModalities === undefined ? {} : { outputModalities },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -360,15 +341,13 @@ export class LlmService extends Service {
|
||||
}
|
||||
// Capability metadata rides through: an explicit modality omission is
|
||||
// negative capability downstream preflights act on (image admission).
|
||||
const inputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.inputModalities)
|
||||
const outputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.outputModalities)
|
||||
const inputModalities = this.detachedModalities(resolved.inputModalities)
|
||||
const info: LlmResolvedModelInfo = {
|
||||
provider,
|
||||
id: model,
|
||||
name: resolved.name,
|
||||
...resolved.description === undefined ? {} : { description: resolved.description },
|
||||
...inputModalities === undefined ? {} : { inputModalities },
|
||||
...outputModalities === undefined ? {} : { outputModalities },
|
||||
...context === undefined ? {} : { context: { contextWindow: context.contextWindow } },
|
||||
}
|
||||
const reasoning = resolved.reasoning
|
||||
|
||||
@@ -57,8 +57,6 @@ export interface ImageBlock {
|
||||
type: 'image'
|
||||
/** Immutable bytes and intrinsic display metadata owned by the attachment service. */
|
||||
attachment: ImageAttachmentRef
|
||||
/** Optional provider- and UI-facing alternative text, carried from the prompt wire's image part. */
|
||||
alt?: string
|
||||
}
|
||||
|
||||
/** A tool invocation requested by the model. */
|
||||
@@ -156,8 +154,6 @@ export interface LlmModelInfo {
|
||||
description?: string
|
||||
/** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */
|
||||
inputModalities?: readonly ModelModality[]
|
||||
/** Structured response modalities; absent means unknown, while an explicit omission is negative capability. */
|
||||
outputModalities?: readonly ModelModality[]
|
||||
}
|
||||
|
||||
/** Provider-owned context capacity for one exact provider/model route. */
|
||||
|
||||
@@ -857,8 +857,6 @@ describe('LlmService', () => {
|
||||
[{ provider: 'route', id: 'model', name: 1 }, 'non-string name'],
|
||||
[{ provider: 'route', id: 'model', name: '' }, 'empty name'],
|
||||
[{ provider: 'route', id: 'model', name: 'Model', description: 1 }, 'non-string description'],
|
||||
[{ provider: 'route', id: 'model', name: 'Model', inputModalities: 'text' }, 'non-array input modalities'],
|
||||
[{ provider: 'route', id: 'model', name: 'Model', outputModalities: [1] }, 'non-string output modality'],
|
||||
] as const)('rejects invalid exact model metadata (%s: %s)', async (metadata, _label) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -880,7 +878,7 @@ describe('LlmService', () => {
|
||||
override resolveModel(): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({
|
||||
provider: 'route', id: 'model', name: 'Model',
|
||||
inputModalities: ['text', 'image'], outputModalities: ['text'],
|
||||
inputModalities: ['text', 'image'],
|
||||
})
|
||||
}
|
||||
}(SCRIPT)
|
||||
@@ -890,7 +888,7 @@ describe('LlmService', () => {
|
||||
// rebuild that drops it silently reads as "modalities unknown".
|
||||
await expect(ctx.llm.resolveModelInfo('route', 'model')).resolves.toEqual({
|
||||
provider: 'route', id: 'model', name: 'Model',
|
||||
inputModalities: ['text', 'image'], outputModalities: ['text'],
|
||||
inputModalities: ['text', 'image'],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1177,8 +1175,6 @@ describe('LlmService', () => {
|
||||
[{ provider: 'route', id: 'm', name: 1 }, 'non-string name'],
|
||||
[{ provider: 'route', id: 'm', name: '' }, 'empty name'],
|
||||
[{ provider: 'route', id: 'm', name: 'M', description: 1 }, 'non-string description'],
|
||||
[{ provider: 'route', id: 'm', name: 'M', inputModalities: 'text' }, 'non-array input modalities'],
|
||||
[{ provider: 'route', id: 'm', name: 'M', outputModalities: [1] }, 'non-string output modality'],
|
||||
] as const)('rejects invalid model metadata (%s: %s)', async (metadata, _label) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
@@ -25,11 +25,6 @@ const CHARS_PER_TOKEN = 4
|
||||
/** Per-block structural overhead for JSON framing and type tags. */
|
||||
const BLOCK_OVERHEAD = 4
|
||||
|
||||
/** Provider-neutral visual estimate: base cost plus one cost unit per 512px tile. */
|
||||
const IMAGE_BASE_TOKENS = 85
|
||||
const IMAGE_TILE_TOKENS = 170
|
||||
const IMAGE_TILE_EDGE = 512
|
||||
|
||||
/** Role-field framing overhead added to every priced message. */
|
||||
const ROLE_OVERHEAD = 4
|
||||
|
||||
@@ -362,12 +357,6 @@ export class TokenMeterService extends Service {
|
||||
case 'reasoning':
|
||||
tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
|
||||
break
|
||||
case 'image': {
|
||||
const tiles = Math.ceil(block.attachment.width / IMAGE_TILE_EDGE)
|
||||
* Math.ceil(block.attachment.height / IMAGE_TILE_EDGE)
|
||||
tokens += IMAGE_BASE_TOKENS + tiles * IMAGE_TILE_TOKENS + BLOCK_OVERHEAD
|
||||
break
|
||||
}
|
||||
case 'tool-call':
|
||||
tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN)
|
||||
+ Math.ceil(block.arguments.length / CHARS_PER_TOKEN)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { createUserMessage, CallId, createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
@@ -129,16 +128,6 @@ describe('TokenMeterService pricing', () => {
|
||||
const blocks: ContentBlock[] = [
|
||||
{ type: 'text', text: 'abcd' },
|
||||
{ type: 'reasoning', text: 'ab' },
|
||||
{
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1024,
|
||||
height: 513,
|
||||
},
|
||||
},
|
||||
{ type: 'tool-call', id: CallId('c'), name: 'read', arguments: '{"x":1}' },
|
||||
{
|
||||
type: 'tool-result',
|
||||
@@ -152,7 +141,7 @@ describe('TokenMeterService pricing', () => {
|
||||
role: 'assistant', content: blocks,
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
expect(estimated).toBe(813)
|
||||
expect(estimated).toBeGreaterThan(30)
|
||||
expect(service.estimateMessage(textMessage('abcd'))).toBe(9)
|
||||
})
|
||||
|
||||
|
||||
Generated
-3
@@ -2983,9 +2983,6 @@ importers:
|
||||
'@deepseek-ai/dsh-attachment':
|
||||
specifier: workspace:^
|
||||
version: link:../../attachment/attachment
|
||||
'@deepseek-ai/dsh-attachment-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../attachment/attachment-local
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
|
||||
Reference in New Issue
Block a user