feat: support drag and drop file upload
This commit is contained in:
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user