SarahXia0405 commited on
Commit
11dfddf
·
verified ·
1 Parent(s): b1aea60

Update web/src/App.tsx

Browse files
Files changed (1) hide show
  1. web/src/App.tsx +59 -63
web/src/App.tsx CHANGED
@@ -121,7 +121,7 @@ function mapLanguagePref(lang: Language): string {
121
  return "Auto";
122
  }
123
 
124
- // ✅ NEW: localStorage helpers for saved chats
125
  function savedChatsStorageKey(email: string) {
126
  return `saved_chats::${email}`;
127
  }
@@ -314,7 +314,7 @@ function App() {
314
 
315
  const [savedChats, setSavedChats] = useState<SavedChat[]>([]);
316
 
317
- // ✅ NEW: load saved chats after login
318
  useEffect(() => {
319
  if (!user?.email) return;
320
  try {
@@ -330,7 +330,7 @@ function App() {
330
  }
331
  }, [user?.email]);
332
 
333
- // ✅ NEW: persist saved chats whenever changed
334
  useEffect(() => {
335
  if (!user?.email) return;
336
  try {
@@ -350,6 +350,7 @@ function App() {
350
  const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
351
  const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>("individual");
352
 
 
353
  const uploadedFingerprintsRef = useRef<Set<string>>(new Set());
354
 
355
  useEffect(() => {
@@ -449,20 +450,15 @@ function App() {
449
  // =========================
450
  // ✅ Stable course switching logic
451
  // =========================
452
-
453
- // Track whether we have hydrated My Space course selection for the current "visit"
454
  const didHydrateMySpaceRef = useRef(false);
455
 
456
- // Wrapper: only allow manual course change in My Space (individual)
457
  const handleCourseChange = (nextCourseId: string) => {
458
  if (!nextCourseId) return;
459
 
460
- // If we are inside a group course workspace, course is bound to workspace; ignore manual changes to avoid flicker
461
  if (currentWorkspace.type === "group" && currentWorkspace.category === "course") {
462
  return;
463
  }
464
 
465
- // In My Space (individual): update state immediately and persist
466
  setCurrentCourseId(nextCourseId);
467
  try {
468
  localStorage.setItem(MYSPACE_COURSE_KEY, nextCourseId);
@@ -471,27 +467,22 @@ function App() {
471
  }
472
  };
473
 
474
- // Sync courseId from group-workspace (authoritative)
475
- // AND hydrate My Space from localStorage only once per entry (no loops)
476
  useEffect(() => {
477
  if (!currentWorkspace) return;
478
 
479
- // Group course workspace: force to workspace course id
480
  if (currentWorkspace.type === "group" && currentWorkspace.category === "course") {
481
  const cid = currentWorkspace.courseInfo?.id;
482
  if (cid && cid !== currentCourseId) setCurrentCourseId(cid);
483
- didHydrateMySpaceRef.current = false; // reset for next time we go back to My Space
484
  return;
485
  }
486
 
487
- // My Space: hydrate only once when entering My Space
488
  if (currentWorkspace.type === "individual") {
489
  if (!didHydrateMySpaceRef.current) {
490
  didHydrateMySpaceRef.current = true;
491
 
492
  const saved = localStorage.getItem(MYSPACE_COURSE_KEY);
493
- const valid =
494
- saved && availableCourses.some((c) => c.id === saved) ? saved : undefined;
495
 
496
  const next = valid || currentCourseId || "course1";
497
  if (next !== currentCourseId) setCurrentCourseId(next);
@@ -502,13 +493,11 @@ function App() {
502
  currentWorkspace?.type,
503
  currentWorkspace?.category,
504
  currentWorkspace?.courseInfo?.id,
505
- // include availableCourses so validation works
506
  availableCourses,
507
  currentCourseId,
508
  currentWorkspace,
509
  ]);
510
 
511
- // Persist My Space course selection whenever it changes (defensive, keeps FE consistent)
512
  useEffect(() => {
513
  if (currentWorkspace?.type !== "individual") return;
514
  try {
@@ -524,7 +513,6 @@ function App() {
524
  localStorage.setItem("theme", isDarkMode ? "dark" : "light");
525
  }, [isDarkMode]);
526
 
527
- // ✅ lock outer page scroll
528
  useEffect(() => {
529
  const prev = document.body.style.overflow;
530
  document.body.style.overflow = "hidden";
@@ -645,14 +633,12 @@ function App() {
645
  else if (chatMode === "review") setReviewMessages((prev) => [...prev, userMessage]);
646
  else setQuizMessages((prev) => [...prev, userMessage]);
647
 
648
-
649
  if (chatMode === "quiz") {
650
- // Quiz mode: always route to backend (grading + next question handled server-side)
651
  setIsTyping(true);
652
-
653
  try {
654
  const docType = getCurrentDocTypeForChat();
655
-
656
  const r = await apiChat({
657
  user_id: user.email,
658
  message: content,
@@ -660,7 +646,7 @@ function App() {
660
  language_preference: mapLanguagePref(language),
661
  doc_type: docType,
662
  });
663
-
664
  const refs = (r.refs || [])
665
  .map((x) => {
666
  const a = x?.source_file ? String(x.source_file) : "";
@@ -669,7 +655,7 @@ function App() {
669
  return s || null;
670
  })
671
  .filter(Boolean) as string[];
672
-
673
  const assistantMessage: Message = {
674
  id: (Date.now() + 1).toString(),
675
  role: "assistant",
@@ -677,21 +663,19 @@ function App() {
677
  timestamp: new Date(),
678
  references: refs.length ? refs : undefined,
679
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
680
- // Quiz flow is driven by backend, so don't force a local "Next" unless you want it
681
  showNextButton: false,
682
  };
683
 
684
  setIsTyping(false);
685
-
686
  setTimeout(() => {
687
  setQuizMessages((prev) => [...prev, assistantMessage]);
688
- // In quiz, backend typically asks the next prompt; keep waitingForAnswer true
689
  setQuizState((prev) => ({ ...prev, waitingForAnswer: true, showNextButton: false }));
690
  }, 50);
691
  } catch (e: any) {
692
  setIsTyping(false);
693
  toast.error(e?.message || "Quiz failed");
694
-
695
  const assistantMessage: Message = {
696
  id: (Date.now() + 1).toString(),
697
  role: "assistant",
@@ -699,16 +683,15 @@ function App() {
699
  timestamp: new Date(),
700
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
701
  };
702
-
703
  setTimeout(() => {
704
  setQuizMessages((prev) => [...prev, assistantMessage]);
705
  }, 50);
706
  }
707
-
708
  return;
709
  }
710
 
711
-
712
  setIsTyping(true);
713
  try {
714
  const docType = getCurrentDocTypeForChat();
@@ -773,7 +756,7 @@ function App() {
773
 
774
  const handleNextQuestion = async () => {
775
  if (!user) return;
776
-
777
  const prompt = "Please give me another question of the same quiz style.";
778
  const sender: GroupMember = {
779
  id: user.email,
@@ -781,8 +764,7 @@ function App() {
781
  email: user.email,
782
  avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
783
  };
784
-
785
- // record the "next" action as a user turn (keeps server history consistent)
786
  const userMessage: Message = {
787
  id: Date.now().toString(),
788
  role: "user",
@@ -790,10 +772,10 @@ function App() {
790
  timestamp: new Date(),
791
  sender,
792
  };
793
-
794
  setQuizMessages((prev) => [...prev, userMessage]);
795
  setIsTyping(true);
796
-
797
  try {
798
  const docType = getCurrentDocTypeForChat();
799
  const r = await apiChat({
@@ -803,7 +785,7 @@ function App() {
803
  language_preference: mapLanguagePref(language),
804
  doc_type: docType,
805
  });
806
-
807
  const refs = (r.refs || [])
808
  .map((x) => {
809
  const a = x?.source_file ? String(x.source_file) : "";
@@ -812,7 +794,7 @@ function App() {
812
  return s || null;
813
  })
814
  .filter(Boolean) as string[];
815
-
816
  const assistantMessage: Message = {
817
  id: (Date.now() + 1).toString(),
818
  role: "assistant",
@@ -822,9 +804,9 @@ function App() {
822
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
823
  showNextButton: false,
824
  };
825
-
826
  setIsTyping(false);
827
-
828
  setTimeout(() => {
829
  setQuizMessages((prev) => [...prev, assistantMessage]);
830
  setQuizState((prev) => ({
@@ -837,7 +819,7 @@ function App() {
837
  } catch (e: any) {
838
  setIsTyping(false);
839
  toast.error(e?.message || "Quiz failed");
840
-
841
  const assistantMessage: Message = {
842
  id: (Date.now() + 1).toString(),
843
  role: "assistant",
@@ -845,25 +827,25 @@ function App() {
845
  timestamp: new Date(),
846
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
847
  };
848
-
849
  setTimeout(() => setQuizMessages((prev) => [...prev, assistantMessage]), 50);
850
  }
851
  };
852
 
853
  const handleStartQuiz = async () => {
854
  if (!user) return;
855
-
856
  setIsTyping(true);
857
  try {
858
  const docType = getCurrentDocTypeForChat();
859
-
860
  const r = await apiQuizStart({
861
  user_id: user.email,
862
  language_preference: mapLanguagePref(language),
863
  doc_type: docType,
864
  learning_mode: "quiz",
865
  });
866
-
867
  const refs = (r.refs || [])
868
  .map((x) => {
869
  const a = x?.source_file ? String(x.source_file) : "";
@@ -872,7 +854,7 @@ function App() {
872
  return s || null;
873
  })
874
  .filter(Boolean) as string[];
875
-
876
  const assistantMessage: Message = {
877
  id: Date.now().toString(),
878
  role: "assistant",
@@ -882,18 +864,17 @@ function App() {
882
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
883
  showNextButton: false,
884
  };
885
-
886
  setIsTyping(false);
887
-
888
  setTimeout(() => {
889
  setQuizMessages((prev) => [...prev, assistantMessage]);
890
- // backend will ask: "choose 1 or 2" first; user should answer next
891
  setQuizState({ currentQuestion: 0, waitingForAnswer: true, showNextButton: false });
892
  }, 50);
893
  } catch (e: any) {
894
  setIsTyping(false);
895
  toast.error(e?.message || "Start quiz failed");
896
-
897
  const assistantMessage: Message = {
898
  id: Date.now().toString(),
899
  role: "assistant",
@@ -901,42 +882,51 @@ function App() {
901
  timestamp: new Date(),
902
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
903
  };
904
-
905
  setTimeout(() => setQuizMessages((prev) => [...prev, assistantMessage]), 50);
906
  }
907
  };
908
 
909
-
910
  // =========================
911
- // File Upload (FIXED)
912
  // =========================
913
  const handleFileUpload = (files: File[]) => {
914
  const newFiles: UploadedFile[] = files.map((file) => ({ file, type: "other" as FileType }));
915
  setUploadedFiles((prev) => [...prev, ...newFiles]);
916
  };
917
-
918
- // FIX: ChatArea expects index-based removal
919
  const handleRemoveFile = (index: number) => {
920
  setUploadedFiles((prev) => {
921
  if (index < 0 || index >= prev.length) return prev;
922
-
923
  const removed = prev[index]?.file;
924
  const next = prev.filter((_, i) => i !== index);
925
-
926
- // 同步清理 fingerprints:否则同一个文件删除后可能无法再次触发 upload/type change
927
  if (removed) {
928
  const fp = `${removed.name}::${removed.size}::${removed.lastModified}`;
929
  uploadedFingerprintsRef.current.delete(fp);
930
  }
931
-
932
  return next;
933
  });
934
  };
935
 
 
 
 
936
 
937
- if (!targetFile) return;
 
 
 
 
 
 
 
938
 
939
- const fp = `${targetFile.name}::${targetFile.size}::${targetFile.lastModified}`;
 
940
  if (uploadedFingerprintsRef.current.has(fp)) return;
941
  uploadedFingerprintsRef.current.add(fp);
942
 
@@ -944,10 +934,12 @@ function App() {
944
  await apiUpload({
945
  user_id: user.email,
946
  doc_type: DOC_TYPE_MAP[type] || "Other Course Document",
947
- file: targetFile,
948
  });
949
  toast.success("File uploaded to backend");
950
  } catch (e: any) {
 
 
951
  toast.error(e?.message || "Upload failed");
952
  }
953
  };
@@ -962,7 +954,11 @@ function App() {
962
 
963
  return chat.messages.every((savedMsg, idx) => {
964
  const currentMsg = messages[idx];
965
- return savedMsg.id === currentMsg.id && savedMsg.role === currentMsg.role && savedMsg.content === currentMsg.content;
 
 
 
 
966
  });
967
  }) || null
968
  );
 
121
  return "Auto";
122
  }
123
 
124
+ // ✅ localStorage helpers for saved chats
125
  function savedChatsStorageKey(email: string) {
126
  return `saved_chats::${email}`;
127
  }
 
314
 
315
  const [savedChats, setSavedChats] = useState<SavedChat[]>([]);
316
 
317
+ // ✅ load saved chats after login
318
  useEffect(() => {
319
  if (!user?.email) return;
320
  try {
 
330
  }
331
  }, [user?.email]);
332
 
333
+ // ✅ persist saved chats whenever changed
334
  useEffect(() => {
335
  if (!user?.email) return;
336
  try {
 
350
  const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
351
  const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>("individual");
352
 
353
+ // ✅ used to prevent duplicate upload per file fingerprint
354
  const uploadedFingerprintsRef = useRef<Set<string>>(new Set());
355
 
356
  useEffect(() => {
 
450
  // =========================
451
  // ✅ Stable course switching logic
452
  // =========================
 
 
453
  const didHydrateMySpaceRef = useRef(false);
454
 
 
455
  const handleCourseChange = (nextCourseId: string) => {
456
  if (!nextCourseId) return;
457
 
 
458
  if (currentWorkspace.type === "group" && currentWorkspace.category === "course") {
459
  return;
460
  }
461
 
 
462
  setCurrentCourseId(nextCourseId);
463
  try {
464
  localStorage.setItem(MYSPACE_COURSE_KEY, nextCourseId);
 
467
  }
468
  };
469
 
 
 
470
  useEffect(() => {
471
  if (!currentWorkspace) return;
472
 
 
473
  if (currentWorkspace.type === "group" && currentWorkspace.category === "course") {
474
  const cid = currentWorkspace.courseInfo?.id;
475
  if (cid && cid !== currentCourseId) setCurrentCourseId(cid);
476
+ didHydrateMySpaceRef.current = false;
477
  return;
478
  }
479
 
 
480
  if (currentWorkspace.type === "individual") {
481
  if (!didHydrateMySpaceRef.current) {
482
  didHydrateMySpaceRef.current = true;
483
 
484
  const saved = localStorage.getItem(MYSPACE_COURSE_KEY);
485
+ const valid = saved && availableCourses.some((c) => c.id === saved) ? saved : undefined;
 
486
 
487
  const next = valid || currentCourseId || "course1";
488
  if (next !== currentCourseId) setCurrentCourseId(next);
 
493
  currentWorkspace?.type,
494
  currentWorkspace?.category,
495
  currentWorkspace?.courseInfo?.id,
 
496
  availableCourses,
497
  currentCourseId,
498
  currentWorkspace,
499
  ]);
500
 
 
501
  useEffect(() => {
502
  if (currentWorkspace?.type !== "individual") return;
503
  try {
 
513
  localStorage.setItem("theme", isDarkMode ? "dark" : "light");
514
  }, [isDarkMode]);
515
 
 
516
  useEffect(() => {
517
  const prev = document.body.style.overflow;
518
  document.body.style.overflow = "hidden";
 
633
  else if (chatMode === "review") setReviewMessages((prev) => [...prev, userMessage]);
634
  else setQuizMessages((prev) => [...prev, userMessage]);
635
 
 
636
  if (chatMode === "quiz") {
 
637
  setIsTyping(true);
638
+
639
  try {
640
  const docType = getCurrentDocTypeForChat();
641
+
642
  const r = await apiChat({
643
  user_id: user.email,
644
  message: content,
 
646
  language_preference: mapLanguagePref(language),
647
  doc_type: docType,
648
  });
649
+
650
  const refs = (r.refs || [])
651
  .map((x) => {
652
  const a = x?.source_file ? String(x.source_file) : "";
 
655
  return s || null;
656
  })
657
  .filter(Boolean) as string[];
658
+
659
  const assistantMessage: Message = {
660
  id: (Date.now() + 1).toString(),
661
  role: "assistant",
 
663
  timestamp: new Date(),
664
  references: refs.length ? refs : undefined,
665
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
 
666
  showNextButton: false,
667
  };
668
 
669
  setIsTyping(false);
670
+
671
  setTimeout(() => {
672
  setQuizMessages((prev) => [...prev, assistantMessage]);
 
673
  setQuizState((prev) => ({ ...prev, waitingForAnswer: true, showNextButton: false }));
674
  }, 50);
675
  } catch (e: any) {
676
  setIsTyping(false);
677
  toast.error(e?.message || "Quiz failed");
678
+
679
  const assistantMessage: Message = {
680
  id: (Date.now() + 1).toString(),
681
  role: "assistant",
 
683
  timestamp: new Date(),
684
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
685
  };
686
+
687
  setTimeout(() => {
688
  setQuizMessages((prev) => [...prev, assistantMessage]);
689
  }, 50);
690
  }
691
+
692
  return;
693
  }
694
 
 
695
  setIsTyping(true);
696
  try {
697
  const docType = getCurrentDocTypeForChat();
 
756
 
757
  const handleNextQuestion = async () => {
758
  if (!user) return;
759
+
760
  const prompt = "Please give me another question of the same quiz style.";
761
  const sender: GroupMember = {
762
  id: user.email,
 
764
  email: user.email,
765
  avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
766
  };
767
+
 
768
  const userMessage: Message = {
769
  id: Date.now().toString(),
770
  role: "user",
 
772
  timestamp: new Date(),
773
  sender,
774
  };
775
+
776
  setQuizMessages((prev) => [...prev, userMessage]);
777
  setIsTyping(true);
778
+
779
  try {
780
  const docType = getCurrentDocTypeForChat();
781
  const r = await apiChat({
 
785
  language_preference: mapLanguagePref(language),
786
  doc_type: docType,
787
  });
788
+
789
  const refs = (r.refs || [])
790
  .map((x) => {
791
  const a = x?.source_file ? String(x.source_file) : "";
 
794
  return s || null;
795
  })
796
  .filter(Boolean) as string[];
797
+
798
  const assistantMessage: Message = {
799
  id: (Date.now() + 1).toString(),
800
  role: "assistant",
 
804
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
805
  showNextButton: false,
806
  };
807
+
808
  setIsTyping(false);
809
+
810
  setTimeout(() => {
811
  setQuizMessages((prev) => [...prev, assistantMessage]);
812
  setQuizState((prev) => ({
 
819
  } catch (e: any) {
820
  setIsTyping(false);
821
  toast.error(e?.message || "Quiz failed");
822
+
823
  const assistantMessage: Message = {
824
  id: (Date.now() + 1).toString(),
825
  role: "assistant",
 
827
  timestamp: new Date(),
828
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
829
  };
830
+
831
  setTimeout(() => setQuizMessages((prev) => [...prev, assistantMessage]), 50);
832
  }
833
  };
834
 
835
  const handleStartQuiz = async () => {
836
  if (!user) return;
837
+
838
  setIsTyping(true);
839
  try {
840
  const docType = getCurrentDocTypeForChat();
841
+
842
  const r = await apiQuizStart({
843
  user_id: user.email,
844
  language_preference: mapLanguagePref(language),
845
  doc_type: docType,
846
  learning_mode: "quiz",
847
  });
848
+
849
  const refs = (r.refs || [])
850
  .map((x) => {
851
  const a = x?.source_file ? String(x.source_file) : "";
 
854
  return s || null;
855
  })
856
  .filter(Boolean) as string[];
857
+
858
  const assistantMessage: Message = {
859
  id: Date.now().toString(),
860
  role: "assistant",
 
864
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
865
  showNextButton: false,
866
  };
867
+
868
  setIsTyping(false);
869
+
870
  setTimeout(() => {
871
  setQuizMessages((prev) => [...prev, assistantMessage]);
 
872
  setQuizState({ currentQuestion: 0, waitingForAnswer: true, showNextButton: false });
873
  }, 50);
874
  } catch (e: any) {
875
  setIsTyping(false);
876
  toast.error(e?.message || "Start quiz failed");
877
+
878
  const assistantMessage: Message = {
879
  id: Date.now().toString(),
880
  role: "assistant",
 
882
  timestamp: new Date(),
883
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
884
  };
885
+
886
  setTimeout(() => setQuizMessages((prev) => [...prev, assistantMessage]), 50);
887
  }
888
  };
889
 
 
890
  // =========================
891
+ // File Upload (FIXED)
892
  // =========================
893
  const handleFileUpload = (files: File[]) => {
894
  const newFiles: UploadedFile[] = files.map((file) => ({ file, type: "other" as FileType }));
895
  setUploadedFiles((prev) => [...prev, ...newFiles]);
896
  };
897
+
898
+ // FIX 1: index-based removal + clear fingerprint so same file can be re-added
899
  const handleRemoveFile = (index: number) => {
900
  setUploadedFiles((prev) => {
901
  if (index < 0 || index >= prev.length) return prev;
902
+
903
  const removed = prev[index]?.file;
904
  const next = prev.filter((_, i) => i !== index);
905
+
 
906
  if (removed) {
907
  const fp = `${removed.name}::${removed.size}::${removed.lastModified}`;
908
  uploadedFingerprintsRef.current.delete(fp);
909
  }
910
+
911
  return next;
912
  });
913
  };
914
 
915
+ // ✅ FIX 2: NO await inside setState updater (prevents Vite/esbuild error)
916
+ const handleFileTypeChange = async (index: number, type: FileType) => {
917
+ if (!user) return;
918
 
919
+ const target = uploadedFiles[index]?.file;
920
+ if (!target) return;
921
+
922
+ // update UI first (sync)
923
+ setUploadedFiles((prev) => {
924
+ if (index < 0 || index >= prev.length) return prev;
925
+ return prev.map((f, i) => (i === index ? { ...f, type } : f));
926
+ });
927
 
928
+ // de-dupe upload per file fingerprint
929
+ const fp = `${target.name}::${target.size}::${target.lastModified}`;
930
  if (uploadedFingerprintsRef.current.has(fp)) return;
931
  uploadedFingerprintsRef.current.add(fp);
932
 
 
934
  await apiUpload({
935
  user_id: user.email,
936
  doc_type: DOC_TYPE_MAP[type] || "Other Course Document",
937
+ file: target,
938
  });
939
  toast.success("File uploaded to backend");
940
  } catch (e: any) {
941
+ // allow retry
942
+ uploadedFingerprintsRef.current.delete(fp);
943
  toast.error(e?.message || "Upload failed");
944
  }
945
  };
 
954
 
955
  return chat.messages.every((savedMsg, idx) => {
956
  const currentMsg = messages[idx];
957
+ return (
958
+ savedMsg.id === currentMsg.id &&
959
+ savedMsg.role === currentMsg.role &&
960
+ savedMsg.content === currentMsg.content
961
+ );
962
  });
963
  }) || null
964
  );