Add agent chat frontend base
This commit is contained in:
@@ -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">, </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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user