add reference to right-click menu
This commit is contained in:
@@ -114,7 +114,7 @@
|
||||
5. 生成中的进行中动画;
|
||||
6. 错误气泡(失败/余额不足,带原因);
|
||||
7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。
|
||||
8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体。右键动作由消息气泡的 `useRightClickMenu` 内部执行,不向上层暴露消息文本、素材地址或私有对象键。
|
||||
8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区。引用复用附件去重、9 张上限和发送链路;右键菜单只保留已有的 `objectKey` 与素材 `source`,引用时由输入区优先按 `objectKey`、缺失时按 `source` 从当前画布和素材库选项重新查询并构造 `EditorAgentAttachmentRef`,同时命中时画布优先。
|
||||
|
||||
不做(明确排除,防止后人补齐):
|
||||
|
||||
|
||||
+97
@@ -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;
|
||||
|
||||
+28
-1
@@ -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 &&
|
||||
|
||||
+43
-17
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user