SarahXia0405 commited on
Commit
a6047b4
·
verified ·
1 Parent(s): de7b224

Update web/src/App.tsx

Browse files
Files changed (1) hide show
  1. web/src/App.tsx +507 -445
web/src/App.tsx CHANGED
@@ -1,67 +1,150 @@
 
 
1
  import { toast } from "sonner";
 
 
 
 
 
 
 
 
 
 
2
  import { LeftSidebar } from "./components/sidebar/LeftSidebar";
3
 
 
 
4
  // backend API bindings
5
  import { apiChat, apiUpload, apiMemoryline, apiQuizStart } from "./lib/api";
6
 
7
- // NEW: review-star logic
8
- import {
9
- type ReviewStarState,
10
- type ReviewEventType,
 
 
 
 
 
 
 
 
 
 
 
11
  name: string;
12
  kind: MessageAttachmentKind;
13
  size: number;
14
- // 这两个只是展示用,不影响后端
15
- fileType?: FileType; // syllabus / lecture-slides / ...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  }
17
 
18
  export interface Message {
 
 
 
 
 
 
19
  references?: string[];
 
 
20
  sender?: GroupMember;
 
 
21
  showNextButton?: boolean;
22
 
23
- // ✅ NEW: show files with the user message (metadata only)
24
  attachments?: MessageAttachment[];
25
 
26
  questionData?: {
27
  type: "multiple-choice" | "fill-in-blank" | "open-ended";
28
  question: string;
 
 
 
 
 
 
29
  }
30
 
31
  export interface User {
32
- // required identity
33
  name: string;
34
  email: string;
35
 
36
- // profile fields
37
  studentId?: string;
38
  department?: string;
39
  yearLevel?: string;
40
  major?: string;
41
- bio?: string; // may be generated by Clare, then user can edit in ProfileEditor
42
 
43
- // learning preferences
44
- learningStyle?: string; // "visual" | "auditory" | ...
45
- learningPace?: string; // "slow" | "moderate" | "fast"
46
 
47
- // avatar
48
  avatarUrl?: string;
49
 
50
- // control flags
51
  onboardingCompleted?: boolean;
52
  }
53
 
54
- | "exam"
55
- | "assignment"
56
- | "summary";
57
-
58
- export type Language = "auto" | "en" | "zh";
59
- export type ChatMode = "ask" | "review" | "quiz";
60
 
 
 
 
 
61
  other: "Other Course Document",
62
  };
63
 
64
- // ✅ NEW: UI courseId -> backend course_id(你后端 logs 显示的是 course_ist345)
65
  const BACKEND_COURSE_ID_MAP: Record<string, string> = {
66
  course1: "course_ist345",
67
  course2: "course_ist345",
@@ -69,38 +152,96 @@ const BACKEND_COURSE_ID_MAP: Record<string, string> = {
69
  course4: "course_ist345",
70
  };
71
 
72
- function mapLanguagePref(lang: Language): string {
 
73
  return "Auto";
74
  }
75
 
76
- // ✅ localStorage helpers for saved chats
77
  function savedChatsStorageKey(email: string) {
78
  return `saved_chats::${email}`;
79
  }
80
- .filter(Boolean) as SavedChat[];
81
- }
82
 
83
- // ✅ localStorage helpers for user profile
84
  function profileStorageKey(email: string) {
85
  return `user_profile::${email}`;
86
  }
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  const [user, setUser] = useState<User | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
- // ✅ unified user update helpers
91
  const updateUser = (patch: Partial<User>) => {
92
  setUser((prev) => (prev ? { ...prev, ...patch } : prev));
93
  };
 
 
 
 
94
  return {
95
  ...prev,
96
  ...next,
97
- onboardingCompleted:
98
- next.onboardingCompleted ?? prev.onboardingCompleted,
99
  };
100
  });
101
  };
102
 
103
- // persist user profile whenever it changes (per-email)
104
  useEffect(() => {
105
  if (!user?.email) return;
106
  try {
@@ -111,7 +252,53 @@ function profileStorageKey(email: string) {
111
  }, [user]);
112
 
113
  // -------------------------
114
- // ✅ Course selection (stable)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  // -------------------------
116
  const MYSPACE_COURSE_KEY = "myspace_selected_course";
117
 
@@ -119,7 +306,6 @@ function profileStorageKey(email: string) {
119
  return localStorage.getItem(MYSPACE_COURSE_KEY) || "course1";
120
  });
121
 
122
- // ✅ NEW: computed backend course id
123
  const backendCourseId = useMemo(() => {
124
  return BACKEND_COURSE_ID_MAP[currentCourseId] || "course_ist345";
125
  }, [currentCourseId]);
@@ -128,90 +314,18 @@ function profileStorageKey(email: string) {
128
  {
129
  id: "course1",
130
  name: "Introduction to AI",
131
- instructor: {
132
- name: "Dr. Sarah Johnson",
133
- email: "sarah.johnson@university.edu",
134
- },
135
- teachingAssistant: {
136
- name: "Michael Chen",
137
- email: "michael.chen@university.edu",
138
- },
139
  },
140
  {
141
  id: "course2",
142
  name: "Machine Learning",
143
  instructor: { name: "Prof. David Lee", email: "david.lee@university.edu" },
144
- teachingAssistant: {
145
- name: "Emily Zhang",
146
- email: "emily.zhang@university.edu",
147
- },
148
  },
149
- {
150
- id: "course3",
151
-
152
- const hasUserMessages = currentMessages.some((msg) => msg.role === "user");
153
- const expectedWelcomeId =
154
- chatMode === "ask" ? "1" : chatMode === "review" ? "review-1" : "quiz-1";
155
-
156
-
157
-
158
-
159
- const hasWelcomeMessage = currentMessages.some(
160
- (msg) => msg.id === expectedWelcomeId && msg.role === "assistant"
161
- );
162
-
163
- const [savedChats, setSavedChats] = useState<SavedChat[]>([]);
164
-
165
- // ✅ load saved chats after login
166
- useEffect(() => {
167
- if (!user?.email) return;
168
- try {
169
- }
170
- }, [user?.email]);
171
-
172
- // ✅ persist saved chats whenever changed
173
- useEffect(() => {
174
- if (!user?.email) return;
175
- try {
176
- savedChatsStorageKey(user.email),
177
- JSON.stringify(savedChats)
178
- );
179
- } catch {
180
- // ignore
181
- }
182
- }, [savedChats, user?.email]);
183
-
184
- const [groupMembers] = useState<GroupMember[]>([
185
- const [currentWorkspaceId, setCurrentWorkspaceId] =
186
- useState<string>("individual");
187
-
188
- // ✅ used to prevent duplicate upload per file fingerprint
189
- const uploadedFingerprintsRef = useRef<Set<string>>(new Set());
190
-
191
- useEffect(() => {
192
- user.email
193
- )}`;
194
- const course1Info = availableCourses.find((c) => c.id === "course1");
195
- const course2Info = availableCourses.find((c) => c.name === "AI Ethics"); // may be undefined, that's OK
196
-
197
- setWorkspaces([
198
- { id: "individual", name: "My Space", type: "individual", avatar: userAvatar },
199
-
200
- const spaceType: SpaceType = currentWorkspace?.type || "individual";
201
-
202
- // =========================
203
- // ✅ Scheme 1: "My Space" uses Group-like sidebar view model
204
- // =========================
205
- const mySpaceCourseInfo = useMemo(() => {
206
- return availableCourses.find((c) => c.id === currentCourseId);
207
- }, [availableCourses, currentCourseId]);
208
- return groupMembers;
209
- }, [currentWorkspaceId, mySpaceUserMember, clareMember, groupMembers]);
210
-
211
- // =========================
212
- // ✅ Stable course switching logic
213
- // =========================
214
- const didHydrateMySpaceRef = useRef(false);
215
 
216
  const handleCourseChange = (nextCourseId: string) => {
217
  setCurrentCourseId(nextCourseId);
@@ -222,36 +336,106 @@ function profileStorageKey(email: string) {
222
  }
223
  };
224
 
225
- useEffect(() => {
226
- if (!currentWorkspace) return;
 
 
 
 
 
 
 
 
 
 
227
 
228
- if (currentWorkspace.type === "group" && currentWorkspace.category === "course") {
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
 
 
 
231
 
232
- const cid = currentWorkspace.courseInfo?.id;
233
- if (cid && cid !== currentCourseId) setCurrentCourseId(cid);
234
- didHydrateMySpaceRef.current = false;
235
- didHydrateMySpaceRef.current = true;
236
 
237
- const saved = localStorage.getItem(MYSPACE_COURSE_KEY);
238
- const valid = saved && availableCourses.some((c) => c.id === saved) ? saved : undefined;
 
 
 
 
239
 
 
 
240
 
 
 
 
 
 
 
241
 
 
 
 
 
 
 
 
 
 
242
 
243
- const next = valid || currentCourseId || "course1";
244
- if (next !== currentCourseId) setCurrentCourseId(next);
245
- if (currentWorkspace?.type !== "individual") return;
246
  try {
247
- const prev = localStorage.getItem(MYSPACE_COURSE_KEY);
248
- if (prev !== currentCourseId) localStorage.setItem(MYSPACE_COURSE_KEY, currentCourseId);
249
  } catch {
250
  // ignore
251
  }
252
- }, [currentCourseId, currentWorkspace?.type]);
 
 
 
 
 
253
 
254
  useEffect(() => {
 
 
 
255
  const r = await apiMemoryline(user.email);
256
  const pct = Math.round((r.progress_pct ?? 0) * 100);
257
  setMemoryProgress(pct);
@@ -259,108 +443,115 @@ function profileStorageKey(email: string) {
259
  // silent
260
  }
261
  })();
262
- }, [user]);
263
-
264
- // =========================
265
- // ✅ Review Star (按天) state
266
- // =========================
267
- const reviewStarKey = useMemo(() => {
268
- if (!user) return "";
269
- return `review_star::${user.email}::${currentWorkspaceId}`;
270
- }, [user, currentWorkspaceId]);
271
 
272
- const [reviewStarState, setReviewStarState] = useState<ReviewStarState | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
 
 
 
 
 
 
274
 
275
- useEffect(() => {
276
- if (!user || !reviewStarKey) return;
277
- const reviewStarOpacity = starOpacity(reviewStarState);
278
- const reviewEnergyPct = energyPct(reviewStarState);
 
279
 
280
- // ✅ FIX: default doc_type should NOT be Syllabus
281
  const getCurrentDocTypeForChat = (): string => {
282
  if (uploadedFiles.length > 0) {
283
  const last = uploadedFiles[uploadedFiles.length - 1];
284
  return DOC_TYPE_MAP[last.type] || "Other Course Document";
285
  }
286
  return "All"; // ✅ IMPORTANT
 
287
 
 
 
 
288
 
 
 
289
 
 
290
 
 
 
 
 
291
 
292
-
293
-
294
-
295
-
296
-
297
-
298
-
299
-
300
-
301
-
302
-
 
 
303
  };
304
 
 
 
 
305
  const handleSendMessage = async (content: string) => {
306
- ? content
307
- : `📎 Sent ${fileNames.length} file(s)\n${fileNames.map((n) => `- ${n}`).join("\n")}`;
 
 
 
 
 
 
308
 
309
- // ✅ snapshot attachments at send-time
310
  const attachmentsSnapshot: MessageAttachment[] = uploadedFiles.map((uf) => {
311
  const lower = uf.file.name.toLowerCase();
312
  const kind: MessageAttachmentKind =
313
- else if (chatMode === "review") setReviewMessages((prev) => [...prev, userMessage]);
314
- else setQuizMessages((prev) => [...prev, userMessage]);
315
-
316
-
317
-
318
- if (chatMode === "quiz") {
319
- setIsTyping(true);
320
-
321
- try {
322
- const docType = getCurrentDocTypeForChat();
323
 
324
- const r: any = await apiChat({
325
- user_id: user.email,
326
- message: effectiveContent,
327
- learning_mode: "quiz",
328
- language_preference: mapLanguagePref(language),
329
- doc_type: docType,
330
- course_id: backendCourseId, // NEW
331
- } as any);
332
-
333
- const normalizeRefs = (raw: any): string[] => {
334
- const arr = Array.isArray(raw) ? raw : [];
335
- return arr
336
- .map((x) => {
337
- if (typeof x === "string") {
338
- const s = x.trim();
339
- return s ? s : null;
340
- }
341
- const a = x?.source_file ? String(x.source_file) : "";
342
- const b = x?.section ? String(x.section) : "";
343
- const s = `${a}${a && b ? " — " : ""}${b}`.trim();
344
- return s || null;
345
- })
346
- .filter(Boolean) as string[];
347
- };
348
-
349
- const refs = normalizeRefs((r as any).refs ?? (r as any).references);
350
 
351
- role: "assistant",
352
- content: r.reply || "",
353
- timestamp: new Date(),
354
- references: refs, // ✅ allow []
355
- sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
356
- showNextButton: false,
357
- };
358
- role: "assistant",
359
- content: "Sorry — quiz request failed. Please try again.",
360
- timestamp: new Date(),
361
 
362
- sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
363
- };
364
 
365
  try {
366
  const docType = getCurrentDocTypeForChat();
@@ -368,30 +559,27 @@ function profileStorageKey(email: string) {
368
  const r: any = await apiChat({
369
  user_id: user.email,
370
  message: effectiveContent,
371
- learning_mode: learningMode,
372
  language_preference: mapLanguagePref(language),
373
  doc_type: docType,
374
- course_id: backendCourseId, // ✅ NEW
375
  } as any);
376
 
377
- const refs = (r.refs || [])
378
- .map((x: any) => {
379
- const a = x?.source_file ? String(x.source_file) : "";
380
- const b = x?.section ? String(x.section) : "";
381
- const s = `${a}${a && b ? " — " : ""}${b}`.trim();
382
- return s || null;
383
- })
384
- .filter(Boolean) as string[];
385
 
386
  const assistantMessage: Message = {
387
  id: (Date.now() + 1).toString(),
388
  role: "assistant",
389
- content: r.reply || "",
390
  timestamp: new Date(),
391
- references: refs, // ✅ allow []
392
- sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
393
  };
394
 
 
 
 
 
395
  try {
396
  const ml = await apiMemoryline(user.email);
397
  setMemoryProgress(Math.round((ml.progress_pct ?? 0) * 100));
@@ -399,55 +587,30 @@ function profileStorageKey(email: string) {
399
  // ignore
400
  }
401
  } catch (e: any) {
402
- setIsTyping(false);
403
  toast.error(e?.message || "Chat failed");
 
 
404
  role: "assistant",
405
  content: "Sorry — chat request failed. Please try again.",
406
  timestamp: new Date(),
407
-
408
- sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
409
  };
410
 
411
- setQuizMessages((prev) => [...prev, userMessage]);
412
- setIsTyping(true);
413
-
414
-
415
-
416
- try {
417
- const docType = getCurrentDocTypeForChat();
418
- const r: any = await apiChat({
419
- user_id: user.email,
420
- message: prompt,
421
- learning_mode: "quiz",
422
- language_preference: mapLanguagePref(language),
423
- doc_type: docType,
424
- course_id: backendCourseId, // ✅ NEW
425
- } as any);
426
-
427
- const refs = (r.refs || [])
428
- .map((x: any) => {
429
- const a = x?.source_file ? String(x.source_file) : "";
430
- const b = x?.section ? String(x.section) : "";
431
- const s = `${a}${a && b ? " — " : ""}${b}`.trim();
432
- return s || null;
433
- })
434
- .filter(Boolean) as string[];
435
-
436
- const assistantMessage: Message = {
437
- id: (Date.now() + 1).toString(),
438
- role: "assistant",
439
- content: "Sorry — quiz request failed. Please try again.",
440
- timestamp: new Date(),
441
-
442
- sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
443
- };
444
 
 
 
 
 
445
  if (!user) return;
446
-
447
  setIsTyping(true);
448
 
449
-
450
-
451
  try {
452
  const docType = getCurrentDocTypeForChat();
453
 
@@ -456,46 +619,49 @@ function profileStorageKey(email: string) {
456
  language_preference: mapLanguagePref(language),
457
  doc_type: docType,
458
  learning_mode: "quiz",
459
- course_id: backendCourseId, // ✅ NEW
460
  } as any);
461
 
462
- const refs = (r.refs || [])
463
- .map((x: any) => {
464
- const a = x?.source_file ? String(x.source_file) : "";
465
- const b = x?.section ? String(x.section) : "";
466
- const s = `${a}${a && b ? " — " : ""}${b}`.trim();
467
- return s || null;
468
- })
469
- .filter(Boolean) as string[];
470
 
471
  const assistantMessage: Message = {
472
  id: Date.now().toString(),
473
  role: "assistant",
474
- content: "Sorry — could not start the quiz. Please try again.",
475
  timestamp: new Date(),
476
-
477
- sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
478
  };
479
 
480
- }
481
- };
482
-
483
- // =========================
484
- // File Upload (FIXED)
485
- // =========================
486
-
487
- const handleFileUpload = async (input: File[] | FileList | null | undefined) => {
488
- const files = Array.isArray(input) ? input : input ? Array.from(input) : [];
489
- if (!files.length) return;
490
-
491
- const newFiles: UploadedFile[] = files.map((file) => ({ file, type: "other" as FileType }));
492
-
493
- setUploadedFiles((prev) => [...prev, ...newFiles]);
494
 
495
- if (!user) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  }
497
  };
498
 
 
 
 
499
  const isCurrentChatSaved = (): SavedChat | null => {
500
  if (messages.length <= 1) return null;
501
 
@@ -506,11 +672,7 @@ function profileStorageKey(email: string) {
506
 
507
  return chat.messages.every((savedMsg, idx) => {
508
  const currentMsg = messages[idx];
509
- return (
510
- savedMsg.id === currentMsg.id &&
511
- savedMsg.role === currentMsg.role &&
512
- savedMsg.content === currentMsg.content
513
- );
514
  });
515
  }) || null
516
  );
@@ -539,9 +701,7 @@ function profileStorageKey(email: string) {
539
  return;
540
  }
541
 
542
- const title = `Chat - ${
543
- chatMode === "ask" ? "Ask" : chatMode === "review" ? "Review" : "Quiz"
544
- } - ${new Date().toLocaleDateString()}`;
545
 
546
  const newChat: SavedChat = {
547
  id: Date.now().toString(),
@@ -572,36 +732,6 @@ function profileStorageKey(email: string) {
572
  const handleClearConversation = (shouldSave: boolean = false) => {
573
  if (shouldSave) handleSaveChat();
574
 
575
- const initialMessages: Record<ChatMode, Message[]> = {
576
- ask: [
577
- {
578
- id: "1",
579
- role: "assistant",
580
- content:
581
- "👋 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!",
582
- timestamp: new Date(),
583
- },
584
- ],
585
- review: [
586
- {
587
- id: "review-1",
588
- role: "assistant",
589
- content:
590
- "📚 Welcome to Review mode! I'll help you review and consolidate your learning. Let's go through what you've learned!",
591
- timestamp: new Date(),
592
- },
593
- ],
594
- quiz: [
595
- {
596
- id: "quiz-1",
597
- role: "assistant",
598
- content:
599
- "🎯 Welcome to Quiz mode! I'll test your understanding with personalized questions based on your learning history. Ready to start?",
600
- timestamp: new Date(),
601
- },
602
- ],
603
- };
604
-
605
  if (chatMode === "ask") setAskMessages(initialMessages.ask);
606
  else if (chatMode === "review") setReviewMessages(initialMessages.review);
607
  else {
@@ -610,6 +740,9 @@ function profileStorageKey(email: string) {
610
  }
611
  };
612
 
 
 
 
613
  const handleSave = (
614
  content: string,
615
  type: "export" | "quiz" | "summary",
@@ -621,13 +754,7 @@ function profileStorageKey(email: string) {
621
 
622
  if (saveAsChat && type !== "summary") {
623
  const chatMessages: Message[] = [
624
- {
625
- id: "1",
626
- role: "assistant",
627
- content:
628
- "👋 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!",
629
- timestamp: new Date(),
630
- },
631
  { id: Date.now().toString(), role: "assistant", content, timestamp: new Date() },
632
  ];
633
 
@@ -677,128 +804,48 @@ function profileStorageKey(email: string) {
677
  toast.success("Removed from saved items");
678
  };
679
 
680
- const handleCreateWorkspace = (payload: { name: string; category: "course" | "personal"; courseId?: string; invites: string[] }) => {
681
- const id = `group-${Date.now()}`;
682
- const avatar = `https://api.dicebear.com/7.x/shapes/svg?seed=${encodeURIComponent(payload.name)}`;
683
-
684
- const creatorMember: GroupMember = user
685
- ? {
686
- id: user.email,
687
- name: user.name,
688
- email: user.email,
689
- avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
690
- }
691
- : { id: "unknown", name: "Unknown", email: "unknown@email.com" };
692
-
693
- const members: GroupMember[] = [
694
- creatorMember,
695
- ...payload.invites.map((email) => ({
696
- id: email,
697
- name: email.split("@")[0] || email,
698
- email,
699
- })),
700
- ];
701
-
702
- let newWorkspace: Workspace;
703
-
704
- if (payload.category === "course") {
705
- const courseInfo = availableCourses.find((c) => c.id === payload.courseId);
706
- newWorkspace = {
707
- id,
708
- name: payload.name,
709
- type: "group",
710
- avatar,
711
- members,
712
- category: "course",
713
- courseName: courseInfo?.name || "Untitled Course",
714
- courseInfo,
715
- };
716
- } else {
717
- newWorkspace = {
718
- id,
719
- name: payload.name,
720
- type: "group",
721
- avatar,
722
- members,
723
- category: "personal",
724
- isEditable: true,
725
- };
726
- }
727
-
728
- setWorkspaces((prev) => [...prev, newWorkspace]);
729
- setCurrentWorkspaceId(id);
730
-
731
- if (payload.category === "course" && payload.courseId) {
732
- setCurrentCourseId(payload.courseId);
733
- }
734
-
735
- toast.success("New group workspace created");
736
- };
737
-
738
- const handleReviewClick = () => {
739
- setChatMode("review");
740
- setShowReviewBanner(false);
741
- localStorage.setItem("reviewBannerDismissed", "true");
742
- };
743
-
744
- const handleDismissReviewBanner = () => {
745
- setShowReviewBanner(false);
746
- localStorage.setItem("reviewBannerDismissed", "true");
747
- };
748
-
749
- // ✅ login: hydrate profile and only show onboarding if not completed
750
- const handleLogin = (newUser: User) => {
751
- const hydrated = hydrateUserFromStorage(newUser);
752
- setUser(hydrated);
753
  if (!user) return <LoginScreen onLogin={handleLogin} />;
754
 
755
- if (showOnboarding && user)
756
  return <Onboarding user={user} onComplete={handleOnboardingComplete} onSkip={handleOnboardingSkip} />;
 
757
 
758
-
759
-
760
-
761
-
762
-
763
-
764
-
765
  return (
766
  <div className="fixed inset-0 w-full bg-background overflow-hidden">
767
  <Toaster />
768
- workspaces={workspaces}
769
- currentWorkspace={currentWorkspace}
770
- onWorkspaceChange={setCurrentWorkspaceId}
771
- onCreateWorkspace={handleCreateWorkspace}
772
- onLogout={() => setUser(null)}
773
- availableCourses={availableCourses}
774
- onUserUpdate={handleUserSave}
775
- </div>
 
 
 
776
 
777
  {showProfileEditor && user && (
778
  <ProfileEditor user={user} onSave={handleUserSave} onClose={() => setShowProfileEditor(false)} />
779
-
780
-
781
-
782
-
783
  )}
784
 
785
  {showReviewBanner && (
786
  <div className="flex-shrink-0 w-full bg-background border-b border-border relative z-50">
787
- <ReviewBanner onReview={handleReviewClick} onDismiss={handleDismissReviewBanner} />
788
-
789
-
790
-
791
-
792
-
793
-
794
-
795
-
796
-
797
-
798
  </div>
799
  )}
800
 
801
  <div className="flex flex-1 min-h-0 min-w-0 overflow-hidden relative">
 
802
  {!leftPanelVisible && (
803
  <Button
804
  variant="secondary"
@@ -812,12 +859,14 @@ function profileStorageKey(email: string) {
812
  </Button>
813
  )}
814
 
 
815
  {leftSidebarOpen && (
816
  <div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setLeftSidebarOpen(false)} />
817
  )}
818
 
 
819
  {leftPanelVisible ? (
820
- <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">
821
  <Button
822
  variant="secondary"
823
  size="icon"
@@ -829,14 +878,14 @@ function profileStorageKey(email: string) {
829
  <ChevronLeft className="h-3 w-3" />
830
  </Button>
831
 
832
- <div className="flex-1 min-h-0 min-w-0 overflow-hidden">
833
  <LeftSidebar
834
  learningMode={learningMode}
835
  language={language}
836
  onLearningModeChange={setLearningMode}
837
  onLanguageChange={setLanguage}
838
- spaceType={sidebarSpaceType}
839
- groupMembers={sidebarGroupMembers}
840
  user={user}
841
  onLogin={setUser}
842
  onLogout={() => setUser(null)}
@@ -851,7 +900,7 @@ function profileStorageKey(email: string) {
851
  onDeleteSavedChat={handleDeleteSavedChat}
852
  onRenameSavedChat={handleRenameSavedChat}
853
  currentWorkspaceId={currentWorkspaceId}
854
- workspaces={sidebarWorkspaces}
855
  selectedCourse={currentCourseId}
856
  availableCourses={availableCourses}
857
  />
@@ -859,6 +908,7 @@ function profileStorageKey(email: string) {
859
  </aside>
860
  ) : null}
861
 
 
862
  <aside
863
  className={[
864
  "fixed lg:hidden z-50",
@@ -882,8 +932,8 @@ function profileStorageKey(email: string) {
882
  language={language}
883
  onLearningModeChange={setLearningMode}
884
  onLanguageChange={setLanguage}
885
- spaceType={sidebarSpaceType}
886
- groupMembers={sidebarGroupMembers}
887
  user={user}
888
  onLogin={setUser}
889
  onLogout={() => setUser(null)}
@@ -898,21 +948,22 @@ function profileStorageKey(email: string) {
898
  onDeleteSavedChat={handleDeleteSavedChat}
899
  onRenameSavedChat={handleRenameSavedChat}
900
  currentWorkspaceId={currentWorkspaceId}
901
- workspaces={sidebarWorkspaces}
902
  selectedCourse={currentCourseId}
903
  availableCourses={availableCourses}
904
  />
905
  </div>
906
  </aside>
907
 
 
908
  <main className="flex flex-1 min-w-0 min-h-0 overflow-hidden flex-col">
909
  <div className="flex-1 min-h-0 min-w-0 overflow-hidden">
910
  <ChatArea
911
  isLoggedIn={!!user}
912
  learningMode={learningMode}
 
913
  onClearConversation={() => setShowClearDialog(true)}
914
  onSaveChat={handleSaveChat}
915
- onLearningModeChange={setLearningMode}
916
  spaceType={spaceType}
917
  chatMode={chatMode}
918
  quizState={quizState}
@@ -933,10 +984,21 @@ function profileStorageKey(email: string) {
933
  currentCourseId={currentCourseId}
934
  onCourseChange={handleCourseChange}
935
  showReviewBanner={showReviewBanner}
936
- onReviewActivity={handleReviewActivity}
937
- currentUserId={user?.email}
938
- docType={getCurrentDocTypeForChat()} // ✅ changed from "Syllabus"
939
- // ✅ bio is still allowed to be updated by chat/Clare
940
  onProfileBioUpdate={(bio) => updateUser({ bio })}
 
 
 
 
 
 
941
  />
942
- </div>
 
 
 
 
 
 
 
1
+ // web/src/App.tsx
2
+ import React, { useEffect, useMemo, useRef, useState } from "react";
3
  import { toast } from "sonner";
4
+ import { Toaster } from "./components/ui/sonner";
5
+ import { Button } from "./components/ui/button";
6
+
7
+ import { Header } from "./components/Header";
8
+ import { ChatArea } from "./components/ChatArea";
9
+ import { LoginScreen } from "./components/LoginScreen";
10
+ import { ProfileEditor } from "./components/ProfileEditor";
11
+ import { ReviewBanner } from "./components/ReviewBanner";
12
+ import { Onboarding } from "./components/Onboarding";
13
+
14
  import { LeftSidebar } from "./components/sidebar/LeftSidebar";
15
 
16
+ import { X, ChevronLeft, ChevronRight } from "lucide-react";
17
+
18
  // backend API bindings
19
  import { apiChat, apiUpload, apiMemoryline, apiQuizStart } from "./lib/api";
20
 
21
+ // =====================
22
+ // Types (keep compatible)
23
+ // =====================
24
+
25
+ export type LearningMode = "general" | "concept" | "socratic" | "exam" | "assignment" | "summary";
26
+ export type Language = "auto" | "en" | "zh";
27
+ export type ChatMode = "ask" | "review" | "quiz";
28
+
29
+ export type SpaceType = "individual" | "group";
30
+
31
+ export type FileType = "syllabus" | "lecture-slides" | "paper" | "other";
32
+
33
+ export type MessageAttachmentKind = "pdf" | "doc" | "ppt" | "image" | "file";
34
+
35
+ export interface MessageAttachment {
36
  name: string;
37
  kind: MessageAttachmentKind;
38
  size: number;
39
+ fileType?: FileType;
40
+ }
41
+
42
+ export interface GroupMember {
43
+ id: string;
44
+ name: string;
45
+ email: string;
46
+ avatar?: string;
47
+ isAI?: boolean;
48
+ }
49
+
50
+ export interface CourseInfo {
51
+ id: string;
52
+ name: string;
53
+ instructor?: { name: string; email: string };
54
+ teachingAssistant?: { name: string; email: string };
55
+ }
56
+
57
+ export interface Workspace {
58
+ id: string;
59
+ name: string;
60
+ type: SpaceType;
61
+ avatar?: string;
62
+ members?: GroupMember[];
63
+ // optional course binding
64
+ category?: "course" | "personal";
65
+ courseName?: string;
66
+ courseInfo?: CourseInfo;
67
+ isEditable?: boolean;
68
+ }
69
+
70
+ export interface SavedItem {
71
+ id: string;
72
+ title: string;
73
+ content: string;
74
+ type: "export" | "quiz" | "summary";
75
+ timestamp: Date;
76
+ isSaved: boolean;
77
+ format: "pdf" | "text";
78
+ workspaceId?: string;
79
+ }
80
+
81
+ export interface SavedChat {
82
+ id: string;
83
+ title: string;
84
+ messages: Message[];
85
+ chatMode: ChatMode;
86
+ timestamp: Date;
87
  }
88
 
89
  export interface Message {
90
+ id: string;
91
+ role: "user" | "assistant";
92
+ content: string;
93
+ timestamp: Date;
94
+
95
+ // ✅ IMPORTANT: chat references for RAG citations
96
  references?: string[];
97
+
98
+ // group mode sender
99
  sender?: GroupMember;
100
+
101
+ // quiz UI helpers
102
  showNextButton?: boolean;
103
 
104
+ // files attached with a user message
105
  attachments?: MessageAttachment[];
106
 
107
  questionData?: {
108
  type: "multiple-choice" | "fill-in-blank" | "open-ended";
109
  question: string;
110
+ };
111
+ }
112
+
113
+ export interface UploadedFile {
114
+ file: File;
115
+ type: FileType;
116
  }
117
 
118
  export interface User {
 
119
  name: string;
120
  email: string;
121
 
 
122
  studentId?: string;
123
  department?: string;
124
  yearLevel?: string;
125
  major?: string;
126
+ bio?: string;
127
 
128
+ learningStyle?: string;
129
+ learningPace?: string;
 
130
 
 
131
  avatarUrl?: string;
132
 
 
133
  onboardingCompleted?: boolean;
134
  }
135
 
136
+ // =====================
137
+ // Helpers
138
+ // =====================
 
 
 
139
 
140
+ const DOC_TYPE_MAP: Record<FileType, string> = {
141
+ syllabus: "Syllabus",
142
+ "lecture-slides": "Lecture Slides / PPT",
143
+ paper: "Literature Review / Paper",
144
  other: "Other Course Document",
145
  };
146
 
147
+ // ✅ NEW: UI courseId -> backend course_id
148
  const BACKEND_COURSE_ID_MAP: Record<string, string> = {
149
  course1: "course_ist345",
150
  course2: "course_ist345",
 
152
  course4: "course_ist345",
153
  };
154
 
155
+ function mapLanguagePref(_lang: Language): string {
156
+ // 你当前后端 detect_language 会再处理;这里统一给 Auto
157
  return "Auto";
158
  }
159
 
 
160
  function savedChatsStorageKey(email: string) {
161
  return `saved_chats::${email}`;
162
  }
 
 
163
 
 
164
  function profileStorageKey(email: string) {
165
  return `user_profile::${email}`;
166
  }
167
 
168
+ function parseSavedChats(raw: string | null): SavedChat[] {
169
+ if (!raw) return [];
170
+ try {
171
+ const arr = JSON.parse(raw) as any[];
172
+ if (!Array.isArray(arr)) return [];
173
+ return arr
174
+ .map((x) => ({
175
+ ...x,
176
+ timestamp: x?.timestamp ? new Date(x.timestamp) : new Date(),
177
+ messages: Array.isArray(x?.messages)
178
+ ? x.messages.map((m: any) => ({ ...m, timestamp: m?.timestamp ? new Date(m.timestamp) : new Date() }))
179
+ : [],
180
+ }))
181
+ .filter(Boolean) as SavedChat[];
182
+ } catch {
183
+ return [];
184
+ }
185
+ }
186
+
187
+ function normalizeRefsToStrings(raw: any): string[] {
188
+ const arr = Array.isArray(raw) ? raw : [];
189
+ return arr
190
+ .map((x) => {
191
+ if (typeof x === "string") {
192
+ const s = x.trim();
193
+ return s ? s : null;
194
+ }
195
+ const a = x?.source_file ? String(x.source_file) : "";
196
+ const b = x?.section ? String(x.section) : "";
197
+ const s = `${a}${a && b ? " — " : ""}${b}`.trim();
198
+ return s || null;
199
+ })
200
+ .filter(Boolean) as string[];
201
+ }
202
+
203
+ // =====================
204
+ // App
205
+ // =====================
206
+
207
+ export default function App() {
208
+ // -------------------------
209
+ // Auth / Profile
210
+ // -------------------------
211
  const [user, setUser] = useState<User | null>(null);
212
+ const [showProfileEditor, setShowProfileEditor] = useState(false);
213
+
214
+ const hydrateUserFromStorage = (u: User): User => {
215
+ try {
216
+ const raw = localStorage.getItem(profileStorageKey(u.email));
217
+ if (!raw) return u;
218
+ const prev = JSON.parse(raw) as Partial<User>;
219
+ return {
220
+ ...u,
221
+ ...prev,
222
+ onboardingCompleted: prev.onboardingCompleted ?? u.onboardingCompleted,
223
+ };
224
+ } catch {
225
+ return u;
226
+ }
227
+ };
228
 
 
229
  const updateUser = (patch: Partial<User>) => {
230
  setUser((prev) => (prev ? { ...prev, ...patch } : prev));
231
  };
232
+
233
+ const handleUserSave = (next: User) => {
234
+ setUser((prev) => {
235
+ if (!prev) return next;
236
  return {
237
  ...prev,
238
  ...next,
239
+ onboardingCompleted: next.onboardingCompleted ?? prev.onboardingCompleted,
 
240
  };
241
  });
242
  };
243
 
244
+ // persist profile per email
245
  useEffect(() => {
246
  if (!user?.email) return;
247
  try {
 
252
  }, [user]);
253
 
254
  // -------------------------
255
+ // Onboarding
256
+ // -------------------------
257
+ const [showOnboarding, setShowOnboarding] = useState(false);
258
+
259
+ useEffect(() => {
260
+ if (!user) return;
261
+ setShowOnboarding(!user.onboardingCompleted);
262
+ }, [user]);
263
+
264
+ const handleOnboardingComplete = (updatedUser: User) => {
265
+ handleUserSave({ ...updatedUser, onboardingCompleted: true });
266
+ setShowOnboarding(false);
267
+ };
268
+
269
+ const handleOnboardingSkip = () => {
270
+ setShowOnboarding(false);
271
+ };
272
+
273
+ const handleLogin = (newUser: User) => {
274
+ const hydrated = hydrateUserFromStorage(newUser);
275
+ setUser(hydrated);
276
+ };
277
+
278
+ // -------------------------
279
+ // Workspaces (minimal but compatible)
280
+ // -------------------------
281
+ const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
282
+ const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>("individual");
283
+
284
+ useEffect(() => {
285
+ if (!user) return;
286
+
287
+ const userAvatar = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`;
288
+ const mySpace: Workspace = { id: "individual", name: "My Space", type: "individual", avatar: userAvatar };
289
+
290
+ setWorkspaces([mySpace]);
291
+ setCurrentWorkspaceId("individual");
292
+ }, [user?.email]);
293
+
294
+ const currentWorkspace = useMemo(() => {
295
+ return workspaces.find((w) => w.id === currentWorkspaceId) || workspaces[0];
296
+ }, [workspaces, currentWorkspaceId]);
297
+
298
+ const spaceType: SpaceType = currentWorkspace?.type || "individual";
299
+
300
+ // -------------------------
301
+ // Courses
302
  // -------------------------
303
  const MYSPACE_COURSE_KEY = "myspace_selected_course";
304
 
 
306
  return localStorage.getItem(MYSPACE_COURSE_KEY) || "course1";
307
  });
308
 
 
309
  const backendCourseId = useMemo(() => {
310
  return BACKEND_COURSE_ID_MAP[currentCourseId] || "course_ist345";
311
  }, [currentCourseId]);
 
314
  {
315
  id: "course1",
316
  name: "Introduction to AI",
317
+ instructor: { name: "Dr. Sarah Johnson", email: "sarah.johnson@university.edu" },
318
+ teachingAssistant: { name: "Michael Chen", email: "michael.chen@university.edu" },
 
 
 
 
 
 
319
  },
320
  {
321
  id: "course2",
322
  name: "Machine Learning",
323
  instructor: { name: "Prof. David Lee", email: "david.lee@university.edu" },
324
+ teachingAssistant: { name: "Emily Zhang", email: "emily.zhang@university.edu" },
 
 
 
325
  },
326
+ { id: "course3", name: "NLP Systems" },
327
+ { id: "course4", name: "AI Engineering" },
328
+ ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
 
330
  const handleCourseChange = (nextCourseId: string) => {
331
  setCurrentCourseId(nextCourseId);
 
336
  }
337
  };
338
 
339
+ // -------------------------
340
+ // Sidebar state
341
+ // -------------------------
342
+ const [leftPanelVisible, setLeftPanelVisible] = useState<boolean>(true);
343
+ const [leftSidebarOpen, setLeftSidebarOpen] = useState<boolean>(false);
344
+
345
+ // -------------------------
346
+ // Modes / UI state
347
+ // -------------------------
348
+ const [learningMode, setLearningMode] = useState<LearningMode>("concept");
349
+ const [language, setLanguage] = useState<Language>("auto");
350
+ const [chatMode, setChatMode] = useState<ChatMode>("ask");
351
 
352
+ const [showReviewBanner, setShowReviewBanner] = useState<boolean>(false);
353
 
354
+ // -------------------------
355
+ // Messages
356
+ // -------------------------
357
+ const initialMessages: Record<ChatMode, Message[]> = useMemo(
358
+ () => ({
359
+ ask: [
360
+ {
361
+ id: "1",
362
+ role: "assistant",
363
+ content:
364
+ "👋 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!",
365
+ timestamp: new Date(),
366
+ },
367
+ ],
368
+ review: [
369
+ {
370
+ id: "review-1",
371
+ role: "assistant",
372
+ content: "📚 Welcome to Review mode! I'll help you review and consolidate your learning. Let's go through what you've learned!",
373
+ timestamp: new Date(),
374
+ },
375
+ ],
376
+ quiz: [
377
+ {
378
+ id: "quiz-1",
379
+ role: "assistant",
380
+ content: "🎯 Welcome to Quiz mode! I'll test your understanding with personalized questions based on your learning history. Ready to start?",
381
+ timestamp: new Date(),
382
+ },
383
+ ],
384
+ }),
385
+ []
386
+ );
387
 
388
+ const [askMessages, setAskMessages] = useState<Message[]>(initialMessages.ask);
389
+ const [reviewMessages, setReviewMessages] = useState<Message[]>(initialMessages.review);
390
+ const [quizMessages, setQuizMessages] = useState<Message[]>(initialMessages.quiz);
391
 
392
+ const messages = chatMode === "ask" ? askMessages : chatMode === "review" ? reviewMessages : quizMessages;
 
 
 
393
 
394
+ // quiz state kept minimal for ChatArea compatibility
395
+ const [quizState, setQuizState] = useState<{ currentQuestion: number; waitingForAnswer: boolean; showNextButton: boolean }>({
396
+ currentQuestion: 0,
397
+ waitingForAnswer: false,
398
+ showNextButton: false,
399
+ });
400
 
401
+ const [isTyping, setIsTyping] = useState(false);
402
+ const [showClearDialog, setShowClearDialog] = useState(false);
403
 
404
+ // -------------------------
405
+ // Saved chats/items
406
+ // -------------------------
407
+ const [savedChats, setSavedChats] = useState<SavedChat[]>([]);
408
+ const [savedItems, setSavedItems] = useState<SavedItem[]>([]);
409
+ const [recentlySavedId, setRecentlySavedId] = useState<string | null>(null);
410
 
411
+ useEffect(() => {
412
+ if (!user?.email) return;
413
+ try {
414
+ const raw = localStorage.getItem(savedChatsStorageKey(user.email));
415
+ setSavedChats(parseSavedChats(raw));
416
+ } catch {
417
+ setSavedChats([]);
418
+ }
419
+ }, [user?.email]);
420
 
421
+ useEffect(() => {
422
+ if (!user?.email) return;
 
423
  try {
424
+ localStorage.setItem(savedChatsStorageKey(user.email), JSON.stringify(savedChats));
 
425
  } catch {
426
  // ignore
427
  }
428
+ }, [savedChats, user?.email]);
429
+
430
+ // -------------------------
431
+ // Memoryline
432
+ // -------------------------
433
+ const [memoryProgress, setMemoryProgress] = useState<number>(0);
434
 
435
  useEffect(() => {
436
+ if (!user) return;
437
+ (async () => {
438
+ try {
439
  const r = await apiMemoryline(user.email);
440
  const pct = Math.round((r.progress_pct ?? 0) * 100);
441
  setMemoryProgress(pct);
 
443
  // silent
444
  }
445
  })();
446
+ }, [user?.email]);
 
 
 
 
 
 
 
 
447
 
448
+ // -------------------------
449
+ // Group members (for sender display)
450
+ // -------------------------
451
+ const clareMember: GroupMember = useMemo(
452
+ () => ({ id: "clare", name: "Clare", email: "clare@ai", isAI: true }),
453
+ []
454
+ );
455
+
456
+ const mySpaceUserMember: GroupMember | null = useMemo(() => {
457
+ if (!user) return null;
458
+ return {
459
+ id: user.email,
460
+ name: user.name,
461
+ email: user.email,
462
+ avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
463
+ };
464
+ }, [user?.email, user?.name]);
465
 
466
+ const groupMembers: GroupMember[] = useMemo(() => {
467
+ const base: GroupMember[] = [];
468
+ if (mySpaceUserMember) base.push(mySpaceUserMember);
469
+ base.push(clareMember);
470
+ return base;
471
+ }, [mySpaceUserMember, clareMember]);
472
 
473
+ // -------------------------
474
+ // Uploads
475
+ // -------------------------
476
+ const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
477
+ const uploadedFingerprintsRef = useRef<Set<string>>(new Set());
478
 
 
479
  const getCurrentDocTypeForChat = (): string => {
480
  if (uploadedFiles.length > 0) {
481
  const last = uploadedFiles[uploadedFiles.length - 1];
482
  return DOC_TYPE_MAP[last.type] || "Other Course Document";
483
  }
484
  return "All"; // ✅ IMPORTANT
485
+ };
486
 
487
+ const handleFileUpload = async (input: File[] | FileList | null | undefined) => {
488
+ const files = Array.isArray(input) ? input : input ? Array.from(input) : [];
489
+ if (!files.length) return;
490
 
491
+ const newFiles: UploadedFile[] = files.map((file) => ({ file, type: "other" as FileType }));
492
+ setUploadedFiles((prev) => [...prev, ...newFiles]);
493
 
494
+ if (!user) return;
495
 
496
+ for (const f of files) {
497
+ const fp = `${f.name}::${f.size}::${f.lastModified}`;
498
+ if (uploadedFingerprintsRef.current.has(fp)) continue;
499
+ uploadedFingerprintsRef.current.add(fp);
500
 
501
+ try {
502
+ await apiUpload({
503
+ user_id: user.email,
504
+ doc_type: getCurrentDocTypeForChat(),
505
+ file: f,
506
+ // @ts-expect-error api.ts accepts course_id in your updated version
507
+ course_id: backendCourseId,
508
+ });
509
+ toast.success(`Uploaded: ${f.name}`);
510
+ } catch (e: any) {
511
+ toast.error(e?.message || `Upload failed: ${f.name}`);
512
+ }
513
+ }
514
  };
515
 
516
+ // -------------------------
517
+ // Chat send
518
+ // -------------------------
519
  const handleSendMessage = async (content: string) => {
520
+ if (!user) return;
521
+
522
+ const trimmed = (content || "").trim();
523
+ const fileNames = uploadedFiles.map((f) => f.file.name);
524
+ const effectiveContent =
525
+ trimmed.length > 0 ? trimmed : fileNames.length ? `📎 Sent ${fileNames.length} file(s)\n${fileNames.map((n) => `- ${n}`).join("\n")}` : "";
526
+
527
+ if (!effectiveContent) return;
528
 
 
529
  const attachmentsSnapshot: MessageAttachment[] = uploadedFiles.map((uf) => {
530
  const lower = uf.file.name.toLowerCase();
531
  const kind: MessageAttachmentKind =
532
+ lower.endsWith(".pdf") ? "pdf" : lower.endsWith(".ppt") || lower.endsWith(".pptx") ? "ppt" : lower.endsWith(".doc") || lower.endsWith(".docx") ? "doc" : lower.match(/\.(png|jpg|jpeg|webp)$/) ? "image" : "file";
533
+ return {
534
+ name: uf.file.name,
535
+ kind,
536
+ size: uf.file.size,
537
+ fileType: uf.type,
538
+ };
539
+ });
 
 
540
 
541
+ const userMessage: Message = {
542
+ id: Date.now().toString(),
543
+ role: "user",
544
+ content: effectiveContent,
545
+ timestamp: new Date(),
546
+ attachments: attachmentsSnapshot.length ? attachmentsSnapshot : undefined,
547
+ sender: spaceType === "group" ? mySpaceUserMember ?? undefined : undefined,
548
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
 
550
+ if (chatMode === "ask") setAskMessages((prev) => [...prev, userMessage]);
551
+ else if (chatMode === "review") setReviewMessages((prev) => [...prev, userMessage]);
552
+ else setQuizMessages((prev) => [...prev, userMessage]);
 
 
 
 
 
 
 
553
 
554
+ setIsTyping(true);
 
555
 
556
  try {
557
  const docType = getCurrentDocTypeForChat();
 
559
  const r: any = await apiChat({
560
  user_id: user.email,
561
  message: effectiveContent,
562
+ learning_mode: chatMode === "quiz" ? "quiz" : learningMode,
563
  language_preference: mapLanguagePref(language),
564
  doc_type: docType,
565
+ course_id: backendCourseId,
566
  } as any);
567
 
568
+ const refs = normalizeRefsToStrings(r?.refs ?? r?.references);
 
 
 
 
 
 
 
569
 
570
  const assistantMessage: Message = {
571
  id: (Date.now() + 1).toString(),
572
  role: "assistant",
573
+ content: r?.reply || "",
574
  timestamp: new Date(),
575
+ references: refs,
576
+ sender: spaceType === "group" ? clareMember : undefined,
577
  };
578
 
579
+ if (chatMode === "ask") setAskMessages((prev) => [...prev, assistantMessage]);
580
+ else if (chatMode === "review") setReviewMessages((prev) => [...prev, assistantMessage]);
581
+ else setQuizMessages((prev) => [...prev, assistantMessage]);
582
+
583
  try {
584
  const ml = await apiMemoryline(user.email);
585
  setMemoryProgress(Math.round((ml.progress_pct ?? 0) * 100));
 
587
  // ignore
588
  }
589
  } catch (e: any) {
 
590
  toast.error(e?.message || "Chat failed");
591
+ const failMessage: Message = {
592
+ id: (Date.now() + 1).toString(),
593
  role: "assistant",
594
  content: "Sorry — chat request failed. Please try again.",
595
  timestamp: new Date(),
596
+ sender: spaceType === "group" ? clareMember : undefined,
 
597
  };
598
 
599
+ if (chatMode === "ask") setAskMessages((prev) => [...prev, failMessage]);
600
+ else if (chatMode === "review") setReviewMessages((prev) => [...prev, failMessage]);
601
+ else setQuizMessages((prev) => [...prev, failMessage]);
602
+ } finally {
603
+ setIsTyping(false);
604
+ }
605
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
606
 
607
+ // -------------------------
608
+ // Quiz start
609
+ // -------------------------
610
+ const handleStartQuiz = async () => {
611
  if (!user) return;
 
612
  setIsTyping(true);
613
 
 
 
614
  try {
615
  const docType = getCurrentDocTypeForChat();
616
 
 
619
  language_preference: mapLanguagePref(language),
620
  doc_type: docType,
621
  learning_mode: "quiz",
622
+ course_id: backendCourseId,
623
  } as any);
624
 
625
+ const refs = normalizeRefsToStrings(r?.refs ?? r?.references);
 
 
 
 
 
 
 
626
 
627
  const assistantMessage: Message = {
628
  id: Date.now().toString(),
629
  role: "assistant",
630
+ content: r?.reply || "",
631
  timestamp: new Date(),
632
+ references: refs,
633
+ sender: spaceType === "group" ? clareMember : undefined,
634
  };
635
 
636
+ setChatMode("quiz");
637
+ setQuizMessages((prev) => {
638
+ // ensure welcome exists
639
+ const hasWelcome = prev.some((m) => m.id === "quiz-1" && m.role === "assistant");
640
+ const base = hasWelcome ? prev : [...initialMessages.quiz];
641
+ return [...base, assistantMessage];
642
+ });
 
 
 
 
 
 
 
643
 
644
+ setQuizState({ currentQuestion: 0, waitingForAnswer: false, showNextButton: false });
645
+ } catch (e: any) {
646
+ toast.error(e?.message || "Could not start quiz");
647
+ setQuizMessages((prev) => [
648
+ ...prev,
649
+ {
650
+ id: Date.now().toString(),
651
+ role: "assistant",
652
+ content: "Sorry — could not start the quiz. Please try again.",
653
+ timestamp: new Date(),
654
+ sender: spaceType === "group" ? clareMember : undefined,
655
+ },
656
+ ]);
657
+ } finally {
658
+ setIsTyping(false);
659
  }
660
  };
661
 
662
+ // -------------------------
663
+ // Save / Load chats
664
+ // -------------------------
665
  const isCurrentChatSaved = (): SavedChat | null => {
666
  if (messages.length <= 1) return null;
667
 
 
672
 
673
  return chat.messages.every((savedMsg, idx) => {
674
  const currentMsg = messages[idx];
675
+ return savedMsg.id === currentMsg.id && savedMsg.role === currentMsg.role && savedMsg.content === currentMsg.content;
 
 
 
 
676
  });
677
  }) || null
678
  );
 
701
  return;
702
  }
703
 
704
+ const title = `Chat - ${chatMode === "ask" ? "Ask" : chatMode === "review" ? "Review" : "Quiz"} - ${new Date().toLocaleDateString()}`;
 
 
705
 
706
  const newChat: SavedChat = {
707
  id: Date.now().toString(),
 
732
  const handleClearConversation = (shouldSave: boolean = false) => {
733
  if (shouldSave) handleSaveChat();
734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  if (chatMode === "ask") setAskMessages(initialMessages.ask);
736
  else if (chatMode === "review") setReviewMessages(initialMessages.review);
737
  else {
 
740
  }
741
  };
742
 
743
+ // -------------------------
744
+ // Saved items (minimal)
745
+ // -------------------------
746
  const handleSave = (
747
  content: string,
748
  type: "export" | "quiz" | "summary",
 
754
 
755
  if (saveAsChat && type !== "summary") {
756
  const chatMessages: Message[] = [
757
+ initialMessages.ask[0],
 
 
 
 
 
 
758
  { id: Date.now().toString(), role: "assistant", content, timestamp: new Date() },
759
  ];
760
 
 
804
  toast.success("Removed from saved items");
805
  };
806
 
807
+ // -------------------------
808
+ // Render guards
809
+ // -------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
810
  if (!user) return <LoginScreen onLogin={handleLogin} />;
811
 
812
+ if (showOnboarding && user) {
813
  return <Onboarding user={user} onComplete={handleOnboardingComplete} onSkip={handleOnboardingSkip} />;
814
+ }
815
 
816
+ // -------------------------
817
+ // Layout (FIX left sidebar)
818
+ // -------------------------
 
 
 
 
819
  return (
820
  <div className="fixed inset-0 w-full bg-background overflow-hidden">
821
  <Toaster />
822
+
823
+ <div className="flex flex-col h-full min-h-0 min-w-0 overflow-hidden">
824
+ <Header
825
+ workspaces={workspaces}
826
+ currentWorkspace={currentWorkspace}
827
+ onWorkspaceChange={setCurrentWorkspaceId}
828
+ onCreateWorkspace={() => {}}
829
+ onLogout={() => setUser(null)}
830
+ availableCourses={availableCourses}
831
+ onUserUpdate={handleUserSave}
832
+ />
833
 
834
  {showProfileEditor && user && (
835
  <ProfileEditor user={user} onSave={handleUserSave} onClose={() => setShowProfileEditor(false)} />
 
 
 
 
836
  )}
837
 
838
  {showReviewBanner && (
839
  <div className="flex-shrink-0 w-full bg-background border-b border-border relative z-50">
840
+ <ReviewBanner
841
+ onReview={() => setChatMode("review")}
842
+ onDismiss={() => setShowReviewBanner(false)}
843
+ />
 
 
 
 
 
 
 
844
  </div>
845
  )}
846
 
847
  <div className="flex flex-1 min-h-0 min-w-0 overflow-hidden relative">
848
+ {/* Desktop: open button when closed */}
849
  {!leftPanelVisible && (
850
  <Button
851
  variant="secondary"
 
859
  </Button>
860
  )}
861
 
862
+ {/* Mobile overlay */}
863
  {leftSidebarOpen && (
864
  <div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setLeftSidebarOpen(false)} />
865
  )}
866
 
867
+ {/* Desktop sidebar */}
868
  {leftPanelVisible ? (
869
+ <aside className="hidden lg:flex w-80 flex-shrink-0 h-full min-h-0 bg-card border-r border-border overflow-hidden relative flex-col">
870
  <Button
871
  variant="secondary"
872
  size="icon"
 
878
  <ChevronLeft className="h-3 w-3" />
879
  </Button>
880
 
881
+ <div className="flex-1 min-h-0 overflow-hidden">
882
  <LeftSidebar
883
  learningMode={learningMode}
884
  language={language}
885
  onLearningModeChange={setLearningMode}
886
  onLanguageChange={setLanguage}
887
+ spaceType={spaceType}
888
+ groupMembers={groupMembers}
889
  user={user}
890
  onLogin={setUser}
891
  onLogout={() => setUser(null)}
 
900
  onDeleteSavedChat={handleDeleteSavedChat}
901
  onRenameSavedChat={handleRenameSavedChat}
902
  currentWorkspaceId={currentWorkspaceId}
903
+ workspaces={workspaces}
904
  selectedCourse={currentCourseId}
905
  availableCourses={availableCourses}
906
  />
 
908
  </aside>
909
  ) : null}
910
 
911
+ {/* Mobile sidebar */}
912
  <aside
913
  className={[
914
  "fixed lg:hidden z-50",
 
932
  language={language}
933
  onLearningModeChange={setLearningMode}
934
  onLanguageChange={setLanguage}
935
+ spaceType={spaceType}
936
+ groupMembers={groupMembers}
937
  user={user}
938
  onLogin={setUser}
939
  onLogout={() => setUser(null)}
 
948
  onDeleteSavedChat={handleDeleteSavedChat}
949
  onRenameSavedChat={handleRenameSavedChat}
950
  currentWorkspaceId={currentWorkspaceId}
951
+ workspaces={workspaces}
952
  selectedCourse={currentCourseId}
953
  availableCourses={availableCourses}
954
  />
955
  </div>
956
  </aside>
957
 
958
+ {/* Main */}
959
  <main className="flex flex-1 min-w-0 min-h-0 overflow-hidden flex-col">
960
  <div className="flex-1 min-h-0 min-w-0 overflow-hidden">
961
  <ChatArea
962
  isLoggedIn={!!user}
963
  learningMode={learningMode}
964
+ onLearningModeChange={setLearningMode}
965
  onClearConversation={() => setShowClearDialog(true)}
966
  onSaveChat={handleSaveChat}
 
967
  spaceType={spaceType}
968
  chatMode={chatMode}
969
  quizState={quizState}
 
984
  currentCourseId={currentCourseId}
985
  onCourseChange={handleCourseChange}
986
  showReviewBanner={showReviewBanner}
987
+ onReviewActivity={() => {}}
988
+ currentUserId={user.email}
989
+ docType={getCurrentDocTypeForChat()}
 
990
  onProfileBioUpdate={(bio) => updateUser({ bio })}
991
+ // ---- below are typical ChatArea props in your project
992
+ messages={messages}
993
+ onSendMessage={handleSendMessage}
994
+ onUploadFiles={handleFileUpload}
995
+ onStartQuiz={handleStartQuiz}
996
+ memoryProgress={memoryProgress}
997
  />
998
+ </div>
999
+ </main>
1000
+ </div>
1001
+ </div>
1002
+ </div>
1003
+ );
1004
+ }