Spaces:
Sleeping
Sleeping
Update web/src/components/ChatArea.tsx
Browse files- web/src/components/ChatArea.tsx +49 -71
web/src/components/ChatArea.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
// web/src/components/ChatArea.tsx
|
| 2 |
-
import React, {
|
|
|
|
|
|
|
| 3 |
import { Button } from "./ui/button";
|
| 4 |
import { Textarea } from "./ui/textarea";
|
| 5 |
import { Input } from "./ui/input";
|
|
@@ -10,7 +12,7 @@ import {
|
|
| 10 |
Share2,
|
| 11 |
Upload,
|
| 12 |
X,
|
| 13 |
-
Trash2,
|
| 14 |
File,
|
| 15 |
FileText,
|
| 16 |
Presentation,
|
|
@@ -64,7 +66,6 @@ interface ChatAreaProps {
|
|
| 64 |
onFileUpload: (files: File[]) => void;
|
| 65 |
onRemoveFile: (index: number) => void;
|
| 66 |
|
| 67 |
-
|
| 68 |
onFileTypeChange: (index: number, type: FileType) => void;
|
| 69 |
memoryProgress: number;
|
| 70 |
isLoggedIn: boolean;
|
|
@@ -562,7 +563,7 @@ export function ChatArea({
|
|
| 562 |
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
| 563 |
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
| 564 |
};
|
| 565 |
-
|
| 566 |
const fileKey = (f: File) => `${f.name}::${f.size}::${f.lastModified}`;
|
| 567 |
|
| 568 |
const removeUploadedByFile = (file: File) => {
|
|
@@ -570,7 +571,6 @@ export function ChatArea({
|
|
| 570 |
if (idx >= 0) onRemoveFile(idx);
|
| 571 |
};
|
| 572 |
|
| 573 |
-
|
| 574 |
// ✅ useObjectUrlCache: for image thumbnails (uploaded + pending)
|
| 575 |
const allThumbFiles = React.useMemo(() => {
|
| 576 |
return [...uploadedFiles.map((u) => u.file), ...pendingFiles.map((p) => p.file)];
|
|
@@ -589,7 +589,7 @@ export function ChatArea({
|
|
| 589 |
}) => {
|
| 590 |
const ext = file.name.toLowerCase();
|
| 591 |
const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
|
| 592 |
-
|
| 593 |
const label = ext.endsWith(".pdf")
|
| 594 |
? "PDF"
|
| 595 |
: ext.endsWith(".pptx") || ext.endsWith(".ppt")
|
|
@@ -599,62 +599,58 @@ export function ChatArea({
|
|
| 599 |
: isImage
|
| 600 |
? "Image"
|
| 601 |
: "File";
|
| 602 |
-
|
| 603 |
const thumbUrl = isImage ? getOrCreate(file) : null;
|
| 604 |
-
|
| 605 |
const handleRemove = () => {
|
| 606 |
if (source === "uploaded") {
|
| 607 |
-
// 关键:仍然走 index 删除(或按 file 找 index)
|
| 608 |
onRemoveFile(index);
|
| 609 |
-
//
|
| 610 |
-
// removeUploadedByFile(file);
|
| 611 |
} else {
|
| 612 |
setPendingFiles((prev) => prev.filter((p) => fileKey(p.file) !== fileKey(file)));
|
| 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 |
<button
|
| 620 |
type="button"
|
| 621 |
onClick={(e) => {
|
| 622 |
e.preventDefault();
|
| 623 |
e.stopPropagation();
|
| 624 |
-
|
| 625 |
}}
|
| 626 |
-
className="
|
| 627 |
title="Remove"
|
| 628 |
>
|
| 629 |
<Trash2 className="h-4 w-4" />
|
| 630 |
</button>
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
{/* 文本区 */}
|
| 634 |
-
<div className="min-w-0 flex-1">
|
| 635 |
-
<div className="text-sm font-medium truncate" title={file.name}>
|
| 636 |
-
{file.name}
|
| 637 |
-
</div>
|
| 638 |
-
<div className="text-xs text-muted-foreground">{label}</div>
|
| 639 |
-
</div>
|
| 640 |
-
|
| 641 |
-
{/* ✅ 缩略图:更小更干净 */}
|
| 642 |
-
{isImage ? (
|
| 643 |
-
<div className="relative h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
|
| 644 |
-
{thumbUrl ? (
|
| 645 |
-
<img
|
| 646 |
-
src={thumbUrl}
|
| 647 |
-
alt={file.name}
|
| 648 |
-
className="h-full w-full object-cover"
|
| 649 |
-
draggable={false}
|
| 650 |
-
/>
|
| 651 |
-
) : (
|
| 652 |
-
<div className="h-full w-full flex items-center justify-center">
|
| 653 |
-
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
| 654 |
-
</div>
|
| 655 |
-
)}
|
| 656 |
-
</div>
|
| 657 |
-
) : null}
|
| 658 |
</div>
|
| 659 |
);
|
| 660 |
};
|
|
@@ -926,33 +922,17 @@ export function ChatArea({
|
|
| 926 |
{/* Uploaded Files Preview (chip UI) */}
|
| 927 |
{(uploadedFiles.length > 0 || pendingFiles.length > 0) && (
|
| 928 |
<div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
| 929 |
-
{/* uploaded */}
|
| 930 |
-
{uploadedFiles.map((uf, i) =>
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
{uf.type}
|
| 939 |
-
</div>
|
| 940 |
-
</div>
|
| 941 |
-
|
| 942 |
-
{/* 垃圾桶:直接删 props(UI + state 同步删) */}
|
| 943 |
-
<Button
|
| 944 |
-
variant="ghost"
|
| 945 |
-
size="icon"
|
| 946 |
-
onClick={() => onRemoveFile(i)} // 或者 onRemoveFile(uf) 也行(因为 App 现在兼容了)
|
| 947 |
-
title="Remove"
|
| 948 |
-
>
|
| 949 |
-
<Trash2 className="h-4 w-4" />
|
| 950 |
-
</Button>
|
| 951 |
-
</div>
|
| 952 |
-
);
|
| 953 |
-
})}
|
| 954 |
|
| 955 |
-
{/* pending
|
| 956 |
{pendingFiles.map((p, idx) => (
|
| 957 |
<FileChip key={`p-${p.file.name}-${p.file.size}-${p.file.lastModified}`} file={p.file} index={idx} source="pending" />
|
| 958 |
))}
|
|
@@ -1064,9 +1044,7 @@ export function ChatArea({
|
|
| 1064 |
: "Ask Clare anything about the course or drag files here..."
|
| 1065 |
}
|
| 1066 |
disabled={!isLoggedIn || (chatMode === "quiz" && !quizState.waitingForAnswer)}
|
| 1067 |
-
className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${
|
| 1068 |
-
isDragging ? "border-primary border-dashed" : "border-border"
|
| 1069 |
-
}`}
|
| 1070 |
/>
|
| 1071 |
|
| 1072 |
<div className="absolute bottom-2 right-2 flex gap-1">
|
|
|
|
| 1 |
// web/src/components/ChatArea.tsx
|
| 2 |
+
import React, { useRef, useLayoutEffect } from "react";
|
| 3 |
+
import React, { useEffect, useMemo, useState } from "react";
|
| 4 |
+
|
| 5 |
import { Button } from "./ui/button";
|
| 6 |
import { Textarea } from "./ui/textarea";
|
| 7 |
import { Input } from "./ui/input";
|
|
|
|
| 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 |
onFileTypeChange: (index: number, type: FileType) => void;
|
| 70 |
memoryProgress: number;
|
| 71 |
isLoggedIn: boolean;
|
|
|
|
| 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) => {
|
|
|
|
| 571 |
if (idx >= 0) onRemoveFile(idx);
|
| 572 |
};
|
| 573 |
|
|
|
|
| 574 |
// ✅ useObjectUrlCache: for image thumbnails (uploaded + pending)
|
| 575 |
const allThumbFiles = React.useMemo(() => {
|
| 576 |
return [...uploadedFiles.map((u) => u.file), ...pendingFiles.map((p) => p.file)];
|
|
|
|
| 589 |
}) => {
|
| 590 |
const ext = file.name.toLowerCase();
|
| 591 |
const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
|
| 592 |
+
|
| 593 |
const label = ext.endsWith(".pdf")
|
| 594 |
? "PDF"
|
| 595 |
: ext.endsWith(".pptx") || ext.endsWith(".ppt")
|
|
|
|
| 599 |
: isImage
|
| 600 |
? "Image"
|
| 601 |
: "File";
|
| 602 |
+
|
| 603 |
const thumbUrl = isImage ? getOrCreate(file) : null;
|
| 604 |
+
|
| 605 |
const handleRemove = () => {
|
| 606 |
if (source === "uploaded") {
|
|
|
|
| 607 |
onRemoveFile(index);
|
| 608 |
+
// 或:removeUploadedByFile(file);
|
|
|
|
| 609 |
} else {
|
| 610 |
setPendingFiles((prev) => prev.filter((p) => fileKey(p.file) !== fileKey(file)));
|
| 611 |
}
|
| 612 |
};
|
| 613 |
+
|
| 614 |
return (
|
| 615 |
<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">
|
| 616 |
+
{/* ✅ 缩略图:右侧小图(上传图片时显示) */}
|
| 617 |
+
{isImage ? (
|
| 618 |
+
<div className="relative h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
|
| 619 |
+
{thumbUrl ? (
|
| 620 |
+
<img src={thumbUrl} alt={file.name} className="h-full w-full object-cover" draggable={false} />
|
| 621 |
+
) : (
|
| 622 |
+
<div className="h-full w-full flex items-center justify-center">
|
| 623 |
+
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
| 624 |
+
</div>
|
| 625 |
+
)}
|
| 626 |
+
</div>
|
| 627 |
+
) : (
|
| 628 |
+
<div className="relative h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted flex items-center justify-center">
|
| 629 |
+
<File className="h-4 w-4 text-muted-foreground" />
|
| 630 |
+
</div>
|
| 631 |
+
)}
|
| 632 |
+
|
| 633 |
+
{/* 文本区 */}
|
| 634 |
+
<div className="min-w-0 flex-1">
|
| 635 |
+
<div className="text-sm font-medium truncate" title={file.name}>
|
| 636 |
+
{file.name}
|
| 637 |
+
</div>
|
| 638 |
+
<div className="text-xs text-muted-foreground">{label}</div>
|
| 639 |
+
</div>
|
| 640 |
+
|
| 641 |
+
{/* ✅ 删除 */}
|
| 642 |
<button
|
| 643 |
type="button"
|
| 644 |
onClick={(e) => {
|
| 645 |
e.preventDefault();
|
| 646 |
e.stopPropagation();
|
| 647 |
+
handleRemove();
|
| 648 |
}}
|
| 649 |
+
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-card hover:bg-muted"
|
| 650 |
title="Remove"
|
| 651 |
>
|
| 652 |
<Trash2 className="h-4 w-4" />
|
| 653 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
</div>
|
| 655 |
);
|
| 656 |
};
|
|
|
|
| 922 |
{/* Uploaded Files Preview (chip UI) */}
|
| 923 |
{(uploadedFiles.length > 0 || pendingFiles.length > 0) && (
|
| 924 |
<div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
| 925 |
+
{/* ✅ uploaded: 改为使用 FileChip,并显示缩略图 */}
|
| 926 |
+
{uploadedFiles.map((uf, i) => (
|
| 927 |
+
<FileChip
|
| 928 |
+
key={`u-${uf.file.name}-${uf.file.size}-${uf.file.lastModified}`}
|
| 929 |
+
file={uf.file}
|
| 930 |
+
index={i}
|
| 931 |
+
source="uploaded"
|
| 932 |
+
/>
|
| 933 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 934 |
|
| 935 |
+
{/* pending */}
|
| 936 |
{pendingFiles.map((p, idx) => (
|
| 937 |
<FileChip key={`p-${p.file.name}-${p.file.size}-${p.file.lastModified}`} file={p.file} index={idx} source="pending" />
|
| 938 |
))}
|
|
|
|
| 1044 |
: "Ask Clare anything about the course or drag files here..."
|
| 1045 |
}
|
| 1046 |
disabled={!isLoggedIn || (chatMode === "quiz" && !quizState.waitingForAnswer)}
|
| 1047 |
+
className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${isDragging ? "border-primary border-dashed" : "border-border"}`}
|
|
|
|
|
|
|
| 1048 |
/>
|
| 1049 |
|
| 1050 |
<div className="absolute bottom-2 right-2 flex gap-1">
|