Spaces:
Sleeping
Sleeping
| // web/src/App.tsx | |
| import React, { useState, useEffect, useRef, useMemo } 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 { X, ChevronLeft, ChevronRight } from "lucide-react"; | |
| import { Button } from "./components/ui/button"; | |
| import { Toaster } from "./components/ui/sonner"; | |
| import { toast } from "sonner"; | |
| import { LeftSidebar } from "./components/sidebar/LeftSidebar"; | |
| // backend API bindings | |
| import { apiChat, apiUpload, apiMemoryline, apiQuizStart } from "./lib/api"; | |
| // NEW: review-star logic | |
| import { | |
| type ReviewStarState, | |
| type ReviewEventType, | |
| markReviewActive, | |
| normalizeToday, | |
| starOpacity, | |
| energyPct, | |
| } from "./lib/reviewStar"; | |
| export interface Message { | |
| id: string; | |
| role: "user" | "assistant"; | |
| content: string; | |
| timestamp: Date; | |
| references?: string[]; | |
| sender?: GroupMember; | |
| showNextButton?: boolean; | |
| 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; | |
| } | |
| 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; | |
| } | |
| const DOC_TYPE_MAP: Record<FileType, string> = { | |
| syllabus: "Syllabus", | |
| "lecture-slides": "Lecture Slides / PPT", | |
| "literature-review": "Literature Review / Paper", | |
| other: "Other Course Document", | |
| }; | |
| function mapLanguagePref(lang: Language): string { | |
| if (lang === "zh") return "中文"; | |
| if (lang === "en") return "English"; | |
| return "Auto"; | |
| } | |
| // ✅ localStorage helpers for saved chats | |
| function savedChatsStorageKey(email: string) { | |
| return `saved_chats::${email}`; | |
| } | |
| function hydrateSavedChats(raw: any): SavedChat[] { | |
| if (!Array.isArray(raw)) return []; | |
| return raw | |
| .map((c: any) => { | |
| try { | |
| return { | |
| ...c, | |
| timestamp: c?.timestamp ? new Date(c.timestamp) : new Date(), | |
| messages: Array.isArray(c?.messages) | |
| ? c.messages.map((m: any) => ({ | |
| ...m, | |
| timestamp: m?.timestamp ? new Date(m.timestamp) : new Date(), | |
| })) | |
| : [], | |
| } as SavedChat; | |
| } catch { | |
| return null; | |
| } | |
| }) | |
| .filter(Boolean) as SavedChat[]; | |
| } | |
| 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<User | null>(null); | |
| // ------------------------- | |
| // ✅ Course selection (stable) | |
| // ------------------------- | |
| const MYSPACE_COURSE_KEY = "myspace_selected_course"; | |
| const [currentCourseId, setCurrentCourseId] = useState<string>(() => { | |
| return localStorage.getItem(MYSPACE_COURSE_KEY) || "course1"; | |
| }); | |
| 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" }, | |
| }, | |
| ]; | |
| const [askMessages, setAskMessages] = useState<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(), | |
| }, | |
| ]); | |
| const [reviewMessages, setReviewMessages] = useState<Message[]>([ | |
| { | |
| 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<Message[]>([ | |
| { | |
| 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<LearningMode>("concept"); | |
| const [language, setLanguage] = useState<Language>("auto"); | |
| const [chatMode, setChatMode] = useState<ChatMode>("ask"); | |
| const messages = chatMode === "ask" ? askMessages : chatMode === "review" ? reviewMessages : quizMessages; | |
| const prevChatModeRef = useRef<ChatMode>(chatMode); | |
| useEffect(() => { | |
| 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 ((modeChanged || currentMessages.length === 0 || !hasWelcomeMessage) && !hasUserMessages) { | |
| const initialMessages: Record<ChatMode, Message[]> = { | |
| 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]); | |
| const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]); | |
| 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 [showReviewBanner, setShowReviewBanner] = useState(() => true); | |
| const [showClearDialog, setShowClearDialog] = useState(false); | |
| const [savedItems, setSavedItems] = useState<SavedItem[]>([]); | |
| const [recentlySavedId, setRecentlySavedId] = useState<string | null>(null); | |
| const [savedChats, setSavedChats] = useState<SavedChat[]>([]); | |
| // ✅ load saved chats after login | |
| useEffect(() => { | |
| if (!user?.email) return; | |
| try { | |
| const raw = localStorage.getItem(savedChatsStorageKey(user.email)); | |
| if (!raw) { | |
| setSavedChats([]); | |
| return; | |
| } | |
| const parsed = JSON.parse(raw); | |
| setSavedChats(hydrateSavedChats(parsed)); | |
| } catch { | |
| setSavedChats([]); | |
| } | |
| }, [user?.email]); | |
| // ✅ persist saved chats whenever changed | |
| useEffect(() => { | |
| if (!user?.email) return; | |
| try { | |
| localStorage.setItem(savedChatsStorageKey(user.email), JSON.stringify(savedChats)); | |
| } catch { | |
| // ignore | |
| } | |
| }, [savedChats, user?.email]); | |
| const [groupMembers] = useState<GroupMember[]>([ | |
| { 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" }, | |
| ]); | |
| const [workspaces, setWorkspaces] = useState<Workspace[]>([]); | |
| const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>("individual"); | |
| // ✅ used to prevent duplicate upload per file fingerprint | |
| const uploadedFingerprintsRef = useRef<Set<string>>(new Set()); | |
| 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"); // may be undefined, that's OK | |
| 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]); | |
| const fallbackWorkspace: Workspace = { | |
| id: "individual", | |
| name: "My Space", | |
| type: "individual", | |
| avatar: user ? `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}` : "", | |
| }; | |
| const currentWorkspace: Workspace = | |
| workspaces.find((w) => w.id === currentWorkspaceId) || workspaces[0] || fallbackWorkspace; | |
| const spaceType: SpaceType = currentWorkspace?.type || "individual"; | |
| // ========================= | |
| // ✅ Scheme 1: "My Space" uses Group-like sidebar view model | |
| // ========================= | |
| const mySpaceCourseInfo = useMemo(() => { | |
| return availableCourses.find((c) => c.id === currentCourseId); | |
| }, [availableCourses, currentCourseId]); | |
| const mySpaceUserMember: GroupMember | null = useMemo(() => { | |
| if (!user) return null; | |
| return { | |
| id: user.email, | |
| name: user.name, | |
| email: user.email, | |
| avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`, | |
| }; | |
| }, [user]); | |
| const clareMember: GroupMember = useMemo( | |
| () => ({ id: "clare", name: "Clare AI", email: "clare@ai.assistant", isAI: true }), | |
| [] | |
| ); | |
| const sidebarWorkspaces: Workspace[] = useMemo(() => { | |
| if (!workspaces?.length) return workspaces; | |
| if (!mySpaceUserMember) return workspaces; | |
| return workspaces.map((w) => { | |
| if (w.id !== "individual") return w; | |
| return { | |
| ...w, | |
| category: "course", | |
| courseName: mySpaceCourseInfo?.name || w.courseName || "My Course", | |
| courseInfo: mySpaceCourseInfo, | |
| members: [clareMember, mySpaceUserMember], | |
| }; | |
| }); | |
| }, [workspaces, mySpaceCourseInfo, mySpaceUserMember, clareMember]); | |
| const sidebarSpaceType: SpaceType = useMemo(() => { | |
| return currentWorkspaceId === "individual" ? "group" : spaceType; | |
| }, [currentWorkspaceId, spaceType]); | |
| const sidebarGroupMembers: GroupMember[] = useMemo(() => { | |
| if (currentWorkspaceId === "individual" && mySpaceUserMember) { | |
| return [clareMember, mySpaceUserMember]; | |
| } | |
| return groupMembers; | |
| }, [currentWorkspaceId, mySpaceUserMember, clareMember, groupMembers]); | |
| // ========================= | |
| // ✅ Stable course switching logic | |
| // ========================= | |
| const didHydrateMySpaceRef = useRef(false); | |
| const handleCourseChange = (nextCourseId: string) => { | |
| if (!nextCourseId) return; | |
| if (currentWorkspace.type === "group" && currentWorkspace.category === "course") { | |
| return; | |
| } | |
| setCurrentCourseId(nextCourseId); | |
| try { | |
| localStorage.setItem(MYSPACE_COURSE_KEY, nextCourseId); | |
| } catch { | |
| // ignore | |
| } | |
| }; | |
| useEffect(() => { | |
| if (!currentWorkspace) return; | |
| if (currentWorkspace.type === "group" && currentWorkspace.category === "course") { | |
| const cid = currentWorkspace.courseInfo?.id; | |
| if (cid && cid !== currentCourseId) setCurrentCourseId(cid); | |
| didHydrateMySpaceRef.current = false; | |
| return; | |
| } | |
| if (currentWorkspace.type === "individual") { | |
| if (!didHydrateMySpaceRef.current) { | |
| didHydrateMySpaceRef.current = true; | |
| const saved = localStorage.getItem(MYSPACE_COURSE_KEY); | |
| const valid = saved && availableCourses.some((c) => c.id === saved) ? saved : undefined; | |
| const next = valid || currentCourseId || "course1"; | |
| if (next !== currentCourseId) setCurrentCourseId(next); | |
| } | |
| } | |
| }, [ | |
| currentWorkspaceId, | |
| currentWorkspace?.type, | |
| currentWorkspace?.category, | |
| currentWorkspace?.courseInfo?.id, | |
| availableCourses, | |
| currentCourseId, | |
| currentWorkspace, | |
| ]); | |
| useEffect(() => { | |
| if (currentWorkspace?.type !== "individual") return; | |
| try { | |
| const prev = localStorage.getItem(MYSPACE_COURSE_KEY); | |
| if (prev !== currentCourseId) localStorage.setItem(MYSPACE_COURSE_KEY, currentCourseId); | |
| } catch { | |
| // ignore | |
| } | |
| }, [currentCourseId, currentWorkspace?.type]); | |
| useEffect(() => { | |
| document.documentElement.classList.toggle("dark", isDarkMode); | |
| localStorage.setItem("theme", isDarkMode ? "dark" : "light"); | |
| }, [isDarkMode]); | |
| useEffect(() => { | |
| const prev = document.body.style.overflow; | |
| document.body.style.overflow = "hidden"; | |
| return () => { | |
| document.body.style.overflow = prev; | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| if (!user) return; | |
| (async () => { | |
| try { | |
| const r = await apiMemoryline(user.email); | |
| const pct = Math.round((r.progress_pct ?? 0) * 100); | |
| setMemoryProgress(pct); | |
| } catch { | |
| // silent | |
| } | |
| })(); | |
| }, [user]); | |
| // ========================= | |
| // ✅ Review Star (按天) state | |
| // ========================= | |
| const reviewStarKey = useMemo(() => { | |
| if (!user) return ""; | |
| return `review_star::${user.email}::${currentWorkspaceId}`; | |
| }, [user, currentWorkspaceId]); | |
| const [reviewStarState, setReviewStarState] = useState<ReviewStarState | null>(null); | |
| useEffect(() => { | |
| if (!user || !reviewStarKey) return; | |
| if (chatMode !== "review") return; | |
| const next = normalizeToday(reviewStarKey); | |
| setReviewStarState(next); | |
| }, [chatMode, reviewStarKey, user]); | |
| const handleReviewActivity = (event: ReviewEventType) => { | |
| if (!user || !reviewStarKey) return; | |
| const next = markReviewActive(reviewStarKey, event); | |
| setReviewStarState(next); | |
| }; | |
| const reviewStarOpacity = starOpacity(reviewStarState); | |
| const reviewEnergyPct = energyPct(reviewStarState); | |
| 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 getCurrentDocTypeForChat = (): string => { | |
| if (uploadedFiles.length > 0) { | |
| const last = uploadedFiles[uploadedFiles.length - 1]; | |
| return DOC_TYPE_MAP[last.type] || "Syllabus"; | |
| } | |
| return "Syllabus"; | |
| }; | |
| const handleSendMessage = async (content: string) => { | |
| if (!content.trim() || !user) return; | |
| 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, | |
| }; | |
| if (chatMode === "ask") setAskMessages((prev) => [...prev, userMessage]); | |
| else if (chatMode === "review") setReviewMessages((prev) => [...prev, userMessage]); | |
| else setQuizMessages((prev) => [...prev, userMessage]); | |
| if (chatMode === "quiz") { | |
| setIsTyping(true); | |
| try { | |
| const docType = getCurrentDocTypeForChat(); | |
| const r = await apiChat({ | |
| user_id: user.email, | |
| message: content, | |
| learning_mode: "quiz", | |
| language_preference: mapLanguagePref(language), | |
| doc_type: docType, | |
| }); | |
| const refs = (r.refs || []) | |
| .map((x) => { | |
| const a = x?.source_file ? String(x.source_file) : ""; | |
| const b = x?.section ? String(x.section) : ""; | |
| const s = `${a}${a && b ? " — " : ""}${b}`.trim(); | |
| return s || null; | |
| }) | |
| .filter(Boolean) as string[]; | |
| const assistantMessage: Message = { | |
| id: (Date.now() + 1).toString(), | |
| role: "assistant", | |
| content: r.reply || "", | |
| timestamp: new Date(), | |
| references: refs.length ? refs : undefined, | |
| sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined, | |
| showNextButton: false, | |
| }; | |
| setIsTyping(false); | |
| setTimeout(() => { | |
| setQuizMessages((prev) => [...prev, assistantMessage]); | |
| setQuizState((prev) => ({ ...prev, waitingForAnswer: true, showNextButton: false })); | |
| }, 50); | |
| } catch (e: any) { | |
| setIsTyping(false); | |
| toast.error(e?.message || "Quiz failed"); | |
| const assistantMessage: Message = { | |
| id: (Date.now() + 1).toString(), | |
| role: "assistant", | |
| content: "Sorry — quiz request failed. Please try again.", | |
| timestamp: new Date(), | |
| sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined, | |
| }; | |
| setTimeout(() => { | |
| setQuizMessages((prev) => [...prev, assistantMessage]); | |
| }, 50); | |
| } | |
| return; | |
| } | |
| setIsTyping(true); | |
| try { | |
| const docType = getCurrentDocTypeForChat(); | |
| const r = await apiChat({ | |
| user_id: user.email, | |
| message: content, | |
| learning_mode: learningMode, | |
| language_preference: mapLanguagePref(language), | |
| doc_type: docType, | |
| }); | |
| const refs = (r.refs || []) | |
| .map((x) => { | |
| const a = x?.source_file ? String(x.source_file) : ""; | |
| const b = x?.section ? String(x.section) : ""; | |
| const s = `${a}${a && b ? " — " : ""}${b}`.trim(); | |
| return s || null; | |
| }) | |
| .filter(Boolean) as string[]; | |
| const assistantMessage: Message = { | |
| id: (Date.now() + 1).toString(), | |
| role: "assistant", | |
| content: r.reply || "", | |
| timestamp: new Date(), | |
| references: refs.length ? refs : undefined, | |
| sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined, | |
| }; | |
| setIsTyping(false); | |
| setTimeout(() => { | |
| if (chatMode === "ask") setAskMessages((prev) => [...prev, assistantMessage]); | |
| else if (chatMode === "review") setReviewMessages((prev) => [...prev, assistantMessage]); | |
| }, 50); | |
| try { | |
| const ml = await apiMemoryline(user.email); | |
| setMemoryProgress(Math.round((ml.progress_pct ?? 0) * 100)); | |
| } catch { | |
| // ignore | |
| } | |
| } catch (e: any) { | |
| setIsTyping(false); | |
| toast.error(e?.message || "Chat failed"); | |
| const assistantMessage: Message = { | |
| id: (Date.now() + 1).toString(), | |
| role: "assistant", | |
| content: "Sorry — chat request failed. Please try again.", | |
| timestamp: new Date(), | |
| sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined, | |
| }; | |
| setTimeout(() => { | |
| if (chatMode === "ask") setAskMessages((prev) => [...prev, assistantMessage]); | |
| if (chatMode === "review") setReviewMessages((prev) => [...prev, assistantMessage]); | |
| }, 50); | |
| } | |
| }; | |
| const handleNextQuestion = async () => { | |
| if (!user) return; | |
| const prompt = "Please give me another question of the same quiz style."; | |
| 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: prompt, | |
| timestamp: new Date(), | |
| sender, | |
| }; | |
| setQuizMessages((prev) => [...prev, userMessage]); | |
| setIsTyping(true); | |
| try { | |
| const docType = getCurrentDocTypeForChat(); | |
| const r = await apiChat({ | |
| user_id: user.email, | |
| message: prompt, | |
| learning_mode: "quiz", | |
| language_preference: mapLanguagePref(language), | |
| doc_type: docType, | |
| }); | |
| const refs = (r.refs || []) | |
| .map((x) => { | |
| const a = x?.source_file ? String(x.source_file) : ""; | |
| const b = x?.section ? String(x.section) : ""; | |
| const s = `${a}${a && b ? " — " : ""}${b}`.trim(); | |
| return s || null; | |
| }) | |
| .filter(Boolean) as string[]; | |
| const assistantMessage: Message = { | |
| id: (Date.now() + 1).toString(), | |
| role: "assistant", | |
| content: r.reply || "", | |
| timestamp: new Date(), | |
| references: refs.length ? refs : undefined, | |
| sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined, | |
| showNextButton: false, | |
| }; | |
| setIsTyping(false); | |
| setTimeout(() => { | |
| setQuizMessages((prev) => [...prev, assistantMessage]); | |
| setQuizState((prev) => ({ | |
| ...prev, | |
| currentQuestion: prev.currentQuestion + 1, | |
| waitingForAnswer: true, | |
| showNextButton: false, | |
| })); | |
| }, 50); | |
| } catch (e: any) { | |
| setIsTyping(false); | |
| toast.error(e?.message || "Quiz failed"); | |
| const assistantMessage: Message = { | |
| id: (Date.now() + 1).toString(), | |
| role: "assistant", | |
| content: "Sorry — quiz request failed. Please try again.", | |
| timestamp: new Date(), | |
| sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined, | |
| }; | |
| setTimeout(() => setQuizMessages((prev) => [...prev, assistantMessage]), 50); | |
| } | |
| }; | |
| const handleStartQuiz = async () => { | |
| if (!user) return; | |
| setIsTyping(true); | |
| try { | |
| const docType = getCurrentDocTypeForChat(); | |
| const r = await apiQuizStart({ | |
| user_id: user.email, | |
| language_preference: mapLanguagePref(language), | |
| doc_type: docType, | |
| learning_mode: "quiz", | |
| }); | |
| const refs = (r.refs || []) | |
| .map((x) => { | |
| const a = x?.source_file ? String(x.source_file) : ""; | |
| const b = x?.section ? String(x.section) : ""; | |
| const s = `${a}${a && b ? " — " : ""}${b}`.trim(); | |
| return s || null; | |
| }) | |
| .filter(Boolean) as string[]; | |
| const assistantMessage: Message = { | |
| id: Date.now().toString(), | |
| role: "assistant", | |
| content: r.reply || "", | |
| timestamp: new Date(), | |
| references: refs.length ? refs : undefined, | |
| sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined, | |
| showNextButton: false, | |
| }; | |
| setIsTyping(false); | |
| setTimeout(() => { | |
| setQuizMessages((prev) => [...prev, assistantMessage]); | |
| setQuizState({ currentQuestion: 0, waitingForAnswer: true, showNextButton: false }); | |
| }, 50); | |
| } catch (e: any) { | |
| setIsTyping(false); | |
| toast.error(e?.message || "Start quiz failed"); | |
| const assistantMessage: Message = { | |
| id: Date.now().toString(), | |
| role: "assistant", | |
| content: "Sorry — could not start the quiz. Please try again.", | |
| timestamp: new Date(), | |
| sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined, | |
| }; | |
| setTimeout(() => setQuizMessages((prev) => [...prev, assistantMessage]), 50); | |
| } | |
| }; | |
| // ========================= | |
| // ✅ File Upload (FIXED) | |
| // ========================= | |
| // 1) Upload: accept File[] OR FileList OR null | |
| const handleFileUpload = (input: File[] | FileList | null | undefined) => { | |
| const files = Array.isArray(input) ? input : input ? Array.from(input) : []; | |
| if (!files.length) return; | |
| const newFiles: UploadedFile[] = files.map((file) => ({ file, type: "other" as FileType })); | |
| setUploadedFiles((prev) => [...prev, ...newFiles]); | |
| }; | |
| // 2) Remove: accept index OR File OR UploadedFile (use any to avoid prop signature mismatch crash) | |
| const handleRemoveFile = (arg: any) => { | |
| setUploadedFiles((prev) => { | |
| if (!prev.length) return prev; | |
| let idx = -1; | |
| if (typeof arg === "number") { | |
| idx = arg; | |
| } else { | |
| const file = | |
| arg?.file instanceof File | |
| ? (arg as UploadedFile).file | |
| : arg instanceof File | |
| ? (arg as File) | |
| : null; | |
| if (file) { | |
| idx = prev.findIndex( | |
| (x) => | |
| x.file.name === file.name && | |
| x.file.size === file.size && | |
| x.file.lastModified === file.lastModified | |
| ); | |
| } | |
| } | |
| if (idx < 0 || idx >= prev.length) return prev; | |
| const removed = prev[idx]?.file; | |
| const next = prev.filter((_, i) => i !== idx); | |
| if (removed) { | |
| const fp = `${removed.name}::${removed.size}::${removed.lastModified}`; | |
| uploadedFingerprintsRef.current.delete(fp); | |
| } | |
| return next; | |
| }); | |
| }; | |
| // 3) Type Change: must be async, and must NOT leave "await" outside | |
| const handleFileTypeChange = async (index: number, type: FileType) => { | |
| if (!user) return; | |
| const target = uploadedFiles[index]?.file; | |
| if (!target) return; | |
| // update UI state immediately | |
| setUploadedFiles((prev) => prev.map((f, i) => (i === index ? { ...f, type } : f))); | |
| // dedupe upload per file fingerprint | |
| const fp = `${target.name}::${target.size}::${target.lastModified}`; | |
| if (uploadedFingerprintsRef.current.has(fp)) return; | |
| uploadedFingerprintsRef.current.add(fp); | |
| try { | |
| await apiUpload({ | |
| user_id: user.email, | |
| doc_type: DOC_TYPE_MAP[type] || "Other Course Document", | |
| file: target, | |
| }); | |
| toast.success("File uploaded to backend"); | |
| } catch (e: any) { | |
| toast.error(e?.message || "Upload failed"); | |
| // allow retry if failed | |
| uploadedFingerprintsRef.current.delete(fp); | |
| } | |
| }; | |
| const isCurrentChatSaved = (): SavedChat | null => { | |
| if (messages.length <= 1) return null; | |
| return ( | |
| savedChats.find((chat) => { | |
| if (chat.chatMode !== chatMode) return false; | |
| if (chat.messages.length !== messages.length) return false; | |
| return chat.messages.every((savedMsg, idx) => { | |
| const currentMsg = messages[idx]; | |
| return ( | |
| savedMsg.id === currentMsg.id && | |
| savedMsg.role === currentMsg.role && | |
| savedMsg.content === currentMsg.content | |
| ); | |
| }); | |
| }) || null | |
| ); | |
| }; | |
| 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 handleSaveChat = () => { | |
| if (messages.length <= 1) { | |
| toast.info("No conversation to save"); | |
| return; | |
| } | |
| const existingChat = isCurrentChatSaved(); | |
| if (existingChat) { | |
| handleDeleteSavedChat(existingChat.id); | |
| toast.success("Chat unsaved"); | |
| return; | |
| } | |
| 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) => { | |
| setChatMode(savedChat.chatMode); | |
| if (savedChat.chatMode === "ask") setAskMessages(savedChat.messages); | |
| else if (savedChat.chatMode === "review") setReviewMessages(savedChat.messages); | |
| else { | |
| setQuizMessages(savedChat.messages); | |
| setQuizState({ currentQuestion: 0, waitingForAnswer: false, showNextButton: false }); | |
| } | |
| toast.success("Chat loaded!"); | |
| }; | |
| const handleClearConversation = (shouldSave: boolean = false) => { | |
| if (shouldSave) handleSaveChat(); | |
| const initialMessages: Record<ChatMode, Message[]> = { | |
| 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(), | |
| }, | |
| ], | |
| }; | |
| 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 handleSave = ( | |
| content: string, | |
| type: "export" | "quiz" | "summary", | |
| saveAsChat: boolean = false, | |
| format: "pdf" | "text" = "text", | |
| workspaceId?: string | |
| ) => { | |
| if (!content.trim()) return; | |
| if (saveAsChat && type !== "summary") { | |
| 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; | |
| } | |
| const existingItem = savedItems.find((item) => item.content === content && item.type === type); | |
| if (existingItem) { | |
| handleUnsave(existingItem.id); | |
| return; | |
| } | |
| 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); | |
| 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"); | |
| }; | |
| 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)}`; | |
| 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 { | |
| newWorkspace = { | |
| id, | |
| name: payload.name, | |
| type: "group", | |
| avatar, | |
| members, | |
| category: "personal", | |
| isEditable: true, | |
| }; | |
| } | |
| setWorkspaces((prev) => [...prev, newWorkspace]); | |
| setCurrentWorkspaceId(id); | |
| if (payload.category === "course" && payload.courseId) { | |
| setCurrentCourseId(payload.courseId); | |
| } | |
| toast.success("New group workspace created"); | |
| }; | |
| const handleReviewClick = () => { | |
| setChatMode("review"); | |
| setShowReviewBanner(false); | |
| localStorage.setItem("reviewBannerDismissed", "true"); | |
| }; | |
| const handleDismissReviewBanner = () => { | |
| setShowReviewBanner(false); | |
| localStorage.setItem("reviewBannerDismissed", "true"); | |
| }; | |
| const handleLogin = (newUser: User) => { | |
| setUser(newUser); | |
| setShowOnboarding(true); | |
| }; | |
| const handleOnboardingComplete = (updatedUser: User) => { | |
| setUser(updatedUser); | |
| setShowOnboarding(false); | |
| }; | |
| const handleOnboardingSkip = () => setShowOnboarding(false); | |
| if (!user) return <LoginScreen onLogin={handleLogin} />; | |
| if (showOnboarding && user) | |
| return <Onboarding user={user} onComplete={handleOnboardingComplete} onSkip={handleOnboardingSkip} />; | |
| return ( | |
| <div className="fixed inset-0 w-full bg-background overflow-hidden"> | |
| <Toaster /> | |
| <div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden"> | |
| <div className="flex-shrink-0"> | |
| <Header | |
| user={user} | |
| onMenuClick={() => setLeftSidebarOpen(!leftSidebarOpen)} | |
| onUserClick={() => setShowProfileEditor(true)} | |
| isDarkMode={isDarkMode} | |
| onToggleDarkMode={() => setIsDarkMode(!isDarkMode)} | |
| language={language} | |
| onLanguageChange={setLanguage} | |
| workspaces={workspaces} | |
| currentWorkspace={currentWorkspace} | |
| onWorkspaceChange={setCurrentWorkspaceId} | |
| onCreateWorkspace={handleCreateWorkspace} | |
| onLogout={() => setUser(null)} | |
| availableCourses={availableCourses} | |
| onUserUpdate={setUser} | |
| reviewStarOpacity={reviewStarOpacity} | |
| reviewEnergyPct={reviewEnergyPct} | |
| onStarClick={() => { | |
| setChatMode("review"); | |
| setShowReviewBanner(false); | |
| localStorage.setItem("reviewBannerDismissed", "true"); | |
| }} | |
| /> | |
| </div> | |
| {showProfileEditor && user && ( | |
| <ProfileEditor user={user} onSave={setUser} onClose={() => setShowProfileEditor(false)} /> | |
| )} | |
| {showReviewBanner && ( | |
| <div className="flex-shrink-0 w-full bg-background border-b border-border relative z-50"> | |
| <ReviewBanner onReview={handleReviewClick} onDismiss={handleDismissReviewBanner} /> | |
| </div> | |
| )} | |
| <div className="flex flex-1 min-h-0 min-w-0 overflow-hidden relative"> | |
| {!leftPanelVisible && ( | |
| <Button | |
| variant="secondary" | |
| size="icon" | |
| onClick={() => setLeftPanelVisible(true)} | |
| className="hidden lg:flex absolute z-[100] h-8 w-5 shadow-lg rounded-full bg-card border border-border transition-all duration-200 ease-in-out hover:translate-x-[10px]" | |
| style={{ left: "-5px", top: "1rem" }} | |
| title="Open panel" | |
| > | |
| <ChevronRight className="h-3 w-3" /> | |
| </Button> | |
| )} | |
| {leftSidebarOpen && ( | |
| <div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setLeftSidebarOpen(false)} /> | |
| )} | |
| {leftPanelVisible ? ( | |
| <aside className="hidden lg:flex w-80 h-full min-h-0 min-w-0 bg-card border-r border-border overflow-hidden relative flex-col"> | |
| <Button | |
| variant="secondary" | |
| size="icon" | |
| onClick={() => setLeftPanelVisible(false)} | |
| className="absolute z-[70] h-8 w-5 shadow-lg rounded-full bg-card border border-border" | |
| style={{ right: "-10px", top: "1rem" }} | |
| title="Close panel" | |
| > | |
| <ChevronLeft className="h-3 w-3" /> | |
| </Button> | |
| <div className="flex-1 min-h-0 min-w-0 overflow-hidden"> | |
| <LeftSidebar | |
| learningMode={learningMode} | |
| language={language} | |
| onLearningModeChange={setLearningMode} | |
| onLanguageChange={setLanguage} | |
| spaceType={sidebarSpaceType} | |
| groupMembers={sidebarGroupMembers} | |
| user={user} | |
| onLogin={setUser} | |
| onLogout={() => setUser(null)} | |
| isLoggedIn={!!user} | |
| onEditProfile={() => setShowProfileEditor(true)} | |
| savedItems={savedItems} | |
| recentlySavedId={recentlySavedId} | |
| onUnsave={handleUnsave} | |
| onSave={handleSave} | |
| savedChats={savedChats} | |
| onLoadChat={handleLoadChat} | |
| onDeleteSavedChat={handleDeleteSavedChat} | |
| onRenameSavedChat={handleRenameSavedChat} | |
| currentWorkspaceId={currentWorkspaceId} | |
| workspaces={sidebarWorkspaces} | |
| selectedCourse={currentCourseId} | |
| availableCourses={availableCourses} | |
| /> | |
| </div> | |
| </aside> | |
| ) : null} | |
| <aside | |
| className={[ | |
| "fixed lg:hidden z-50", | |
| "left-0 top-0 bottom-0", | |
| "w-80 bg-card border-r border-border", | |
| "transform transition-transform duration-300 ease-in-out", | |
| leftSidebarOpen ? "translate-x-0" : "-translate-x-full", | |
| "overflow-hidden flex flex-col", | |
| ].join(" ")} | |
| > | |
| <div className="p-4 border-b border-border flex justify-between items-center flex-shrink-0"> | |
| <h3>Settings & Guide</h3> | |
| <Button variant="ghost" size="icon" onClick={() => setLeftSidebarOpen(false)}> | |
| <X className="h-5 w-5" /> | |
| </Button> | |
| </div> | |
| <div className="flex-1 min-h-0 overflow-hidden"> | |
| <LeftSidebar | |
| learningMode={learningMode} | |
| language={language} | |
| onLearningModeChange={setLearningMode} | |
| onLanguageChange={setLanguage} | |
| spaceType={sidebarSpaceType} | |
| groupMembers={sidebarGroupMembers} | |
| user={user} | |
| onLogin={setUser} | |
| onLogout={() => setUser(null)} | |
| isLoggedIn={!!user} | |
| onEditProfile={() => setShowProfileEditor(true)} | |
| savedItems={savedItems} | |
| recentlySavedId={recentlySavedId} | |
| onUnsave={handleUnsave} | |
| onSave={handleSave} | |
| savedChats={savedChats} | |
| onLoadChat={handleLoadChat} | |
| onDeleteSavedChat={handleDeleteSavedChat} | |
| onRenameSavedChat={handleRenameSavedChat} | |
| currentWorkspaceId={currentWorkspaceId} | |
| workspaces={sidebarWorkspaces} | |
| selectedCourse={currentCourseId} | |
| availableCourses={availableCourses} | |
| /> | |
| </div> | |
| </aside> | |
| <main className="flex flex-1 min-w-0 min-h-0 overflow-hidden flex-col"> | |
| <div className="flex-1 min-h-0 min-w-0 overflow-hidden"> | |
| <ChatArea | |
| messages={messages} | |
| onSendMessage={handleSendMessage} | |
| uploadedFiles={uploadedFiles} | |
| onFileUpload={handleFileUpload} | |
| onRemoveFile={handleRemoveFile} | |
| onFileTypeChange={handleFileTypeChange} | |
| memoryProgress={memoryProgress} | |
| isLoggedIn={!!user} | |
| learningMode={learningMode} | |
| onClearConversation={() => 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") as "pdf" | "text", targetWorkspaceId) | |
| } | |
| leftPanelVisible={leftPanelVisible} | |
| currentCourseId={currentCourseId} | |
| onCourseChange={handleCourseChange} | |
| availableCourses={availableCourses} | |
| showReviewBanner={showReviewBanner} | |
| onReviewActivity={handleReviewActivity} | |
| currentUserId={user?.email} | |
| docType={"Syllabus"} | |
| /> | |
| </div> | |
| </main> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default App; | |