feat : Support file uploads #56
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user