Merge master into worktree/custom-model-image-input-0185de
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# 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
|
# 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: f1fbcbd29b188505e647265c4c974886c066e936
|
2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 8639a1ab638c85fc01a29083a1b81eacdc2152d4
|
||||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: a55467cacc412b6f313dd932a0e4868965c039ea
|
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 1090edd06a62d70a736c85eb1c7d9e6edba187c8
|
||||||
+1
-1
@@ -140,7 +140,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state
|
|||||||
|
|
||||||
### Limits and trust boundaries
|
### Limits and trust boundaries
|
||||||
|
|
||||||
Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (32 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end.
|
Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 20 images and 100 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (160 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end.
|
||||||
|
|
||||||
Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser.
|
Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -140,7 +140,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme
|
|||||||
|
|
||||||
### 限制与信任边界
|
### 限制与信任边界
|
||||||
|
|
||||||
第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 32 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。
|
第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 20 张图片和 100 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 160 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。
|
||||||
|
|
||||||
格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。
|
格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。
|
||||||
|
|
||||||
|
|||||||
+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/feature/2026-08-12-web-image-intake-and-limits-alignment.md
|
||||||
|
2026-08-12-web-image-intake-and-limits-alignment.md: 2f8b99bb4850d9875dcba0a03ae8ad9f340d1506
|
||||||
|
2026-08-12-web-image-intake-and-limits-alignment.zh.md: 62d5ebd54275ae8de0e0b9ba701ba34042dfcde7
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Agent Note: Whole-page image drop, projected intake limits, and thumbnail tiling
|
||||||
|
|
||||||
|
Status: implemented
|
||||||
|
|
||||||
|
English | [中文](2026-08-12-web-image-intake-and-limits-alignment.zh.md)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The second alignment step for issue #2248, after the [attachment display note](2026-08-11-web-attachment-display-alignment.md) (whose rail/toast/atoms decisions stand; this note supersedes its history-gallery geometry and the lightbox backdrop specifics). Remaining gaps against DeepSeek Chat: images could only be dropped on the composer card — a drop over the transcript navigated the browser away to the file; the lightbox close glyph was a bare `×` text character (buttons inherit no font family and the glyph's ink sits above the line box center, so it rendered visibly off-center) over a `color-mix(label-primary 74%)` backdrop that inverts to a bright white wash in dark mode; a message's images stacked vertically as up-to-240px blocks because the gallery container itself was pinned to 240px; and nothing client-side enforced or displayed the image limits — a user could stack 50 images and learn about `maxImagesPerMessage` only from a raw `attachment-error (TOO_MANY_IMAGES)` toast after submit, watching the rail empty and refill around the failure.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Whole-page drop.** InputBar binds `dragenter`/`dragover`/`dragleave`/`drop` on the document (enter/leave depth counting, viewport-edge and `dragend` resets, `Files`-type gating so text drags keep their native textarea path) and renders the new `DropOverlay` atom in `ui-attachment`: a body-portaled, pointer-inert full-viewport layer (DeepSeek Chat's DragMask visuals — white/70% + 10px blur, dark `rgba(39,39,48,0.7)`, illustration, title, limits line) whose `disabled` variant announces a locked/busy composer. Pointer-inertness is load-bearing: drag events keep targeting the page below, so the depth count never sees the overlay itself. Document-level listener state is safe because the composer-bar slot is `kind: 'single'`.
|
||||||
|
|
||||||
|
**Lightbox.** The close control is `ui-primitives`' `IconCloseOutline16` (the Modal precedent — an SVG centered in its viewBox needs no font metrics). The backdrop is the shared dialog mask (`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`, black-based in both themes) painted on a separate sibling layer, because `backdrop-filter` on the container would blur the previewed image itself.
|
||||||
|
|
||||||
|
**History thumbnails (DeepSeek Chat rules).** A message's lone image renders at 240px on its long edge with the displayed ratio clamped to [0.25, 4], cropped by `cover` with the anchor at the top of very tall images and the left of very wide ones, never upscaled; several images render as fixed 64px square tiles in one wrapping row (10px gap, user messages right-aligned). Consecutive assistant `image` blocks merge into one gallery so they tile instead of each opening a one-image row.
|
||||||
|
|
||||||
|
**Limits aligned and projected.** Defaults are 20 images / 5 MiB per image / 100 MiB aggregate (`attachment-local`), with the HTTP carrier cap raised to one shared `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB` (http-bridge, previously two independent 32 MiB literals) to satisfy the load-time capacity assertion (aggregate × 4/3 + headroom ≈ 134.3 MiB). Consumer products cluster at 10–20 attachments (ChatGPT 10, Gemini 10, Claude 20; DeepSeek Chat's 50 is the outlier), and a vision-model image costs roughly 1300–4800 tokens, so 50 images can fill a 200k context in one message. The 5 MiB per-image default admits images across Anthropic routes that impose either a 5 MiB or 10 MiB maximum; deployments using only routes with the larger limit can override it. A 512 MiB aggregate cannot pass this transport because base64-in-JSON would need a single JSON string past V8's ~512 MiB string ceiling. The limits reach clients as the `imageLimits` session projection — a constant-per-boot unit (`apply` returns the same state reference, so baselines alone carry it and no change frames exist) registered by **apiproxy**, not the attachment Service Definition: `dsh-llm` depends on `dsh-attachment` (`ImageBlock` → `ImageAttachmentRef`), so the seam package referencing `dsh-session-projection` (whose graph reaches `dsh-llm` through `dsh-session`) closes a project-reference cycle, and the per-message count/aggregate rules the value describes are the proxy's own admission checks anyway. The `SessionProjectionMap` merge rides the proxy's sessions wire-contract file, which every client program already includes through the carrier's type re-exports.
|
||||||
|
|
||||||
|
**Intake pre-check and error copy.** Both intake gestures converge on one `intakeImages` wrapper in InputBar that checks count, per-image bytes, and aggregate bytes against the projection before `addImages`: a violating batch is refused whole (DeepSeek Chat semantics) with an immediate banner naming the limit — no submit-time rollback theater. The host checks stay as the backstop for callers that bypass the composer. Banner copy follows one principle the user set: reasons a user can act on (model without vision, count, size, resolution, format — now a positive list of supported formats instead of echoing the rejected MIME type) get product sentences naming the way out; reasons they cannot act on (corrupt base64, lost references, read failures) fold into one send-failed sentence that keeps the reason code, because the product currently faces developers and a reportable code beats a dead end. Non-attachment error codes keep the raw message + code presentation.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**Registering the projection unit in the attachment Service Definition's constructor.** The natural seam owner, and the first implementation — rejected by the dependency graph (the cycle above) and by a test-harness interaction: the base constructor calling `ctx.inject` made directly-constructed stores in specs trigger the global invariant host, which then double-mounted an `attachments` service into the same root.
|
||||||
|
|
||||||
|
**`--dsw-alias-bg-mask-photo` (0.88 black, theme-stable, unused) for the lightbox.** The design system's photo-viewer token and dsweb's likely lightbox wash; the user chose consistency with the settings dialog mask (`bg-mask-1` + blur) — both fix the dark-mode inversion.
|
||||||
|
|
||||||
|
**Pre-checking inside `apply.ts`'s `addImages` inject.** The seam-purist placement, rejected for plumbing cost: the projection store has no non-React face exposed to the inject factory, while InputBar already consumes projections idiomatically and is the single caller of both gestures.
|
||||||
|
|
||||||
|
**A `host.describe` field instead of a projection.** Session-independent and cheaper, but delivered through an injected prop chain rather than `useProjection`, and the projection's key-absence semantics ("no attachment service composed → no pre-check") fall out for free.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
A drop anywhere on the window now lands in the rail, over-limit intake fails at the moment of the gesture with copy naming the limit, and history images tile like DeepSeek Chat's. The carrier's default request-body budget is ~5× larger and remains a per-request resident-memory bound (the bridge buffers bodies whole; recorded in the connection README's limitations). The fixture transport mirrors the projection with hardcoded default numbers — a deployment that overrides the limits diverges from fixture-mode copy, acceptable for a keyless demo lane. Gallery arrow navigation, lightbox zoom/download, and non-image file cards remain deferred (#2248).
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# Agent Note:整页图片拖放、上限投影预检与缩略图平铺
|
||||||
|
|
||||||
|
状态:implemented
|
||||||
|
|
||||||
|
[English](2026-08-12-web-image-intake-and-limits-alignment.md) | 中文
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
issue #2248 的第二步对齐,接在[附件展示 note](2026-08-11-web-attachment-display-alignment.md) 之后(其附件栏、toast 与原子组件包的决策继续有效;本 note 取代其中历史画廊几何与灯箱 backdrop 的具体规格)。与 DeepSeek Chat 相比剩下的差距:图片只能拖到 composer 卡片上——拖到聊天记录区会让浏览器直接导航到文件;灯箱关闭钮是裸 `×` 文本字符(button 不继承字体,且该字形的墨迹在行框中心之上,因此明显偏斜),backdrop 用 `color-mix(label-primary 74%)`,dark 下反转成刺眼的白色蒙层;一条消息的多张图各自以最大 240px 的块竖着堆叠,因为画廊容器本身被钉在 240px;客户端完全不执行也不展示图片限额——用户可以攒 50 张图,直到提交后收到原始的 `attachment-error (TOO_MANY_IMAGES)` toast,眼看附件栏清空又回滚。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
**整页拖放。** InputBar 在 document 上绑定 `dragenter`/`dragover`/`dragleave`/`drop`(enter/leave 深度计数、视口边缘与 `dragend` 复位、按 `Files` 类型门控使文本拖拽保留原生 textarea 路径),并渲染 `ui-attachment` 新增的 `DropOverlay` 原子组件:经 body portal、不接收指针事件的全视口层(DeepSeek Chat DragMask 的视觉——白色 70% 加 10px 模糊,dark 为 `rgba(39,39,48,0.7)`,插画、标题、上限行),`disabled` 变体宣告锁定或忙碌的 composer。指针惰性是承重的:拖拽事件继续命中下方页面,深度计数永远看不到遮罩自己。document 级监听状态是安全的,因为 composer-bar slot 为 `kind: 'single'`。
|
||||||
|
|
||||||
|
**灯箱。** 关闭钮换成 `ui-primitives` 的 `IconCloseOutline16`(Modal 的先例——在 viewBox 内居中的 SVG 不依赖字体度量)。backdrop 用共享的对话框遮罩(`--dsw-alias-bg-mask-1` 加 `--dsw-mask-blur`,两个主题都是黑基色),画在独立的兄弟图层上,因为 `backdrop-filter` 画在容器上会把预览图自己也模糊掉。
|
||||||
|
|
||||||
|
**历史缩略图(DeepSeek Chat 规则)。** 一条消息仅有的一张图长边 240px、展示比例钳制在 [0.25, 4],`cover` 裁切,特别高的图锚定顶部、特别宽的锚定左侧,从不放大;多张图渲染为固定 64px 方块,单个可换行的横排(10px 间距,用户消息右对齐)。assistant 连续的 `image` 块合并进同一个画廊,平铺而不是各占一行。
|
||||||
|
|
||||||
|
**上限对齐并投影。** 默认值为每条消息 20 张、单图 5 MiB、总量 100 MiB(`attachment-local`),HTTP 载体上限提为唯一共享的 `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB`(http-bridge,原先是两个独立的 32 MiB 字面量),以满足加载时的容量断言(总量 × 4/3 加余量 ≈ 134.3 MiB)。消费级产品集中在 10 到 20 个附件(ChatGPT 10、Gemini 10、Claude 20;DeepSeek Chat 的 50 是例外),且视觉模型一张图约 1300 到 4800 token,因此 50 张图可在一条消息中填满 200k 上下文。默认单图上限采用 5 MiB,可适用于分别采用 5 MiB 或 10 MiB 上限的 Anthropic 路由;仅使用较大上限路由的部署可以覆盖该值。512 MiB 总量无法通过当前传输,因为 base64 进 JSON 需要一个超过 V8 约 512 MiB 字符串上限的单个 JSON 字符串。限额以 `imageLimits` 会话投影到达客户端。它是每次启动恒定的单元(`apply` 返回同一状态引用,因此只靠基线携带、不存在变更帧),由 **apiproxy** 而非 attachment Service Definition 注册:`dsh-llm` 依赖 `dsh-attachment`(`ImageBlock` → `ImageAttachmentRef`),seam 包引用 `dsh-session-projection`(其图谱经 `dsh-session` 到达 `dsh-llm`)会闭合 project-reference 环,而该值描述的每消息数量与总量规则本来就是 proxy 自己的准入检查。`SessionProjectionMap` 合并放在 proxy 的 sessions 协议文件里,每个客户端程序都经载体的类型再导出包含它。
|
||||||
|
|
||||||
|
**加入预检与错误文案。** 两种加入手势汇合到 InputBar 的一个 `intakeImages` 包装:在 `addImages` 之前按投影检查数量、单图字节与总字节,违规的一批整体拒收(DeepSeek Chat 语义)并立刻弹出点名上限的横幅——不再有提交时的回滚戏码。宿主检查保留,兜底绕过 composer 的调用方。横幅文案遵循用户定下的一条原则:用户能解决的原因(模型不支持视觉、数量、大小、分辨率、格式——格式改为正面列出支持列表而不是回显被拒的 MIME 类型)用点明出路的产品句子;用户无法解决的原因(base64 损坏、引用丢失、读取失败)折叠为一条保留原因码的发送失败句子,因为产品当前面向开发者,可上报的码好过死胡同。非附件错误码保留原文加错误码的展示。
|
||||||
|
|
||||||
|
## 备选方案
|
||||||
|
|
||||||
|
**在 attachment Service Definition 构造函数里注册投影单元。** 天然的 seam 归属,也是第一版实现——被依赖图(上述环)和一个测试基建交互否决:基类构造函数调用 `ctx.inject` 使得 spec 中直接构造的 store 触发全局 invariant 宿主,后者往同一 root 重复挂载 `attachments` 服务。
|
||||||
|
|
||||||
|
**灯箱用 `--dsw-alias-bg-mask-photo`(0.88 黑、主题恒定、无人使用)。** 设计系统的照片查看器 token,也可能是 dsweb 灯箱实际的蒙层;用户选择与 settings 对话框遮罩一致(`bg-mask-1` 加模糊)——两者都能修复 dark 反转。
|
||||||
|
|
||||||
|
**在 `apply.ts` 的 `addImages` inject 里预检。** seam 纯度上的位置,因管线成本否决:投影仓没有暴露给 inject 工厂的非 React 面,而 InputBar 已经以惯用方式消费投影,且是两种手势的唯一调用方。
|
||||||
|
|
||||||
|
**用 `host.describe` 字段代替投影。** 与会话无关且更便宜,但要经注入 prop 链而非 `useProjection` 送达,而投影的键缺席语义("未组合 attachment 服务 → 不预检")是白拿的。
|
||||||
|
|
||||||
|
## 后果
|
||||||
|
|
||||||
|
拖到窗口任何位置都能进附件栏,超限加入在手势发生的那一刻就以点名上限的文案失败,历史图片像 DeepSeek Chat 一样平铺。载体的默认请求体预算扩大约 5 倍,并且仍是单请求驻留内存上界(桥把请求体整体缓冲;已记录在 connection README 的限制节)。fixture 传输用硬编码的默认数字镜像该投影——改配置的部署会与 fixture 模式的文案分叉,对 keyless 演示通道可接受。画廊左右切换、灯箱缩放与下载、非图片文件卡片仍然推迟(#2248)。
|
||||||
@@ -115,6 +115,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
|
|||||||
- **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)).
|
- **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)).
|
||||||
- **Keep compiler faces explicit.** Each package uses one aggregate except `api/remotes`; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)).
|
- **Keep compiler faces explicit.** Each package uses one aggregate except `api/remotes`; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)).
|
||||||
- **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement.
|
- **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement.
|
||||||
|
- Do not comment on facts obvious from code.
|
||||||
- **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction.
|
- **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction.
|
||||||
- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
|
- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
|
||||||
- **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)).
|
- **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)).
|
||||||
|
|||||||
@@ -143,8 +143,55 @@ it('accepts pasted images into the composer rail in order and removes them', asy
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const toast = await screen.findByRole('alert')
|
const toast = await screen.findByRole('alert')
|
||||||
expect(toast.textContent).toContain('Unsupported image format: text/plain')
|
expect(toast.textContent).toContain('Only PNG, JPG, WebP, and GIF images are supported')
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByRole('alert')).toBeNull()
|
expect(screen.queryByRole('alert')).toBeNull()
|
||||||
}, { timeout: 6_000 })
|
}, { timeout: 6_000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts a whole-page drop under the limits-labeled overlay and refuses an over-limit batch at intake', async () => {
|
||||||
|
mountAssembledApp()
|
||||||
|
|
||||||
|
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||||
|
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
|
||||||
|
if (start === null) throw new Error('fixture Workspace new-session action missing')
|
||||||
|
fireEvent.click(start)
|
||||||
|
const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||||
|
|
||||||
|
// A file drag anywhere over the page raises the full-viewport overlay whose
|
||||||
|
// desc line carries the projected limits — copy that can only render after
|
||||||
|
// the imageLimits projection crossed the real fixture transport.
|
||||||
|
const image = new File([new Uint8Array([137, 80, 78, 71])], 'dropped.png', { type: 'image/png' })
|
||||||
|
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')
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(overlay.textContent).toContain('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"]')
|
||||||
|
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 })
|
||||||
|
expect(screen.queryByRole('status')).toBeNull()
|
||||||
|
|
||||||
|
// An intake that would exceed the projected per-message count is refused as
|
||||||
|
// a whole batch at add time: the banner names the limit and the rail keeps
|
||||||
|
// only the previously accepted thumbnail — no submit-time rollback.
|
||||||
|
const batch = Array.from({ length: 20 }, (_, i) =>
|
||||||
|
new File([new Uint8Array([137, 80, 78, 71])], `bulk-${String(i)}.png`, { type: 'image/png' }))
|
||||||
|
fireEvent.paste(textarea, {
|
||||||
|
clipboardData: {
|
||||||
|
items: batch.map(file => ({ kind: 'file', type: 'image/png', getAsFile: () => file })),
|
||||||
|
getData: () => '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const banner = await screen.findByRole('alert')
|
||||||
|
expect(banner.textContent).toContain('A message can include up to 20 images')
|
||||||
|
const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
|
||||||
|
expect([...(rail?.querySelectorAll('img') ?? [])]).toHaveLength(1)
|
||||||
|
})
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||||
config-catalog.md: 5442e18d57de9991f53283e6623c2dbe4788b32b
|
config-catalog.md: bd5b646d119828373d5f8c1a5160f2e85c049ce8
|
||||||
config-catalog.zh.md: 7ba2449a00494963cb014e1be95b60e143efe4b4
|
config-catalog.zh.md: 33d5aa44591c4df9658a286366a2ff993e034b0e
|
||||||
@@ -367,7 +367,7 @@ export interface ConnectionConfig {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts)
|
Source: [`packages/client/connection/src/index.ts:50`](../packages/client/connection/src/index.ts)
|
||||||
|
|
||||||
## `@deepseek-ai/dsh-client-hmr`
|
## `@deepseek-ai/dsh-client-hmr`
|
||||||
|
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ export interface ConnectionConfig {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
来源:[`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts)
|
来源:[`packages/client/connection/src/index.ts:50`](../packages/client/connection/src/index.ts)
|
||||||
|
|
||||||
## `@deepseek-ai/dsh-client-hmr`
|
## `@deepseek-ai/dsh-client-hmr`
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ export { readImageFile, saveImageFile, validateImageFile } from './store.ts'
|
|||||||
/** Default maximum encoded bytes for one image. */
|
/** Default maximum encoded bytes for one image. */
|
||||||
export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||||
/** Default maximum images in one prompt. */
|
/** Default maximum images in one prompt. */
|
||||||
export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10
|
export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20
|
||||||
/** Default maximum aggregate image bytes in one prompt. */
|
/** Default maximum aggregate image bytes in one prompt. */
|
||||||
export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024
|
export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 100 * 1024 * 1024
|
||||||
/** Default maximum intrinsic pixels for one image. */
|
/** Default maximum intrinsic pixels for one image. */
|
||||||
export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000
|
export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import LocalAttachmentStore, {
|
|||||||
describe('local attachment service', () => {
|
describe('local attachment service', () => {
|
||||||
it('resolves every omitted admission limit explicitly', () => {
|
it('resolves every omitted admission limit explicitly', () => {
|
||||||
const service = new LocalAttachmentStore(new Context(), {})
|
const service = new LocalAttachmentStore(new Context(), {})
|
||||||
|
expect(DEFAULT_MAX_IMAGE_BYTES).toBe(5 * 1024 * 1024)
|
||||||
expect(service.imageLimits).toEqual({
|
expect(service.imageLimits).toEqual({
|
||||||
maxImageBytes: DEFAULT_MAX_IMAGE_BYTES,
|
maxImageBytes: DEFAULT_MAX_IMAGE_BYTES,
|
||||||
maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE,
|
maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE,
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||||
README.md: a82bb55ab65df2732ad16248d2cc9aa15b60e94d
|
README.md: d3727df981ecbc022345def48b38fbc879e29cb2
|
||||||
README.zh.md: d4c19db8e74e3562ec55b5ef60675aef79b82008
|
README.zh.md: 69af4cdba553399c0db0e0587e540c2a7f5375d2
|
||||||
@@ -23,3 +23,4 @@ None; this package neither assembles nor sends a provider request.
|
|||||||
## Known Limitations and Deferred Work
|
## Known Limitations and Deferred Work
|
||||||
|
|
||||||
- **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path.
|
- **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path.
|
||||||
|
- **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 160 MiB, sized for the default 100 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.
|
||||||
@@ -23,3 +23,4 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r
|
|||||||
## 已知限制与暂缓事项
|
## 已知限制与暂缓事项
|
||||||
|
|
||||||
- **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent,并增加首次打开的延迟;没有仅从持久化读取的路径。
|
- **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent,并增加首次打开的延迟;没有仅从持久化读取的路径。
|
||||||
|
- **`/api` 桥把每个请求体整体缓冲在内存里**:`maxRequestBodyBytes`(默认 160 MiB,按默认 100 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。
|
||||||
@@ -979,6 +979,18 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
|
|||||||
values['contextPressure'] = contextPressureOf(log)
|
values['contextPressure'] = contextPressureOf(log)
|
||||||
// Always present (token-meter composed): heuristic request composition.
|
// Always present (token-meter composed): heuristic request composition.
|
||||||
values['contextBreakdown'] = contextBreakdownOf(log)
|
values['contextBreakdown'] = contextBreakdownOf(log)
|
||||||
|
// Always present (attachment service composed): the deployment image
|
||||||
|
// limits, constant per boot (mirrors the attachment-local defaults).
|
||||||
|
// Deliberate host divergence: the real gateway never pushes an imageLimits
|
||||||
|
// change frame (constant unit), but the fixture's uniform baseline replay
|
||||||
|
// frames every key here, incidentally exercising higher-seq-wins.
|
||||||
|
values['imageLimits'] = {
|
||||||
|
maxImageBytes: 5 * 1024 * 1024,
|
||||||
|
maxImagesPerMessage: 20,
|
||||||
|
maxMessageImageBytes: 100 * 1024 * 1024,
|
||||||
|
maxImagePixels: 40_000_000,
|
||||||
|
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
||||||
|
}
|
||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
|
|
||||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||||
|
|
||||||
|
/** Default carrier cap for all HTTP RPC bodies: sized for the default
|
||||||
|
* aggregate image limit (100 MiB) after base64 expansion plus envelope
|
||||||
|
* headroom (~134.3 MiB required), rounded up for slack. The bridge buffers
|
||||||
|
* each body in memory, so this cap is also the per-request resident bound. */
|
||||||
|
export const DEFAULT_MAX_REQUEST_BODY_BYTES = 160 * 1024 * 1024
|
||||||
|
|
||||||
/** Transport-independent request handler consumed by the Host HTTP bridge. */
|
/** Transport-independent request handler consumed by the Host HTTP bridge. */
|
||||||
export interface FetchHandler {
|
export interface FetchHandler {
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +33,7 @@ export async function bridge(
|
|||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
apiHandler: FetchHandler,
|
apiHandler: FetchHandler,
|
||||||
maxRequestBodyBytes = 32 * 1024 * 1024,
|
maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
// Client-disconnect detection MUST hang off the response, not the request:
|
// Client-disconnect detection MUST hang off the response, not the request:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {} from '@deepseek-ai/dsh-attachment'
|
|||||||
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||||
import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||||
import { bridge } from './http-bridge.ts'
|
import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts'
|
||||||
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||||
import { HostConnectionService } from './rpc-host.ts'
|
import { HostConnectionService } from './rpc-host.ts'
|
||||||
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
|
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
|
||||||
@@ -42,8 +42,6 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** Default carrier cap for all HTTP RPC bodies. */
|
|
||||||
const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024
|
|
||||||
|
|
||||||
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
|
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
|
||||||
export const inject = ['httpServer']
|
export const inject = ['httpServer']
|
||||||
|
|||||||
@@ -164,6 +164,13 @@ describe('createFixtureApi', () => {
|
|||||||
toolsTokens: 0,
|
toolsTokens: 0,
|
||||||
messageTokens: 0,
|
messageTokens: 0,
|
||||||
},
|
},
|
||||||
|
imageLimits: {
|
||||||
|
maxImageBytes: 5 * 1024 * 1024,
|
||||||
|
maxImagesPerMessage: 20,
|
||||||
|
maxMessageImageBytes: 100 * 1024 * 1024,
|
||||||
|
maxImagePixels: 40_000_000,
|
||||||
|
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
||||||
|
},
|
||||||
} },
|
} },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -374,10 +381,14 @@ describe('createFixtureApi', () => {
|
|||||||
value: { systemTokens: 0, toolsTokens: 0 },
|
value: { systemTokens: 0, toolsTokens: 0 },
|
||||||
})
|
})
|
||||||
expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0)
|
expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0)
|
||||||
expect(first[9]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
expect(first[9]?.payload).toMatchObject({
|
||||||
expect(second[9]?.rpcId).toBe(first[9]?.rpcId) // stable rpcId across replays (host replay semantics)
|
type: 'session/projection', sessionId: 'fx-alpha', key: 'imageLimits',
|
||||||
expect(first[10]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
value: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 },
|
||||||
expect(second[10]?.rpcId).toBe(first[10]?.rpcId)
|
})
|
||||||
|
expect(first[10]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||||
|
expect(second[10]?.rpcId).toBe(first[10]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||||
|
expect(first[11]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||||
|
expect(second[11]?.rpcId).toBe(first[11]?.rpcId)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md
|
# pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md
|
||||||
README.md: 9fab9c23b958606030b1e87fcbfa45130c980947
|
README.md: 65db3f03b3ef174d12de75786e197da4712c4de1
|
||||||
README.zh.md: 668dba11154538f52a9a87692020868c1b8a63d5
|
README.zh.md: 2b714e880912d6dc097ce73aca4286a674bdade5
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
English | [中文](README.zh.md)
|
English | [中文](README.zh.md)
|
||||||
|
|
||||||
Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), and the original-image lightbox (`ImageLightbox`). Every string arrives through label props resolved by the owning plugin's own locale namespace, and nothing here reads application state; `@deepseek-ai/dsh-client-ui-conversation` is the current consumer, bridging its `conversation` dictionary through its `image-labels` module.
|
Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), the original-image lightbox (`ImageLightbox`), and the full-page drop overlay (`DropOverlay`). Every string arrives through label props resolved by the owning plugin's own locale namespace, and nothing here reads application state; `@deepseek-ai/dsh-client-ui-conversation` is the current consumer, bridging its `conversation` dictionary through its `image-labels` module.
|
||||||
|
|
||||||
## Attachment rail
|
## Attachment rail
|
||||||
|
|
||||||
@@ -10,7 +10,11 @@ Pure React attachment atoms (zero cordis): the composer draft-image rail (`Attac
|
|||||||
|
|
||||||
## Message images and the lightbox
|
## Message images and the lightbox
|
||||||
|
|
||||||
`MessageImage` renders one durable history image bounded to 240px on its longer edge, loading a session-authorized URL through the owner's `ImageLoader`; a failed load renders an explicit retry control, and a settled load answers a single click by opening `ImageLightbox` (clicks during loading are ignored). `ImageGallery` wraps a message's images in one aligned flex group (`end` for user messages, `start` for assistant messages) and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview that closes on Escape, a backdrop press, or its close control, and restores focus to its opener on unmount.
|
`MessageImage` renders one durable history image, loading a session-authorized URL through the owner's `ImageLoader`; a failed load renders an explicit retry control, and a settled load answers a single click by opening `ImageLightbox` (clicks during loading are ignored). Sizing follows DeepSeek Chat: a message's lone image (`variant="single"`) renders at 240px on its longer edge with the displayed aspect ratio clamped to [0.25, 4] — the overflow is cropped by `object-fit: cover`, anchored to the top of very tall images and the left of very wide ones — and never upscales past its natural size; an image among several (`variant="tile"`) is a fixed 64px square. `ImageGallery` wraps a message's images in one aligned wrapping flex group (`end` for user messages, `start` for assistant messages), picks the variant from the image count, and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview over the shared dialog mask (`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`, painted on its own layer so the blur never touches the previewed image) that closes on Escape, a mask press, or its close control, and restores focus to its opener on unmount.
|
||||||
|
|
||||||
|
## Drop overlay
|
||||||
|
|
||||||
|
`DropOverlay` is the full-viewport invitation shown while a file drag is over the page: illustration, title, and a limits line while drops are accepted (`disabled` swaps the blocked illustration and hides the limits line). The layer is pointer-inert — the owner's document-level drag listeners keep the enter/leave count and decide accept/reject; the overlay only shows state. It portals to the body like the lightbox.
|
||||||
|
|
||||||
## Model Experience
|
## Model Experience
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[English](README.md) | 中文
|
[English](README.md) | 中文
|
||||||
|
|
||||||
纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)与原图灯箱(`ImageLightbox`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
|
纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)、原图灯箱(`ImageLightbox`)与整页拖放遮罩(`DropOverlay`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
|
||||||
|
|
||||||
## 附件栏
|
## 附件栏
|
||||||
|
|
||||||
@@ -10,7 +10,11 @@
|
|||||||
|
|
||||||
## 消息图片与灯箱
|
## 消息图片与灯箱
|
||||||
|
|
||||||
`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。
|
`MessageImage` 渲染一张持久化历史图片,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。尺寸规则对齐 DeepSeek Chat:一条消息仅有的一张图(`variant="single"`)长边 240px、展示宽高比钳制在 [0.25, 4] 之间——超出部分由 `object-fit: cover` 裁切,特别高的图锚定顶部、特别宽的图锚定左侧——且从不放大超过原始尺寸;多图中的一张(`variant="tile"`)为固定 64px 方块。`ImageGallery` 将一条消息的图片包为一个对齐的可换行弹性分组(用户消息 `end`,助手消息 `start`),按图片数量选择 variant,空列表不渲染。`ImageLightbox` 是文档级模态预览,铺在共享的对话框遮罩上(`--dsw-alias-bg-mask-1` 加 `--dsw-mask-blur`,画在独立图层上,模糊不会波及预览图本身),按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。
|
||||||
|
|
||||||
|
## 拖放遮罩
|
||||||
|
|
||||||
|
`DropOverlay` 是文件拖拽悬停页面时的全视口邀请层:插画、标题,接受拖放时再加一行上限说明(`disabled` 换为禁用插画并隐藏上限行)。该层不接收指针事件——持有方的 document 级拖拽监听器负责 enter/leave 计数和接受与否的判定;遮罩只呈现状态。与灯箱一样经 body portal 渲染。
|
||||||
|
|
||||||
## 模型体验
|
## 模型体验
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/* Full-viewport drop invitation (DeepSeek Chat DragMask). pointer-events:
|
||||||
|
none — the layer is decoration; drag events must keep hitting the page so
|
||||||
|
the owner's enter/leave count stays balanced. The frosted sheet color is
|
||||||
|
the theme's drop-mask alias (dark override lives with the theme owner). */
|
||||||
|
.mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: var(--dsw-alias-bg-mask-drop);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
animation: fade-in 160ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.mask {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: -3%;
|
||||||
|
padding: 0 40px;
|
||||||
|
color: var(--dsw-alias-label-primary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.illustration {
|
||||||
|
width: 115px;
|
||||||
|
height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-top: 16px;
|
||||||
|
font: var(--dsw-font-l-20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin-top: 16px;
|
||||||
|
font: var(--dsw-font-s-14);
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import css from './DropOverlay.module.css'
|
||||||
|
|
||||||
|
/** Drop-overlay strings the owner resolves from its own locale namespace. */
|
||||||
|
export interface DropOverlayLabels {
|
||||||
|
/** Headline inviting the drop, or naming why it is unavailable. */
|
||||||
|
title: string
|
||||||
|
/** Limits line under the title; shown only while drops are accepted. */
|
||||||
|
desc?: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-viewport invitation shown while a file drag is over the page
|
||||||
|
* (DeepSeek Chat's DragMask). Decoration only: `pointer-events: none` keeps
|
||||||
|
* drag targeting on the page below, so the owner's document-level listeners
|
||||||
|
* keep an accurate enter/leave count and own accept/reject. Rendered through
|
||||||
|
* a body portal for the same transformed-ancestor reason as the lightbox.
|
||||||
|
*
|
||||||
|
* @param props.disabled - drops are currently refused; renders the blocked
|
||||||
|
* illustration and drops the desc line.
|
||||||
|
* @param props.labels - resolved title and limits strings.
|
||||||
|
* @returns the overlay layer.
|
||||||
|
*/
|
||||||
|
export function DropOverlay({ disabled, labels }: {
|
||||||
|
disabled: boolean
|
||||||
|
labels: DropOverlayLabels
|
||||||
|
}) {
|
||||||
|
return createPortal(
|
||||||
|
<div className={css.mask} role="status">
|
||||||
|
<div className={css.wrap}>
|
||||||
|
<div className={css.illustration} aria-hidden="true">
|
||||||
|
{disabled ? <UploadDisabledIllustration /> : <UploadIllustration />}
|
||||||
|
</div>
|
||||||
|
<div className={css.title}>{labels.title}</div>
|
||||||
|
{!disabled && labels.desc !== undefined && <div className={css.desc}>{labels.desc}</div>}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tilted photo-and-note cards (DeepSeek Chat upload illustration). */
|
||||||
|
const UploadIllustration = () => (
|
||||||
|
<svg width="115" height="84" viewBox="0 0 115 84" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clipPath="url(#dshDropOverlayClip)">
|
||||||
|
<rect y="17.0742" width="44.1832" height="43.6431" rx="12" transform="rotate(-22.7338 0 17.0742)" fill="#9CE5ED" />
|
||||||
|
<rect x="73.4043" y="8.54297" width="43.7267" height="50.5284" rx="8" transform="rotate(17.403 73.4043 8.54297)" fill="#679EFE" />
|
||||||
|
<path d="M30.4917 28.1369L40.8865 33.4564L37.2232 34.9524L29.5302 31.0159L26.7919 39.2122L23.1285 40.7082L26.8287 29.6338L16.8967 24.5516L20.5601 23.0556L27.7902 26.7549L30.3639 19.052L34.0273 17.556L30.4917 28.1369Z" fill="white" />
|
||||||
|
<path d="M77.5088 26.3047L101.057 33.7966" stroke="white" strokeWidth="3" />
|
||||||
|
<path d="M72.2646 42.7871L86.3938 47.2823" stroke="white" strokeWidth="3" />
|
||||||
|
<path d="M74.8867 34.5469L98.4353 42.0388" stroke="white" strokeWidth="3" />
|
||||||
|
<rect x="31.583" y="38.6641" width="44.9157" height="44.3666" rx="12" transform="rotate(-0.134233 31.583 38.6641)" fill="#3964FE" />
|
||||||
|
<path d="M38.9521 73.0337C39.6129 71.7086 41.7113 66.0937 43.5113 61.1663C44.1607 59.3885 46.7484 59.3923 47.4591 61.1465C48.9728 64.8828 50.7969 68.6922 51.9988 69.1925C54.2946 70.1482 57.9854 59.3573 68.0064 70.1801" stroke="white" strokeWidth="3" />
|
||||||
|
<circle cx="60.6157" cy="52.247" r="4.38794" transform="rotate(22.5996 60.6157 52.247)" fill="white" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="dshDropOverlayClip">
|
||||||
|
<rect width="115" height="84" fill="white" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Greyed cards with a blocked badge (DeepSeek Chat disabled illustration). */
|
||||||
|
const UploadDisabledIllustration = () => (
|
||||||
|
<svg width="115" height="84" viewBox="0 0 115 84" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M29.6829 4.63701L11.0677 12.4368C4.95519 14.998 2.07624 22.0294 4.6374 28.1419L12.2285 46.259C14.7896 52.3715 21.8211 55.2505 27.9336 52.6893L46.5488 44.8895C52.6613 42.3283 55.5403 35.2969 52.9791 29.1844L45.388 11.0673C42.8269 4.9548 35.7954 2.07585 29.6829 4.63701Z" fill="#979DA6" />
|
||||||
|
<path d="M30.4915 28.1375L40.8863 33.4569L37.223 34.9529L29.53 31.0165L26.7917 39.2128L23.1283 40.7088L26.8285 29.6344L16.8965 24.5522L20.5599 23.0562L27.79 26.7555L30.3637 19.0526L34.0271 17.5566L30.4915 28.1375Z" fill="white" />
|
||||||
|
<path d="M107.496 19.2285L81.0381 10.9357C76.8221 9.61423 72.333 11.9607 71.0116 16.1768L60.6844 49.1246C59.363 53.3406 61.7095 57.8297 65.9255 59.1511L92.383 67.4439C96.599 68.7654 101.088 66.4189 102.41 62.2029L112.737 29.255C114.058 25.039 111.712 20.55 107.496 19.2285Z" fill="#979DA6" />
|
||||||
|
<path d="M77.5088 26.3047L101.057 33.7967" stroke="white" strokeWidth="3" />
|
||||||
|
<path d="M72.2646 42.7871L86.3938 47.2823" stroke="white" strokeWidth="3" />
|
||||||
|
<path d="M74.8867 34.5469L98.4353 42.0388" stroke="white" strokeWidth="3" />
|
||||||
|
<path d="M66.5798 30.1418L41.481 30.2006C33.5281 30.2193 27.0962 36.6815 27.1148 44.6343L27.172 69.0742C27.1907 77.0271 33.6529 83.459 41.6057 83.4404L66.7045 83.3816C74.6574 83.363 81.0894 76.9008 81.0707 68.9479L81.0135 44.5081C80.9949 36.5552 74.5327 30.1232 66.5798 30.1418Z" fill="#F59E0B" />
|
||||||
|
<path d="M54 70.7969C61.732 70.7969 68 64.5289 68 56.7969C68 49.0649 61.732 42.7969 54 42.7969C46.268 42.7969 40 49.0649 40 56.7969C40 64.5289 46.268 70.7969 54 70.7969Z" stroke="white" strokeWidth="3.5" />
|
||||||
|
<path d="M44 46.7969L64 66.7969" stroke="white" strokeWidth="3.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
@@ -5,10 +5,20 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent);
|
}
|
||||||
|
|
||||||
|
/* Same mask recipe as the Modal primitive and the settings dialog. A separate
|
||||||
|
layer, not a background on .backdrop: backdrop-filter there would blur the
|
||||||
|
previewed image and the close control along with the page. */
|
||||||
|
.mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--dsw-alias-bg-mask-1);
|
||||||
|
backdrop-filter: var(--dsw-mask-blur);
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
|
position: relative;
|
||||||
max-width: min(100%, 1600px);
|
max-width: min(100%, 1600px);
|
||||||
max-height: calc(100vh - 80px);
|
max-height: calc(100vh - 80px);
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
@@ -21,6 +31,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
@@ -29,6 +40,5 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--dsw-specific-input-major);
|
background: var(--dsw-specific-input-major);
|
||||||
color: var(--dsw-alias-label-primary);
|
color: var(--dsw-alias-label-primary);
|
||||||
font-size: 24px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import css from './ImageLightbox.module.css'
|
import css from './ImageLightbox.module.css'
|
||||||
|
|
||||||
/** Lightbox strings the owner resolves from its own locale namespace. */
|
/** Lightbox strings the owner resolves from its own locale namespace. */
|
||||||
@@ -51,10 +52,12 @@ export function ImageLightbox({ src, alt, labels, onClose }: {
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={labels.dialog}
|
aria-label={labels.dialog}
|
||||||
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
|
|
||||||
>
|
>
|
||||||
|
<div className={css.mask} aria-hidden="true" onMouseDown={onClose} />
|
||||||
<img className={css.image} src={src} alt={alt} />
|
<img className={css.image} src={src} alt={alt} />
|
||||||
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
|
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>
|
||||||
|
<IconCloseOutline16 size={16} />
|
||||||
|
</button>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
.gallery {
|
.gallery {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
width: min(240px, 100%);
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery[data-align='end'] {
|
.gallery[data-align='end'] {
|
||||||
@@ -29,11 +29,18 @@
|
|||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.frame[data-variant='tile'] {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
min-width: 64px;
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
.frame img {
|
.frame img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading,
|
.loading,
|
||||||
@@ -51,3 +58,12 @@
|
|||||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A failed tile keeps the 64px grid cell instead of growing to its copy. */
|
||||||
|
.error[data-variant='tile'] {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
padding: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
@@ -23,18 +23,38 @@ export interface MessageImageLabels {
|
|||||||
lightbox: ImageLightboxLabels
|
lightbox: ImageLightboxLabels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Display box for a lone image (DeepSeek Chat rule): long edge 240px with
|
||||||
|
* the rendered aspect ratio clamped to [0.25, 4] — the overflow is cropped by
|
||||||
|
* `object-fit: cover` — and never upscaled past the image's natural size. The
|
||||||
|
* crop anchor keeps the top of very tall images and the left of very wide
|
||||||
|
* ones, where the informative content usually starts. */
|
||||||
|
function singleFit(attachment: ImageAttachmentRef): { width: number; height: number; objectPosition: string } {
|
||||||
|
const natural = attachment.width / attachment.height
|
||||||
|
const ratio = Math.min(4, Math.max(0.25, natural))
|
||||||
|
const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 }
|
||||||
|
const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height)
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.round(box.width * scale)),
|
||||||
|
height: Math.max(1, Math.round(box.height * scale)),
|
||||||
|
objectPosition: natural < 0.25 ? 'center top' : natural > 4 ? 'left center' : 'center',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact history renderer with retryable loading and click-to-open original
|
* Compact history renderer with retryable loading and click-to-open original
|
||||||
* preview.
|
* preview. A lone image renders at its `singleFit` size; an image among
|
||||||
|
* several renders as a fixed 64px square tile.
|
||||||
*
|
*
|
||||||
* @param props.attachment - the durable image reference to load and bound.
|
* @param props.attachment - the durable image reference to load and bound.
|
||||||
* @param props.load - session-authorized URL loader.
|
* @param props.load - session-authorized URL loader.
|
||||||
|
* @param props.variant - `single` for a message's lone image, `tile` otherwise.
|
||||||
* @param props.labels - resolved strings (tooltip, loading, retry, lightbox).
|
* @param props.labels - resolved strings (tooltip, loading, retry, lightbox).
|
||||||
* @returns the bounded thumbnail button, or the retry control on failure.
|
* @returns the bounded thumbnail button, or the retry control on failure.
|
||||||
*/
|
*/
|
||||||
export function MessageImage({ attachment, load, labels }: {
|
export function MessageImage({ attachment, load, variant, labels }: {
|
||||||
attachment: ImageAttachmentRef
|
attachment: ImageAttachmentRef
|
||||||
load: ImageLoader
|
load: ImageLoader
|
||||||
|
variant: 'single' | 'tile'
|
||||||
labels: MessageImageLabels
|
labels: MessageImageLabels
|
||||||
}) {
|
}) {
|
||||||
const [src, setSrc] = useState<string | null>(null)
|
const [src, setSrc] = useState<string | null>(null)
|
||||||
@@ -45,10 +65,10 @@ export function MessageImage({ attachment, load, labels }: {
|
|||||||
const [attempt, setAttempt] = useState(0)
|
const [attempt, setAttempt] = useState(0)
|
||||||
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
|
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
|
||||||
const close = useCallback(() => { setOpen(false) }, [])
|
const close = useCallback(() => { setOpen(false) }, [])
|
||||||
const size = useMemo(() => {
|
const fit = useMemo(
|
||||||
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
|
() => (variant === 'single' ? singleFit(attachment) : undefined),
|
||||||
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
|
[attachment, variant],
|
||||||
}, [attachment.height, attachment.width])
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true
|
let live = true
|
||||||
@@ -59,25 +79,29 @@ export function MessageImage({ attachment, load, labels }: {
|
|||||||
}, [attachment, load, attempt])
|
}, [attachment, load, attempt])
|
||||||
|
|
||||||
const label = attachment.name ?? labels.image
|
const label = attachment.name ?? labels.image
|
||||||
if (error) return <button type="button" className={css.error} onClick={request}>{labels.loadFailed}</button>
|
if (error) return <button type="button" className={css.error} data-variant={variant} onClick={request}>{labels.loadFailed}</button>
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={css.frame}
|
className={css.frame}
|
||||||
style={size}
|
data-variant={variant}
|
||||||
|
style={fit === undefined ? undefined : { width: fit.width, height: fit.height }}
|
||||||
title={labels.open}
|
title={labels.open}
|
||||||
aria-label={labels.openNamed(label)}
|
aria-label={labels.openNamed(label)}
|
||||||
onClick={() => { if (src !== null) setOpen(true) }}
|
onClick={() => { if (src !== null) setOpen(true) }}
|
||||||
>
|
>
|
||||||
{src === null ? <span className={css.loading}>{labels.loading}</span> : <img src={src} alt={label} />}
|
{src === null
|
||||||
|
? <span className={css.loading}>{labels.loading}</span>
|
||||||
|
: <img src={src} alt={label} style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
|
||||||
</button>
|
</button>
|
||||||
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
|
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wrapping image group shared by user and assistant history. */
|
/** 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 }: {
|
export function ImageGallery({ images, load, align, labels }: {
|
||||||
images: readonly { attachment: ImageAttachmentRef }[]
|
images: readonly { attachment: ImageAttachmentRef }[]
|
||||||
load: ImageLoader
|
load: ImageLoader
|
||||||
@@ -85,10 +109,11 @@ export function ImageGallery({ images, load, align, labels }: {
|
|||||||
labels: MessageImageLabels
|
labels: MessageImageLabels
|
||||||
}) {
|
}) {
|
||||||
if (images.length === 0) return null
|
if (images.length === 0) return null
|
||||||
|
const variant = images.length === 1 ? 'single' : 'tile'
|
||||||
return (
|
return (
|
||||||
<div className={css.gallery} data-align={align}>
|
<div className={css.gallery} data-align={align}>
|
||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} labels={labels} />
|
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} variant={variant} labels={labels} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Pure React attachment atoms (zero cordis): the composer draft-image rail,
|
* Pure React attachment atoms (zero cordis): the composer draft-image rail,
|
||||||
* the chat-history image gallery, and the original-image lightbox. Owners
|
* the chat-history image gallery, the original-image lightbox, and the
|
||||||
* resolve every string through their own locale namespace and pass it down;
|
* full-page drop overlay. Owners resolve every string through their own
|
||||||
* nothing here reads application state.
|
* locale namespace and pass it down; nothing here reads application state.
|
||||||
* @module @deepseek-ai/dsh-client-ui-attachment
|
* @module @deepseek-ai/dsh-client-ui-attachment
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { AttachmentRail } from './AttachmentRail.tsx'
|
export { AttachmentRail } from './AttachmentRail.tsx'
|
||||||
export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx'
|
export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx'
|
||||||
|
export { DropOverlay } from './DropOverlay.tsx'
|
||||||
|
export type { DropOverlayLabels } from './DropOverlay.tsx'
|
||||||
export { ImageLightbox } from './ImageLightbox.tsx'
|
export { ImageLightbox } from './ImageLightbox.tsx'
|
||||||
export type { ImageLightboxLabels } from './ImageLightbox.tsx'
|
export type { ImageLightboxLabels } from './ImageLightbox.tsx'
|
||||||
export { ImageGallery, MessageImage } from './MessageImage.tsx'
|
export { ImageGallery, MessageImage } from './MessageImage.tsx'
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { cleanup, render } from '@testing-library/react'
|
||||||
|
import { DropOverlay } from '../src/DropOverlay.tsx'
|
||||||
|
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
describe('DropOverlay', () => {
|
||||||
|
it('portals the invitation with its title and limits desc to the body', () => {
|
||||||
|
const view = render(
|
||||||
|
<DropOverlay disabled={false} labels={{ title: '图片拖动到此处即可添加', desc: '最多 20 张,每张 5MB' }} />,
|
||||||
|
)
|
||||||
|
const overlay = view.getByRole('status')
|
||||||
|
expect(overlay.parentElement).toBe(document.body)
|
||||||
|
expect(overlay.textContent).toContain('图片拖动到此处即可添加')
|
||||||
|
expect(overlay.textContent).toContain('最多 20 张,每张 5MB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits the desc line when none is resolved', () => {
|
||||||
|
const view = render(<DropOverlay disabled={false} labels={{ title: '图片拖动到此处即可添加' }} />)
|
||||||
|
expect(view.getByRole('status').textContent).toBe('图片拖动到此处即可添加')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the desc and switches the illustration while disabled', () => {
|
||||||
|
const enabled = render(
|
||||||
|
<DropOverlay disabled={false} labels={{ title: '拖入', desc: '限制' }} />,
|
||||||
|
)
|
||||||
|
const enabledSvg = enabled.getByRole('status').querySelector('svg')!.innerHTML
|
||||||
|
enabled.unmount()
|
||||||
|
const disabled = render(
|
||||||
|
<DropOverlay disabled labels={{ title: '当前无法添加图片', desc: '限制' }} />,
|
||||||
|
)
|
||||||
|
const overlay = disabled.getByRole('status')
|
||||||
|
expect(overlay.textContent).toBe('当前无法添加图片')
|
||||||
|
expect(overlay.querySelector('svg')!.innerHTML).not.toBe(enabledSvg)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -39,12 +39,13 @@ describe('ImageLightbox', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('closes on a backdrop press but not on a press over the image', () => {
|
it('closes on a mask press but not on a press over the image', () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
|
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
|
||||||
fireEvent.mouseDown(view.getByRole('img'))
|
fireEvent.mouseDown(view.getByRole('img'))
|
||||||
expect(onClose).not.toHaveBeenCalled()
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
fireEvent.mouseDown(view.getByRole('dialog', { name: '原图预览' }))
|
const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement
|
||||||
|
fireEvent.mouseDown(mask)
|
||||||
expect(onClose).toHaveBeenCalledTimes(1)
|
expect(onClose).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -29,7 +29,7 @@ const attachment = {
|
|||||||
describe('MessageImage', () => {
|
describe('MessageImage', () => {
|
||||||
it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
|
it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
|
||||||
const load = vi.fn().mockResolvedValue('blob:history')
|
const load = vi.fn().mockResolvedValue('blob:history')
|
||||||
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
|
||||||
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||||
expect(frame.getAttribute('style')).toContain('width: 240px')
|
expect(frame.getAttribute('style')).toContain('width: 240px')
|
||||||
expect(frame.getAttribute('style')).toContain('height: 120px')
|
expect(frame.getAttribute('style')).toContain('height: 120px')
|
||||||
@@ -44,7 +44,7 @@ describe('MessageImage', () => {
|
|||||||
|
|
||||||
it('ignores a click while the thumbnail is still loading', () => {
|
it('ignores a click while the thumbnail is still loading', () => {
|
||||||
const load = vi.fn(() => new Promise<string>(() => {}))
|
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||||
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
|
||||||
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||||
expect(view.getByText('图片加载中…')).toBeTruthy()
|
expect(view.getByText('图片加载中…')).toBeTruthy()
|
||||||
fireEvent.click(frame)
|
fireEvent.click(frame)
|
||||||
@@ -54,7 +54,7 @@ describe('MessageImage', () => {
|
|||||||
it('falls back to the image label for an unnamed attachment', async () => {
|
it('falls back to the image label for an unnamed attachment', async () => {
|
||||||
const { name: _named, ...unnamed } = attachment
|
const { name: _named, ...unnamed } = attachment
|
||||||
const load = vi.fn().mockResolvedValue('blob:unnamed')
|
const load = vi.fn().mockResolvedValue('blob:unnamed')
|
||||||
const view = render(<MessageImage attachment={unnamed} load={load} labels={labels} />)
|
const view = render(<MessageImage attachment={unnamed} load={load} variant="single" labels={labels} />)
|
||||||
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
|
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
|
||||||
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
|
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
|
||||||
})
|
})
|
||||||
@@ -64,7 +64,7 @@ describe('MessageImage', () => {
|
|||||||
.mockRejectedValueOnce(new Error('offline'))
|
.mockRejectedValueOnce(new Error('offline'))
|
||||||
.mockRejectedValueOnce(new Error('still offline'))
|
.mockRejectedValueOnce(new Error('still offline'))
|
||||||
.mockResolvedValueOnce('blob:retry')
|
.mockResolvedValueOnce('blob:retry')
|
||||||
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
|
||||||
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||||
fireEvent.click(retry)
|
fireEvent.click(retry)
|
||||||
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||||
@@ -73,16 +73,59 @@ describe('MessageImage', () => {
|
|||||||
expect(load).toHaveBeenCalledTimes(3)
|
expect(load).toHaveBeenCalledTimes(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => {
|
||||||
|
const load = vi.fn().mockResolvedValue('blob:ratio')
|
||||||
|
const tall = render(
|
||||||
|
<MessageImage attachment={{ ...attachment, width: 100, height: 2000 }} load={load} variant="single" labels={labels} />,
|
||||||
|
)
|
||||||
|
const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||||
|
expect(tallFrame.getAttribute('style')).toContain('width: 60px')
|
||||||
|
expect(tallFrame.getAttribute('style')).toContain('height: 240px')
|
||||||
|
await waitFor(() => { expect(tall.getByAltText('history.png')).toBeTruthy() })
|
||||||
|
expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top')
|
||||||
|
tall.unmount()
|
||||||
|
const wide = render(
|
||||||
|
<MessageImage attachment={{ ...attachment, width: 4000, height: 100 }} load={load} variant="single" labels={labels} />,
|
||||||
|
)
|
||||||
|
const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||||
|
expect(wideFrame.getAttribute('style')).toContain('width: 240px')
|
||||||
|
expect(wideFrame.getAttribute('style')).toContain('height: 60px')
|
||||||
|
await waitFor(() => { expect(wide.getByAltText('history.png')).toBeTruthy() })
|
||||||
|
expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center')
|
||||||
|
wide.unmount()
|
||||||
|
const small = render(
|
||||||
|
<MessageImage attachment={{ ...attachment, width: 100, height: 100 }} load={load} variant="single" labels={labels} />,
|
||||||
|
)
|
||||||
|
const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||||
|
expect(smallFrame.getAttribute('style')).toContain('width: 100px')
|
||||||
|
expect(smallFrame.getAttribute('style')).toContain('height: 100px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a tile at the fixed square without inline sizing', () => {
|
||||||
|
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||||
|
const view = render(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
|
||||||
|
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||||
|
expect(frame.getAttribute('data-variant')).toBe('tile')
|
||||||
|
expect(frame.getAttribute('style')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the tile variant on the failed-load retry control', async () => {
|
||||||
|
const load = vi.fn().mockRejectedValue(new Error('offline'))
|
||||||
|
const view = render(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
|
||||||
|
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||||
|
expect(retry.getAttribute('data-variant')).toBe('tile')
|
||||||
|
})
|
||||||
|
|
||||||
it('ignores a load settling after unmount', async () => {
|
it('ignores a load settling after unmount', async () => {
|
||||||
let resolve: ((url: string) => void) | undefined
|
let resolve: ((url: string) => void) | undefined
|
||||||
const load = vi.fn(() => new Promise<string>((r) => { resolve = r }))
|
const load = vi.fn(() => new Promise<string>((r) => { resolve = r }))
|
||||||
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
|
||||||
view.unmount()
|
view.unmount()
|
||||||
resolve?.('blob:late')
|
resolve?.('blob:late')
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
let reject: ((error: Error) => void) | undefined
|
let reject: ((error: Error) => void) | undefined
|
||||||
const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
|
const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
|
||||||
const second = render(<MessageImage attachment={attachment} load={failing} labels={labels} />)
|
const second = render(<MessageImage attachment={attachment} load={failing} variant="single" labels={labels} />)
|
||||||
second.unmount()
|
second.unmount()
|
||||||
reject?.(new Error('late failure'))
|
reject?.(new Error('late failure'))
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
@@ -100,4 +143,15 @@ describe('ImageGallery', () => {
|
|||||||
expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
|
expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
|
||||||
await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
|
await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders a lone image large and several images as square tiles', () => {
|
||||||
|
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||||
|
const lone = render(<ImageGallery images={[{ attachment }]} load={load} align="start" labels={labels} />)
|
||||||
|
expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
|
||||||
|
lone.unmount()
|
||||||
|
const several = render(
|
||||||
|
<ImageGallery images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
|
||||||
|
)
|
||||||
|
expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||||
README.md: ed8f888d35693ecaa2667ea432b462f4bb3369cf
|
README.md: e2db456f92151c3602fce0bf40a86e4019cda1cb
|
||||||
README.zh.md: e6a2dd0b545b66ab01b213b5ebc937e22af8ac1a
|
README.zh.md: 399228c619d50a7fc1909db81350b4078803f8f7
|
||||||
@@ -36,6 +36,8 @@ Keyboard message submission resolves delivery from the addressed session's runni
|
|||||||
|
|
||||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||||
|
|
||||||
|
Image intake accepts paste and whole-page drop: the bar binds document-level drag listeners (the composer-bar slot is `kind: 'single'`, so at most one bar binds them) and shows the `DropOverlay` atom while a file drag is over the window — text drags pass through untouched, and a locked or busy composer shows the blocked overlay and refuses the drop. Both gestures feed one intake pre-check against the host's `imageLimits` projection (count, per-image bytes, aggregate bytes): an addition that would break a limit is refused as a whole batch with an immediate banner naming the limit, and never enters the rail. Host-side rejections that arrive anyway surface as product copy mapped from the `attachment-error` reason (`image-labels.ts` `attachmentErrorText`); reasons the user cannot act on fold into one send-failed line carrying the reason code, and non-attachment error codes keep their developer-facing message plus code.
|
||||||
|
|
||||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists.
|
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists.
|
||||||
|
|
||||||
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
|
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
|||||||
|
|
||||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||||
|
|
||||||
|
图片经粘贴与整页拖放进入:输入栏绑定 document 级拖拽监听(composer-bar slot 为 `kind: 'single'`,同一时刻至多一个 bar 绑定),文件拖拽悬停窗口时显示 `DropOverlay` 原子组件——纯文本拖拽不受影响,锁定或忙碌的 composer 显示禁用遮罩并拒绝 drop。两种手势共用一条对宿主 `imageLimits` 投影的加入预检(数量、单图字节、总字节):会突破上限的加入整批拒收,立刻弹出点名上限的横幅,完全不进入附件栏。仍然到达的宿主侧拒绝按 `attachment-error` 原因映射为产品文案(`image-labels.ts` 的 `attachmentErrorText`);用户无法解决的原因折叠为一条带原因码的发送失败文案,非附件错误码保留开发者可读的原文加错误码。
|
||||||
|
|
||||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
|
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
|
||||||
|
|
||||||
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。
|
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。
|
||||||
|
|||||||
@@ -313,9 +313,9 @@ export function apply(ctx: Context): void {
|
|||||||
return null
|
return null
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof UnsupportedImageMediaTypeError) {
|
if (error instanceof UnsupportedImageMediaTypeError) {
|
||||||
return t('image.unsupportedType', {
|
// Positive copy: the supported list is fixed in imageMediaType,
|
||||||
type: error.mediaType || t('image.unknownType'),
|
// and naming it beats echoing the rejected MIME type back.
|
||||||
})
|
return t('image.unsupportedType')
|
||||||
}
|
}
|
||||||
return error instanceof Error ? error.message : String(error)
|
return error instanceof Error ? error.message : String(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
// turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
|
// turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
|
||||||
|
|
||||||
import { memo, useMemo } from 'react'
|
import { memo, useMemo } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
@@ -48,34 +49,60 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
|||||||
|| interrupted === true
|
|| interrupted === true
|
||||||
|| blocks.some(block => block.kind !== 'tool-call')
|
|| blocks.some(block => block.kind !== 'tool-call')
|
||||||
if (!hasVisible) return null
|
if (!hasVisible) return null
|
||||||
|
const rendered: ReactNode[] = []
|
||||||
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
|
const block = blocks[i]
|
||||||
|
if (block === undefined) continue
|
||||||
|
switch (block.kind) {
|
||||||
|
case 'text':
|
||||||
|
rendered.push(
|
||||||
|
<MarkdownText
|
||||||
|
key={i}
|
||||||
|
text={block.text}
|
||||||
|
streaming={streaming}
|
||||||
|
codeLabels={codeLabels}
|
||||||
|
fileMentions={mentions}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'reasoning':
|
||||||
|
rendered.push(<ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />)
|
||||||
|
break
|
||||||
|
case 'image': {
|
||||||
|
// Consecutive image blocks share one gallery so several images tile
|
||||||
|
// into rows instead of each opening a one-image group of its own.
|
||||||
|
// Keyed by the group's FIRST block index: a streaming append that
|
||||||
|
// extends the group then only grows `images` instead of remounting
|
||||||
|
// the gallery under a shifted key.
|
||||||
|
const start = i
|
||||||
|
const group = [block]
|
||||||
|
while (i + 1 < blocks.length) {
|
||||||
|
const next = blocks[i + 1]
|
||||||
|
if (next === undefined || next.kind !== 'image') break
|
||||||
|
group.push(next)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
rendered.push(<ImageGallery key={start} images={group} load={imageLoader} align="start" labels={messageImageLabels(t)} />)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||||
|
case 'tool-call':
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
rendered.push(
|
||||||
|
<JsonBlock
|
||||||
|
key={i}
|
||||||
|
label={t('message.unknownBlock')}
|
||||||
|
payload={block.block}
|
||||||
|
truncatedLabel={total => t('json.truncated', { total })}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className={css.root} data-streaming={streaming || undefined}>
|
<div className={css.root} data-streaming={streaming || undefined}>
|
||||||
<div className={css.body}>
|
<div className={css.body}>
|
||||||
{blocks.map((block, i) => {
|
{rendered}
|
||||||
switch (block.kind) {
|
|
||||||
case 'text': return (
|
|
||||||
<MarkdownText
|
|
||||||
key={i}
|
|
||||||
text={block.text}
|
|
||||||
streaming={streaming}
|
|
||||||
codeLabels={codeLabels}
|
|
||||||
fileMentions={mentions}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
|
|
||||||
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" labels={messageImageLabels(t)} />
|
|
||||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
|
||||||
case 'tool-call': return null
|
|
||||||
default: return (
|
|
||||||
<JsonBlock
|
|
||||||
key={i}
|
|
||||||
label={t('message.unknownBlock')}
|
|
||||||
payload={block.block}
|
|
||||||
truncatedLabel={total => t('json.truncated', { total })}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
|
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,11 +3,60 @@
|
|||||||
* application state; owners resolve every string). */
|
* application state; owners resolve every string). */
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels,
|
AttachmentRailLabels, DropOverlayLabels, ImageLightboxLabels, MessageImageLabels,
|
||||||
} from '@deepseek-ai/dsh-client-ui-attachment'
|
} from '@deepseek-ai/dsh-client-ui-attachment'
|
||||||
|
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
|
||||||
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import type { ConversationKey } from './locales.ts'
|
import type { ConversationKey } from './locales.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Byte count as user-facing megabytes (`10MB`, `2.5MB`).
|
||||||
|
* @param bytes - the byte count.
|
||||||
|
* @returns the rounded megabyte text.
|
||||||
|
*/
|
||||||
|
export function imageSizeText(bytes: number): string {
|
||||||
|
const mb = bytes / (1024 * 1024)
|
||||||
|
return `${Number.isInteger(mb) ? String(mb) : mb.toFixed(1)}MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product copy for a host attachment rejection (the `attachment-error`
|
||||||
|
* `details.reason`). User-solvable reasons name the limit and the way out;
|
||||||
|
* reasons the user cannot act on fold into one send-failed line carrying the
|
||||||
|
* reason code for a bug report.
|
||||||
|
* @param t - the conversation-namespace translate.
|
||||||
|
* @param reason - the wire `details.reason` code.
|
||||||
|
* @param limits - projected limits interpolated into count/size copy, when known.
|
||||||
|
* @returns the banner text.
|
||||||
|
*/
|
||||||
|
export function attachmentErrorText(
|
||||||
|
t: Translate<ConversationKey>,
|
||||||
|
reason: string,
|
||||||
|
limits?: ImageAttachmentLimits,
|
||||||
|
): string {
|
||||||
|
switch (reason) {
|
||||||
|
case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported')
|
||||||
|
case 'SUBAGENT_IMAGE_UNSUPPORTED': return t('image.subagentUnsupported')
|
||||||
|
case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels')
|
||||||
|
// Undecodable bytes or a declared type its bytes contradict: solvable by
|
||||||
|
// replacing or re-exporting the file, so it reads as a format problem.
|
||||||
|
case 'INVALID_IMAGE':
|
||||||
|
case 'IMAGE_TYPE_MISMATCH':
|
||||||
|
return t('image.unsupportedType')
|
||||||
|
case 'TOO_MANY_IMAGES':
|
||||||
|
if (limits !== undefined) return t('image.tooMany', { count: limits.maxImagesPerMessage })
|
||||||
|
break
|
||||||
|
case 'IMAGE_TOO_LARGE':
|
||||||
|
if (limits !== undefined) return t('image.fileTooLarge', { size: imageSizeText(limits.maxImageBytes) })
|
||||||
|
break
|
||||||
|
case 'IMAGES_TOO_LARGE':
|
||||||
|
if (limits !== undefined) return t('image.totalTooLarge', { size: imageSizeText(limits.maxMessageImageBytes) })
|
||||||
|
break
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
return t('image.sendFailed', { reason })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the original-image lightbox strings.
|
* Resolve the original-image lightbox strings.
|
||||||
* @param t - the conversation-namespace translate.
|
* @param t - the conversation-namespace translate.
|
||||||
@@ -33,6 +82,25 @@ export function messageImageLabels(t: Translate<ConversationKey>): MessageImageL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the full-page drop overlay strings.
|
||||||
|
* @param t - the conversation-namespace translate.
|
||||||
|
* @param accepting - whether drops are currently accepted.
|
||||||
|
* @param limits - per-message limits for the desc line, when known.
|
||||||
|
* @returns the overlay title, with the limits desc while accepting.
|
||||||
|
*/
|
||||||
|
export function dropOverlayLabels(
|
||||||
|
t: Translate<ConversationKey>,
|
||||||
|
accepting: boolean,
|
||||||
|
limits?: { count: number; size: string },
|
||||||
|
): DropOverlayLabels {
|
||||||
|
if (!accepting) return { title: t('image.dropBlocked') }
|
||||||
|
return {
|
||||||
|
title: t('image.dropTitle'),
|
||||||
|
desc: limits === undefined ? undefined : t('image.dropDesc', { count: limits.count, size: limits.size }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the composer draft-image rail strings.
|
* Resolve the composer draft-image rail strings.
|
||||||
* @param t - the conversation-namespace translate.
|
* @param t - the conversation-namespace translate.
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export const zh = {
|
|||||||
'input.send': '发送消息',
|
'input.send': '发送消息',
|
||||||
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
|
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
|
||||||
'input.accessMode': '访问模式,当前:{name}',
|
'input.accessMode': '访问模式,当前:{name}',
|
||||||
'image.dropHint': '松开以添加图片',
|
'image.dropTitle': '图片拖动到此处即可添加',
|
||||||
|
'image.dropDesc': '最多 {count} 张,每张 {size}',
|
||||||
|
'image.dropBlocked': '当前无法添加图片',
|
||||||
'image.pending': '待发送图片',
|
'image.pending': '待发送图片',
|
||||||
'image.openOriginal': '查看原图',
|
'image.openOriginal': '查看原图',
|
||||||
'image.openOriginalLabel': '{label},点击查看原图',
|
'image.openOriginalLabel': '{label},点击查看原图',
|
||||||
@@ -39,8 +41,14 @@ export const zh = {
|
|||||||
'image.preview': '原图预览',
|
'image.preview': '原图预览',
|
||||||
'image.closePreview': '关闭原图预览',
|
'image.closePreview': '关闭原图预览',
|
||||||
'image.serviceUnavailable': '图片读取服务不可用',
|
'image.serviceUnavailable': '图片读取服务不可用',
|
||||||
'image.unsupportedType': '不支持的图片格式:{type}',
|
'image.unsupportedType': '仅支持 PNG、JPG、WebP、GIF 格式的图片',
|
||||||
'image.unknownType': '未知格式',
|
'image.tooMany': '一条消息最多添加 {count} 张图片',
|
||||||
|
'image.fileTooLarge': '单张图片不能超过 {size}',
|
||||||
|
'image.totalTooLarge': '图片总大小超过 {size},请移除部分图片',
|
||||||
|
'image.tooManyPixels': '图片分辨率过大,请压缩后重试',
|
||||||
|
'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
|
||||||
|
'image.subagentUnsupported': '子智能体会话暂不支持图片',
|
||||||
|
'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
|
||||||
'context.aria': '上下文已用 {percent}',
|
'context.aria': '上下文已用 {percent}',
|
||||||
'context.used': '上下文已用',
|
'context.used': '上下文已用',
|
||||||
'context.system': '系统提示词',
|
'context.system': '系统提示词',
|
||||||
@@ -184,7 +192,9 @@ export const en = {
|
|||||||
'input.send': 'Send message',
|
'input.send': 'Send message',
|
||||||
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
|
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
|
||||||
'input.accessMode': 'Access mode, current: {name}',
|
'input.accessMode': 'Access mode, current: {name}',
|
||||||
'image.dropHint': 'Drop to add images',
|
'image.dropTitle': 'Drag images here to add them',
|
||||||
|
'image.dropDesc': 'Up to {count} images, {size} each',
|
||||||
|
'image.dropBlocked': 'Images cannot be added right now',
|
||||||
'image.pending': 'Pending images',
|
'image.pending': 'Pending images',
|
||||||
'image.openOriginal': 'View original',
|
'image.openOriginal': 'View original',
|
||||||
'image.openOriginalLabel': '{label}, click to view original',
|
'image.openOriginalLabel': '{label}, click to view original',
|
||||||
@@ -198,8 +208,14 @@ export const en = {
|
|||||||
'image.preview': 'Original image preview',
|
'image.preview': 'Original image preview',
|
||||||
'image.closePreview': 'Close original image preview',
|
'image.closePreview': 'Close original image preview',
|
||||||
'image.serviceUnavailable': 'Image loading service unavailable',
|
'image.serviceUnavailable': 'Image loading service unavailable',
|
||||||
'image.unsupportedType': 'Unsupported image format: {type}',
|
'image.unsupportedType': 'Only PNG, JPG, WebP, and GIF images are supported',
|
||||||
'image.unknownType': 'unknown format',
|
'image.tooMany': 'A message can include up to {count} images',
|
||||||
|
'image.fileTooLarge': 'Each image must be smaller than {size}',
|
||||||
|
'image.totalTooLarge': 'Images exceed {size} in total; remove some and try again',
|
||||||
|
'image.tooManyPixels': 'Image resolution is too high; compress it and try again',
|
||||||
|
'image.modelUnsupported': 'The current model does not support images; switch to a model that does',
|
||||||
|
'image.subagentUnsupported': 'Subagent sessions do not support images yet',
|
||||||
|
'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again',
|
||||||
'context.aria': '{percent} of context used',
|
'context.aria': '{percent} of context used',
|
||||||
'context.used': 'of context used',
|
'context.used': 'of context used',
|
||||||
'context.system': 'System prompt',
|
'context.system': 'System prompt',
|
||||||
|
|||||||
@@ -115,25 +115,6 @@
|
|||||||
background: var(--dsw-alias-state-business-primary);
|
background: var(--dsw-alias-state-business-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dragActive {
|
|
||||||
border-color: var(--dsw-alias-state-business-primary);
|
|
||||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropHint {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 2;
|
|
||||||
inset: 4px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary));
|
|
||||||
color: var(--dsw-alias-state-business-primary);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accessory {
|
.accessory {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -7,23 +7,28 @@
|
|||||||
* (running/removed/promptError) are self-selected via useSession. */
|
* (running/removed/promptError) are self-selected via useSession. */
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
|
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
|
||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
|
import { AttachmentRail, DropOverlay, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||||
import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment'
|
import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||||
// Type-only: the `plan` projection key merge (the TodoDock posture — the
|
// Type-only: the `plan` projection key merge (the TodoDock posture — the
|
||||||
// composer reads a host-computed value; the domain owns the key).
|
// composer reads a host-computed value; the domain owns the key).
|
||||||
import type {} from '@deepseek-ai/dsh-plan-mode/client'
|
import type {} from '@deepseek-ai/dsh-plan-mode/client'
|
||||||
// Type-only: the `goal` projection key merge (hint disambiguation).
|
// Type-only: the `goal` projection key merge (hint disambiguation).
|
||||||
import type {} from '@deepseek-ai/dsh-goal/client'
|
import type {} from '@deepseek-ai/dsh-goal/client'
|
||||||
|
// The `imageLimits` projection key merge (intake pre-check) arrives with the
|
||||||
|
// wire types: apiproxy's sessions contract declares it, and client-runtime's
|
||||||
|
// api-remotes import already places it in every client program.
|
||||||
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
|
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
|
||||||
import { deriveDecorations } from '../input/decorations.ts'
|
import { deriveDecorations } from '../input/decorations.ts'
|
||||||
import type { DraftDecorations } from '../input/decorations.ts'
|
import type { DraftDecorations } from '../input/decorations.ts'
|
||||||
import { attachmentRailLabels, lightboxLabels } from '../image-labels.ts'
|
import {
|
||||||
|
attachmentErrorText, attachmentRailLabels, dropOverlayLabels, imageSizeText, lightboxLabels,
|
||||||
|
} from '../image-labels.ts'
|
||||||
import { ContextMeter } from './ContextMeter.tsx'
|
import { ContextMeter } from './ContextMeter.tsx'
|
||||||
import { PermissionSelect } from './PermissionSelect.tsx'
|
import { PermissionSelect } from './PermissionSelect.tsx'
|
||||||
import css from './InputBar.module.css'
|
import css from './InputBar.module.css'
|
||||||
@@ -80,14 +85,22 @@ export function InputBar({
|
|||||||
setToast({ seq: toastSeq.current, text })
|
setToast({ seq: toastSeq.current, text })
|
||||||
}, [])
|
}, [])
|
||||||
const dismissToast = useCallback(() => { setToast(null) }, [])
|
const dismissToast = useCallback(() => { setToast(null) }, [])
|
||||||
|
// The deployment's image-intake limits (absent while no attachment service
|
||||||
|
// is composed — the pre-check below then defers entirely to the host).
|
||||||
|
const imageLimits = useProjection('imageLimits')
|
||||||
// Prompt failures are ordinary failures (no create/attach transaction exists
|
// Prompt failures are ordinary failures (no create/attach transaction exists
|
||||||
// anymore): the toast announces promptError, the draft stays in the machine,
|
// anymore): the toast announces promptError, the draft stays in the machine,
|
||||||
// and the user resubmits. A remount over a session whose machine still holds
|
// and the user resubmits. A remount over a session whose machine still holds
|
||||||
// an unresolved promptError deliberately re-announces it once — the failure
|
// an unresolved promptError deliberately re-announces it once — the failure
|
||||||
// is still pending, and a transient banner is its only surface.
|
// is still pending, and a transient banner is its only surface. Attachment
|
||||||
|
// rejections show product copy keyed by the wire reason; other codes are
|
||||||
|
// developer-facing and keep the raw message plus code.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
|
if (promptError === null) return
|
||||||
}, [promptError, showToast])
|
showToast(promptError.error.code === 'attachment-error'
|
||||||
|
? attachmentErrorText(t, promptError.error.details.reason, imageLimits)
|
||||||
|
: `${promptError.error.message} (${promptError.error.code})`)
|
||||||
|
}, [promptError, showToast, t, imageLimits])
|
||||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||||
const cardRef = useRef<HTMLDivElement | null>(null)
|
const cardRef = useRef<HTMLDivElement | null>(null)
|
||||||
const dragDepthRef = useRef(0)
|
const dragDepthRef = useRef(0)
|
||||||
@@ -384,10 +397,7 @@ export function InputBar({
|
|||||||
.filter(item => item.kind === 'file')
|
.filter(item => item.kind === 'file')
|
||||||
.map(item => item.getAsFile())
|
.map(item => item.getAsFile())
|
||||||
.filter((file): file is File => file !== null)
|
.filter((file): file is File => file !== null)
|
||||||
if (files.length > 0 && addImages !== undefined) {
|
if (files.length > 0) intakeImages(files)
|
||||||
const rejected = addImages(files)
|
|
||||||
if (rejected !== null) showToast(rejected)
|
|
||||||
}
|
|
||||||
const text = e.clipboardData.getData('text/plain')
|
const text = e.clipboardData.getData('text/plain')
|
||||||
if (text === '') {
|
if (text === '') {
|
||||||
if (files.length > 0) e.preventDefault()
|
if (files.length > 0) e.preventDefault()
|
||||||
@@ -406,38 +416,94 @@ export function InputBar({
|
|||||||
keyboard.track(keyboard.snapshot.draft, caret)
|
keyboard.track(keyboard.snapshot.draft, caret)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
|
// Intake pre-check (DeepSeek Chat semantics): an addition that would break
|
||||||
if (!event.dataTransfer.types.includes('Files')) return
|
// a projected limit is refused as a whole batch, announced immediately, and
|
||||||
event.preventDefault()
|
// never enters the rail — no more submit-time failure rolling the rail
|
||||||
if (locked || machineBusy || addImages === undefined) return
|
// back. The host enforces the same limits at submit for callers that bypass
|
||||||
dragDepthRef.current += 1
|
// this composer.
|
||||||
setDragActive(true)
|
const intakeImages = useCallback((files: readonly File[]): void => {
|
||||||
}
|
if (addImages === undefined || files.length === 0) return
|
||||||
|
const rejected = ((): string | null => {
|
||||||
|
if (imageLimits !== undefined) {
|
||||||
|
// Format precedes limits (DeepSeek Chat's filter order): 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) {
|
||||||
|
return t('image.tooMany', { count: imageLimits.maxImagesPerMessage })
|
||||||
|
}
|
||||||
|
if (files.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)
|
||||||
|
if (total > imageLimits.maxMessageImageBytes) {
|
||||||
|
return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addImages(files)
|
||||||
|
})()
|
||||||
|
if (rejected !== null) showToast(rejected)
|
||||||
|
}, [addImages, attachments, imageLimits, showToast, t])
|
||||||
|
|
||||||
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
|
// Whole-page file-drop intake (DeepSeek Chat behavior): the listeners live
|
||||||
if (!event.dataTransfer.types.includes('Files')) return
|
// on the document so a drop anywhere over the window adds images, not only
|
||||||
event.preventDefault()
|
// over the composer card. Safe as document-level state: the composer-bar
|
||||||
event.dataTransfer.dropEffect = locked || machineBusy || addImages === undefined ? 'none' : 'copy'
|
// slot is `kind: 'single'`, so at most one bar is mounted to bind these.
|
||||||
}
|
// Text drags carry no 'Files' type and pass through untouched, keeping the
|
||||||
|
// native drop-text-into-textarea path. The overlay layer itself is
|
||||||
const onDragLeave = (event: DragEvent<HTMLDivElement>): void => {
|
// pointer-inert, so it never disturbs the enter/leave count.
|
||||||
if (!event.dataTransfer.types.includes('Files') || locked || machineBusy) return
|
const canAcceptDrop = !locked && !machineBusy && addImages !== undefined
|
||||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
useEffect(() => {
|
||||||
if (dragDepthRef.current === 0) setDragActive(false)
|
const hasFiles = (event: globalThis.DragEvent): boolean =>
|
||||||
}
|
event.dataTransfer?.types.includes('Files') ?? false
|
||||||
|
const reset = (): void => {
|
||||||
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
|
dragDepthRef.current = 0
|
||||||
if (!event.dataTransfer.types.includes('Files')) return
|
setDragActive(false)
|
||||||
event.preventDefault()
|
|
||||||
dragDepthRef.current = 0
|
|
||||||
setDragActive(false)
|
|
||||||
if (locked || machineBusy || addImages === undefined) return
|
|
||||||
const dropped = [...event.dataTransfer.files]
|
|
||||||
if (dropped.length > 0) {
|
|
||||||
const rejected = addImages(dropped)
|
|
||||||
if (rejected !== null) showToast(rejected)
|
|
||||||
}
|
}
|
||||||
}
|
const onDragEnter = (event: globalThis.DragEvent): void => {
|
||||||
|
if (!hasFiles(event)) return
|
||||||
|
event.preventDefault()
|
||||||
|
dragDepthRef.current += 1
|
||||||
|
setDragActive(true)
|
||||||
|
}
|
||||||
|
const onDragOver = (event: globalThis.DragEvent): void => {
|
||||||
|
if (!hasFiles(event) || event.dataTransfer === null) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
|
||||||
|
}
|
||||||
|
const onDragLeave = (event: globalThis.DragEvent): void => {
|
||||||
|
if (!hasFiles(event)) return
|
||||||
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||||
|
if (dragDepthRef.current === 0) setDragActive(false)
|
||||||
|
// Leaving through the viewport edge does not balance the count on every
|
||||||
|
// engine; a page-root leave at the border means the drag left the window.
|
||||||
|
const leavingViewport = event.clientX <= 0 || event.clientY <= 0
|
||||||
|
|| event.clientX >= window.innerWidth || event.clientY >= window.innerHeight
|
||||||
|
if ((event.target === document.documentElement || event.target === document.body) && leavingViewport) reset()
|
||||||
|
}
|
||||||
|
const onDrop = (event: globalThis.DragEvent): void => {
|
||||||
|
if (!hasFiles(event)) return
|
||||||
|
event.preventDefault()
|
||||||
|
reset()
|
||||||
|
if (!canAcceptDrop) return
|
||||||
|
intakeImages([...(event.dataTransfer?.files ?? [])])
|
||||||
|
}
|
||||||
|
document.addEventListener('dragenter', onDragEnter)
|
||||||
|
document.addEventListener('dragover', onDragOver)
|
||||||
|
document.addEventListener('dragleave', onDragLeave)
|
||||||
|
document.addEventListener('drop', onDrop)
|
||||||
|
window.addEventListener('dragend', reset)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('dragenter', onDragEnter)
|
||||||
|
document.removeEventListener('dragover', onDragOver)
|
||||||
|
document.removeEventListener('dragleave', onDragLeave)
|
||||||
|
document.removeEventListener('drop', onDrop)
|
||||||
|
window.removeEventListener('dragend', reset)
|
||||||
|
}
|
||||||
|
}, [canAcceptDrop, intakeImages])
|
||||||
|
|
||||||
const closePreview = useCallback(() => { setPreview(null) }, [])
|
const closePreview = useCallback(() => { setPreview(null) }, [])
|
||||||
|
|
||||||
@@ -573,6 +639,15 @@ export function InputBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
|
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
|
||||||
|
{dragActive && (
|
||||||
|
<DropOverlay
|
||||||
|
disabled={!canAcceptDrop}
|
||||||
|
labels={dropOverlayLabels(t, canAcceptDrop, imageLimits === undefined ? undefined : {
|
||||||
|
count: imageLimits.maxImagesPerMessage,
|
||||||
|
size: imageSizeText(imageLimits.maxImageBytes),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{toast !== null && (
|
{toast !== null && (
|
||||||
<Toast
|
<Toast
|
||||||
key={toast.seq}
|
key={toast.seq}
|
||||||
@@ -594,16 +669,11 @@ export function InputBar({
|
|||||||
click's reopen (close-then-open flickers the chip's open echo). */}
|
click's reopen (close-then-open flickers the chip's open echo). */}
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
|
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger)}
|
||||||
data-composer-card
|
data-composer-card
|
||||||
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
|
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
|
||||||
onPointerDown={workspaceTrigger ? (e) => { e.stopPropagation() } : undefined}
|
onPointerDown={workspaceTrigger ? (e) => { e.stopPropagation() } : undefined}
|
||||||
onDragEnter={onDragEnter}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDragLeave={onDragLeave}
|
|
||||||
onDrop={onDrop}
|
|
||||||
>
|
>
|
||||||
{dragActive && <div className={css.dropHint} role="status">{t('image.dropHint')}</div>}
|
|
||||||
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
|
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
|
||||||
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
|
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
|
||||||
{railItems.length > 0 && (
|
{railItems.length > 0 && (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
|||||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||||
|
import { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts'
|
||||||
import { en, zh } from '../src/client/locales.ts'
|
import { en, zh } from '../src/client/locales.ts'
|
||||||
|
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
@@ -25,6 +26,40 @@ const attachment = {
|
|||||||
name: 'history.png',
|
name: 'history.png',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('attachment rejection copy', () => {
|
||||||
|
const limits = {
|
||||||
|
maxImageBytes: 5 * 1024 * 1024,
|
||||||
|
maxImagesPerMessage: 20,
|
||||||
|
maxMessageImageBytes: 100 * 1024 * 1024,
|
||||||
|
maxImagePixels: 40_000_000,
|
||||||
|
mediaTypes: ['image/png'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders megabytes without a trailing fraction unless one exists', () => {
|
||||||
|
expect(imageSizeText(10 * 1024 * 1024)).toBe('10MB')
|
||||||
|
expect(imageSizeText(2.5 * 1024 * 1024)).toBe('2.5MB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps user-solvable reasons to limit-naming copy', () => {
|
||||||
|
expect(attachmentErrorText(t, 'MODEL_DOES_NOT_SUPPORT_IMAGES')).toBe('当前模型不支持图片,请切换支持图片的模型')
|
||||||
|
expect(attachmentErrorText(t, 'SUBAGENT_IMAGE_UNSUPPORTED')).toBe('子智能体会话暂不支持图片')
|
||||||
|
expect(attachmentErrorText(t, 'IMAGE_TOO_MANY_PIXELS')).toBe('图片分辨率过大,请压缩后重试')
|
||||||
|
expect(attachmentErrorText(t, 'INVALID_IMAGE')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||||
|
expect(attachmentErrorText(t, 'IMAGE_TYPE_MISMATCH')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||||
|
expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片')
|
||||||
|
expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 5MB')
|
||||||
|
expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB,请移除部分图片')
|
||||||
|
expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('folds unknown reasons and limit reasons without projected limits into the send-failed line', () => {
|
||||||
|
expect(attachmentErrorText(t, 'INVALID_IMAGE_BASE64')).toBe('图片发送失败(INVALID_IMAGE_BASE64),请重新添加图片后再试')
|
||||||
|
expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败(TOO_MANY_IMAGES),请重新添加图片后再试')
|
||||||
|
expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败(IMAGE_TOO_LARGE),请重新添加图片后再试')
|
||||||
|
expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败(IMAGES_TOO_LARGE),请重新添加图片后再试')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('assistant images through the label bridge', () => {
|
describe('assistant images through the label bridge', () => {
|
||||||
it('resolves zh dictionary strings and opens the lightbox on a single click', async () => {
|
it('resolves zh dictionary strings and opens the lightbox on a single click', async () => {
|
||||||
const view = render(
|
const view = render(
|
||||||
@@ -60,6 +95,27 @@ describe('assistant images through the label bridge', () => {
|
|||||||
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
|
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('merges consecutive image blocks into one tiled gallery, split by text', async () => {
|
||||||
|
const view = render(
|
||||||
|
<AssistantMarkdown
|
||||||
|
t={t}
|
||||||
|
blocks={[
|
||||||
|
{ kind: 'image', attachment },
|
||||||
|
{ kind: 'image', attachment },
|
||||||
|
{ kind: 'text', text: 'between' },
|
||||||
|
{ kind: 'image', attachment },
|
||||||
|
]}
|
||||||
|
streaming={false}
|
||||||
|
loadImage={() => Promise.resolve('blob:grouped')}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
await view.findAllByAltText('history.png')
|
||||||
|
const galleries = view.container.querySelectorAll('[data-align="start"]')
|
||||||
|
expect(galleries).toHaveLength(2)
|
||||||
|
expect(galleries[0]?.querySelectorAll('[data-variant="tile"]')).toHaveLength(2)
|
||||||
|
expect(galleries[1]?.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps assistant images at their original position between text blocks', async () => {
|
it('keeps assistant images at their original position between text blocks', async () => {
|
||||||
const view = render(
|
const view = render(
|
||||||
<AssistantMarkdown
|
<AssistantMarkdown
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ interface BenchOptions {
|
|||||||
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
|
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
|
||||||
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
|
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
|
||||||
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
|
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
|
||||||
|
/** The `imageLimits` projection value (absent = no attachment service). */
|
||||||
|
imageLimits?: {
|
||||||
|
maxImageBytes: number
|
||||||
|
maxImagesPerMessage: number
|
||||||
|
maxMessageImageBytes: number
|
||||||
|
maxImagePixels: number
|
||||||
|
mediaTypes: readonly ('image/png' | 'image/jpeg' | 'image/webp' | 'image/gif')[]
|
||||||
|
}
|
||||||
draft?: string
|
draft?: string
|
||||||
running?: boolean
|
running?: boolean
|
||||||
subagent?: Exclude<ConversationSnapshot['subagent'], null>
|
subagent?: Exclude<ConversationSnapshot['subagent'], null>
|
||||||
@@ -146,7 +154,9 @@ function bench(over?: BenchOptions) {
|
|||||||
baselinesReady: true, recentWorkspaceId: undefined,
|
baselinesReady: true, recentWorkspaceId: undefined,
|
||||||
})),
|
})),
|
||||||
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
|
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
|
||||||
(selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)),
|
(selector ?? (v => v))(key === 'permissions'
|
||||||
|
? over?.permissions
|
||||||
|
: key === 'plan' ? over?.plan : key === 'imageLimits' ? over?.imageLimits : undefined)),
|
||||||
useInput: bindSnapshotSelector(shell.state),
|
useInput: bindSnapshotSelector(shell.state),
|
||||||
inputActions: shell.actions,
|
inputActions: shell.actions,
|
||||||
keyboard: shell,
|
keyboard: shell,
|
||||||
@@ -212,18 +222,147 @@ describe('image draft rail', () => {
|
|||||||
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
|
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts file drops and prevents browser navigation', () => {
|
it('accepts a drop anywhere on the page under the full-page overlay', () => {
|
||||||
const addImages = vi.fn(() => null)
|
const addImages = vi.fn(() => null)
|
||||||
const { view } = bench({ addImages })
|
const { view } = bench({ addImages })
|
||||||
const card = view.container.querySelector('[class*="card"]')!
|
|
||||||
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
|
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
|
||||||
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
|
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
|
||||||
expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false)
|
// The drag never touches the composer card: the listeners are page-wide.
|
||||||
expect(view.getByRole('status').textContent).toContain('松开以添加图片')
|
expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false)
|
||||||
expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false)
|
expect(view.getByRole('status').textContent).toContain('图片拖动到此处即可添加')
|
||||||
|
expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false)
|
||||||
expect(dataTransfer.dropEffect).toBe('copy')
|
expect(dataTransfer.dropEffect).toBe('copy')
|
||||||
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
|
expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false)
|
||||||
expect(addImages).toHaveBeenCalledWith([image])
|
expect(addImages).toHaveBeenCalledWith([image])
|
||||||
|
expect(view.queryByRole('status')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps text drags native and hides the overlay when the drag leaves or ends', () => {
|
||||||
|
const addImages = vi.fn(() => null)
|
||||||
|
const { view } = bench({ addImages })
|
||||||
|
// A text drag carries no Files type: no overlay, native behavior stays.
|
||||||
|
fireEvent.dragEnter(document.body, { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' } })
|
||||||
|
expect(view.queryByRole('status')).toBeNull()
|
||||||
|
const dataTransfer = { types: ['Files'], files: [], dropEffect: 'none' }
|
||||||
|
fireEvent.dragEnter(document.body, { dataTransfer })
|
||||||
|
expect(view.getByRole('status')).toBeTruthy()
|
||||||
|
fireEvent.dragLeave(document.body, { dataTransfer })
|
||||||
|
expect(view.queryByRole('status')).toBeNull()
|
||||||
|
// An aborted drag (Escape) fires dragend without a balancing leave.
|
||||||
|
fireEvent.dragEnter(document.body, { dataTransfer })
|
||||||
|
fireEvent.dragEnter(document.querySelector('textarea')!, { dataTransfer })
|
||||||
|
expect(view.getByRole('status')).toBeTruthy()
|
||||||
|
fireEvent.dragEnd(window, { dataTransfer })
|
||||||
|
expect(view.queryByRole('status')).toBeNull()
|
||||||
|
expect(addImages).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => {
|
||||||
|
const limits = {
|
||||||
|
maxImageBytes: 1024 * 1024,
|
||||||
|
maxImagesPerMessage: 2,
|
||||||
|
maxMessageImageBytes: 2 * 1024 * 1024,
|
||||||
|
maxImagePixels: 40_000_000,
|
||||||
|
mediaTypes: ['image/png'] as const,
|
||||||
|
}
|
||||||
|
const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' })
|
||||||
|
const drop = (files: File[]) => {
|
||||||
|
fireEvent.drop(document.body, { dataTransfer: { types: ['Files'], files, dropEffect: 'none' } })
|
||||||
|
}
|
||||||
|
// Count: three at once over a two-image limit → the whole batch refused.
|
||||||
|
const overCount = bench({ addImages: vi.fn(() => null), imageLimits: limits })
|
||||||
|
drop([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()
|
||||||
|
cleanup()
|
||||||
|
// Per-file bytes.
|
||||||
|
const overFile = bench({ addImages: vi.fn(() => null), imageLimits: limits })
|
||||||
|
drop([png(1024 * 1024 + 1, 'big.png')])
|
||||||
|
expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB')
|
||||||
|
expect(overFile.props.addImages).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] })
|
||||||
|
drop([png(1024 * 1024, 'more.png')])
|
||||||
|
expect(overTotal.view.getByRole('alert').textContent).toContain('图片总大小超过 2MB')
|
||||||
|
expect(overTotal.props.addImages).not.toHaveBeenCalled()
|
||||||
|
cleanup()
|
||||||
|
// Within every limit: the batch passes through to addImages.
|
||||||
|
const within = bench({ addImages: vi.fn(() => null), imageLimits: limits })
|
||||||
|
const fits = png(16, 'fits.png')
|
||||||
|
drop([fits])
|
||||||
|
expect(within.props.addImages).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 { view } = bench({
|
||||||
|
addImages,
|
||||||
|
imageLimits: {
|
||||||
|
maxImageBytes: 8,
|
||||||
|
maxImagesPerMessage: 1,
|
||||||
|
maxMessageImageBytes: 8,
|
||||||
|
maxImagePixels: 40_000_000,
|
||||||
|
mediaTypes: ['image/png'] as const,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Oversized AND over-count AND wrong type: the format rejection wins.
|
||||||
|
const files = [
|
||||||
|
new File([new ArrayBuffer(64)], 'a.pdf', { type: 'application/pdf' }),
|
||||||
|
new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }),
|
||||||
|
]
|
||||||
|
fireEvent.drop(document.body, { dataTransfer: { types: ['Files'], files, dropEffect: 'none' } })
|
||||||
|
expect(addImages).toHaveBeenCalledWith(files)
|
||||||
|
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the projected limits in the drop overlay desc line', () => {
|
||||||
|
const { view } = bench({
|
||||||
|
addImages: vi.fn(() => null),
|
||||||
|
imageLimits: {
|
||||||
|
maxImageBytes: 5 * 1024 * 1024,
|
||||||
|
maxImagesPerMessage: 20,
|
||||||
|
maxMessageImageBytes: 100 * 1024 * 1024,
|
||||||
|
maxImagePixels: 40_000_000,
|
||||||
|
mediaTypes: ['image/png'] as const,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
fireEvent.dragEnter(document.body, { dataTransfer: { types: ['Files'], files: [], dropEffect: 'none' } })
|
||||||
|
expect(view.getByRole('status').textContent).toContain('最多 20 张,每张 5MB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('announces server attachment rejections as product copy, other codes as developer text', () => {
|
||||||
|
const attachmentError = (reason: string): ConversationSnapshot['promptError'] => ({
|
||||||
|
op: 'send',
|
||||||
|
error: { code: 'attachment-error', message: 'raw wire text', details: { reason } },
|
||||||
|
})
|
||||||
|
const model = bench({ promptError: attachmentError('MODEL_DOES_NOT_SUPPORT_IMAGES') })
|
||||||
|
expect(model.view.getByRole('alert').textContent).toContain('当前模型不支持图片,请切换支持图片的模型')
|
||||||
|
cleanup()
|
||||||
|
const unknown = bench({ promptError: attachmentError('ATTACHMENT_NOT_REFERENCED') })
|
||||||
|
expect(unknown.view.getByRole('alert').textContent).toContain('图片发送失败(ATTACHMENT_NOT_REFERENCED)')
|
||||||
|
cleanup()
|
||||||
|
const other = bench({
|
||||||
|
promptError: { op: 'send', error: { code: 'internal', message: 'boom', details: {} } },
|
||||||
|
})
|
||||||
|
expect(other.view.getByRole('alert').textContent).toContain('boom (internal)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the blocked overlay and refuses the drop while the composer is locked', () => {
|
||||||
|
const addImages = vi.fn(() => null)
|
||||||
|
const { view } = bench({ addImages, inert: true })
|
||||||
|
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
|
||||||
|
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' }
|
||||||
|
fireEvent.dragEnter(document.body, { dataTransfer })
|
||||||
|
expect(view.getByRole('status').textContent).toContain('当前无法添加图片')
|
||||||
|
fireEvent.dragOver(document.body, { dataTransfer })
|
||||||
|
expect(dataTransfer.dropEffect).toBe('none')
|
||||||
|
fireEvent.drop(document.body, { dataTransfer })
|
||||||
|
expect(addImages).not.toHaveBeenCalled()
|
||||||
|
expect(view.queryByRole('status')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sends an image-only draft and removes its thumbnail', () => {
|
it('sends an image-only draft and removes its thumbnail', () => {
|
||||||
@@ -250,7 +389,7 @@ describe('image draft rail', () => {
|
|||||||
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
|
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
try {
|
try {
|
||||||
const addImages = vi.fn(() => '不支持的图片格式:text/plain')
|
const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||||
const { view, textarea } = bench({ addImages })
|
const { view, textarea } = bench({ addImages })
|
||||||
const paste = () => {
|
const paste = () => {
|
||||||
fireEvent.paste(textarea, {
|
fireEvent.paste(textarea, {
|
||||||
@@ -261,12 +400,12 @@ describe('image draft rail', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
paste()
|
paste()
|
||||||
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain')
|
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||||
act(() => { vi.advanceTimersByTime(4000) })
|
act(() => { vi.advanceTimersByTime(4000) })
|
||||||
expect(view.queryByRole('alert')).toBeNull()
|
expect(view.queryByRole('alert')).toBeNull()
|
||||||
// The identical rejection re-announces: the toast is keyed per show.
|
// The identical rejection re-announces: the toast is keyed per show.
|
||||||
paste()
|
paste()
|
||||||
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain')
|
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ body {
|
|||||||
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.12);
|
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.12);
|
||||||
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
|
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
|
||||||
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
|
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
|
||||||
|
--dsw-alias-bg-mask-drop: rgba(255, 255, 255, 0.7);
|
||||||
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-60);
|
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-60);
|
||||||
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-bluish-60);
|
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-bluish-60);
|
||||||
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-150);
|
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-150);
|
||||||
@@ -253,6 +254,7 @@ body[data-ds-dark-theme] {
|
|||||||
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.2);
|
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.2);
|
||||||
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
|
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
|
||||||
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
|
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
|
||||||
|
--dsw-alias-bg-mask-drop: rgba(39, 39, 48, 0.7);
|
||||||
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-800);
|
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-800);
|
||||||
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-850);
|
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-850);
|
||||||
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-700);
|
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-700);
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||||
README.md: 5915d20b176ed6eccdb2c939bdf58b0a122271c5
|
README.md: 059c3eacbcd47bfc39820ab3db5545dbc2e2ccb8
|
||||||
README.zh.md: e1128323c45c8388562582de25cf8c68d936fac0
|
README.zh.md: 17bbad0094bfac49d63d6076a01d4c5cd2c5aa6b
|
||||||
@@ -26,7 +26,7 @@ Question responses are validated against their pending request before the first
|
|||||||
|
|
||||||
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compact/summary` record on the same page as the replacement that cites it.
|
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compact/summary` record on the same page as the replacement that cites it.
|
||||||
|
|
||||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
|
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds no other domain's knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. The gateway registers exactly one unit of its own: `imageLimits`, the attachments config it enforces at prompt admission, published as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the unit activates only while both the registry and the attachments service are composed.
|
||||||
|
|
||||||
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
|
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
|||||||
|
|
||||||
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志 `compact/summary` 记录与引用它的替换留在同一页。
|
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志 `compact/summary` 记录与引用它的替换留在同一页。
|
||||||
|
|
||||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有其他领域的知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。网关唯一自己注册的单元是 `imageLimits`:它在 prompt 准入时执行的 attachments 配置,以每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限;该单元仅在注册表与 attachments 服务同时组合时激活。
|
||||||
|
|
||||||
会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。
|
会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-a
|
|||||||
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
|
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
|
||||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||||
import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
|
import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
|
||||||
|
import { imageLimitsProjectionSchema } from './api/sessions.schema.ts'
|
||||||
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
||||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||||
import { RpcId } from './api/rpc.ts'
|
import { RpcId } from './api/rpc.ts'
|
||||||
@@ -1227,6 +1228,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The imageLimits projection unit: the attachments config this proxy
|
||||||
|
// enforces at prompt admission, constant per host boot. `apply` keeps the
|
||||||
|
// same state reference for every event, so no change frames are ever
|
||||||
|
// pushed — baselines alone carry the value — and clients pre-check intake
|
||||||
|
// and label upload affordances from it. Registered here, not in the
|
||||||
|
// attachment Service Definition: dsh-llm depends on dsh-attachment, so the
|
||||||
|
// seam package cannot reference the projection registry without a cycle,
|
||||||
|
// and the per-message rules the value describes are this proxy's own
|
||||||
|
// admission checks. The child activates only while both seams are composed.
|
||||||
|
// `view` reading the live service instead of the (null) state is sanctioned
|
||||||
|
// exactly for boot-constant units: the value cannot change within a process
|
||||||
|
// lifetime, so the fold stays observationally pure, and a stale persisted
|
||||||
|
// cache row re-viewing to the current config is the correct outcome.
|
||||||
|
ctx.inject(['sessionProjections', 'attachments'], (projectionCtx) => {
|
||||||
|
projectionCtx.sessionProjections.register<'imageLimits', null>({
|
||||||
|
key: 'imageLimits',
|
||||||
|
schema: imageLimitsProjectionSchema,
|
||||||
|
init: () => null,
|
||||||
|
apply: state => state,
|
||||||
|
view: () => projectionCtx.attachments.imageLimits,
|
||||||
|
stateVersion: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
/** Project both durable inbox lists, optionally including the splice currently being emitted. */
|
/** Project both durable inbox lists, optionally including the splice currently being emitted. */
|
||||||
const queueItems = (
|
const queueItems = (
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
|
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
|
||||||
} from './sessions.ts'
|
} from './sessions.ts'
|
||||||
import type { ToolEventView } from './events.ts'
|
import type { ToolEventView } from './events.ts'
|
||||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||||
import type { WorkspaceId } from './workspace.ts'
|
import type { WorkspaceId } from './workspace.ts'
|
||||||
import {
|
import {
|
||||||
SESSION_SEARCH_RESULT_LIMIT,
|
SESSION_SEARCH_RESULT_LIMIT,
|
||||||
@@ -213,7 +213,20 @@ export const sessionProjectionsBlockSchema = z.object({
|
|||||||
// -1 = empty log (the lastSeq convention of session/subscribed).
|
// -1 = empty log (the lastSeq convention of session/subscribed).
|
||||||
asOfSeq: z.number().int().min(-1),
|
asOfSeq: z.number().int().min(-1),
|
||||||
values: z.record(z.string(), z.unknown()),
|
values: z.record(z.string(), z.unknown()),
|
||||||
}) as unknown as z.ZodType<SessionProjectionsBlock>
|
}) as unknown as z.ZodType<Wire<SessionProjectionsBlock>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* imageLimits projection unit schema (host-side view validation). zod widens
|
||||||
|
* `readonly ImageMediaType[]` to `string[]`; on the JSON wire the two
|
||||||
|
* serialize identically, so the cast records exactly that widening.
|
||||||
|
*/
|
||||||
|
export const imageLimitsProjectionSchema = z.object({
|
||||||
|
maxImageBytes: z.number().int().positive(),
|
||||||
|
maxImagesPerMessage: z.number().int().positive(),
|
||||||
|
maxMessageImageBytes: z.number().int().positive(),
|
||||||
|
maxImagePixels: z.number().int().positive(),
|
||||||
|
mediaTypes: z.array(z.string()),
|
||||||
|
}) as unknown as z.ZodType<ImageAttachmentLimits>
|
||||||
|
|
||||||
/** session.history response value (projections rides the tail page only). */
|
/** session.history response value (projections rides the tail page only). */
|
||||||
export const sessionHistoryValueSchema: z.ZodType<Wire<ResponseValue<'session.history'>>> = z.object({
|
export const sessionHistoryValueSchema: z.ZodType<Wire<ResponseValue<'session.history'>>> = z.object({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||||
import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||||
// The pure-type outlet: api/ is browser-importable, and the package root's
|
// The pure-type outlet: api/ is browser-importable, and the package root's
|
||||||
@@ -15,6 +15,19 @@ import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
|
|||||||
import type { ToolEventView } from './events.ts'
|
import type { ToolEventView } from './events.ts'
|
||||||
import type { WorkspaceId } from './workspace.ts'
|
import type { WorkspaceId } from './workspace.ts'
|
||||||
|
|
||||||
|
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||||
|
interface SessionProjectionMap {
|
||||||
|
/**
|
||||||
|
* The deployment's image-intake limits: the attachments service's config
|
||||||
|
* as this proxy enforces it at prompt admission, constant per host boot.
|
||||||
|
* Clients pre-check count and bytes at intake and show the limits in
|
||||||
|
* upload affordances. Key absence means no attachment service is
|
||||||
|
* composed — clients skip the pre-check and let the host answer.
|
||||||
|
*/
|
||||||
|
imageLimits: ImageAttachmentLimits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
declare module '@deepseek-ai/dsh-llm' {
|
declare module '@deepseek-ai/dsh-llm' {
|
||||||
interface MessageSourceMap {
|
interface MessageSourceMap {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { Context } from '@deepseek-ai/cordis'
|
import { Context } from '@deepseek-ai/cordis'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||||
|
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
@@ -86,6 +87,51 @@ describe('session.history projections block', () => {
|
|||||||
expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq)
|
expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('publishes the attachments imageLimits as a constant unit while both seams are composed', async () => {
|
||||||
|
const { ctx, session } = await harness(true)
|
||||||
|
const limits = {
|
||||||
|
maxImageBytes: 5 * 1024 * 1024,
|
||||||
|
maxImagesPerMessage: 20,
|
||||||
|
maxMessageImageBytes: 100 * 1024 * 1024,
|
||||||
|
maxImagePixels: 40_000_000,
|
||||||
|
mediaTypes: ['image/png'] as const,
|
||||||
|
}
|
||||||
|
await ctx.plugin(class extends AttachmentStore {
|
||||||
|
readonly imageLimits = limits
|
||||||
|
validateImage(): Promise<void> { return Promise.resolve() }
|
||||||
|
saveImage(): Promise<never> { return Promise.reject(new Error('unused')) }
|
||||||
|
readImage(): Promise<never> { return Promise.reject(new Error('unused')) }
|
||||||
|
})
|
||||||
|
const gateway = api(ctx)
|
||||||
|
seedMessages(session, 2)
|
||||||
|
const response = await gateway.sessions.history(request({ sessionId: session.id }))
|
||||||
|
if (!response.result.ok) throw new Error('history failed')
|
||||||
|
expect(response.result.value.projections?.values['imageLimits']).toEqual(limits)
|
||||||
|
// Constant unit: appending events must never broadcast an imageLimits frame.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
const abort = new AbortController()
|
||||||
|
const stream = gateway.events.mux({ rpcId: RpcId('t-limits-mux'), payload: {} }, abort.signal)
|
||||||
|
const frames: MuxFrame[] = []
|
||||||
|
const drained = (async () => {
|
||||||
|
for await (const envelope of stream) {
|
||||||
|
frames.push(envelope.payload)
|
||||||
|
if (frames.some(f => f.type === 'session/event')) abort.abort()
|
||||||
|
}
|
||||||
|
})().catch(() => {})
|
||||||
|
seedMessages(session, 1)
|
||||||
|
await drained
|
||||||
|
expect(frames.some(f => f.type === 'session/projection' && f.key === 'imageLimits')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the imageLimits key absent while no attachment service is composed', async () => {
|
||||||
|
const { ctx, session } = await harness(true)
|
||||||
|
seedMessages(session, 1)
|
||||||
|
const response = await api(ctx).sessions.history(request({ sessionId: session.id }))
|
||||||
|
if (!response.result.ok) throw new Error('history failed')
|
||||||
|
expect(response.result.value.projections).toBeDefined()
|
||||||
|
expect('imageLimits' in (response.result.value.projections?.values ?? {})).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
|
it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
|
||||||
const { ctx, session } = await harness(true)
|
const { ctx, session } = await harness(true)
|
||||||
ctx.sessionProjections.register(lastUserUnit())
|
ctx.sessionProjections.register(lastUserUnit())
|
||||||
|
|||||||
Reference in New Issue
Block a user