add reference to right-click menu

This commit is contained in:
2026-07-20 18:14:06 +08:00
parent f86df0c12a
commit 26572cfc88
8 changed files with 287 additions and 24 deletions
@@ -114,7 +114,7 @@
5. 生成中的进行中动画;
6. 错误气泡(失败/余额不足,带原因);
7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。
8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体。右键动作由消息气泡的 `useRightClickMenu` 内部执行,不向上层暴露消息文本、素材地址或私有对象键。
8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区。引用复用附件去重、9 张上限和发送链路;右键菜单只保留已有的 `objectKey` 与素材 `source`,引用时由输入区优先按 `objectKey`、缺失时按 `source` 从当前画布和素材库选项重新查询并构造 `EditorAgentAttachmentRef`,同时命中时画布优先。
不做(明确排除,防止后人补齐):
@@ -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(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-generated-1',
resourceId: 'resource-generated-1',
title: 'Agent生成图片-1',
src: '/generated-editor-images/generated-1.png',
objectKey: 'editor/generated-1.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated',
},
]}
/>,
);
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;
@@ -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<EditorAgentAttachmentRef> => {
@@ -608,6 +634,7 @@ export function EditorAgentConversationPanelView({
}
onConfirmToolCall={confirmToolCall}
onCancelToolCall={cancelToolCall}
onReferenceImage={referenceContextAsset}
onJobCompleted={() => {
void refreshActiveConversation();
onCanvasRefreshRequested?.();
@@ -20,13 +20,17 @@ vi.mock('@/src/services/clipboard.ts', () => ({
copyTextToClipboard: vi.fn(),
}));
function renderMessage(message: EditorAgentMessage) {
function renderMessage(
message: EditorAgentMessage,
onReferenceImage?: Parameters<typeof MessageBubble>[0]['onReferenceImage'],
) {
return render(
<MessageBubble
message={message}
busyAction={null}
onConfirmToolCall={vi.fn()}
onCancelToolCall={vi.fn()}
onReferenceImage={onReferenceImage}
/>,
);
}
@@ -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();
@@ -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<void>;
onCancelToolCall: (messageId: number) => Promise<void>;
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 &&
@@ -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' ? (
<button
type="button"
role="menuitem"
disabled={pendingAction !== null}
onClick={() => onAction(EditorAgentRightClickAction.CopyImage)}
>
{actionLabel({
action: EditorAgentRightClickAction.CopyImage,
idleLabel: '复制图片',
pendingAction,
resultAction,
result,
})}
</button>
<>
{target.asset.source.trim() ? (
<button
type="button"
role="menuitem"
disabled={pendingAction !== null}
onClick={() =>
onAction(EditorAgentRightClickAction.ReferenceImage)
}
>
{actionLabel({
action: EditorAgentRightClickAction.ReferenceImage,
idleLabel: '引用',
pendingAction,
resultAction,
result,
})}
</button>
) : null}
<button
type="button"
role="menuitem"
disabled={pendingAction !== null}
onClick={() => onAction(EditorAgentRightClickAction.CopyImage)}
>
{actionLabel({
action: EditorAgentRightClickAction.CopyImage,
idleLabel: '复制图片',
pendingAction,
resultAction,
result,
})}
</button>
</>
) : null}
<button
type="button"
@@ -5,6 +5,7 @@ import type { EditorAgentAttachmentRef } from '@/packages/shared/src/contracts';
export enum EditorAgentRightClickAction {
CopyText = 'copy_text',
CopyImage = 'copy_image',
ReferenceImage = 'reference_image',
DownloadAsset = 'download_asset',
}
@@ -134,7 +134,11 @@ async function downloadAsset(asset: EditorAgentContextAsset) {
}
}
export function useRightClickMenu() {
export function useRightClickMenu({
onReferenceImage,
}: {
onReferenceImage?: (asset: EditorAgentContextAsset) => boolean;
} = {}) {
const [rightClickMenu, setRightClickMenu] =
useState<RightClickMenuState | null>(null);
@@ -182,6 +186,11 @@ export function useRightClickMenu() {
target.kind === 'asset' &&
target.asset.mediaType === 'image'
? await copyAssetImage(target.asset)
: action === EditorAgentRightClickAction.ReferenceImage &&
target.kind === 'asset' &&
target.asset.mediaType === 'image' &&
target.asset.source.trim()
? (onReferenceImage?.(target.asset) ?? false)
: action === EditorAgentRightClickAction.DownloadAsset &&
target.kind === 'asset'
? await downloadAsset(target.asset)
@@ -197,7 +206,7 @@ export function useRightClickMenu() {
: current,
);
},
[rightClickMenu],
[onReferenceImage, rightClickMenu],
);
return {