feat: support drag and drop file upload

This commit is contained in:
wuhaolei
2025-04-29 18:43:30 +08:00
parent ee1c084ef8
commit aa59470e05

View File

@@ -39,6 +39,11 @@ import {
} from "../ui/tooltip"; } from "../ui/tooltip";
import { MessageContentImageUrl } from "@langchain/core/messages"; import { MessageContentImageUrl } from "@langchain/core/messages";
interface MessageContentImageUrlWrapper {
id: string;
image: MessageContentImageUrl;
}
function StickyToBottomContent(props: { function StickyToBottomContent(props: {
content: ReactNode; content: ReactNode;
footer?: ReactNode; footer?: ReactNode;
@@ -115,7 +120,7 @@ export function Thread() {
parseAsBoolean.withDefault(false), parseAsBoolean.withDefault(false),
); );
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [imageUrlList, setImageUrlList] = useState<MessageContentImageUrl[]>( const [imageUrlList, setImageUrlList] = useState<MessageContentImageUrlWrapper[]>(
[], [],
); );
const [firstTokenReceived, setFirstTokenReceived] = useState(false); const [firstTokenReceived, setFirstTokenReceived] = useState(false);
@@ -127,6 +132,8 @@ export function Thread() {
const lastError = useRef<string | undefined>(undefined); const lastError = useRef<string | undefined>(undefined);
const dropRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!stream.error) { if (!stream.error) {
lastError.current = undefined; lastError.current = undefined;
@@ -182,7 +189,7 @@ export function Thread() {
type: "text", type: "text",
text: input, text: input,
}, },
...imageUrlList, ...imageUrlList.map((item) => item.image),
], ],
}; };
@@ -217,7 +224,7 @@ export function Thread() {
resolve({ resolve({
type: "image_url", type: "image_url",
image_url: { image_url: {
url: reader.result as string, url: reader.result as string
}, },
}); });
}; };
@@ -225,7 +232,11 @@ export function Thread() {
}); });
}), }),
); );
setImageUrlList([...imageUrlList, ...imageUrls]); const wrappedImages = imageUrls.map((image) => ({
id: uuidv4(),
image,
}));
setImageUrlList([...imageUrlList, ...wrappedImages]);
} }
e.target.value = ""; e.target.value = "";
}; };
@@ -247,6 +258,79 @@ export function Thread() {
(m) => m.type === "ai" || m.type === "tool", (m) => m.type === "ai" || m.type === "tool",
); );
useEffect(() => {
if (!dropRef.current) return;
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!e.dataTransfer) return;
const files = Array.from(e.dataTransfer.files);
const imageFiles = files.filter((file) =>
file.type.startsWith("image/"),
);
if (files.some(file => !file.type.startsWith("image/"))) {
toast.error("You have uploaded invalid file type. Please upload an image.");
}
if (imageFiles.length) {
const imageUrls = await Promise.all(
Array.from(imageFiles).map((file) => {
return new Promise<MessageContentImageUrl>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve({
type: "image_url",
image_url: {
url: reader.result as string,
},
});
};
reader.readAsDataURL(file);
});
}),
);
const wrappedImages = imageUrls.map((image) => ({
id: uuidv4(),
image,
}));
setImageUrlList([...imageUrlList, ...wrappedImages]);
}
};
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const element = dropRef.current;
element.addEventListener("dragover", handleDragOver);
element.addEventListener("drop", handleDrop);
element.addEventListener("dragenter", handleDragEnter);
element.addEventListener("dragleave", handleDragLeave);
return () => {
element.removeEventListener("dragover", handleDragOver);
element.removeEventListener("drop", handleDrop);
element.removeEventListener("dragenter", handleDragEnter);
element.removeEventListener("dragleave", handleDragLeave);
};
});
return ( return (
<div className="flex h-screen w-full overflow-hidden"> <div className="flex h-screen w-full overflow-hidden">
<div className="relative hidden lg:flex"> <div className="relative hidden lg:flex">
@@ -430,22 +514,25 @@ export function Thread() {
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95 absolute bottom-full left-1/2 mb-4 -translate-x-1/2" /> <ScrollToBottom className="animate-in fade-in-0 zoom-in-95 absolute bottom-full left-1/2 mb-4 -translate-x-1/2" />
<div className="bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl border shadow-xs"> <div
ref={dropRef}
className="bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl border shadow-xs"
>
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2" className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2"
> >
{imageUrlList.length > 0 && ( {imageUrlList.length > 0 && (
<div className="flex flex-wrap gap-2 p-3.5 pb-0"> <div className="flex flex-wrap gap-2 p-3.5 pb-0">
{imageUrlList.map((imageUrl) => { {imageUrlList.map((imageItemWrapper) => {
const imageUrlString = const imageUrlString =
typeof imageUrl.image_url === "string" typeof imageItemWrapper.image.image_url === "string"
? imageUrl.image_url ? imageItemWrapper.image.image_url
: imageUrl.image_url.url; : imageItemWrapper.image.image_url.url;
return ( return (
<div <div
className="relative" className="relative"
key={imageUrlString} key={imageItemWrapper.id}
> >
<img <img
src={imageUrlString} src={imageUrlString}
@@ -457,7 +544,7 @@ export function Thread() {
onClick={() => onClick={() =>
setImageUrlList( setImageUrlList(
imageUrlList.filter( imageUrlList.filter(
(url) => url !== imageUrl, (url) => url.id !== imageItemWrapper.id,
), ),
) )
} }