// web/src/components/ChatArea.tsx import React, { useState, useRef, useEffect, useMemo } from 'react'; import { Button } from './ui/button'; import { Textarea } from './ui/textarea'; import { Send, ArrowDown, Trash2, Share2 } from 'lucide-react'; import { Message } from './Message'; import { FileUploadArea } from './FileUploadArea'; import { MemoryLine } from './MemoryLine'; import type { Message as MessageType, LearningMode, UploadedFile, FileType, SpaceType } from '../App'; import { toast } from 'sonner'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu'; interface ChatAreaProps { messages: MessageType[]; onSendMessage: (content: string) => void; uploadedFiles: UploadedFile[]; onFileUpload: (files: File[]) => void; onRemoveFile: (index: number) => void; onFileTypeChange: (index: number, type: FileType) => void; // ✅ feedback 需要 userId userId?: string; // ✅ 由 App.tsx 传入 currentDocTypeForChat docType?: string; memoryProgress: number; isLoggedIn: boolean; learningMode: LearningMode; onClearConversation: () => void; onLearningModeChange: (mode: LearningMode) => void; spaceType: SpaceType; } export function ChatArea({ messages, onSendMessage, uploadedFiles, onFileUpload, onRemoveFile, onFileTypeChange, userId, docType = 'Other', memoryProgress, isLoggedIn, learningMode, onClearConversation, onLearningModeChange, spaceType, }: ChatAreaProps) { const [input, setInput] = useState(''); const [isTyping, setIsTyping] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false); const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); const lastUserMessageContent = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'user' && messages[i].content?.trim()) return messages[i].content; } return ''; }, [messages]); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; useEffect(() => { scrollToBottom(); }, [messages]); useEffect(() => { const handleScroll = () => { if (!scrollContainerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; setShowScrollButton(scrollHeight - scrollTop - clientHeight > 100); }; const container = scrollContainerRef.current; container?.addEventListener('scroll', handleScroll); return () => container?.removeEventListener('scroll', handleScroll); }, []); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!input.trim() || !isLoggedIn) return; onSendMessage(input); setInput(''); setIsTyping(true); setTimeout(() => setIsTyping(false), 1200); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(e); } }; const modeLabels: Record = { concept: 'Concept Explainer', socratic: 'Socratic Tutor', exam: 'Exam Prep', assignment: 'Assignment Helper', summary: 'Quick Summary', }; const handleClearClick = () => { if (messages.length <= 1) { toast.info('No conversation to clear'); return; } if (window.confirm('Are you sure you want to clear the conversation? This cannot be undone.')) { onClearConversation(); toast.success('Conversation cleared'); } }; const handleShareClick = () => { if (messages.length <= 1) { toast.info('No conversation to share'); return; } const conversationText = messages .map((msg) => `${msg.role === 'user' ? 'You' : 'Clare'}: ${msg.content}`) .join('\n\n'); navigator.clipboard .writeText(conversationText) .then(() => toast.success('Conversation copied to clipboard!')) .catch(() => toast.error('Failed to copy conversation')); }; return (
{messages.length > 1 && (
)}
{messages.map((m) => ( ))} {isTyping && (
C
)}
{showScrollButton && (
)}
{(['concept', 'socratic', 'exam', 'assignment', 'summary'] as LearningMode[]).map((mode) => ( onLearningModeChange(mode)} className={learningMode === mode ? 'bg-accent' : ''} > {modeLabels[mode]} ))}