editor agent: update data structure , allow ref attachment
This commit is contained in:
@@ -117,7 +117,7 @@
|
||||
5. 生成中的进行中动画;
|
||||
6. 错误气泡(失败/余额不足,带原因);
|
||||
7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。
|
||||
8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区。引用复用附件去重、9 张上限和发送链路;右键菜单只保留已有的 `objectKey` 与素材 `source`,引用时由输入区优先按 `objectKey`、缺失时按 `source` 从当前画布和素材库选项重新查询并构造 `EditorAgentAttachmentRef`,同时命中时画布优先。
|
||||
8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区。引用复用附件去重、9 张上限和发送链路;
|
||||
9. 消息右键菜单遵循 Canva 式单实例交互:任一菜单已打开时,下一次右键必须先关闭旧菜单;新落点是消息正文或素材时再在新位置打开对应菜单,新落点没有右键动作时仅收起旧菜单,不允许多个消息菜单并存。复制、引用或下载成功后自动关闭菜单;失败时保留菜单和失败状态,避免错误无提示消失。
|
||||
|
||||
不做(明确排除,防止后人补齐):
|
||||
|
||||
@@ -22,9 +22,9 @@ function AttachmentChip({
|
||||
onRightClickMenu
|
||||
? (event) =>
|
||||
onRightClickMenu(event, {
|
||||
...attachment,
|
||||
kind: 'attachment',
|
||||
mediaType: 'image',
|
||||
source: attachment.imageSrc,
|
||||
objectKey: attachment.objectKey,
|
||||
suggestedFileName: label,
|
||||
})
|
||||
: undefined
|
||||
|
||||
+148
@@ -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(
|
||||
<EditorAgentConversationPanelView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
layers={[]}
|
||||
assets={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<EditorAgentConversationPanelView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
layers={[
|
||||
{
|
||||
id: 'layer-updated',
|
||||
resourceId: 'resource-updated',
|
||||
title: '最新附件',
|
||||
src: '/generated/current.png',
|
||||
objectKey: 'editor/current.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
originalWidth: 512,
|
||||
originalHeight: 512,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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({
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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() ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
@@ -117,8 +117,9 @@ function ToolCallView({
|
||||
onRightClickMenu
|
||||
? (event) =>
|
||||
onRightClickMenu(event, {
|
||||
kind: 'generated_media',
|
||||
mediaType: 'image',
|
||||
source: image.imageSrc,
|
||||
mediaSrc: image.imageSrc,
|
||||
objectKey: image.objectKey,
|
||||
suggestedFileName: `Agent生成图片-${index + 1}`,
|
||||
})
|
||||
@@ -146,8 +147,9 @@ function ToolCallView({
|
||||
onRightClickMenu
|
||||
? (event) =>
|
||||
onRightClickMenu(event, {
|
||||
kind: 'generated_media',
|
||||
mediaType: 'video',
|
||||
source: video.videoSrc,
|
||||
mediaSrc: video.videoSrc,
|
||||
objectKey: video.objectKey,
|
||||
suggestedFileName: `Agent生成视频-${index + 1}`,
|
||||
})
|
||||
@@ -180,8 +182,9 @@ function ToolCallView({
|
||||
onRightClickMenu
|
||||
? (event) =>
|
||||
onRightClickMenu(event, {
|
||||
kind: 'generated_media',
|
||||
mediaType: 'audio',
|
||||
source: audio.audioSrc,
|
||||
mediaSrc: audio.audioSrc,
|
||||
objectKey: audio.objectKey,
|
||||
suggestedFileName: `Agent生成音频-${index + 1}`,
|
||||
})
|
||||
|
||||
@@ -9,12 +9,19 @@ export enum EditorAgentRightClickAction {
|
||||
DownloadAsset = 'download_asset',
|
||||
}
|
||||
|
||||
export type EditorAgentContextAsset = {
|
||||
mediaType: 'image' | 'video' | 'audio';
|
||||
source: string;
|
||||
objectKey?: string | null;
|
||||
suggestedFileName: string;
|
||||
};
|
||||
export type EditorAgentContextAsset =
|
||||
| (EditorAgentAttachmentRef & {
|
||||
kind: 'attachment';
|
||||
mediaType: 'image';
|
||||
suggestedFileName: string;
|
||||
})
|
||||
| {
|
||||
kind: 'generated_media';
|
||||
mediaType: 'image' | 'video' | 'audio';
|
||||
mediaSrc: string;
|
||||
objectKey?: string | null;
|
||||
suggestedFileName: string;
|
||||
};
|
||||
|
||||
export type RightClickMenuTarget =
|
||||
| { kind: 'text'; text: string }
|
||||
@@ -28,3 +35,7 @@ export type RightClickMenuHandler = (
|
||||
export function attachmentKey(attachment: EditorAgentAttachmentRef) {
|
||||
return `${attachment.source}:${attachment.referenceId}`;
|
||||
}
|
||||
|
||||
export function contextAssetMediaSrc(asset: EditorAgentContextAsset) {
|
||||
return asset.kind === 'attachment' ? asset.imageSrc : asset.mediaSrc;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ import { probeImageFileDimensions } from '@/src/components/image-editor/ImageCan
|
||||
import { uploadEditorMediaAssetFile } from '@/src/services/image-editor/editorMediaAssetUploadClient.ts';
|
||||
import { createEditorProjectResource } from '@/src/services/image-editor/editorProjectClient.ts';
|
||||
|
||||
import { attachmentKey, type EditorAgentContextAsset } from './common.ts';
|
||||
import {
|
||||
attachmentKey,
|
||||
contextAssetMediaSrc,
|
||||
type EditorAgentContextAsset,
|
||||
} from './common.ts';
|
||||
|
||||
export type AttachmentPickerTab = 'canvas' | 'library';
|
||||
|
||||
@@ -255,11 +259,24 @@ export function useConversationAttachments({
|
||||
|
||||
const referenceContextAsset = useCallback(
|
||||
(asset: EditorAgentContextAsset) => {
|
||||
const directAttachment: EditorAgentAttachmentRef | undefined =
|
||||
asset.kind === 'attachment'
|
||||
? {
|
||||
source: asset.source,
|
||||
referenceId: asset.referenceId,
|
||||
objectKey: asset.objectKey,
|
||||
imageSrc: asset.imageSrc,
|
||||
thumbnailSrc: asset.thumbnailSrc,
|
||||
label: asset.label,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
}
|
||||
: undefined;
|
||||
const referenceOption = directAttachment
|
||||
? attachmentOptionsByKey.get(attachmentKey(directAttachment))
|
||||
: undefined;
|
||||
const objectKey = asset.objectKey?.trim();
|
||||
const source = asset.source.trim();
|
||||
if (!objectKey && !source) {
|
||||
return false;
|
||||
}
|
||||
const mediaSrc = contextAssetMediaSrc(asset).trim();
|
||||
const objectKeyOption = objectKey
|
||||
? attachmentOptions.find(
|
||||
({ attachment }) => attachment.objectKey?.trim() === objectKey,
|
||||
@@ -269,13 +286,18 @@ export function useConversationAttachments({
|
||||
objectKeyOption ||
|
||||
attachmentOptions.find(({ attachment }) => {
|
||||
return (
|
||||
attachment.imageSrc.trim() === source ||
|
||||
attachment.thumbnailSrc?.trim() === source
|
||||
attachment.imageSrc.trim() === mediaSrc ||
|
||||
attachment.thumbnailSrc?.trim() === mediaSrc
|
||||
);
|
||||
});
|
||||
return option ? appendAttachments([option.attachment]) : false;
|
||||
const matchedAttachment =
|
||||
referenceOption?.attachment || option?.attachment || directAttachment;
|
||||
return matchedAttachment?.referenceId.trim() &&
|
||||
matchedAttachment.imageSrc.trim()
|
||||
? appendAttachments([matchedAttachment])
|
||||
: false;
|
||||
},
|
||||
[appendAttachments, attachmentOptions],
|
||||
[appendAttachments, attachmentOptions, attachmentOptionsByKey],
|
||||
);
|
||||
|
||||
const handleInputPaste = useCallback(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { readAssetBytes } from '@/src/services/assetReadUrlService.ts';
|
||||
import { copyTextToClipboard } from '@/src/services/clipboard.ts';
|
||||
|
||||
import {
|
||||
contextAssetMediaSrc,
|
||||
type EditorAgentContextAsset,
|
||||
EditorAgentRightClickAction,
|
||||
type RightClickMenuTarget,
|
||||
@@ -93,7 +94,7 @@ async function copyAssetImage(asset: EditorAgentContextAsset) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const pngBlob = readAssetBytes(asset.source, {
|
||||
const pngBlob = readAssetBytes(contextAssetMediaSrc(asset), {
|
||||
objectKey: asset.objectKey,
|
||||
}).then(async (response) => convertImageBlobToPng(await response.blob()));
|
||||
// Clipboard write must start in the menu click's user-activation stack.
|
||||
@@ -115,7 +116,7 @@ async function downloadAsset(asset: EditorAgentContextAsset) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await readAssetBytes(asset.source, {
|
||||
const response = await readAssetBytes(contextAssetMediaSrc(asset), {
|
||||
objectKey: asset.objectKey,
|
||||
});
|
||||
const blob = await response.blob();
|
||||
@@ -189,7 +190,7 @@ export function useRightClickMenu({
|
||||
: action === EditorAgentRightClickAction.ReferenceImage &&
|
||||
target.kind === 'asset' &&
|
||||
target.asset.mediaType === 'image' &&
|
||||
target.asset.source.trim()
|
||||
contextAssetMediaSrc(target.asset).trim()
|
||||
? (onReferenceImage?.(target.asset) ?? false)
|
||||
: action === EditorAgentRightClickAction.DownloadAsset &&
|
||||
target.kind === 'asset'
|
||||
|
||||
Reference in New Issue
Block a user