Spaces:
Sleeping
Sleeping
Update web/src/App.tsx
Browse files- 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 |
-
// ✅
|
| 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 |
-
// ✅
|
| 318 |
useEffect(() => {
|
| 319 |
if (!user?.email) return;
|
| 320 |
try {
|
|
@@ -330,7 +330,7 @@ function App() {
|
|
| 330 |
}
|
| 331 |
}, [user?.email]);
|
| 332 |
|
| 333 |
-
// ✅
|
| 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;
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 938 |
|
| 939 |
-
|
|
|
|
| 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:
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
);
|