Spaces:
Sleeping
Sleeping
Update web/src/components/ChatArea.tsx
Browse files- web/src/components/ChatArea.tsx +12 -302
web/src/components/ChatArea.tsx
CHANGED
|
@@ -86,7 +86,8 @@ interface ChatAreaProps {
|
|
| 86 |
uploadedFiles: UploadedFile[];
|
| 87 |
onFileUpload: (files: File[]) => void;
|
| 88 |
onRemoveFile: (index: number) => void;
|
| 89 |
-
|
|
|
|
| 90 |
|
| 91 |
onFileTypeChange: (index: number, type: FileType) => void;
|
| 92 |
memoryProgress: number;
|
|
@@ -174,7 +175,6 @@ function FileViewerContent({ file }: { file: File }) {
|
|
| 174 |
}
|
| 175 |
|
| 176 |
if (isPdfFile(file.name)) {
|
| 177 |
-
// Force PDF MIME, in case file.type is empty and the browser blocks preview.
|
| 178 |
const pdfBlob = new Blob([file], { type: "application/pdf" });
|
| 179 |
const pdfUrl = URL.createObjectURL(pdfBlob);
|
| 180 |
|
|
@@ -296,71 +296,6 @@ export function ChatArea({
|
|
| 296 |
const [shareLink, setShareLink] = useState("");
|
| 297 |
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
|
| 298 |
|
| 299 |
-
// --------------------------
|
| 300 |
-
// Profile Init Flow (Ask mode)
|
| 301 |
-
// --------------------------
|
| 302 |
-
type InitStatus = "idle" | "offered" | "asking" | "generating" | "done";
|
| 303 |
-
|
| 304 |
-
type InitQ = {
|
| 305 |
-
id: string;
|
| 306 |
-
title: string;
|
| 307 |
-
placeholder?: string;
|
| 308 |
-
};
|
| 309 |
-
|
| 310 |
-
const INIT_QUESTIONS: InitQ[] = [
|
| 311 |
-
{
|
| 312 |
-
id: "course_goal",
|
| 313 |
-
title: "What’s the single most important outcome you want from this course?",
|
| 314 |
-
placeholder: "e.g., understand LLM basics, build a project, prep for an exam, apply to work…",
|
| 315 |
-
},
|
| 316 |
-
{
|
| 317 |
-
id: "background",
|
| 318 |
-
title: "What’s your current background (major, job, or anything relevant)?",
|
| 319 |
-
placeholder: "One sentence is totally fine.",
|
| 320 |
-
},
|
| 321 |
-
{
|
| 322 |
-
id: "ai_experience",
|
| 323 |
-
title: "Have you worked with AI/LLMs before? If yes, at what level?",
|
| 324 |
-
placeholder: "e.g., none / used ChatGPT / built small projects / research…",
|
| 325 |
-
},
|
| 326 |
-
{
|
| 327 |
-
id: "python_level",
|
| 328 |
-
title: "How comfortable are you with Python? (Beginner / Intermediate / Advanced)",
|
| 329 |
-
placeholder: "Type one: Beginner / Intermediate / Advanced",
|
| 330 |
-
},
|
| 331 |
-
{
|
| 332 |
-
id: "preferred_format",
|
| 333 |
-
title: "What helps you learn best? (You can list multiple, separated by commas)",
|
| 334 |
-
placeholder: "Step-by-step, examples, visuals, concise answers, Socratic questions…",
|
| 335 |
-
},
|
| 336 |
-
{
|
| 337 |
-
id: "pace",
|
| 338 |
-
title: "What pace do you prefer from me? (Fast / Steady / Very detailed)",
|
| 339 |
-
placeholder: "Type one: Fast / Steady / Very detailed",
|
| 340 |
-
},
|
| 341 |
-
{
|
| 342 |
-
id: "biggest_pain",
|
| 343 |
-
title: "Where do you typically get stuck when learning technical topics?",
|
| 344 |
-
placeholder: "Concepts, tools, task breakdown, math, confidence, time management…",
|
| 345 |
-
},
|
| 346 |
-
{
|
| 347 |
-
id: "support_pref",
|
| 348 |
-
title: "When you’re unsure, how should I support you?",
|
| 349 |
-
placeholder: "Hints first / guided questions / direct answer / ask then answer…",
|
| 350 |
-
},
|
| 351 |
-
];
|
| 352 |
-
|
| 353 |
-
const [initStatus, setInitStatus] = useState<InitStatus>("idle");
|
| 354 |
-
const [initNeedOffer, setInitNeedOffer] = useState(false);
|
| 355 |
-
const [initStep, setInitStep] = useState(0);
|
| 356 |
-
const [initAnswers, setInitAnswers] = useState<Record<string, any>>({});
|
| 357 |
-
const [generatedBio, setGeneratedBio] = useState<string>("");
|
| 358 |
-
|
| 359 |
-
// IMPORTANT: allow typing during "asking"; lock typing only during "generating"
|
| 360 |
-
const initInputLocked = chatMode === "ask" && initStatus === "generating";
|
| 361 |
-
// Use this to block other actions (uploads / drag-drop / etc.) during asking+generating
|
| 362 |
-
const initBlockActions = chatMode === "ask" && (initStatus === "asking" || initStatus === "generating");
|
| 363 |
-
|
| 364 |
const courses =
|
| 365 |
availableCourses.length > 0
|
| 366 |
? availableCourses
|
|
@@ -444,96 +379,10 @@ export function ChatArea({
|
|
| 444 |
return () => container.removeEventListener("scroll", handleScroll);
|
| 445 |
}, []);
|
| 446 |
|
| 447 |
-
// Check if we should run profile init flow (Ask mode only)
|
| 448 |
-
useEffect(() => {
|
| 449 |
-
if (!isLoggedIn) return;
|
| 450 |
-
if (chatMode !== "ask") return;
|
| 451 |
-
if (!currentUserId) return;
|
| 452 |
-
|
| 453 |
-
// If already completed in this session, do nothing
|
| 454 |
-
if (initStatus !== "idle") return;
|
| 455 |
-
|
| 456 |
-
let cancelled = false;
|
| 457 |
-
|
| 458 |
-
(async () => {
|
| 459 |
-
try {
|
| 460 |
-
const r = await fetch(`/api/profile/status?user_id=${encodeURIComponent(currentUserId)}`);
|
| 461 |
-
if (!r.ok) return;
|
| 462 |
-
const j = await r.json();
|
| 463 |
-
if (cancelled) return;
|
| 464 |
-
|
| 465 |
-
if (j?.need_init) {
|
| 466 |
-
setInitNeedOffer(true);
|
| 467 |
-
setInitStatus("offered");
|
| 468 |
-
}
|
| 469 |
-
} catch {
|
| 470 |
-
// ignore
|
| 471 |
-
}
|
| 472 |
-
})();
|
| 473 |
-
|
| 474 |
-
return () => {
|
| 475 |
-
cancelled = true;
|
| 476 |
-
};
|
| 477 |
-
}, [isLoggedIn, chatMode, currentUserId, initStatus]);
|
| 478 |
-
|
| 479 |
const handleSubmit = async (e: React.FormEvent | React.KeyboardEvent) => {
|
| 480 |
e.preventDefault();
|
| 481 |
if (!isLoggedIn) return;
|
| 482 |
|
| 483 |
-
// INIT FLOW: treat input as the answer to the current init question
|
| 484 |
-
if (chatMode === "ask" && initStatus === "asking") {
|
| 485 |
-
const text = input.trim();
|
| 486 |
-
if (!text) return;
|
| 487 |
-
|
| 488 |
-
const q = INIT_QUESTIONS[initStep];
|
| 489 |
-
const nextAnswers = { ...initAnswers, [q.id]: text };
|
| 490 |
-
|
| 491 |
-
setInitAnswers(nextAnswers);
|
| 492 |
-
setInput("");
|
| 493 |
-
|
| 494 |
-
const nextStep = initStep + 1;
|
| 495 |
-
|
| 496 |
-
// finished -> generate bio + save to backend
|
| 497 |
-
if (nextStep >= INIT_QUESTIONS.length) {
|
| 498 |
-
setInitStatus("generating");
|
| 499 |
-
|
| 500 |
-
try {
|
| 501 |
-
const r = await fetch("/api/profile/init_submit", {
|
| 502 |
-
method: "POST",
|
| 503 |
-
headers: { "Content-Type": "application/json" },
|
| 504 |
-
body: JSON.stringify({
|
| 505 |
-
user_id: currentUserId,
|
| 506 |
-
answers: nextAnswers,
|
| 507 |
-
language_preference: "English",
|
| 508 |
-
}),
|
| 509 |
-
});
|
| 510 |
-
|
| 511 |
-
if (!r.ok) throw new Error("init_submit failed");
|
| 512 |
-
const j = await r.json();
|
| 513 |
-
|
| 514 |
-
setGeneratedBio(j?.bio || "");
|
| 515 |
-
onProfileBioUpdate?.(j?.bio || ""); // ✅ NEW: sync into user profile
|
| 516 |
-
|
| 517 |
-
setInitStatus("done");
|
| 518 |
-
setInitNeedOffer(false);
|
| 519 |
-
|
| 520 |
-
// reset
|
| 521 |
-
setInitStep(0);
|
| 522 |
-
setInitAnswers({});
|
| 523 |
-
} catch {
|
| 524 |
-
toast.error("Sorry — I couldn’t generate your Bio. Please try again.");
|
| 525 |
-
setInitStatus("asking");
|
| 526 |
-
}
|
| 527 |
-
|
| 528 |
-
return;
|
| 529 |
-
}
|
| 530 |
-
|
| 531 |
-
// go next question
|
| 532 |
-
setInitStep(nextStep);
|
| 533 |
-
return;
|
| 534 |
-
}
|
| 535 |
-
|
| 536 |
-
// ORIGINAL behavior (unchanged)
|
| 537 |
const hasText = !!input.trim();
|
| 538 |
const hasFiles = uploadedFiles.length > 0;
|
| 539 |
|
|
@@ -744,7 +593,6 @@ export function ChatArea({
|
|
| 744 |
const saved = isCurrentChatSaved();
|
| 745 |
|
| 746 |
if (saved) {
|
| 747 |
-
// keep behavior
|
| 748 |
onConfirmClear(false as any);
|
| 749 |
return;
|
| 750 |
}
|
|
@@ -763,7 +611,6 @@ export function ChatArea({
|
|
| 763 |
e.preventDefault();
|
| 764 |
e.stopPropagation();
|
| 765 |
if (!isLoggedIn) return;
|
| 766 |
-
if (initBlockActions) return; // block drag UI during init
|
| 767 |
setIsDragging(true);
|
| 768 |
};
|
| 769 |
|
|
@@ -780,7 +627,6 @@ export function ChatArea({
|
|
| 780 |
e.stopPropagation();
|
| 781 |
setIsDragging(false);
|
| 782 |
if (!isLoggedIn) return;
|
| 783 |
-
if (initBlockActions) return; // block file drop during init
|
| 784 |
|
| 785 |
const fileList = e.dataTransfer.files;
|
| 786 |
const files: File[] = [];
|
|
@@ -810,7 +656,6 @@ export function ChatArea({
|
|
| 810 |
return;
|
| 811 |
}
|
| 812 |
|
| 813 |
-
// limit total files per conversation
|
| 814 |
const currentCount = uploadedFiles.length + pendingFiles.length;
|
| 815 |
const remaining = MAX_UPLOAD_FILES - currentCount;
|
| 816 |
|
|
@@ -826,7 +671,6 @@ export function ChatArea({
|
|
| 826 |
toast.warning(`Only the first ${accepted.length} file(s) were added (max ${MAX_UPLOAD_FILES}).`);
|
| 827 |
}
|
| 828 |
|
| 829 |
-
// append, do NOT overwrite existing pending
|
| 830 |
setPendingFiles((prev) => [
|
| 831 |
...prev,
|
| 832 |
...accepted.map((file) => ({ file, type: "other" as FileType })),
|
|
@@ -836,11 +680,6 @@ export function ChatArea({
|
|
| 836 |
};
|
| 837 |
|
| 838 |
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 839 |
-
if (initBlockActions) {
|
| 840 |
-
e.target.value = "";
|
| 841 |
-
return;
|
| 842 |
-
}
|
| 843 |
-
|
| 844 |
const files = Array.from(e.target.files || []) as File[];
|
| 845 |
|
| 846 |
if (files.length === 0) {
|
|
@@ -870,7 +709,6 @@ export function ChatArea({
|
|
| 870 |
return;
|
| 871 |
}
|
| 872 |
|
| 873 |
-
// limit total files per conversation
|
| 874 |
const currentCount = uploadedFiles.length + pendingFiles.length;
|
| 875 |
const remaining = MAX_UPLOAD_FILES - currentCount;
|
| 876 |
|
|
@@ -887,7 +725,6 @@ export function ChatArea({
|
|
| 887 |
toast.warning(`Only the first ${accepted.length} file(s) were added (max ${MAX_UPLOAD_FILES}).`);
|
| 888 |
}
|
| 889 |
|
| 890 |
-
// append, do NOT overwrite existing pending
|
| 891 |
setPendingFiles((prev) => [
|
| 892 |
...prev,
|
| 893 |
...accepted.map((file) => ({ file, type: "other" as FileType })),
|
|
@@ -922,7 +759,6 @@ export function ChatArea({
|
|
| 922 |
setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
|
| 923 |
};
|
| 924 |
|
| 925 |
-
// File helpers
|
| 926 |
const getFileIcon = (filename: string) => {
|
| 927 |
const ext = filename.toLowerCase();
|
| 928 |
if (ext.endsWith(".pdf")) return FileText;
|
|
@@ -940,14 +776,12 @@ export function ChatArea({
|
|
| 940 |
|
| 941 |
const fileKey = (f: File) => `${f.name}::${f.size}::${f.lastModified}`;
|
| 942 |
|
| 943 |
-
// useObjectUrlCache: for image thumbnails (uploaded + pending)
|
| 944 |
const allThumbFiles = useMemo(() => {
|
| 945 |
return [...uploadedFiles.map((u) => u.file), ...pendingFiles.map((p) => p.file)];
|
| 946 |
}, [uploadedFiles, pendingFiles]);
|
| 947 |
|
| 948 |
const { getOrCreate } = useObjectUrlCache(allThumbFiles);
|
| 949 |
|
| 950 |
-
// a compact "chip" UI (the one with left Trash)
|
| 951 |
const FileChip = ({
|
| 952 |
file,
|
| 953 |
index,
|
|
@@ -1003,7 +837,6 @@ export function ChatArea({
|
|
| 1003 |
<div className="text-xs text-muted-foreground">{label}</div>
|
| 1004 |
</div>
|
| 1005 |
|
| 1006 |
-
{/* Thumbnail (image preview or file icon) */}
|
| 1007 |
<div className="relative h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
|
| 1008 |
{isImage ? (
|
| 1009 |
thumbUrl ? (
|
|
@@ -1033,11 +866,6 @@ export function ChatArea({
|
|
| 1033 |
|
| 1034 |
const bottomPad = Math.max(24, composerHeight + 24);
|
| 1035 |
|
| 1036 |
-
const initPlaceholder =
|
| 1037 |
-
initStatus === "asking"
|
| 1038 |
-
? "Type your answer here and press Enter / Send..."
|
| 1039 |
-
: undefined;
|
| 1040 |
-
|
| 1041 |
return (
|
| 1042 |
<div className="relative flex flex-col h-full min-h-0 w-full overflow-hidden">
|
| 1043 |
{/* Top Bar */}
|
|
@@ -1125,7 +953,7 @@ export function ChatArea({
|
|
| 1125 |
variant="ghost"
|
| 1126 |
size="icon"
|
| 1127 |
onClick={handleSaveClick}
|
| 1128 |
-
disabled={!isLoggedIn
|
| 1129 |
className={`h-8 w-8 rounded-md hover:bg-muted/50 ${isCurrentChatSaved() ? "text-primary" : ""}`}
|
| 1130 |
title={isCurrentChatSaved() ? "Unsave" : "Save"}
|
| 1131 |
>
|
|
@@ -1136,7 +964,7 @@ export function ChatArea({
|
|
| 1136 |
variant="ghost"
|
| 1137 |
size="icon"
|
| 1138 |
onClick={handleOpenDownloadDialog}
|
| 1139 |
-
disabled={!isLoggedIn
|
| 1140 |
className="h-8 w-8 rounded-md hover:bg-muted/50"
|
| 1141 |
title="Download"
|
| 1142 |
>
|
|
@@ -1147,7 +975,7 @@ export function ChatArea({
|
|
| 1147 |
variant="ghost"
|
| 1148 |
size="icon"
|
| 1149 |
onClick={handleShareClick}
|
| 1150 |
-
disabled={!isLoggedIn
|
| 1151 |
className="h-8 w-8 rounded-md hover:bg-muted/50"
|
| 1152 |
title="Share"
|
| 1153 |
>
|
|
@@ -1157,7 +985,7 @@ export function ChatArea({
|
|
| 1157 |
<Button
|
| 1158 |
variant="outline"
|
| 1159 |
onClick={handleClearClick}
|
| 1160 |
-
disabled={!isLoggedIn
|
| 1161 |
className="h-8 px-3 gap-2 rounded-md border border-border disabled:opacity-60 !bg-[var(--card)] !text-[var(--card-foreground)] hover:!opacity-90 [&_svg]:!text-[var(--card-foreground)] [&_span]:!text-[var(--card-foreground)]"
|
| 1162 |
title="New Chat"
|
| 1163 |
>
|
|
@@ -1216,118 +1044,6 @@ export function ChatArea({
|
|
| 1216 |
</React.Fragment>
|
| 1217 |
))}
|
| 1218 |
|
| 1219 |
-
{/* Profile Init Offer (Ask mode only) */}
|
| 1220 |
-
{chatMode === "ask" && initNeedOffer && initStatus === "offered" && (
|
| 1221 |
-
<div className="flex gap-2 justify-start px-4">
|
| 1222 |
-
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
|
| 1223 |
-
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
|
| 1224 |
-
</div>
|
| 1225 |
-
|
| 1226 |
-
<div className="w-full" style={{ maxWidth: "min(770px, calc(100% - 2rem))" }}>
|
| 1227 |
-
<div className="rounded-2xl border bg-card px-4 py-3 space-y-3">
|
| 1228 |
-
<div className="font-semibold">Quick intro so I can personalize your experience</div>
|
| 1229 |
-
<div className="text-sm text-muted-foreground leading-relaxed">
|
| 1230 |
-
I’m Clare, your AI teaching assistant. If you’d like, we can answer a few short questions so I can
|
| 1231 |
-
tailor explanations, pacing, and practice to you. Your answers will be summarized into your Profile Bio
|
| 1232 |
-
and used only inside this platform.
|
| 1233 |
-
</div>
|
| 1234 |
-
<div className="flex gap-2">
|
| 1235 |
-
<Button
|
| 1236 |
-
type="button"
|
| 1237 |
-
onClick={() => {
|
| 1238 |
-
setInitStatus("asking");
|
| 1239 |
-
setInitStep(0);
|
| 1240 |
-
setInitAnswers({});
|
| 1241 |
-
setGeneratedBio("");
|
| 1242 |
-
// Keep focus in the composer for immediate typing
|
| 1243 |
-
setTimeout(() => {
|
| 1244 |
-
const ta = document.querySelector("textarea");
|
| 1245 |
-
(ta as HTMLTextAreaElement | null)?.focus?.();
|
| 1246 |
-
}, 0);
|
| 1247 |
-
}}
|
| 1248 |
-
>
|
| 1249 |
-
Yes — let’s start
|
| 1250 |
-
</Button>
|
| 1251 |
-
<Button
|
| 1252 |
-
type="button"
|
| 1253 |
-
variant="outline"
|
| 1254 |
-
onClick={async () => {
|
| 1255 |
-
try {
|
| 1256 |
-
await fetch("/api/profile/dismiss", {
|
| 1257 |
-
method: "POST",
|
| 1258 |
-
headers: { "Content-Type": "application/json" },
|
| 1259 |
-
body: JSON.stringify({ user_id: currentUserId, days: 7 }),
|
| 1260 |
-
});
|
| 1261 |
-
} catch {}
|
| 1262 |
-
setInitNeedOffer(false);
|
| 1263 |
-
setInitStatus("idle");
|
| 1264 |
-
}}
|
| 1265 |
-
>
|
| 1266 |
-
Maybe later
|
| 1267 |
-
</Button>
|
| 1268 |
-
</div>
|
| 1269 |
-
</div>
|
| 1270 |
-
</div>
|
| 1271 |
-
</div>
|
| 1272 |
-
)}
|
| 1273 |
-
|
| 1274 |
-
{/* Current init question bubble */}
|
| 1275 |
-
{chatMode === "ask" && initStatus === "asking" && (
|
| 1276 |
-
<div className="flex gap-2 justify-start px-4">
|
| 1277 |
-
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
|
| 1278 |
-
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
|
| 1279 |
-
</div>
|
| 1280 |
-
|
| 1281 |
-
<div
|
| 1282 |
-
className="bg-muted rounded-2xl px-4 py-3 w-full"
|
| 1283 |
-
style={{ maxWidth: "min(770px, calc(100% - 2rem))" }}
|
| 1284 |
-
>
|
| 1285 |
-
<div className="text-sm font-medium mb-1">
|
| 1286 |
-
{INIT_QUESTIONS[initStep]?.title}
|
| 1287 |
-
</div>
|
| 1288 |
-
<div className="text-xs text-muted-foreground">
|
| 1289 |
-
{INIT_QUESTIONS[initStep]?.placeholder || "Just type your answer and press Send."}
|
| 1290 |
-
</div>
|
| 1291 |
-
<div className="text-xs text-muted-foreground mt-2">
|
| 1292 |
-
Question {initStep + 1} of {INIT_QUESTIONS.length}
|
| 1293 |
-
</div>
|
| 1294 |
-
</div>
|
| 1295 |
-
</div>
|
| 1296 |
-
)}
|
| 1297 |
-
|
| 1298 |
-
{/* Generating bubble */}
|
| 1299 |
-
{chatMode === "ask" && initStatus === "generating" && (
|
| 1300 |
-
<div className="flex gap-2 justify-start px-4">
|
| 1301 |
-
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
|
| 1302 |
-
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
|
| 1303 |
-
</div>
|
| 1304 |
-
<div className="bg-muted rounded-2xl px-4 py-3">
|
| 1305 |
-
<div className="text-sm">Thanks — I’m generating your Profile Bio now…</div>
|
| 1306 |
-
</div>
|
| 1307 |
-
</div>
|
| 1308 |
-
)}
|
| 1309 |
-
|
| 1310 |
-
{/* Done bubble with bio */}
|
| 1311 |
-
{chatMode === "ask" && initStatus === "done" && !!generatedBio && (
|
| 1312 |
-
<div className="flex gap-2 justify-start px-4">
|
| 1313 |
-
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
|
| 1314 |
-
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
|
| 1315 |
-
</div>
|
| 1316 |
-
|
| 1317 |
-
<div className="w-full" style={{ maxWidth: "min(770px, calc(100% - 2rem))" }}>
|
| 1318 |
-
<div className="bg-muted rounded-2xl px-4 py-3 space-y-2">
|
| 1319 |
-
<div className="text-sm font-medium">
|
| 1320 |
-
Thank you — I’ve saved this to your Profile Bio.
|
| 1321 |
-
</div>
|
| 1322 |
-
<div className="text-sm whitespace-pre-wrap">{generatedBio}</div>
|
| 1323 |
-
<div className="text-xs text-muted-foreground">
|
| 1324 |
-
You can update it anytime. I’ll use this to adapt explanations, pacing, and practice.
|
| 1325 |
-
</div>
|
| 1326 |
-
</div>
|
| 1327 |
-
</div>
|
| 1328 |
-
</div>
|
| 1329 |
-
)}
|
| 1330 |
-
|
| 1331 |
{isAppTyping && (
|
| 1332 |
<div className="flex gap-2 justify-start px-4">
|
| 1333 |
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
|
|
@@ -1413,7 +1129,6 @@ export function ChatArea({
|
|
| 1413 |
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 cursor-pointer hover:bg-muted/40"
|
| 1414 |
title="Click to preview"
|
| 1415 |
>
|
| 1416 |
-
{/* Thumbnail (image preview or file icon) */}
|
| 1417 |
<div className="h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
|
| 1418 |
{isImage ? (
|
| 1419 |
thumbUrl ? (
|
|
@@ -1448,11 +1163,10 @@ export function ChatArea({
|
|
| 1448 |
size="icon"
|
| 1449 |
onClick={(e) => {
|
| 1450 |
e.preventDefault();
|
| 1451 |
-
e.stopPropagation();
|
| 1452 |
onRemoveFile(i);
|
| 1453 |
}}
|
| 1454 |
title="Remove"
|
| 1455 |
-
disabled={initBlockActions}
|
| 1456 |
>
|
| 1457 |
<Trash2 className="h-4 w-4" />
|
| 1458 |
</Button>
|
|
@@ -1489,7 +1203,7 @@ export function ChatArea({
|
|
| 1489 |
variant="ghost"
|
| 1490 |
size="sm"
|
| 1491 |
className="gap-1.5 h-8 px-2 text-xs hover:bg-muted/50"
|
| 1492 |
-
disabled={!isLoggedIn
|
| 1493 |
type="button"
|
| 1494 |
>
|
| 1495 |
<span>{modeLabels[learningMode]}</span>
|
|
@@ -1590,7 +1304,6 @@ export function ChatArea({
|
|
| 1590 |
variant="ghost"
|
| 1591 |
disabled={
|
| 1592 |
!isLoggedIn ||
|
| 1593 |
-
initBlockActions ||
|
| 1594 |
(chatMode === "quiz" && !quizState.waitingForAnswer)
|
| 1595 |
}
|
| 1596 |
className="h-8 w-8 hover:bg-muted/50"
|
|
@@ -1606,8 +1319,7 @@ export function ChatArea({
|
|
| 1606 |
onChange={(e) => setInput(e.target.value)}
|
| 1607 |
onKeyDown={handleKeyDown}
|
| 1608 |
placeholder={
|
| 1609 |
-
|
| 1610 |
-
(!isLoggedIn
|
| 1611 |
? "Please log in on the right to start chatting..."
|
| 1612 |
: chatMode === "quiz"
|
| 1613 |
? quizState.waitingForAnswer
|
|
@@ -1619,11 +1331,10 @@ export function ChatArea({
|
|
| 1619 |
? "Type a message or drag files here... (mention @Clare to get AI assistance)"
|
| 1620 |
: learningMode === "general"
|
| 1621 |
? "Ask me anything! Please provide context about your question..."
|
| 1622 |
-
: "Ask Clare anything about the course or drag files here..."
|
| 1623 |
}
|
| 1624 |
disabled={
|
| 1625 |
!isLoggedIn ||
|
| 1626 |
-
initInputLocked || // key fix: DO NOT lock input during "asking"
|
| 1627 |
(chatMode === "quiz" && !quizState.waitingForAnswer)
|
| 1628 |
}
|
| 1629 |
className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${
|
|
@@ -1637,8 +1348,7 @@ export function ChatArea({
|
|
| 1637 |
size="icon"
|
| 1638 |
disabled={
|
| 1639 |
(!input.trim() && uploadedFiles.length === 0) ||
|
| 1640 |
-
!isLoggedIn
|
| 1641 |
-
initInputLocked
|
| 1642 |
}
|
| 1643 |
className="h-8 w-8 rounded-full"
|
| 1644 |
>
|
|
@@ -1653,7 +1363,7 @@ export function ChatArea({
|
|
| 1653 |
accept=".pdf,.docx,.pptx,.doc,.ppt,.jpg,.jpeg,.png,.gif,.webp"
|
| 1654 |
onChange={handleFileSelect}
|
| 1655 |
className="hidden"
|
| 1656 |
-
disabled={!isLoggedIn
|
| 1657 |
/>
|
| 1658 |
</div>
|
| 1659 |
</form>
|
|
|
|
| 86 |
uploadedFiles: UploadedFile[];
|
| 87 |
onFileUpload: (files: File[]) => void;
|
| 88 |
onRemoveFile: (index: number) => void;
|
| 89 |
+
|
| 90 |
+
onProfileBioUpdate?: (bio: string) => void; // still allowed (ProfileEditor / future AI updates)
|
| 91 |
|
| 92 |
onFileTypeChange: (index: number, type: FileType) => void;
|
| 93 |
memoryProgress: number;
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
if (isPdfFile(file.name)) {
|
|
|
|
| 178 |
const pdfBlob = new Blob([file], { type: "application/pdf" });
|
| 179 |
const pdfUrl = URL.createObjectURL(pdfBlob);
|
| 180 |
|
|
|
|
| 296 |
const [shareLink, setShareLink] = useState("");
|
| 297 |
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
|
| 298 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
const courses =
|
| 300 |
availableCourses.length > 0
|
| 301 |
? availableCourses
|
|
|
|
| 379 |
return () => container.removeEventListener("scroll", handleScroll);
|
| 380 |
}, []);
|
| 381 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
const handleSubmit = async (e: React.FormEvent | React.KeyboardEvent) => {
|
| 383 |
e.preventDefault();
|
| 384 |
if (!isLoggedIn) return;
|
| 385 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
const hasText = !!input.trim();
|
| 387 |
const hasFiles = uploadedFiles.length > 0;
|
| 388 |
|
|
|
|
| 593 |
const saved = isCurrentChatSaved();
|
| 594 |
|
| 595 |
if (saved) {
|
|
|
|
| 596 |
onConfirmClear(false as any);
|
| 597 |
return;
|
| 598 |
}
|
|
|
|
| 611 |
e.preventDefault();
|
| 612 |
e.stopPropagation();
|
| 613 |
if (!isLoggedIn) return;
|
|
|
|
| 614 |
setIsDragging(true);
|
| 615 |
};
|
| 616 |
|
|
|
|
| 627 |
e.stopPropagation();
|
| 628 |
setIsDragging(false);
|
| 629 |
if (!isLoggedIn) return;
|
|
|
|
| 630 |
|
| 631 |
const fileList = e.dataTransfer.files;
|
| 632 |
const files: File[] = [];
|
|
|
|
| 656 |
return;
|
| 657 |
}
|
| 658 |
|
|
|
|
| 659 |
const currentCount = uploadedFiles.length + pendingFiles.length;
|
| 660 |
const remaining = MAX_UPLOAD_FILES - currentCount;
|
| 661 |
|
|
|
|
| 671 |
toast.warning(`Only the first ${accepted.length} file(s) were added (max ${MAX_UPLOAD_FILES}).`);
|
| 672 |
}
|
| 673 |
|
|
|
|
| 674 |
setPendingFiles((prev) => [
|
| 675 |
...prev,
|
| 676 |
...accepted.map((file) => ({ file, type: "other" as FileType })),
|
|
|
|
| 680 |
};
|
| 681 |
|
| 682 |
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
const files = Array.from(e.target.files || []) as File[];
|
| 684 |
|
| 685 |
if (files.length === 0) {
|
|
|
|
| 709 |
return;
|
| 710 |
}
|
| 711 |
|
|
|
|
| 712 |
const currentCount = uploadedFiles.length + pendingFiles.length;
|
| 713 |
const remaining = MAX_UPLOAD_FILES - currentCount;
|
| 714 |
|
|
|
|
| 725 |
toast.warning(`Only the first ${accepted.length} file(s) were added (max ${MAX_UPLOAD_FILES}).`);
|
| 726 |
}
|
| 727 |
|
|
|
|
| 728 |
setPendingFiles((prev) => [
|
| 729 |
...prev,
|
| 730 |
...accepted.map((file) => ({ file, type: "other" as FileType })),
|
|
|
|
| 759 |
setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
|
| 760 |
};
|
| 761 |
|
|
|
|
| 762 |
const getFileIcon = (filename: string) => {
|
| 763 |
const ext = filename.toLowerCase();
|
| 764 |
if (ext.endsWith(".pdf")) return FileText;
|
|
|
|
| 776 |
|
| 777 |
const fileKey = (f: File) => `${f.name}::${f.size}::${f.lastModified}`;
|
| 778 |
|
|
|
|
| 779 |
const allThumbFiles = useMemo(() => {
|
| 780 |
return [...uploadedFiles.map((u) => u.file), ...pendingFiles.map((p) => p.file)];
|
| 781 |
}, [uploadedFiles, pendingFiles]);
|
| 782 |
|
| 783 |
const { getOrCreate } = useObjectUrlCache(allThumbFiles);
|
| 784 |
|
|
|
|
| 785 |
const FileChip = ({
|
| 786 |
file,
|
| 787 |
index,
|
|
|
|
| 837 |
<div className="text-xs text-muted-foreground">{label}</div>
|
| 838 |
</div>
|
| 839 |
|
|
|
|
| 840 |
<div className="relative h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
|
| 841 |
{isImage ? (
|
| 842 |
thumbUrl ? (
|
|
|
|
| 866 |
|
| 867 |
const bottomPad = Math.max(24, composerHeight + 24);
|
| 868 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
return (
|
| 870 |
<div className="relative flex flex-col h-full min-h-0 w-full overflow-hidden">
|
| 871 |
{/* Top Bar */}
|
|
|
|
| 953 |
variant="ghost"
|
| 954 |
size="icon"
|
| 955 |
onClick={handleSaveClick}
|
| 956 |
+
disabled={!isLoggedIn}
|
| 957 |
className={`h-8 w-8 rounded-md hover:bg-muted/50 ${isCurrentChatSaved() ? "text-primary" : ""}`}
|
| 958 |
title={isCurrentChatSaved() ? "Unsave" : "Save"}
|
| 959 |
>
|
|
|
|
| 964 |
variant="ghost"
|
| 965 |
size="icon"
|
| 966 |
onClick={handleOpenDownloadDialog}
|
| 967 |
+
disabled={!isLoggedIn}
|
| 968 |
className="h-8 w-8 rounded-md hover:bg-muted/50"
|
| 969 |
title="Download"
|
| 970 |
>
|
|
|
|
| 975 |
variant="ghost"
|
| 976 |
size="icon"
|
| 977 |
onClick={handleShareClick}
|
| 978 |
+
disabled={!isLoggedIn}
|
| 979 |
className="h-8 w-8 rounded-md hover:bg-muted/50"
|
| 980 |
title="Share"
|
| 981 |
>
|
|
|
|
| 985 |
<Button
|
| 986 |
variant="outline"
|
| 987 |
onClick={handleClearClick}
|
| 988 |
+
disabled={!isLoggedIn}
|
| 989 |
className="h-8 px-3 gap-2 rounded-md border border-border disabled:opacity-60 !bg-[var(--card)] !text-[var(--card-foreground)] hover:!opacity-90 [&_svg]:!text-[var(--card-foreground)] [&_span]:!text-[var(--card-foreground)]"
|
| 990 |
title="New Chat"
|
| 991 |
>
|
|
|
|
| 1044 |
</React.Fragment>
|
| 1045 |
))}
|
| 1046 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1047 |
{isAppTyping && (
|
| 1048 |
<div className="flex gap-2 justify-start px-4">
|
| 1049 |
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
|
|
|
|
| 1129 |
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 cursor-pointer hover:bg-muted/40"
|
| 1130 |
title="Click to preview"
|
| 1131 |
>
|
|
|
|
| 1132 |
<div className="h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
|
| 1133 |
{isImage ? (
|
| 1134 |
thumbUrl ? (
|
|
|
|
| 1163 |
size="icon"
|
| 1164 |
onClick={(e) => {
|
| 1165 |
e.preventDefault();
|
| 1166 |
+
e.stopPropagation();
|
| 1167 |
onRemoveFile(i);
|
| 1168 |
}}
|
| 1169 |
title="Remove"
|
|
|
|
| 1170 |
>
|
| 1171 |
<Trash2 className="h-4 w-4" />
|
| 1172 |
</Button>
|
|
|
|
| 1203 |
variant="ghost"
|
| 1204 |
size="sm"
|
| 1205 |
className="gap-1.5 h-8 px-2 text-xs hover:bg-muted/50"
|
| 1206 |
+
disabled={!isLoggedIn}
|
| 1207 |
type="button"
|
| 1208 |
>
|
| 1209 |
<span>{modeLabels[learningMode]}</span>
|
|
|
|
| 1304 |
variant="ghost"
|
| 1305 |
disabled={
|
| 1306 |
!isLoggedIn ||
|
|
|
|
| 1307 |
(chatMode === "quiz" && !quizState.waitingForAnswer)
|
| 1308 |
}
|
| 1309 |
className="h-8 w-8 hover:bg-muted/50"
|
|
|
|
| 1319 |
onChange={(e) => setInput(e.target.value)}
|
| 1320 |
onKeyDown={handleKeyDown}
|
| 1321 |
placeholder={
|
| 1322 |
+
!isLoggedIn
|
|
|
|
| 1323 |
? "Please log in on the right to start chatting..."
|
| 1324 |
: chatMode === "quiz"
|
| 1325 |
? quizState.waitingForAnswer
|
|
|
|
| 1331 |
? "Type a message or drag files here... (mention @Clare to get AI assistance)"
|
| 1332 |
: learningMode === "general"
|
| 1333 |
? "Ask me anything! Please provide context about your question..."
|
| 1334 |
+
: "Ask Clare anything about the course or drag files here..."
|
| 1335 |
}
|
| 1336 |
disabled={
|
| 1337 |
!isLoggedIn ||
|
|
|
|
| 1338 |
(chatMode === "quiz" && !quizState.waitingForAnswer)
|
| 1339 |
}
|
| 1340 |
className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${
|
|
|
|
| 1348 |
size="icon"
|
| 1349 |
disabled={
|
| 1350 |
(!input.trim() && uploadedFiles.length === 0) ||
|
| 1351 |
+
!isLoggedIn
|
|
|
|
| 1352 |
}
|
| 1353 |
className="h-8 w-8 rounded-full"
|
| 1354 |
>
|
|
|
|
| 1363 |
accept=".pdf,.docx,.pptx,.doc,.ppt,.jpg,.jpeg,.png,.gif,.webp"
|
| 1364 |
onChange={handleFileSelect}
|
| 1365 |
className="hidden"
|
| 1366 |
+
disabled={!isLoggedIn}
|
| 1367 |
/>
|
| 1368 |
</div>
|
| 1369 |
</form>
|