From 791493db0e799eb0b280ac73419b7c566c2a2d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 21 Jul 2026 13:50:40 +0800 Subject: [PATCH] =?UTF-8?q?editor=20agent:=20fix=20A=20=E5=8F=91=E9=80=81?= =?UTF-8?q?=E4=B8=AD=E5=BC=95=E7=94=A8=20B=EF=BC=8C=E9=9A=8F=E5=90=8E?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=A4=B1=E8=B4=A5,=20B=20is=20lost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../【编辑器】画布Agent对话面板-2026-07-03.md | 2 +- .../EditorAgentConversationPanelView.test.tsx | 130 ++++++++++++++++++ .../EditorAgentConversationPanelView.tsx | 4 +- .../useConversationAttachments.ts | 6 +- 4 files changed, 136 insertions(+), 6 deletions(-) diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 34779120b..c51df984d 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` 负责纯展示;选择、引用、粘贴上传完成、移除、发送清空和失败恢复都必须经同一最新状态更新入口。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用。 +- 输入区附件临时状态统一收口到 `useConversationAttachments`,选择弹窗由独立的 `AttachmentPicker` 负责纯展示;选择、引用、粘贴上传完成、移除、发送清空和失败恢复都必须经同一最新状态更新入口。发送失败时,已发送附件必须与等待期间新增的附件去重合并,不得因输入区已非空而丢弃。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用。 ## 工具调用确认展示契约 diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index ef29dcfb7..a6934ce5a 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -1074,6 +1074,136 @@ describe('EditorAgentConversationPanelView', () => { ).toBeNull(); }); + it('merges sent attachments with references added while a failed request is pending', async () => { + const client = createClient(); + let rejectSend!: (reason?: unknown) => void; + vi.mocked(client.sendMessage).mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectSend = reject; + }), + ); + vi.mocked(client.getConversation).mockResolvedValue({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '失败恢复并发附件', + messages: [ + { + id: 9, + role: 'system', + text: 'internal tool result', + attachments: [], + toolCall: { + toolName: 'generate_image', + status: 'completed', + args: {}, + displayArgs: { + stringArgs: [], + imageArgs: [], + extras: { priceMudPoints: 1 }, + }, + images: [ + { + resourceId: 'resource-b', + objectKey: 'editor/attachment-b.png', + imageSrc: '/generated-editor-images/attachment-b.png', + width: 512, + height: 512, + }, + ], + }, + createdAt: '2026-07-20T00:00:00.000Z', + }, + ], + createdAt: '2026-07-20T00:00:00.000Z', + updatedAt: '2026-07-20T00:00:10.000Z', + }); + + render( + , + ); + + const messageLog = await screen.findByRole('log', { + name: '画布 Agent 消息流', + }); + fireEvent.click(screen.getByRole('button', { name: '添加附件' })); + const attachmentDialog = screen.getByRole('dialog', { + name: '选择图片附件', + }); + fireEvent.click( + within(attachmentDialog).getByRole('checkbox', { + name: '选择画布图片 附件 A', + }), + ); + 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-a' })], + }), + expect.any(Object), + ); + }); + await waitFor(() => + expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(), + ); + fireEvent.contextMenu( + messageLog.querySelector('.grid.grid-cols-3 > div')!, + { clientX: 30, clientY: 40 }, + ); + fireEvent.click(screen.getByRole('menuitem', { name: '引用' })); + expect(await screen.findByText('附件 B')).toBeTruthy(); + + await act(async () => { + rejectSend(new Error('Network error')); + }); + + expect(await screen.findByText('Network error')).toBeTruthy(); + expect(screen.getByText('附件 A')).toBeTruthy(); + expect(screen.getByText('附件 B')).toBeTruthy(); + }); + it('preserves messages when the panel is collapsed and reopened', async () => { const client = createClient(); vi.mocked(client.sendMessage).mockResolvedValue({ diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 94502ae59..23d1a3341 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -111,7 +111,7 @@ export function EditorAgentConversationPanelView({ handleInputPaste, removeAttachment, consumeAttachments, - restoreAttachmentsIfEmpty, + restoreAttachments, } = useConversationAttachments({ projectId, layers, assets }); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); @@ -134,7 +134,7 @@ export function EditorAgentConversationPanelView({ const nextAttachments = consumeAttachments(); void sendMessage(text, nextAttachments).catch(() => { setDraftText((currentText) => (currentText ? currentText : text)); - restoreAttachmentsIfEmpty(nextAttachments); + restoreAttachments(nextAttachments); }); }; diff --git a/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts b/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts index 626d56df5..7b9631ad4 100644 --- a/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts +++ b/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts @@ -365,10 +365,10 @@ export function useConversationAttachments({ return currentAttachments; }, [updateAttachments]); - const restoreAttachmentsIfEmpty = useCallback( + const restoreAttachments = useCallback( (failedAttachments: EditorAgentAttachmentRef[]) => { updateAttachments((currentAttachments) => - currentAttachments.length ? currentAttachments : failedAttachments, + mergeAttachments(failedAttachments, currentAttachments), ); }, [updateAttachments], @@ -392,6 +392,6 @@ export function useConversationAttachments({ handleInputPaste, removeAttachment, consumeAttachments, - restoreAttachmentsIfEmpty, + restoreAttachments, }; }