2025-03-03 12:40:24 -08:00
|
|
|
import { v4 as uuidv4 } from "uuid";
|
2025-04-28 10:22:17 +09:00
|
|
|
import { ReactNode, useEffect, useRef, ChangeEvent } from "react";
|
2025-03-06 15:58:02 -08:00
|
|
|
import { motion } from "framer-motion";
|
2025-03-03 12:31:27 -08:00
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { useStreamContext } from "@/providers/Stream";
|
|
|
|
|
import { useState, FormEvent } from "react";
|
|
|
|
|
import { Button } from "../ui/button";
|
2025-03-03 13:24:24 -08:00
|
|
|
import { Checkpoint, Message } from "@langchain/langgraph-sdk";
|
2025-03-03 12:31:27 -08:00
|
|
|
import { AssistantMessage, AssistantMessageLoading } from "./messages/ai";
|
|
|
|
|
import { HumanMessage } from "./messages/human";
|
2025-03-03 12:40:24 -08:00
|
|
|
import {
|
|
|
|
|
DO_NOT_RENDER_ID_PREFIX,
|
|
|
|
|
ensureToolCallsHaveResponses,
|
|
|
|
|
} from "@/lib/ensure-tool-responses";
|
2025-03-03 12:51:21 -08:00
|
|
|
import { LangGraphLogoSVG } from "../icons/langgraph";
|
2025-03-03 13:13:57 -08:00
|
|
|
import { TooltipIconButton } from "./tooltip-icon-button";
|
2025-03-04 11:01:19 -08:00
|
|
|
import {
|
|
|
|
|
ArrowDown,
|
|
|
|
|
LoaderCircle,
|
|
|
|
|
PanelRightOpen,
|
2025-03-12 01:27:22 +05:30
|
|
|
PanelRightClose,
|
2025-03-04 11:01:19 -08:00
|
|
|
SquarePen,
|
2025-04-28 10:22:17 +09:00
|
|
|
Plus,
|
|
|
|
|
CircleX,
|
2025-03-04 11:01:19 -08:00
|
|
|
} from "lucide-react";
|
2025-03-17 13:14:47 -07:00
|
|
|
import { useQueryState, parseAsBoolean } from "nuqs";
|
2025-03-04 15:37:40 +01:00
|
|
|
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
2025-03-04 10:34:52 -08:00
|
|
|
import ThreadHistory from "./history";
|
2025-03-04 13:37:42 -08:00
|
|
|
import { toast } from "sonner";
|
2025-03-06 15:58:02 -08:00
|
|
|
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
2025-03-11 10:59:49 -07:00
|
|
|
import { Label } from "../ui/label";
|
|
|
|
|
import { Switch } from "../ui/switch";
|
2025-04-02 13:33:11 -07:00
|
|
|
import { GitHubSVG } from "../icons/github";
|
|
|
|
|
import {
|
|
|
|
|
Tooltip,
|
|
|
|
|
TooltipContent,
|
|
|
|
|
TooltipProvider,
|
|
|
|
|
TooltipTrigger,
|
|
|
|
|
} from "../ui/tooltip";
|
2025-05-15 15:41:18 -07:00
|
|
|
import { MessageContentImageUrl, MessageContentText } from "@langchain/core/messages";
|
|
|
|
|
import { extractPdfText } from "@/lib/pdf";
|
|
|
|
|
|
|
|
|
|
|
2025-03-03 12:31:27 -08:00
|
|
|
|
2025-04-29 18:43:30 +08:00
|
|
|
interface MessageContentImageUrlWrapper {
|
|
|
|
|
id: string;
|
|
|
|
|
image: MessageContentImageUrl;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-15 15:41:18 -07:00
|
|
|
interface MessageContentPdfWrapper {
|
|
|
|
|
id: string;
|
|
|
|
|
pdf: MessageContentText;
|
|
|
|
|
name: string;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-04 15:37:40 +01:00
|
|
|
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}
|
|
|
|
|
>
|
2025-04-11 11:46:23 +09:00
|
|
|
<div
|
|
|
|
|
ref={context.contentRef}
|
|
|
|
|
className={props.contentClassName}
|
|
|
|
|
>
|
2025-03-04 15:37:40 +01:00
|
|
|
{props.content}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{props.footer}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-04 15:56:36 +01:00
|
|
|
function ScrollToBottom(props: { className?: string }) {
|
|
|
|
|
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
|
|
|
|
|
|
|
|
|
if (isAtBottom) return null;
|
|
|
|
|
return (
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
className={props.className}
|
|
|
|
|
onClick={() => scrollToBottom()}
|
|
|
|
|
>
|
2025-04-11 11:46:23 +09:00
|
|
|
<ArrowDown className="h-4 w-4" />
|
2025-03-04 15:56:36 +01:00
|
|
|
<span>Scroll to bottom</span>
|
|
|
|
|
</Button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-02 13:33:11 -07:00
|
|
|
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"
|
|
|
|
|
>
|
2025-04-11 11:46:23 +09:00
|
|
|
<GitHubSVG
|
|
|
|
|
width="24"
|
|
|
|
|
height="24"
|
|
|
|
|
/>
|
2025-04-02 13:33:11 -07:00
|
|
|
</a>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="left">
|
|
|
|
|
<p>Open GitHub repo</p>
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-03 12:31:27 -08:00
|
|
|
export function Thread() {
|
2025-03-17 13:13:28 -07:00
|
|
|
const [threadId, setThreadId] = useQueryState("threadId");
|
|
|
|
|
const [chatHistoryOpen, setChatHistoryOpen] = useQueryState(
|
2025-03-04 11:01:19 -08:00
|
|
|
"chatHistoryOpen",
|
2025-03-17 13:13:28 -07:00
|
|
|
parseAsBoolean.withDefault(false),
|
2025-03-04 11:01:19 -08:00
|
|
|
);
|
2025-03-17 13:13:28 -07:00
|
|
|
const [hideToolCalls, setHideToolCalls] = useQueryState(
|
2025-03-11 10:59:49 -07:00
|
|
|
"hideToolCalls",
|
2025-03-17 13:13:28 -07:00
|
|
|
parseAsBoolean.withDefault(false),
|
2025-03-11 10:59:49 -07:00
|
|
|
);
|
2025-03-03 12:31:27 -08:00
|
|
|
const [input, setInput] = useState("");
|
2025-04-29 18:43:30 +08:00
|
|
|
const [imageUrlList, setImageUrlList] = useState<MessageContentImageUrlWrapper[]>(
|
2025-04-28 10:22:17 +09:00
|
|
|
[],
|
|
|
|
|
);
|
2025-05-15 15:41:18 -07:00
|
|
|
const [pdfUrlList, setPdfUrlList] = useState<MessageContentPdfWrapper[]>(
|
|
|
|
|
[],
|
|
|
|
|
);
|
2025-03-03 12:31:27 -08:00
|
|
|
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
|
2025-03-06 15:58:02 -08:00
|
|
|
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
|
2025-03-04 15:54:43 +01:00
|
|
|
|
2025-03-03 12:31:27 -08:00
|
|
|
const stream = useStreamContext();
|
|
|
|
|
const messages = stream.messages;
|
|
|
|
|
const isLoading = stream.isLoading;
|
|
|
|
|
|
2025-03-04 13:37:42 -08:00
|
|
|
const lastError = useRef<string | undefined>(undefined);
|
|
|
|
|
|
2025-04-29 18:43:30 +08:00
|
|
|
const dropRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
2025-03-04 13:37:42 -08:00
|
|
|
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]);
|
|
|
|
|
|
2025-03-04 14:44:55 +01:00
|
|
|
// TODO: this should be part of the useStream hook
|
2025-03-04 15:54:43 +01:00
|
|
|
const prevMessageLength = useRef(0);
|
2025-03-03 12:31:27 -08:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (
|
|
|
|
|
messages.length !== prevMessageLength.current &&
|
|
|
|
|
messages?.length &&
|
|
|
|
|
messages[messages.length - 1].type === "ai"
|
|
|
|
|
) {
|
|
|
|
|
setFirstTokenReceived(true);
|
|
|
|
|
}
|
2025-03-04 15:46:40 +01:00
|
|
|
|
|
|
|
|
prevMessageLength.current = messages.length;
|
2025-03-03 12:31:27 -08:00
|
|
|
}, [messages]);
|
|
|
|
|
|
|
|
|
|
const handleSubmit = (e: FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!input.trim() || isLoading) return;
|
|
|
|
|
setFirstTokenReceived(false);
|
|
|
|
|
|
2025-03-03 12:40:24 -08:00
|
|
|
const newHumanMessage: Message = {
|
|
|
|
|
id: uuidv4(),
|
|
|
|
|
type: "human",
|
2025-04-28 10:22:17 +09:00
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
|
|
|
|
text: input,
|
|
|
|
|
},
|
2025-04-29 18:43:30 +08:00
|
|
|
...imageUrlList.map((item) => item.image),
|
2025-05-15 15:41:18 -07:00
|
|
|
...pdfUrlList.map((item) => item.pdf),
|
2025-04-28 10:22:17 +09:00
|
|
|
],
|
2025-03-03 12:40:24 -08:00
|
|
|
};
|
|
|
|
|
|
2025-03-04 13:45:02 +01:00
|
|
|
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
|
2025-03-03 12:31:27 -08:00
|
|
|
stream.submit(
|
2025-03-04 13:45:02 +01:00
|
|
|
{ messages: [...toolMessages, newHumanMessage] },
|
2025-03-04 14:44:55 +01:00
|
|
|
{
|
|
|
|
|
streamMode: ["values"],
|
|
|
|
|
optimisticValues: (prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
messages: [
|
|
|
|
|
...(prev.messages ?? []),
|
|
|
|
|
...toolMessages,
|
|
|
|
|
newHumanMessage,
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
},
|
2025-03-03 12:31:27 -08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
setInput("");
|
2025-04-28 10:22:17 +09:00
|
|
|
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: {
|
2025-04-29 18:43:30 +08:00
|
|
|
url: reader.result as string
|
2025-04-28 10:22:17 +09:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
);
|
2025-04-29 18:43:30 +08:00
|
|
|
const wrappedImages = imageUrls.map((image) => ({
|
|
|
|
|
id: uuidv4(),
|
|
|
|
|
image,
|
|
|
|
|
}));
|
|
|
|
|
setImageUrlList([...imageUrlList, ...wrappedImages]);
|
2025-04-28 10:22:17 +09:00
|
|
|
}
|
|
|
|
|
e.target.value = "";
|
2025-03-03 12:31:27 -08:00
|
|
|
};
|
|
|
|
|
|
2025-05-15 15:41:18 -07:00
|
|
|
|
|
|
|
|
const handlePDFUpload = async (e: ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
const files = e.target.files;
|
|
|
|
|
if (files) {
|
|
|
|
|
const pdfTexts: MessageContentPdfWrapper[] = await Promise.all(
|
|
|
|
|
Array.from(files).map(async (file) => {
|
|
|
|
|
const pdf = await extractPdfText(file);
|
|
|
|
|
return {
|
|
|
|
|
id: uuidv4(),
|
|
|
|
|
pdf,
|
|
|
|
|
name: file.name,
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
setPdfUrlList([...pdfUrlList, ...pdfTexts]);
|
|
|
|
|
}
|
|
|
|
|
e.target.value = "";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-03-03 13:24:24 -08:00
|
|
|
const handleRegenerate = (
|
2025-03-04 14:12:56 +01:00
|
|
|
parentCheckpoint: Checkpoint | null | undefined,
|
2025-03-03 13:24:24 -08:00
|
|
|
) => {
|
|
|
|
|
// Do this so the loading state is correct
|
|
|
|
|
prevMessageLength.current = prevMessageLength.current - 1;
|
|
|
|
|
setFirstTokenReceived(false);
|
|
|
|
|
stream.submit(undefined, {
|
|
|
|
|
checkpoint: parentCheckpoint,
|
|
|
|
|
streamMode: ["values"],
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-05 15:33:02 -08:00
|
|
|
const chatStarted = !!threadId || !!messages.length;
|
2025-04-08 12:21:07 -07:00
|
|
|
const hasNoAIOrToolMessages = !messages.find(
|
|
|
|
|
(m) => m.type === "ai" || m.type === "tool",
|
|
|
|
|
);
|
2025-03-05 15:32:47 -08:00
|
|
|
|
2025-04-29 18:43:30 +08:00
|
|
|
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/"),
|
|
|
|
|
);
|
|
|
|
|
|
2025-05-15 12:11:15 -07:00
|
|
|
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.");
|
2025-04-29 18:43:30 +08:00
|
|
|
}
|
2025-05-15 15:41:18 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* If there are any image files in the dropped files, this block reads each image file as a data URL,
|
|
|
|
|
* wraps it in a MessageContentImageUrl object, and updates the imageUrlList state with the new images.
|
|
|
|
|
* This enables preview and later sending of uploaded images in the chat UI.
|
|
|
|
|
*/
|
2025-04-29 18:43:30 +08:00
|
|
|
if (imageFiles.length) {
|
|
|
|
|
const imageUrls = await Promise.all(
|
|
|
|
|
Array.from(imageFiles).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);
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const wrappedImages = imageUrls.map((image) => ({
|
|
|
|
|
id: uuidv4(),
|
|
|
|
|
image,
|
|
|
|
|
}));
|
|
|
|
|
setImageUrlList([...imageUrlList, ...wrappedImages]);
|
|
|
|
|
}
|
2025-05-15 15:41:18 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 pdfPreviews = pdfFiles.map((file) => ({
|
|
|
|
|
id: uuidv4(),
|
|
|
|
|
pdf: { type: 'text' as const, text: '' },
|
|
|
|
|
name: file.name,
|
|
|
|
|
}));
|
|
|
|
|
setPdfUrlList([...pdfUrlList, ...pdfPreviews]);
|
|
|
|
|
}
|
2025-04-29 18:43:30 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
2025-03-03 12:31:27 -08:00
|
|
|
return (
|
2025-04-11 11:46:23 +09:00
|
|
|
<div className="flex h-screen w-full overflow-hidden">
|
|
|
|
|
<div className="relative hidden lg:flex">
|
2025-03-06 15:58:02 -08:00
|
|
|
<motion.div
|
2025-04-11 11:46:23 +09:00
|
|
|
className="absolute z-20 h-full overflow-hidden border-r bg-white"
|
2025-03-06 15:58:02 -08:00
|
|
|
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 }
|
|
|
|
|
}
|
|
|
|
|
>
|
2025-04-11 11:46:23 +09:00
|
|
|
<div
|
|
|
|
|
className="relative h-full"
|
|
|
|
|
style={{ width: 300 }}
|
|
|
|
|
>
|
2025-03-06 15:58:02 -08:00
|
|
|
<ThreadHistory />
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
</div>
|
|
|
|
|
<motion.div
|
2025-03-04 15:37:40 +01:00
|
|
|
className={cn(
|
2025-04-11 11:46:23 +09:00
|
|
|
"relative flex min-w-0 flex-1 flex-col overflow-hidden",
|
2025-03-05 15:32:47 -08:00
|
|
|
!chatStarted && "grid-rows-[1fr]",
|
2025-03-03 12:51:21 -08:00
|
|
|
)}
|
2025-03-06 15:58:02 -08:00
|
|
|
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 }
|
|
|
|
|
}
|
2025-03-04 15:37:40 +01:00
|
|
|
>
|
2025-03-06 15:58:02 -08:00
|
|
|
{!chatStarted && (
|
2025-04-11 11:46:23 +09:00
|
|
|
<div className="absolute top-0 left-0 z-10 flex w-full items-center justify-between gap-3 p-2 pl-4">
|
2025-04-02 13:33:11 -07:00
|
|
|
<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>
|
2025-03-06 15:58:02 -08:00
|
|
|
</div>
|
|
|
|
|
)}
|
2025-03-05 15:32:47 -08:00
|
|
|
{chatStarted && (
|
2025-04-11 11:46:23 +09:00
|
|
|
<div className="relative z-10 flex items-center justify-between gap-3 p-2">
|
|
|
|
|
<div className="relative flex items-center justify-start gap-2">
|
2025-03-06 16:05:58 -08:00
|
|
|
<div className="absolute left-0 z-10">
|
|
|
|
|
{(!chatHistoryOpen || !isLargeScreen) && (
|
|
|
|
|
<Button
|
|
|
|
|
className="hover:bg-gray-100"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => setChatHistoryOpen((p) => !p)}
|
|
|
|
|
>
|
2025-03-12 01:27:22 +05:30
|
|
|
{chatHistoryOpen ? (
|
|
|
|
|
<PanelRightOpen className="size-5" />
|
|
|
|
|
) : (
|
|
|
|
|
<PanelRightClose className="size-5" />
|
|
|
|
|
)}
|
2025-03-06 16:05:58 -08:00
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<motion.button
|
2025-04-11 11:46:23 +09:00
|
|
|
className="flex cursor-pointer items-center gap-2"
|
2025-03-04 11:01:19 -08:00
|
|
|
onClick={() => setThreadId(null)}
|
2025-03-06 16:05:58 -08:00
|
|
|
animate={{
|
|
|
|
|
marginLeft: !chatHistoryOpen ? 48 : 0,
|
|
|
|
|
}}
|
|
|
|
|
transition={{
|
|
|
|
|
type: "spring",
|
|
|
|
|
stiffness: 300,
|
|
|
|
|
damping: 30,
|
|
|
|
|
}}
|
2025-03-04 11:01:19 -08:00
|
|
|
>
|
2025-04-11 11:46:23 +09:00
|
|
|
<LangGraphLogoSVG
|
|
|
|
|
width={32}
|
|
|
|
|
height={32}
|
|
|
|
|
/>
|
2025-03-04 11:01:19 -08:00
|
|
|
<span className="text-xl font-semibold tracking-tight">
|
2025-03-10 16:30:10 -07:00
|
|
|
Agent Chat
|
2025-03-04 11:01:19 -08:00
|
|
|
</span>
|
2025-03-06 16:05:58 -08:00
|
|
|
</motion.button>
|
2025-03-04 11:01:19 -08:00
|
|
|
</div>
|
2025-03-04 15:43:35 +01:00
|
|
|
|
2025-04-02 13:33:11 -07:00
|
|
|
<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>
|
2025-03-04 15:37:40 +01:00
|
|
|
|
2025-04-11 11:46:23 +09:00
|
|
|
<div className="from-background to-background/0 absolute inset-x-0 top-full h-5 bg-gradient-to-b" />
|
2025-03-03 12:51:21 -08:00
|
|
|
</div>
|
|
|
|
|
)}
|
2025-03-03 12:31:27 -08:00
|
|
|
|
2025-03-04 10:34:52 -08:00
|
|
|
<StickToBottom className="relative flex-1 overflow-hidden">
|
2025-03-04 15:37:40 +01:00
|
|
|
<StickyToBottomContent
|
|
|
|
|
className={cn(
|
2025-04-11 11:46:23 +09:00
|
|
|
"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",
|
2025-03-05 15:32:47 -08:00
|
|
|
chatStarted && "grid grid-rows-[1fr_auto]",
|
2025-03-04 15:37:40 +01:00
|
|
|
)}
|
2025-03-07 15:46:00 +01:00
|
|
|
contentClassName="pt-8 pb-16 max-w-3xl mx-auto flex flex-col gap-4 w-full"
|
2025-03-04 15:37:40 +01:00
|
|
|
content={
|
|
|
|
|
<>
|
2025-03-04 15:54:43 +01:00
|
|
|
{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}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
)}
|
2025-04-08 12:21:07 -07:00
|
|
|
{/* 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}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-03-04 15:37:40 +01:00
|
|
|
{isLoading && !firstTokenReceived && (
|
|
|
|
|
<AssistantMessageLoading />
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
footer={
|
2025-04-11 11:46:23 +09:00
|
|
|
<div className="sticky bottom-0 flex flex-col items-center gap-8 bg-white">
|
2025-03-05 15:32:47 -08:00
|
|
|
{!chatStarted && (
|
2025-04-11 11:46:23 +09:00
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<LangGraphLogoSVG className="h-8 flex-shrink-0" />
|
2025-03-04 17:38:33 +01:00
|
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
2025-03-10 16:30:10 -07:00
|
|
|
Agent Chat
|
2025-03-04 17:38:33 +01:00
|
|
|
</h1>
|
2025-03-04 10:35:03 -08:00
|
|
|
</div>
|
2025-03-04 15:43:35 +01:00
|
|
|
)}
|
2025-03-04 15:56:36 +01:00
|
|
|
|
2025-04-11 11:46:23 +09:00
|
|
|
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95 absolute bottom-full left-1/2 mb-4 -translate-x-1/2" />
|
2025-03-04 15:56:36 +01:00
|
|
|
|
2025-04-29 18:43:30 +08:00
|
|
|
<div
|
|
|
|
|
ref={dropRef}
|
|
|
|
|
className="bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl border shadow-xs"
|
|
|
|
|
>
|
2025-03-04 15:37:40 +01:00
|
|
|
<form
|
|
|
|
|
onSubmit={handleSubmit}
|
2025-04-11 11:46:23 +09:00
|
|
|
className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2"
|
2025-03-04 15:37:40 +01:00
|
|
|
>
|
2025-04-28 10:22:17 +09:00
|
|
|
{imageUrlList.length > 0 && (
|
|
|
|
|
<div className="flex flex-wrap gap-2 p-3.5 pb-0">
|
2025-04-29 18:43:30 +08:00
|
|
|
{imageUrlList.map((imageItemWrapper) => {
|
2025-04-28 10:22:17 +09:00
|
|
|
const imageUrlString =
|
2025-04-29 18:43:30 +08:00
|
|
|
typeof imageItemWrapper.image.image_url === "string"
|
|
|
|
|
? imageItemWrapper.image.image_url
|
|
|
|
|
: imageItemWrapper.image.image_url.url;
|
2025-04-28 10:22:17 +09:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="relative"
|
2025-04-29 18:43:30 +08:00
|
|
|
key={imageItemWrapper.id}
|
2025-04-28 10:22:17 +09:00
|
|
|
>
|
|
|
|
|
<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(
|
2025-04-29 18:43:30 +08:00
|
|
|
(url) => url.id !== imageItemWrapper.id,
|
2025-04-28 10:22:17 +09:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-05-15 15:41:18 -07:00
|
|
|
{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 bg-gray-100 rounded px-2 py-1 border-1 border-teal-700 bg-teal-900 text-white rounded-md px-2 py-2" key={pdf.id}>
|
|
|
|
|
<span className=" truncate max-w-xs 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>
|
|
|
|
|
)}
|
2025-03-07 15:18:57 +01:00
|
|
|
<textarea
|
2025-03-04 15:37:40 +01:00
|
|
|
value={input}
|
|
|
|
|
onChange={(e) => setInput(e.target.value)}
|
2025-03-07 15:18:57 +01:00
|
|
|
onKeyDown={(e) => {
|
2025-03-21 13:33:55 +09:00
|
|
|
if (
|
|
|
|
|
e.key === "Enter" &&
|
|
|
|
|
!e.shiftKey &&
|
|
|
|
|
!e.metaKey &&
|
|
|
|
|
!e.nativeEvent.isComposing
|
|
|
|
|
) {
|
2025-03-07 15:18:57 +01:00
|
|
|
e.preventDefault();
|
|
|
|
|
const el = e.target as HTMLElement | undefined;
|
|
|
|
|
const form = el?.closest("form");
|
|
|
|
|
form?.requestSubmit();
|
|
|
|
|
}
|
|
|
|
|
}}
|
2025-03-04 15:37:40 +01:00
|
|
|
placeholder="Type your message..."
|
2025-04-11 11:46:23 +09:00
|
|
|
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"
|
2025-03-04 15:37:40 +01:00
|
|
|
/>
|
|
|
|
|
|
2025-03-11 10:59:49 -07:00
|
|
|
<div className="flex items-center justify-between p-2 pt-4">
|
2025-04-28 10:22:17 +09:00
|
|
|
<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">
|
2025-05-15 15:41:18 -07:00
|
|
|
Upload PDF
|
2025-04-28 10:22:17 +09:00
|
|
|
</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<input
|
|
|
|
|
id="file-input"
|
|
|
|
|
type="file"
|
2025-05-15 15:41:18 -07:00
|
|
|
onChange={handlePDFUpload}
|
2025-04-28 10:22:17 +09:00
|
|
|
multiple
|
2025-05-15 15:41:18 -07:00
|
|
|
accept="application/pdf"
|
2025-04-28 10:22:17 +09:00
|
|
|
className="hidden"
|
|
|
|
|
/>
|
2025-03-11 10:59:49 -07:00
|
|
|
<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>
|
2025-03-04 16:11:44 +01:00
|
|
|
{stream.isLoading ? (
|
2025-04-11 11:46:23 +09:00
|
|
|
<Button
|
|
|
|
|
key="stop"
|
|
|
|
|
onClick={() => stream.stop()}
|
|
|
|
|
>
|
|
|
|
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
2025-03-04 16:11:44 +01:00
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
) : (
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
2025-04-11 11:46:23 +09:00
|
|
|
className="shadow-md transition-all"
|
2025-03-04 16:11:44 +01:00
|
|
|
disabled={isLoading || !input.trim()}
|
|
|
|
|
>
|
|
|
|
|
Send
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2025-03-04 15:37:40 +01:00
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
2025-03-03 12:31:27 -08:00
|
|
|
/>
|
2025-03-04 15:37:40 +01:00
|
|
|
</StickToBottom>
|
2025-03-06 15:58:02 -08:00
|
|
|
</motion.div>
|
2025-03-03 12:31:27 -08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|