Spaces:
Sleeping
Sleeping
Update web/src/components/ChatArea.tsx
Browse files- web/src/components/ChatArea.tsx +73 -51
web/src/components/ChatArea.tsx
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
| 12 |
Share2,
|
| 13 |
Upload,
|
| 14 |
X,
|
| 15 |
-
Trash2,
|
| 16 |
File,
|
| 17 |
FileText,
|
| 18 |
Presentation,
|
|
@@ -66,6 +66,7 @@ interface ChatAreaProps {
|
|
| 66 |
onFileUpload: (files: File[]) => void;
|
| 67 |
onRemoveFile: (index: number) => void;
|
| 68 |
|
|
|
|
| 69 |
onFileTypeChange: (index: number, type: FileType) => void;
|
| 70 |
memoryProgress: number;
|
| 71 |
isLoggedIn: boolean;
|
|
@@ -563,7 +564,7 @@ export function ChatArea({
|
|
| 563 |
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
| 564 |
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
| 565 |
};
|
| 566 |
-
|
| 567 |
const fileKey = (f: File) => `${f.name}::${f.size}::${f.lastModified}`;
|
| 568 |
|
| 569 |
const removeUploadedByFile = (file: File) => {
|
|
@@ -572,13 +573,13 @@ export function ChatArea({
|
|
| 572 |
};
|
| 573 |
|
| 574 |
|
| 575 |
-
// useObjectUrlCache: for image thumbnails (uploaded + pending)
|
| 576 |
const allThumbFiles = React.useMemo(() => {
|
| 577 |
return [...uploadedFiles.map((u) => u.file), ...pendingFiles.map((p) => p.file)];
|
| 578 |
}, [uploadedFiles, pendingFiles]);
|
| 579 |
const { getOrCreate } = useObjectUrlCache(allThumbFiles);
|
| 580 |
|
| 581 |
-
// NEW: a compact "chip" UI (the one with left X)
|
| 582 |
const FileChip = ({
|
| 583 |
file,
|
| 584 |
index,
|
|
@@ -590,7 +591,7 @@ export function ChatArea({
|
|
| 590 |
}) => {
|
| 591 |
const ext = file.name.toLowerCase();
|
| 592 |
const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
|
| 593 |
-
|
| 594 |
const label = ext.endsWith(".pdf")
|
| 595 |
? "PDF"
|
| 596 |
: ext.endsWith(".pptx") || ext.endsWith(".ppt")
|
|
@@ -600,59 +601,62 @@ export function ChatArea({
|
|
| 600 |
: isImage
|
| 601 |
? "Image"
|
| 602 |
: "File";
|
| 603 |
-
|
| 604 |
const thumbUrl = isImage ? getOrCreate(file) : null;
|
| 605 |
-
|
| 606 |
const handleRemove = () => {
|
| 607 |
if (source === "uploaded") {
|
| 608 |
-
|
|
|
|
|
|
|
|
|
|
| 609 |
} else {
|
| 610 |
setPendingFiles((prev) => prev.filter((p) => fileKey(p.file) !== fileKey(file)));
|
| 611 |
}
|
| 612 |
};
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
return (
|
| 617 |
<div className="flex items-center gap-2 rounded-xl border border-border bg-card px-3 py-2 shadow-sm w-[320px] max-w-full">
|
| 618 |
-
{/*
|
| 619 |
-
{isImage ? (
|
| 620 |
-
<div className="relative h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
|
| 621 |
-
{thumbUrl ? (
|
| 622 |
-
<img src={thumbUrl} alt={file.name} className="h-full w-full object-cover" draggable={false} />
|
| 623 |
-
) : (
|
| 624 |
-
<div className="h-full w-full flex items-center justify-center">
|
| 625 |
-
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
| 626 |
-
</div>
|
| 627 |
-
)}
|
| 628 |
-
</div>
|
| 629 |
-
) : (
|
| 630 |
-
<div className="relative h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted flex items-center justify-center">
|
| 631 |
-
<File className="h-4 w-4 text-muted-foreground" />
|
| 632 |
-
</div>
|
| 633 |
-
)}
|
| 634 |
-
|
| 635 |
-
{/* 文本区 */}
|
| 636 |
-
<div className="min-w-0 flex-1">
|
| 637 |
-
<div className="text-sm font-medium truncate" title={file.name}>
|
| 638 |
-
{file.name}
|
| 639 |
-
</div>
|
| 640 |
-
<div className="text-xs text-muted-foreground">{label}</div>
|
| 641 |
-
</div>
|
| 642 |
-
|
| 643 |
-
{/* ✅ 删除按钮 */}
|
| 644 |
<button
|
| 645 |
type="button"
|
| 646 |
onClick={(e) => {
|
| 647 |
e.preventDefault();
|
| 648 |
e.stopPropagation();
|
| 649 |
-
|
| 650 |
}}
|
| 651 |
-
className="inline-flex h-
|
| 652 |
title="Remove"
|
| 653 |
>
|
| 654 |
<Trash2 className="h-4 w-4" />
|
| 655 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
</div>
|
| 657 |
);
|
| 658 |
};
|
|
@@ -924,17 +928,33 @@ export function ChatArea({
|
|
| 924 |
{/* Uploaded Files Preview (chip UI) */}
|
| 925 |
{(uploadedFiles.length > 0 || pendingFiles.length > 0) && (
|
| 926 |
<div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
| 927 |
-
{/*
|
| 928 |
-
{uploadedFiles.map((uf, i) =>
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 936 |
|
| 937 |
-
{/* pending */}
|
| 938 |
{pendingFiles.map((p, idx) => (
|
| 939 |
<FileChip key={`p-${p.file.name}-${p.file.size}-${p.file.lastModified}`} file={p.file} index={idx} source="pending" />
|
| 940 |
))}
|
|
@@ -1046,7 +1066,9 @@ export function ChatArea({
|
|
| 1046 |
: "Ask Clare anything about the course or drag files here..."
|
| 1047 |
}
|
| 1048 |
disabled={!isLoggedIn || (chatMode === "quiz" && !quizState.waitingForAnswer)}
|
| 1049 |
-
className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${
|
|
|
|
|
|
|
| 1050 |
/>
|
| 1051 |
|
| 1052 |
<div className="absolute bottom-2 right-2 flex gap-1">
|
|
@@ -1304,4 +1326,4 @@ export function ChatArea({
|
|
| 1304 |
)}
|
| 1305 |
</div>
|
| 1306 |
);
|
| 1307 |
-
}
|
|
|
|
| 12 |
Share2,
|
| 13 |
Upload,
|
| 14 |
X,
|
| 15 |
+
Trash2,
|
| 16 |
File,
|
| 17 |
FileText,
|
| 18 |
Presentation,
|
|
|
|
| 66 |
onFileUpload: (files: File[]) => void;
|
| 67 |
onRemoveFile: (index: number) => void;
|
| 68 |
|
| 69 |
+
|
| 70 |
onFileTypeChange: (index: number, type: FileType) => void;
|
| 71 |
memoryProgress: number;
|
| 72 |
isLoggedIn: boolean;
|
|
|
|
| 564 |
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
| 565 |
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
| 566 |
};
|
| 567 |
+
|
| 568 |
const fileKey = (f: File) => `${f.name}::${f.size}::${f.lastModified}`;
|
| 569 |
|
| 570 |
const removeUploadedByFile = (file: File) => {
|
|
|
|
| 573 |
};
|
| 574 |
|
| 575 |
|
| 576 |
+
// ✅ useObjectUrlCache: for image thumbnails (uploaded + pending)
|
| 577 |
const allThumbFiles = React.useMemo(() => {
|
| 578 |
return [...uploadedFiles.map((u) => u.file), ...pendingFiles.map((p) => p.file)];
|
| 579 |
}, [uploadedFiles, pendingFiles]);
|
| 580 |
const { getOrCreate } = useObjectUrlCache(allThumbFiles);
|
| 581 |
|
| 582 |
+
// ✅ NEW: a compact "chip" UI (the one with left X)
|
| 583 |
const FileChip = ({
|
| 584 |
file,
|
| 585 |
index,
|
|
|
|
| 591 |
}) => {
|
| 592 |
const ext = file.name.toLowerCase();
|
| 593 |
const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
|
| 594 |
+
|
| 595 |
const label = ext.endsWith(".pdf")
|
| 596 |
? "PDF"
|
| 597 |
: ext.endsWith(".pptx") || ext.endsWith(".ppt")
|
|
|
|
| 601 |
: isImage
|
| 602 |
? "Image"
|
| 603 |
: "File";
|
| 604 |
+
|
| 605 |
const thumbUrl = isImage ? getOrCreate(file) : null;
|
| 606 |
+
|
| 607 |
const handleRemove = () => {
|
| 608 |
if (source === "uploaded") {
|
| 609 |
+
// 关键:仍然走 index 删除(或按 file 找 index)
|
| 610 |
+
onRemoveFile(index);
|
| 611 |
+
// 如果你更想稳一点(不依赖 index),用下面这一行替代上面那行:
|
| 612 |
+
// removeUploadedByFile(file);
|
| 613 |
} else {
|
| 614 |
setPendingFiles((prev) => prev.filter((p) => fileKey(p.file) !== fileKey(file)));
|
| 615 |
}
|
| 616 |
};
|
| 617 |
+
|
|
|
|
|
|
|
| 618 |
return (
|
| 619 |
<div className="flex items-center gap-2 rounded-xl border border-border bg-card px-3 py-2 shadow-sm w-[320px] max-w-full">
|
| 620 |
+
{/* 叉叉:只 stopPropagation,不要一堆 stopImmediatePropagation,避免某些浏览器/事件链出问题 */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
<button
|
| 622 |
type="button"
|
| 623 |
onClick={(e) => {
|
| 624 |
e.preventDefault();
|
| 625 |
e.stopPropagation();
|
| 626 |
+
onRemoveFile?.(index); // ✅ 用 index 删(前后端状态都会删掉)
|
| 627 |
}}
|
| 628 |
+
className="ml-2 inline-flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card hover:bg-muted"
|
| 629 |
title="Remove"
|
| 630 |
>
|
| 631 |
<Trash2 className="h-4 w-4" />
|
| 632 |
</button>
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
{/* 文本区 */}
|
| 636 |
+
<div className="min-w-0 flex-1">
|
| 637 |
+
<div className="text-sm font-medium truncate" title={file.name}>
|
| 638 |
+
{file.name}
|
| 639 |
+
</div>
|
| 640 |
+
<div className="text-xs text-muted-foreground">{label}</div>
|
| 641 |
+
</div>
|
| 642 |
+
|
| 643 |
+
{/* ✅ 缩略图:更小更干净 */}
|
| 644 |
+
{isImage ? (
|
| 645 |
+
<div className="relative h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
|
| 646 |
+
{thumbUrl ? (
|
| 647 |
+
<img
|
| 648 |
+
src={thumbUrl}
|
| 649 |
+
alt={file.name}
|
| 650 |
+
className="h-full w-full object-cover"
|
| 651 |
+
draggable={false}
|
| 652 |
+
/>
|
| 653 |
+
) : (
|
| 654 |
+
<div className="h-full w-full flex items-center justify-center">
|
| 655 |
+
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
| 656 |
+
</div>
|
| 657 |
+
)}
|
| 658 |
+
</div>
|
| 659 |
+
) : null}
|
| 660 |
</div>
|
| 661 |
);
|
| 662 |
};
|
|
|
|
| 928 |
{/* Uploaded Files Preview (chip UI) */}
|
| 929 |
{(uploadedFiles.length > 0 || pendingFiles.length > 0) && (
|
| 930 |
<div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
| 931 |
+
{/* uploaded */}
|
| 932 |
+
{uploadedFiles.map((uf, i) => {
|
| 933 |
+
const key = `${uf.file.name}::${uf.file.size}::${uf.file.lastModified}`;
|
| 934 |
+
|
| 935 |
+
return (
|
| 936 |
+
<div key={key} className="flex items-center justify-between gap-2 rounded-md border px-3 py-2">
|
| 937 |
+
<div className="min-w-0">
|
| 938 |
+
<div className="truncate text-sm font-medium">{uf.file.name}</div>
|
| 939 |
+
<div className="text-xs text-muted-foreground">
|
| 940 |
+
{uf.type}
|
| 941 |
+
</div>
|
| 942 |
+
</div>
|
| 943 |
+
|
| 944 |
+
{/* 垃圾桶:直接删 props(UI + state 同步删) */}
|
| 945 |
+
<Button
|
| 946 |
+
variant="ghost"
|
| 947 |
+
size="icon"
|
| 948 |
+
onClick={() => onRemoveFile(i)} // 或者 onRemoveFile(uf) 也行(因为 App 现在兼容了)
|
| 949 |
+
title="Remove"
|
| 950 |
+
>
|
| 951 |
+
<Trash2 className="h-4 w-4" />
|
| 952 |
+
</Button>
|
| 953 |
+
</div>
|
| 954 |
+
);
|
| 955 |
+
})}
|
| 956 |
|
| 957 |
+
{/* pending (type dialog 之前也能显示的话) */}
|
| 958 |
{pendingFiles.map((p, idx) => (
|
| 959 |
<FileChip key={`p-${p.file.name}-${p.file.size}-${p.file.lastModified}`} file={p.file} index={idx} source="pending" />
|
| 960 |
))}
|
|
|
|
| 1066 |
: "Ask Clare anything about the course or drag files here..."
|
| 1067 |
}
|
| 1068 |
disabled={!isLoggedIn || (chatMode === "quiz" && !quizState.waitingForAnswer)}
|
| 1069 |
+
className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${
|
| 1070 |
+
isDragging ? "border-primary border-dashed" : "border-border"
|
| 1071 |
+
}`}
|
| 1072 |
/>
|
| 1073 |
|
| 1074 |
<div className="absolute bottom-2 right-2 flex gap-1">
|
|
|
|
| 1326 |
)}
|
| 1327 |
</div>
|
| 1328 |
);
|
| 1329 |
+
}
|