feat(web): whole-page image drop, projected intake limits, and thumbnail tiling
Second alignment step for #2248: document-level drag intake behind the new DropOverlay atom, lightbox close icon and shared dialog mask, DeepSeek Chat thumbnail rules (single 240px long edge with ratio clamp, 64px tiles, merged consecutive assistant images), image limits raised to 20/10MiB/100MiB and published to clients as the imageLimits projection, whole-batch intake pre-check with product-copy banners, and attachment-error reasons mapped to localized copy.
This commit is contained in:
@@ -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: f1621cd37811a7df19802c8b6abc9b616434012e
|
||||
2026-08-12-web-image-intake-and-limits-alignment.zh.md: 7224fa850bc1d063cf7bd8eceb2bb75e9226b2e4
|
||||
@@ -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 raised and projected.** Defaults move to 20 images / 10 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). The numbers follow the market and the physics: consumer products cluster at 10–20 attachments (ChatGPT 10, Gemini 10, Claude 20; DeepSeek Chat's 50 is the outlier), a vision-model image costs roughly 1300–4800 tokens so 50 images can fill a 200k context in one message, Anthropic applies stricter per-image dimension limits above 20 per request, and 10 MiB matches Claude's per-image cap. 512 MiB aggregate (proposed initially) dies at the transport regardless of preference: 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).
|
||||
@@ -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 张、单图 10 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 上下文,Anthropic 在每请求超过 20 张时启用更严的单图尺寸限制,10 MiB 与 Claude 的单图上限一致。最初提议的 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)。
|
||||
Reference in New Issue
Block a user