diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..92168a94bec2a99726b8e1f0362ce8251780d8c5 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,1159 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Header } from './components/Header'; +import { LeftSidebar } from './components/LeftSidebar'; +import { ChatArea } from './components/ChatArea'; +import { LoginScreen } from './components/LoginScreen'; +import { ProfileEditor } from './components/ProfileEditor'; +import { ReviewBanner } from './components/ReviewBanner'; +import { Onboarding } from './components/Onboarding'; +import { Menu, X, User, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from './components/ui/button'; +import { Toaster } from './components/ui/sonner'; +import { toast } from 'sonner'; + +export interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp: Date; + references?: string[]; + sender?: GroupMember; // For group chat + showNextButton?: boolean; // For quiz mode + questionData?: { + type: 'multiple-choice' | 'fill-in-blank' | 'open-ended'; + question: string; + options?: string[]; + correctAnswer: string; + explanation: string; + sampleAnswer?: string; + }; +} + +export interface User { + name: string; + email: string; +} + +export interface GroupMember { + id: string; + name: string; + email: string; + avatar?: string; + isAI?: boolean; +} + +export type SpaceType = 'individual' | 'group'; + +export interface CourseInfo { + id: string; + name: string; + instructor: { + name: string; + email: string; + }; + teachingAssistant: { + name: string; + email: string; + }; +} + +export interface Workspace { + id: string; + name: string; + type: SpaceType; + avatar: string; + members?: GroupMember[]; + category?: 'course' | 'personal'; + courseName?: string; + courseInfo?: CourseInfo; + isEditable?: boolean; // For personal interest workspaces +} + +export type FileType = 'syllabus' | 'lecture-slides' | 'literature-review' | 'other'; + +export interface UploadedFile { + file: File; + type: FileType; +} + +export type LearningMode = 'general' | 'concept' | 'socratic' | 'exam' | 'assignment' | 'summary'; +export type Language = 'auto' | 'en' | 'zh'; +export type ChatMode = 'ask' | 'review' | 'quiz'; + +export interface SavedItem { + id: string; + title: string; + content: string; + type: 'export' | 'quiz' | 'summary'; + timestamp: Date; + isSaved: boolean; + format?: 'pdf' | 'text'; + workspaceId: string; +} + +export interface SavedChat { + id: string; + title: string; + messages: Message[]; + chatMode: ChatMode; + timestamp: Date; +} + +function App() { + const [isDarkMode, setIsDarkMode] = useState(() => { + const saved = localStorage.getItem('theme'); + return saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches); + }); + const [user, setUser] = useState(null); + // Global current course selection + const [currentCourseId, setCurrentCourseId] = useState(() => localStorage.getItem('myspace_selected_course') || 'course1'); + + // Available courses with instructor/TA info + const availableCourses: CourseInfo[] = [ + { + id: 'course1', + name: 'Introduction to AI', + instructor: { name: 'Dr. Sarah Johnson', email: 'sarah.johnson@university.edu' }, + teachingAssistant: { name: 'Michael Chen', email: 'michael.chen@university.edu' } + }, + { + id: 'course2', + name: 'Machine Learning', + instructor: { name: 'Prof. David Lee', email: 'david.lee@university.edu' }, + teachingAssistant: { name: 'Emily Zhang', email: 'emily.zhang@university.edu' } + }, + { + id: 'course3', + name: 'Data Structures', + instructor: { name: 'Dr. Robert Smith', email: 'robert.smith@university.edu' }, + teachingAssistant: { name: 'Lisa Wang', email: 'lisa.wang@university.edu' } + }, + { + id: 'course4', + name: 'Web Development', + instructor: { name: 'Prof. Maria Garcia', email: 'maria.garcia@university.edu' }, + teachingAssistant: { name: 'James Brown', email: 'james.brown@university.edu' } + }, + ]; + + // Separate messages for each chat mode + const [askMessages, setAskMessages] = useState([ + { + id: '1', + role: 'assistant', + content: "👋 Hi! I'm Clare, your AI teaching assistant. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!", + timestamp: new Date(), + } + ]); + const [reviewMessages, setReviewMessages] = useState([ + { + id: 'review-1', + role: 'assistant', + content: "📚 Welcome to Review mode! I'll help you review and consolidate your learning. Let's go through what you've learned!", + timestamp: new Date(), + } + ]); + const [quizMessages, setQuizMessages] = useState([ + { + id: 'quiz-1', + role: 'assistant', + content: "🎯 Welcome to Quiz mode! I'll test your understanding with personalized questions based on your learning history. Ready to start?", + timestamp: new Date(), + } + ]); + + const [learningMode, setLearningMode] = useState('concept'); + const [language, setLanguage] = useState('auto'); + const [chatMode, setChatMode] = useState('ask'); + + // Get current messages based on chat mode + const messages = chatMode === 'ask' ? askMessages : chatMode === 'review' ? reviewMessages : quizMessages; + + // Track previous chat mode to detect mode changes + const prevChatModeRef = useRef(chatMode); + + // Ensure welcome message exists when switching modes or when messages are empty + useEffect(() => { + // Check the actual state arrays, not the computed messages + let currentMessages: Message[]; + let setCurrentMessages: (messages: Message[]) => void; + + if (chatMode === 'ask') { + currentMessages = askMessages; + setCurrentMessages = setAskMessages; + } else if (chatMode === 'review') { + currentMessages = reviewMessages; + setCurrentMessages = setReviewMessages; + } else { + currentMessages = quizMessages; + setCurrentMessages = setQuizMessages; + } + + const hasUserMessages = currentMessages.some(msg => msg.role === 'user'); + const expectedWelcomeId = chatMode === 'ask' ? '1' : chatMode === 'review' ? 'review-1' : 'quiz-1'; + const hasWelcomeMessage = currentMessages.some(msg => msg.id === expectedWelcomeId && msg.role === 'assistant'); + const modeChanged = prevChatModeRef.current !== chatMode; + + // If mode changed or messages are empty or missing welcome message, restore welcome message + if ((modeChanged || currentMessages.length === 0 || !hasWelcomeMessage) && !hasUserMessages) { + const initialMessages: Record = { + ask: [{ + id: '1', + role: 'assistant', + content: "👋 Hi! I'm Clare, your AI teaching assistant. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!", + timestamp: new Date(), + }], + review: [{ + id: 'review-1', + role: 'assistant', + content: "📚 Welcome to Review mode! I'll help you review and consolidate your learning. Let's go through what you've learned!", + timestamp: new Date(), + }], + quiz: [{ + id: 'quiz-1', + role: 'assistant', + content: "🎯 Welcome to Quiz mode! I'll test your understanding with personalized questions based on your learning history. Ready to start?", + timestamp: new Date(), + }], + }; + + setCurrentMessages(initialMessages[chatMode]); + } + + prevChatModeRef.current = chatMode; + }, [chatMode, askMessages.length, reviewMessages.length, quizMessages.length]); // Only depend on lengths to avoid infinite loops + const [uploadedFiles, setUploadedFiles] = useState([]); + const [memoryProgress, setMemoryProgress] = useState(36); + const [quizState, setQuizState] = useState<{ + currentQuestion: number; + waitingForAnswer: boolean; + showNextButton: boolean; + }>({ + currentQuestion: 0, + waitingForAnswer: false, + showNextButton: false, + }); + const [isTyping, setIsTyping] = useState(false); + const [leftSidebarOpen, setLeftSidebarOpen] = useState(false); + const [leftPanelVisible, setLeftPanelVisible] = useState(true); + const [showProfileEditor, setShowProfileEditor] = useState(false); + const [showOnboarding, setShowOnboarding] = useState(false); + const [exportResult, setExportResult] = useState(''); + // Review banner state + const [showReviewBanner, setShowReviewBanner] = useState(() => { + // Temporarily force show for testing - remove this after confirming it works + // const dismissed = localStorage.getItem('reviewBannerDismissed'); + // return !dismissed || dismissed === 'false'; + return true; // Force show for testing + }); + const [resultType, setResultType] = useState<'export' | 'quiz' | 'summary' | null>(null); + const [showClearDialog, setShowClearDialog] = useState(false); + + // Saved conversations/summaries + const [savedItems, setSavedItems] = useState([]); + const [recentlySavedId, setRecentlySavedId] = useState(null); + + // Saved chats + const [savedChats, setSavedChats] = useState([]); + + // Mock group members + const [groupMembers] = useState([ + { id: 'clare', name: 'Clare AI', email: 'clare@ai.assistant', isAI: true }, + { id: '1', name: 'Sarah Johnson', email: 'sarah.j@university.edu' }, + { id: '2', name: 'Michael Chen', email: 'michael.c@university.edu' }, + { id: '3', name: 'Emma Williams', email: 'emma.w@university.edu' }, + ]); + + // Workspaces - individual workspace uses user's avatar, group workspaces use group avatars + const [workspaces, setWorkspaces] = useState([]); + const [currentWorkspaceId, setCurrentWorkspaceId] = useState('individual'); + + // Initialize workspaces when user logs in + useEffect(() => { + if (user) { + const userAvatar = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`; + const course1Info = availableCourses.find(c => c.id === 'course1'); + const course2Info = availableCourses.find(c => c.name === 'AI Ethics'); + + setWorkspaces([ + { + id: 'individual', + name: 'My Space', + type: 'individual', + avatar: userAvatar, + }, + { + id: 'group-1', + name: 'CS 101 Study Group', + type: 'group', + avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=cs101group', + members: groupMembers, + category: 'course', + courseName: course1Info?.name || 'CS 101', + courseInfo: course1Info, + }, + { + id: 'group-2', + name: 'AI Ethics Team', + type: 'group', + avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=aiethicsteam', + members: groupMembers, + category: 'course', + courseName: course2Info?.name || 'AI Ethics', + courseInfo: course2Info, + }, + ]); + } + }, [user, groupMembers, availableCourses]); + + // Get current workspace + const currentWorkspace = workspaces.find(w => w.id === currentWorkspaceId) || workspaces[0]; + const spaceType: SpaceType = currentWorkspace?.type || 'individual'; + + // Keep current course in sync with workspace type + useEffect(() => { + if (!currentWorkspace) return; + if (currentWorkspace.type === 'group' && currentWorkspace.category === 'course' && currentWorkspace.courseName) { + setCurrentCourseId(currentWorkspace.courseName); + } else if (currentWorkspace.type === 'individual') { + const saved = localStorage.getItem('myspace_selected_course'); + if (saved) setCurrentCourseId(saved); + } + }, [currentWorkspaceId, currentWorkspace]); + + // Persist selection for My Space + useEffect(() => { + if (currentWorkspace?.type === 'individual') { + localStorage.setItem('myspace_selected_course', currentCourseId); + } + }, [currentCourseId, currentWorkspace]); + + useEffect(() => { + document.documentElement.classList.toggle('dark', isDarkMode); + localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); + }, [isDarkMode]); + + const generateQuizQuestion = () => { + const questions: Array<{ + type: 'multiple-choice' | 'fill-in-blank' | 'open-ended'; + question: string; + options?: string[]; + correctAnswer: string; + explanation: string; + sampleAnswer?: string; + }> = [ + { + type: 'multiple-choice', + question: "Which of the following is NOT a principle of Responsible AI?", + options: ["A) Fairness", "B) Transparency", "C) Profit Maximization", "D) Accountability"], + correctAnswer: "C", + explanation: "Profit Maximization is not a principle of Responsible AI. The key principles are Fairness, Transparency, and Accountability." + }, + { + type: 'fill-in-blank', + question: "Algorithmic fairness ensures that AI systems do not discriminate against individuals based on their _____.", + correctAnswer: "protected characteristics", + explanation: "Protected characteristics include attributes like race, gender, age, religion, etc. AI systems should not discriminate based on these." + }, + { + type: 'open-ended', + question: "Explain why transparency is important in AI systems.", + correctAnswer: "Transparency allows users to understand how AI systems make decisions, which builds trust and enables accountability.", + sampleAnswer: "Transparency allows users to understand how AI systems make decisions, which builds trust and enables accountability.", + explanation: "Transparency is crucial because it helps users understand AI decision-making processes, enables debugging, and ensures accountability." + } + ]; + + const randomIndex = Math.floor(Math.random() * questions.length); + return questions[randomIndex]; + }; + + const handleSendMessage = (content: string) => { + if (!content.trim() || !user) return; + + // Attach sender info for all user messages + const sender: GroupMember = { + id: user.email, + name: user.name, + email: user.email, + avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`, + }; + + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content, + timestamp: new Date(), + sender, + }; + + // Add user message to the appropriate mode's message list + if (chatMode === 'ask') { + setAskMessages(prev => [...prev, userMessage]); + } else if (chatMode === 'review') { + setReviewMessages(prev => [...prev, userMessage]); + } else { + setQuizMessages(prev => [...prev, userMessage]); + } + + // Handle Quiz mode differently + if (chatMode === 'quiz') { + if (quizState.waitingForAnswer) { + // User is answering a question + const isCorrect = Math.random() > 0.3; // Simulate answer checking + + setIsTyping(true); + setTimeout(() => { + const feedback = isCorrect + ? "✅ Correct! Great job!" + : "❌ Not quite right, but good effort!"; + + const explanation = "Here's the explanation: The correct answer demonstrates understanding of the key concepts. Let me break it down for you..."; + + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: `${feedback}\n\n${explanation}`, + timestamp: new Date(), + sender: spaceType === 'group' ? groupMembers.find(m => m.isAI) : undefined, + showNextButton: true, + }; + + // Close typing indicator first + setIsTyping(false); + + // Wait a bit to ensure typing indicator disappears before adding message + setTimeout(() => { + setQuizMessages(prev => [...prev, assistantMessage]); + setQuizState(prev => ({ ...prev, waitingForAnswer: false, showNextButton: true })); + }, 50); + }, 2000); + } + return; + } + + // Handle Ask and Review modes + // Respond in all workspaces to keep conversations continuous + const shouldAIRespond = true; + + if (shouldAIRespond) { + setIsTyping(true); + setTimeout(() => { + let response = ''; + + if (chatMode === 'ask') { + const responses: Record = { + general: "I'd be happy to help! To provide you with the most accurate and relevant answer, could you please provide some context about what you're asking? For example, what subject or topic is this related to?", + concept: "Great question! Let me break this concept down for you. In Responsible AI, this relates to ensuring our AI systems are fair, transparent, and accountable. Would you like me to explain any specific aspect in more detail?", + socratic: "That's an interesting point! Let me ask you this: What do you think are the key ethical considerations when deploying AI systems? Take a moment to think about it.", + exam: "Let me test your understanding with a quick question: Which of the following is NOT a principle of Responsible AI? A) Fairness B) Transparency C) Profit Maximization D) Accountability", + assignment: "I can help you with that assignment! Let's break it down into manageable steps. First, what specific aspect are you working on?", + summary: "Here's a quick summary: Responsible AI focuses on developing and deploying AI systems that are ethical, fair, transparent, and accountable to society.", + }; + response = responses[learningMode]; + } else if (chatMode === 'review') { + // Check if this is a review request by checking window storage + const reviewData = (window as any).__lastReviewData; + if (reviewData) { + if (reviewData.startsWith('REVIEW_TOPIC:')) { + // Parse review topic data + const data = reviewData.replace('REVIEW_TOPIC:', '').split('|'); + const [title, previousQuestion, memoryRetention, schedule, status, weight, lastReviewed] = data; + + response = `Let's review **${title}** together!\n\n**Your Previous Question:**\n"${previousQuestion}"\n\n**Review Details:**\n- **Memory Retention:** ${memoryRetention}%\n- **Schedule:** ${schedule}\n- **Status:** ${status.toUpperCase()}\n- **Weight:** ${weight}%\n- **Last Reviewed:** ${lastReviewed}\n\nLet's go through this topic step by step. What would you like to focus on first?`; + // Clear the stored data + delete (window as any).__lastReviewData; + } else if (reviewData === 'REVIEW_ALL') { + response = `I'll help you review all the topics that need your attention. Based on your learning history, here are the topics we should focus on:\n\n1. **Main Concept of Lab 3** (Urgent - Memory Retention: 25%)\n2. **Effective Prompt Engineering** (Review - Memory Retention: 60%)\n3. **Objective LLM Evaluation** (Stable - Memory Retention: 90%)\n\nLet's start with the most urgent ones first. Which topic would you like to begin with?`; + // Clear the stored data + delete (window as any).__lastReviewData; + } else { + response = "Let's review what you've learned! Based on your previous conversations, here are the key concepts we covered: [Review content would go here]"; + } + } else { + response = "Let's review what you've learned! Based on your previous conversations, here are the key concepts we covered: [Review content would go here]"; + } + } + + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: response, + timestamp: new Date(), + references: chatMode === 'ask' ? ['Module 10, Section 2.3', 'Lecture Notes - Week 5'] : undefined, + sender: spaceType === 'group' ? groupMembers.find(m => m.isAI) : undefined, + }; + + // Close typing indicator first + setIsTyping(false); + + // Wait a bit to ensure typing indicator disappears before adding message + setTimeout(() => { + if (chatMode === 'ask') { + setAskMessages(prev => [...prev, assistantMessage]); + } else if (chatMode === 'review') { + setReviewMessages(prev => [...prev, assistantMessage]); + } + }, 50); + }, 2000); + } + }; + + const handleNextQuestion = () => { + setIsTyping(true); + const question = generateQuizQuestion(); + let questionText = question.question; + + if (question.type === 'multiple-choice') { + questionText += '\n\n' + question.options.join('\n'); + } + + setTimeout(() => { + const assistantMessage: Message = { + id: Date.now().toString(), + role: 'assistant', + content: questionText, + timestamp: new Date(), + sender: spaceType === 'group' ? groupMembers.find(m => m.isAI) : undefined, + questionData: question, + }; + + // Close typing indicator first + setIsTyping(false); + + // Wait a bit to ensure typing indicator disappears before adding message + setTimeout(() => { + setQuizMessages(prev => [...prev, assistantMessage]); + setQuizState(prev => ({ + ...prev, + currentQuestion: prev.currentQuestion + 1, + waitingForAnswer: true, + showNextButton: false + })); + }, 50); + }, 2000); + }; + + const handleStartQuiz = () => { + handleNextQuestion(); + }; + + const handleFileUpload = (files: File[]) => { + const newFiles: UploadedFile[] = files.map(file => ({ + file, + type: 'other' as FileType, // Default type + })); + setUploadedFiles(prev => [...prev, ...newFiles]); + }; + + const handleRemoveFile = (index: number) => { + setUploadedFiles(prev => prev.filter((_, i) => i !== index)); + }; + + const handleFileTypeChange = (index: number, type: FileType) => { + setUploadedFiles(prev => prev.map((file, i) => + i === index ? { ...file, type } : file + )); + }; + + // Helper function to check if current chat is already saved + const isCurrentChatSaved = (): SavedChat | null => { + if (messages.length <= 1) return null; + + // Find a saved chat that matches the current messages and chatMode + return savedChats.find(chat => { + if (chat.chatMode !== chatMode) return false; + if (chat.messages.length !== messages.length) return false; + + // Check if all messages match + return chat.messages.every((savedMsg, index) => { + const currentMsg = messages[index]; + return ( + savedMsg.id === currentMsg.id && + savedMsg.role === currentMsg.role && + savedMsg.content === currentMsg.content + ); + }); + }) || null; + }; + + const handleSaveChat = () => { + if (messages.length <= 1) { + toast.info('No conversation to save'); + return; + } + + // Check if already saved + const existingChat = isCurrentChatSaved(); + if (existingChat) { + // Unsave: remove from saved chats + handleDeleteSavedChat(existingChat.id); + toast.success('Chat unsaved'); + return; + } + + // Save: add new chat + const title = `Chat - ${chatMode === 'ask' ? 'Ask' : chatMode === 'review' ? 'Review' : 'Quiz'} - ${new Date().toLocaleDateString()}`; + const newChat: SavedChat = { + id: Date.now().toString(), + title, + messages: [...messages], + chatMode, + timestamp: new Date(), + }; + + setSavedChats(prev => [newChat, ...prev]); + setLeftPanelVisible(true); + toast.success('Chat saved!'); + }; + + const handleLoadChat = (savedChat: SavedChat) => { + // Set the chat mode first + setChatMode(savedChat.chatMode); + + // Then set the messages for that mode + if (savedChat.chatMode === 'ask') { + setAskMessages(savedChat.messages); + } else if (savedChat.chatMode === 'review') { + setReviewMessages(savedChat.messages); + } else { + setQuizMessages(savedChat.messages); + // Reset quiz state + setQuizState({ + currentQuestion: 0, + waitingForAnswer: false, + showNextButton: false, + }); + } + + toast.success('Chat loaded!'); + }; + + const handleDeleteSavedChat = (id: string) => { + setSavedChats(prev => prev.filter(chat => chat.id !== id)); + toast.success('Chat deleted'); + }; + + const handleRenameSavedChat = (id: string, newTitle: string) => { + setSavedChats(prev => prev.map(chat => + chat.id === id ? { ...chat, title: newTitle } : chat + )); + toast.success('Chat renamed'); + }; + + const handleClearConversation = (shouldSave: boolean = false) => { + if (shouldSave) { + handleSaveChat(); + } + + const initialMessages: Record = { + ask: [{ + id: '1', + role: 'assistant', + content: "👋 Hi! I'm Clare, your AI teaching assistant. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!", + timestamp: new Date(), + }], + review: [{ + id: 'review-1', + role: 'assistant', + content: "📚 Welcome to Review mode! I'll help you review and consolidate your learning. Let's go through what you've learned!", + timestamp: new Date(), + }], + quiz: [{ + id: 'quiz-1', + role: 'assistant', + content: "🎯 Welcome to Quiz mode! I'll test your understanding with personalized questions based on your learning history. Ready to start?", + timestamp: new Date(), + }], + }; + + // Clear only the current mode's conversation + if (chatMode === 'ask') { + setAskMessages(initialMessages.ask); + } else if (chatMode === 'review') { + setReviewMessages(initialMessages.review); + } else { + setQuizMessages(initialMessages.quiz); + setQuizState({ + currentQuestion: 0, + waitingForAnswer: false, + showNextButton: false, + }); + } + }; + + const handleExport = () => { + const result = `# Conversation Export +Date: ${new Date().toLocaleDateString()} +Student: ${user?.name} + +## Summary +This conversation covered key concepts in Module 10 – Responsible AI, including ethical considerations, fairness, transparency, and accountability in AI systems. + +## Key Takeaways +1. Understanding the principles of Responsible AI +2. Real-world applications and implications +3. Best practices for ethical AI development + +Exported successfully! ✓`; + + setExportResult(result); + setResultType('export'); + toast.success('Conversation exported!'); + }; + + const handleQuiz = () => { + const quiz = `# Micro-Quiz: Responsible AI + +**Question 1:** Which of the following is a key principle of Responsible AI? +a) Profit maximization +b) Transparency +c) Rapid deployment +d) Cost reduction + +**Question 2:** What is algorithmic fairness? +(Short answer expected) + +**Question 3:** True or False: AI systems should always prioritize accuracy over fairness. + +Generate quiz based on your conversation!`; + + setExportResult(quiz); + setResultType('quiz'); + toast.success('Quiz generated!'); + }; + + const handleSummary = () => { + const summary = `# Learning Summary + +## Today's Session +**Duration:** 25 minutes +**Topics Covered:** 3 +**Messages Exchanged:** 12 + +## Key Concepts Discussed +• Principles of Responsible AI +• Ethical considerations in AI development +• Fairness and transparency in algorithms + +## Recommended Next Steps +1. Review Module 10, Section 2.3 +2. Complete practice quiz on algorithmic fairness +3. Read additional resources on AI ethics + +## Progress Update +You've covered 65% of Module 10 content. Keep up the great work! 🎉`; + + setExportResult(summary); + setResultType('summary'); + toast.success('Summary generated!'); + }; + + const handleSave = ( + content: string, + type: 'export' | 'quiz' | 'summary', + saveAsChat: boolean = false, + format: 'pdf' | 'text' = 'text', + workspaceId?: string + ) => { + if (!content.trim()) return; + + // Summary should always be saved as file, not chat + // If saving as chat (from RightPanel export/quiz only, not summary) + if (saveAsChat && type !== 'summary') { + // Convert the export result to a chat format + // Create messages from the export content + const chatMessages: Message[] = [ + { + id: '1', + role: 'assistant', + content: "👋 Hi! I'm Clare, your AI teaching assistant. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!", + timestamp: new Date(), + }, + { + id: Date.now().toString(), + role: 'assistant', + content, + timestamp: new Date(), + } + ]; + + const title = type === 'export' ? 'Exported Conversation' : 'Micro-Quiz'; + const newChat: SavedChat = { + id: Date.now().toString(), + title: `${title} - ${new Date().toLocaleDateString()}`, + messages: chatMessages, + chatMode: 'ask', + timestamp: new Date(), + }; + + setSavedChats(prev => [newChat, ...prev]); + setLeftPanelVisible(true); + toast.success('Chat saved!'); + return; + } + + // Otherwise, save as file (existing behavior) + // Check if already saved + const existingItem = savedItems.find(item => item.content === content && item.type === type); + if (existingItem) { + // Unsave: remove from saved items + handleUnsave(existingItem.id); + return; + } + + // Save: add new item + const title = type === 'export' ? 'Exported Conversation' : type === 'quiz' ? 'Micro-Quiz' : 'Summarization'; + const newItem: SavedItem = { + id: Date.now().toString(), + title: `${title} - ${new Date().toLocaleDateString()}`, + content, + type, + timestamp: new Date(), + isSaved: true, + format, + workspaceId: workspaceId || currentWorkspaceId, + }; + + setSavedItems(prev => [newItem, ...prev]); + setRecentlySavedId(newItem.id); + setLeftPanelVisible(true); // Open left panel + + // Clear the highlight after animation + setTimeout(() => { + setRecentlySavedId(null); + }, 2000); + + toast.success('Saved for later!'); + }; + + const handleUnsave = (id: string) => { + setSavedItems(prev => prev.filter(item => item.id !== id)); + toast.success('Removed from saved items'); + }; + + // Create a new group workspace + const handleCreateWorkspace = (payload: { + name: string; + category: 'course' | 'personal'; + courseId?: string; + invites: string[]; + }) => { + const id = `group-${Date.now()}`; + const avatar = `https://api.dicebear.com/7.x/shapes/svg?seed=${encodeURIComponent(payload.name)}`; + + // Add creator as first member + const creatorMember: GroupMember = user ? { + id: user.email, + name: user.name, + email: user.email, + avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`, + } : { id: 'unknown', name: 'Unknown', email: 'unknown@email.com' }; + + const members: GroupMember[] = [ + creatorMember, + ...payload.invites.map(email => ({ + id: email, + name: email.split('@')[0] || email, + email, + })) + ]; + + let newWorkspace: Workspace; + + if (payload.category === 'course') { + const courseInfo = availableCourses.find(c => c.id === payload.courseId); + newWorkspace = { + id, + name: payload.name, + type: 'group', + avatar, + members, + category: 'course', + courseName: courseInfo?.name || 'Untitled Course', + courseInfo, + }; + } else { + // Personal interest workspace + newWorkspace = { + id, + name: payload.name, + type: 'group', + avatar, + members, + category: 'personal', + isEditable: true, + }; + } + + setWorkspaces(prev => [...prev, newWorkspace]); + setCurrentWorkspaceId(id); + + // Set current course if it's a course workspace + if (payload.category === 'course' && payload.courseId) { + setCurrentCourseId(payload.courseId); + } + + toast.success('New group workspace created'); + }; + + // Handle review click - switch to review mode + const handleReviewClick = () => { + setChatMode('review'); + setShowReviewBanner(false); + localStorage.setItem('reviewBannerDismissed', 'true'); + }; + + // Handle dismiss review banner + const handleDismissReviewBanner = () => { + setShowReviewBanner(false); + localStorage.setItem('reviewBannerDismissed', 'true'); + }; + + // Handle login - check if user needs onboarding + const handleLogin = (newUser: User) => { + setUser(newUser); + // For testing: always show onboarding (comment out localStorage check) + // const onboardingCompleted = localStorage.getItem(`onboarding_completed_${newUser.email}`); + // if (!onboardingCompleted) { + setShowOnboarding(true); + // } + }; + + // Handle onboarding completion + const handleOnboardingComplete = (updatedUser: User) => { + setUser(updatedUser); + setShowOnboarding(false); + // For testing: don't save to localStorage (comment out) + // localStorage.setItem(`onboarding_completed_${updatedUser.email}`, 'true'); + }; + + // Handle onboarding skip + const handleOnboardingSkip = () => { + // For testing: don't save to localStorage (comment out) + // if (user) { + // localStorage.setItem(`onboarding_completed_${user.email}`, 'true'); + // } + setShowOnboarding(false); + }; + + // Show login screen if user is not logged in + if (!user) { + return ; + } + + // Show onboarding if user just logged in and hasn't completed it + if (showOnboarding && user) { + return ( + <> + + + ); + } + + return ( +
+ +
setLeftSidebarOpen(!leftSidebarOpen)} + onUserClick={() => {}} + isDarkMode={isDarkMode} + onToggleDarkMode={() => setIsDarkMode(!isDarkMode)} + language={language} + onLanguageChange={setLanguage} + workspaces={workspaces} + currentWorkspace={currentWorkspace} + onWorkspaceChange={setCurrentWorkspaceId} + onCreateWorkspace={handleCreateWorkspace} + onLogout={() => setUser(null)} + availableCourses={availableCourses} + onUserUpdate={setUser} + /> + {showProfileEditor && user && ( + setShowProfileEditor(false)} + /> + )} + + {/* Review Banner - Below Header */} + {showReviewBanner && ( +
+ +
+ )} + +
+ {/* Toggle Button - When panel is closed, at left edge, center axis aligned to left edge */} + {!leftPanelVisible && ( + + )} + {/* Mobile Sidebar Toggle - Left */} + {leftSidebarOpen && ( +
setLeftSidebarOpen(false)} + /> + )} + + {/* Left Sidebar */} + {leftPanelVisible ? ( + + ) : null} + + {/* Left Sidebar - Mobile */} + + + {/* Main Chat Area */} +
+ setShowClearDialog(true)} + onSaveChat={handleSaveChat} + onLearningModeChange={setLearningMode} + spaceType={spaceType} + chatMode={chatMode} + onChatModeChange={setChatMode} + onNextQuestion={handleNextQuestion} + onStartQuiz={handleStartQuiz} + quizState={quizState} + isTyping={isTyping} + showClearDialog={showClearDialog} + onConfirmClear={(shouldSave) => { + handleClearConversation(shouldSave); + setShowClearDialog(false); + }} + onCancelClear={() => setShowClearDialog(false)} + savedChats={savedChats} + workspaces={workspaces} + currentWorkspaceId={currentWorkspaceId} + onSaveFile={(content, type, _format, targetWorkspaceId) => handleSave(content, type, false, _format ?? 'text', targetWorkspaceId)} + leftPanelVisible={leftPanelVisible} + currentCourseId={currentCourseId} + onCourseChange={setCurrentCourseId} + availableCourses={availableCourses} + showReviewBanner={showReviewBanner} + /> +
+ +
+
+ ); +} + +export default App; \ No newline at end of file diff --git a/web/src/Attributions.md b/web/src/Attributions.md new file mode 100644 index 0000000000000000000000000000000000000000..9b7cd4e13487db2e20f4f3844255f0b967db8448 --- /dev/null +++ b/web/src/Attributions.md @@ -0,0 +1,3 @@ +This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md). + +This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license). \ No newline at end of file diff --git a/web/src/assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png b/web/src/assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png new file mode 100644 index 0000000000000000000000000000000000000000..40497e953690a6624da27b14e4fc718fc3bc77c8 Binary files /dev/null and b/web/src/assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png differ diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7ce0fce26d59f7aff6e64dc875dfea0e9c64fff3 --- /dev/null +++ b/web/src/components/ChatArea.tsx @@ -0,0 +1,1511 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Button } from './ui/button'; +import { Textarea } from './ui/textarea'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Send, ArrowDown, AlertCircle, Trash2, Share2, Upload, X, File, FileText, Presentation, Image as ImageIcon, Bookmark, Plus, Download, Copy } from 'lucide-react'; +import { Message } from './Message'; +import { Alert, AlertDescription } from './ui/alert'; +import { Badge } from './ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; +import type { Message as MessageType, LearningMode, UploadedFile, FileType, SpaceType, ChatMode, SavedChat, Workspace } from '../App'; +import { toast } from 'sonner'; +import { jsPDF } from 'jspdf'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from './ui/dropdown-menu'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogOverlay } from './ui/dialog'; +import { Checkbox } from './ui/checkbox'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from './ui/alert-dialog'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { SmartReview } from './SmartReview'; +import clareAvatar from '../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png'; + +interface ChatAreaProps { + messages: MessageType[]; + onSendMessage: (content: string) => void; + uploadedFiles: UploadedFile[]; + onFileUpload: (files: File[]) => void; + onRemoveFile: (index: number) => void; + onFileTypeChange: (index: number, type: FileType) => void; + memoryProgress: number; + isLoggedIn: boolean; + learningMode: LearningMode; + onClearConversation: () => void; + onSaveChat: () => void; + onLearningModeChange: (mode: LearningMode) => void; + spaceType: SpaceType; + chatMode: ChatMode; + onChatModeChange: (mode: ChatMode) => void; + onNextQuestion: () => void; + onStartQuiz: () => void; + quizState: { + currentQuestion: number; + waitingForAnswer: boolean; + showNextButton: boolean; + }; + isTyping: boolean; + showClearDialog: boolean; + onConfirmClear: (shouldSave: boolean) => void; + onCancelClear: () => void; + savedChats: SavedChat[]; + workspaces: Workspace[]; + currentWorkspaceId: string; + onSaveFile?: (content: string, type: 'export' | 'summary', format?: 'pdf' | 'text', workspaceId?: string) => void; + leftPanelVisible?: boolean; + currentCourseId?: string; + onCourseChange?: (courseId: string) => void; + availableCourses?: Array<{ id: string; name: string }>; + showReviewBanner?: boolean; +} + +interface PendingFile { + file: File; + type: FileType; +} + +export function ChatArea({ + messages, + onSendMessage, + uploadedFiles, + onFileUpload, + onRemoveFile, + onFileTypeChange, + memoryProgress, + isLoggedIn, + learningMode, + onClearConversation, + onSaveChat, + onLearningModeChange, + spaceType, + chatMode, + onChatModeChange, + onNextQuestion, + onStartQuiz, + quizState, + isTyping: isAppTyping, + showClearDialog, + onConfirmClear, + onCancelClear, + savedChats, + workspaces, + currentWorkspaceId, + onSaveFile, + leftPanelVisible = false, + currentCourseId, + onCourseChange, + availableCourses = [], + showReviewBanner = false, +}: ChatAreaProps) { + const [input, setInput] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const [showScrollButton, setShowScrollButton] = useState(false); + const [showTopBorder, setShowTopBorder] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [pendingFiles, setPendingFiles] = useState([]); + const [showTypeDialog, setShowTypeDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [fileToDelete, setFileToDelete] = useState(null); + const [selectedFile, setSelectedFile] = useState<{file: File; index: number} | null>(null); + const [showFileViewer, setShowFileViewer] = useState(false); + const [showDownloadDialog, setShowDownloadDialog] = useState(false); + const [downloadPreview, setDownloadPreview] = useState(''); + const [downloadTab, setDownloadTab] = useState<'chat' | 'summary'>('chat'); + const [downloadOptions, setDownloadOptions] = useState({ chat: true, summary: false }); + const [showShareDialog, setShowShareDialog] = useState(false); + const [shareLink, setShareLink] = useState(''); + const [targetWorkspaceId, setTargetWorkspaceId] = useState(''); + + // Use availableCourses if provided, otherwise fallback to default + const courses = availableCourses.length > 0 ? availableCourses : [ + { id: 'course1', name: 'Introduction to AI' }, + { id: 'course2', name: 'Machine Learning' }, + { id: 'course3', name: 'Data Structures' }, + { id: 'course4', name: 'Web Development' }, + ]; + const messagesEndRef = useRef(null); + const scrollContainerRef = useRef(null); + const fileInputRef = useRef(null); + const isInitialMount = useRef(true); + const previousMessagesLength = useRef(messages.length); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + // Only auto-scroll when new messages are added (not on initial load) + useEffect(() => { + // Skip auto-scroll on initial mount + if (isInitialMount.current) { + isInitialMount.current = false; + previousMessagesLength.current = messages.length; + // Ensure we stay at top on initial load + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + return; + } + + // Only scroll if new messages were added (length increased) + if (messages.length > previousMessagesLength.current) { + scrollToBottom(); + } + previousMessagesLength.current = messages.length; + }, [messages]); + + useEffect(() => { + const handleScroll = () => { + if (scrollContainerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 100; + setShowScrollButton(!isAtBottom); + // Show border when content is scrolled below the fixed bar + setShowTopBorder(scrollTop > 0); + } + }; + + const container = scrollContainerRef.current; + if (container) { + // Check initial state + handleScroll(); + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + } + }, [messages]); + + // Use native event listeners to prevent scroll propagation to left panel + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const handleWheel = (e: WheelEvent) => { + // Always stop propagation to prevent scrolling left panel + e.stopPropagation(); + e.stopImmediatePropagation(); + }; + + container.addEventListener('wheel', handleWheel, { passive: false, capture: true }); + + return () => { + container.removeEventListener('wheel', handleWheel, { capture: true }); + }; + }, []); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || !isLoggedIn) return; + + onSendMessage(input); + setInput(''); + // All modes now use isAppTyping from App.tsx, so we don't set local isTyping + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }; + + const modeLabels: Record = { + general: 'General', + concept: 'Concept Explainer', + socratic: 'Socratic Tutor', + exam: 'Exam Prep', + assignment: 'Assignment Helper', + summary: 'Quick Summary', + }; + + // Handle review topic button click + const handleReviewTopic = (item: { title: string; previousQuestion: string; memoryRetention: number; schedule: string; status: string; weight: number; lastReviewed: string }) => { + // Send a user message with review request and data + // Format: User-friendly message + hidden data for parsing + const userMessage = `Please help me review: ${item.title}`; + const reviewData = `REVIEW_TOPIC:${item.title}|${item.previousQuestion}|${item.memoryRetention}|${item.schedule}|${item.status}|${item.weight}|${item.lastReviewed}`; + // Store review data temporarily to pass to App.tsx + (window as any).__lastReviewData = reviewData; + onSendMessage(userMessage); + }; + + // Handle review all button click + const handleReviewAll = () => { + // Send a user message requesting review all + (window as any).__lastReviewData = 'REVIEW_ALL'; + onSendMessage('Please help me review all topics that need attention.'); + }; + + const handleClearClick = () => { + // Check if current chat is a saved/previewed chat + const isSavedChat = isCurrentChatSaved(); + + // If viewing a saved chat, clear directly without dialog + if (isSavedChat) { + // Directly clear without showing dialog + onConfirmClear(false); + return; + } + + // Check if there are user messages (not just welcome message) + const hasUserMessages = messages.some(msg => msg.role === 'user'); + if (!hasUserMessages) { + // No user messages, just clear without showing dialog + onClearConversation(); + return; + } + // User messages exist, show dialog to save or discard + onClearConversation(); + }; + + const buildPreviewContent = () => { + if (!messages.length) return ''; + return messages + .map((msg) => `${msg.role === 'user' ? 'You' : 'Clare'}: ${msg.content}`) + .join('\n\n'); + }; + + const buildSummaryContent = () => { + if (!messages.length) return 'No messages to summarize.'; + + // Simple summary: count messages and list main topics + const userMessages = messages.filter(msg => msg.role === 'user'); + const assistantMessages = messages.filter(msg => msg.role === 'assistant'); + + let summary = `Chat Summary\n================\n\n`; + summary += `Total Messages: ${messages.length}\n`; + summary += `- User Messages: ${userMessages.length}\n`; + summary += `- Assistant Responses: ${assistantMessages.length}\n\n`; + + summary += `Key Points:\n`; + // Extract first 3 user messages as key points + userMessages.slice(0, 3).forEach((msg, idx) => { + const preview = msg.content.substring(0, 80); + summary += `${idx + 1}. ${preview}${msg.content.length > 80 ? '...' : ''}\n`; + }); + + return summary; + }; + + const handleOpenDownloadDialog = () => { + setDownloadTab('chat'); + setDownloadOptions({ chat: true, summary: false }); + setDownloadPreview(buildPreviewContent()); + setShowDownloadDialog(true); + }; + + const handleCopyPreview = async () => { + try { + await navigator.clipboard.writeText(downloadPreview); + toast.success('Copied preview'); + } catch (e) { + toast.error('Copy failed'); + } + }; + + const handleDownloadFile = async () => { + try { + let contentToPdf = ''; + + // Build content based on selected options + if (downloadOptions.chat) { + contentToPdf += buildPreviewContent(); + } + + if (downloadOptions.summary) { + if (downloadOptions.chat) { + contentToPdf += '\n\n================\n\n'; + } + contentToPdf += buildSummaryContent(); + } + + if (!contentToPdf.trim()) { + toast.error('Please select at least one option'); + return; + } + + // Create PDF + const pdf = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4' + }); + + // Set font + pdf.setFontSize(11); + + // Add title + pdf.setFontSize(14); + pdf.text('Chat Export', 10, 10); + pdf.setFontSize(11); + + // Split content into lines and add to PDF + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 10; + const maxWidth = 190; // A4 width minus margins + const lineHeight = 5; + let yPosition = 20; + + // Split text into lines that fit in the page + const lines = pdf.splitTextToSize(contentToPdf, maxWidth); + + lines.forEach((line: string) => { + if (yPosition > pageHeight - margin) { + pdf.addPage(); + yPosition = margin; + } + pdf.text(line, margin, yPosition); + yPosition += lineHeight; + }); + + // Save PDF + pdf.save('chat-export.pdf'); + setShowDownloadDialog(false); + toast.success('PDF downloaded successfully'); + } catch (error) { + console.error('PDF generation error:', error); + toast.error('Failed to generate PDF'); + } + }; + + // Check if current chat is already saved + const isCurrentChatSaved = (): boolean => { + if (messages.length <= 1) return false; + + // Find a saved chat that matches the current messages and chatMode + return savedChats.some(chat => { + if (chat.chatMode !== chatMode) return false; + if (chat.messages.length !== messages.length) return false; + + // Check if all messages match + return chat.messages.every((savedMsg, index) => { + const currentMsg = messages[index]; + return ( + savedMsg.id === currentMsg.id && + savedMsg.role === currentMsg.role && + savedMsg.content === currentMsg.content + ); + }); + }); + }; + + const handleSaveClick = () => { + if (messages.length <= 1) { + toast.info('No conversation to save'); + return; + } + + onSaveChat(); + }; + + const handleShareClick = () => { + if (messages.length <= 1) { + toast.info('No conversation to share'); + return; + } + // Create a temporary share link for this session + const conversationText = buildPreviewContent(); + const blob = new Blob([conversationText], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + setShareLink(url); + // Default to current workspace + setTargetWorkspaceId(currentWorkspaceId); + setShowShareDialog(true); + }; + + const handleCopyShareLink = async () => { + try { + await navigator.clipboard.writeText(shareLink); + toast.success('Link copied'); + } catch { + toast.error('Failed to copy link'); + } + }; + + const handleShareSendToWorkspace = () => { + const content = buildPreviewContent(); + onSaveFile?.(content, 'export', 'text', targetWorkspaceId); + setShowShareDialog(false); + toast.success('Sent to workspace Saved Files'); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!isLoggedIn) return; + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + if (!isLoggedIn) return; + + const fileList = e.dataTransfer.files; + const files: File[] = []; + for (let i = 0; i < fileList.length; i++) { + const file = fileList.item(i); + if (file) { + files.push(file); + } + } + + const validFiles = files.filter((file) => { + const ext = file.name.toLowerCase(); + return ['.pdf', '.docx', '.pptx', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.doc', '.ppt'].some((allowedExt) => + ext.endsWith(allowedExt) + ); + }); + + if (validFiles.length > 0) { + setPendingFiles(validFiles.map(file => ({ file, type: 'other' as FileType }))); + setShowTypeDialog(true); + } else { + toast.error('Please upload .pdf, .docx, .pptx, or image files'); + } + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []) as File[]; + if (files.length > 0) { + const validFiles = files.filter((file) => { + const ext = file.name.toLowerCase(); + return ['.pdf', '.docx', '.pptx', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.doc', '.ppt'].some((allowedExt) => + ext.endsWith(allowedExt) + ); + }); + if (validFiles.length > 0) { + setPendingFiles(validFiles.map(file => ({ file, type: 'other' as FileType }))); + setShowTypeDialog(true); + } else { + toast.error('Please upload .pdf, .docx, .pptx, or image files'); + } + } + e.target.value = ''; + }; + + const handleConfirmUpload = () => { + onFileUpload(pendingFiles.map(pf => pf.file)); + // Update the parent's file types + const startIndex = uploadedFiles.length; + pendingFiles.forEach((pf, idx) => { + setTimeout(() => { + onFileTypeChange(startIndex + idx, pf.type); + }, 0); + }); + setPendingFiles([]); + setShowTypeDialog(false); + toast.success(`${pendingFiles.length} file(s) uploaded successfully`); + }; + + const handleCancelUpload = () => { + setPendingFiles([]); + setShowTypeDialog(false); + }; + + const handlePendingFileTypeChange = (index: number, type: FileType) => { + setPendingFiles(prev => prev.map((pf, i) => + i === index ? { ...pf, type } : pf + )); + }; + + const getFileIcon = (filename: string) => { + const ext = filename.toLowerCase(); + if (ext.endsWith('.pdf')) return FileText; + if (ext.endsWith('.docx') || ext.endsWith('.doc')) return File; + if (ext.endsWith('.pptx') || ext.endsWith('.ppt')) return Presentation; + if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].some(e => ext.endsWith(e))) return ImageIcon; + return File; + }; + + const getFileTypeInfo = (filename: string) => { + const ext = filename.toLowerCase(); + if (ext.endsWith('.pdf')) { + return { bgColor: 'bg-red-500', type: 'PDF' }; + } + if (ext.endsWith('.docx') || ext.endsWith('.doc')) { + return { bgColor: 'bg-blue-500', type: 'Document' }; + } + if (ext.endsWith('.pptx') || ext.endsWith('.ppt')) { + return { bgColor: 'bg-orange-500', type: 'Presentation' }; + } + if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].some(e => ext.endsWith(e))) { + return { bgColor: 'bg-green-500', type: 'Image' }; + } + return { bgColor: 'bg-gray-500', type: 'File' }; + }; + + const formatFileSize = (bytes: number) => { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + }; + + // File Thumbnail Component - ChatGPT style + const FileThumbnail = ({ + file, + Icon, + fileInfo, + isImage, + onPreview, + onRemove + }: { + file: File; + Icon: React.ComponentType<{ className?: string }>; + fileInfo: { bgColor: string; type: string }; + isImage: boolean; + onPreview: () => void; + onRemove: (e: React.MouseEvent) => void; + }) => { + const [imagePreview, setImagePreview] = useState(null); + const [imageLoading, setImageLoading] = useState(true); + + useEffect(() => { + if (isImage) { + setImageLoading(true); + const reader = new FileReader(); + reader.onload = (e) => { + setImagePreview(e.target?.result as string); + setImageLoading(false); + }; + reader.onerror = () => { + setImageLoading(false); + }; + reader.readAsDataURL(file); + } else { + setImagePreview(null); + setImageLoading(false); + } + }, [file, isImage]); + + // All files have consistent height: h-16 (64px) + // Image files: square card (w-16 h-16), no filename + if (isImage) { + return ( +
+ {/* Square image thumbnail card - ChatGPT style, same height as other files */} +
+
+ {imageLoading ? ( +
+ +
+ ) : imagePreview ? ( + {file.name} { + e.currentTarget.style.display = 'none'; + setImageLoading(false); + }} + /> + ) : ( +
+ +
+ )} +
+ {/* Remove button - top right corner inside card, always visible - ChatGPT style */} + +
+
+ ); + } + + // Other files: horizontal rectangle with icon, filename and type (same height as images, fixed width) + return ( +
+ {/* Horizontal file card - ChatGPT style, same height as images, fixed width */} +
+ {/* File icon with colored background */} +
+ +
+ {/* Spacing between icon and filename - one character width */} +
+ {/* File name and type */} +
+

+ {file.name} +

+

+ {fileInfo.type} +

+
+ {/* Remove button - top right corner inside card, always visible - ChatGPT style */} + +
+
+ ); + }; + + // File Viewer Content Component + const FileViewerContent = ({ file }: { file: File }) => { + const [content, setContent] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadFile = async () => { + try { + setLoading(true); + setError(null); + + const ext = file.name.toLowerCase(); + + if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].some(e => ext.endsWith(e))) { + // Image file + const reader = new FileReader(); + reader.onload = (e) => { + setContent(e.target?.result as string); + setLoading(false); + }; + reader.onerror = () => { + setError('Failed to load image'); + setLoading(false); + }; + reader.readAsDataURL(file); + } else if (ext.endsWith('.pdf')) { + // PDF file - show info + setContent('PDF files cannot be previewed directly. Please download the file to view it.'); + setLoading(false); + } else { + // Text-based files + const reader = new FileReader(); + reader.onload = (e) => { + setContent(e.target?.result as string); + setLoading(false); + }; + reader.onerror = () => { + setError('Failed to load file'); + setLoading(false); + }; + reader.readAsText(file); + } + } catch (err) { + setError('Failed to load file'); + setLoading(false); + } + }; + + loadFile(); + }, [file]); + + if (loading) { + return
Loading...
; + } + + if (error) { + return
{error}
; + } + + const ext = file.name.toLowerCase(); + const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].some(e => ext.endsWith(e)); + + if (isImage) { + return ( +
+ {file.name} +
+ ); + } + + return ( +
+ {content} +
+ ); + }; + + const getFileTypeLabel = (type: FileType) => { + const labels: Record = { + 'syllabus': 'Syllabus', + 'lecture-slides': 'Lecture Slides / PPT', + 'literature-review': 'Literature Review / Paper', + 'other': 'Other Course Document', + }; + return labels[type]; + }; + + return ( +
+ {/* Chat Area with Floating Input */} +
+ {/* Messages Area */} +
+ {/* Top Bar - Course Selector, Chat Mode Tabs, and Action Buttons - Sticky at top */} +
+ {/* Course Selector - Left */} +
+ {(() => { + const current = workspaces.find(w => w.id === currentWorkspaceId); + if (current?.type === 'group') { + if (current.category === 'course' && current.courseName) { + // Show fixed course label, not selectable + return ( +
+ {current.courseName} +
+ ); + } + // Personal interest: hide selector + return null; + } + // Individual workspace: show selectable courses + return ( + + ); + })()} +
+ + {/* Chat Mode Tabs - Center (aligned with panel toggle button) */} +
+ onChatModeChange(value as ChatMode)} + className="w-auto" + orientation="horizontal" + > + + Ask + + Review + {/* Red dot badge in top-right corner */} + + + Quiz + + +
+ + {/* Action Buttons - Right */} +
+ + + + +
+
+ + {/* Messages Content */} +
+
+ {messages.map((message, index) => ( + + + {/* Smart Review - Show below welcome message in Review mode */} + {chatMode === 'review' && + message.id === 'review-1' && + message.role === 'assistant' && ( +
+ {/* Avatar placeholder to align with message bubble */} +
+
+ +
+
+ )} + {/* Quiz Mode Start Button - Below welcome message */} + {chatMode === 'quiz' && + message.id === 'quiz-1' && + message.role === 'assistant' && + quizState.currentQuestion === 0 && + !quizState.waitingForAnswer && + !isAppTyping && ( +
+ +
+ )} +
+ ))} + + {/* Show typing indicator - use isAppTyping for all modes to ensure consistent display */} + {isAppTyping && ( +
+
+ Clare +
+
+
+
+
+
+
+
+
+ )} + +
+
+
+
+ + {/* Scroll to Bottom Button - Above input box */} + {showScrollButton && ( +
+ +
+ )} + + {/* Floating Input Area - Fixed at viewport bottom, horizontally centered in chat area */} +
+
+ {/* Uploaded Files Display */} + {uploadedFiles.length > 0 && ( +
+ {uploadedFiles.map((uploadedFile, index) => { + const Icon = getFileIcon(uploadedFile.file.name); + const fileInfo = getFileTypeInfo(uploadedFile.file.name); + const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].some(ext => + uploadedFile.file.name.toLowerCase().endsWith(`.${ext}`) + ); + + return ( +
+ { + setSelectedFile({ file: uploadedFile.file, index }); + setShowFileViewer(true); + }} + onRemove={(e) => { + e.stopPropagation(); + setFileToDelete(index); + setShowDeleteDialog(true); + }} + /> +
+ ); + })} +
+ )} + +
+
+ {/* Mode Selector and Upload Button - ChatGPT style at bottom left */} +
+ {/* Only show mode selector in ask mode */} + {chatMode === 'ask' && ( + + + + + + onLearningModeChange('general')} + className={learningMode === 'general' ? 'bg-accent' : ''} + > +
+ General + + Answer various questions (context required) + +
+
+ onLearningModeChange('concept')} + className={learningMode === 'concept' ? 'bg-accent' : ''} + > +
+ Concept Explainer + + Get detailed explanations of concepts + +
+
+ onLearningModeChange('socratic')} + className={learningMode === 'socratic' ? 'bg-accent' : ''} + > +
+ Socratic Tutor + + Learn through guided questions + +
+
+ onLearningModeChange('exam')} + className={learningMode === 'exam' ? 'bg-accent' : ''} + > +
+ Exam Prep + + Practice with quiz questions + +
+
+ onLearningModeChange('assignment')} + className={learningMode === 'assignment' ? 'bg-accent' : ''} + > +
+ Assignment Helper + + Get help with assignments + +
+
+ onLearningModeChange('summary')} + className={learningMode === 'summary' ? 'bg-accent' : ''} + > +
+ Quick Summary + + Get concise summaries + +
+
+
+
+ )} + {/* Upload Button - Right of mode selector */} + +
+