fix(web): 处理评审发现的图片记录边界情况
- 图片错误结果在 Result 页签保留错误名称与代码 - 空文本块加图片的记录按纯图片标注,不再空行 - sourceBlock 的持久化图片守卫检查 attachmentId 字段 - 移除 ui-chat 对 util/crypto 的过期 tsconfig 引用 - 同步 slots.md 层级图与 2026-08-20 所有权 Note
This commit is contained in:
parent
c27de594fd
commit
d420292400
14 changed files with 88 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 转储。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -50,9 +50,6 @@
|
|||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../util/crypto"
|
||||
},
|
||||
{
|
||||
"path": "../../util/workspace-path"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue