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-conversationowns the per-session durable image URL cache.HistoricalImageCachemoved fromui-chatintopackages/client/ui-conversation/src/client/conversation/historical-images.tsand is served asctx.uiConversation.imageUrl(sessionId, attachment). Chat and Trajectory resolve through the same instance, so one session attachment costs onesession.attachmentread and one browser URL, revoked when the Session binding is released. This partially supersedes theui-chatcache ownership recorded in client Session/Conversation ownership.- The gallery owner contract (
MessageImagesOwnerProps,RenderMessageImages) moved to theui-conversationclient contract.ui-chatkeeps itsconversation.message.imagesSlotMap row over the shared owner type;ui-trajectorydeclares its own child slotconversation.trajectory.imageswith the same owner type;ui-attachmentregisters the oneMessageImagesgallery component into both keys, so loading, retry, and lightbox behavior is identical in both views. TrajectorySourceBlockcarriesattachment?: ImageAttachmentRefinstead ofimageSrc/imageAlt. The inline-source sniffing (sourceImage,safeImageSource) and the Trajectory-localPanelImagerenderer 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.imageOnlycount; 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.attachmentalready 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.
TrajectoryTablethreads a requiredrenderImagesprop through its detail components;ui-trajectorygains a type-only dependency ondsh-attachment, andui-attachmentgains a type-only dependency onui-trajectoryfor the new SlotMap row.- The keyless assembled snapshot
apps/web/tests/trajectory-image-display.snapshot.tspins 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.imagesSlotMap 行沿用共享 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需要把必填的renderImagesprop 逐层传入详情组件;ui-trajectory新增对dsh-attachment的仅类型依赖,ui-attachment为新的 SlotMap 行新增对ui-trajectory的仅类型依赖。- keyless 组装快照
apps/web/tests/trajectory-image-display.snapshot.ts直接钉住共享缓存这一事实:详情面板中的图片 URL 与 Chat 画廊对同一 fixture 附件的 URL 字符串相同。