diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index f2c1ca6aa..0bde7f9b9 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -73,6 +73,7 @@ - 网格末尾上传格为后续补齐项;在上传格未落地前,对话附件只从已有画布资源和账号素材库选择。后续若从对话入口上传图片,必须复用素材库 / 画布资源登记链路,不新增对话私有图片类型。 - 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。 - 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`。 +- 输入区附件临时状态统一收口到 `useConversationAttachments`,选择弹窗由独立的 `AttachmentPicker` 负责纯展示;选择、引用、粘贴上传完成、移除、发送清空和失败恢复都必须经同一最新状态更新入口。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用。 ## 工具调用确认展示契约 diff --git a/src/components/image-editor/EditorAgentConversation/AttachmentPicker.tsx b/src/components/image-editor/EditorAgentConversation/AttachmentPicker.tsx new file mode 100644 index 000000000..2df97baff --- /dev/null +++ b/src/components/image-editor/EditorAgentConversation/AttachmentPicker.tsx @@ -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; + attachmentError: string | null; + onTabChange: (tab: AttachmentPickerTab) => void; + onToggleKey: (key: string) => void; + onApply: () => void; + onClose: () => void; +}) { + const visibleOptions = tab === 'canvas' ? canvasOptions : libraryOptions; + + return ( + + + 取消 + + + 应用 + + + } + > +
+
+ + +
+ {attachmentError ? ( +
+ {attachmentError} +
+ ) : null} +
+ {visibleOptions.map((option) => { + const label = + option.attachment.label?.trim() || option.attachment.referenceId; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index abd98c53b..8233b03cb 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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(); diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 433d9539d..94502ae59 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -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) { 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; - attachmentError: string | null; - onTabChange: (tab: AttachmentPickerTab) => void; - onToggleKey: (key: string) => void; - onApply: () => void; - onClose: () => void; -}) { - const visibleOptions = tab === 'canvas' ? canvasOptions : libraryOptions; - - return ( - - - 取消 - - - 应用 - - - } - > -
-
- - -
- {attachmentError ? ( -
- {attachmentError} -
- ) : null} -
- {visibleOptions.map((option) => { - const label = - option.attachment.label?.trim() || option.attachment.referenceId; - return ( - - ); - })} -
-
-
- ); -} - export function EditorAgentConversationPanelView({ open, onToggleOpen, @@ -278,70 +93,31 @@ export function EditorAgentConversationPanelView({ onConfirmSent, }); const [draftText, setDraftText] = useState(''); - const [attachments, setAttachments] = useState( - [], - ); - const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false); - const [attachmentPickerTab, setAttachmentPickerTab] = - useState('canvas'); - const [attachmentError, setAttachmentError] = useState(null); - const [isPastingAttachment, setIsPastingAttachment] = useState(false); - const [draftAttachmentKeys, setDraftAttachmentKeys] = useState>( - () => 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(); - [...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) => { 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 => { - 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, - ) => { - 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({ - setAttachmentPickerOpen(false)} + onClose={closeAttachmentPicker} /> 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 { + 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( + [], + ); + const attachmentsRef = useRef([]); + const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false); + const [attachmentPickerTab, setAttachmentPickerTab] = + useState('canvas'); + const [attachmentError, setAttachmentError] = useState(null); + const [isPastingAttachment, setIsPastingAttachment] = useState(false); + const isPastingAttachmentRef = useRef(false); + const [draftAttachmentKeys, setDraftAttachmentKeys] = useState>( + () => 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(); + 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) => { + 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, + }; +}