feat: Add agent inbox as prebuilt gen ui component

This commit is contained in:
bracesproul
2025-03-10 14:04:11 -07:00
parent 953a50f888
commit 29d8ba5163
17 changed files with 6219 additions and 2138 deletions

View File

@@ -24,6 +24,7 @@
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/postcss": "^4.0.9", "@tailwindcss/postcss": "^4.0.9",
@@ -35,6 +36,7 @@
"esbuild-plugin-tailwindcss": "^2.0.1", "esbuild-plugin-tailwindcss": "^2.0.1",
"framer-motion": "^12.4.9", "framer-motion": "^12.4.9",
"katex": "^0.16.21", "katex": "^0.16.21",
"lodash": "^4.17.21",
"lucide-react": "^0.476.0", "lucide-react": "^0.476.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"prettier": "^3.5.2", "prettier": "^3.5.2",
@@ -57,6 +59,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@types/lodash": "^4.17.16",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/react": "^19.0.8", "@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",

6444
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,523 @@
import { HumanResponseWithEdits, SubmitType } from "../types";
import { Textarea } from "@/components/ui/textarea";
import React from "react";
import { haveArgsChanged, prettifyText } from "../utils";
import { Button } from "@/components/ui/button";
import { Undo2 } from "lucide-react";
import { MarkdownText } from "../../markdown-text";
import { ActionRequest, HumanInterrupt } from "@langchain/langgraph/prebuilt";
import { toast } from "sonner";
import { Separator } from "@/components/ui/separator";
function ResetButton({ handleReset }: { handleReset: () => void }) {
return (
<Button
onClick={handleReset}
variant="ghost"
className="flex items-center justify-center gap-2 text-gray-500 hover:text-red-500"
>
<Undo2 className="w-4 h-4" />
<span>Reset</span>
</Button>
);
}
function ArgsRenderer({ args }: { args: Record<string, any> }) {
return (
<div className="flex flex-col gap-6 items-start w-full">
{Object.entries(args).map(([k, v]) => {
let value = "";
if (["string", "number"].includes(typeof v)) {
value = v as string;
} else {
value = JSON.stringify(v, null);
}
return (
<div key={`args-${k}`} className="flex flex-col gap-1 items-start">
<p className="text-sm leading-[18px] text-gray-600 text-wrap">
{prettifyText(k)}:
</p>
<span className="text-[13px] leading-[18px] text-black bg-zinc-100 rounded-xl p-3 w-full max-w-full">
<MarkdownText>{value}</MarkdownText>
</span>
</div>
);
})}
</div>
);
}
interface InboxItemInputProps {
interruptValue: HumanInterrupt;
humanResponse: HumanResponseWithEdits[];
supportsMultipleMethods: boolean;
acceptAllowed: boolean;
hasEdited: boolean;
hasAddedResponse: boolean;
initialValues: Record<string, string>;
streaming: boolean;
streamFinished: boolean;
setHumanResponse: React.Dispatch<
React.SetStateAction<HumanResponseWithEdits[]>
>;
setSelectedSubmitType: React.Dispatch<
React.SetStateAction<SubmitType | undefined>
>;
setHasAddedResponse: React.Dispatch<React.SetStateAction<boolean>>;
setHasEdited: React.Dispatch<React.SetStateAction<boolean>>;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void>;
}
function ResponseComponent({
humanResponse,
streaming,
showArgsInResponse,
interruptValue,
onResponseChange,
handleSubmit,
}: {
humanResponse: HumanResponseWithEdits[];
streaming: boolean;
showArgsInResponse: boolean;
interruptValue: HumanInterrupt;
onResponseChange: (change: string, response: HumanResponseWithEdits) => void;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void>;
}) {
const res = humanResponse.find((r) => r.type === "response");
if (!res || typeof res.args !== "string") {
return null;
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
handleSubmit(e);
}
};
return (
<div className="flex flex-col gap-4 p-6 items-start w-full rounded-xl border-[1px] border-gray-300">
<div className="flex items-center justify-between w-full">
<p className="font-semibold text-black text-base">
Respond to assistant
</p>
<ResetButton
handleReset={() => {
onResponseChange("", res);
}}
/>
</div>
{showArgsInResponse && (
<ArgsRenderer args={interruptValue.action_request.args} />
)}
<div className="flex flex-col gap-[6px] items-start w-full">
<p className="text-sm min-w-fit font-medium">Response</p>
<Textarea
disabled={streaming}
value={res.args}
onChange={(e) => onResponseChange(e.target.value, res)}
onKeyDown={handleKeyDown}
rows={4}
placeholder="Your response here..."
/>
</div>
<div className="flex items-center justify-end w-full gap-2">
<Button variant="brand" disabled={streaming} onClick={handleSubmit}>
Send Response
</Button>
</div>
</div>
);
}
const Response = React.memo(ResponseComponent);
function AcceptComponent({
streaming,
actionRequestArgs,
handleSubmit,
}: {
streaming: boolean;
actionRequestArgs: Record<string, any>;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void>;
}) {
return (
<div className="flex flex-col gap-4 items-start w-full p-6 rounded-lg border-[1px] border-gray-300">
{actionRequestArgs && Object.keys(actionRequestArgs).length > 0 && (
<ArgsRenderer args={actionRequestArgs} />
)}
<Button
variant="brand"
disabled={streaming}
onClick={handleSubmit}
className="w-full"
>
Accept
</Button>
</div>
);
}
function EditAndOrAcceptComponent({
humanResponse,
streaming,
initialValues,
onEditChange,
handleSubmit,
interruptValue,
}: {
humanResponse: HumanResponseWithEdits[];
streaming: boolean;
initialValues: Record<string, string>;
interruptValue: HumanInterrupt;
onEditChange: (
text: string | string[],
response: HumanResponseWithEdits,
key: string | string[],
) => void;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void>;
}) {
const defaultRows = React.useRef<Record<string, number>>({});
const editResponse = humanResponse.find((r) => r.type === "edit");
const acceptResponse = humanResponse.find((r) => r.type === "accept");
if (
!editResponse ||
typeof editResponse.args !== "object" ||
!editResponse.args
) {
if (acceptResponse) {
return (
<AcceptComponent
actionRequestArgs={interruptValue.action_request.args}
streaming={streaming}
handleSubmit={handleSubmit}
/>
);
}
return null;
}
const header = editResponse.acceptAllowed ? "Edit/Accept" : "Edit";
let buttonText = "Submit";
if (editResponse.acceptAllowed && !editResponse.editsMade) {
buttonText = "Accept";
}
const handleReset = () => {
if (
!editResponse ||
typeof editResponse.args !== "object" ||
!editResponse.args ||
!editResponse.args.args
) {
return;
}
// use initialValues to reset the text areas
const keysToReset: string[] = [];
const valuesToReset: string[] = [];
Object.entries(initialValues).forEach(([k, v]) => {
if (k in (editResponse.args as Record<string, any>).args) {
const value = ["string", "number"].includes(typeof v)
? v
: JSON.stringify(v, null);
keysToReset.push(k);
valuesToReset.push(value);
}
});
if (keysToReset.length > 0 && valuesToReset.length > 0) {
onEditChange(valuesToReset, editResponse, keysToReset);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
handleSubmit(e);
}
};
return (
<div className="flex flex-col gap-4 items-start w-full p-6 rounded-lg border-[1px] border-gray-300">
<div className="flex items-center justify-between w-full">
<p className="font-semibold text-black text-base">{header}</p>
<ResetButton handleReset={handleReset} />
</div>
{Object.entries(editResponse.args.args).map(([k, v], idx) => {
const value = ["string", "number"].includes(typeof v)
? v
: JSON.stringify(v, null);
// Calculate the default number of rows by the total length of the initial value divided by 30
// or 8, whichever is greater. Stored in a ref to prevent re-rendering.
if (
defaultRows.current[k as keyof typeof defaultRows.current] ===
undefined
) {
defaultRows.current[k as keyof typeof defaultRows.current] = !v.length
? 3
: Math.max(v.length / 30, 7);
}
const numRows =
defaultRows.current[k as keyof typeof defaultRows.current] || 8;
return (
<div
className="flex flex-col gap-1 items-start w-full h-full px-[1px]"
key={`allow-edit-args--${k}-${idx}`}
>
<div className="flex flex-col gap-[6px] items-start w-full">
<p className="text-sm min-w-fit font-medium">{prettifyText(k)}</p>
<Textarea
disabled={streaming}
className="h-full"
value={value}
onChange={(e) => onEditChange(e.target.value, editResponse, k)}
onKeyDown={handleKeyDown}
rows={numRows}
/>
</div>
</div>
);
})}
<div className="flex items-center justify-end w-full gap-2">
<Button variant="brand" disabled={streaming} onClick={handleSubmit}>
{buttonText}
</Button>
</div>
</div>
);
}
const EditAndOrAccept = React.memo(EditAndOrAcceptComponent);
export function InboxItemInput({
interruptValue,
humanResponse,
streaming,
streamFinished,
supportsMultipleMethods,
acceptAllowed,
hasEdited,
hasAddedResponse,
initialValues,
setHumanResponse,
setSelectedSubmitType,
setHasEdited,
setHasAddedResponse,
handleSubmit,
}: InboxItemInputProps) {
const isEditAllowed = interruptValue.config.allow_edit;
const isResponseAllowed = interruptValue.config.allow_respond;
const hasArgs = Object.entries(interruptValue.action_request.args).length > 0;
const showArgsInResponse =
hasArgs && !isEditAllowed && !acceptAllowed && isResponseAllowed;
const showArgsOutsideActionCards =
hasArgs && !showArgsInResponse && !isEditAllowed && !acceptAllowed;
const onEditChange = (
change: string | string[],
response: HumanResponseWithEdits,
key: string | string[],
) => {
if (
(Array.isArray(change) && !Array.isArray(key)) ||
(!Array.isArray(change) && Array.isArray(key))
) {
toast("Error", {
description: "Something went wrong",
richColors: true,
closeButton: true,
});
return;
}
let valuesChanged = true;
if (typeof response.args === "object") {
const updatedArgs = { ...(response.args?.args || {}) };
if (Array.isArray(change) && Array.isArray(key)) {
// Handle array inputs by mapping corresponding values
change.forEach((value, index) => {
if (index < key.length) {
updatedArgs[key[index]] = value;
}
});
} else {
// Handle single value case
updatedArgs[key as string] = change as string;
}
const haveValuesChanged = haveArgsChanged(updatedArgs, initialValues);
valuesChanged = haveValuesChanged;
}
if (!valuesChanged) {
setHasEdited(false);
if (acceptAllowed) {
setSelectedSubmitType("accept");
} else if (hasAddedResponse) {
setSelectedSubmitType("response");
}
} else {
setSelectedSubmitType("edit");
setHasEdited(true);
}
setHumanResponse((prev) => {
if (typeof response.args !== "object" || !response.args) {
console.error(
"Mismatched response type",
!!response.args,
typeof response.args,
);
return prev;
}
const newEdit: HumanResponseWithEdits = {
type: response.type,
args: {
action: response.args.action,
args:
Array.isArray(change) && Array.isArray(key)
? {
...response.args.args,
...Object.fromEntries(key.map((k, i) => [k, change[i]])),
}
: {
...response.args.args,
[key as string]: change as string,
},
},
};
if (
prev.find(
(p) =>
p.type === response.type &&
typeof p.args === "object" &&
p.args?.action === (response.args as ActionRequest).action,
)
) {
return prev.map((p) => {
if (
p.type === response.type &&
typeof p.args === "object" &&
p.args?.action === (response.args as ActionRequest).action
) {
if (p.acceptAllowed) {
return {
...newEdit,
acceptAllowed: true,
editsMade: valuesChanged,
};
}
return newEdit;
}
return p;
});
} else {
throw new Error("No matching response found");
}
});
};
const onResponseChange = (
change: string,
response: HumanResponseWithEdits,
) => {
if (!change) {
setHasAddedResponse(false);
if (hasEdited) {
// The user has deleted their response, so we should set the submit type to
// `edit` if they've edited, or `accept` if it's allowed and they have not edited.
setSelectedSubmitType("edit");
} else if (acceptAllowed) {
setSelectedSubmitType("accept");
}
} else {
setSelectedSubmitType("response");
setHasAddedResponse(true);
}
setHumanResponse((prev) => {
const newResponse: HumanResponseWithEdits = {
type: response.type,
args: change,
};
if (prev.find((p) => p.type === response.type)) {
return prev.map((p) => {
if (p.type === response.type) {
if (p.acceptAllowed) {
return {
...newResponse,
acceptAllowed: true,
editsMade: !!change,
};
}
return newResponse;
}
return p;
});
} else {
throw new Error("No human response found for string response");
}
});
};
return (
<div
className="w-full flex flex-col items-start justify-start gap-2"
>
{showArgsOutsideActionCards && (
<ArgsRenderer args={interruptValue.action_request.args} />
)}
<div className="flex flex-col gap-2 items-start w-full">
<EditAndOrAccept
humanResponse={humanResponse}
streaming={streaming}
initialValues={initialValues}
interruptValue={interruptValue}
onEditChange={onEditChange}
handleSubmit={handleSubmit}
/>
{supportsMultipleMethods ? (
<div className="flex gap-3 items-center mx-auto mt-3">
<Separator className="w-[full]" />
<p className="text-sm text-gray-500">Or</p>
<Separator className="w-full" />
</div>
) : null}
<Response
humanResponse={humanResponse}
streaming={streaming}
showArgsInResponse={showArgsInResponse}
interruptValue={interruptValue}
onResponseChange={onResponseChange}
handleSubmit={handleSubmit}
/>
{streaming && <p className="text-sm text-gray-600">Running...</p>}
{streamFinished && (
<p className="text-base text-green-600 font-medium">
Successfully finished Graph invocation.
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,294 @@
import { ChevronRight, X, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import { useEffect, useState } from "react";
import {
baseMessageObject,
isArrayOfMessages,
prettifyText,
unknownToPrettyDate,
} from "../utils";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { BaseMessage } from "@langchain/core/messages";
import { ToolCall } from "@langchain/core/messages/tool";
import { ToolCallTable } from "./tool-call-table";
import { Button } from "@/components/ui/button";
import { MarkdownText } from "../../markdown-text";
interface StateViewRecursiveProps {
value: unknown;
expanded?: boolean;
}
const messageTypeToLabel = (message: BaseMessage) => {
let type = "";
if ("type" in message) {
type = message.type as string;
} else {
type = message._getType();
}
switch (type) {
case "human":
return "User";
case "ai":
return "Assistant";
case "tool":
return "Tool";
case "System":
return "System";
default:
return "";
}
};
function MessagesRenderer({ messages }: { messages: BaseMessage[] }) {
return (
<div className="flex flex-col gap-1 w-full">
{messages.map((msg, idx) => {
const messageTypeLabel = messageTypeToLabel(msg);
const content =
typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content, null);
return (
<div
key={msg.id ?? `message-${idx}`}
className="flex flex-col gap-[2px] ml-2 w-full"
>
<p className="font-medium text-gray-700">{messageTypeLabel}:</p>
{content && <MarkdownText>{content}</MarkdownText>}
{"tool_calls" in msg && msg.tool_calls ? (
<div className="flex flex-col gap-1 items-start w-full">
{(msg.tool_calls as ToolCall[]).map((tc, idx) => (
<ToolCallTable
key={tc.id ?? `tool-call-${idx}`}
toolCall={tc}
/>
))}
</div>
) : null}
</div>
);
})}
</div>
);
}
function StateViewRecursive(props: StateViewRecursiveProps) {
const date = unknownToPrettyDate(props.value);
if (date) {
return <p className="font-light text-gray-600">{date}</p>;
}
if (["string", "number"].includes(typeof props.value)) {
return <MarkdownText>{props.value as string}</MarkdownText>;
}
if (typeof props.value === "boolean") {
return <MarkdownText>{JSON.stringify(props.value)}</MarkdownText>;
}
if (props.value == null) {
return <p className="font-light text-gray-600 whitespace-pre-wrap">null</p>;
}
if (Array.isArray(props.value)) {
if (props.value.length > 0 && isArrayOfMessages(props.value)) {
return <MessagesRenderer messages={props.value} />;
}
const valueArray = props.value as unknown[];
return (
<div className="flex flex-row gap-1 items-start justify-start w-full">
<span className="font-normal text-black">[</span>
{valueArray.map((item, idx) => {
const itemRenderValue = baseMessageObject(item);
return (
<div
key={`state-view-${idx}`}
className="flex flex-row items-start whitespace-pre-wrap w-full"
>
<StateViewRecursive value={itemRenderValue} />
{idx < valueArray?.length - 1 && (
<span className="text-black font-normal">,&nbsp;</span>
)}
</div>
);
})}
<span className="font-normal text-black">]</span>
</div>
);
}
if (typeof props.value === "object") {
if (Object.keys(props.value).length === 0) {
return <p className="font-light text-gray-600">{"{}"}</p>;
}
return (
<div className="flex flex-col gap-1 items-start justify-start ml-6 relative w-full">
{/* Vertical line */}
<div className="absolute left-[-24px] top-0 h-full w-[1px] bg-gray-200" />
{Object.entries(props.value).map(([key, value], idx) => (
<div
key={`state-view-object-${key}-${idx}`}
className="relative w-full"
>
{/* Horizontal connector line */}
<div className="absolute left-[-20px] top-[10px] h-[1px] w-[18px] bg-gray-200" />
<StateViewObject
expanded={props.expanded}
keyName={key}
value={value}
/>
</div>
))}
</div>
);
}
}
function HasContentsEllipsis({ onClick }: { onClick?: () => void }) {
return (
<span
onClick={onClick}
className={cn(
"font-mono text-[10px] leading-3 p-[2px] rounded-md",
"bg-gray-50 hover:bg-gray-100 text-gray-600 hover:text-gray-800",
"transition-colors ease-in-out cursor-pointer",
"-translate-y-[2px] inline-block",
)}
>
{"{...}"}
</span>
);
}
interface StateViewProps {
keyName: string;
value: unknown;
/**
* Whether or not to expand or collapse the view
* @default true
*/
expanded?: boolean;
}
export function StateViewObject(props: StateViewProps) {
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (props.expanded != null) {
setExpanded(props.expanded);
}
}, [props.expanded]);
return (
<div className="flex flex-row gap-2 items-start justify-start relative text-sm">
<motion.div
initial={false}
animate={{ rotate: expanded ? 90 : 0 }}
transition={{ duration: 0.2 }}
>
<div
onClick={() => setExpanded((prev) => !prev)}
className="w-5 h-5 flex items-center justify-center hover:bg-gray-100 text-gray-500 hover:text-black rounded-md transition-colors ease-in-out cursor-pointer"
>
<ChevronRight className="w-4 h-4" />
</div>
</motion.div>
<div className="flex flex-col gap-1 items-start justify-start w-full">
<p className="text-black font-normal">
{prettifyText(props.keyName)}{" "}
{!expanded && (
<HasContentsEllipsis onClick={() => setExpanded((prev) => !prev)} />
)}
</p>
<motion.div
initial={false}
animate={{
height: expanded ? "auto" : 0,
opacity: expanded ? 1 : 0,
}}
transition={{
duration: 0.2,
ease: "easeInOut",
}}
style={{ overflow: "hidden" }}
className="relative w-full"
>
<StateViewRecursive expanded={props.expanded} value={props.value} />
</motion.div>
</div>
</div>
);
}
interface StateViewComponentProps {
values: Record<string, any>;
description: string | undefined;
handleShowSidePanel: (showState: boolean, showDescription: boolean) => void;
view: "description" | "state";
}
export function StateView({
handleShowSidePanel,
view,
values,
description,
}: StateViewComponentProps) {
const [expanded, setExpanded] = useState(false);
if (!values) {
return <div>No state found</div>;
}
return (
<div className="overflow-y-auto pl-6 border-t-[1px] lg:border-t-[0px] lg:border-l-[1px] border-gray-100 flex flex-row gap-0 w-full">
{view === "description" && (
<div className="pt-6 pb-2">
<MarkdownText>
{description ?? "No description provided"}
</MarkdownText>
</div>
)}
{view === "state" && (
<div className="flex flex-col items-start justify-start gap-1 pt-6 pb-2">
{Object.entries(values).map(([k, v], idx) => (
<StateViewObject
expanded={expanded}
key={`state-view-${k}-${idx}`}
keyName={k}
value={v}
/>
))}
</div>
)}
<div className="flex gap-2 items-start justify-end pt-6 pr-6">
{view === "state" && (
<Button
onClick={() => setExpanded((prev) => !prev)}
variant="ghost"
className="text-gray-600"
size="sm"
>
{expanded ? (
<ChevronsUpDown className="w-4 h-4" />
) : (
<ChevronsDownUp className="w-4 h-4" />
)}
</Button>
)}
<Button
onClick={() => handleShowSidePanel(false, false)}
variant="ghost"
className="text-gray-600"
size="sm"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,170 @@
import { Button } from "@/components/ui/button";
import { ThreadIdCopyable } from "./thread-id";
import { InboxItemInput } from "./inbox-item-input";
import useInterruptedActions from "../hooks/use-interrupted-actions";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { StringParam, useQueryParam } from "use-query-params";
import { constructOpenInStudioURL } from "../utils";
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
interface ThreadActionsViewProps {
interrupt: HumanInterrupt;
handleShowSidePanel: (showState: boolean, showDescription: boolean) => void;
showState: boolean;
showDescription: boolean;
}
function ButtonGroup({
handleShowState,
handleShowDescription,
showingState,
showingDescription,
}: {
handleShowState: () => void;
handleShowDescription: () => void;
showingState: boolean;
showingDescription: boolean;
}) {
return (
<div className="flex flex-row gap-0 items-center justify-center">
<Button
variant="outline"
className={cn(
"rounded-l-md rounded-r-none border-r-[0px]",
showingState ? "text-black" : "bg-white",
)}
size="sm"
onClick={handleShowState}
>
State
</Button>
<Button
variant="outline"
className={cn(
"rounded-l-none rounded-r-md border-l-[0px]",
showingDescription ? "text-black" : "bg-white",
)}
size="sm"
onClick={handleShowDescription}
>
Description
</Button>
</div>
);
}
export function ThreadActionsView({
interrupt,
handleShowSidePanel,
showDescription,
showState,
}: ThreadActionsViewProps) {
const [threadId] = useQueryParam("threadId", StringParam);
const {
acceptAllowed,
hasEdited,
hasAddedResponse,
streaming,
supportsMultipleMethods,
streamFinished,
loading,
handleSubmit,
handleIgnore,
handleResolve,
setSelectedSubmitType,
setHasAddedResponse,
setHasEdited,
humanResponse,
setHumanResponse,
initialHumanInterruptEditValue,
} = useInterruptedActions({
interrupt,
});
const [apiUrl] = useQueryParam("apiUrl", StringParam);
const handleOpenInStudio = () => {
if (!apiUrl) {
toast("Error", {
description: "Please set the LangGraph deployment URL in settings.",
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
const studioUrl = constructOpenInStudioURL(apiUrl, threadId ?? undefined);
window.open(studioUrl, "_blank");
};
const threadTitle = interrupt.action_request.action || "Unknown";
const actionsDisabled = loading || streaming;
return (
<div className="flex flex-col min-h-full w-full p-12 gap-9">
{/* Header */}
<div className="flex flex-wrap items-center justify-between w-full gap-3">
<div className="flex items-center justify-start gap-3">
<p className="text-2xl tracking-tighter text-pretty">{threadTitle}</p>
{threadId && <ThreadIdCopyable threadId={threadId} />}
</div>
<div className="flex flex-row gap-2 items-center justify-start">
{apiUrl && (
<Button
size="sm"
variant="outline"
className="flex items-center gap-1 bg-white"
onClick={handleOpenInStudio}
>
Studio
</Button>
)}
<ButtonGroup
handleShowState={() => handleShowSidePanel(true, false)}
handleShowDescription={() => handleShowSidePanel(false, true)}
showingState={showState}
showingDescription={showDescription}
/>
</div>
</div>
<div className="flex flex-row gap-2 items-center justify-start w-full">
<Button
variant="outline"
className="text-gray-800 border-gray-500 font-normal bg-white"
onClick={handleResolve}
disabled={actionsDisabled}
>
Mark as Resolved
</Button>
<Button
variant="outline"
className="text-gray-800 border-gray-500 font-normal bg-white"
onClick={handleIgnore}
disabled={actionsDisabled}
>
Ignore
</Button>
</div>
{/* Actions */}
<InboxItemInput
acceptAllowed={acceptAllowed}
hasEdited={hasEdited}
hasAddedResponse={hasAddedResponse}
interruptValue={interrupt}
humanResponse={humanResponse}
initialValues={initialHumanInterruptEditValue.current}
setHumanResponse={setHumanResponse}
streaming={streaming}
streamFinished={streamFinished}
supportsMultipleMethods={supportsMultipleMethods}
setSelectedSubmitType={setSelectedSubmitType}
setHasAddedResponse={setHasAddedResponse}
setHasEdited={setHasEdited}
handleSubmit={handleSubmit}
/>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { Copy, CopyCheck } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { TooltipIconButton } from "../../tooltip-icon-button";
export function ThreadIdTooltip({ threadId }: { threadId: string }) {
const firstThreeChars = threadId.slice(0, 3);
const lastThreeChars = threadId.slice(-3);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<p className="font-mono tracking-tighter text-[10px] leading-[12px] px-1 py-[2px] bg-gray-100 rounded-md">
{firstThreeChars}...{lastThreeChars}
</p>
</TooltipTrigger>
<TooltipContent>
<ThreadIdCopyable threadId={threadId} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function ThreadIdCopyable({
threadId,
showUUID = false,
}: {
threadId: string;
showUUID?: boolean;
}) {
const [copied, setCopied] = React.useState(false);
const handleCopy = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
navigator.clipboard.writeText(threadId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<TooltipIconButton
onClick={(e) => handleCopy(e)}
variant="ghost"
tooltip="Copy thread ID"
className="flex flex-grow-0 gap-1 items-center p-1 rounded-md border-[1px] cursor-pointer hover:bg-gray-50/90 border-gray-200 w-fit"
>
<p className="font-mono text-xs">{showUUID ? threadId : "ID"}</p>
<AnimatePresence mode="wait" initial={false}>
{copied ? (
<motion.div
key="check"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
>
<CopyCheck className="text-green-500 max-w-3 w-3 max-h-3 h-3" />
</motion.div>
) : (
<motion.div
key="copy"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
>
<Copy className="text-gray-500 max-w-3 w-3 max-h-3 h-3" />
</motion.div>
)}
</AnimatePresence>
</TooltipIconButton>
);
}

View File

@@ -0,0 +1,45 @@
import { ToolCall } from "@langchain/core/messages/tool";
import { unknownToPrettyDate } from "../utils";
export function ToolCallTable({ toolCall }: { toolCall: ToolCall }) {
return (
<div className="min-w-[300px] max-w-full border rounded-lg overflow-hidden">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left px-2 py-0 bg-gray-100 text-sm" colSpan={2}>
{toolCall.name}
</th>
</tr>
</thead>
<tbody>
{Object.entries(toolCall.args).map(([key, value]) => {
let valueStr = "";
if (["string", "number"].includes(typeof value)) {
valueStr = value.toString();
}
const date = unknownToPrettyDate(value);
if (date) {
valueStr = date;
}
try {
valueStr = valueStr || JSON.stringify(value, null);
} catch (_) {
// failed to stringify, just assign an empty string
valueStr = "";
}
return (
<tr key={key} className="border-t">
<td className="px-2 py-1 font-medium w-1/3 text-xs">{key}</td>
<td className="px-2 py-1 font-mono text-xs">{valueStr}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,297 @@
import { HumanResponseWithEdits, SubmitType } from "../types";
import {
KeyboardEvent,
Dispatch,
SetStateAction,
MutableRefObject,
useState,
useRef,
useEffect,
} from "react";
import { createDefaultHumanResponse } from "../utils";
import { toast } from "sonner";
import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt";
import { useThreads } from "@/providers/Thread";
import { StringParam, useQueryParam } from "use-query-params";
interface UseInterruptedActionsInput {
interrupt: HumanInterrupt;
}
interface UseInterruptedActionsValue {
// Actions
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
) => Promise<void>;
handleIgnore: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => Promise<void>;
handleResolve: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => Promise<void>;
// State values
streaming: boolean;
streamFinished: boolean;
loading: boolean;
supportsMultipleMethods: boolean;
hasEdited: boolean;
hasAddedResponse: boolean;
acceptAllowed: boolean;
humanResponse: HumanResponseWithEdits[];
// State setters
setSelectedSubmitType: Dispatch<SetStateAction<SubmitType | undefined>>;
setHumanResponse: Dispatch<SetStateAction<HumanResponseWithEdits[]>>;
setHasAddedResponse: Dispatch<SetStateAction<boolean>>;
setHasEdited: Dispatch<SetStateAction<boolean>>;
// Refs
initialHumanInterruptEditValue: MutableRefObject<Record<string, string>>;
}
export default function useInterruptedActions({
interrupt,
}: UseInterruptedActionsInput): UseInterruptedActionsValue {
const { resumeRun, ignoreRun } = useThreads();
const [threadId] = useQueryParam("threadId", StringParam);
const [humanResponse, setHumanResponse] = useState<HumanResponseWithEdits[]>(
[],
);
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
const [streamFinished, setStreamFinished] = useState(false);
const initialHumanInterruptEditValue = useRef<Record<string, string>>({});
const [selectedSubmitType, setSelectedSubmitType] = useState<SubmitType>();
// Whether or not the user has edited any fields which allow editing.
const [hasEdited, setHasEdited] = useState(false);
// Whether or not the user has added a response.
const [hasAddedResponse, setHasAddedResponse] = useState(false);
const [acceptAllowed, setAcceptAllowed] = useState(false);
useEffect(() => {
try {
const { responses, defaultSubmitType, hasAccept } =
createDefaultHumanResponse(interrupt, initialHumanInterruptEditValue);
setSelectedSubmitType(defaultSubmitType);
setHumanResponse(responses);
setAcceptAllowed(hasAccept);
} catch (e) {
console.error("Error formatting and setting human response state", e);
}
}, [interrupt]);
const handleSubmit = async (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
) => {
e.preventDefault();
if (!humanResponse) {
toast("Error", {
description: "Please enter a response.",
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
if (!threadId) {
toast("Error", {
description: "Please select a thread.",
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
let errorOccurred = false;
initialHumanInterruptEditValue.current = {};
if (
humanResponse.some((r) => ["response", "edit", "accept"].includes(r.type))
) {
setStreamFinished(false);
try {
const humanResponseInput: HumanResponse[] = humanResponse.flatMap(
(r) => {
if (r.type === "edit") {
if (r.acceptAllowed && !r.editsMade) {
return {
type: "accept",
args: r.args,
};
} else {
return {
type: "edit",
args: r.args,
};
}
}
if (r.type === "response" && !r.args) {
// If response was allowed but no response was given, do not include in the response
return [];
}
return {
type: r.type,
args: r.args,
};
},
);
const input = humanResponseInput.find(
(r) => r.type === selectedSubmitType,
);
if (!input) {
toast("Error", {
description: "No response found.",
richColors: true,
closeButton: true,
duration: 5000,
});
return;
}
setLoading(true);
setStreaming(true);
const response = resumeRun(threadId, [input], {
stream: true,
});
if (!response) {
// This will only be undefined if the graph ID is not found
// in this case, the method will trigger a toast for us.
return;
}
toast("Success", {
description: "Response submitted successfully.",
duration: 5000,
});
if (!errorOccurred) {
setStreamFinished(true);
}
} catch (e: any) {
console.error("Error sending human response", e);
if ("message" in e && e.message.includes("Invalid assistant ID")) {
toast("Error: Invalid assistant ID", {
description:
"The provided assistant ID was not found in this graph. Please update the assistant ID in the settings and try again.",
richColors: true,
closeButton: true,
duration: 5000,
});
} else {
toast("Error", {
description: "Failed to submit response.",
richColors: true,
closeButton: true,
duration: 5000,
});
}
errorOccurred = true;
setStreaming(false);
setStreamFinished(false);
}
if (!errorOccurred) {
setStreaming(false);
setStreamFinished(false);
}
} else {
setLoading(true);
await resumeRun(threadId, humanResponse);
toast("Success", {
description: "Response submitted successfully.",
duration: 5000,
});
}
setLoading(false);
};
const handleIgnore = async (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
e.preventDefault();
if (!threadId) {
toast("Error", {
description: "Please select a thread.",
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
const ignoreResponse = humanResponse.find((r) => r.type === "ignore");
if (!ignoreResponse) {
toast("Error", {
description: "The selected thread does not support ignoring.",
duration: 5000,
});
return;
}
setLoading(true);
initialHumanInterruptEditValue.current = {};
await resumeRun(threadId, [ignoreResponse]);
setLoading(false);
toast("Successfully ignored thread", {
duration: 5000,
});
};
const handleResolve = async (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
e.preventDefault();
if (!threadId) {
toast("Error", {
description: "Please select a thread.",
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
setLoading(true);
initialHumanInterruptEditValue.current = {};
await ignoreRun(threadId);
setLoading(false);
};
const supportsMultipleMethods =
humanResponse.filter(
(r) => r.type === "edit" || r.type === "accept" || r.type === "response",
).length > 1;
return {
handleSubmit,
handleIgnore,
handleResolve,
humanResponse,
streaming,
streamFinished,
loading,
supportsMultipleMethods,
hasEdited,
hasAddedResponse,
acceptAllowed,
setSelectedSubmitType,
setHumanResponse,
setHasAddedResponse,
setHasEdited,
initialHumanInterruptEditValue,
};
}

View File

@@ -0,0 +1,68 @@
import { StateView } from "./components/state-view";
import { ThreadActionsView } from "./components/thread-actions-view";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
import { useStreamContext } from "@/providers/Stream";
interface ThreadViewProps {
interrupt: HumanInterrupt;
}
export function ThreadView({ interrupt }: ThreadViewProps) {
const thread = useStreamContext();
const [showDescription, setShowDescription] = useState(true);
const [showState, setShowState] = useState(false);
const showSidePanel = showDescription || showState;
const handleShowSidePanel = (
showState: boolean,
showDescription: boolean,
) => {
if (showState && showDescription) {
console.error("Cannot show both state and description");
return;
}
if (showState) {
setShowDescription(false);
setShowState(true);
} else if (showDescription) {
setShowState(false);
setShowDescription(true);
} else {
setShowState(false);
setShowDescription(false);
}
};
return (
<div className="flex flex-col lg:flex-row w-full h-[750px] border-[1px] border-slate-200 rounded-2xl pb-4">
<div
className={cn(
"flex overflow-y-auto",
showSidePanel ? "lg:min-w-1/2 w-full" : "w-full",
)}
>
<ThreadActionsView
interrupt={interrupt}
handleShowSidePanel={handleShowSidePanel}
showState={showState}
showDescription={showDescription}
/>
</div>
<div
className={cn(
showSidePanel ? "flex" : "hidden",
"overflow-y-auto lg:max-w-1/2 w-full",
)}
>
<StateView
handleShowSidePanel={handleShowSidePanel}
description={interrupt.description}
values={thread.values}
view={showState ? "state" : "description"}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { BaseMessage } from "@langchain/core/messages";
import { Thread, ThreadStatus } from "@langchain/langgraph-sdk";
import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt";
export type HumanResponseWithEdits = HumanResponse &
(
| { acceptAllowed?: false; editsMade?: never }
| { acceptAllowed?: true; editsMade?: boolean }
);
export type Email = {
id: string;
thread_id: string;
from_email: string;
to_email: string;
subject: string;
page_content: string;
send_time: string | undefined;
read?: boolean;
status?: "in-queue" | "processing" | "hitl" | "done";
};
export interface ThreadValues {
email: Email;
messages: BaseMessage[];
triage: {
logic: string;
response: string;
};
}
export type ThreadData<
ThreadValues extends Record<string, any> = Record<string, any>,
> = {
thread: Thread<ThreadValues>;
} & (
| {
status: "interrupted";
interrupts: HumanInterrupt[] | undefined;
}
| {
status: "idle" | "busy" | "error";
interrupts?: never;
}
);
export type ThreadStatusWithAll = ThreadStatus | "all";
export type SubmitType = "accept" | "response" | "edit";
export interface AgentInbox {
/**
* A unique identifier for the inbox.
*/
id: string;
/**
* The ID of the graph.
*/
graphId: string;
/**
* The URL of the deployment. Either a localhost URL, or a deployment URL.
*/
deploymentUrl: string;
/**
* Optional name for the inbox, used in the UI to label the inbox.
*/
name?: string;
/**
* Whether or not the inbox is selected.
*/
selected: boolean;
}

View File

@@ -0,0 +1,223 @@
import { BaseMessage, isBaseMessage } from "@langchain/core/messages";
import { format } from "date-fns";
import { startCase } from "lodash";
import { HumanResponseWithEdits, SubmitType } from "./types";
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
export function prettifyText(action: string) {
return startCase(action.replace(/_/g, " "));
}
export function isArrayOfMessages(
value: Record<string, any>[],
): value is BaseMessage[] {
if (
value.every(isBaseMessage) ||
(Array.isArray(value) &&
value.every(
(v) =>
typeof v === "object" &&
"id" in v &&
"type" in v &&
"content" in v &&
"additional_kwargs" in v,
))
) {
return true;
}
return false;
}
export function baseMessageObject(item: unknown): string {
if (isBaseMessage(item)) {
const contentText =
typeof item.content === "string"
? item.content
: JSON.stringify(item.content, null);
let toolCallText = "";
if ("tool_calls" in item) {
toolCallText = JSON.stringify(item.tool_calls, null);
}
if ("type" in item) {
return `${item.type}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
} else if ("_getType" in item) {
return `${item._getType()}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
}
} else if (
typeof item === "object" &&
item &&
"type" in item &&
"content" in item
) {
const contentText =
typeof item.content === "string"
? item.content
: JSON.stringify(item.content, null);
let toolCallText = "";
if ("tool_calls" in item) {
toolCallText = JSON.stringify(item.tool_calls, null);
}
return `${item.type}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
}
if (typeof item === "object") {
return JSON.stringify(item, null);
} else {
return item as string;
}
}
export function unknownToPrettyDate(input: unknown): string | undefined {
try {
if (
Object.prototype.toString.call(input) === "[object Date]" ||
new Date(input as string)
) {
return format(new Date(input as string), "MM/dd/yyyy hh:mm a");
}
} catch (_) {
// failed to parse date. no-op
}
return undefined;
}
export function createDefaultHumanResponse(
interrupt: HumanInterrupt,
initialHumanInterruptEditValue: React.MutableRefObject<
Record<string, string>
>,
): {
responses: HumanResponseWithEdits[];
defaultSubmitType: SubmitType | undefined;
hasAccept: boolean;
} {
const responses: HumanResponseWithEdits[] = [];
if (interrupt.config.allow_edit) {
if (interrupt.config.allow_accept) {
Object.entries(interrupt.action_request.args).forEach(([k, v]) => {
let stringValue = "";
if (typeof v === "string") {
stringValue = v;
} else {
stringValue = JSON.stringify(v, null);
}
if (
!initialHumanInterruptEditValue.current ||
!(k in initialHumanInterruptEditValue.current)
) {
initialHumanInterruptEditValue.current = {
...initialHumanInterruptEditValue.current,
[k]: stringValue,
};
} else if (
k in initialHumanInterruptEditValue.current &&
initialHumanInterruptEditValue.current[k] !== stringValue
) {
console.error(
"KEY AND VALUE FOUND IN initialHumanInterruptEditValue.current THAT DOES NOT MATCH THE ACTION REQUEST",
{
key: k,
value: stringValue,
expectedValue: initialHumanInterruptEditValue.current[k],
},
);
}
});
responses.push({
type: "edit",
args: interrupt.action_request,
acceptAllowed: true,
editsMade: false,
});
} else {
responses.push({
type: "edit",
args: interrupt.action_request,
acceptAllowed: false,
});
}
}
if (interrupt.config.allow_respond) {
responses.push({
type: "response",
args: "",
});
}
if (interrupt.config.allow_ignore) {
responses.push({
type: "ignore",
args: null,
});
}
// Set the submit type.
// Priority: accept > response > edit
const acceptAllowedConfig = interrupt.config.allow_accept;
const ignoreAllowedConfig = interrupt.config.allow_ignore;
const hasResponse = responses.find((r) => r.type === "response");
const hasAccept =
responses.find((r) => r.acceptAllowed) || acceptAllowedConfig;
const hasEdit = responses.find((r) => r.type === "edit");
let defaultSubmitType: SubmitType | undefined;
if (hasAccept) {
defaultSubmitType = "accept";
} else if (hasResponse) {
defaultSubmitType = "response";
} else if (hasEdit) {
defaultSubmitType = "edit";
}
if (acceptAllowedConfig && !responses.find((r) => r.type === "accept")) {
responses.push({
type: "accept",
args: null,
});
}
if (ignoreAllowedConfig && !responses.find((r) => r.type === "ignore")) {
responses.push({
type: "ignore",
args: null,
});
}
return { responses, defaultSubmitType, hasAccept: !!hasAccept };
}
export function constructOpenInStudioURL(
deploymentUrl: string,
threadId?: string,
) {
const smithStudioURL = new URL("https://smith.langchain.com/studio/thread");
// trim the trailing slash from deploymentUrl
const trimmedDeploymentUrl = deploymentUrl.replace(/\/$/, "");
if (threadId) {
smithStudioURL.pathname += `/${threadId}`;
}
smithStudioURL.searchParams.append("baseUrl", trimmedDeploymentUrl);
return smithStudioURL.toString();
}
export function haveArgsChanged(
args: unknown,
initialValues: Record<string, string>,
): boolean {
if (typeof args !== "object" || !args) {
return false;
}
const currentValues = args as Record<string, string>;
return Object.entries(currentValues).some(([key, value]) => {
const valueString = ["string", "number"].includes(typeof value)
? value.toString()
: JSON.stringify(value, null);
return initialValues[key] !== valueString;
});
}

View File

@@ -9,6 +9,8 @@ import { cn } from "@/lib/utils";
import { ToolCalls, ToolResult } from "./tool-calls"; import { ToolCalls, ToolResult } from "./tool-calls";
import { MessageContentComplex } from "@langchain/core/messages"; import { MessageContentComplex } from "@langchain/core/messages";
import { Fragment } from "react/jsx-runtime"; import { Fragment } from "react/jsx-runtime";
import { isAgentInboxInterrupt } from "@/lib/is-hitl";
import { ThreadView } from "../agent-inbox";
function CustomComponent({ function CustomComponent({
message, message,
@@ -79,6 +81,7 @@ export function AssistantMessage({
const thread = useStreamContext(); const thread = useStreamContext();
const meta = thread.getMessagesMetadata(message); const meta = thread.getMessagesMetadata(message);
const interrupt = thread.interrupt;
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint; const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
const anthropicStreamedToolCalls = Array.isArray(message.content) const anthropicStreamedToolCalls = Array.isArray(message.content)
? parseAnthropicStreamedToolCalls(message.content) ? parseAnthropicStreamedToolCalls(message.content)
@@ -115,6 +118,9 @@ export function AssistantMessage({
)) || )) ||
(hasToolCalls && <ToolCalls toolCalls={message.tool_calls} />)} (hasToolCalls && <ToolCalls toolCalls={message.tool_calls} />)}
<CustomComponent message={message} thread={thread} /> <CustomComponent message={message} thread={thread} />
{isAgentInboxInterrupt(interrupt?.value) && (
<ThreadView interrupt={interrupt.value[0]} />
)}
<div <div
className={cn( className={cn(
"flex gap-2 items-center mr-auto transition-opacity", "flex gap-2 items-center mr-auto transition-opacity",

View File

@@ -19,6 +19,7 @@ const buttonVariants = cva(
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
brand: "bg-[#2F6868] hover:bg-[#2F6868]/90 border-[#2F6868] text-white",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

17
src/lib/is-hitl.ts Normal file
View File

@@ -0,0 +1,17 @@
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
export function isAgentInboxInterrupt(
value: unknown,
): value is HumanInterrupt[] {
return (
Array.isArray(value) &&
"action_request" in value[0] &&
typeof value[0].action_request === "object" &&
"config" in value[0] &&
typeof value[0].config === "object" &&
"allow_respond" in value[0].config &&
"allow_accept" in value[0].config &&
"allow_edit" in value[0].config &&
"allow_ignore" in value[0].config
);
}

View File

@@ -68,6 +68,8 @@ const StreamSession = ({
}, },
}); });
console.log("streamValue.interrupt", streamValue.interrupt);
return ( return (
<StreamContext.Provider value={streamValue}> <StreamContext.Provider value={streamValue}>
{children} {children}

View File

@@ -1,6 +1,6 @@
import { validate } from "uuid"; import { validate } from "uuid";
import { getApiKey } from "@/lib/api-key"; import { getApiKey } from "@/lib/api-key";
import { Client, Thread } from "@langchain/langgraph-sdk"; import { Client, Run, Thread } from "@langchain/langgraph-sdk";
import { useQueryParam, StringParam } from "use-query-params"; import { useQueryParam, StringParam } from "use-query-params";
import { import {
createContext, createContext,
@@ -11,6 +11,9 @@ import {
Dispatch, Dispatch,
SetStateAction, SetStateAction,
} from "react"; } from "react";
import { END } from "@langchain/langgraph/web";
import { HumanResponse } from "@langchain/langgraph/prebuilt";
import { toast } from "sonner";
interface ThreadContextType { interface ThreadContextType {
getThreads: () => Promise<Thread[]>; getThreads: () => Promise<Thread[]>;
@@ -18,6 +21,21 @@ interface ThreadContextType {
setThreads: Dispatch<SetStateAction<Thread[]>>; setThreads: Dispatch<SetStateAction<Thread[]>>;
threadsLoading: boolean; threadsLoading: boolean;
setThreadsLoading: Dispatch<SetStateAction<boolean>>; setThreadsLoading: Dispatch<SetStateAction<boolean>>;
resumeRun: <TStream extends boolean = false>(
threadId: string,
response: HumanResponse[],
options?: {
stream?: TStream;
},
) => TStream extends true
?
| AsyncGenerator<{
event: Record<string, any>;
data: any;
}>
| undefined
: Promise<Run> | undefined;
ignoreRun: (threadId: string) => Promise<void>;
} }
const ThreadContext = createContext<ThreadContextType | undefined>(undefined); const ThreadContext = createContext<ThreadContextType | undefined>(undefined);
@@ -47,7 +65,6 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
const getThreads = useCallback(async (): Promise<Thread[]> => { const getThreads = useCallback(async (): Promise<Thread[]> => {
if (!apiUrl || !assistantId) return []; if (!apiUrl || !assistantId) return [];
const client = createClient(apiUrl, getApiKey() ?? undefined); const client = createClient(apiUrl, getApiKey() ?? undefined);
const threads = await client.threads.search({ const threads = await client.threads.search({
@@ -60,12 +77,76 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
return threads; return threads;
}, [apiUrl, assistantId]); }, [apiUrl, assistantId]);
const resumeRun = <TStream extends boolean = false>(
threadId: string,
response: HumanResponse[],
options?: {
stream?: TStream;
},
): TStream extends true
?
| AsyncGenerator<{
event: Record<string, any>;
data: any;
}>
| undefined
: Promise<Run> | undefined => {
if (!apiUrl || !assistantId) return undefined;
const client = createClient(apiUrl, getApiKey() ?? undefined);
try {
if (options?.stream) {
return client.runs.stream(threadId, assistantId, {
command: {
resume: response,
},
streamMode: "events",
}) as any; // Type assertion needed due to conditional return type
}
return client.runs.create(threadId, assistantId, {
command: {
resume: response,
},
}) as any; // Type assertion needed due to conditional return type
} catch (e: any) {
console.error("Error sending human response", e);
throw e;
}
};
const ignoreRun = async (threadId: string) => {
if (!apiUrl || !assistantId) return;
const client = createClient(apiUrl, getApiKey() ?? undefined);
try {
await client.threads.updateState(threadId, {
values: null,
asNode: END,
});
toast("Success", {
description: "Ignored thread",
duration: 3000,
});
} catch (e) {
console.error("Error ignoring thread", e);
toast("Error", {
description: "Failed to ignore thread",
richColors: true,
closeButton: true,
duration: 3000,
});
}
};
const value = { const value = {
getThreads, getThreads,
threads, threads,
setThreads, setThreads,
threadsLoading, threadsLoading,
setThreadsLoading, setThreadsLoading,
resumeRun,
ignoreRun,
}; };
return ( return (