import { Bot, Image as ImageIcon, Loader2, MessageCircle, Paperclip, Plus, Send, Square, Trash2, X, } from 'lucide-react'; import { type FormEvent, type WheelEvent as ReactWheelEvent, useEffect, useMemo, useState, } from 'react'; import { EDITOR_AGENT_MAX_ATTACHMENTS, type EditorAgentAttachmentRef, type EditorAgentGenerationResultEvent, type EditorAgentGenerationRecord, type EditorAgentMessage, type EditorAgentStage, } from '../../../packages/shared/src/contracts/editorAgent'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; import { UnifiedModal } from '../common/UnifiedModal'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes'; import { type EditorAgentConversationClient, useEditorAgentConversation, } from './useEditorAgentConversation'; type AttachmentPickerTab = 'canvas' | 'library'; type EditorAgentAttachmentOption = { key: string; sourceLabel: string; attachment: EditorAgentAttachmentRef; }; type EditorAgentConversationPanelViewProps = { projectId?: string | null; open: boolean; onToggleOpen: () => void; layers?: CanvasLayer[]; assets?: EditorAsset[]; onGenerationResult?: (event: EditorAgentGenerationResultEvent) => void; client?: EditorAgentConversationClient; }; function stopAgentPanelWheel(event: ReactWheelEvent) { event.stopPropagation(); } function attachmentKey(attachment: EditorAgentAttachmentRef) { return `${attachment.source}:${attachment.referenceId}`; } 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 stageLabel(stage: EditorAgentStage) { if (stage === 'thinking') { return '思考中'; } if (stage === 'responding') { return '回复中'; } if (stage === 'generating') { return '生成中'; } if (stage === 'completed') { return '完成'; } if (stage === 'failed') { return '失败'; } return ''; } function toolLabel(toolName: EditorAgentGenerationRecord['toolName']) { if (toolName === 'edit_image') { return '修改图片'; } if (toolName === 'generate_character') { return '生成角色'; } if (toolName === 'generate_icon_spritesheet') { return '生成图标'; } if (toolName === 'generate_ui_design') { return '生成 UI'; } return '生成图片'; } function messageRoleLabel(role: EditorAgentMessage['role']) { return role === 'user' ? '你' : 'Agent'; } function AttachmentChip({ attachment, onRemove, }: { attachment: EditorAgentAttachmentRef; onRemove?: () => void; }) { const label = attachment.label?.trim() || attachment.referenceId; return ( ); } function GenerationRecordsView({ generations, }: { generations: EditorAgentGenerationRecord[]; }) { if (!generations.length) { return null; } return (
{generations.map((generation) => (
{generation.status === 'generating' || generation.error ? (
{generation.status === 'generating' ? (
) : null} {generation.error ? (
{generation.error}
) : null} {generation.images.length ? (
{generation.images.map((image, index) => (
))}
) : null}
))}
); } function MessageBubble({ message, }: { message: EditorAgentMessage; }) { const isUser = message.role === 'user'; return (
{message.text || (message.status === 'streaming' ? '...' : '')}
{message.attachments.length ? (
{message.attachments.map((attachment) => ( ))}
) : null}
); } 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({ projectId, open, onToggleOpen, layers = [], assets = [], onGenerationResult, client, }: EditorAgentConversationPanelViewProps) { const [hasConversationMounted, setHasConversationMounted] = useState(open); useEffect(() => { if (open) { setHasConversationMounted(true); } }, [open]); const effectiveProjectId = hasConversationMounted ? projectId : null; const { conversations, activeConversationId, messages, stage, isLoadingConversations, isLoadingMessages, isCreatingConversation, isDeletingConversation, isStreaming, errorMessage, createConversation, selectConversation, sendMessage, stopCurrentTurn, deleteActiveConversation, } = useEditorAgentConversation({ projectId: effectiveProjectId, client, onGenerationResult, }); const [draftText, setDraftText] = useState(''); const [attachments, setAttachments] = useState( [], ); const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false); const [attachmentPickerTab, setAttachmentPickerTab] = useState('canvas'); const [attachmentError, setAttachmentError] = useState(null); 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 currentStageLabel = stageLabel(stage); const hasProject = Boolean(projectId?.trim()); 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 (isStreaming) { stopCurrentTurn(); 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 removeAttachment = (targetAttachment: EditorAgentAttachmentRef) => { const key = attachmentKey(targetAttachment); setAttachments((currentAttachments) => currentAttachments.filter( (attachment) => attachmentKey(attachment) !== key, ), ); }; if (!open) { return ( ); } return ( <>