fix reference & paste concurrence, extract picker modal and hook

This commit is contained in:
2026-07-20 19:27:13 +08:00
parent 26572cfc88
commit e6fd4a1fe1
5 changed files with 796 additions and 410 deletions
@@ -73,6 +73,7 @@
- 网格末尾上传格为后续补齐项;在上传格未落地前,对话附件只从已有画布资源和账号素材库选择。后续若从对话入口上传图片,必须复用素材库 / 画布资源登记链路,不新增对话私有图片类型。
- 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。
- 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`。
- 输入区附件临时状态统一收口到 `useConversationAttachments`,选择弹窗由独立的 `AttachmentPicker` 负责纯展示;选择、引用、粘贴上传完成、移除、发送清空和失败恢复都必须经同一最新状态更新入口。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用。
## 工具调用确认展示契约
@@ -0,0 +1,116 @@
import { PlatformActionButton } from '@/src/components/common/PlatformActionButton.tsx';
import { UnifiedModal } from '@/src/components/common/UnifiedModal.tsx';
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
import type {
AttachmentPickerTab,
EditorAgentAttachmentOption,
} from './useConversationAttachments.ts';
export function AttachmentPicker({
open,
tab,
canvasOptions,
libraryOptions,
selectedKeys,
attachmentError,
onTabChange,
onToggleKey,
onApply,
onClose,
}: {
open: boolean;
tab: AttachmentPickerTab;
canvasOptions: EditorAgentAttachmentOption[];
libraryOptions: EditorAgentAttachmentOption[];
selectedKeys: Set<string>;
attachmentError: string | null;
onTabChange: (tab: AttachmentPickerTab) => void;
onToggleKey: (key: string) => void;
onApply: () => void;
onClose: () => void;
}) {
const visibleOptions = tab === 'canvas' ? canvasOptions : libraryOptions;
return (
<UnifiedModal
open={open}
title="选择图片附件"
size="md"
onClose={onClose}
footer={
<>
<PlatformActionButton tone="ghost" size="sm" onClick={onClose}>
取消
</PlatformActionButton>
<PlatformActionButton tone="primary" size="sm" onClick={onApply}>
应用
</PlatformActionButton>
</>
}
>
<div className="flex min-h-[18rem] flex-col gap-3">
<div className="flex gap-2">
<button
type="button"
className={`rounded-full px-3 py-1.5 text-sm ${
tab === 'canvas'
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600'
}`}
onClick={() => onTabChange('canvas')}
>
画布
</button>
<button
type="button"
className={`rounded-full px-3 py-1.5 text-sm ${
tab === 'library'
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600'
}`}
onClick={() => onTabChange('library')}
>
素材库
</button>
</div>
{attachmentError ? (
<div className="rounded-2xl bg-amber-50 px-3 py-2 text-sm text-amber-700">
{attachmentError}
</div>
) : null}
<div className="grid grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3">
{visibleOptions.map((option) => {
const label =
option.attachment.label?.trim() || option.attachment.referenceId;
return (
<label
key={option.key}
className="flex cursor-pointer flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-2 text-sm text-slate-700 shadow-sm"
>
<ResolvedAssetImage
src={
option.attachment.thumbnailSrc ?? option.attachment.imageSrc
}
objectKey={option.attachment.objectKey}
refreshKey={option.attachment.referenceId}
alt=""
className="aspect-square rounded-xl bg-slate-100 object-cover"
/>
<span className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`选择${option.sourceLabel}图片 ${label}`}
checked={selectedKeys.has(option.key)}
onChange={() => onToggleKey(option.key)}
/>
<span className="truncate">{label}</span>
</span>
</label>
);
})}
</div>
</div>
</UnifiedModal>
);
}
@@ -524,9 +524,277 @@ describe('EditorAgentConversationPanelView', () => {
);
});
expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy();
expect(
screen.queryByRole('option', { name: 'conversation-1' }),
).toBeNull();
expect(screen.queryByRole('option', { name: 'conversation-1' })).toBeNull();
});
it('keeps a referenced image when a pending paste upload finishes', 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-referenced',
objectKey: 'editor/referenced.png',
imageSrc: '/generated-editor-images/referenced.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',
});
let resolveUpload!: (upload: {
src: string;
objectKey: string;
assetObjectId: string;
legacyPublicPath: string;
}) => void;
uploadEditorMediaAssetFileMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveUpload = resolve;
}),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-referenced',
resourceId: 'resource-referenced',
title: '并发引用图',
src: '/generated-editor-images/referenced.png',
objectKey: 'editor/referenced.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(),
);
const input = screen.getByLabelText('发送给画布 Agent');
fireEvent.paste(input, {
clipboardData: {
files: [
new File(['pending-paste'], 'pending.png', { type: 'image/png' }),
],
},
});
await waitFor(() =>
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1),
);
fireEvent.contextMenu(
messageLog.querySelector('.grid.grid-cols-3 > div')!,
{ clientX: 30, clientY: 40 },
);
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
expect(await screen.findByText('并发引用图')).toBeTruthy();
await act(async () => {
resolveUpload({
src: '/generated/pasted.png',
objectKey:
'generated-character-drafts/editor/agent-paste/image/pasted.png',
assetObjectId: 'asset-object-pasted',
legacyPublicPath: '/generated/pasted.png',
});
});
expect(await screen.findByText('粘贴图片')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
attachments: [
expect.objectContaining({
referenceId: 'resource-referenced',
}),
expect.objectContaining({ referenceId: 'resource-pasted' }),
],
}),
expect.any(Object),
);
});
});
it('evaluates the nine-image limit against references added during paste', async () => {
const client = createClient();
const layers = Array.from({ length: 9 }, (_, index) => ({
id: `layer-${index + 1}`,
resourceId: `resource-${index + 1}`,
title: `附件-${index + 1}`,
src: `/generated/attachment-${index + 1}.png`,
objectKey: `editor/attachment-${index + 1}.png`,
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: index + 1,
sourceType: 'generated' as const,
}));
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-9',
objectKey: 'editor/attachment-9.png',
imageSrc: '/generated/attachment-9.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',
});
let resolveUpload!: (upload: {
src: string;
objectKey: string;
assetObjectId: string;
legacyPublicPath: string;
}) => void;
uploadEditorMediaAssetFileMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveUpload = resolve;
}),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={layers}
/>,
);
const messageLog = await screen.findByRole('log', {
name: '画布 Agent 消息流',
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
for (let index = 1; index <= 8; index += 1) {
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: `选择画布图片 附件-${index}`,
}),
);
}
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
const input = screen.getByLabelText('发送给画布 Agent');
fireEvent.paste(input, {
clipboardData: {
files: [
new File(['pending-paste'], 'pending.png', { type: 'image/png' }),
],
},
});
await waitFor(() =>
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1),
);
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('附件-9')).toBeTruthy();
await act(async () => {
resolveUpload({
src: '/generated/pasted.png',
objectKey:
'generated-character-drafts/editor/agent-paste/image/pasted.png',
assetObjectId: 'asset-object-pasted',
legacyPublicPath: '/generated/pasted.png',
});
});
expect(await screen.findByText('最多 9 张')).toBeTruthy();
expect(screen.queryByText('粘贴图片')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
const request = vi.mocked(client.sendMessage).mock.calls[0]?.[1];
expect(request?.attachments).toHaveLength(9);
expect(request?.attachments).toEqual(
expect.arrayContaining([
expect.objectContaining({ referenceId: 'resource-9' }),
]),
);
expect(request?.attachments).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ referenceId: 'resource-pasted' }),
]),
);
});
});
it('sends selected attachments even when the text input is empty', async () => {
@@ -648,9 +916,9 @@ describe('EditorAgentConversationPanelView', () => {
).toBe('失败后恢复这条草稿');
expect(screen.getByText('角色图层')).toBeTruthy();
expect(
within(screen.getByRole('log', { name: '画布 Agent 消息流' })).queryByText(
'失败后恢复这条草稿',
),
within(
screen.getByRole('log', { name: '画布 Agent 消息流' }),
).queryByText('失败后恢复这条草稿'),
).toBeNull();
});
@@ -785,9 +1053,9 @@ describe('EditorAgentConversationPanelView', () => {
});
expect(screen.getByRole('button', { name: '执行中' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
expect(screen.getByRole('button', { name: '取消' }).hasAttribute('disabled')).toBe(
true,
);
expect(
screen.getByRole('button', { name: '取消' }).hasAttribute('disabled'),
).toBe(true);
await act(async () => {
resolveConfirmation();
@@ -9,26 +9,16 @@ import {
X,
} from 'lucide-react';
import {
type ClipboardEvent as ReactClipboardEvent,
type FormEvent,
useEffect,
useMemo,
useState,
type WheelEvent as ReactWheelEvent,
} from 'react';
import {
EDITOR_AGENT_MAX_ATTACHMENTS,
type EditorAgentAttachmentRef,
} from '@/packages/shared/src/contracts';
import { PlatformActionButton } from '@/src/components/common/PlatformActionButton.tsx';
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,
type EditorAgentContextAsset,
} from '@/src/components/image-editor/EditorAgentConversation/common.ts';
import { AttachmentPicker } from '@/src/components/image-editor/EditorAgentConversation/AttachmentPicker.tsx';
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
import {
MessageBubble,
ThinkingBubble,
@@ -37,25 +27,14 @@ import type {
CanvasLayer,
EditorAsset,
} from '@/src/components/image-editor/ImageCanvasEditorTypes.ts';
import { probeImageFileDimensions } from '@/src/components/image-editor/ImageCanvasFileModel.ts';
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
import { uploadEditorMediaAssetFile } from '@/src/services/image-editor/editorMediaAssetUploadClient.ts';
import { createEditorProjectResource } from '@/src/services/image-editor/editorProjectClient.ts';
import { useImageCanvasContextStore } from '../useImageCanvasContextStore.ts';
import { useConversationAttachments } from './useConversationAttachments.ts';
import {
type EditorAgentConversationClient,
useEditorAgentConversation,
} from './useEditorAgentConversation';
type AttachmentPickerTab = 'canvas' | 'library';
type EditorAgentAttachmentOption = {
key: string;
sourceLabel: string;
attachment: EditorAgentAttachmentRef;
};
type EditorAgentConversationPanelViewProps = {
open: boolean;
onToggleOpen: () => void;
@@ -71,170 +50,6 @@ function stopAgentPanelWheel(event: ReactWheelEvent<HTMLElement>) {
event.stopPropagation();
}
function isImageLayer(layer: CanvasLayer) {
return (
(layer.mediaType ?? 'image') === 'image' &&
Boolean(layer.resourceId?.trim()) &&
layer.src.trim()
);
}
function isImageAsset(asset: EditorAsset) {
return (asset.mediaType ?? 'image') === 'image' && asset.src.trim();
}
function createCanvasAttachmentOptions(
layers: CanvasLayer[] = [],
): EditorAgentAttachmentOption[] {
return layers.filter(isImageLayer).map((layer) => {
const attachment: EditorAgentAttachmentRef = {
source: 'canvas_resource',
referenceId: layer.resourceId || layer.id,
objectKey: layer.objectKey ?? null,
imageSrc: layer.src,
thumbnailSrc: layer.thumbnailSrc ?? null,
label: layer.title,
width: layer.width,
height: layer.height,
};
return {
key: attachmentKey(attachment),
sourceLabel: '画布',
attachment,
};
});
}
function createLibraryAttachmentOptions(
assets: EditorAsset[] = [],
): EditorAgentAttachmentOption[] {
return assets.filter(isImageAsset).map((asset) => {
const attachment: EditorAgentAttachmentRef = {
source: 'library_asset',
referenceId: asset.id,
objectKey: asset.objectKey ?? null,
imageSrc: asset.src,
thumbnailSrc: asset.thumbnailSrc ?? null,
label: asset.label,
width: asset.width,
height: asset.height,
};
return {
key: attachmentKey(attachment),
sourceLabel: '素材库',
attachment,
};
});
}
function AttachmentPickerModal({
open,
tab,
canvasOptions,
libraryOptions,
selectedKeys,
attachmentError,
onTabChange,
onToggleKey,
onApply,
onClose,
}: {
open: boolean;
tab: AttachmentPickerTab;
canvasOptions: EditorAgentAttachmentOption[];
libraryOptions: EditorAgentAttachmentOption[];
selectedKeys: Set<string>;
attachmentError: string | null;
onTabChange: (tab: AttachmentPickerTab) => void;
onToggleKey: (key: string) => void;
onApply: () => void;
onClose: () => void;
}) {
const visibleOptions = tab === 'canvas' ? canvasOptions : libraryOptions;
return (
<UnifiedModal
open={open}
title="选择图片附件"
size="md"
onClose={onClose}
footer={
<>
<PlatformActionButton tone="ghost" size="sm" onClick={onClose}>
取消
</PlatformActionButton>
<PlatformActionButton tone="primary" size="sm" onClick={onApply}>
应用
</PlatformActionButton>
</>
}
>
<div className="flex min-h-[18rem] flex-col gap-3">
<div className="flex gap-2">
<button
type="button"
className={`rounded-full px-3 py-1.5 text-sm ${
tab === 'canvas'
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600'
}`}
onClick={() => onTabChange('canvas')}
>
画布
</button>
<button
type="button"
className={`rounded-full px-3 py-1.5 text-sm ${
tab === 'library'
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600'
}`}
onClick={() => onTabChange('library')}
>
素材库
</button>
</div>
{attachmentError ? (
<div className="rounded-2xl bg-amber-50 px-3 py-2 text-sm text-amber-700">
{attachmentError}
</div>
) : null}
<div className="grid grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3">
{visibleOptions.map((option) => {
const label =
option.attachment.label?.trim() || option.attachment.referenceId;
return (
<label
key={option.key}
className="flex cursor-pointer flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-2 text-sm text-slate-700 shadow-sm"
>
<ResolvedAssetImage
src={
option.attachment.thumbnailSrc ?? option.attachment.imageSrc
}
objectKey={option.attachment.objectKey}
refreshKey={option.attachment.referenceId}
alt=""
className="aspect-square rounded-xl bg-slate-100 object-cover"
/>
<span className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`选择${option.sourceLabel}图片 ${label}`}
checked={selectedKeys.has(option.key)}
onChange={() => onToggleKey(option.key)}
/>
<span className="truncate">{label}</span>
</span>
</label>
);
})}
</div>
</div>
</UnifiedModal>
);
}
export function EditorAgentConversationPanelView({
open,
onToggleOpen,
@@ -278,70 +93,31 @@ export function EditorAgentConversationPanelView({
onConfirmSent,
});
const [draftText, setDraftText] = useState('');
const [attachments, setAttachments] = useState<EditorAgentAttachmentRef[]>(
[],
);
const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false);
const [attachmentPickerTab, setAttachmentPickerTab] =
useState<AttachmentPickerTab>('canvas');
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [isPastingAttachment, setIsPastingAttachment] = useState(false);
const [draftAttachmentKeys, setDraftAttachmentKeys] = useState<Set<string>>(
() => new Set(),
);
const {
attachments,
attachmentError,
isPastingAttachment,
attachmentPickerOpen,
attachmentPickerTab,
draftAttachmentKeys,
canvasAttachmentOptions,
libraryAttachmentOptions,
setAttachmentPickerTab,
openAttachmentPicker,
closeAttachmentPicker,
toggleAttachmentKey,
applyAttachmentSelection,
referenceContextAsset,
handleInputPaste,
removeAttachment,
consumeAttachments,
restoreAttachmentsIfEmpty,
} = useConversationAttachments({ projectId, layers, assets });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const canvasAttachmentOptions = useMemo(
() => createCanvasAttachmentOptions(layers),
[layers],
);
const libraryAttachmentOptions = useMemo(
() => createLibraryAttachmentOptions(assets),
[assets],
);
const attachmentOptionsByKey = useMemo(() => {
const optionMap = new Map<string, EditorAgentAttachmentOption>();
[...canvasAttachmentOptions, ...libraryAttachmentOptions].forEach(
(option) => optionMap.set(option.key, option),
);
return optionMap;
}, [canvasAttachmentOptions, libraryAttachmentOptions]);
const hasProject = Boolean(projectId?.trim());
const isConversationBusy = isWaiting || isToolCallActionPending;
const openAttachmentPicker = () => {
setAttachmentError(null);
setDraftAttachmentKeys(new Set(attachments.map(attachmentKey)));
setAttachmentPickerOpen(true);
};
const toggleAttachmentKey = (key: string) => {
setDraftAttachmentKeys((currentKeys) => {
const nextKeys = new Set(currentKeys);
if (nextKeys.has(key)) {
nextKeys.delete(key);
} else {
nextKeys.add(key);
}
return nextKeys;
});
};
const applyAttachmentSelection = () => {
const nextAttachments = Array.from(draftAttachmentKeys)
.map((key) => attachmentOptionsByKey.get(key)?.attachment)
.filter((attachment): attachment is EditorAgentAttachmentRef =>
Boolean(attachment),
);
if (nextAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
return;
}
setAttachments(nextAttachments);
setAttachmentPickerOpen(false);
};
const submitMessage = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isWaiting) {
@@ -355,162 +131,12 @@ export function EditorAgentConversationPanelView({
return;
}
setDraftText('');
const nextAttachments = attachments;
setAttachments([]);
const nextAttachments = consumeAttachments();
void sendMessage(text, nextAttachments).catch(() => {
setDraftText((currentText) => (currentText ? currentText : text));
setAttachments((currentAttachments) =>
currentAttachments.length ? currentAttachments : nextAttachments,
);
restoreAttachmentsIfEmpty(nextAttachments);
});
};
const appendAttachments = (nextAttachments: EditorAgentAttachmentRef[]) => {
function mergeAttachments(
currentAttachments: EditorAgentAttachmentRef[],
nextAttachments: EditorAgentAttachmentRef[],
) {
const merged = [...currentAttachments];
const existingKeys = new Set(currentAttachments.map(attachmentKey));
nextAttachments.forEach((attachment) => {
const key = attachmentKey(attachment);
if (!existingKeys.has(key)) {
existingKeys.add(key);
merged.push(attachment);
}
});
return merged;
}
const mergedAttachments = mergeAttachments(attachments, nextAttachments);
if (mergedAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
return false;
}
setAttachments(mergedAttachments);
setAttachmentError(null);
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> => {
if (!projectId?.trim()) {
throw new Error('缺少画布项目');
}
const [upload, dimensions] = await Promise.all([
uploadEditorMediaAssetFile(file, 'image', {
pathSegments: ['editor', 'agent-paste', 'image', `${Date.now()}`],
entityId: projectId,
metadata: {
source: 'agent-input-paste',
},
}),
probeImageFileDimensions(file),
]);
const width = dimensions?.width ?? 1;
const height = dimensions?.height ?? 1;
const resource = await createEditorProjectResource(projectId, {
imageSrc: upload.src,
objectKey: upload.objectKey,
assetObjectId: upload.assetObjectId,
width,
height,
sourceType: 'uploaded',
});
return {
source: 'canvas_resource',
referenceId: resource.resourceId,
objectKey: resource.objectKey ?? upload.objectKey,
imageSrc: resource.imageSrc,
thumbnailSrc: null,
label: resource.label ?? '粘贴图片',
width: resource.width,
height: resource.height,
};
};
function extractClipboardImageFiles(
clipboardData: DataTransfer | null,
): File[] {
if (!clipboardData) {
return [];
}
const fileItems = Array.from(clipboardData.files ?? []).filter((file) =>
file.type.startsWith('image/'),
);
return [...fileItems];
}
const handleInputPaste = (
event: ReactClipboardEvent<HTMLTextAreaElement>,
) => {
const imageFiles = extractClipboardImageFiles(event.clipboardData);
if (!imageFiles.length) {
return;
}
if (isPastingAttachment) {
return;
}
event.preventDefault();
if (attachments.length >= EDITOR_AGENT_MAX_ATTACHMENTS) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
return;
}
const remainingAttachmentSlots =
EDITOR_AGENT_MAX_ATTACHMENTS - attachments.length;
const uploadFiles = imageFiles.slice(0, remainingAttachmentSlots);
const hasOverflow = uploadFiles.length < imageFiles.length;
// TODO: deduplicate those existing assets
setIsPastingAttachment(true);
setAttachmentError('图片上传中');
void Promise.all(
uploadFiles.map((file) => createPastedAgentImageAttachment(file)),
)
.then((pastedAttachments) => {
if (appendAttachments(pastedAttachments) && hasOverflow) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
}
})
.catch(() => {
setAttachmentError('图片粘贴失败,请重试');
})
.finally(() => {
setIsPastingAttachment(false);
});
};
const removeAttachment = (targetAttachment: EditorAgentAttachmentRef) => {
const key = attachmentKey(targetAttachment);
setAttachments((currentAttachments) =>
currentAttachments.filter(
(attachment) => attachmentKey(attachment) !== key,
),
);
};
if (!open) {
return (
@@ -713,7 +339,7 @@ export function EditorAgentConversationPanelView({
</div>
</form>
</aside>
<AttachmentPickerModal
<AttachmentPicker
open={attachmentPickerOpen}
tab={attachmentPickerTab}
canvasOptions={canvasAttachmentOptions}
@@ -723,7 +349,7 @@ export function EditorAgentConversationPanelView({
onTabChange={setAttachmentPickerTab}
onToggleKey={toggleAttachmentKey}
onApply={applyAttachmentSelection}
onClose={() => setAttachmentPickerOpen(false)}
onClose={closeAttachmentPicker}
/>
<PlatformDangerConfirmDialog
open={deleteConfirmOpen}
@@ -0,0 +1,375 @@
import {
type ClipboardEvent as ReactClipboardEvent,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import {
EDITOR_AGENT_MAX_ATTACHMENTS,
type EditorAgentAttachmentRef,
} from '@/packages/shared/src/contracts';
import type {
CanvasLayer,
EditorAsset,
} from '@/src/components/image-editor/ImageCanvasEditorTypes.ts';
import { probeImageFileDimensions } from '@/src/components/image-editor/ImageCanvasFileModel.ts';
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';
export type AttachmentPickerTab = 'canvas' | 'library';
export type EditorAgentAttachmentOption = {
key: string;
sourceLabel: string;
attachment: EditorAgentAttachmentRef;
};
type AttachmentUpdater =
| EditorAgentAttachmentRef[]
| ((
currentAttachments: EditorAgentAttachmentRef[],
) => EditorAgentAttachmentRef[]);
function isImageLayer(layer: CanvasLayer) {
return (
(layer.mediaType ?? 'image') === 'image' &&
Boolean(layer.resourceId?.trim()) &&
layer.src.trim()
);
}
function isImageAsset(asset: EditorAsset) {
return (asset.mediaType ?? 'image') === 'image' && asset.src.trim();
}
function createCanvasAttachmentOptions(
layers: CanvasLayer[],
): EditorAgentAttachmentOption[] {
return layers.filter(isImageLayer).map((layer) => {
const attachment: EditorAgentAttachmentRef = {
source: 'canvas_resource',
referenceId: layer.resourceId || layer.id,
objectKey: layer.objectKey ?? null,
imageSrc: layer.src,
thumbnailSrc: layer.thumbnailSrc ?? null,
label: layer.title,
width: layer.width,
height: layer.height,
};
return {
key: attachmentKey(attachment),
sourceLabel: '画布',
attachment,
};
});
}
function createLibraryAttachmentOptions(
assets: EditorAsset[],
): EditorAgentAttachmentOption[] {
return assets.filter(isImageAsset).map((asset) => {
const attachment: EditorAgentAttachmentRef = {
source: 'library_asset',
referenceId: asset.id,
objectKey: asset.objectKey ?? null,
imageSrc: asset.src,
thumbnailSrc: asset.thumbnailSrc ?? null,
label: asset.label,
width: asset.width,
height: asset.height,
};
return {
key: attachmentKey(attachment),
sourceLabel: '素材库',
attachment,
};
});
}
function mergeAttachments(
currentAttachments: EditorAgentAttachmentRef[],
nextAttachments: EditorAgentAttachmentRef[],
) {
const merged = [...currentAttachments];
const existingKeys = new Set(currentAttachments.map(attachmentKey));
nextAttachments.forEach((attachment) => {
const key = attachmentKey(attachment);
if (!existingKeys.has(key)) {
existingKeys.add(key);
merged.push(attachment);
}
});
return merged;
}
function extractClipboardImageFiles(clipboardData: DataTransfer | null) {
if (!clipboardData) {
return [];
}
return Array.from(clipboardData.files ?? []).filter((file) =>
file.type.startsWith('image/'),
);
}
async function createPastedAgentImageAttachment(
projectId: string,
file: File,
): Promise<EditorAgentAttachmentRef> {
const [upload, dimensions] = await Promise.all([
uploadEditorMediaAssetFile(file, 'image', {
pathSegments: ['editor', 'agent-paste', 'image', `${Date.now()}`],
entityId: projectId,
metadata: {
source: 'agent-input-paste',
},
}),
probeImageFileDimensions(file),
]);
const width = dimensions?.width ?? 1;
const height = dimensions?.height ?? 1;
const resource = await createEditorProjectResource(projectId, {
imageSrc: upload.src,
objectKey: upload.objectKey,
assetObjectId: upload.assetObjectId,
width,
height,
sourceType: 'uploaded',
});
return {
source: 'canvas_resource',
referenceId: resource.resourceId,
objectKey: resource.objectKey ?? upload.objectKey,
imageSrc: resource.imageSrc,
thumbnailSrc: null,
label: resource.label ?? '粘贴图片',
width: resource.width,
height: resource.height,
};
}
export function useConversationAttachments({
projectId,
layers,
assets,
}: {
projectId: string | null;
layers: CanvasLayer[];
assets: EditorAsset[];
}) {
const [attachments, setAttachments] = useState<EditorAgentAttachmentRef[]>(
[],
);
const attachmentsRef = useRef<EditorAgentAttachmentRef[]>([]);
const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false);
const [attachmentPickerTab, setAttachmentPickerTab] =
useState<AttachmentPickerTab>('canvas');
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [isPastingAttachment, setIsPastingAttachment] = useState(false);
const isPastingAttachmentRef = useRef(false);
const [draftAttachmentKeys, setDraftAttachmentKeys] = useState<Set<string>>(
() => new Set(),
);
const canvasAttachmentOptions = useMemo(
() => createCanvasAttachmentOptions(layers),
[layers],
);
const libraryAttachmentOptions = useMemo(
() => createLibraryAttachmentOptions(assets),
[assets],
);
const attachmentOptions = useMemo(
() => [...canvasAttachmentOptions, ...libraryAttachmentOptions],
[canvasAttachmentOptions, libraryAttachmentOptions],
);
const attachmentOptionsByKey = useMemo(() => {
const optionMap = new Map<string, EditorAgentAttachmentOption>();
attachmentOptions.forEach((option) => optionMap.set(option.key, option));
return optionMap;
}, [attachmentOptions]);
const updateAttachments = useCallback((updater: AttachmentUpdater) => {
const nextAttachments =
typeof updater === 'function' ? updater(attachmentsRef.current) : updater;
attachmentsRef.current = nextAttachments;
setAttachments(nextAttachments);
return nextAttachments;
}, []);
const appendAttachments = useCallback(
(nextAttachments: EditorAgentAttachmentRef[]) => {
const mergedAttachments = mergeAttachments(
attachmentsRef.current,
nextAttachments,
);
if (mergedAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
return false;
}
updateAttachments(mergedAttachments);
setAttachmentError(null);
return true;
},
[updateAttachments],
);
const openAttachmentPicker = useCallback(() => {
setAttachmentError(null);
setDraftAttachmentKeys(new Set(attachmentsRef.current.map(attachmentKey)));
setAttachmentPickerOpen(true);
}, []);
const closeAttachmentPicker = useCallback(() => {
setAttachmentPickerOpen(false);
}, []);
const toggleAttachmentKey = useCallback((key: string) => {
setDraftAttachmentKeys((currentKeys) => {
const nextKeys = new Set(currentKeys);
if (nextKeys.has(key)) {
nextKeys.delete(key);
} else {
nextKeys.add(key);
}
return nextKeys;
});
}, []);
const applyAttachmentSelection = useCallback(() => {
const nextAttachments = Array.from(draftAttachmentKeys)
.map((key) => attachmentOptionsByKey.get(key)?.attachment)
.filter((attachment): attachment is EditorAgentAttachmentRef =>
Boolean(attachment),
);
if (nextAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
return;
}
updateAttachments(nextAttachments);
setAttachmentPickerOpen(false);
}, [attachmentOptionsByKey, draftAttachmentKeys, updateAttachments]);
const referenceContextAsset = useCallback(
(asset: EditorAgentContextAsset) => {
const objectKey = asset.objectKey?.trim();
const source = asset.source.trim();
if (!objectKey && !source) {
return false;
}
const objectKeyOption = objectKey
? attachmentOptions.find(
({ attachment }) => attachment.objectKey?.trim() === objectKey,
)
: undefined;
const option =
objectKeyOption ||
attachmentOptions.find(({ attachment }) => {
return (
attachment.imageSrc.trim() === source ||
attachment.thumbnailSrc?.trim() === source
);
});
return option ? appendAttachments([option.attachment]) : false;
},
[appendAttachments, attachmentOptions],
);
const handleInputPaste = useCallback(
(event: ReactClipboardEvent<HTMLTextAreaElement>) => {
const imageFiles = extractClipboardImageFiles(event.clipboardData);
if (!imageFiles.length || isPastingAttachmentRef.current) {
return;
}
event.preventDefault();
const remainingAttachmentSlots =
EDITOR_AGENT_MAX_ATTACHMENTS - attachmentsRef.current.length;
if (remainingAttachmentSlots <= 0) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
return;
}
const uploadFiles = imageFiles.slice(0, remainingAttachmentSlots);
const hasOverflow = uploadFiles.length < imageFiles.length;
const currentProjectId = projectId?.trim();
if (!currentProjectId) {
setAttachmentError('缺少画布项目');
return;
}
isPastingAttachmentRef.current = true;
setIsPastingAttachment(true);
setAttachmentError('图片上传中');
void Promise.all(
uploadFiles.map((file) =>
createPastedAgentImageAttachment(currentProjectId, file),
),
)
.then((pastedAttachments) => {
if (appendAttachments(pastedAttachments) && hasOverflow) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
}
})
.catch(() => {
setAttachmentError('图片粘贴失败,请重试');
})
.finally(() => {
isPastingAttachmentRef.current = false;
setIsPastingAttachment(false);
});
},
[appendAttachments, projectId],
);
const removeAttachment = useCallback(
(targetAttachment: EditorAgentAttachmentRef) => {
const key = attachmentKey(targetAttachment);
updateAttachments((currentAttachments) =>
currentAttachments.filter(
(attachment) => attachmentKey(attachment) !== key,
),
);
},
[updateAttachments],
);
const consumeAttachments = useCallback(() => {
const currentAttachments = attachmentsRef.current;
updateAttachments([]);
return currentAttachments;
}, [updateAttachments]);
const restoreAttachmentsIfEmpty = useCallback(
(failedAttachments: EditorAgentAttachmentRef[]) => {
updateAttachments((currentAttachments) =>
currentAttachments.length ? currentAttachments : failedAttachments,
);
},
[updateAttachments],
);
return {
attachments,
attachmentError,
isPastingAttachment,
attachmentPickerOpen,
attachmentPickerTab,
draftAttachmentKeys,
canvasAttachmentOptions,
libraryAttachmentOptions,
setAttachmentPickerTab,
openAttachmentPicker,
closeAttachmentPicker,
toggleAttachmentKey,
applyAttachmentSelection,
referenceContextAsset,
handleInputPaste,
removeAttachment,
consumeAttachments,
restoreAttachmentsIfEmpty,
};
}