fix: Port hitl fixes

This commit is contained in:
bracesproul
2025-04-10 09:35:31 -07:00
parent 83cd23f5af
commit 75ef15f9d1
19 changed files with 555 additions and 97 deletions
+1 -1
View File
@@ -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 &&
+27 -6
View File
@@ -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);
}
+82 -26
View File
@@ -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 &&
+27 -6
View File
@@ -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>