mirror of
https://github.com/langchain-ai/create-agent-chat-app.git
synced 2026-06-30 21:37:54 -04:00
fix: Port hitl fixes
This commit is contained in:
+1
-1
@@ -238,7 +238,7 @@ const createStartServersMessage = (
|
||||
framework: "nextjs" | "vite",
|
||||
): string => {
|
||||
return `Then, start both the web, and LangGraph development servers with one command:
|
||||
${chalk.cyan(`${packageManager} dev`)}
|
||||
${chalk.cyan(`${packageManager} run dev`)}
|
||||
|
||||
This will start the web server at:
|
||||
${chalk.cyan(framework === "nextjs" ? "http://localhost:3000" : "http://localhost:5173")}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const GitHubSVG = ({ width = "100%", height = "100%" }) => (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width={width}
|
||||
height={height}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
@@ -28,7 +28,7 @@ function ArgsRenderer({ args }: { args: Record<string, any> }) {
|
||||
{Object.entries(args).map(([k, v]) => {
|
||||
let value = "";
|
||||
if (["string", "number"].includes(typeof v)) {
|
||||
value = v as string;
|
||||
value = v.toString();
|
||||
} else {
|
||||
value = JSON.stringify(v, null);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ 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";
|
||||
|
||||
function StickyToBottomContent(props: {
|
||||
content: ReactNode;
|
||||
@@ -67,6 +74,27 @@ function ScrollToBottom(props: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -172,6 +200,9 @@ export function Thread() {
|
||||
};
|
||||
|
||||
const chatStarted = !!threadId || !!messages.length;
|
||||
const hasNoAIOrToolMessages = !messages.find(
|
||||
(m) => m.type === "ai" || m.type === "tool",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-screen overflow-hidden">
|
||||
@@ -218,23 +249,28 @@ export function Thread() {
|
||||
>
|
||||
{!chatStarted && (
|
||||
<div className="absolute top-0 left-0 w-full flex items-center justify-between gap-3 p-2 pl-4 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>
|
||||
{(!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="flex items-center justify-between gap-3 p-2 pl-4 z-10 relative">
|
||||
<div className="flex items-center justify-between gap-3 p-2 z-10 relative">
|
||||
<div className="flex items-center justify-start gap-2 relative">
|
||||
<div className="absolute left-0 z-10">
|
||||
{(!chatHistoryOpen || !isLargeScreen) && (
|
||||
@@ -270,15 +306,20 @@ export function Thread() {
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<TooltipIconButton
|
||||
size="lg"
|
||||
className="p-4"
|
||||
tooltip="New thread"
|
||||
variant="ghost"
|
||||
onClick={() => setThreadId(null)}
|
||||
>
|
||||
<SquarePen className="size-5" />
|
||||
</TooltipIconButton>
|
||||
<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="absolute inset-x-0 top-full h-5 bg-gradient-to-b from-background to-background/0" />
|
||||
</div>
|
||||
@@ -287,7 +328,7 @@ export function Thread() {
|
||||
<StickToBottom className="relative flex-1 overflow-hidden">
|
||||
<StickyToBottomContent
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-y-scroll [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
|
||||
"absolute px-4 inset-0 overflow-y-scroll [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
|
||||
!chatStarted && "flex flex-col items-stretch mt-[25vh]",
|
||||
chatStarted && "grid grid-rows-[1fr_auto]",
|
||||
)}
|
||||
@@ -312,13 +353,23 @@ export function Thread() {
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{/* 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 flex flex-col items-center gap-8 bottom-0 px-4 bg-white">
|
||||
<div className="sticky flex flex-col items-center gap-8 bottom-0 bg-white">
|
||||
{!chatStarted && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<LangGraphLogoSVG className="flex-shrink-0 h-8" />
|
||||
@@ -339,7 +390,12 @@ export function Thread() {
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.metaKey) {
|
||||
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");
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Fragment } from "react/jsx-runtime";
|
||||
import { isAgentInboxInterruptSchema } from "@/lib/agent-inbox-interrupt";
|
||||
import { ThreadView } from "../agent-inbox";
|
||||
import { useQueryState, parseAsBoolean } from "nuqs";
|
||||
import { GenericInterruptView } from "./generic-interrupt";
|
||||
|
||||
function CustomComponent({
|
||||
message,
|
||||
@@ -69,11 +70,12 @@ export function AssistantMessage({
|
||||
isLoading,
|
||||
handleRegenerate,
|
||||
}: {
|
||||
message: Message;
|
||||
message: Message | undefined;
|
||||
isLoading: boolean;
|
||||
handleRegenerate: (parentCheckpoint: Checkpoint | null | undefined) => void;
|
||||
}) {
|
||||
const contentString = getContentString(message.content);
|
||||
const content = message?.content ?? [];
|
||||
const contentString = getContentString(content);
|
||||
const [hideToolCalls] = useQueryState(
|
||||
"hideToolCalls",
|
||||
parseAsBoolean.withDefault(false),
|
||||
@@ -81,15 +83,20 @@ export function AssistantMessage({
|
||||
|
||||
const thread = useStreamContext();
|
||||
const isLastMessage =
|
||||
thread.messages[thread.messages.length - 1].id === message.id;
|
||||
const meta = thread.getMessagesMetadata(message);
|
||||
const interrupt = thread.interrupt;
|
||||
thread.messages[thread.messages.length - 1].id === message?.id;
|
||||
const hasNoAIOrToolMessages = !thread.messages.find(
|
||||
(m) => m.type === "ai" || m.type === "tool",
|
||||
);
|
||||
const meta = message ? thread.getMessagesMetadata(message) : undefined;
|
||||
const threadInterrupt = thread.interrupt;
|
||||
|
||||
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
|
||||
const anthropicStreamedToolCalls = Array.isArray(message.content)
|
||||
? parseAnthropicStreamedToolCalls(message.content)
|
||||
const anthropicStreamedToolCalls = Array.isArray(content)
|
||||
? parseAnthropicStreamedToolCalls(content)
|
||||
: undefined;
|
||||
|
||||
const hasToolCalls =
|
||||
message &&
|
||||
"tool_calls" in message &&
|
||||
message.tool_calls &&
|
||||
message.tool_calls.length > 0;
|
||||
@@ -99,7 +106,7 @@ export function AssistantMessage({
|
||||
(tc) => tc.args && Object.keys(tc.args).length > 0,
|
||||
);
|
||||
const hasAnthropicToolCalls = !!anthropicStreamedToolCalls?.length;
|
||||
const isToolResult = message.type === "tool";
|
||||
const isToolResult = message?.type === "tool";
|
||||
|
||||
if (isToolResult && hideToolCalls) {
|
||||
return null;
|
||||
@@ -129,10 +136,16 @@ export function AssistantMessage({
|
||||
</>
|
||||
)}
|
||||
|
||||
<CustomComponent message={message} thread={thread} />
|
||||
{isAgentInboxInterruptSchema(interrupt?.value) && isLastMessage && (
|
||||
<ThreadView interrupt={interrupt.value} />
|
||||
)}
|
||||
{message && <CustomComponent message={message} thread={thread} />}
|
||||
{isAgentInboxInterruptSchema(threadInterrupt?.value) &&
|
||||
(isLastMessage || hasNoAIOrToolMessages) && (
|
||||
<ThreadView interrupt={threadInterrupt.value} />
|
||||
)}
|
||||
{threadInterrupt?.value &&
|
||||
!isAgentInboxInterruptSchema(threadInterrupt.value) &&
|
||||
isLastMessage ? (
|
||||
<GenericInterruptView interrupt={threadInterrupt.value} />
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 items-center mr-auto transition-opacity",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
function isComplexValue(value: any): boolean {
|
||||
return Array.isArray(value) || (typeof value === "object" && value !== null);
|
||||
}
|
||||
|
||||
export function GenericInterruptView({
|
||||
interrupt,
|
||||
}: {
|
||||
interrupt: Record<string, any> | Record<string, any>[];
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const contentStr = JSON.stringify(interrupt, null, 2);
|
||||
const contentLines = contentStr.split("\n");
|
||||
const shouldTruncate = contentLines.length > 4 || contentStr.length > 500;
|
||||
|
||||
// Function to truncate long string values
|
||||
const truncateValue = (value: any): any => {
|
||||
if (typeof value === "string" && value.length > 100) {
|
||||
return value.substring(0, 100) + "...";
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && !isExpanded) {
|
||||
return value.slice(0, 2).map(truncateValue);
|
||||
}
|
||||
|
||||
if (isComplexValue(value) && !isExpanded) {
|
||||
const strValue = JSON.stringify(value, null, 2);
|
||||
if (strValue.length > 100) {
|
||||
// Return plain text for truncated content instead of a JSON object
|
||||
return `Truncated ${strValue.length} characters...`;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
// Process entries based on expanded state
|
||||
const processEntries = () => {
|
||||
if (Array.isArray(interrupt)) {
|
||||
return isExpanded ? interrupt : interrupt.slice(0, 5);
|
||||
} else {
|
||||
const entries = Object.entries(interrupt);
|
||||
if (!isExpanded && shouldTruncate) {
|
||||
// When collapsed, process each value to potentially truncate it
|
||||
return entries.map(([key, value]) => [key, truncateValue(value)]);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
};
|
||||
|
||||
const displayEntries = processEntries();
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 px-4 py-2 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<h3 className="font-medium text-gray-900">Human Interrupt</h3>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
className="min-w-full bg-gray-100"
|
||||
initial={false}
|
||||
animate={{ height: "auto" }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="p-3">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={isExpanded ? "expanded" : "collapsed"}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{
|
||||
maxHeight: isExpanded ? "none" : "500px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{displayEntries.map((item, argIdx) => {
|
||||
const [key, value] = Array.isArray(interrupt)
|
||||
? [argIdx.toString(), item]
|
||||
: (item as [string, any]);
|
||||
return (
|
||||
<tr key={argIdx}>
|
||||
<td className="px-4 py-2 text-sm font-medium text-gray-900 whitespace-nowrap">
|
||||
{key}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">
|
||||
{isComplexValue(value) ? (
|
||||
<code className="bg-gray-50 rounded px-2 py-1 font-mono text-sm">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</code>
|
||||
) : (
|
||||
String(value)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{(shouldTruncate ||
|
||||
(Array.isArray(interrupt) && interrupt.length > 5)) && (
|
||||
<motion.button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full py-2 flex items-center justify-center border-t-[1px] border-gray-200 text-gray-500 hover:text-gray-600 hover:bg-gray-50 transition-all ease-in-out duration-200 cursor-pointer"
|
||||
initial={{ scale: 1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{isExpanded ? <ChevronUp /> : <ChevronDown />}
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export function HumanMessage({
|
||||
onSubmit={handleSubmitEdit}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-right px-4 py-2 rounded-3xl bg-muted">
|
||||
<p className="px-4 py-2 rounded-3xl bg-muted w-fit ml-auto whitespace-pre-wrap">
|
||||
{contentString}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function ToolCalls({
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">
|
||||
{isComplexValue(value) ? (
|
||||
<code className="bg-gray-50 rounded px-2 py-1 font-mono text-sm">
|
||||
<code className="bg-gray-50 rounded px-2 py-1 font-mono text-sm break-all">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</code>
|
||||
) : (
|
||||
@@ -148,7 +148,7 @@ export function ToolResult({ message }: { message: ToolMessage }) {
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">
|
||||
{isComplexValue(value) ? (
|
||||
<code className="bg-gray-50 rounded px-2 py-1 font-mono text-sm">
|
||||
<code className="bg-gray-50 rounded px-2 py-1 font-mono text-sm break-all">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</code>
|
||||
) : (
|
||||
|
||||
@@ -6,6 +6,7 @@ export function isAgentInboxInterruptSchema(
|
||||
const valueAsObject = Array.isArray(value) ? value[0] : value;
|
||||
return (
|
||||
valueAsObject &&
|
||||
typeof valueAsObject === "object" &&
|
||||
"action_request" in valueAsObject &&
|
||||
typeof valueAsObject.action_request === "object" &&
|
||||
"config" in valueAsObject &&
|
||||
|
||||
@@ -120,12 +120,30 @@ const StreamSession = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Default values for the form
|
||||
const DEFAULT_API_URL = "http://localhost:2024";
|
||||
const DEFAULT_ASSISTANT_ID = "agent";
|
||||
|
||||
export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [apiUrl, setApiUrl] = useQueryState("apiUrl");
|
||||
// Get environment variables
|
||||
const envApiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL;
|
||||
const envAssistantId: string | undefined = process.env.NEXT_PUBLIC_ASSISTANT_ID;
|
||||
const envApiKey: string | undefined = process.env.NEXT_PUBLIC_LANGSMITH_API_KEY;
|
||||
|
||||
// Use URL params with env var fallbacks
|
||||
const [apiUrl, setApiUrl] = useQueryState("apiUrl", {
|
||||
defaultValue: envApiUrl || "",
|
||||
});
|
||||
const [assistantId, setAssistantId] = useQueryState("assistantId", {
|
||||
defaultValue: envAssistantId || "",
|
||||
});
|
||||
|
||||
// For API key, use localStorage with env var fallback
|
||||
const [apiKey, _setApiKey] = useState(() => {
|
||||
return getApiKey();
|
||||
const storedKey = getApiKey();
|
||||
return storedKey || envApiKey || "";
|
||||
});
|
||||
|
||||
const setApiKey = (key: string) => {
|
||||
@@ -133,9 +151,12 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||
_setApiKey(key);
|
||||
};
|
||||
|
||||
const [assistantId, setAssistantId] = useQueryState("assistantId");
|
||||
// Determine final values to use, prioritizing URL params then env vars
|
||||
const finalApiUrl = apiUrl || envApiUrl;
|
||||
const finalAssistantId = assistantId || envAssistantId;
|
||||
|
||||
if (!apiUrl || !assistantId) {
|
||||
// If we're missing any required values, show the form
|
||||
if (!finalApiUrl || !finalAssistantId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen w-full p-4">
|
||||
<div className="animate-in fade-in-0 zoom-in-95 flex flex-col border bg-background shadow-lg rounded-lg max-w-3xl">
|
||||
@@ -181,7 +202,7 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||
id="apiUrl"
|
||||
name="apiUrl"
|
||||
className="bg-background"
|
||||
defaultValue={apiUrl ?? "http://localhost:2024"}
|
||||
defaultValue={apiUrl || DEFAULT_API_URL}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -199,7 +220,7 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||
id="assistantId"
|
||||
name="assistantId"
|
||||
className="bg-background"
|
||||
defaultValue={assistantId ?? "agent"}
|
||||
defaultValue={assistantId || DEFAULT_ASSISTANT_ID}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const GitHubSVG = ({ width = "100%", height = "100%" }) => (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width={width}
|
||||
height={height}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
@@ -28,7 +28,7 @@ function ArgsRenderer({ args }: { args: Record<string, any> }) {
|
||||
{Object.entries(args).map(([k, v]) => {
|
||||
let value = "";
|
||||
if (["string", "number"].includes(typeof v)) {
|
||||
value = v as string;
|
||||
value = v.toString();
|
||||
} else {
|
||||
value = JSON.stringify(v, null);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ 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";
|
||||
|
||||
function StickyToBottomContent(props: {
|
||||
content: ReactNode;
|
||||
@@ -67,6 +74,27 @@ function ScrollToBottom(props: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -172,6 +200,9 @@ export function Thread() {
|
||||
};
|
||||
|
||||
const chatStarted = !!threadId || !!messages.length;
|
||||
const hasNoAIOrToolMessages = !messages.find(
|
||||
(m) => m.type === "ai" || m.type === "tool",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-screen overflow-hidden">
|
||||
@@ -218,23 +249,28 @@ export function Thread() {
|
||||
>
|
||||
{!chatStarted && (
|
||||
<div className="absolute top-0 left-0 w-full flex items-center justify-between gap-3 p-2 pl-4 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>
|
||||
{(!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="flex items-center justify-between gap-3 p-2 pl-4 z-10 relative">
|
||||
<div className="flex items-center justify-between gap-3 p-2 z-10 relative">
|
||||
<div className="flex items-center justify-start gap-2 relative">
|
||||
<div className="absolute left-0 z-10">
|
||||
{(!chatHistoryOpen || !isLargeScreen) && (
|
||||
@@ -270,15 +306,20 @@ export function Thread() {
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<TooltipIconButton
|
||||
size="lg"
|
||||
className="p-4"
|
||||
tooltip="New thread"
|
||||
variant="ghost"
|
||||
onClick={() => setThreadId(null)}
|
||||
>
|
||||
<SquarePen className="size-5" />
|
||||
</TooltipIconButton>
|
||||
<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="absolute inset-x-0 top-full h-5 bg-gradient-to-b from-background to-background/0" />
|
||||
</div>
|
||||
@@ -287,7 +328,7 @@ export function Thread() {
|
||||
<StickToBottom className="relative flex-1 overflow-hidden">
|
||||
<StickyToBottomContent
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-y-scroll [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
|
||||
"absolute px-4 inset-0 overflow-y-scroll [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
|
||||
!chatStarted && "flex flex-col items-stretch mt-[25vh]",
|
||||
chatStarted && "grid grid-rows-[1fr_auto]",
|
||||
)}
|
||||
@@ -312,13 +353,23 @@ export function Thread() {
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{/* 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 flex flex-col items-center gap-8 bottom-0 px-4 bg-white">
|
||||
<div className="sticky flex flex-col items-center gap-8 bottom-0 bg-white">
|
||||
{!chatStarted && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<LangGraphLogoSVG className="flex-shrink-0 h-8" />
|
||||
@@ -339,7 +390,12 @@ export function Thread() {
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.metaKey) {
|
||||
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");
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Fragment } from "react/jsx-runtime";
|
||||
import { isAgentInboxInterruptSchema } from "@/lib/agent-inbox-interrupt";
|
||||
import { ThreadView } from "../agent-inbox";
|
||||
import { useQueryState, parseAsBoolean } from "nuqs";
|
||||
import { GenericInterruptView } from "./generic-interrupt";
|
||||
|
||||
function CustomComponent({
|
||||
message,
|
||||
@@ -69,11 +70,12 @@ export function AssistantMessage({
|
||||
isLoading,
|
||||
handleRegenerate,
|
||||
}: {
|
||||
message: Message;
|
||||
message: Message | undefined;
|
||||
isLoading: boolean;
|
||||
handleRegenerate: (parentCheckpoint: Checkpoint | null | undefined) => void;
|
||||
}) {
|
||||
const contentString = getContentString(message.content);
|
||||
const content = message?.content ?? [];
|
||||
const contentString = getContentString(content);
|
||||
const [hideToolCalls] = useQueryState(
|
||||
"hideToolCalls",
|
||||
parseAsBoolean.withDefault(false),
|
||||
@@ -81,15 +83,20 @@ export function AssistantMessage({
|
||||
|
||||
const thread = useStreamContext();
|
||||
const isLastMessage =
|
||||
thread.messages[thread.messages.length - 1].id === message.id;
|
||||
const meta = thread.getMessagesMetadata(message);
|
||||
const interrupt = thread.interrupt;
|
||||
thread.messages[thread.messages.length - 1].id === message?.id;
|
||||
const hasNoAIOrToolMessages = !thread.messages.find(
|
||||
(m) => m.type === "ai" || m.type === "tool",
|
||||
);
|
||||
const meta = message ? thread.getMessagesMetadata(message) : undefined;
|
||||
const threadInterrupt = thread.interrupt;
|
||||
|
||||
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
|
||||
const anthropicStreamedToolCalls = Array.isArray(message.content)
|
||||
? parseAnthropicStreamedToolCalls(message.content)
|
||||
const anthropicStreamedToolCalls = Array.isArray(content)
|
||||
? parseAnthropicStreamedToolCalls(content)
|
||||
: undefined;
|
||||
|
||||
const hasToolCalls =
|
||||
message &&
|
||||
"tool_calls" in message &&
|
||||
message.tool_calls &&
|
||||
message.tool_calls.length > 0;
|
||||
@@ -99,7 +106,7 @@ export function AssistantMessage({
|
||||
(tc) => tc.args && Object.keys(tc.args).length > 0,
|
||||
);
|
||||
const hasAnthropicToolCalls = !!anthropicStreamedToolCalls?.length;
|
||||
const isToolResult = message.type === "tool";
|
||||
const isToolResult = message?.type === "tool";
|
||||
|
||||
if (isToolResult && hideToolCalls) {
|
||||
return null;
|
||||
@@ -129,10 +136,16 @@ export function AssistantMessage({
|
||||
</>
|
||||
)}
|
||||
|
||||
<CustomComponent message={message} thread={thread} />
|
||||
{isAgentInboxInterruptSchema(interrupt?.value) && isLastMessage && (
|
||||
<ThreadView interrupt={interrupt.value} />
|
||||
)}
|
||||
{message && <CustomComponent message={message} thread={thread} />}
|
||||
{isAgentInboxInterruptSchema(threadInterrupt?.value) &&
|
||||
(isLastMessage || hasNoAIOrToolMessages) && (
|
||||
<ThreadView interrupt={threadInterrupt.value} />
|
||||
)}
|
||||
{threadInterrupt?.value &&
|
||||
!isAgentInboxInterruptSchema(threadInterrupt.value) &&
|
||||
isLastMessage ? (
|
||||
<GenericInterruptView interrupt={threadInterrupt.value} />
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 items-center mr-auto transition-opacity",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
function isComplexValue(value: any): boolean {
|
||||
return Array.isArray(value) || (typeof value === "object" && value !== null);
|
||||
}
|
||||
|
||||
export function GenericInterruptView({
|
||||
interrupt,
|
||||
}: {
|
||||
interrupt: Record<string, any> | Record<string, any>[];
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const contentStr = JSON.stringify(interrupt, null, 2);
|
||||
const contentLines = contentStr.split("\n");
|
||||
const shouldTruncate = contentLines.length > 4 || contentStr.length > 500;
|
||||
|
||||
// Function to truncate long string values
|
||||
const truncateValue = (value: any): any => {
|
||||
if (typeof value === "string" && value.length > 100) {
|
||||
return value.substring(0, 100) + "...";
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && !isExpanded) {
|
||||
return value.slice(0, 2).map(truncateValue);
|
||||
}
|
||||
|
||||
if (isComplexValue(value) && !isExpanded) {
|
||||
const strValue = JSON.stringify(value, null, 2);
|
||||
if (strValue.length > 100) {
|
||||
// Return plain text for truncated content instead of a JSON object
|
||||
return `Truncated ${strValue.length} characters...`;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
// Process entries based on expanded state
|
||||
const processEntries = () => {
|
||||
if (Array.isArray(interrupt)) {
|
||||
return isExpanded ? interrupt : interrupt.slice(0, 5);
|
||||
} else {
|
||||
const entries = Object.entries(interrupt);
|
||||
if (!isExpanded && shouldTruncate) {
|
||||
// When collapsed, process each value to potentially truncate it
|
||||
return entries.map(([key, value]) => [key, truncateValue(value)]);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
};
|
||||
|
||||
const displayEntries = processEntries();
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 px-4 py-2 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<h3 className="font-medium text-gray-900">Human Interrupt</h3>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
className="min-w-full bg-gray-100"
|
||||
initial={false}
|
||||
animate={{ height: "auto" }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="p-3">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={isExpanded ? "expanded" : "collapsed"}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{
|
||||
maxHeight: isExpanded ? "none" : "500px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{displayEntries.map((item, argIdx) => {
|
||||
const [key, value] = Array.isArray(interrupt)
|
||||
? [argIdx.toString(), item]
|
||||
: (item as [string, any]);
|
||||
return (
|
||||
<tr key={argIdx}>
|
||||
<td className="px-4 py-2 text-sm font-medium text-gray-900 whitespace-nowrap">
|
||||
{key}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">
|
||||
{isComplexValue(value) ? (
|
||||
<code className="bg-gray-50 rounded px-2 py-1 font-mono text-sm">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</code>
|
||||
) : (
|
||||
String(value)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{(shouldTruncate ||
|
||||
(Array.isArray(interrupt) && interrupt.length > 5)) && (
|
||||
<motion.button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full py-2 flex items-center justify-center border-t-[1px] border-gray-200 text-gray-500 hover:text-gray-600 hover:bg-gray-50 transition-all ease-in-out duration-200 cursor-pointer"
|
||||
initial={{ scale: 1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{isExpanded ? <ChevronUp /> : <ChevronDown />}
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export function HumanMessage({
|
||||
onSubmit={handleSubmitEdit}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-right px-4 py-2 rounded-3xl bg-muted">
|
||||
<p className="px-4 py-2 rounded-3xl bg-muted w-fit ml-auto whitespace-pre-wrap">
|
||||
{contentString}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function ToolCalls({
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">
|
||||
{isComplexValue(value) ? (
|
||||
<code className="bg-gray-50 rounded px-2 py-1 font-mono text-sm">
|
||||
<code className="bg-gray-50 rounded px-2 py-1 font-mono text-sm break-all">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</code>
|
||||
) : (
|
||||
@@ -148,7 +148,7 @@ export function ToolResult({ message }: { message: ToolMessage }) {
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">
|
||||
{isComplexValue(value) ? (
|
||||
<code className="bg-gray-50 rounded px-2 py-1 font-mono text-sm">
|
||||
<code className="bg-gray-50 rounded px-2 py-1 font-mono text-sm break-all">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</code>
|
||||
) : (
|
||||
|
||||
@@ -6,6 +6,7 @@ export function isAgentInboxInterruptSchema(
|
||||
const valueAsObject = Array.isArray(value) ? value[0] : value;
|
||||
return (
|
||||
valueAsObject &&
|
||||
typeof valueAsObject === "object" &&
|
||||
"action_request" in valueAsObject &&
|
||||
typeof valueAsObject.action_request === "object" &&
|
||||
"config" in valueAsObject &&
|
||||
|
||||
@@ -120,12 +120,30 @@ const StreamSession = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Default values for the form
|
||||
const DEFAULT_API_URL = "http://localhost:2024";
|
||||
const DEFAULT_ASSISTANT_ID = "agent";
|
||||
|
||||
export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [apiUrl, setApiUrl] = useQueryState("apiUrl");
|
||||
// Get environment variables
|
||||
const envApiUrl: string | undefined = import.meta.env.VITE_API_URL;
|
||||
const envAssistantId: string | undefined = import.meta.env.VITE_ASSISTANT_ID;
|
||||
const envApiKey: string | undefined = import.meta.env.VITE_LANGSMITH_API_KEY;
|
||||
|
||||
// Use URL params with env var fallbacks
|
||||
const [apiUrl, setApiUrl] = useQueryState("apiUrl", {
|
||||
defaultValue: envApiUrl || "",
|
||||
});
|
||||
const [assistantId, setAssistantId] = useQueryState("assistantId", {
|
||||
defaultValue: envAssistantId || "",
|
||||
});
|
||||
|
||||
// For API key, use localStorage with env var fallback
|
||||
const [apiKey, _setApiKey] = useState(() => {
|
||||
return getApiKey();
|
||||
const storedKey = getApiKey();
|
||||
return storedKey || envApiKey || "";
|
||||
});
|
||||
|
||||
const setApiKey = (key: string) => {
|
||||
@@ -133,9 +151,12 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||
_setApiKey(key);
|
||||
};
|
||||
|
||||
const [assistantId, setAssistantId] = useQueryState("assistantId");
|
||||
// Determine final values to use, prioritizing URL params then env vars
|
||||
const finalApiUrl = apiUrl || envApiUrl;
|
||||
const finalAssistantId = assistantId || envAssistantId;
|
||||
|
||||
if (!apiUrl || !assistantId) {
|
||||
// If we're missing any required values, show the form
|
||||
if (!finalApiUrl || !finalAssistantId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen w-full p-4">
|
||||
<div className="animate-in fade-in-0 zoom-in-95 flex flex-col border bg-background shadow-lg rounded-lg max-w-3xl">
|
||||
@@ -181,7 +202,7 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||
id="apiUrl"
|
||||
name="apiUrl"
|
||||
className="bg-background"
|
||||
defaultValue={apiUrl ?? "http://localhost:2024"}
|
||||
defaultValue={apiUrl || DEFAULT_API_URL}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -199,7 +220,7 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||
id="assistantId"
|
||||
name="assistantId"
|
||||
className="bg-background"
|
||||
defaultValue={assistantId ?? "agent"}
|
||||
defaultValue={assistantId || DEFAULT_ASSISTANT_ID}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user