editor agent: update data structure , allow ref attachment

This commit is contained in:
2026-07-21 12:51:42 +08:00
parent a8cb085867
commit 3bea67d01a
9 changed files with 217 additions and 27 deletions
@@ -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
@@ -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'