import { Bot, ChevronDown, Loader2, MessageCircle, Paperclip, Plus, Send, Trash2, 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 { PlatformToolModalShell } from '@/src/components/common/PlatformToolModalShell.tsx'; import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx'; import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts'; import { MessageBubble, ThinkingBubble, } from '@/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx'; 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 { type EditorAgentConversationClient, useEditorAgentConversation, } from './useEditorAgentConversation'; type AttachmentPickerTab = 'canvas' | 'library'; type EditorAgentAttachmentOption = { key: string; sourceLabel: string; attachment: EditorAgentAttachmentRef; }; type EditorAgentConversationPanelViewProps = { open: boolean; onToggleOpen: () => void; layers?: CanvasLayer[]; assets?: EditorAsset[]; onCanvasRefreshRequested?: () => void; // TODO refactor: move the task list update seperate onConfirmSent?: () => void; client?: EditorAgentConversationClient; }; 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, layers = [], assets = [], onCanvasRefreshRequested, onConfirmSent, client, }: EditorAgentConversationPanelViewProps) { const [hasConversationMounted, setHasConversationMounted] = useState(open); useEffect(() => { if (open) { setHasConversationMounted(true); } }, [open]); const projectId = useImageCanvasContextStore((state) => state.projectId); const effectiveProjectId = hasConversationMounted ? projectId : null; const { conversations, activeConversation, activeConversationId, messages, isLoadingConversations, isLoadingMessages, isCreatingConversation, isDeletingConversation, isWaiting, isPatienceNoticeVisible, toolCallAction, isToolCallActionPending, errorMessage, createConversation, selectConversation, refreshActiveConversation, sendMessage, confirmToolCall, cancelToolCall, deleteActiveConversation, } = useEditorAgentConversation({ projectId: effectiveProjectId, client, onCanvasRefreshRequested, 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 [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 hasCurrentConversationContent = messages.length > 0; const isConversationSelectDisabled = !conversations.length || isLoadingConversations || isConversationBusy; const currentConversationTitle = activeConversation?.title ?? '新对话'; const handleCreateConversation = () => { void createConversation() .then(() => { setDraftText(''); setAttachments([]); setAttachmentError(null); setDraftAttachmentKeys(new Set()); setAttachmentPickerOpen(false); }) .catch(() => undefined); }; 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) { return; } if (isPastingAttachment) { return; } const text = draftText.trim(); if (!text && !attachments.length) { return; } setDraftText(''); const nextAttachments = attachments; setAttachments([]); void sendMessage(text, nextAttachments).catch(() => { setDraftText((currentText) => (currentText ? currentText : text)); setAttachments((currentAttachments) => currentAttachments.length ? currentAttachments : 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 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 ( ); } return ( <>