diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 44a98094b..9c9a55663 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -74,7 +74,7 @@ - 附件选择弹窗使用 `PlatformToolModalShell` 承接 portal 主题变量和不透明 panel 背景;不能直接把未注入 `platform-theme` 的 `UnifiedModal` portal 到 `document.body`,否则 `--platform-modal-fill` 失效后面板会变透明。 - 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。 - 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`。 -- 输入区附件临时状态统一收口到 `useConversationAttachments`,选择弹窗由独立的 `AttachmentPicker` 负责纯展示;选择、引用、粘贴上传完成、移除、发送清空和失败恢复都必须经同一最新状态更新入口。引用历史消息附件时先保留消息中的展示快照,最终发送前再按 `source + referenceId` 从当前画布和素材库选项刷新,避免提前刷新后又被失败恢复的旧快照覆盖。发送失败时,已发送附件必须与等待期间新增的附件去重合并,不得因输入区已非空而丢弃;合并后超过 9 张时优先保留等待期间的最新附件,不恢复失败请求的附件,并立即显示上限错误。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用。 +- 输入区附件临时状态统一收口到 `useConversationAttachments`,选择弹窗由独立的 `AttachmentPicker` 负责纯展示;选择、引用、粘贴上传完成、移除、发送清空和失败恢复都必须经同一最新状态更新入口。引用历史消息附件时先保留消息中的展示快照,最终发送前再按 `source + referenceId` 从当前画布和素材库选项刷新,避免提前刷新后又被失败恢复的旧快照覆盖。发送失败时,已发送附件必须与等待期间新增的附件去重合并,不得因输入区已非空而丢弃;同一 `source + referenceId` 冲突时保留等待期间的当前草稿快照,失败请求快照只补充缺失 identity。合并后超过 9 张时优先保留等待期间的最新附件,不恢复失败请求的附件,并立即显示上限错误。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用。 ## 工具调用确认展示契约 diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index 4f49fd310..edafe0bab 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -1222,6 +1222,120 @@ describe('EditorAgentConversationPanelView', () => { expect(screen.getByText('附件 B')).toBeTruthy(); }); + it('keeps the current snapshot when failed recovery has the same attachment identity', async () => { + const client = createClient(); + let rejectSend!: (reason?: unknown) => void; + vi.mocked(client.sendMessage).mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectSend = reject; + }), + ); + const oldLayer = { + id: 'layer-updated', + resourceId: 'resource-updated', + title: '旧版附件', + src: '/generated-editor-images/old.png', + objectKey: 'editor/old.png', + x: 0, + y: 0, + width: 512, + height: 512, + originalWidth: 512, + originalHeight: 512, + zIndex: 1, + sourceType: 'generated' as const, + }; + const currentLayer = { + ...oldLayer, + title: '当前附件', + src: '/generated-editor-images/current.png', + objectKey: 'editor/current.png', + }; + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + }); + fireEvent.click(screen.getByRole('button', { name: '添加附件' })); + let attachmentDialog = screen.getByRole('dialog', { + name: '选择图片附件', + }); + fireEvent.click( + within(attachmentDialog).getByRole('checkbox', { + name: '选择画布图片 旧版附件', + }), + ); + fireEvent.click( + within(attachmentDialog).getByRole('button', { name: '应用' }), + ); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + + await waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledWith( + 'conversation-1', + expect.objectContaining({ + attachments: [ + expect.objectContaining({ + referenceId: 'resource-updated', + objectKey: 'editor/old.png', + }), + ], + }), + expect.any(Object), + ); + }); + + rerender( + , + ); + fireEvent.click(screen.getByRole('button', { name: '添加附件' })); + attachmentDialog = screen.getByRole('dialog', { + name: '选择图片附件', + }); + fireEvent.click( + within(attachmentDialog).getByRole('checkbox', { + name: '选择画布图片 当前附件', + }), + ); + fireEvent.click( + within(attachmentDialog).getByRole('button', { name: '应用' }), + ); + + await act(async () => { + rejectSend(new Error('Network error')); + }); + + expect(await screen.findByText('Network error')).toBeTruthy(); + expect(screen.getByText('当前附件')).toBeTruthy(); + expect(screen.queryByText('旧版附件')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '发送' })); + await waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledTimes(2); + expect( + vi.mocked(client.sendMessage).mock.calls[1]?.[1].attachments, + ).toEqual([ + expect.objectContaining({ + referenceId: 'resource-updated', + objectKey: 'editor/current.png', + }), + ]); + }); + }); + it('keeps the latest nine attachments when failed attachment recovery would exceed the limit', async () => { const client = createClient(); let rejectSend!: (reason?: unknown) => void; diff --git a/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts b/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts index 130b095e0..64a233cd7 100644 --- a/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts +++ b/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts @@ -372,9 +372,10 @@ export function useConversationAttachments({ const restoreAttachments = useCallback( (failedAttachments: EditorAgentAttachmentRef[]) => { updateAttachments((currentAttachments) => { + // respect current (version) when duplicated with old ones const restoredAttachments = mergeAttachments( - failedAttachments, currentAttachments, + failedAttachments, ); if (restoredAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) { setAttachmentError(