Spaces:
Sleeping
Sleeping
| // 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<HTMLDivElement>(null); | |
| const scrollContainerRef = useRef<HTMLDivElement>(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<LearningMode, string> = { | |
| 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 ( | |
| <div className="flex flex-col h-full"> | |
| <div className="flex-1 relative border-b-2 border-border"> | |
| {messages.length > 1 && ( | |
| <div className="absolute top-4 right-12 z-10 flex gap-2"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={handleShareClick} | |
| disabled={!isLoggedIn} | |
| className="gap-2 bg-background/95 backdrop-blur-sm shadow-sm hover:shadow-md transition-all group" | |
| > | |
| <Share2 className="h-4 w-4" /> | |
| <span className="hidden group-hover:inline">Share</span> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={handleClearClick} | |
| disabled={!isLoggedIn} | |
| className="gap-2 bg-background/95 backdrop-blur-sm shadow-sm hover:shadow-md transition-all group" | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| <span className="hidden group-hover:inline">Clear</span> | |
| </Button> | |
| </div> | |
| )} | |
| <div ref={scrollContainerRef} className="h-full max-h-[600px] overflow-y-auto px-4 py-6 pb-36"> | |
| <div className="max-w-4xl mx-auto space-y-6"> | |
| {messages.map((m) => ( | |
| <Message | |
| key={m.id} | |
| message={m} | |
| showSenderInfo={spaceType === 'group'} | |
| userId={userId} | |
| isLoggedIn={isLoggedIn} | |
| learningMode={learningMode} | |
| docType={docType} | |
| lastUserText={lastUserMessageContent} | |
| /> | |
| ))} | |
| {isTyping && ( | |
| <div className="flex gap-3"> | |
| <div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center flex-shrink-0"> | |
| <span className="text-white text-sm">C</span> | |
| </div> | |
| <div className="bg-muted rounded-2xl px-4 py-3"> | |
| <div className="flex gap-1"> | |
| <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '0ms' }} /> | |
| <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '150ms' }} /> | |
| <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '300ms' }} /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| </div> | |
| {showScrollButton && ( | |
| <div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20"> | |
| <Button | |
| variant="secondary" | |
| size="icon" | |
| className="rounded-full shadow-lg hover:shadow-xl transition-shadow bg-background" | |
| onClick={scrollToBottom} | |
| > | |
| <ArrowDown className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| )} | |
| <div className="absolute bottom-0 left-0 right-0 bg-background/95 backdrop-blur-sm z-10"> | |
| <div className="max-w-4xl mx-auto px-4 py-4"> | |
| <form onSubmit={handleSubmit}> | |
| <div className="relative"> | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="absolute bottom-3 left-2 gap-1.5 h-8 px-2 text-xs z-10 hover:bg-muted/50" | |
| disabled={!isLoggedIn} | |
| type="button" | |
| > | |
| <span>{modeLabels[learningMode]}</span> | |
| <svg className="h-3 w-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> | |
| </svg> | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent align="start" className="w-56"> | |
| {(['concept', 'socratic', 'exam', 'assignment', 'summary'] as LearningMode[]).map((mode) => ( | |
| <DropdownMenuItem | |
| key={mode} | |
| onClick={() => onLearningModeChange(mode)} | |
| className={learningMode === mode ? 'bg-accent' : ''} | |
| > | |
| <span className="font-medium">{modeLabels[mode]}</span> | |
| </DropdownMenuItem> | |
| ))} | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| <Textarea | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={ | |
| isLoggedIn | |
| ? spaceType === 'group' | |
| ? 'Type a message... (mention @Clare to get AI assistance)' | |
| : 'Ask Clare anything about the course...' | |
| : 'Please log in on the right to start chatting...' | |
| } | |
| disabled={!isLoggedIn} | |
| className="min-h-[80px] pl-4 pr-12 resize-none bg-background border-2 border-border" | |
| /> | |
| <Button type="submit" size="icon" disabled={!input.trim() || !isLoggedIn} className="absolute bottom-2 right-2 rounded-full"> | |
| <Send className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="bg-card"> | |
| <div className="max-w-4xl mx-auto px-4 py-4"> | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> | |
| <FileUploadArea | |
| uploadedFiles={uploadedFiles} | |
| onFileUpload={onFileUpload} | |
| onRemoveFile={onRemoveFile} | |
| onFileTypeChange={onFileTypeChange} | |
| disabled={!isLoggedIn} | |
| /> | |
| <MemoryLine progress={memoryProgress} /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |