Add agent chat frontend base

This commit is contained in:
武阳
2026-05-06 12:10:22 +08:00
parent 69c335714c
commit d6a2359dc1
69 changed files with 14436 additions and 0 deletions
@@ -0,0 +1,499 @@
import React from "react";
import { DecisionWithEdits, SubmitType, HITLRequest } from "../types";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Undo2 } from "lucide-react";
import { MarkdownText } from "../../markdown-text";
import { haveArgsChanged, prettifyText } from "../utils";
import { toast } from "sonner";
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="h-4 w-4" />
<span>Reset</span>
</Button>
);
}
function ArgsRenderer({ args }: { args: Record<string, unknown> }) {
return (
<div className="flex w-full flex-col items-start gap-6">
{Object.entries(args).map(([key, value]) => {
const stringValue =
typeof value === "string" || typeof value === "number"
? value.toString()
: JSON.stringify(value, null);
return (
<div
key={`args-${key}`}
className="flex flex-col items-start gap-1"
>
<p className="text-sm leading-[18px] text-wrap text-gray-600">
{prettifyText(key)}
</p>
<span className="w-full max-w-full rounded-xl bg-zinc-100 p-3 text-[13px] leading-[18px] text-black">
<MarkdownText>{stringValue}</MarkdownText>
</span>
</div>
);
})}
</div>
);
}
interface InboxItemInputProps {
interruptValue: HITLRequest;
humanResponse: DecisionWithEdits[];
supportsMultipleMethods: boolean;
approveAllowed: boolean;
hasEdited: boolean;
hasAddedResponse: boolean;
initialValues: Record<string, string>;
isLoading: boolean;
selectedSubmitType: SubmitType | undefined;
setHumanResponse: React.Dispatch<React.SetStateAction<DecisionWithEdits[]>>;
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> | void;
}
function ApproveOnly({
isLoading,
actionRequestArgs,
handleSubmit,
}: {
isLoading: boolean;
actionRequestArgs: Record<string, unknown>;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void> | void;
}) {
return (
<div className="flex w-full flex-col items-start gap-4 rounded-lg border border-gray-300 p-6">
{Object.keys(actionRequestArgs).length > 0 && (
<ArgsRenderer args={actionRequestArgs} />
)}
<Button
variant="brand"
disabled={isLoading}
onClick={handleSubmit}
className="w-full"
>
Approve
</Button>
</div>
);
}
function EditActionCard({
humanResponse,
isLoading,
initialValues,
onEditChange,
handleSubmit,
actionArgs,
}: {
humanResponse: DecisionWithEdits[];
isLoading: boolean;
initialValues: Record<string, string>;
actionArgs: Record<string, unknown>;
onEditChange: (
text: string | string[],
response: DecisionWithEdits,
key: string | string[],
) => void;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void> | void;
}) {
const defaultRows = React.useRef<Record<string, number>>({});
const editResponse = humanResponse.find(
(response) => response.type === "edit",
);
const approveResponse = humanResponse.find(
(response) => response.type === "approve",
);
if (
!editResponse ||
editResponse.type !== "edit" ||
typeof editResponse.edited_action !== "object" ||
!editResponse.edited_action
) {
if (approveResponse) {
return (
<ApproveOnly
actionRequestArgs={actionArgs}
isLoading={isLoading}
handleSubmit={handleSubmit}
/>
);
}
return null;
}
const header = editResponse.acceptAllowed ? "Edit/Approve" : "Edit";
const buttonText =
editResponse.acceptAllowed && !editResponse.editsMade
? "Approve"
: "Submit";
const handleReset = () => {
if (!editResponse.edited_action?.args) {
return;
}
const keysToReset: string[] = [];
const valuesToReset: string[] = [];
Object.entries(initialValues).forEach(([key, value]) => {
if (key in editResponse.edited_action.args) {
const stringValue =
typeof value === "string" || typeof value === "number"
? value.toString()
: JSON.stringify(value, null);
keysToReset.push(key);
valuesToReset.push(stringValue);
}
});
if (keysToReset.length > 0 && valuesToReset.length > 0) {
onEditChange(valuesToReset, editResponse, keysToReset);
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
handleSubmit(event);
}
};
return (
<div className="flex w-full min-w-full flex-col items-start gap-4 rounded-lg border border-gray-300 p-6">
<div className="flex w-full items-center justify-between">
<p className="text-base font-semibold text-black">{header}</p>
<ResetButton handleReset={handleReset} />
</div>
{Object.entries(editResponse.edited_action.args).map(
([key, value], idx) => {
const stringValue =
typeof value === "string" || typeof value === "number"
? value.toString()
: JSON.stringify(value, null);
if (defaultRows.current[key] === undefined) {
defaultRows.current[key] = !stringValue.length
? 3
: Math.max(stringValue.length / 30, 7);
}
return (
<div
className="flex h-full w-full flex-col items-start gap-1 px-[1px]"
key={`allow-edit-args--${key}-${idx}`}
>
<div className="flex w-full flex-col items-start gap-[6px]">
<p className="min-w-fit text-sm font-medium">
{prettifyText(key)}
</p>
<Textarea
disabled={isLoading}
className="h-full w-full max-w-full"
value={stringValue}
onChange={(event) =>
onEditChange(event.target.value, editResponse, key)
}
onKeyDown={handleKeyDown}
rows={defaultRows.current[key] || 8}
/>
</div>
</div>
);
},
)}
<div className="flex w-full items-center justify-end gap-2">
<Button
variant="brand"
disabled={isLoading}
onClick={handleSubmit}
>
{buttonText}
</Button>
</div>
</div>
);
}
const EditAndApprove = React.memo(EditActionCard);
function RejectActionCard({
humanResponse,
isLoading,
onChange,
handleSubmit,
showArgs,
actionArgs,
}: {
humanResponse: DecisionWithEdits[];
isLoading: boolean;
onChange: (value: string, response: DecisionWithEdits) => void;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void> | void;
showArgs: boolean;
actionArgs: Record<string, unknown>;
}) {
const rejectResponse = humanResponse.find(
(response) => response.type === "reject",
);
if (!rejectResponse) {
return null;
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
handleSubmit(event);
}
};
return (
<div className="flex w-full max-w-full flex-col items-start gap-4 rounded-xl border border-gray-300 p-6">
<div className="flex w-full items-center justify-between">
<p className="text-base font-semibold text-black">Reject</p>
<ResetButton handleReset={() => onChange("", rejectResponse)} />
</div>
{showArgs && <ArgsRenderer args={actionArgs} />}
<div className="flex w-full flex-col items-start gap-[6px]">
<p className="min-w-fit text-sm font-medium">Reason</p>
<Textarea
disabled={isLoading}
className="w-full max-w-full"
value={rejectResponse.message ?? ""}
onChange={(event) => onChange(event.target.value, rejectResponse)}
onKeyDown={handleKeyDown}
rows={4}
placeholder="Share feedback with the agent..."
/>
</div>
<div className="flex w-full items-center justify-end gap-2">
<Button
variant="brand"
disabled={isLoading}
onClick={handleSubmit}
>
Submit rejection
</Button>
</div>
</div>
);
}
const RejectCard = React.memo(RejectActionCard);
export function InboxItemInput({
interruptValue,
humanResponse,
approveAllowed,
hasEdited,
hasAddedResponse,
initialValues,
isLoading,
supportsMultipleMethods,
selectedSubmitType,
setHumanResponse,
setSelectedSubmitType,
setHasAddedResponse,
setHasEdited,
handleSubmit,
}: InboxItemInputProps) {
const allowedDecisions =
interruptValue.review_configs?.[0]?.allowed_decisions ?? [];
const actionRequest = interruptValue.action_requests?.[0];
const actionArgs = actionRequest?.args ?? {};
const isEditAllowed = allowedDecisions.includes("edit");
const isRejectAllowed = allowedDecisions.includes("reject");
const hasArgs = Object.keys(actionArgs).length > 0;
const showArgsInReject =
hasArgs && !isEditAllowed && !approveAllowed && isRejectAllowed;
const showArgsOutsideCards =
hasArgs && !showArgsInReject && !isEditAllowed && !approveAllowed;
const onEditChange = (
change: string | string[],
response: DecisionWithEdits,
key: string | string[],
) => {
if (
(Array.isArray(change) && !Array.isArray(key)) ||
(!Array.isArray(change) && Array.isArray(key))
) {
toast.error("Error", {
description: "Unable to update edited values.",
richColors: true,
closeButton: true,
});
return;
}
let valuesChanged = true;
if (response.type === "edit" && response.edited_action) {
const updatedArgs = { ...(response.edited_action.args || {}) };
if (Array.isArray(change) && Array.isArray(key)) {
change.forEach((value, index) => {
if (index < key.length) {
updatedArgs[key[index]] = value;
}
});
} else {
updatedArgs[key as string] = change as string;
}
valuesChanged = haveArgsChanged(updatedArgs, initialValues);
}
if (!valuesChanged) {
setHasEdited(false);
if (approveAllowed) {
setSelectedSubmitType("approve");
} else if (hasAddedResponse) {
setSelectedSubmitType("reject");
}
} else {
setSelectedSubmitType("edit");
setHasEdited(true);
}
setHumanResponse((prev) => {
if (response.type !== "edit" || !response.edited_action) {
console.error("Mismatched response type for edit", response.type);
return prev;
}
const newArgs =
Array.isArray(change) && Array.isArray(key)
? {
...response.edited_action.args,
...Object.fromEntries(key.map((k, index) => [k, change[index]])),
}
: {
...response.edited_action.args,
[key as string]: change as string,
};
const newEdit: DecisionWithEdits = {
type: "edit",
edited_action: {
name: response.edited_action.name,
args: newArgs,
},
};
return prev.map((existing) => {
if (existing.type !== "edit") {
return existing;
}
if (existing.acceptAllowed) {
return {
...newEdit,
acceptAllowed: true,
editsMade: valuesChanged,
};
}
return newEdit;
});
});
};
const onRejectChange = (change: string, response: DecisionWithEdits) => {
if (response.type !== "reject") {
console.error("Mismatched response type for rejection");
return;
}
const trimmed = change.trim();
setHasAddedResponse(!!trimmed);
if (!trimmed) {
if (hasEdited) {
setSelectedSubmitType("edit");
} else if (approveAllowed) {
setSelectedSubmitType("approve");
}
} else {
setSelectedSubmitType("reject");
}
setHumanResponse((prev) =>
prev.map((existing) =>
existing.type === "reject"
? { type: "reject", message: change }
: existing,
),
);
};
return (
<div className="flex w-full max-w-full flex-col items-start justify-start gap-2">
{showArgsOutsideCards && <ArgsRenderer args={actionArgs} />}
<div className="flex w-full flex-col items-stretch gap-2">
<EditAndApprove
humanResponse={humanResponse}
isLoading={isLoading}
initialValues={initialValues}
actionArgs={actionArgs}
onEditChange={onEditChange}
handleSubmit={handleSubmit}
/>
{supportsMultipleMethods ? (
<div className="mx-auto mt-3 flex items-center gap-3">
<Separator className="w-full" />
<p className="text-sm text-gray-500">Or</p>
<Separator className="w-full" />
</div>
) : null}
<RejectCard
humanResponse={humanResponse}
isLoading={isLoading}
showArgs={showArgsInReject}
actionArgs={actionArgs}
onChange={onRejectChange}
handleSubmit={handleSubmit}
/>
{isLoading && (
<p className="text-sm text-gray-600">Submitting decision...</p>
)}
{selectedSubmitType && supportsMultipleMethods && (
<p className="text-xs text-gray-500">
Currently selected: {prettifyText(selectedSubmitType)}
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,303 @@
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 if ("getType" in message) {
type = (message as BaseMessage).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 w-full flex-col gap-1">
{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="ml-2 flex w-full flex-col gap-[2px]"
>
<p className="font-medium text-gray-700">{messageTypeLabel}:</p>
{content && <MarkdownText>{content}</MarkdownText>}
{"tool_calls" in msg && msg.tool_calls ? (
<div className="flex w-full flex-col items-start gap-1">
{(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 whitespace-pre-wrap text-gray-600">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 w-full flex-row items-start justify-start gap-1">
<span className="font-normal text-black">[</span>
{valueArray.map((item, idx) => {
const itemRenderValue = baseMessageObject(item);
return (
<div
key={`state-view-${idx}`}
className="flex w-full flex-row items-start whitespace-pre-wrap"
>
<StateViewRecursive value={itemRenderValue} />
{idx < valueArray?.length - 1 && (
<span className="font-normal text-black">,&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="relative ml-6 flex w-full flex-col items-start justify-start gap-1">
{/* Vertical line */}
<div className="absolute top-0 left-[-24px] 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 top-[10px] left-[-20px] 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(
"rounded-md p-[2px] font-mono text-[10px] leading-3",
"bg-gray-50 text-gray-600 hover:bg-gray-100 hover:text-gray-800",
"cursor-pointer transition-colors ease-in-out",
"inline-block -translate-y-[2px]",
)}
>
{"{...}"}
</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="relative flex flex-row items-start justify-start gap-2 text-sm">
<motion.div
initial={false}
animate={{ rotate: expanded ? 90 : 0 }}
transition={{ duration: 0.2 }}
>
<div
onClick={() => setExpanded((prev) => !prev)}
className="flex h-5 w-5 cursor-pointer items-center justify-center rounded-md text-gray-500 transition-colors ease-in-out hover:bg-gray-100 hover:text-black"
>
<ChevronRight className="h-4 w-4" />
</div>
</motion.div>
<div className="flex w-full flex-col items-start justify-start gap-1">
<p className="font-normal text-black">
{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={cn(
"flex min-w-full flex-row gap-0",
view === "state" &&
"border-t-[1px] border-gray-100 lg:border-t-[0px] lg:border-l-[1px]",
)}
>
{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">
{Object.entries(values).map(([k, v], idx) => (
<StateViewObject
expanded={expanded}
key={`state-view-${k}-${idx}`}
keyName={k}
value={v}
/>
))}
</div>
)}
<div className="flex items-start justify-end gap-2">
{view === "state" && (
<Button
onClick={() => setExpanded((prev) => !prev)}
variant="ghost"
className="text-gray-600"
size="sm"
>
{expanded ? (
<ChevronsUpDown className="h-4 w-4" />
) : (
<ChevronsDownUp className="h-4 w-4" />
)}
</Button>
)}
<Button
onClick={() => handleShowSidePanel(false, false)}
variant="ghost"
className="text-gray-600"
size="sm"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,449 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Interrupt } from "@langchain/langgraph-sdk";
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 { useQueryState } from "nuqs";
import { constructOpenInStudioURL, buildDecisionFromState } from "../utils";
import { Decision, HITLRequest, DecisionType, ActionRequest } from "../types";
import { useStreamContext } from "@/providers/Stream";
interface ThreadActionsViewProps {
interrupt: Interrupt<HITLRequest>;
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 items-center justify-center gap-0">
<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>
);
}
function isValidHitlRequest(
interrupt: Interrupt<HITLRequest>,
): interrupt is Interrupt<HITLRequest> & { value: HITLRequest } {
return (
!!interrupt.value &&
Array.isArray(interrupt.value.action_requests) &&
interrupt.value.action_requests.length > 0 &&
Array.isArray(interrupt.value.review_configs) &&
interrupt.value.review_configs.length > 0
);
}
function getDecisionStatus(
decision: Decision | undefined,
): DecisionType | null {
if (!decision) return null;
return decision.type;
}
function getActionTitle(action?: ActionRequest) {
return action?.name ?? "Unknown interrupt";
}
export function ThreadActionsView({
interrupt,
handleShowSidePanel,
showDescription,
showState,
}: ThreadActionsViewProps) {
const stream = useStreamContext();
const [threadId] = useQueryState("threadId");
const [apiUrl] = useQueryState("apiUrl");
const [currentIndex, setCurrentIndex] = useState(0);
const [addressedActions, setAddressedActions] = useState<
Map<number, Decision>
>(new Map());
const [submittingAll, setSubmittingAll] = useState(false);
const hitlValue = interrupt.value;
const actionRequests = useMemo(
() => hitlValue?.action_requests ?? [],
[hitlValue?.action_requests],
);
const reviewConfigs = useMemo(
() => hitlValue?.review_configs ?? [],
[hitlValue?.review_configs],
);
const hasMultipleActions = actionRequests.length > 1;
const currentAction = actionRequests[currentIndex];
const matchingConfig =
reviewConfigs.find(
(config) => config.action_name === currentAction?.name,
) ?? reviewConfigs[currentIndex];
const singleActionInterrupt = useMemo(() => {
if (!currentAction || !matchingConfig) {
return interrupt;
}
return {
...interrupt,
value: {
action_requests: [currentAction],
review_configs: [matchingConfig],
},
};
}, [interrupt, currentAction, matchingConfig]);
const {
approveAllowed,
hasEdited,
hasAddedResponse,
streaming,
supportsMultipleMethods,
streamFinished,
loading,
handleSubmit,
handleResolve,
setSelectedSubmitType,
setHasAddedResponse,
setHasEdited,
humanResponse,
setHumanResponse,
selectedSubmitType,
initialHumanInterruptEditValue,
} = useInterruptedActions({
interrupt: singleActionInterrupt,
});
useEffect(() => {
setCurrentIndex(0);
setAddressedActions(new Map());
}, [interrupt]);
const handleOpenInStudio = () => {
if (!apiUrl) {
toast.error("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 handleApproveAll = useCallback(() => {
if (!hasMultipleActions) return;
try {
const allDecisions: Decision[] = actionRequests.map(() => ({
type: "approve",
}));
stream.submit(
{},
{
command: {
resume: { decisions: allDecisions },
},
},
);
toast("Success", {
description: "All actions approved successfully.",
duration: 5000,
});
} catch (error) {
console.error("Error approving all actions", error);
toast.error("Error", {
description: "Failed to approve all actions.",
richColors: true,
closeButton: true,
duration: 5000,
});
}
}, [actionRequests, hasMultipleActions, stream]);
const handleSubmitAll = useCallback(() => {
if (!hasMultipleActions) return;
if (addressedActions.size !== actionRequests.length) {
toast.error("Error", {
description: `Please address all ${actionRequests.length} actions before submitting.`,
richColors: true,
closeButton: true,
duration: 5000,
});
return;
}
try {
setSubmittingAll(true);
const allDecisions = actionRequests.map((_, index) => {
const decision = addressedActions.get(index);
if (!decision) {
throw new Error(`Missing decision for action ${index + 1}`);
}
return decision;
});
stream.submit(
{},
{
command: {
resume: { decisions: allDecisions },
},
},
);
toast("Success", {
description: "All actions submitted successfully.",
duration: 5000,
});
setAddressedActions(new Map());
} catch (error) {
console.error("Error submitting all actions", error);
toast.error("Error", {
description: "Failed to submit actions.",
richColors: true,
closeButton: true,
duration: 5000,
});
} finally {
setSubmittingAll(false);
}
}, [actionRequests, addressedActions, hasMultipleActions, stream]);
const allAllowApprove = useMemo(() => {
if (!hasMultipleActions) return false;
return actionRequests.every((actionRequest) => {
const matching = reviewConfigs.find(
(config) => config.action_name === actionRequest.name,
);
return matching?.allowed_decisions.includes("approve");
});
}, [actionRequests, reviewConfigs, hasMultipleActions]);
const handleSaveDecision = () => {
const { decision, error } = buildDecisionFromState(
humanResponse,
selectedSubmitType,
);
if (!decision || error) {
toast.error("Error", {
description: error ?? "Unable to determine decision.",
richColors: true,
closeButton: true,
duration: 5000,
});
return;
}
setAddressedActions((prev) => {
const next = new Map(prev);
next.set(currentIndex, decision);
return next;
});
toast("Success", {
description: `Action ${currentIndex + 1} captured.`,
duration: 3000,
});
if (currentIndex < actionRequests.length - 1) {
setCurrentIndex((prev) => Math.min(actionRequests.length - 1, prev + 1));
}
};
const currentTitle = getActionTitle(currentAction);
const actionsDisabled = loading || streaming || submittingAll;
const hasAllDecisions =
hasMultipleActions && addressedActions.size === actionRequests.length;
if (!isValidHitlRequest(interrupt)) {
return (
<div className="flex min-h-full w-full flex-col items-center justify-center rounded-2xl bg-gray-50/50 p-8">
<p className="text-sm text-gray-600">
Unable to render interrupt. The data provided is not in the expected
HITL format.
</p>
</div>
);
}
const interruptValue = singleActionInterrupt.value as HITLRequest;
return (
<div className="flex min-h-full w-full max-w-full flex-col gap-9">
<div className="flex w-full flex-wrap items-center justify-between gap-3">
<div className="flex items-center justify-start gap-3">
<p className="text-2xl tracking-tighter text-pretty">
{hasMultipleActions
? `${currentTitle} (${currentIndex + 1}/${actionRequests.length})`
: currentTitle}
</p>
{threadId && <ThreadIdCopyable threadId={threadId} />}
</div>
<div className="flex flex-row items-center justify-start gap-2">
{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 w-full flex-row flex-wrap items-center justify-start gap-2">
<Button
variant="outline"
className="border-gray-500 bg-white font-normal text-gray-800"
onClick={handleResolve}
disabled={actionsDisabled}
>
Mark as Resolved
</Button>
{hasMultipleActions && allAllowApprove && (
<Button
variant="outline"
className="border-gray-500 bg-white font-normal text-gray-800"
onClick={handleApproveAll}
disabled={actionsDisabled}
>
Approve All
</Button>
)}
</div>
{hasMultipleActions && (
<div className="flex w-full items-center gap-2">
{actionRequests.map((_, index) => {
const status = getDecisionStatus(addressedActions.get(index));
return (
<button
type="button"
key={index}
onClick={() => setCurrentIndex(index)}
className={cn(
"h-2 flex-1 rounded-full border transition-colors",
"border-gray-300 bg-gray-200",
status === "approve" && "border-emerald-500 bg-emerald-200",
status === "reject" && "border-red-500 bg-red-200",
status === "edit" && "border-amber-500 bg-amber-200",
index === currentIndex &&
"outline-primary outline-2 outline-offset-2",
)}
>
<span className="sr-only">Action {index + 1}</span>
</button>
);
})}
</div>
)}
<InboxItemInput
approveAllowed={approveAllowed}
hasEdited={hasEdited}
hasAddedResponse={hasAddedResponse}
interruptValue={interruptValue}
humanResponse={humanResponse}
initialValues={initialHumanInterruptEditValue.current}
setHumanResponse={setHumanResponse}
supportsMultipleMethods={supportsMultipleMethods}
setSelectedSubmitType={setSelectedSubmitType}
setHasAddedResponse={setHasAddedResponse}
setHasEdited={setHasEdited}
handleSubmit={hasMultipleActions ? handleSaveDecision : handleSubmit}
isLoading={hasMultipleActions ? submittingAll : loading}
selectedSubmitType={selectedSubmitType}
/>
{hasMultipleActions && (
<div className="flex w-full items-center justify-between">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={currentIndex === 0}
onClick={() => setCurrentIndex((prev) => Math.max(0, prev - 1))}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={currentIndex === actionRequests.length - 1}
onClick={() =>
setCurrentIndex((prev) =>
Math.min(actionRequests.length - 1, prev + 1),
)
}
>
Next
</Button>
</div>
<Button
variant="brand"
disabled={!hasAllDecisions || submittingAll}
onClick={handleSubmitAll}
>
{submittingAll
? "Submitting..."
: `Submit all ${actionRequests.length} decisions`}
</Button>
</div>
)}
{!hasMultipleActions && streamFinished && (
<p className="text-base font-medium text-green-600">
Successfully finished Graph invocation.
</p>
)}
</div>
);
}
@@ -0,0 +1,84 @@
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="rounded-md bg-gray-100 px-1 py-[2px] font-mono text-[10px] leading-[12px] tracking-tighter">
{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 w-fit flex-grow-0 cursor-pointer items-center gap-1 rounded-md border-[1px] border-gray-200 p-1 hover:bg-gray-50/90"
>
<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="h-3 max-h-3 w-3 max-w-3 text-green-500" />
</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="h-3 max-h-3 w-3 max-w-3 text-gray-500" />
</motion.div>
)}
</AnimatePresence>
</TooltipIconButton>
);
}
@@ -0,0 +1,51 @@
import { ToolCall } from "@langchain/core/messages/tool";
import { unknownToPrettyDate } from "../utils";
export function ToolCallTable({ toolCall }: { toolCall: ToolCall }) {
return (
<div className="max-w-full min-w-[300px] overflow-hidden rounded-lg border">
<table className="w-full border-collapse">
<thead>
<tr>
<th
className="bg-gray-100 px-2 py-0 text-left 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="w-1/3 px-2 py-1 text-xs font-medium">{key}</td>
<td className="px-2 py-1 font-mono text-xs">{valueStr}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,240 @@
import { useStreamContext } from "@/providers/Stream";
import { END } from "@langchain/langgraph/web";
import { Interrupt } from "@langchain/langgraph-sdk";
import { toast } from "sonner";
import {
Dispatch,
KeyboardEvent,
MutableRefObject,
SetStateAction,
useEffect,
useRef,
useState,
} from "react";
import { Decision, DecisionWithEdits, HITLRequest, SubmitType } from "../types";
import { buildDecisionFromState, createDefaultHumanResponse } from "../utils";
interface UseInterruptedActionsInput {
interrupt: Interrupt<HITLRequest>;
}
interface UseInterruptedActionsValue {
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
) => Promise<void>;
handleResolve: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => Promise<void>;
streaming: boolean;
streamFinished: boolean;
loading: boolean;
supportsMultipleMethods: boolean;
hasEdited: boolean;
hasAddedResponse: boolean;
approveAllowed: boolean;
humanResponse: DecisionWithEdits[];
selectedSubmitType: SubmitType | undefined;
setSelectedSubmitType: Dispatch<SetStateAction<SubmitType | undefined>>;
setHumanResponse: Dispatch<SetStateAction<DecisionWithEdits[]>>;
setHasAddedResponse: Dispatch<SetStateAction<boolean>>;
setHasEdited: Dispatch<SetStateAction<boolean>>;
initialHumanInterruptEditValue: MutableRefObject<Record<string, string>>;
}
export default function useInterruptedActions({
interrupt,
}: UseInterruptedActionsInput): UseInterruptedActionsValue {
const thread = useStreamContext();
const [humanResponse, setHumanResponse] = useState<DecisionWithEdits[]>([]);
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
const [streamFinished, setStreamFinished] = useState(false);
const [selectedSubmitType, setSelectedSubmitType] = useState<SubmitType>();
const [hasEdited, setHasEdited] = useState(false);
const [hasAddedResponse, setHasAddedResponse] = useState(false);
const [approveAllowed, setApproveAllowed] = useState(false);
const initialHumanInterruptEditValue = useRef<Record<string, string>>({});
useEffect(() => {
const hitlValue = interrupt.value as HITLRequest | undefined;
initialHumanInterruptEditValue.current = {};
if (!hitlValue) {
setHumanResponse([]);
setSelectedSubmitType(undefined);
setApproveAllowed(false);
setHasEdited(false);
setHasAddedResponse(false);
return;
}
try {
const { responses, defaultSubmitType, hasApprove } =
createDefaultHumanResponse(hitlValue, initialHumanInterruptEditValue);
setHumanResponse(responses);
setSelectedSubmitType(defaultSubmitType);
setApproveAllowed(hasApprove);
setHasEdited(false);
setHasAddedResponse(false);
} catch (error) {
console.error("Error formatting and setting human response state", error);
setHumanResponse([]);
setSelectedSubmitType(undefined);
setApproveAllowed(false);
}
}, [interrupt]);
const resumeRun = (decisions: Decision[]): boolean => {
try {
thread.submit(
{},
{
command: {
resume: {
decisions,
},
},
},
);
return true;
} catch (error) {
console.error("Error sending human response", error);
return false;
}
};
const handleSubmit = async (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
) => {
e.preventDefault();
const { decision, error } = buildDecisionFromState(
humanResponse,
selectedSubmitType,
);
if (!decision) {
toast.error("Error", {
description: error ?? "Unsupported response type.",
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
if (error) {
toast.error("Error", {
description: error,
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
let errorOccurred = false;
initialHumanInterruptEditValue.current = {};
try {
setLoading(true);
setStreaming(true);
const resumedSuccessfully = resumeRun([decision]);
if (!resumedSuccessfully) {
errorOccurred = true;
return;
}
toast("Success", {
description: "Response submitted successfully.",
duration: 5000,
});
setStreamFinished(true);
} catch (error: any) {
console.error("Error sending human response", error);
errorOccurred = true;
if ("message" in error && error.message.includes("Invalid assistant")) {
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("Error", {
description: "Failed to submit response.",
richColors: true,
closeButton: true,
duration: 5000,
});
}
} finally {
setStreaming(false);
setLoading(false);
if (errorOccurred) {
setStreamFinished(false);
}
}
};
const handleResolve = async (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
e.preventDefault();
setLoading(true);
initialHumanInterruptEditValue.current = {};
try {
thread.submit(
{},
{
command: {
goto: END,
},
},
);
toast("Success", {
description: "Marked thread as resolved.",
duration: 3000,
});
} catch (error) {
console.error("Error marking thread as resolved", error);
toast.error("Error", {
description: "Failed to mark thread as resolved.",
richColors: true,
closeButton: true,
duration: 3000,
});
} finally {
setLoading(false);
}
};
const supportsMultipleMethods =
humanResponse.filter((response) =>
["edit", "approve", "reject"].includes(response.type),
).length > 1;
return {
handleSubmit,
handleResolve,
humanResponse,
selectedSubmitType,
streaming,
streamFinished,
loading,
supportsMultipleMethods,
hasEdited,
hasAddedResponse,
approveAllowed,
setSelectedSubmitType,
setHumanResponse,
setHasAddedResponse,
setHasEdited,
initialHumanInterruptEditValue,
};
}
@@ -0,0 +1,104 @@
import { useEffect, useMemo, useState } from "react";
import { Interrupt } from "@langchain/langgraph-sdk";
import { cn } from "@/lib/utils";
import { useStreamContext } from "@/providers/Stream";
import { HITLRequest } from "./types";
import { StateView } from "./components/state-view";
import { ThreadActionsView } from "./components/thread-actions-view";
interface ThreadViewProps {
interrupt: Interrupt<HITLRequest> | Interrupt<HITLRequest>[];
}
export function ThreadView({ interrupt }: ThreadViewProps) {
const thread = useStreamContext();
const interrupts = useMemo(
() =>
(Array.isArray(interrupt) ? interrupt : [interrupt]).filter(
(item): item is Interrupt<HITLRequest> => !!item,
),
[interrupt],
);
const [activeInterruptIndex, setActiveInterruptIndex] = useState(0);
const [showDescription, setShowDescription] = useState(false);
const [showState, setShowState] = useState(false);
const showSidePanel = showDescription || showState;
useEffect(() => {
setActiveInterruptIndex(0);
}, [interrupts.length]);
const activeInterrupt = interrupts[activeInterruptIndex];
const activeDescription =
activeInterrupt?.value?.action_requests?.[0]?.description ?? "";
const handleShowSidePanel = (
showStateFlag: boolean,
showDescriptionFlag: boolean,
) => {
if (showStateFlag && showDescriptionFlag) {
console.error("Cannot show both state and description");
return;
}
if (showStateFlag) {
setShowDescription(false);
setShowState(true);
} else if (showDescriptionFlag) {
setShowState(false);
setShowDescription(true);
} else {
setShowState(false);
setShowDescription(false);
}
};
if (!activeInterrupt) {
return null;
}
return (
<div className="flex h-full w-full flex-col rounded-2xl bg-gray-50 p-8 lg:flex-row">
{showSidePanel ? (
<StateView
handleShowSidePanel={handleShowSidePanel}
description={activeDescription}
values={thread.values}
view={showState ? "state" : "description"}
/>
) : (
<div className="flex w-full flex-col gap-6">
{interrupts.length > 1 && (
<div className="flex flex-wrap items-center gap-2">
{interrupts.map((it, idx) => {
const title =
it.value?.action_requests?.[0]?.name ??
`Interrupt ${idx + 1}`;
return (
<button
key={it.id ?? idx}
type="button"
onClick={() => setActiveInterruptIndex(idx)}
className={cn(
"rounded-full border px-3 py-1 text-sm transition-colors",
idx === activeInterruptIndex
? "border-primary bg-primary/10 text-primary"
: "hover:border-primary hover:text-primary border-gray-300 bg-white text-gray-600",
)}
>
{title}
</button>
);
})}
</div>
)}
<ThreadActionsView
interrupt={activeInterrupt}
handleShowSidePanel={handleShowSidePanel}
showState={showState}
showDescription={showDescription}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,104 @@
import { BaseMessage } from "@langchain/core/messages";
import { Interrupt, Thread, ThreadStatus } from "@langchain/langgraph-sdk";
export type DecisionType = "approve" | "edit" | "reject";
export interface Action {
name: string;
args: Record<string, unknown>;
}
export interface ActionRequest {
name: string;
args: Record<string, unknown>;
description?: string;
}
export interface ReviewConfig {
action_name: string;
allowed_decisions: DecisionType[];
args_schema?: Record<string, unknown>;
}
export interface HITLRequest {
action_requests: ActionRequest[];
review_configs: ReviewConfig[];
}
export type Decision =
| { type: "approve" }
| { type: "reject"; message?: string }
| { type: "edit"; edited_action: Action };
export type DecisionWithEdits =
| { type: "approve" }
| { type: "reject"; message?: string }
| {
type: "edit";
edited_action: Action;
acceptAllowed?: boolean;
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: Interrupt<HITLRequest>[] | undefined;
}
| {
status: "idle" | "busy" | "error";
interrupts?: never;
}
);
export type ThreadStatusWithAll = ThreadStatus | "all";
export type SubmitType = DecisionType;
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;
}
@@ -0,0 +1,238 @@
import { BaseMessage, isBaseMessage } from "@langchain/core/messages";
import { format } from "date-fns";
import { startCase } from "lodash";
import {
Action,
Decision,
DecisionWithEdits,
HITLRequest,
SubmitType,
} from "./types";
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 as BaseMessage).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(
hitlRequest: HITLRequest,
initialHumanInterruptEditValue: React.MutableRefObject<
Record<string, string>
>,
): {
responses: DecisionWithEdits[];
defaultSubmitType: SubmitType | undefined;
hasApprove: boolean;
} {
const responses: DecisionWithEdits[] = [];
const actionRequest = hitlRequest.action_requests?.[0];
const reviewConfig =
hitlRequest.review_configs?.find(
(config) => config.action_name === actionRequest?.name,
) ?? hitlRequest.review_configs?.[0];
if (!actionRequest || !reviewConfig) {
return { responses: [], defaultSubmitType: undefined, hasApprove: false };
}
const allowedDecisions = reviewConfig.allowed_decisions ?? [];
if (allowedDecisions.includes("edit")) {
Object.entries(actionRequest.args).forEach(([key, value]) => {
const stringValue =
typeof value === "string" || typeof value === "number"
? value.toString()
: JSON.stringify(value, null);
initialHumanInterruptEditValue.current = {
...initialHumanInterruptEditValue.current,
[key]: stringValue,
};
});
const editedAction: Action = {
name: actionRequest.name,
args: { ...actionRequest.args },
};
responses.push({
type: "edit",
edited_action: editedAction,
acceptAllowed: allowedDecisions.includes("approve"),
editsMade: false,
});
}
if (allowedDecisions.includes("approve")) {
responses.push({ type: "approve" });
}
if (allowedDecisions.includes("reject")) {
responses.push({ type: "reject", message: "" });
}
// Determine default submit type. Priority: approve > reject > edit
let defaultSubmitType: SubmitType | undefined;
if (allowedDecisions.includes("approve")) {
defaultSubmitType = "approve";
} else if (allowedDecisions.includes("reject")) {
defaultSubmitType = "reject";
} else if (allowedDecisions.includes("edit")) {
defaultSubmitType = "edit";
}
const hasApprove = allowedDecisions.includes("approve");
return { responses, defaultSubmitType, hasApprove };
}
export function buildDecisionFromState(
responses: DecisionWithEdits[],
selectedSubmitType: SubmitType | undefined,
): { decision?: Decision; error?: string } {
if (!responses.length) {
return { error: "Please enter a response." };
}
const selectedDecision = responses.find(
(response) => response.type === selectedSubmitType,
);
if (!selectedDecision) {
return { error: "No response selected." };
}
if (selectedDecision.type === "approve") {
return { decision: { type: "approve" } };
}
if (selectedDecision.type === "reject") {
const message = selectedDecision.message?.trim();
if (!message) {
return { error: "Please provide a rejection reason." };
}
return { decision: { type: "reject", message } };
}
if (selectedDecision.type === "edit") {
if (selectedDecision.acceptAllowed && !selectedDecision.editsMade) {
return { decision: { type: "approve" } };
}
return {
decision: {
type: "edit",
edited_action: selectedDecision.edited_action,
},
};
}
return { error: "Unsupported response type." };
}
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;
});
}