feat : Support file uploads #56

This commit is contained in:
neulhan
2025-04-28 10:22:17 +09:00
parent 72d92a5bca
commit ee1c084ef8
3 changed files with 117 additions and 7 deletions

View File

@@ -1,5 +1,5 @@
import { v4 as uuidv4 } from "uuid";
import { ReactNode, useEffect, useRef } from "react";
import { ReactNode, useEffect, useRef, ChangeEvent } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { useStreamContext } from "@/providers/Stream";
@@ -20,6 +20,8 @@ import {
PanelRightOpen,
PanelRightClose,
SquarePen,
Plus,
CircleX,
} from "lucide-react";
import { useQueryState, parseAsBoolean } from "nuqs";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
@@ -35,6 +37,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import { MessageContentImageUrl } from "@langchain/core/messages";
function StickyToBottomContent(props: {
content: ReactNode;
@@ -112,6 +115,9 @@ export function Thread() {
parseAsBoolean.withDefault(false),
);
const [input, setInput] = useState("");
const [imageUrlList, setImageUrlList] = useState<MessageContentImageUrl[]>(
[],
);
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
@@ -171,7 +177,13 @@ export function Thread() {
const newHumanMessage: Message = {
id: uuidv4(),
type: "human",
content: input,
content: [
{
type: "text",
text: input,
},
...imageUrlList,
],
};
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
@@ -191,6 +203,31 @@ export function Thread() {
);
setInput("");
setImageUrlList([]);
};
const handleImageUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
const imageUrls = await Promise.all(
Array.from(files).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);
});
}),
);
setImageUrlList([...imageUrlList, ...imageUrls]);
}
e.target.value = "";
};
const handleRegenerate = (
@@ -398,6 +435,38 @@ export function Thread() {
onSubmit={handleSubmit}
className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2"
>
{imageUrlList.length > 0 && (
<div className="flex flex-wrap gap-2 p-3.5 pb-0">
{imageUrlList.map((imageUrl) => {
const imageUrlString =
typeof imageUrl.image_url === "string"
? imageUrl.image_url
: imageUrl.image_url.url;
return (
<div
className="relative"
key={imageUrlString}
>
<img
src={imageUrlString}
alt="uploaded"
className="h-16 w-16 rounded-md object-cover"
/>
<CircleX
className="absolute top-[2px] right-[2px] size-4 cursor-pointer rounded-full bg-gray-500 text-white"
onClick={() =>
setImageUrlList(
imageUrlList.filter(
(url) => url !== imageUrl,
),
)
}
/>
</div>
);
})}
</div>
)}
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -419,7 +488,24 @@ export function Thread() {
/>
<div className="flex items-center justify-between p-2 pt-4">
<div>
<div className="flex items-center gap-2">
<Label
htmlFor="file-input"
className="flex cursor-pointer items-center gap-2"
>
<Plus className="size-5 text-gray-600" />
<span className="text-sm text-gray-600">
Upload Images
</span>
</Label>
<input
id="file-input"
type="file"
onChange={handleImageUpload}
multiple
accept="image/*"
className="hidden"
/>
<div className="flex items-center space-x-2">
<Switch
id="render-tool-calls"

View File

@@ -1,7 +1,7 @@
import { useStreamContext } from "@/providers/Stream";
import { Message } from "@langchain/langgraph-sdk";
import { useState } from "react";
import { getContentString } from "../utils";
import { getContentImageUrls, getContentString } from "../utils";
import { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea";
import { BranchSwitcher, CommandBar } from "./shared";
@@ -46,6 +46,7 @@ export function HumanMessage({
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState("");
const contentString = getContentString(message.content);
const contentImageUrls = getContentImageUrls(message.content);
const handleSubmitEdit = () => {
setIsEditing(false);
@@ -84,9 +85,22 @@ export function HumanMessage({
onSubmit={handleSubmitEdit}
/>
) : (
<p className="bg-muted ml-auto w-fit rounded-3xl px-4 py-2 whitespace-pre-wrap">
{contentString}
</p>
<div className="flex flex-col gap-2">
{contentImageUrls.length > 0 && (
<div className="flex flex-wrap justify-end gap-2">
{contentImageUrls.map((imageUrl) => (
<img
src={imageUrl}
alt="uploaded image"
className="bg-muted h-16 w-16 rounded-md object-cover"
/>
))}
</div>
)}
<p className="bg-muted ml-auto w-fit rounded-3xl px-4 py-2 text-right whitespace-pre-wrap">
{contentString}
</p>
</div>
)}
<div

View File

@@ -7,3 +7,13 @@ export function getContentString(content: Message["content"]): string {
.map((c) => c.text);
return texts.join(" ");
}
export function getContentImageUrls(content: Message["content"]): string[] {
if (typeof content === "string") return [];
return content
.filter((c) => c.type === "image_url")
.map((c) => {
if (typeof c.image_url === "string") return c.image_url;
return c.image_url.url;
});
}