SarahXia0405's picture
Update web/src/components/ChatArea.tsx
28f05af verified
// 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>
);
}