From 632d05cf55914a479f2faa7d53dfe15a4e9676ed 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 15:22:36 +0800 Subject: [PATCH] editor agent: add attachment count limit for recovery --- .../【编辑器】画布Agent对话面板-2026-07-03.md | 2 +- .../EditorAgentConversationPanelView.test.tsx | 94 +++++++++++++++++++ .../useConversationAttachments.ts | 17 +++- 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index c51df984d..ed45c26c1 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 张时优先保留等待期间的最新附件,不恢复失败请求的附件,并立即显示上限错误。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用。 ## 工具调用确认展示契约 diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index a6934ce5a..0059a0a00 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -1204,6 +1204,100 @@ describe('EditorAgentConversationPanelView', () => { expect(screen.getByText('附件 B')).toBeTruthy(); }); + it('keeps the latest nine attachments when failed attachment recovery would exceed the limit', async () => { + const client = createClient(); + let rejectSend!: (reason?: unknown) => void; + vi.mocked(client.sendMessage).mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectSend = reject; + }), + ); + const layers = Array.from({ length: 18 }, (_, index) => ({ + id: `layer-${index + 1}`, + resourceId: `resource-${index + 1}`, + title: `附件-${index + 1}`, + src: `/generated-editor-images/attachment-${index + 1}.png`, + objectKey: `editor/attachment-${index + 1}.png`, + x: 0, + y: 0, + width: 512, + height: 512, + originalWidth: 512, + originalHeight: 512, + zIndex: index + 1, + sourceType: 'generated' as const, + })); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + }); + fireEvent.click(screen.getByRole('button', { name: '添加附件' })); + let attachmentDialog = screen.getByRole('dialog', { + name: '选择图片附件', + }); + for (let index = 1; index <= 9; index += 1) { + fireEvent.click( + within(attachmentDialog).getByRole('checkbox', { + name: `选择画布图片 附件-${index}`, + }), + ); + } + 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.any(Array) }), + expect.any(Object), + ); + expect( + vi.mocked(client.sendMessage).mock.calls[0]?.[1].attachments, + ).toHaveLength(9); + }); + + fireEvent.click(screen.getByRole('button', { name: '添加附件' })); + attachmentDialog = screen.getByRole('dialog', { + name: '选择图片附件', + }); + for (let index = 10; index <= 18; index += 1) { + fireEvent.click( + within(attachmentDialog).getByRole('checkbox', { + name: `选择画布图片 附件-${index}`, + }), + ); + } + fireEvent.click( + within(attachmentDialog).getByRole('button', { name: '应用' }), + ); + + await act(async () => { + rejectSend(new Error('Network error')); + }); + + expect( + await screen.findByText('最多 9 张,发送失败的附件未恢复'), + ).toBeTruthy(); + for (let index = 1; index <= 9; index += 1) { + expect(screen.queryByText(`附件-${index}`)).toBeNull(); + } + for (let index = 10; index <= 18; index += 1) { + expect(screen.getByText(`附件-${index}`)).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/useConversationAttachments.ts b/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts index 7b9631ad4..5254cc524 100644 --- a/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts +++ b/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts @@ -367,9 +367,20 @@ export function useConversationAttachments({ const restoreAttachments = useCallback( (failedAttachments: EditorAgentAttachmentRef[]) => { - updateAttachments((currentAttachments) => - mergeAttachments(failedAttachments, currentAttachments), - ); + updateAttachments((currentAttachments) => { + const restoredAttachments = mergeAttachments( + failedAttachments, + currentAttachments, + ); + if (restoredAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) { + setAttachmentError( + `最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张,发送失败的附件未恢复`, + ); + return currentAttachments; + } + setAttachmentError(null); + return restoredAttachments; + }); }, [updateAttachments], );