Spaces:
Sleeping
Sleeping
Update web/src/App.tsx
Browse files- web/src/App.tsx +116 -187
web/src/App.tsx
CHANGED
|
@@ -21,8 +21,8 @@ export interface Message {
|
|
| 21 |
content: string;
|
| 22 |
timestamp: Date;
|
| 23 |
references?: string[];
|
| 24 |
-
sender?: GroupMember;
|
| 25 |
-
showNextButton?: boolean;
|
| 26 |
questionData?: {
|
| 27 |
type: "multiple-choice" | "fill-in-blank" | "open-ended";
|
| 28 |
question: string;
|
|
@@ -51,14 +51,8 @@ export type SpaceType = "individual" | "group";
|
|
| 51 |
export interface CourseInfo {
|
| 52 |
id: string;
|
| 53 |
name: string;
|
| 54 |
-
instructor: {
|
| 55 |
-
|
| 56 |
-
email: string;
|
| 57 |
-
};
|
| 58 |
-
teachingAssistant: {
|
| 59 |
-
name: string;
|
| 60 |
-
email: string;
|
| 61 |
-
};
|
| 62 |
}
|
| 63 |
|
| 64 |
export interface Workspace {
|
|
@@ -70,7 +64,7 @@ export interface Workspace {
|
|
| 70 |
category?: "course" | "personal";
|
| 71 |
courseName?: string;
|
| 72 |
courseInfo?: CourseInfo;
|
| 73 |
-
isEditable?: boolean;
|
| 74 |
}
|
| 75 |
|
| 76 |
export type FileType = "syllabus" | "lecture-slides" | "literature-review" | "other";
|
|
@@ -124,10 +118,8 @@ function App() {
|
|
| 124 |
|
| 125 |
const [user, setUser] = useState<User | null>(null);
|
| 126 |
|
| 127 |
-
// Global current course selection (My Space only)
|
| 128 |
const [currentCourseId, setCurrentCourseId] = useState<string>(() => localStorage.getItem("myspace_selected_course") || "course1");
|
| 129 |
|
| 130 |
-
// Available courses with instructor/TA info
|
| 131 |
const availableCourses: CourseInfo[] = [
|
| 132 |
{
|
| 133 |
id: "course1",
|
|
@@ -155,7 +147,6 @@ function App() {
|
|
| 155 |
},
|
| 156 |
];
|
| 157 |
|
| 158 |
-
// Separate messages for each chat mode
|
| 159 |
const [askMessages, setAskMessages] = useState<Message[]>([
|
| 160 |
{
|
| 161 |
id: "1",
|
|
@@ -192,7 +183,6 @@ function App() {
|
|
| 192 |
|
| 193 |
const prevChatModeRef = useRef<ChatMode>(chatMode);
|
| 194 |
|
| 195 |
-
// Ensure welcome message exists when switching modes or when messages are empty
|
| 196 |
useEffect(() => {
|
| 197 |
let currentMessages: Message[];
|
| 198 |
let setCurrentMessages: (messages: Message[]) => void;
|
|
@@ -267,21 +257,14 @@ function App() {
|
|
| 267 |
const [showProfileEditor, setShowProfileEditor] = useState(false);
|
| 268 |
const [showOnboarding, setShowOnboarding] = useState(false);
|
| 269 |
|
| 270 |
-
|
| 271 |
-
const [showReviewBanner, setShowReviewBanner] = useState(() => {
|
| 272 |
-
return true; // force show for testing (as you had)
|
| 273 |
-
});
|
| 274 |
-
|
| 275 |
const [showClearDialog, setShowClearDialog] = useState(false);
|
| 276 |
|
| 277 |
-
// Saved conversations/summaries
|
| 278 |
const [savedItems, setSavedItems] = useState<SavedItem[]>([]);
|
| 279 |
const [recentlySavedId, setRecentlySavedId] = useState<string | null>(null);
|
| 280 |
|
| 281 |
-
// Saved chats
|
| 282 |
const [savedChats, setSavedChats] = useState<SavedChat[]>([]);
|
| 283 |
|
| 284 |
-
// Mock group members
|
| 285 |
const [groupMembers] = useState<GroupMember[]>([
|
| 286 |
{ id: "clare", name: "Clare AI", email: "clare@ai.assistant", isAI: true },
|
| 287 |
{ id: "1", name: "Sarah Johnson", email: "sarah.j@university.edu" },
|
|
@@ -289,27 +272,19 @@ function App() {
|
|
| 289 |
{ id: "3", name: "Emma Williams", email: "emma.w@university.edu" },
|
| 290 |
]);
|
| 291 |
|
| 292 |
-
// Workspaces
|
| 293 |
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
| 294 |
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>("individual");
|
| 295 |
|
| 296 |
-
// Avoid double upload for same file
|
| 297 |
const uploadedFingerprintsRef = useRef<Set<string>>(new Set());
|
| 298 |
|
| 299 |
-
// Initialize workspaces when user logs in
|
| 300 |
useEffect(() => {
|
| 301 |
if (user) {
|
| 302 |
const userAvatar = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`;
|
| 303 |
const course1Info = availableCourses.find((c) => c.id === "course1");
|
| 304 |
-
const course2Info = availableCourses.find((c) => c.name === "AI Ethics");
|
| 305 |
|
| 306 |
setWorkspaces([
|
| 307 |
-
{
|
| 308 |
-
id: "individual",
|
| 309 |
-
name: "My Space",
|
| 310 |
-
type: "individual",
|
| 311 |
-
avatar: userAvatar,
|
| 312 |
-
},
|
| 313 |
{
|
| 314 |
id: "group-1",
|
| 315 |
name: "CS 101 Study Group",
|
|
@@ -334,16 +309,13 @@ function App() {
|
|
| 334 |
}
|
| 335 |
}, [user, groupMembers, availableCourses]);
|
| 336 |
|
| 337 |
-
// Get current workspace
|
| 338 |
const currentWorkspace = workspaces.find((w) => w.id === currentWorkspaceId) || workspaces[0];
|
| 339 |
const spaceType: SpaceType = currentWorkspace?.type || "individual";
|
| 340 |
|
| 341 |
-
// Keep current course in sync with workspace type
|
| 342 |
useEffect(() => {
|
| 343 |
if (!currentWorkspace) return;
|
| 344 |
|
| 345 |
if (currentWorkspace.type === "group" && currentWorkspace.category === "course") {
|
| 346 |
-
// ✅ FIX: keep courseId as id (not name)
|
| 347 |
const cid = currentWorkspace.courseInfo?.id;
|
| 348 |
if (cid) setCurrentCourseId(cid);
|
| 349 |
return;
|
|
@@ -355,7 +327,6 @@ function App() {
|
|
| 355 |
}
|
| 356 |
}, [currentWorkspaceId, currentWorkspace]);
|
| 357 |
|
| 358 |
-
// Persist selection for My Space
|
| 359 |
useEffect(() => {
|
| 360 |
if (currentWorkspace?.type === "individual") {
|
| 361 |
localStorage.setItem("myspace_selected_course", currentCourseId);
|
|
@@ -367,7 +338,15 @@ function App() {
|
|
| 367 |
localStorage.setItem("theme", isDarkMode ? "dark" : "light");
|
| 368 |
}, [isDarkMode]);
|
| 369 |
|
| 370 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
useEffect(() => {
|
| 372 |
if (!user) return;
|
| 373 |
|
|
@@ -377,7 +356,7 @@ function App() {
|
|
| 377 |
const pct = Math.round((r.progress_pct ?? 0) * 100);
|
| 378 |
setMemoryProgress(pct);
|
| 379 |
} catch {
|
| 380 |
-
// silent
|
| 381 |
}
|
| 382 |
})();
|
| 383 |
}, [user]);
|
|
@@ -421,9 +400,6 @@ function App() {
|
|
| 421 |
return questions[randomIndex];
|
| 422 |
};
|
| 423 |
|
| 424 |
-
// Pick doc_type for chat:
|
| 425 |
-
// - if any uploaded files: use the most recent file's selected type
|
| 426 |
-
// - else: default to Syllabus
|
| 427 |
const getCurrentDocTypeForChat = (): string => {
|
| 428 |
if (uploadedFiles.length > 0) {
|
| 429 |
const last = uploadedFiles[uploadedFiles.length - 1];
|
|
@@ -450,15 +426,10 @@ function App() {
|
|
| 450 |
sender,
|
| 451 |
};
|
| 452 |
|
| 453 |
-
if (chatMode === "ask")
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
setReviewMessages((prev) => [...prev, userMessage]);
|
| 457 |
-
} else {
|
| 458 |
-
setQuizMessages((prev) => [...prev, userMessage]);
|
| 459 |
-
}
|
| 460 |
|
| 461 |
-
// Quiz mode stays mock for now (your current behavior)
|
| 462 |
if (chatMode === "quiz") {
|
| 463 |
if (quizState.waitingForAnswer) {
|
| 464 |
const isCorrect = Math.random() > 0.3;
|
|
@@ -488,7 +459,6 @@ function App() {
|
|
| 488 |
return;
|
| 489 |
}
|
| 490 |
|
| 491 |
-
// Ask / Review: real backend call
|
| 492 |
setIsTyping(true);
|
| 493 |
try {
|
| 494 |
const docType = getCurrentDocTypeForChat();
|
|
@@ -522,14 +492,10 @@ function App() {
|
|
| 522 |
setIsTyping(false);
|
| 523 |
|
| 524 |
setTimeout(() => {
|
| 525 |
-
if (chatMode === "ask")
|
| 526 |
-
|
| 527 |
-
} else if (chatMode === "review") {
|
| 528 |
-
setReviewMessages((prev) => [...prev, assistantMessage]);
|
| 529 |
-
}
|
| 530 |
}, 50);
|
| 531 |
|
| 532 |
-
// refresh memory progress (best-effort)
|
| 533 |
try {
|
| 534 |
const ml = await apiMemoryline(user.email);
|
| 535 |
setMemoryProgress(Math.round((ml.progress_pct ?? 0) * 100));
|
|
@@ -588,15 +554,10 @@ function App() {
|
|
| 588 |
}, 2000);
|
| 589 |
};
|
| 590 |
|
| 591 |
-
const handleStartQuiz = () =>
|
| 592 |
-
handleNextQuestion();
|
| 593 |
-
};
|
| 594 |
|
| 595 |
const handleFileUpload = (files: File[]) => {
|
| 596 |
-
const newFiles: UploadedFile[] = files.map((file) => ({
|
| 597 |
-
file,
|
| 598 |
-
type: "other" as FileType, // default; user will confirm type in ChatArea dialog
|
| 599 |
-
}));
|
| 600 |
setUploadedFiles((prev) => [...prev, ...newFiles]);
|
| 601 |
};
|
| 602 |
|
|
@@ -605,10 +566,8 @@ function App() {
|
|
| 605 |
};
|
| 606 |
|
| 607 |
const handleFileTypeChange = async (index: number, type: FileType) => {
|
| 608 |
-
// update FE state first
|
| 609 |
setUploadedFiles((prev) => prev.map((file, i) => (i === index ? { ...file, type } : file)));
|
| 610 |
|
| 611 |
-
// upload to backend once (best effort)
|
| 612 |
if (!user) return;
|
| 613 |
|
| 614 |
const target = uploadedFiles[index];
|
|
@@ -632,7 +591,6 @@ function App() {
|
|
| 632 |
}
|
| 633 |
};
|
| 634 |
|
| 635 |
-
// Helper function to check if current chat is already saved
|
| 636 |
const isCurrentChatSaved = (): SavedChat | null => {
|
| 637 |
if (messages.length <= 1) return null;
|
| 638 |
|
|
@@ -679,17 +637,11 @@ function App() {
|
|
| 679 |
const handleLoadChat = (savedChat: SavedChat) => {
|
| 680 |
setChatMode(savedChat.chatMode);
|
| 681 |
|
| 682 |
-
if (savedChat.chatMode === "ask")
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
setReviewMessages(savedChat.messages);
|
| 686 |
-
} else {
|
| 687 |
setQuizMessages(savedChat.messages);
|
| 688 |
-
setQuizState({
|
| 689 |
-
currentQuestion: 0,
|
| 690 |
-
waitingForAnswer: false,
|
| 691 |
-
showNextButton: false,
|
| 692 |
-
});
|
| 693 |
}
|
| 694 |
|
| 695 |
toast.success("Chat loaded!");
|
|
@@ -706,9 +658,7 @@ function App() {
|
|
| 706 |
};
|
| 707 |
|
| 708 |
const handleClearConversation = (shouldSave: boolean = false) => {
|
| 709 |
-
if (shouldSave)
|
| 710 |
-
handleSaveChat();
|
| 711 |
-
}
|
| 712 |
|
| 713 |
const initialMessages: Record<ChatMode, Message[]> = {
|
| 714 |
ask: [
|
|
@@ -738,17 +688,11 @@ function App() {
|
|
| 738 |
],
|
| 739 |
};
|
| 740 |
|
| 741 |
-
if (chatMode === "ask")
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
setReviewMessages(initialMessages.review);
|
| 745 |
-
} else {
|
| 746 |
setQuizMessages(initialMessages.quiz);
|
| 747 |
-
setQuizState({
|
| 748 |
-
currentQuestion: 0,
|
| 749 |
-
waitingForAnswer: false,
|
| 750 |
-
showNextButton: false,
|
| 751 |
-
});
|
| 752 |
}
|
| 753 |
};
|
| 754 |
|
|
@@ -770,12 +714,7 @@ function App() {
|
|
| 770 |
"👋 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!",
|
| 771 |
timestamp: new Date(),
|
| 772 |
},
|
| 773 |
-
{
|
| 774 |
-
id: Date.now().toString(),
|
| 775 |
-
role: "assistant",
|
| 776 |
-
content,
|
| 777 |
-
timestamp: new Date(),
|
| 778 |
-
},
|
| 779 |
];
|
| 780 |
|
| 781 |
const title = type === "export" ? "Exported Conversation" : "Micro-Quiz";
|
|
@@ -815,10 +754,7 @@ function App() {
|
|
| 815 |
setRecentlySavedId(newItem.id);
|
| 816 |
setLeftPanelVisible(true);
|
| 817 |
|
| 818 |
-
setTimeout(() =>
|
| 819 |
-
setRecentlySavedId(null);
|
| 820 |
-
}, 2000);
|
| 821 |
-
|
| 822 |
toast.success("Saved for later!");
|
| 823 |
};
|
| 824 |
|
|
@@ -827,7 +763,6 @@ function App() {
|
|
| 827 |
toast.success("Removed from saved items");
|
| 828 |
};
|
| 829 |
|
| 830 |
-
// Create a new group workspace
|
| 831 |
const handleCreateWorkspace = (payload: { name: string; category: "course" | "personal"; courseId?: string; invites: string[] }) => {
|
| 832 |
const id = `group-${Date.now()}`;
|
| 833 |
const avatar = `https://api.dicebear.com/7.x/shapes/svg?seed=${encodeURIComponent(payload.name)}`;
|
|
@@ -907,48 +842,46 @@ function App() {
|
|
| 907 |
setShowOnboarding(false);
|
| 908 |
};
|
| 909 |
|
| 910 |
-
const handleOnboardingSkip = () =>
|
| 911 |
-
setShowOnboarding(false);
|
| 912 |
-
};
|
| 913 |
-
|
| 914 |
-
if (!user) {
|
| 915 |
-
return <LoginScreen onLogin={handleLogin} />;
|
| 916 |
-
}
|
| 917 |
|
| 918 |
-
if (
|
| 919 |
-
|
| 920 |
-
}
|
| 921 |
|
| 922 |
return (
|
| 923 |
-
<div className="
|
| 924 |
<Toaster />
|
| 925 |
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
|
|
|
|
|
|
|
|
|
| 942 |
|
| 943 |
{showProfileEditor && user && <ProfileEditor user={user} onSave={setUser} onClose={() => setShowProfileEditor(false)} />}
|
| 944 |
|
|
|
|
| 945 |
{showReviewBanner && (
|
| 946 |
-
<div className="w-full bg-background border-b border-border
|
| 947 |
<ReviewBanner onReview={handleReviewClick} onDismiss={handleDismissReviewBanner} />
|
| 948 |
</div>
|
| 949 |
)}
|
| 950 |
|
| 951 |
-
|
|
|
|
| 952 |
{!leftPanelVisible && (
|
| 953 |
<Button
|
| 954 |
variant="secondary"
|
|
@@ -964,11 +897,9 @@ function App() {
|
|
| 964 |
|
| 965 |
{leftSidebarOpen && <div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setLeftSidebarOpen(false)} />}
|
| 966 |
|
|
|
|
| 967 |
{leftPanelVisible ? (
|
| 968 |
-
<aside
|
| 969 |
-
className="hidden lg:flex w-80 bg-card border-r border-border flex-col h-full min-h-0 relative"
|
| 970 |
-
style={{ borderRight: "1px solid var(--border)", height: "calc(100vh - 4rem)" }}
|
| 971 |
-
>
|
| 972 |
<Button
|
| 973 |
variant="secondary"
|
| 974 |
size="icon"
|
|
@@ -980,58 +911,7 @@ function App() {
|
|
| 980 |
<ChevronLeft className="h-3 w-3" />
|
| 981 |
</Button>
|
| 982 |
|
| 983 |
-
{/*
|
| 984 |
-
<div className="flex-1 min-h-0 overflow-y-auto">
|
| 985 |
-
<LeftSidebar
|
| 986 |
-
learningMode={learningMode}
|
| 987 |
-
language={language}
|
| 988 |
-
onLearningModeChange={setLearningMode}
|
| 989 |
-
onLanguageChange={setLanguage}
|
| 990 |
-
spaceType={spaceType}
|
| 991 |
-
groupMembers={groupMembers}
|
| 992 |
-
user={user}
|
| 993 |
-
onLogin={setUser}
|
| 994 |
-
onLogout={() => setUser(null)}
|
| 995 |
-
isLoggedIn={!!user}
|
| 996 |
-
onEditProfile={() => setShowProfileEditor(true)}
|
| 997 |
-
savedItems={savedItems}
|
| 998 |
-
recentlySavedId={recentlySavedId}
|
| 999 |
-
onUnsave={handleUnsave}
|
| 1000 |
-
onSave={handleSave}
|
| 1001 |
-
savedChats={savedChats}
|
| 1002 |
-
onLoadChat={handleLoadChat}
|
| 1003 |
-
onDeleteSavedChat={handleDeleteSavedChat}
|
| 1004 |
-
onRenameSavedChat={handleRenameSavedChat}
|
| 1005 |
-
currentWorkspaceId={currentWorkspaceId}
|
| 1006 |
-
workspaces={workspaces}
|
| 1007 |
-
selectedCourse={currentCourseId}
|
| 1008 |
-
availableCourses={availableCourses}
|
| 1009 |
-
/>
|
| 1010 |
-
</div>
|
| 1011 |
-
</aside>
|
| 1012 |
-
) : null}
|
| 1013 |
-
|
| 1014 |
-
<aside
|
| 1015 |
-
className={`
|
| 1016 |
-
fixed lg:hidden inset-y-0 left-0 z-50
|
| 1017 |
-
w-80 bg-card border-r border-border
|
| 1018 |
-
transform transition-transform duration-300 ease-in-out
|
| 1019 |
-
${leftSidebarOpen ? "translate-x-0" : "-translate-x-full"}
|
| 1020 |
-
flex flex-col
|
| 1021 |
-
mt-16
|
| 1022 |
-
h-[calc(100vh-4rem)]
|
| 1023 |
-
min-h-0
|
| 1024 |
-
`}
|
| 1025 |
-
>
|
| 1026 |
-
<div className="p-4 border-b border-border flex justify-between items-center">
|
| 1027 |
-
<h3>Settings & Guide</h3>
|
| 1028 |
-
<Button variant="ghost" size="icon" onClick={() => setLeftSidebarOpen(false)}>
|
| 1029 |
-
<X className="h-5 w-5" />
|
| 1030 |
-
</Button>
|
| 1031 |
-
</div>
|
| 1032 |
-
|
| 1033 |
-
{/* ✅ KEY: isolate scroll container for mobile LeftSidebar */}
|
| 1034 |
-
<div className="flex-1 min-h-0 overflow-y-auto">
|
| 1035 |
<LeftSidebar
|
| 1036 |
learningMode={learningMode}
|
| 1037 |
language={language}
|
|
@@ -1051,15 +931,64 @@ function App() {
|
|
| 1051 |
savedChats={savedChats}
|
| 1052 |
onLoadChat={handleLoadChat}
|
| 1053 |
onDeleteSavedChat={handleDeleteSavedChat}
|
|
|
|
| 1054 |
currentWorkspaceId={currentWorkspaceId}
|
| 1055 |
workspaces={workspaces}
|
| 1056 |
selectedCourse={currentCourseId}
|
| 1057 |
availableCourses={availableCourses}
|
| 1058 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1059 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
</aside>
|
| 1061 |
|
| 1062 |
-
|
|
|
|
| 1063 |
<ChatArea
|
| 1064 |
messages={messages}
|
| 1065 |
onSendMessage={handleSendMessage}
|
|
|
|
| 21 |
content: string;
|
| 22 |
timestamp: Date;
|
| 23 |
references?: string[];
|
| 24 |
+
sender?: GroupMember;
|
| 25 |
+
showNextButton?: boolean;
|
| 26 |
questionData?: {
|
| 27 |
type: "multiple-choice" | "fill-in-blank" | "open-ended";
|
| 28 |
question: string;
|
|
|
|
| 51 |
export interface CourseInfo {
|
| 52 |
id: string;
|
| 53 |
name: string;
|
| 54 |
+
instructor: { name: string; email: string };
|
| 55 |
+
teachingAssistant: { name: string; email: string };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
export interface Workspace {
|
|
|
|
| 64 |
category?: "course" | "personal";
|
| 65 |
courseName?: string;
|
| 66 |
courseInfo?: CourseInfo;
|
| 67 |
+
isEditable?: boolean;
|
| 68 |
}
|
| 69 |
|
| 70 |
export type FileType = "syllabus" | "lecture-slides" | "literature-review" | "other";
|
|
|
|
| 118 |
|
| 119 |
const [user, setUser] = useState<User | null>(null);
|
| 120 |
|
|
|
|
| 121 |
const [currentCourseId, setCurrentCourseId] = useState<string>(() => localStorage.getItem("myspace_selected_course") || "course1");
|
| 122 |
|
|
|
|
| 123 |
const availableCourses: CourseInfo[] = [
|
| 124 |
{
|
| 125 |
id: "course1",
|
|
|
|
| 147 |
},
|
| 148 |
];
|
| 149 |
|
|
|
|
| 150 |
const [askMessages, setAskMessages] = useState<Message[]>([
|
| 151 |
{
|
| 152 |
id: "1",
|
|
|
|
| 183 |
|
| 184 |
const prevChatModeRef = useRef<ChatMode>(chatMode);
|
| 185 |
|
|
|
|
| 186 |
useEffect(() => {
|
| 187 |
let currentMessages: Message[];
|
| 188 |
let setCurrentMessages: (messages: Message[]) => void;
|
|
|
|
| 257 |
const [showProfileEditor, setShowProfileEditor] = useState(false);
|
| 258 |
const [showOnboarding, setShowOnboarding] = useState(false);
|
| 259 |
|
| 260 |
+
const [showReviewBanner, setShowReviewBanner] = useState(() => true);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
const [showClearDialog, setShowClearDialog] = useState(false);
|
| 262 |
|
|
|
|
| 263 |
const [savedItems, setSavedItems] = useState<SavedItem[]>([]);
|
| 264 |
const [recentlySavedId, setRecentlySavedId] = useState<string | null>(null);
|
| 265 |
|
|
|
|
| 266 |
const [savedChats, setSavedChats] = useState<SavedChat[]>([]);
|
| 267 |
|
|
|
|
| 268 |
const [groupMembers] = useState<GroupMember[]>([
|
| 269 |
{ id: "clare", name: "Clare AI", email: "clare@ai.assistant", isAI: true },
|
| 270 |
{ id: "1", name: "Sarah Johnson", email: "sarah.j@university.edu" },
|
|
|
|
| 272 |
{ id: "3", name: "Emma Williams", email: "emma.w@university.edu" },
|
| 273 |
]);
|
| 274 |
|
|
|
|
| 275 |
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
| 276 |
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>("individual");
|
| 277 |
|
|
|
|
| 278 |
const uploadedFingerprintsRef = useRef<Set<string>>(new Set());
|
| 279 |
|
|
|
|
| 280 |
useEffect(() => {
|
| 281 |
if (user) {
|
| 282 |
const userAvatar = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`;
|
| 283 |
const course1Info = availableCourses.find((c) => c.id === "course1");
|
| 284 |
+
const course2Info = availableCourses.find((c) => c.name === "AI Ethics");
|
| 285 |
|
| 286 |
setWorkspaces([
|
| 287 |
+
{ id: "individual", name: "My Space", type: "individual", avatar: userAvatar },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
{
|
| 289 |
id: "group-1",
|
| 290 |
name: "CS 101 Study Group",
|
|
|
|
| 309 |
}
|
| 310 |
}, [user, groupMembers, availableCourses]);
|
| 311 |
|
|
|
|
| 312 |
const currentWorkspace = workspaces.find((w) => w.id === currentWorkspaceId) || workspaces[0];
|
| 313 |
const spaceType: SpaceType = currentWorkspace?.type || "individual";
|
| 314 |
|
|
|
|
| 315 |
useEffect(() => {
|
| 316 |
if (!currentWorkspace) return;
|
| 317 |
|
| 318 |
if (currentWorkspace.type === "group" && currentWorkspace.category === "course") {
|
|
|
|
| 319 |
const cid = currentWorkspace.courseInfo?.id;
|
| 320 |
if (cid) setCurrentCourseId(cid);
|
| 321 |
return;
|
|
|
|
| 327 |
}
|
| 328 |
}, [currentWorkspaceId, currentWorkspace]);
|
| 329 |
|
|
|
|
| 330 |
useEffect(() => {
|
| 331 |
if (currentWorkspace?.type === "individual") {
|
| 332 |
localStorage.setItem("myspace_selected_course", currentCourseId);
|
|
|
|
| 338 |
localStorage.setItem("theme", isDarkMode ? "dark" : "light");
|
| 339 |
}, [isDarkMode]);
|
| 340 |
|
| 341 |
+
// ✅ 额外保险:锁 body 滚动(避免某些情况下仍出现页面滚动条)
|
| 342 |
+
useEffect(() => {
|
| 343 |
+
const prev = document.body.style.overflow;
|
| 344 |
+
document.body.style.overflow = "hidden";
|
| 345 |
+
return () => {
|
| 346 |
+
document.body.style.overflow = prev;
|
| 347 |
+
};
|
| 348 |
+
}, []);
|
| 349 |
+
|
| 350 |
useEffect(() => {
|
| 351 |
if (!user) return;
|
| 352 |
|
|
|
|
| 356 |
const pct = Math.round((r.progress_pct ?? 0) * 100);
|
| 357 |
setMemoryProgress(pct);
|
| 358 |
} catch {
|
| 359 |
+
// silent
|
| 360 |
}
|
| 361 |
})();
|
| 362 |
}, [user]);
|
|
|
|
| 400 |
return questions[randomIndex];
|
| 401 |
};
|
| 402 |
|
|
|
|
|
|
|
|
|
|
| 403 |
const getCurrentDocTypeForChat = (): string => {
|
| 404 |
if (uploadedFiles.length > 0) {
|
| 405 |
const last = uploadedFiles[uploadedFiles.length - 1];
|
|
|
|
| 426 |
sender,
|
| 427 |
};
|
| 428 |
|
| 429 |
+
if (chatMode === "ask") setAskMessages((prev) => [...prev, userMessage]);
|
| 430 |
+
else if (chatMode === "review") setReviewMessages((prev) => [...prev, userMessage]);
|
| 431 |
+
else setQuizMessages((prev) => [...prev, userMessage]);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
|
|
|
|
| 433 |
if (chatMode === "quiz") {
|
| 434 |
if (quizState.waitingForAnswer) {
|
| 435 |
const isCorrect = Math.random() > 0.3;
|
|
|
|
| 459 |
return;
|
| 460 |
}
|
| 461 |
|
|
|
|
| 462 |
setIsTyping(true);
|
| 463 |
try {
|
| 464 |
const docType = getCurrentDocTypeForChat();
|
|
|
|
| 492 |
setIsTyping(false);
|
| 493 |
|
| 494 |
setTimeout(() => {
|
| 495 |
+
if (chatMode === "ask") setAskMessages((prev) => [...prev, assistantMessage]);
|
| 496 |
+
else if (chatMode === "review") setReviewMessages((prev) => [...prev, assistantMessage]);
|
|
|
|
|
|
|
|
|
|
| 497 |
}, 50);
|
| 498 |
|
|
|
|
| 499 |
try {
|
| 500 |
const ml = await apiMemoryline(user.email);
|
| 501 |
setMemoryProgress(Math.round((ml.progress_pct ?? 0) * 100));
|
|
|
|
| 554 |
}, 2000);
|
| 555 |
};
|
| 556 |
|
| 557 |
+
const handleStartQuiz = () => handleNextQuestion();
|
|
|
|
|
|
|
| 558 |
|
| 559 |
const handleFileUpload = (files: File[]) => {
|
| 560 |
+
const newFiles: UploadedFile[] = files.map((file) => ({ file, type: "other" as FileType }));
|
|
|
|
|
|
|
|
|
|
| 561 |
setUploadedFiles((prev) => [...prev, ...newFiles]);
|
| 562 |
};
|
| 563 |
|
|
|
|
| 566 |
};
|
| 567 |
|
| 568 |
const handleFileTypeChange = async (index: number, type: FileType) => {
|
|
|
|
| 569 |
setUploadedFiles((prev) => prev.map((file, i) => (i === index ? { ...file, type } : file)));
|
| 570 |
|
|
|
|
| 571 |
if (!user) return;
|
| 572 |
|
| 573 |
const target = uploadedFiles[index];
|
|
|
|
| 591 |
}
|
| 592 |
};
|
| 593 |
|
|
|
|
| 594 |
const isCurrentChatSaved = (): SavedChat | null => {
|
| 595 |
if (messages.length <= 1) return null;
|
| 596 |
|
|
|
|
| 637 |
const handleLoadChat = (savedChat: SavedChat) => {
|
| 638 |
setChatMode(savedChat.chatMode);
|
| 639 |
|
| 640 |
+
if (savedChat.chatMode === "ask") setAskMessages(savedChat.messages);
|
| 641 |
+
else if (savedChat.chatMode === "review") setReviewMessages(savedChat.messages);
|
| 642 |
+
else {
|
|
|
|
|
|
|
| 643 |
setQuizMessages(savedChat.messages);
|
| 644 |
+
setQuizState({ currentQuestion: 0, waitingForAnswer: false, showNextButton: false });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
}
|
| 646 |
|
| 647 |
toast.success("Chat loaded!");
|
|
|
|
| 658 |
};
|
| 659 |
|
| 660 |
const handleClearConversation = (shouldSave: boolean = false) => {
|
| 661 |
+
if (shouldSave) handleSaveChat();
|
|
|
|
|
|
|
| 662 |
|
| 663 |
const initialMessages: Record<ChatMode, Message[]> = {
|
| 664 |
ask: [
|
|
|
|
| 688 |
],
|
| 689 |
};
|
| 690 |
|
| 691 |
+
if (chatMode === "ask") setAskMessages(initialMessages.ask);
|
| 692 |
+
else if (chatMode === "review") setReviewMessages(initialMessages.review);
|
| 693 |
+
else {
|
|
|
|
|
|
|
| 694 |
setQuizMessages(initialMessages.quiz);
|
| 695 |
+
setQuizState({ currentQuestion: 0, waitingForAnswer: false, showNextButton: false });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
}
|
| 697 |
};
|
| 698 |
|
|
|
|
| 714 |
"👋 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!",
|
| 715 |
timestamp: new Date(),
|
| 716 |
},
|
| 717 |
+
{ id: Date.now().toString(), role: "assistant", content, timestamp: new Date() },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
];
|
| 719 |
|
| 720 |
const title = type === "export" ? "Exported Conversation" : "Micro-Quiz";
|
|
|
|
| 754 |
setRecentlySavedId(newItem.id);
|
| 755 |
setLeftPanelVisible(true);
|
| 756 |
|
| 757 |
+
setTimeout(() => setRecentlySavedId(null), 2000);
|
|
|
|
|
|
|
|
|
|
| 758 |
toast.success("Saved for later!");
|
| 759 |
};
|
| 760 |
|
|
|
|
| 763 |
toast.success("Removed from saved items");
|
| 764 |
};
|
| 765 |
|
|
|
|
| 766 |
const handleCreateWorkspace = (payload: { name: string; category: "course" | "personal"; courseId?: string; invites: string[] }) => {
|
| 767 |
const id = `group-${Date.now()}`;
|
| 768 |
const avatar = `https://api.dicebear.com/7.x/shapes/svg?seed=${encodeURIComponent(payload.name)}`;
|
|
|
|
| 842 |
setShowOnboarding(false);
|
| 843 |
};
|
| 844 |
|
| 845 |
+
const handleOnboardingSkip = () => setShowOnboarding(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 846 |
|
| 847 |
+
if (!user) return <LoginScreen onLogin={handleLogin} />;
|
| 848 |
+
if (showOnboarding && user) return <Onboarding user={user} onComplete={handleOnboardingComplete} onSkip={handleOnboardingSkip} />;
|
|
|
|
| 849 |
|
| 850 |
return (
|
| 851 |
+
<div className="h-screen overflow-hidden bg-background flex flex-col">
|
| 852 |
<Toaster />
|
| 853 |
|
| 854 |
+
{/* Header fixed */}
|
| 855 |
+
<div className="flex-shrink-0">
|
| 856 |
+
<Header
|
| 857 |
+
user={user}
|
| 858 |
+
onMenuClick={() => setLeftSidebarOpen(!leftSidebarOpen)}
|
| 859 |
+
onUserClick={() => {}}
|
| 860 |
+
isDarkMode={isDarkMode}
|
| 861 |
+
onToggleDarkMode={() => setIsDarkMode(!isDarkMode)}
|
| 862 |
+
language={language}
|
| 863 |
+
onLanguageChange={setLanguage}
|
| 864 |
+
workspaces={workspaces}
|
| 865 |
+
currentWorkspace={currentWorkspace}
|
| 866 |
+
onWorkspaceChange={setCurrentWorkspaceId}
|
| 867 |
+
onCreateWorkspace={handleCreateWorkspace}
|
| 868 |
+
onLogout={() => setUser(null)}
|
| 869 |
+
availableCourses={availableCourses}
|
| 870 |
+
onUserUpdate={setUser}
|
| 871 |
+
/>
|
| 872 |
+
</div>
|
| 873 |
|
| 874 |
{showProfileEditor && user && <ProfileEditor user={user} onSave={setUser} onClose={() => setShowProfileEditor(false)} />}
|
| 875 |
|
| 876 |
+
{/* Review banner fixed */}
|
| 877 |
{showReviewBanner && (
|
| 878 |
+
<div className="flex-shrink-0 w-full bg-background border-b border-border relative z-50">
|
| 879 |
<ReviewBanner onReview={handleReviewClick} onDismiss={handleDismissReviewBanner} />
|
| 880 |
</div>
|
| 881 |
)}
|
| 882 |
|
| 883 |
+
{/* Main area: NO page scroll, only inner panes scroll */}
|
| 884 |
+
<div className="flex-1 min-h-0 flex overflow-hidden relative" style={{ overscrollBehavior: "none" }}>
|
| 885 |
{!leftPanelVisible && (
|
| 886 |
<Button
|
| 887 |
variant="secondary"
|
|
|
|
| 897 |
|
| 898 |
{leftSidebarOpen && <div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setLeftSidebarOpen(false)} />}
|
| 899 |
|
| 900 |
+
{/* Desktop left panel */}
|
| 901 |
{leftPanelVisible ? (
|
| 902 |
+
<aside className="hidden lg:flex w-80 bg-card border-r border-border flex-col min-h-0 overflow-hidden relative">
|
|
|
|
|
|
|
|
|
|
| 903 |
<Button
|
| 904 |
variant="secondary"
|
| 905 |
size="icon"
|
|
|
|
| 911 |
<ChevronLeft className="h-3 w-3" />
|
| 912 |
</Button>
|
| 913 |
|
| 914 |
+
{/* IMPORTANT: do NOT wrap LeftSidebar with another overflow container */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 915 |
<LeftSidebar
|
| 916 |
learningMode={learningMode}
|
| 917 |
language={language}
|
|
|
|
| 931 |
savedChats={savedChats}
|
| 932 |
onLoadChat={handleLoadChat}
|
| 933 |
onDeleteSavedChat={handleDeleteSavedChat}
|
| 934 |
+
onRenameSavedChat={handleRenameSavedChat}
|
| 935 |
currentWorkspaceId={currentWorkspaceId}
|
| 936 |
workspaces={workspaces}
|
| 937 |
selectedCourse={currentCourseId}
|
| 938 |
availableCourses={availableCourses}
|
| 939 |
/>
|
| 940 |
+
</aside>
|
| 941 |
+
) : null}
|
| 942 |
+
|
| 943 |
+
{/* Mobile left drawer */}
|
| 944 |
+
<aside
|
| 945 |
+
className={`
|
| 946 |
+
fixed lg:hidden inset-y-0 left-0 z-50
|
| 947 |
+
w-80 bg-card border-r border-border
|
| 948 |
+
transform transition-transform duration-300 ease-in-out
|
| 949 |
+
${leftSidebarOpen ? "translate-x-0" : "-translate-x-full"}
|
| 950 |
+
flex flex-col
|
| 951 |
+
mt-16
|
| 952 |
+
h-[calc(100vh-4rem)]
|
| 953 |
+
min-h-0
|
| 954 |
+
overflow-hidden
|
| 955 |
+
`}
|
| 956 |
+
>
|
| 957 |
+
<div className="p-4 border-b border-border flex justify-between items-center flex-shrink-0">
|
| 958 |
+
<h3>Settings & Guide</h3>
|
| 959 |
+
<Button variant="ghost" size="icon" onClick={() => setLeftSidebarOpen(false)}>
|
| 960 |
+
<X className="h-5 w-5" />
|
| 961 |
+
</Button>
|
| 962 |
</div>
|
| 963 |
+
|
| 964 |
+
<LeftSidebar
|
| 965 |
+
learningMode={learningMode}
|
| 966 |
+
language={language}
|
| 967 |
+
onLearningModeChange={setLearningMode}
|
| 968 |
+
onLanguageChange={setLanguage}
|
| 969 |
+
spaceType={spaceType}
|
| 970 |
+
groupMembers={groupMembers}
|
| 971 |
+
user={user}
|
| 972 |
+
onLogin={setUser}
|
| 973 |
+
onLogout={() => setUser(null)}
|
| 974 |
+
isLoggedIn={!!user}
|
| 975 |
+
onEditProfile={() => setShowProfileEditor(true)}
|
| 976 |
+
savedItems={savedItems}
|
| 977 |
+
recentlySavedId={recentlySavedId}
|
| 978 |
+
onUnsave={handleUnsave}
|
| 979 |
+
onSave={handleSave}
|
| 980 |
+
savedChats={savedChats}
|
| 981 |
+
onLoadChat={handleLoadChat}
|
| 982 |
+
onDeleteSavedChat={handleDeleteSavedChat}
|
| 983 |
+
currentWorkspaceId={currentWorkspaceId}
|
| 984 |
+
workspaces={workspaces}
|
| 985 |
+
selectedCourse={currentCourseId}
|
| 986 |
+
availableCourses={availableCourses}
|
| 987 |
+
/>
|
| 988 |
</aside>
|
| 989 |
|
| 990 |
+
{/* Chat column: overflow hidden, ChatArea handles its own inner scroll */}
|
| 991 |
+
<main className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
| 992 |
<ChatArea
|
| 993 |
messages={messages}
|
| 994 |
onSendMessage={handleSendMessage}
|