feat : Support file uploads #56
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
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 { motion } from "framer-motion";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useStreamContext } from "@/providers/Stream";
|
import { useStreamContext } from "@/providers/Stream";
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
|
Plus,
|
||||||
|
CircleX,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQueryState, parseAsBoolean } from "nuqs";
|
import { useQueryState, parseAsBoolean } from "nuqs";
|
||||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "../ui/tooltip";
|
} from "../ui/tooltip";
|
||||||
|
import { MessageContentImageUrl } from "@langchain/core/messages";
|
||||||
|
|
||||||
function StickyToBottomContent(props: {
|
function StickyToBottomContent(props: {
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
@@ -112,6 +115,9 @@ export function Thread() {
|
|||||||
parseAsBoolean.withDefault(false),
|
parseAsBoolean.withDefault(false),
|
||||||
);
|
);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
|
const [imageUrlList, setImageUrlList] = useState<MessageContentImageUrl[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
|
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
|
||||||
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
|
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
|
||||||
|
|
||||||
@@ -171,7 +177,13 @@ export function Thread() {
|
|||||||
const newHumanMessage: Message = {
|
const newHumanMessage: Message = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
type: "human",
|
type: "human",
|
||||||
content: input,
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: input,
|
||||||
|
},
|
||||||
|
...imageUrlList,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
|
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
|
||||||
@@ -191,6 +203,31 @@ export function Thread() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setInput("");
|
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 = (
|
const handleRegenerate = (
|
||||||
@@ -398,6 +435,38 @@ export function Thread() {
|
|||||||
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 && (
|
||||||
|
<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
|
<textarea
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
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 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">
|
<div className="flex items-center space-x-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="render-tool-calls"
|
id="render-tool-calls"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useStreamContext } from "@/providers/Stream";
|
import { useStreamContext } from "@/providers/Stream";
|
||||||
import { Message } from "@langchain/langgraph-sdk";
|
import { Message } from "@langchain/langgraph-sdk";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { getContentString } from "../utils";
|
import { getContentImageUrls, getContentString } from "../utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { BranchSwitcher, CommandBar } from "./shared";
|
import { BranchSwitcher, CommandBar } from "./shared";
|
||||||
@@ -46,6 +46,7 @@ export function HumanMessage({
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const contentString = getContentString(message.content);
|
const contentString = getContentString(message.content);
|
||||||
|
const contentImageUrls = getContentImageUrls(message.content);
|
||||||
|
|
||||||
const handleSubmitEdit = () => {
|
const handleSubmitEdit = () => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -84,9 +85,22 @@ export function HumanMessage({
|
|||||||
onSubmit={handleSubmitEdit}
|
onSubmit={handleSubmitEdit}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="bg-muted ml-auto w-fit rounded-3xl px-4 py-2 whitespace-pre-wrap">
|
<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}
|
{contentString}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -7,3 +7,13 @@ export function getContentString(content: Message["content"]): string {
|
|||||||
.map((c) => c.text);
|
.map((c) => c.text);
|
||||||
return texts.join(" ");
|
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