SarahXia0405's picture
Update web/src/App.tsx
758bc5a verified
// 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;