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 ( ); } 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) && ( )}
)} {chatStarted && (
{(!chatHistoryOpen || !isLargeScreen) && ( )}
setThreadId(null)} animate={{ marginLeft: !chatHistoryOpen ? 48 : 0, }} transition={{ type: "spring", stiffness: 300, damping: 30, }} > Agent Chat
setThreadId(null)} >
)} {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

)}
{imageUrlList.length > 0 && (
{imageUrlList.map((imageItemWrapper) => { const imageUrlString = `data:${imageItemWrapper.block.mime_type};base64,${imageItemWrapper.block.data}`; return (
uploaded setImageUrlList( imageUrlList.filter( (url) => url.id !== imageItemWrapper.id, ), ) } />
); })}
)} {pdfUrlList.length > 0 && (
{pdfUrlList.map((pdf) => (
{pdf.name} setPdfUrlList( pdfUrlList.filter((p) => p.id !== pdf.id), ) } />
))}
)}