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}