diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md
index 51eb670df..34779120b 100644
--- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md
+++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md
@@ -117,7 +117,7 @@
5. 生成中的进行中动画;
6. 错误气泡(失败/余额不足,带原因);
7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。
-8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区。引用复用附件去重、9 张上限和发送链路;右键菜单只保留已有的 `objectKey` 与素材 `source`,引用时由输入区优先按 `objectKey`、缺失时按 `source` 从当前画布和素材库选项重新查询并构造 `EditorAgentAttachmentRef`,同时命中时画布优先。
+8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区。引用复用附件去重、9 张上限和发送链路;
9. 消息右键菜单遵循 Canva 式单实例交互:任一菜单已打开时,下一次右键必须先关闭旧菜单;新落点是消息正文或素材时再在新位置打开对应菜单,新落点没有右键动作时仅收起旧菜单,不允许多个消息菜单并存。复制、引用或下载成功后自动关闭菜单;失败时保留菜单和失败状态,避免错误无提示消失。
不做(明确排除,防止后人补齐):
diff --git a/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx b/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx
index b5596b7a5..b4e9cfaeb 100644
--- a/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx
+++ b/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx
@@ -22,9 +22,9 @@ function AttachmentChip({
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
+ ...attachment,
+ kind: 'attachment',
mediaType: 'image',
- source: attachment.imageSrc,
- objectKey: attachment.objectKey,
suggestedFileName: label,
})
: undefined
diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx
index 26e249099..ef29dcfb7 100644
--- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx
+++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx
@@ -531,6 +531,154 @@ describe('EditorAgentConversationPanelView', () => {
expect(screen.queryByRole('option', { name: 'conversation-1' })).toBeNull();
});
+ it('references a historical pasted attachment outside the current canvas', async () => {
+ const client = createClient();
+ vi.mocked(client.getConversation).mockResolvedValue({
+ conversationId: 'conversation-1',
+ projectId: 'project-1',
+ title: '历史附件',
+ messages: [
+ {
+ id: 8,
+ role: 'user',
+ text: '参考这张粘贴图',
+ attachments: [
+ {
+ source: 'canvas_resource',
+ referenceId: 'resource-historical-paste',
+ objectKey: 'editor/agent-paste/historical.png',
+ imageSrc: '/generated/historical-paste.png',
+ thumbnailSrc: null,
+ label: '历史粘贴图',
+ width: 320,
+ height: 240,
+ },
+ ],
+ toolCall: null,
+ createdAt: '2026-07-20T00:00:00.000Z',
+ },
+ ],
+ createdAt: '2026-07-20T00:00:00.000Z',
+ updatedAt: '2026-07-20T00:00:10.000Z',
+ });
+
+ render(
+ ,
+ );
+
+ const historicalAttachment = await screen.findByText('历史粘贴图');
+ fireEvent.contextMenu(historicalAttachment.closest('.group')!);
+ fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
+ await waitFor(() => {
+ expect(screen.getAllByText('历史粘贴图')).toHaveLength(2);
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: '发送' }));
+ await waitFor(() => {
+ expect(client.sendMessage).toHaveBeenCalledWith(
+ 'conversation-1',
+ expect.objectContaining({
+ attachments: [
+ {
+ source: 'canvas_resource',
+ referenceId: 'resource-historical-paste',
+ objectKey: 'editor/agent-paste/historical.png',
+ imageSrc: '/generated/historical-paste.png',
+ thumbnailSrc: null,
+ label: '历史粘贴图',
+ width: 320,
+ height: 240,
+ },
+ ],
+ }),
+ expect.any(Object),
+ );
+ });
+ });
+
+ it('prefers the current attachment matched by source and reference id', async () => {
+ const client = createClient();
+ vi.mocked(client.getConversation).mockResolvedValue({
+ conversationId: 'conversation-1',
+ projectId: 'project-1',
+ title: '附件更新',
+ messages: [
+ {
+ id: 8,
+ role: 'user',
+ text: '参考旧版图片',
+ attachments: [
+ {
+ source: 'canvas_resource',
+ referenceId: 'resource-updated',
+ objectKey: 'editor/old.png',
+ imageSrc: '/generated/old.png',
+ label: '旧版附件',
+ },
+ ],
+ toolCall: null,
+ createdAt: '2026-07-20T00:00:00.000Z',
+ },
+ ],
+ createdAt: '2026-07-20T00:00:00.000Z',
+ updatedAt: '2026-07-20T00:00:10.000Z',
+ });
+
+ render(
+ ,
+ );
+
+ const historicalAttachment = await screen.findByText('旧版附件');
+ fireEvent.contextMenu(historicalAttachment.closest('.group')!);
+ fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
+ expect(await screen.findByText('最新附件')).toBeTruthy();
+
+ fireEvent.click(screen.getByRole('button', { name: '发送' }));
+ await waitFor(() => {
+ expect(client.sendMessage).toHaveBeenCalledWith(
+ 'conversation-1',
+ expect.objectContaining({
+ attachments: [
+ expect.objectContaining({
+ source: 'canvas_resource',
+ referenceId: 'resource-updated',
+ objectKey: 'editor/current.png',
+ imageSrc: '/generated/current.png',
+ }),
+ ],
+ }),
+ expect.any(Object),
+ );
+ });
+ });
+
it('keeps a referenced image when a pending paste upload finishes', async () => {
const client = createClient();
vi.mocked(client.getConversation).mockResolvedValue({
diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx
index 6aec36f33..2d9866437 100644
--- a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx
+++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx
@@ -284,7 +284,10 @@ describe('MessageBubble', () => {
expect(onReferenceImage).toHaveBeenCalledWith(
expect.objectContaining({
- source: '/generated-editor-images/reference.png',
+ kind: 'attachment',
+ source: 'canvas_resource',
+ referenceId: 'resource-reference-1',
+ imageSrc: '/generated-editor-images/reference.png',
objectKey: 'editor/reference.png',
}),
);
@@ -339,7 +342,8 @@ describe('MessageBubble', () => {
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
expect(onReferenceImage).toHaveBeenCalledWith(
expect.objectContaining({
- source: '/generated-editor-images/generated.png',
+ kind: 'generated_media',
+ mediaSrc: '/generated-editor-images/generated.png',
objectKey: 'editor/generated.png',
}),
);
diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx
index 0bbb3c367..743813b4f 100644
--- a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx
+++ b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
+ contextAssetMediaSrc,
type EditorAgentContextAsset,
EditorAgentRightClickAction,
type RightClickMenuTarget,
@@ -176,7 +177,7 @@ export function MessageBubbleRightClickMenu({
<>
{target.asset.mediaType === 'image' ? (
<>
- {target.asset.source.trim() ? (
+ {contextAssetMediaSrc(target.asset).trim() ? (