fix(web): 处理评审发现的图片记录边界情况

- 图片错误结果在 Result 页签保留错误名称与代码
- 空文本块加图片的记录按纯图片标注,不再空行
- sourceBlock 的持久化图片守卫检查 attachmentId 字段
- 移除 ui-chat 对 util/crypto 的过期 tsconfig 引用
- 同步 slots.md 层级图与 2026-08-20 所有权 Note
This commit is contained in:
creatixchu 2026-08-24 18:58:25 +08:00
parent c27de594fd
commit d420292400
14 changed files with 88 additions and 24 deletions

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md
2026-08-20-client-session-conversation-ownership.md: 8e5521ff1981d83ab72db00dea556b4b2acc97fa
2026-08-20-client-session-conversation-ownership.zh.md: a007a42b3d10ceeced8a2a64696521a96382f4e5
2026-08-20-client-session-conversation-ownership.md: 12e137209bb43d21c3437d2ce5e9d2f9180bbc77
2026-08-20-client-session-conversation-ownership.zh.md: f0d9861eeafc07481c536b85f5ba9667573a66ef

View file

@ -82,7 +82,7 @@ Adding a target does not add a branch to the renderer or Session Controller. The
| `client/ui-session` | Session scope, standard sources, `SessionProvider`, and pending-interaction aggregation | Session transport, Conversation assembly, Approval/Question results |
| `client/ui-workspace` | Workspace hook, browser UI, and cross-Controller navigation policy | Workspace transport, copies of Session data |
| `client/ui-conversation` | Conversation core, registries, bindings, shell, input, composer, queue, and View navigation | Session transport, Chat/Trajectory snapshots |
| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, locale, and historical images | Session lifecycle, generic View navigation, Trajectory |
| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, and locale | Session lifecycle, generic View navigation, Trajectory, historical-image cache |
| `client/ui-trajectory` | Trajectory target, event-record projection, and inspection view | Session snapshots, Chat snapshots |
| `client/ui-approval` | Pending Approval, Remote listener, composer, and approval UI | Session control, generic composer election |
| `client/ui-user-questions` | Pending Question, Remote listener, composer, and question UI | Session control, generic composer election |
@ -296,13 +296,13 @@ Draft and input state belong to Conversation UI and do not enter the Session sna
### Chat owner
`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, Tool-inspection collaboration, and historical-image cache.
`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, and Tool-inspection collaboration.
It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatNodeSeat` and internal Chat consumers use `useChat` instead of passing `useConversation(snapshot => snapshot.views.get('chat'))`.
Only visible non-command Chat Nodes activate Chat. Ordinary command-only history keeps the Hero visible; the `/goal` `command-input` Node activates a fresh Conversation.
The historical-image cache's Session key, pending promise, generation guard, blob URL, and disposer all belong to `ui-chat`; draft images remain part of Conversation input.
The historical-image cache moved to `ui-conversation` (`ctx.uiConversation.imageUrl`), so Chat and Trajectory share one authorized read and one browser URL per session attachment ([Trajectory durable image attachments](../feature/2026-08-24-trajectory-image-attachments.md)); draft images remain part of Conversation input.
### Trajectory owner

View file

@ -82,7 +82,7 @@ UI 层可以同时读取多个 Controller 做一次导航决定,但不得把
| `client/ui-session` | Session scope、标准 source、`SessionProvider`、pending interaction 聚合 | Session transport、Conversation 组装、Approval/Question 结果 |
| `client/ui-workspace` | Workspace hook、浏览器 UI 和跨 Controller 导航策略 | Workspace transport、Session 数据副本 |
| `client/ui-conversation` | Conversation core、registry、binding、shell、input、composer、queue 和 View 导航 | Session transport、Chat/Trajectory snapshot |
| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details、locale 和历史图片 | Session 生命周期、通用 View 导航、Trajectory |
| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details 和 locale | Session 生命周期、通用 View 导航、Trajectory、历史图片 cache |
| `client/ui-trajectory` | Trajectory target、事件记录投影和检查视图 | Session snapshot、Chat snapshot |
| `client/ui-approval` | Pending Approval、Remote listener、composer 和审批 UI | Session control、通用 composer election |
| `client/ui-user-questions` | Pending Question、Remote listener、composer 和问题 UI | Session control、通用 composer election |
@ -296,13 +296,13 @@ Draft 与输入状态属于 Conversation UI,不进入 Session snapshot。Queue
### Chat owner
`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale、tool inspection 协作和历史图片 cache。
`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale 和 tool inspection 协作。
它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatNodeSeat` 和 Chat 内部消费者使用 `useChat`,不再传递 `useConversation(snapshot => snapshot.views.get('chat'))`。
Chat activity 只由可见且非 command 的 Chat Node 激活。普通 command-only history 保持 Hero,`/goal` 的 `command-input` Node 激活 fresh Conversation。
历史图片 cache 的 Session key、pending promise、generation guard、blob URL 和 disposer 同属 `ui-chat`;Draft 图片仍属于 Conversation input。
历史图片 cache 已移入 `ui-conversation`(`ctx.uiConversation.imageUrl`),Chat 与 Trajectory 对同一会话附件共享一次授权读取和一个浏览器 URL([Trajectory 持久化图片附件](../feature/2026-08-24-trajectory-image-attachments.zh.md));Draft 图片仍属于 Conversation input。
### Trajectory owner

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md
2026-08-24-trajectory-image-attachments.md: 4b79ecbb113a0f36b4cf31f9b0c68cb1fc013d13
2026-08-24-trajectory-image-attachments.zh.md: f7695ab322f80699f1f424d26d179415f0181efc
2026-08-24-trajectory-image-attachments.md: 6f89840a7d4686e45012ea2ae25f4cc939a5ca0c
2026-08-24-trajectory-image-attachments.zh.md: f1e8634639e42adc094021c03a339b8f8223bfff

View file

@ -10,7 +10,7 @@ Trajectory did not display session images. A durable `{ type: 'image', attachmen
## 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.
- `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](../architecture/2026-08-20-client-session-conversation-ownership.md).
- 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.

View file

@ -10,7 +10,7 @@ Trajectory 不展示会话图片。持久化的 `{ type: 'image', attachment: Im
## 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 释放而撤销。
- `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 所有权](../architecture/2026-08-20-client-session-conversation-ownership.zh.md)中记录的 `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 转储。

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/slots.md
slots.md: d201223c9e630f16f310d8ff90318ab8c43211e5
slots.zh.md: 23277e94e2a9e85be7f1745a172dfd9cb8a5be37
slots.md: 6eb61780ca2f06ebc38a0fcf2638a7fafcd5ceee
slots.zh.md: e5a25763d382c228525f3da24694e41dd09737e0

View file

@ -134,7 +134,8 @@ root
│ │ │ ├─ conversation.chat.turnTail
│ │ │ └─ tool.call.toolview
│ │ │ └─ tool.view.cordis
│ │ └─ conversation.message.images
│ │ ├─ conversation.message.images
│ │ └─ conversation.trajectory.images
│ ├─ conversation.session.header
│ │ ├─ conversation.session.header.lineage
│ │ ├─ conversation.session.header.actions

View file

@ -134,7 +134,8 @@ root
│ │ │ ├─ conversation.chat.turnTail
│ │ │ └─ tool.call.toolview
│ │ │ └─ tool.view.cordis
│ │ └─ conversation.message.images
│ │ ├─ conversation.message.images
│ │ └─ conversation.trajectory.images
│ ├─ conversation.session.header
│ │ ├─ conversation.session.header.lineage
│ │ ├─ conversation.session.header.actions

View file

@ -50,9 +50,6 @@
{
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../util/crypto"
},
{
"path": "../../util/workspace-path"
},

View file

@ -1160,6 +1160,8 @@ function SourceBlocks({
</span>
</div>
)}
{/* The Raw view keeps model block order and granularity: one
gallery per image block, unlike the aggregated record gallery. */}
{block.attachment !== undefined
? renderImages({ images: [{ attachment: block.attachment }], align: 'start' })
: <pre className={css.sourceBlockContent}>{block.content}</pre>}
@ -1388,11 +1390,14 @@ function SystemPromptDiff({
function ToolOutputBlocks({
blocks,
error,
errorDetail,
preview,
renderImages,
}: {
blocks: readonly TrajectorySourceBlock[]
error: boolean
/** Failure name and code preserved beside image-only error content. */
errorDetail?: string | undefined
preview: boolean
renderImages: RenderMessageImages
}) {
@ -1403,6 +1408,8 @@ function ToolOutputBlocks({
error ? css.errorPayload : undefined,
].filter((value): value is string => value !== undefined).join(' ')}
>
{error && errorDetail !== undefined && errorDetail !== ''
&& <pre className={css.resultBlockText}>{errorDetail}</pre>}
{blocks.map((block, index) => (
block.attachment !== undefined
? (
@ -1630,6 +1637,7 @@ function RecordPayload({
<ToolOutputBlocks
blocks={record.cell.outputBlocks}
error={error}
errorDetail={error ? value : undefined}
preview={preview}
renderImages={renderImages}
/>

View file

@ -120,7 +120,10 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick<
| 'timeSeconds'
| 'startedAt'
> {
const previewMarkdown = previewContent(node.content)
// An empty text block yields an empty preview; treat it as absent so an
// image-bearing record still labels its row instead of rendering blank.
const preview = previewContent(node.content)
const previewMarkdown = preview === '' ? undefined : preview
const images = imageBlockCount(node.content)
return {
text: previewMarkdown === undefined && images > 0
@ -792,7 +795,7 @@ function summarizeAssistantActivity(
if (tools.size > 0) {
return t('layout.toolCallOnly')
}
const images = imageBlockCount(blocks.map(block => ({ type: block.kind })))
const images = blocks.filter(block => block.kind === 'image').length
if (images > 0) return t('layout.imageOnly', { count: images })
return ''
}
@ -828,9 +831,14 @@ function sourceBlock(value: unknown): TrajectorySourceBlock {
if (typeof block.text === 'string') {
return { type: type === 'reasoning' ? 'thinking' : type, content: block.text }
}
if (type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
// Typed content only reaches here as a core ImageBlock; wire-shaped
// 'other' blocks never define `attachment`.
if (
type === 'image'
&& typeof block.attachment === 'object' && block.attachment !== null
&& typeof (block.attachment as Record<string, unknown>).attachmentId === 'string'
) {
// Session-log content is validated into core ContentBlocks by the
// Conversation node assembly; the `attachmentId` guard only keeps
// wire-shaped 'other' blocks with an unrelated `attachment` member out.
return { type, content: '', attachment: block.attachment as ImageAttachmentRef }
}
return { type, content: stringifySourceValue(value) }

View file

@ -594,6 +594,19 @@ describe('durable image attachments', () => {
])
})
it('labels a record whose only text block is empty as image-only', () => {
const nodes = [
{
kind: 'user', seq: 1, time: 1_000, source: null,
content: [{ type: 'text', text: '' }, { type: 'image', attachment }],
},
] as unknown as LegacyConversationSlice['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const user = turns[0]?.groups[0]?.cells[0]
expect(user?.text).toBe('Images ×1')
expect(user?.previewMarkdown).toBeUndefined()
})
it('keeps the text preview when a user message mixes text and images', () => {
const nodes = [
{

View file

@ -1035,6 +1035,42 @@ describe('TrajectoryTable', () => {
.toBe(String(attachment.attachmentId))
})
it('keeps the failure name beside an image-only error result', () => {
const attachment = {
attachmentId: `sha256:${'c'.repeat(64)}`,
mediaType: 'image/png',
bytes: 68,
width: 320,
height: 320,
name: 'failed.png',
} as unknown as NonNullable<
NonNullable<TrajectoryTurnModel['groups'][number]['cells'][number]['outputBlocks']>[number]['attachment']
>
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'tool',
text: 'render {"target":"chart"}',
outputDetail: 'ToolError: RENDER_TRUNCATED',
outputBlocks: [{ type: 'image', content: '', attachment }],
isError: true,
timeSeconds: 0.1,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
expect(screen.getByText('ToolError: RENDER_TRUNCATED')).toBeTruthy()
const gallery = screen.getAllByTestId('record-images').at(-1)
expect(gallery?.getAttribute('data-count')).toBe('1')
})
it('keeps the first row and a compact summary when a turn is collapsed', () => {
render(
<TrajectoryTable