Files
agent-chat-ui/src/components/thread/index.tsx
2025-05-15 16:42:04 -07:00

746 lines
26 KiB
TypeScript

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 (
<div
ref={context.scrollRef}
style={{ width: "100%", height: "100%" }}
className={props.className}
>
<div
ref={context.contentRef}
className={props.contentClassName}
>
{props.content}
</div>
{props.footer}
</div>
);
}
function ScrollToBottom(props: { className?: string }) {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
if (isAtBottom) return null;
return (
<Button
variant="outline"
className={props.className}
onClick={() => scrollToBottom()}
>
<ArrowDown className="h-4 w-4" />
<span>Scroll to bottom</span>
</Button>
);
}
function OpenGitHubRepo() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://github.com/langchain-ai/agent-chat-ui"
target="_blank"
className="flex items-center justify-center"
>
<GitHubSVG
width="24"
height="24"
/>
</a>
</TooltipTrigger>
<TooltipContent side="left">
<p>Open GitHub repo</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
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<UploadedBlock[]>([]);
const [pdfUrlList, setPdfUrlList] = useState<UploadedBlock[]>([]);
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<string | undefined>(undefined);
const dropRef = useRef<HTMLDivElement>(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: (
<p>
<strong>Error:</strong> <code>{message}</code>
</p>
),
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<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
const imageFiles: UploadedBlock[] = await Promise.all(
Array.from(files).map((file) => {
return new Promise<UploadedBlock>((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<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
const pdfFiles: UploadedBlock[] = await Promise.all(
Array.from(files).map((file) => {
return new Promise<UploadedBlock>((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<UploadedBlock>((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<UploadedBlock>((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 (
<div className="flex h-screen w-full overflow-hidden">
<div className="relative hidden lg:flex">
<motion.div
className="absolute z-20 h-full overflow-hidden border-r bg-white"
style={{ width: 300 }}
animate={
isLargeScreen
? { x: chatHistoryOpen ? 0 : -300 }
: { x: chatHistoryOpen ? 0 : -300 }
}
initial={{ x: -300 }}
transition={
isLargeScreen
? { type: "spring", stiffness: 300, damping: 30 }
: { duration: 0 }
}
>
<div
className="relative h-full"
style={{ width: 300 }}
>
<ThreadHistory />
</div>
</motion.div>
</div>
<motion.div
className={cn(
"relative flex min-w-0 flex-1 flex-col overflow-hidden",
!chatStarted && "grid-rows-[1fr]",
)}
layout={isLargeScreen}
animate={{
marginLeft: chatHistoryOpen ? (isLargeScreen ? 300 : 0) : 0,
width: chatHistoryOpen
? isLargeScreen
? "calc(100% - 300px)"
: "100%"
: "100%",
}}
transition={
isLargeScreen
? { type: "spring", stiffness: 300, damping: 30 }
: { duration: 0 }
}
>
{!chatStarted && (
<div className="absolute top-0 left-0 z-10 flex w-full items-center justify-between gap-3 p-2 pl-4">
<div>
{(!chatHistoryOpen || !isLargeScreen) && (
<Button
className="hover:bg-gray-100"
variant="ghost"
onClick={() => setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
<PanelRightOpen className="size-5" />
) : (
<PanelRightClose className="size-5" />
)}
</Button>
)}
</div>
<div className="absolute top-2 right-4 flex items-center">
<OpenGitHubRepo />
</div>
</div>
)}
{chatStarted && (
<div className="relative z-10 flex items-center justify-between gap-3 p-2">
<div className="relative flex items-center justify-start gap-2">
<div className="absolute left-0 z-10">
{(!chatHistoryOpen || !isLargeScreen) && (
<Button
className="hover:bg-gray-100"
variant="ghost"
onClick={() => setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
<PanelRightOpen className="size-5" />
) : (
<PanelRightClose className="size-5" />
)}
</Button>
)}
</div>
<motion.button
className="flex cursor-pointer items-center gap-2"
onClick={() => setThreadId(null)}
animate={{
marginLeft: !chatHistoryOpen ? 48 : 0,
}}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
>
<LangGraphLogoSVG
width={32}
height={32}
/>
<span className="text-xl font-semibold tracking-tight">
Agent Chat
</span>
</motion.button>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center">
<OpenGitHubRepo />
</div>
<TooltipIconButton
size="lg"
className="p-4"
tooltip="New thread"
variant="ghost"
onClick={() => setThreadId(null)}
>
<SquarePen className="size-5" />
</TooltipIconButton>
</div>
<div className="from-background to-background/0 absolute inset-x-0 top-full h-5 bg-gradient-to-b" />
</div>
)}
<StickToBottom className="relative flex-1 overflow-hidden">
<StickyToBottomContent
className={cn(
"absolute inset-0 overflow-y-scroll px-4 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
!chatStarted && "mt-[25vh] flex flex-col items-stretch",
chatStarted && "grid grid-rows-[1fr_auto]",
)}
contentClassName="pt-8 pb-16 max-w-3xl mx-auto flex flex-col gap-4 w-full"
content={
<>
{messages
.filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX))
.map((message, index) =>
message.type === "human" ? (
<HumanMessage
key={message.id || `${message.type}-${index}`}
message={message}
isLoading={isLoading}
/>
) : (
<AssistantMessage
key={message.id || `${message.type}-${index}`}
message={message}
isLoading={isLoading}
handleRegenerate={handleRegenerate}
/>
),
)}
{/* 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 && (
<AssistantMessage
key="interrupt-msg"
message={undefined}
isLoading={isLoading}
handleRegenerate={handleRegenerate}
/>
)}
{isLoading && !firstTokenReceived && (
<AssistantMessageLoading />
)}
</>
}
footer={
<div className="sticky bottom-0 flex flex-col items-center gap-8 bg-white">
{!chatStarted && (
<div className="flex items-center gap-3">
<LangGraphLogoSVG className="h-8 flex-shrink-0" />
<h1 className="text-2xl font-semibold tracking-tight">
Agent Chat
</h1>
</div>
)}
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95 absolute bottom-full left-1/2 mb-4 -translate-x-1/2" />
<div
ref={dropRef}
className="bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl border shadow-xs"
>
<form
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((imageItemWrapper) => {
const imageUrlString = `data:${imageItemWrapper.block.mime_type};base64,${imageItemWrapper.block.data}`;
return (
<div
className="relative"
key={imageItemWrapper.id}
>
<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.id !== imageItemWrapper.id,
),
)
}
/>
</div>
);
})}
</div>
)}
{pdfUrlList.length > 0 && (
<div className="flex flex-wrap gap-2 p-3.5 pb-0">
{pdfUrlList.map((pdf) => (
<div
className="relative flex items-center gap-2 rounded rounded-md border-1 border-teal-700 bg-gray-100 bg-teal-900 px-2 py-1 py-2 text-white"
key={pdf.id}
>
<span className="max-w-xs truncate text-sm">
{pdf.name}
</span>
<CircleX
className="size-4 cursor-pointer text-teal-600 hover:text-teal-500"
onClick={() =>
setPdfUrlList(
pdfUrlList.filter((p) => p.id !== pdf.id),
)
}
/>
</div>
))}
</div>
)}
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.metaKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
const el = e.target as HTMLElement | undefined;
const form = el?.closest("form");
form?.requestSubmit();
}
}}
placeholder="Type your message..."
className="field-sizing-content resize-none border-none bg-transparent p-3.5 pb-0 shadow-none ring-0 outline-none focus:ring-0 focus:outline-none"
/>
<div className="flex items-center justify-between p-2 pt-4">
<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 PDF
</span>
</Label>
<input
id="file-input"
type="file"
onChange={handlePDFUpload}
multiple
accept="application/pdf"
className="hidden"
/>
<div className="flex items-center space-x-2">
<Switch
id="render-tool-calls"
checked={hideToolCalls ?? false}
onCheckedChange={setHideToolCalls}
/>
<Label
htmlFor="render-tool-calls"
className="text-sm text-gray-600"
>
Hide Tool Calls
</Label>
</div>
</div>
{stream.isLoading ? (
<Button
key="stop"
onClick={() => stream.stop()}
>
<LoaderCircle className="h-4 w-4 animate-spin" />
Cancel
</Button>
) : (
<Button
type="submit"
className="shadow-md transition-all"
disabled={isLoading || !input.trim()}
>
Send
</Button>
)}
</div>
</form>
</div>
</div>
}
/>
</StickToBottom>
</motion.div>
</div>
);
}