import { v4 as uuidv4 } from "uuid";
import { ReactNode, useEffect, useRef, ChangeEvent } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { useStreamContext } from "@/providers/Stream";
import { useState, FormEvent } from "react";
import { Button } from "../ui/button";
import { Checkpoint, Message } from "@langchain/langgraph-sdk";
import { AssistantMessage, AssistantMessageLoading } from "./messages/ai";
import { HumanMessage } from "./messages/human";
import {
DO_NOT_RENDER_ID_PREFIX,
ensureToolCallsHaveResponses,
} from "@/lib/ensure-tool-responses";
import { LangGraphLogoSVG } from "../icons/langgraph";
import { TooltipIconButton } from "./tooltip-icon-button";
import {
ArrowDown,
LoaderCircle,
PanelRightOpen,
PanelRightClose,
SquarePen,
Plus,
CircleX,
} from "lucide-react";
import { useQueryState, parseAsBoolean } from "nuqs";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import ThreadHistory from "./history";
import { toast } from "sonner";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { GitHubSVG } from "../icons/github";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import type { Base64ContentBlock } from "@/lib/pdf";
type MessageContentType = Message["content"];
interface UploadedBlock {
id: string;
name: string;
block: Base64ContentBlock;
}
function StickyToBottomContent(props: {
content: ReactNode;
footer?: ReactNode;
className?: string;
contentClassName?: string;
}) {
const context = useStickToBottomContext();
return (
{props.content}
{props.footer}
);
}
function ScrollToBottom(props: { className?: string }) {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
if (isAtBottom) return null;
return (
scrollToBottom()}
>
Scroll to bottom
);
}
function OpenGitHubRepo() {
return (
Open GitHub repo
);
}
export function Thread() {
const [threadId, setThreadId] = useQueryState("threadId");
const [chatHistoryOpen, setChatHistoryOpen] = useQueryState(
"chatHistoryOpen",
parseAsBoolean.withDefault(false),
);
const [hideToolCalls, setHideToolCalls] = useQueryState(
"hideToolCalls",
parseAsBoolean.withDefault(false),
);
const [input, setInput] = useState("");
const [imageUrlList, setImageUrlList] = useState([]);
const [pdfUrlList, setPdfUrlList] = useState([]);
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
const stream = useStreamContext();
const messages = stream.messages;
const isLoading = stream.isLoading;
const lastError = useRef(undefined);
const dropRef = useRef(null);
useEffect(() => {
if (!stream.error) {
lastError.current = undefined;
return;
}
try {
const message = (stream.error as any).message;
if (!message || lastError.current === message) {
// Message has already been logged. do not modify ref, return early.
return;
}
// Message is defined, and it has not been logged yet. Save it, and send the error
lastError.current = message;
toast.error("An error occurred. Please try again.", {
description: (
Error: {message}
),
richColors: true,
closeButton: true,
});
} catch {
// no-op
}
}, [stream.error]);
// TODO: this should be part of the useStream hook
const prevMessageLength = useRef(0);
useEffect(() => {
if (
messages.length !== prevMessageLength.current &&
messages?.length &&
messages[messages.length - 1].type === "ai"
) {
setFirstTokenReceived(true);
}
prevMessageLength.current = messages.length;
}, [messages]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
setFirstTokenReceived(false);
const newHumanMessage: Message = {
id: uuidv4(),
type: "human",
content: [
{ type: "text", text: input },
...imageUrlList.map((item) => item.block),
...pdfUrlList.map((item) => item.block),
] as MessageContentType,
};
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
stream.submit(
{ messages: [...toolMessages, newHumanMessage] },
{
streamMode: ["values"],
optimisticValues: (prev) => ({
...prev,
messages: [
...(prev.messages ?? []),
...toolMessages,
newHumanMessage,
],
}),
},
);
setInput("");
setImageUrlList([]);
};
const handleImageUpload = async (e: ChangeEvent) => {
const files = e.target.files;
if (files) {
const imageFiles: UploadedBlock[] = await Promise.all(
Array.from(files).map((file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
const match = result.match(/^data:(.*);base64/);
const mimeType = match && match[1] ? match[1] : file.type;
resolve({
id: uuidv4(),
name: file.name,
block: {
type: "image",
source_type: "base64",
data: base64,
mime_type: mimeType,
metadata: { name: file.name },
},
});
};
reader.readAsDataURL(file);
});
}),
);
setImageUrlList([...imageUrlList, ...imageFiles]);
}
e.target.value = "";
};
const handlePDFUpload = async (e: ChangeEvent) => {
const files = e.target.files;
if (files) {
const pdfFiles: UploadedBlock[] = await Promise.all(
Array.from(files).map((file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
const match = result.match(/^data:(.*);base64/);
const mimeType = match && match[1] ? match[1] : "application/pdf";
resolve({
id: uuidv4(),
name: file.name,
block: {
type: "file",
source_type: "base64",
data: base64,
mime_type: mimeType,
metadata: { name: file.name },
},
});
};
reader.readAsDataURL(file);
});
}),
);
console.log(pdfFiles[0]);
setPdfUrlList([...pdfUrlList, ...pdfFiles]);
}
e.target.value = "";
};
const handleRegenerate = (
parentCheckpoint: Checkpoint | null | undefined,
) => {
// Do this so the loading state is correct
prevMessageLength.current = prevMessageLength.current - 1;
setFirstTokenReceived(false);
stream.submit(undefined, {
checkpoint: parentCheckpoint,
streamMode: ["values"],
});
};
const chatStarted = !!threadId || !!messages.length;
const hasNoAIOrToolMessages = !messages.find(
(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/") || file.type !== "application/pdf",
)
) {
toast.error(
"You have uploaded invalid file type. Please upload an image or a PDF.",
);
}
/**
* If there are any image files in the dropped files, this block reads each image file as a data URL,
* wraps it in a MessageContentImageWrapper object, and updates the imageUrlList state with the new images.
* This enables preview and later sending of uploaded images in the chat UI.
*/
if (imageFiles.length) {
const imageFilesData: UploadedBlock[] = await Promise.all(
Array.from(imageFiles).map((file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
const match = result.match(/^data:(.*);base64/);
const mimeType = match && match[1] ? match[1] : file.type;
resolve({
id: uuidv4(),
name: file.name,
block: {
type: "image",
source_type: "base64",
data: base64,
mime_type: mimeType,
metadata: { name: file.name },
},
});
};
reader.readAsDataURL(file);
});
}),
);
setImageUrlList([...imageUrlList, ...imageFilesData]);
}
/**
* If there are any PDF files in the dropped files, this block previews the file name of each uploaded PDF
* by rendering a list of file names above the input area, with a remove button for each.
*/
if (files.some((file) => file.type === "application/pdf")) {
const pdfFiles = files.filter(
(file) => file.type === "application/pdf",
);
const pdfFilesData: UploadedBlock[] = await Promise.all(
pdfFiles.map((file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
const match = result.match(/^data:(.*);base64/);
const mimeType =
match && match[1] ? match[1] : "application/pdf";
resolve({
id: uuidv4(),
name: file.name,
block: {
type: "file",
source_type: "base64",
data: base64,
mime_type: mimeType,
metadata: { name: file.name },
},
});
};
reader.readAsDataURL(file);
});
}),
);
setPdfUrlList([...pdfUrlList, ...pdfFilesData]);
}
};
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 (
{!chatStarted && (
{(!chatHistoryOpen || !isLargeScreen) && (
setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
) : (
)}
)}
)}
{chatStarted && (
{(!chatHistoryOpen || !isLargeScreen) && (
setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
) : (
)}
)}
setThreadId(null)}
animate={{
marginLeft: !chatHistoryOpen ? 48 : 0,
}}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
>
Agent Chat
)}
{messages
.filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX))
.map((message, index) =>
message.type === "human" ? (
) : (
),
)}
{/* Special rendering case where there are no AI/tool messages, but there is an interrupt.
We need to render it outside of the messages list, since there are no messages to render */}
{hasNoAIOrToolMessages && !!stream.interrupt && (
)}
{isLoading && !firstTokenReceived && (
)}
>
}
footer={
{!chatStarted && (
Agent Chat
)}
}
/>
);
}