DSH / Atlas
2026-08-24implementedfeaturearchived 2026-09-04

Trajectory durable image attachments

Trajectory 持久化图片附件

Trajectory did not display session images. A durable `{ type: 'image', attachment: ImageAttachmentRef }` block rendered as pretty-printed JSON in the details panel, and an image-only user message produced an empty ledger row. The only image path Trajectory knew was `imageSrc` sniffing over inline wire fields (`url`, `image_url`, base64 `data`), which no production event carries: every producer commits a durable `Imag

English

Problem

Trajectory did not display session images. A durable { type: 'image', attachment: ImageAttachmentRef } block rendered as pretty-printed JSON in the details panel, and an image-only user message produced an empty ledger row. The only image path Trajectory knew was imageSrc sniffing over inline wire fields (url, image_url, base64 data), which no production event carries: every producer commits a durable ImageAttachmentRef before its event is appended. Users could not confirm from the execution ledger which image the model saw (issue #2986), while Chat already displayed the same attachments.

Decision

  • ui-conversation owns the per-session durable image URL cache. HistoricalImageCache moved from ui-chat into packages/client/ui-conversation/src/client/conversation/historical-images.ts and is served as ctx.uiConversation.imageUrl(sessionId, attachment). Chat and Trajectory resolve through the same instance, so one session attachment costs one session.attachment read and one browser URL, revoked when the Session binding is released. This partially supersedes the ui-chat cache ownership recorded in client Session/Conversation ownership.
  • The gallery owner contract (MessageImagesOwnerProps, RenderMessageImages) moved to the ui-conversation client contract. ui-chat keeps its conversation.message.images SlotMap row over the shared owner type; ui-trajectory declares its own child slot conversation.trajectory.images with the same owner type; ui-attachment registers the one MessageImages gallery component into both keys, so loading, retry, and lightbox behavior is identical in both views.
  • TrajectorySourceBlock carries attachment?: ImageAttachmentRef instead of imageSrc/imageAlt. The inline-source sniffing (sourceImage, safeImageSource) and the Trajectory-local PanelImage renderer are removed: no producer writes inline image bytes or URLs into the session log, so those paths were dead code, and the issue explicitly excludes upload-time transient paths.
  • A record whose content has images but no text labels its ledger row with the locale-owned layout.imageOnly count; tool results with only images use the same label for their result summary instead of a JSON dump.
  • Neither the storage nor the BFF changes: session.attachment already authorizes by session-log reference (missing, corrupt, and unreferenced attachments fail loud into the gallery's retry state), and sha256 content addressing already stores each image once.

Alternatives considered

Keep Trajectory's own <img> rendering and feed it resolved URLs. This duplicates the loading placeholder, retry control, and lightbox that ui-attachment already owns, and contradicts slot-based attachment ownership, which rejected cross-plugin component imports.

Lift the conversation.message.images declaration to a shared parent so both views render one key. renderSlot is typed to the declaring entry's own children table, so a sibling conversation.view entry cannot render another entry's child key; the slot registry also rejects a second declaration of the same key. A second key sharing the owner type is the supported composition and lets a theme replace either gallery independently.

Keep the inline imageSrc sniffing beside the durable path. All producers (host prompt admission, read_image, MCP projection, ACP ingress) commit durable refs before their events append, so the sniffing matched nothing; keeping it would preserve a non-durable rendering path the acceptance criteria exclude.

A Trajectory-owned image cache. A second cache per view issues duplicate session.attachment RPCs and duplicate blob URLs for the same session attachment, violating the "Chat and Trajectory reference the same session attachment" requirement for no benefit.

Consequences

  • Both views present one gallery implementation, so image behavior (sizing, retry, lightbox, labels) cannot drift between Chat and Trajectory, and a session attachment is read once regardless of how many views show it.
  • TrajectoryTable threads a required renderImages prop through its detail components; ui-trajectory gains a type-only dependency on dsh-attachment, and ui-attachment gains a type-only dependency on ui-trajectory for the new SlotMap row.
  • The keyless assembled snapshot apps/web/tests/trajectory-image-display.snapshot.ts pins the shared-cache fact directly: the details-panel image URL is string-identical to the Chat gallery's URL for the same fixture attachment.

中文

Problem

Trajectory 不展示会话图片。持久化的 { type: 'image', attachment: ImageAttachmentRef } 块在详情面板里渲染成格式化 JSON,纯图片的用户消息在记录表中是一个空行。Trajectory 唯一认识的图片路径是对内联 wire 字段(url、image_url、base64 data)的 imageSrc 嗅探,而生产事件从不携带这些字段:每个生产方都在事件追加前提交持久化的 ImageAttachmentRef。用户无法从执行记录确认模型看到了哪张图(issue #2986),而 Chat 已经能展示同样的附件。

Decision

  • ui-conversation 拥有按会话的持久化图片 URL 缓存。HistoricalImageCache 从 ui-chat 移入 packages/client/ui-conversation/src/client/conversation/historical-images.ts,以 ctx.uiConversation.imageUrl(sessionId, attachment) 提供。Chat 与 Trajectory 通过同一实例解析,因此一个会话附件只产生一次 session.attachment 读取和一个浏览器 URL,并随 Session binding 释放而撤销。这部分取代了 client Session/Conversation 所有权中记录的 ui-chat 缓存归属。
  • 画廊 owner 契约(MessageImagesOwnerProps、RenderMessageImages)移入 ui-conversation 客户端契约。ui-chat 的 conversation.message.images SlotMap 行沿用共享 owner 类型;ui-trajectory 以同一 owner 类型声明自己的子槽位 conversation.trajectory.images;ui-attachment 把同一个 MessageImages 画廊组件注册进两个键,因此加载、重试与灯箱行为在两个视图中完全一致。
  • TrajectorySourceBlock 以 attachment?: ImageAttachmentRef 取代 imageSrc/imageAlt。内联来源嗅探(sourceImage、safeImageSource)与 Trajectory 本地的 PanelImage 渲染器一并删除:没有生产方向会话日志写入内联图片字节或 URL,这些路径是死代码,且 issue 明确排除上传来源的临时路径。
  • 内容含图片但没有文本的记录,其记录表行以 locale 持有的 layout.imageOnly 计数标注;只含图片的工具结果的摘要也使用同一标签,而不是 JSON 转储。
  • 存储与 BFF 均不改动:session.attachment 已按会话日志引用授权(缺失、损坏与未被引用的附件显式失败并进入画廊的重试态),sha256 内容寻址已保证每张图片只存一份。

Alternatives considered

保留 Trajectory 自己的 <img> 渲染并喂给它解析好的 URL。 这会重复 ui-attachment 已拥有的加载占位、重试控件和灯箱,并与基于 slot 的附件所有权相抵触,该决定已拒绝跨插件直接 import 组件。

把 conversation.message.images 的声明上提到共享父级,让两个视图渲染同一个键。 renderSlot 的类型限定在声明入口自己的 children 表内,同级的 conversation.view 入口无法渲染另一个入口的子键;slot registry 也拒绝对同一键的第二次声明。共享 owner 类型的第二个键是受支持的组合方式,且允许主题独立替换任一画廊。

在持久化路径之外保留内联 imageSrc 嗅探。 所有生产方(宿主 prompt admission、read_image、MCP 投影、ACP 入口)都在事件追加前提交持久化引用,嗅探不会命中任何东西;保留它等于保留验收标准明确排除的非持久化渲染路径。

Trajectory 自有的图片缓存。 每个视图一份缓存会对同一会话附件发出重复的 session.attachment RPC 和重复的 blob URL,违背"Chat 与 Trajectory 引用同一会话附件"的要求,且没有任何收益。

Consequences

  • 两个视图共用一个画廊实现,图片行为(尺寸、重试、灯箱、文案)不会在 Chat 与 Trajectory 之间漂移,且无论多少个视图展示,一个会话附件只读取一次。
  • TrajectoryTable 需要把必填的 renderImages prop 逐层传入详情组件;ui-trajectory 新增对 dsh-attachment 的仅类型依赖,ui-attachment 为新的 SlotMap 行新增对 ui-trajectory 的仅类型依赖。
  • keyless 组装快照 apps/web/tests/trajectory-image-display.snapshot.ts 直接钉住共享缓存这一事实:详情面板中的图片 URL 与 Chat 画廊对同一 fixture 附件的 URL 字符串相同。