diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 3f3262153..f2c1ca6aa 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -114,7 +114,7 @@ 5. 生成中的进行中动画; 6. 错误气泡(失败/余额不足,带原因); 7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。 -8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体。右键动作由消息气泡的 `useRightClickMenu` 内部执行,不向上层暴露消息文本、素材地址或私有对象键。 +8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区。引用复用附件去重、9 张上限和发送链路;右键菜单只保留已有的 `objectKey` 与素材 `source`,引用时由输入区优先按 `objectKey`、缺失时按 `source` 从当前画布和素材库选项重新查询并构造 `EditorAgentAttachmentRef`,同时命中时画布优先。 不做(明确排除,防止后人补齐): diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index 737068d1e..abd98c53b 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -306,6 +306,103 @@ describe('EditorAgentConversationPanelView', () => { ); }); + it('adds a generated result to the composer through the existing right click menu', async () => { + const client = createClient(); + 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-generated-1', + objectKey: 'editor/generated-1.png', + imageSrc: '/generated-editor-images/generated-1.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 消息流', + }); + 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('Agent生成图片-1')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + + await waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledWith( + 'conversation-1', + expect.objectContaining({ + text: '', + attachments: [ + expect.objectContaining({ + source: 'canvas_resource', + referenceId: 'resource-generated-1', + objectKey: 'editor/generated-1.png', + imageSrc: '/generated-editor-images/generated-1.png', + }), + ], + }), + expect.any(Object), + ); + }); + }); + it('disables sending while a message request is pending without showing stop', async () => { const client = createClient(); let resolveSend!: (response: EditorAgentMessageResponse) => void; diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 7846c3b3c..433d9539d 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -25,7 +25,10 @@ import { PlatformActionButton } from '@/src/components/common/PlatformActionButt import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDangerConfirmDialog.tsx'; import { UnifiedModal } from '@/src/components/common/UnifiedModal.tsx'; import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx'; -import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts'; +import { + attachmentKey, + type EditorAgentContextAsset, +} from '@/src/components/image-editor/EditorAgentConversation/common.ts'; import { MessageBubble, ThinkingBubble, @@ -387,6 +390,29 @@ export function EditorAgentConversationPanelView({ return true; }; + const referenceContextAsset = (asset: EditorAgentContextAsset) => { + const objectKey = asset.objectKey?.trim(); + const source = asset.source.trim(); + if (!objectKey && !source) { + return false; + } + const options = [...canvasAttachmentOptions, ...libraryAttachmentOptions]; + const objectKeyOption = objectKey + ? options.find( + ({ attachment }) => attachment.objectKey?.trim() === objectKey, + ) + : undefined; + const option = + objectKeyOption || + options.find(({ attachment }) => { + return ( + attachment.imageSrc.trim() === source || + attachment.thumbnailSrc?.trim() === source + ); + }); + return option ? appendAttachments([option.attachment]) : false; + }; + const createPastedAgentImageAttachment = async ( file: File, ): Promise => { @@ -608,6 +634,7 @@ export function EditorAgentConversationPanelView({ } onConfirmToolCall={confirmToolCall} onCancelToolCall={cancelToolCall} + onReferenceImage={referenceContextAsset} onJobCompleted={() => { void refreshActiveConversation(); onCanvasRefreshRequested?.(); diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx index 3a083b04f..6b02603ff 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx @@ -20,13 +20,17 @@ vi.mock('@/src/services/clipboard.ts', () => ({ copyTextToClipboard: vi.fn(), })); -function renderMessage(message: EditorAgentMessage) { +function renderMessage( + message: EditorAgentMessage, + onReferenceImage?: Parameters[0]['onReferenceImage'], +) { return render( , ); } @@ -182,6 +186,99 @@ describe('MessageBubble', () => { expect(bitmapClose).toHaveBeenCalledTimes(1); }); + it('references a historical attachment through the existing asset menu', async () => { + const attachment = { + source: 'canvas_resource' as const, + referenceId: 'resource-reference-1', + objectKey: 'editor/reference.png', + imageSrc: '/generated-editor-images/reference.png', + label: '历史参考图', + }; + const onReferenceImage = vi.fn(() => true); + const { container } = renderMessage( + { + id: 7, + role: 'user', + text: '参考这张图', + attachments: [attachment], + toolCall: null, + createdAt: '2026-07-20T00:00:00Z', + }, + onReferenceImage, + ); + + fireEvent.contextMenu(container.querySelector('.group')!, { + clientX: 30, + clientY: 40, + }); + fireEvent.click(screen.getByRole('menuitem', { name: '引用' })); + + expect(onReferenceImage).toHaveBeenCalledWith( + expect.objectContaining({ + source: '/generated-editor-images/reference.png', + objectKey: 'editor/reference.png', + }), + ); + expect( + await screen.findByRole('menuitem', { name: '已引用' }), + ).toBeTruthy(); + }); + + it('offers reference for generated images using their media lookup fields', async () => { + const message = { + id: 8, + role: 'system' as const, + text: 'internal tool result', + attachments: [], + toolCall: { + toolName: 'generate_image', + status: 'completed' as const, + args: {}, + displayArgs: { + stringArgs: [], + imageArgs: [], + extras: { priceMudPoints: 1 }, + }, + images: [ + { + resourceId: 'resource-generated-1', + objectKey: 'editor/generated.png', + imageSrc: '/generated-editor-images/generated.png', + width: 512, + height: 512, + }, + { + objectKey: 'editor/legacy.png', + imageSrc: '/generated-editor-images/legacy.png', + }, + ], + }, + createdAt: '2026-07-20T00:00:00Z', + }; + const onReferenceImage = vi.fn(() => true); + const { container } = renderMessage(message, onReferenceImage); + await waitFor(() => + expect(container.querySelectorAll('.grid.grid-cols-3 > div')).toHaveLength( + 2, + ), + ); + const imageCards = container.querySelectorAll('.grid.grid-cols-3 > div'); + + fireEvent.contextMenu(imageCards[0]!, { clientX: 30, clientY: 40 }); + fireEvent.click(screen.getByRole('menuitem', { name: '引用' })); + expect(onReferenceImage).toHaveBeenCalledWith( + expect.objectContaining({ + source: '/generated-editor-images/generated.png', + objectKey: 'editor/generated.png', + }), + ); + + fireEvent.contextMenu(imageCards[1]!, { clientX: 50, clientY: 60 }); + expect(screen.getByRole('menuitem', { name: '引用' })).toBeTruthy(); + expect(screen.getByRole('menuitem', { name: '复制图片' })).toBeTruthy(); + expect(screen.getByRole('menuitem', { name: '下载图片' })).toBeTruthy(); + }); + it('downloads generated video with the inferred extension', async () => { const createObjectURL = vi.fn(() => 'blob:agent-video'); const revokeObjectURL = vi.fn(); diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx index e62e4796b..a6796519f 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx @@ -3,7 +3,10 @@ import { type EditorAgentMessage, } from '@/packages/shared/src/contracts'; import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx'; -import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts'; +import { + attachmentKey, + type EditorAgentContextAsset, +} from '@/src/components/image-editor/EditorAgentConversation/common.ts'; import { PendingToolCall } from '@/src/components/image-editor/EditorAgentConversation/PendingToolCall.tsx'; import ToolCallView from '@/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx'; @@ -48,6 +51,8 @@ type MessageBubbleProps = { onConfirmToolCall: (messageId: number) => Promise; onCancelToolCall: (messageId: number) => Promise; onJobCompleted?: () => void; + // TODO prop drilling + onReferenceImage?: (asset: EditorAgentContextAsset) => boolean; }; export function MessageBubble({ @@ -56,13 +61,14 @@ export function MessageBubble({ onConfirmToolCall, onCancelToolCall, onJobCompleted, + onReferenceImage, }: MessageBubbleProps) { const { rightClickMenu, openRightClickMenu, closeRightClickMenu, runRightClickAction, - } = useRightClickMenu(); + } = useRightClickMenu({ onReferenceImage }); const systemErrorText = message.role === 'system' && !message.toolCall && diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx index 9857dc1b5..689cf9663 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx @@ -36,7 +36,9 @@ function actionLabel({ if (pendingAction === action) { return action === EditorAgentRightClickAction.DownloadAsset ? '下载中' - : '复制中'; + : action === EditorAgentRightClickAction.ReferenceImage + ? '引用中' + : '复制中'; } if (resultAction !== action) { return idleLabel; @@ -44,12 +46,16 @@ function actionLabel({ if (result === 'success') { return action === EditorAgentRightClickAction.DownloadAsset ? '已下载' - : '已复制'; + : action === EditorAgentRightClickAction.ReferenceImage + ? '已引用' + : '已复制'; } if (result === 'error') { return action === EditorAgentRightClickAction.DownloadAsset ? '下载失败' - : '复制失败'; + : action === EditorAgentRightClickAction.ReferenceImage + ? '引用失败' + : '复制失败'; } return idleLabel; } @@ -161,20 +167,40 @@ export function MessageBubbleRightClickMenu({ ) : ( <> {target.asset.mediaType === 'image' ? ( - + <> + {target.asset.source.trim() ? ( + + ) : null} + + ) : null}