import { useState, useEffect, useRef, useCallback, useMemo, type MutableRefObject, type RefObject, type CSSProperties, createElement } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { List, type ListImperativeAPI } from 'react-window'; import { useChatStore, type Message } from '../store/chatStore'; import { useConversationStore } from '../store/chat/conversationStore'; import { useArtifactStore } from '../store/chat/artifactStore'; import { useConnectionStore } from '../store/connectionStore'; import { useAgentStore } from '../store/agentStore'; import { useConfigStore } from '../store/configStore'; import { useSaaSStore } from '../store/saasStore'; import { type UnlistenFn } from '@tauri-apps/api/event'; import { safeListenEvent } from '../lib/safe-tauri'; import { Paperclip, ArrowUp, MessageSquare, Download, X, FileText, Image as ImageIcon, Search, ClipboardList, Square } from 'lucide-react'; import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui'; import { ResizableChatLayout } from './ai/ResizableChatLayout'; import { ArtifactPanel } from './ai/ArtifactPanel'; import { ToolCallChain, type ToolCallStep } from './ai/ToolCallChain'; import { TaskProgress, type Subtask } from './ai/TaskProgress'; import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations'; import { FirstConversationPrompt } from './FirstConversationPrompt'; import { ClassroomPlayer } from './classroom_player'; import { useClassroomStore } from '../store/classroomStore'; import { MessageSearch } from './MessageSearch'; import { OfflineIndicator } from './OfflineIndicator'; import { useVirtualizedMessages, type VirtualizedMessageItem } from '../lib/message-virtualization'; import { Conversation } from './ai/Conversation'; import { ReasoningBlock } from './ai/ReasoningBlock'; import { StreamingText } from './ai/StreamingText'; import { ChatMode } from './ai/ChatMode'; import { ModelSelector } from './ai/ModelSelector'; import { isTauriRuntime } from '../lib/tauri-gateway'; import { SuggestionChips } from './ai/SuggestionChips'; import { PipelineResultPreview } from './pipeline/PipelineResultPreview'; import { PresentationContainer } from './presentation/PresentationContainer'; // TokenMeter temporarily unused — using inline text counter instead // Default heights for virtualized messages const DEFAULT_MESSAGE_HEIGHTS: Record = { user: 80, assistant: 150, tool: 120, hand: 120, workflow: 100, system: 60, }; // Threshold for enabling virtualization (messages count) const VIRTUALIZATION_THRESHOLD = 100; export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenDetail?: () => void }) { const { messages, isStreaming, isLoading, sendMessage: sendToGateway, initStreamListener, chatMode, setChatMode, suggestions, totalInputTokens, totalOutputTokens, cancelStream, } = useChatStore(); const currentAgent = useConversationStore((s) => s.currentAgent); const currentModel = useConversationStore((s) => s.currentModel); const setCurrentModel = useConversationStore((s) => s.setCurrentModel); const { artifacts, selectedArtifactId, artifactPanelOpen, selectArtifact, setArtifactPanelOpen, } = useArtifactStore(); const connectionState = useConnectionStore((s) => s.connectionState); const { activeClassroom, classroomOpen, closeClassroom, generating, progressPercent, progressActivity, error: classroomError, clearError: clearClassroomError } = useClassroomStore(); const clones = useAgentStore((s) => s.clones); const configModels = useConfigStore((s) => s.models); const saasModels = useSaaSStore((s) => s.availableModels); const isLoggedIn = useSaaSStore((s) => s.isLoggedIn); // Merge models: SaaS available models take priority when logged in const models = useMemo(() => { if (isLoggedIn && saasModels.length > 0) { return saasModels.map(m => ({ id: m.alias || m.id, name: m.alias || m.id, provider: m.provider_id, })); } if (configModels.length > 0) { return configModels; } // Fallback: provide common models when no backend is connected return [ { id: 'glm-4-flash-250414', name: 'GLM-4 Flash (免费)', provider: 'zhipu' }, { id: 'gpt-4o', name: 'GPT-4o', provider: 'openai' }, { id: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'openai' }, { id: 'deepseek-chat', name: 'DeepSeek V3', provider: 'deepseek' }, { id: 'qwen-max', name: 'Qwen Max', provider: 'qwen' }, { id: 'claude-3-5-sonnet', name: 'Claude 3.5 Sonnet', provider: 'anthropic' }, ]; }, [isLoggedIn, saasModels, configModels]); const [input, setInput] = useState(''); const [pendingFiles, setPendingFiles] = useState([]); const [searchOpen, setSearchOpen] = useState(false); const scrollRef = useRef(null); const textareaRef = useRef(null); const messageRefs = useRef>(new Map()); const fileInputRef = useRef(null); // Convert messages to virtualization format const virtualizedMessages: VirtualizedMessageItem[] = useMemo( () => messages.map((msg) => ({ id: msg.id, height: DEFAULT_MESSAGE_HEIGHTS[msg.role] ?? 100, role: msg.role, })), [messages] ); // Use virtualization hook const { listRef, getHeight, setHeight, scrollToBottom, } = useVirtualizedMessages(virtualizedMessages, DEFAULT_MESSAGE_HEIGHTS); // Whether to use virtualization const useVirtualization = messages.length >= VIRTUALIZATION_THRESHOLD; // Get current clone for first conversation prompt const currentClone = useMemo(() => { if (!currentAgent) return null; return clones.find((c) => c.id === currentAgent.id) || null; }, [currentAgent, clones]); // Check if should show first conversation prompt const showFirstPrompt = messages.length === 0 && currentClone && !currentClone.onboardingCompleted; // Handle suggestion click from first conversation prompt const handleSelectSuggestion = (text: string) => { setInput(text); textareaRef.current?.focus(); }; // Auto-resize textarea const adjustTextarea = useCallback(() => { const el = textareaRef.current; if (el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 160) + 'px'; } }, []); // File handling const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILES = 5; const addFiles = useCallback((files: FileList | File[]) => { const incoming = Array.from(files).filter((f) => f.size <= MAX_FILE_SIZE); setPendingFiles((prev) => { const combined = [...prev, ...incoming]; return combined.slice(0, MAX_FILES); }); }, []); // Paste handler for images/files useEffect(() => { const handler = (e: ClipboardEvent) => { if (e.clipboardData?.files.length) { e.preventDefault(); addFiles(e.clipboardData.files); } }; document.addEventListener('paste', handler); return () => document.removeEventListener('paste', handler); }, [addFiles]); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); if (e.dataTransfer.files.length) { addFiles(e.dataTransfer.files); } }, [addFiles]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); }, []); // Init agent stream listener on mount useEffect(() => { const unsub = initStreamListener(); return unsub; }, []); // Listen for hand-execution-complete Tauri events useEffect(() => { let unlisten: UnlistenFn | undefined; safeListenEvent<{ approvalId: string; handId: string; success: boolean; error?: string | null }>( 'hand-execution-complete', (event) => { const { handId, success, error } = event.payload; useChatStore.getState().addMessage({ id: crypto.randomUUID(), role: 'hand', content: success ? `Hand ${handId} 执行完成` : `Hand ${handId} 执行失败: ${error || '未知错误'}`, timestamp: new Date(), handName: handId, handStatus: success ? 'completed' : 'failed', handResult: event.payload, }); }, ).then((fn) => { unlisten = fn; }); return () => { unlisten?.(); }; }, []); // Auto-scroll to bottom on new messages useEffect(() => { if (scrollRef.current && !useVirtualization) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } else if (useVirtualization && messages.length > 0) { scrollToBottom(); } }, [messages, useVirtualization, scrollToBottom]); const handleSend = () => { if ((!input.trim() && pendingFiles.length === 0) || isStreaming) return; // Attach file names as metadata in the message const fileContext = pendingFiles.length > 0 ? `\n\n[附件: ${pendingFiles.map((f) => f.name).join(', ')}]` : ''; sendToGateway(input + fileContext); setInput(''); setPendingFiles([]); }; const createRetryHandler = (msgId: string) => () => { if (isStreaming) return; // Find the user message immediately before this error const idx = messages.findIndex(m => m.id === msgId); if (idx > 0) { const prevMsg = messages[idx - 1]; if (prevMsg.role === 'user' && prevMsg.content) { sendToGateway(prevMsg.content); } } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const connected = connectionState === 'connected'; // Build artifact panel content const artifactRightPanel = ( ); return (
{/* Generation progress overlay */} {generating && (

正在生成课堂...

{progressActivity || '准备中...'}

{progressPercent > 0 && (

{progressPercent}%

)}
)} {/* ClassroomPlayer overlay */} {classroomOpen && activeClassroom && ( )} {/* Classroom generation error banner */} {classroomError && (
课堂生成失败: {classroomError}
)} {/* Header — DeerFlow-style: minimal */}
{currentAgent?.name || '新对话'}
{/* Token usage counter — DeerFlow-style plain text */} {!compact && (totalInputTokens + totalOutputTokens) > 0 && (() => { const total = totalInputTokens + totalOutputTokens; const display = total >= 1000 ? `${(total / 1000).toFixed(1)}K` : String(total); return ( {display} ); })()} {!compact && messages.length > 0 && ( )} {/* 详情按钮 (简洁模式) */} {compact && onOpenDetail && ( )}
{/* MessageSearch panel */} {searchOpen && messages.length > 0 && (
{ const el = messageRefs.current.get(id); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }} />
)}
{/* Messages */} {/* Loading skeleton */} {isLoading && messages.length === 0 && ( )} {/* Empty state */} {!isLoading && messages.length === 0 && ( {showFirstPrompt && currentClone ? ( ) : ( } title="欢迎使用 ZCLAW" description={connected ? '发送消息开始对话。' : '请先在设置中连接 Gateway。'} /> )} )} {/* Virtualized list for large message counts, smooth scroll for small counts */} {useVirtualization && messages.length > 0 ? ( { const idx = messages.findIndex(m => m.id === msgId); if (idx > 0) { const prevMsg = messages[idx - 1]; if (prevMsg.role === 'user' && prevMsg.content) { return () => { if (!isStreaming) sendToGateway(prevMsg.content); }; } } return undefined; }} /> ) : ( messages.map((message) => ( { if (el) messageRefs.current.set(message.id, el); }} variants={listItemVariants} initial="hidden" animate="visible" layout transition={defaultTransition} > )) )} {/* Input */}
{/* Suggestion chips */} {!isStreaming && suggestions.length > 0 && !messages.some(m => m.error) && ( { setInput(text); textareaRef.current?.focus(); }} className="mb-3" /> )} {/* Hidden file input */} { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }} /> {/* Pending file previews */} {pendingFiles.length > 0 && (
{pendingFiles.map((file, idx) => (
{file.type.startsWith('image/') ? ( ) : ( )} {file.name} ({(file.size / 1024).toFixed(0)}K)
))}
)} {/* Input card — DeerFlow-style: white card, textarea top, actions bottom */}
{/* Textarea area */}