feat: Add agent inbox as prebuilt gen ui component
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
import { HumanResponseWithEdits, SubmitType } from "../types";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import React from "react";
|
||||
import { haveArgsChanged, prettifyText } from "../utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Undo2 } from "lucide-react";
|
||||
import { MarkdownText } from "../../markdown-text";
|
||||
import { ActionRequest, HumanInterrupt } from "@langchain/langgraph/prebuilt";
|
||||
import { toast } from "sonner";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
function ResetButton({ handleReset }: { handleReset: () => void }) {
|
||||
return (
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
variant="ghost"
|
||||
className="flex items-center justify-center gap-2 text-gray-500 hover:text-red-500"
|
||||
>
|
||||
<Undo2 className="w-4 h-4" />
|
||||
<span>Reset</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ArgsRenderer({ args }: { args: Record<string, any> }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 items-start w-full">
|
||||
{Object.entries(args).map(([k, v]) => {
|
||||
let value = "";
|
||||
if (["string", "number"].includes(typeof v)) {
|
||||
value = v as string;
|
||||
} else {
|
||||
value = JSON.stringify(v, null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`args-${k}`} className="flex flex-col gap-1 items-start">
|
||||
<p className="text-sm leading-[18px] text-gray-600 text-wrap">
|
||||
{prettifyText(k)}:
|
||||
</p>
|
||||
<span className="text-[13px] leading-[18px] text-black bg-zinc-100 rounded-xl p-3 w-full max-w-full">
|
||||
<MarkdownText>{value}</MarkdownText>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InboxItemInputProps {
|
||||
interruptValue: HumanInterrupt;
|
||||
humanResponse: HumanResponseWithEdits[];
|
||||
supportsMultipleMethods: boolean;
|
||||
acceptAllowed: boolean;
|
||||
hasEdited: boolean;
|
||||
hasAddedResponse: boolean;
|
||||
initialValues: Record<string, string>;
|
||||
|
||||
streaming: boolean;
|
||||
streamFinished: boolean;
|
||||
|
||||
setHumanResponse: React.Dispatch<
|
||||
React.SetStateAction<HumanResponseWithEdits[]>
|
||||
>;
|
||||
setSelectedSubmitType: React.Dispatch<
|
||||
React.SetStateAction<SubmitType | undefined>
|
||||
>;
|
||||
setHasAddedResponse: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setHasEdited: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
handleSubmit: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
function ResponseComponent({
|
||||
humanResponse,
|
||||
streaming,
|
||||
showArgsInResponse,
|
||||
interruptValue,
|
||||
onResponseChange,
|
||||
handleSubmit,
|
||||
}: {
|
||||
humanResponse: HumanResponseWithEdits[];
|
||||
streaming: boolean;
|
||||
showArgsInResponse: boolean;
|
||||
interruptValue: HumanInterrupt;
|
||||
onResponseChange: (change: string, response: HumanResponseWithEdits) => void;
|
||||
handleSubmit: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const res = humanResponse.find((r) => r.type === "response");
|
||||
if (!res || typeof res.args !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6 items-start w-full rounded-xl border-[1px] border-gray-300">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p className="font-semibold text-black text-base">
|
||||
Respond to assistant
|
||||
</p>
|
||||
<ResetButton
|
||||
handleReset={() => {
|
||||
onResponseChange("", res);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showArgsInResponse && (
|
||||
<ArgsRenderer args={interruptValue.action_request.args} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-[6px] items-start w-full">
|
||||
<p className="text-sm min-w-fit font-medium">Response</p>
|
||||
<Textarea
|
||||
disabled={streaming}
|
||||
value={res.args}
|
||||
onChange={(e) => onResponseChange(e.target.value, res)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={4}
|
||||
placeholder="Your response here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end w-full gap-2">
|
||||
<Button variant="brand" disabled={streaming} onClick={handleSubmit}>
|
||||
Send Response
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Response = React.memo(ResponseComponent);
|
||||
|
||||
function AcceptComponent({
|
||||
streaming,
|
||||
actionRequestArgs,
|
||||
handleSubmit,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
actionRequestArgs: Record<string, any>;
|
||||
handleSubmit: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-start w-full p-6 rounded-lg border-[1px] border-gray-300">
|
||||
{actionRequestArgs && Object.keys(actionRequestArgs).length > 0 && (
|
||||
<ArgsRenderer args={actionRequestArgs} />
|
||||
)}
|
||||
<Button
|
||||
variant="brand"
|
||||
disabled={streaming}
|
||||
onClick={handleSubmit}
|
||||
className="w-full"
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditAndOrAcceptComponent({
|
||||
humanResponse,
|
||||
streaming,
|
||||
initialValues,
|
||||
onEditChange,
|
||||
handleSubmit,
|
||||
interruptValue,
|
||||
}: {
|
||||
humanResponse: HumanResponseWithEdits[];
|
||||
streaming: boolean;
|
||||
initialValues: Record<string, string>;
|
||||
interruptValue: HumanInterrupt;
|
||||
onEditChange: (
|
||||
text: string | string[],
|
||||
response: HumanResponseWithEdits,
|
||||
key: string | string[],
|
||||
) => void;
|
||||
handleSubmit: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const defaultRows = React.useRef<Record<string, number>>({});
|
||||
const editResponse = humanResponse.find((r) => r.type === "edit");
|
||||
const acceptResponse = humanResponse.find((r) => r.type === "accept");
|
||||
if (
|
||||
!editResponse ||
|
||||
typeof editResponse.args !== "object" ||
|
||||
!editResponse.args
|
||||
) {
|
||||
if (acceptResponse) {
|
||||
return (
|
||||
<AcceptComponent
|
||||
actionRequestArgs={interruptValue.action_request.args}
|
||||
streaming={streaming}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const header = editResponse.acceptAllowed ? "Edit/Accept" : "Edit";
|
||||
let buttonText = "Submit";
|
||||
if (editResponse.acceptAllowed && !editResponse.editsMade) {
|
||||
buttonText = "Accept";
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (
|
||||
!editResponse ||
|
||||
typeof editResponse.args !== "object" ||
|
||||
!editResponse.args ||
|
||||
!editResponse.args.args
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// use initialValues to reset the text areas
|
||||
const keysToReset: string[] = [];
|
||||
const valuesToReset: string[] = [];
|
||||
Object.entries(initialValues).forEach(([k, v]) => {
|
||||
if (k in (editResponse.args as Record<string, any>).args) {
|
||||
const value = ["string", "number"].includes(typeof v)
|
||||
? v
|
||||
: JSON.stringify(v, null);
|
||||
keysToReset.push(k);
|
||||
valuesToReset.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
if (keysToReset.length > 0 && valuesToReset.length > 0) {
|
||||
onEditChange(valuesToReset, editResponse, keysToReset);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-start w-full p-6 rounded-lg border-[1px] border-gray-300">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p className="font-semibold text-black text-base">{header}</p>
|
||||
<ResetButton handleReset={handleReset} />
|
||||
</div>
|
||||
|
||||
{Object.entries(editResponse.args.args).map(([k, v], idx) => {
|
||||
const value = ["string", "number"].includes(typeof v)
|
||||
? v
|
||||
: JSON.stringify(v, null);
|
||||
// Calculate the default number of rows by the total length of the initial value divided by 30
|
||||
// or 8, whichever is greater. Stored in a ref to prevent re-rendering.
|
||||
if (
|
||||
defaultRows.current[k as keyof typeof defaultRows.current] ===
|
||||
undefined
|
||||
) {
|
||||
defaultRows.current[k as keyof typeof defaultRows.current] = !v.length
|
||||
? 3
|
||||
: Math.max(v.length / 30, 7);
|
||||
}
|
||||
const numRows =
|
||||
defaultRows.current[k as keyof typeof defaultRows.current] || 8;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-1 items-start w-full h-full px-[1px]"
|
||||
key={`allow-edit-args--${k}-${idx}`}
|
||||
>
|
||||
<div className="flex flex-col gap-[6px] items-start w-full">
|
||||
<p className="text-sm min-w-fit font-medium">{prettifyText(k)}</p>
|
||||
<Textarea
|
||||
disabled={streaming}
|
||||
className="h-full"
|
||||
value={value}
|
||||
onChange={(e) => onEditChange(e.target.value, editResponse, k)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={numRows}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex items-center justify-end w-full gap-2">
|
||||
<Button variant="brand" disabled={streaming} onClick={handleSubmit}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const EditAndOrAccept = React.memo(EditAndOrAcceptComponent);
|
||||
|
||||
export function InboxItemInput({
|
||||
interruptValue,
|
||||
humanResponse,
|
||||
streaming,
|
||||
streamFinished,
|
||||
supportsMultipleMethods,
|
||||
acceptAllowed,
|
||||
hasEdited,
|
||||
hasAddedResponse,
|
||||
initialValues,
|
||||
setHumanResponse,
|
||||
setSelectedSubmitType,
|
||||
setHasEdited,
|
||||
setHasAddedResponse,
|
||||
handleSubmit,
|
||||
}: InboxItemInputProps) {
|
||||
const isEditAllowed = interruptValue.config.allow_edit;
|
||||
const isResponseAllowed = interruptValue.config.allow_respond;
|
||||
const hasArgs = Object.entries(interruptValue.action_request.args).length > 0;
|
||||
const showArgsInResponse =
|
||||
hasArgs && !isEditAllowed && !acceptAllowed && isResponseAllowed;
|
||||
const showArgsOutsideActionCards =
|
||||
hasArgs && !showArgsInResponse && !isEditAllowed && !acceptAllowed;
|
||||
|
||||
const onEditChange = (
|
||||
change: string | string[],
|
||||
response: HumanResponseWithEdits,
|
||||
key: string | string[],
|
||||
) => {
|
||||
if (
|
||||
(Array.isArray(change) && !Array.isArray(key)) ||
|
||||
(!Array.isArray(change) && Array.isArray(key))
|
||||
) {
|
||||
toast("Error", {
|
||||
description: "Something went wrong",
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let valuesChanged = true;
|
||||
if (typeof response.args === "object") {
|
||||
const updatedArgs = { ...(response.args?.args || {}) };
|
||||
|
||||
if (Array.isArray(change) && Array.isArray(key)) {
|
||||
// Handle array inputs by mapping corresponding values
|
||||
change.forEach((value, index) => {
|
||||
if (index < key.length) {
|
||||
updatedArgs[key[index]] = value;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Handle single value case
|
||||
updatedArgs[key as string] = change as string;
|
||||
}
|
||||
|
||||
const haveValuesChanged = haveArgsChanged(updatedArgs, initialValues);
|
||||
valuesChanged = haveValuesChanged;
|
||||
}
|
||||
|
||||
if (!valuesChanged) {
|
||||
setHasEdited(false);
|
||||
if (acceptAllowed) {
|
||||
setSelectedSubmitType("accept");
|
||||
} else if (hasAddedResponse) {
|
||||
setSelectedSubmitType("response");
|
||||
}
|
||||
} else {
|
||||
setSelectedSubmitType("edit");
|
||||
setHasEdited(true);
|
||||
}
|
||||
|
||||
setHumanResponse((prev) => {
|
||||
if (typeof response.args !== "object" || !response.args) {
|
||||
console.error(
|
||||
"Mismatched response type",
|
||||
!!response.args,
|
||||
typeof response.args,
|
||||
);
|
||||
return prev;
|
||||
}
|
||||
|
||||
const newEdit: HumanResponseWithEdits = {
|
||||
type: response.type,
|
||||
args: {
|
||||
action: response.args.action,
|
||||
args:
|
||||
Array.isArray(change) && Array.isArray(key)
|
||||
? {
|
||||
...response.args.args,
|
||||
...Object.fromEntries(key.map((k, i) => [k, change[i]])),
|
||||
}
|
||||
: {
|
||||
...response.args.args,
|
||||
[key as string]: change as string,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (
|
||||
prev.find(
|
||||
(p) =>
|
||||
p.type === response.type &&
|
||||
typeof p.args === "object" &&
|
||||
p.args?.action === (response.args as ActionRequest).action,
|
||||
)
|
||||
) {
|
||||
return prev.map((p) => {
|
||||
if (
|
||||
p.type === response.type &&
|
||||
typeof p.args === "object" &&
|
||||
p.args?.action === (response.args as ActionRequest).action
|
||||
) {
|
||||
if (p.acceptAllowed) {
|
||||
return {
|
||||
...newEdit,
|
||||
acceptAllowed: true,
|
||||
editsMade: valuesChanged,
|
||||
};
|
||||
}
|
||||
|
||||
return newEdit;
|
||||
}
|
||||
return p;
|
||||
});
|
||||
} else {
|
||||
throw new Error("No matching response found");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onResponseChange = (
|
||||
change: string,
|
||||
response: HumanResponseWithEdits,
|
||||
) => {
|
||||
if (!change) {
|
||||
setHasAddedResponse(false);
|
||||
if (hasEdited) {
|
||||
// The user has deleted their response, so we should set the submit type to
|
||||
// `edit` if they've edited, or `accept` if it's allowed and they have not edited.
|
||||
setSelectedSubmitType("edit");
|
||||
} else if (acceptAllowed) {
|
||||
setSelectedSubmitType("accept");
|
||||
}
|
||||
} else {
|
||||
setSelectedSubmitType("response");
|
||||
setHasAddedResponse(true);
|
||||
}
|
||||
|
||||
setHumanResponse((prev) => {
|
||||
const newResponse: HumanResponseWithEdits = {
|
||||
type: response.type,
|
||||
args: change,
|
||||
};
|
||||
|
||||
if (prev.find((p) => p.type === response.type)) {
|
||||
return prev.map((p) => {
|
||||
if (p.type === response.type) {
|
||||
if (p.acceptAllowed) {
|
||||
return {
|
||||
...newResponse,
|
||||
acceptAllowed: true,
|
||||
editsMade: !!change,
|
||||
};
|
||||
}
|
||||
return newResponse;
|
||||
}
|
||||
return p;
|
||||
});
|
||||
} else {
|
||||
throw new Error("No human response found for string response");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full flex flex-col items-start justify-start gap-2"
|
||||
>
|
||||
{showArgsOutsideActionCards && (
|
||||
<ArgsRenderer args={interruptValue.action_request.args} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<EditAndOrAccept
|
||||
humanResponse={humanResponse}
|
||||
streaming={streaming}
|
||||
initialValues={initialValues}
|
||||
interruptValue={interruptValue}
|
||||
onEditChange={onEditChange}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
{supportsMultipleMethods ? (
|
||||
<div className="flex gap-3 items-center mx-auto mt-3">
|
||||
<Separator className="w-[full]" />
|
||||
<p className="text-sm text-gray-500">Or</p>
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
) : null}
|
||||
<Response
|
||||
humanResponse={humanResponse}
|
||||
streaming={streaming}
|
||||
showArgsInResponse={showArgsInResponse}
|
||||
interruptValue={interruptValue}
|
||||
onResponseChange={onResponseChange}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
{streaming && <p className="text-sm text-gray-600">Running...</p>}
|
||||
{streamFinished && (
|
||||
<p className="text-base text-green-600 font-medium">
|
||||
Successfully finished Graph invocation.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
294
src/components/thread/agent-inbox/components/state-view.tsx
Normal file
294
src/components/thread/agent-inbox/components/state-view.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { ChevronRight, X, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
baseMessageObject,
|
||||
isArrayOfMessages,
|
||||
prettifyText,
|
||||
unknownToPrettyDate,
|
||||
} from "../utils";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BaseMessage } from "@langchain/core/messages";
|
||||
import { ToolCall } from "@langchain/core/messages/tool";
|
||||
import { ToolCallTable } from "./tool-call-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownText } from "../../markdown-text";
|
||||
|
||||
interface StateViewRecursiveProps {
|
||||
value: unknown;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
const messageTypeToLabel = (message: BaseMessage) => {
|
||||
let type = "";
|
||||
if ("type" in message) {
|
||||
type = message.type as string;
|
||||
} else {
|
||||
type = message._getType();
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "human":
|
||||
return "User";
|
||||
case "ai":
|
||||
return "Assistant";
|
||||
case "tool":
|
||||
return "Tool";
|
||||
case "System":
|
||||
return "System";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
function MessagesRenderer({ messages }: { messages: BaseMessage[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
{messages.map((msg, idx) => {
|
||||
const messageTypeLabel = messageTypeToLabel(msg);
|
||||
const content =
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content, null);
|
||||
return (
|
||||
<div
|
||||
key={msg.id ?? `message-${idx}`}
|
||||
className="flex flex-col gap-[2px] ml-2 w-full"
|
||||
>
|
||||
<p className="font-medium text-gray-700">{messageTypeLabel}:</p>
|
||||
{content && <MarkdownText>{content}</MarkdownText>}
|
||||
{"tool_calls" in msg && msg.tool_calls ? (
|
||||
<div className="flex flex-col gap-1 items-start w-full">
|
||||
{(msg.tool_calls as ToolCall[]).map((tc, idx) => (
|
||||
<ToolCallTable
|
||||
key={tc.id ?? `tool-call-${idx}`}
|
||||
toolCall={tc}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateViewRecursive(props: StateViewRecursiveProps) {
|
||||
const date = unknownToPrettyDate(props.value);
|
||||
if (date) {
|
||||
return <p className="font-light text-gray-600">{date}</p>;
|
||||
}
|
||||
|
||||
if (["string", "number"].includes(typeof props.value)) {
|
||||
return <MarkdownText>{props.value as string}</MarkdownText>;
|
||||
}
|
||||
|
||||
if (typeof props.value === "boolean") {
|
||||
return <MarkdownText>{JSON.stringify(props.value)}</MarkdownText>;
|
||||
}
|
||||
|
||||
if (props.value == null) {
|
||||
return <p className="font-light text-gray-600 whitespace-pre-wrap">null</p>;
|
||||
}
|
||||
|
||||
if (Array.isArray(props.value)) {
|
||||
if (props.value.length > 0 && isArrayOfMessages(props.value)) {
|
||||
return <MessagesRenderer messages={props.value} />;
|
||||
}
|
||||
|
||||
const valueArray = props.value as unknown[];
|
||||
return (
|
||||
<div className="flex flex-row gap-1 items-start justify-start w-full">
|
||||
<span className="font-normal text-black">[</span>
|
||||
{valueArray.map((item, idx) => {
|
||||
const itemRenderValue = baseMessageObject(item);
|
||||
return (
|
||||
<div
|
||||
key={`state-view-${idx}`}
|
||||
className="flex flex-row items-start whitespace-pre-wrap w-full"
|
||||
>
|
||||
<StateViewRecursive value={itemRenderValue} />
|
||||
{idx < valueArray?.length - 1 && (
|
||||
<span className="text-black font-normal">, </span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<span className="font-normal text-black">]</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof props.value === "object") {
|
||||
if (Object.keys(props.value).length === 0) {
|
||||
return <p className="font-light text-gray-600">{"{}"}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1 items-start justify-start ml-6 relative w-full">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute left-[-24px] top-0 h-full w-[1px] bg-gray-200" />
|
||||
|
||||
{Object.entries(props.value).map(([key, value], idx) => (
|
||||
<div
|
||||
key={`state-view-object-${key}-${idx}`}
|
||||
className="relative w-full"
|
||||
>
|
||||
{/* Horizontal connector line */}
|
||||
<div className="absolute left-[-20px] top-[10px] h-[1px] w-[18px] bg-gray-200" />
|
||||
<StateViewObject
|
||||
expanded={props.expanded}
|
||||
keyName={key}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function HasContentsEllipsis({ onClick }: { onClick?: () => void }) {
|
||||
return (
|
||||
<span
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"font-mono text-[10px] leading-3 p-[2px] rounded-md",
|
||||
"bg-gray-50 hover:bg-gray-100 text-gray-600 hover:text-gray-800",
|
||||
"transition-colors ease-in-out cursor-pointer",
|
||||
"-translate-y-[2px] inline-block",
|
||||
)}
|
||||
>
|
||||
{"{...}"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface StateViewProps {
|
||||
keyName: string;
|
||||
value: unknown;
|
||||
/**
|
||||
* Whether or not to expand or collapse the view
|
||||
* @default true
|
||||
*/
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
export function StateViewObject(props: StateViewProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.expanded != null) {
|
||||
setExpanded(props.expanded);
|
||||
}
|
||||
}, [props.expanded]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-2 items-start justify-start relative text-sm">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ rotate: expanded ? 90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className="w-5 h-5 flex items-center justify-center hover:bg-gray-100 text-gray-500 hover:text-black rounded-md transition-colors ease-in-out cursor-pointer"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="flex flex-col gap-1 items-start justify-start w-full">
|
||||
<p className="text-black font-normal">
|
||||
{prettifyText(props.keyName)}{" "}
|
||||
{!expanded && (
|
||||
<HasContentsEllipsis onClick={() => setExpanded((prev) => !prev)} />
|
||||
)}
|
||||
</p>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
height: expanded ? "auto" : 0,
|
||||
opacity: expanded ? 1 : 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
style={{ overflow: "hidden" }}
|
||||
className="relative w-full"
|
||||
>
|
||||
<StateViewRecursive expanded={props.expanded} value={props.value} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StateViewComponentProps {
|
||||
values: Record<string, any>;
|
||||
description: string | undefined;
|
||||
handleShowSidePanel: (showState: boolean, showDescription: boolean) => void;
|
||||
view: "description" | "state";
|
||||
}
|
||||
|
||||
export function StateView({
|
||||
handleShowSidePanel,
|
||||
view,
|
||||
values,
|
||||
description,
|
||||
}: StateViewComponentProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!values) {
|
||||
return <div>No state found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto pl-6 border-t-[1px] lg:border-t-[0px] lg:border-l-[1px] border-gray-100 flex flex-row gap-0 w-full">
|
||||
{view === "description" && (
|
||||
<div className="pt-6 pb-2">
|
||||
<MarkdownText>
|
||||
{description ?? "No description provided"}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
)}
|
||||
{view === "state" && (
|
||||
<div className="flex flex-col items-start justify-start gap-1 pt-6 pb-2">
|
||||
{Object.entries(values).map(([k, v], idx) => (
|
||||
<StateViewObject
|
||||
expanded={expanded}
|
||||
key={`state-view-${k}-${idx}`}
|
||||
keyName={k}
|
||||
value={v}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 items-start justify-end pt-6 pr-6">
|
||||
{view === "state" && (
|
||||
<Button
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
variant="ghost"
|
||||
className="text-gray-600"
|
||||
size="sm"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronsUpDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronsDownUp className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => handleShowSidePanel(false, false)}
|
||||
variant="ghost"
|
||||
className="text-gray-600"
|
||||
size="sm"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThreadIdCopyable } from "./thread-id";
|
||||
import { InboxItemInput } from "./inbox-item-input";
|
||||
import useInterruptedActions from "../hooks/use-interrupted-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { StringParam, useQueryParam } from "use-query-params";
|
||||
import { constructOpenInStudioURL } from "../utils";
|
||||
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
|
||||
|
||||
interface ThreadActionsViewProps {
|
||||
interrupt: HumanInterrupt;
|
||||
handleShowSidePanel: (showState: boolean, showDescription: boolean) => void;
|
||||
showState: boolean;
|
||||
showDescription: boolean;
|
||||
}
|
||||
|
||||
function ButtonGroup({
|
||||
handleShowState,
|
||||
handleShowDescription,
|
||||
showingState,
|
||||
showingDescription,
|
||||
}: {
|
||||
handleShowState: () => void;
|
||||
handleShowDescription: () => void;
|
||||
showingState: boolean;
|
||||
showingDescription: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-row gap-0 items-center justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"rounded-l-md rounded-r-none border-r-[0px]",
|
||||
showingState ? "text-black" : "bg-white",
|
||||
)}
|
||||
size="sm"
|
||||
onClick={handleShowState}
|
||||
>
|
||||
State
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"rounded-l-none rounded-r-md border-l-[0px]",
|
||||
showingDescription ? "text-black" : "bg-white",
|
||||
)}
|
||||
size="sm"
|
||||
onClick={handleShowDescription}
|
||||
>
|
||||
Description
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadActionsView({
|
||||
interrupt,
|
||||
handleShowSidePanel,
|
||||
showDescription,
|
||||
showState,
|
||||
}: ThreadActionsViewProps) {
|
||||
const [threadId] = useQueryParam("threadId", StringParam);
|
||||
const {
|
||||
acceptAllowed,
|
||||
hasEdited,
|
||||
hasAddedResponse,
|
||||
streaming,
|
||||
supportsMultipleMethods,
|
||||
streamFinished,
|
||||
loading,
|
||||
handleSubmit,
|
||||
handleIgnore,
|
||||
handleResolve,
|
||||
setSelectedSubmitType,
|
||||
setHasAddedResponse,
|
||||
setHasEdited,
|
||||
humanResponse,
|
||||
setHumanResponse,
|
||||
initialHumanInterruptEditValue,
|
||||
} = useInterruptedActions({
|
||||
interrupt,
|
||||
});
|
||||
const [apiUrl] = useQueryParam("apiUrl", StringParam);
|
||||
|
||||
const handleOpenInStudio = () => {
|
||||
if (!apiUrl) {
|
||||
toast("Error", {
|
||||
description: "Please set the LangGraph deployment URL in settings.",
|
||||
duration: 5000,
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const studioUrl = constructOpenInStudioURL(apiUrl, threadId ?? undefined);
|
||||
window.open(studioUrl, "_blank");
|
||||
};
|
||||
|
||||
const threadTitle = interrupt.action_request.action || "Unknown";
|
||||
const actionsDisabled = loading || streaming;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-full w-full p-12 gap-9">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between w-full gap-3">
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
<p className="text-2xl tracking-tighter text-pretty">{threadTitle}</p>
|
||||
{threadId && <ThreadIdCopyable threadId={threadId} />}
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 items-center justify-start">
|
||||
{apiUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 bg-white"
|
||||
onClick={handleOpenInStudio}
|
||||
>
|
||||
Studio
|
||||
</Button>
|
||||
)}
|
||||
<ButtonGroup
|
||||
handleShowState={() => handleShowSidePanel(true, false)}
|
||||
handleShowDescription={() => handleShowSidePanel(false, true)}
|
||||
showingState={showState}
|
||||
showingDescription={showDescription}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2 items-center justify-start w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-gray-800 border-gray-500 font-normal bg-white"
|
||||
onClick={handleResolve}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
Mark as Resolved
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-gray-800 border-gray-500 font-normal bg-white"
|
||||
onClick={handleIgnore}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
Ignore
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<InboxItemInput
|
||||
acceptAllowed={acceptAllowed}
|
||||
hasEdited={hasEdited}
|
||||
hasAddedResponse={hasAddedResponse}
|
||||
interruptValue={interrupt}
|
||||
humanResponse={humanResponse}
|
||||
initialValues={initialHumanInterruptEditValue.current}
|
||||
setHumanResponse={setHumanResponse}
|
||||
streaming={streaming}
|
||||
streamFinished={streamFinished}
|
||||
supportsMultipleMethods={supportsMultipleMethods}
|
||||
setSelectedSubmitType={setSelectedSubmitType}
|
||||
setHasAddedResponse={setHasAddedResponse}
|
||||
setHasEdited={setHasEdited}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/components/thread/agent-inbox/components/thread-id.tsx
Normal file
81
src/components/thread/agent-inbox/components/thread-id.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Copy, CopyCheck } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import React from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { TooltipIconButton } from "../../tooltip-icon-button";
|
||||
|
||||
export function ThreadIdTooltip({ threadId }: { threadId: string }) {
|
||||
const firstThreeChars = threadId.slice(0, 3);
|
||||
const lastThreeChars = threadId.slice(-3);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<p className="font-mono tracking-tighter text-[10px] leading-[12px] px-1 py-[2px] bg-gray-100 rounded-md">
|
||||
{firstThreeChars}...{lastThreeChars}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<ThreadIdCopyable threadId={threadId} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadIdCopyable({
|
||||
threadId,
|
||||
showUUID = false,
|
||||
}: {
|
||||
threadId: string;
|
||||
showUUID?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(threadId);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipIconButton
|
||||
onClick={(e) => handleCopy(e)}
|
||||
variant="ghost"
|
||||
tooltip="Copy thread ID"
|
||||
className="flex flex-grow-0 gap-1 items-center p-1 rounded-md border-[1px] cursor-pointer hover:bg-gray-50/90 border-gray-200 w-fit"
|
||||
>
|
||||
<p className="font-mono text-xs">{showUUID ? threadId : "ID"}</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<CopyCheck className="text-green-500 max-w-3 w-3 max-h-3 h-3" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Copy className="text-gray-500 max-w-3 w-3 max-h-3 h-3" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TooltipIconButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ToolCall } from "@langchain/core/messages/tool";
|
||||
import { unknownToPrettyDate } from "../utils";
|
||||
|
||||
export function ToolCallTable({ toolCall }: { toolCall: ToolCall }) {
|
||||
return (
|
||||
<div className="min-w-[300px] max-w-full border rounded-lg overflow-hidden">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left px-2 py-0 bg-gray-100 text-sm" colSpan={2}>
|
||||
{toolCall.name}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(toolCall.args).map(([key, value]) => {
|
||||
let valueStr = "";
|
||||
if (["string", "number"].includes(typeof value)) {
|
||||
valueStr = value.toString();
|
||||
}
|
||||
|
||||
const date = unknownToPrettyDate(value);
|
||||
if (date) {
|
||||
valueStr = date;
|
||||
}
|
||||
|
||||
try {
|
||||
valueStr = valueStr || JSON.stringify(value, null);
|
||||
} catch (_) {
|
||||
// failed to stringify, just assign an empty string
|
||||
valueStr = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={key} className="border-t">
|
||||
<td className="px-2 py-1 font-medium w-1/3 text-xs">{key}</td>
|
||||
<td className="px-2 py-1 font-mono text-xs">{valueStr}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { HumanResponseWithEdits, SubmitType } from "../types";
|
||||
import {
|
||||
KeyboardEvent,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
MutableRefObject,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { createDefaultHumanResponse } from "../utils";
|
||||
import { toast } from "sonner";
|
||||
import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt";
|
||||
import { useThreads } from "@/providers/Thread";
|
||||
import { StringParam, useQueryParam } from "use-query-params";
|
||||
|
||||
interface UseInterruptedActionsInput {
|
||||
interrupt: HumanInterrupt;
|
||||
}
|
||||
|
||||
interface UseInterruptedActionsValue {
|
||||
// Actions
|
||||
handleSubmit: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
|
||||
) => Promise<void>;
|
||||
handleIgnore: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
||||
) => Promise<void>;
|
||||
handleResolve: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
||||
) => Promise<void>;
|
||||
|
||||
// State values
|
||||
streaming: boolean;
|
||||
streamFinished: boolean;
|
||||
loading: boolean;
|
||||
supportsMultipleMethods: boolean;
|
||||
hasEdited: boolean;
|
||||
hasAddedResponse: boolean;
|
||||
acceptAllowed: boolean;
|
||||
humanResponse: HumanResponseWithEdits[];
|
||||
|
||||
// State setters
|
||||
setSelectedSubmitType: Dispatch<SetStateAction<SubmitType | undefined>>;
|
||||
setHumanResponse: Dispatch<SetStateAction<HumanResponseWithEdits[]>>;
|
||||
setHasAddedResponse: Dispatch<SetStateAction<boolean>>;
|
||||
setHasEdited: Dispatch<SetStateAction<boolean>>;
|
||||
|
||||
// Refs
|
||||
initialHumanInterruptEditValue: MutableRefObject<Record<string, string>>;
|
||||
}
|
||||
|
||||
export default function useInterruptedActions({
|
||||
interrupt,
|
||||
}: UseInterruptedActionsInput): UseInterruptedActionsValue {
|
||||
const { resumeRun, ignoreRun } = useThreads();
|
||||
const [threadId] = useQueryParam("threadId", StringParam);
|
||||
|
||||
const [humanResponse, setHumanResponse] = useState<HumanResponseWithEdits[]>(
|
||||
[],
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [streamFinished, setStreamFinished] = useState(false);
|
||||
const initialHumanInterruptEditValue = useRef<Record<string, string>>({});
|
||||
const [selectedSubmitType, setSelectedSubmitType] = useState<SubmitType>();
|
||||
// Whether or not the user has edited any fields which allow editing.
|
||||
const [hasEdited, setHasEdited] = useState(false);
|
||||
// Whether or not the user has added a response.
|
||||
const [hasAddedResponse, setHasAddedResponse] = useState(false);
|
||||
const [acceptAllowed, setAcceptAllowed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const { responses, defaultSubmitType, hasAccept } =
|
||||
createDefaultHumanResponse(interrupt, initialHumanInterruptEditValue);
|
||||
setSelectedSubmitType(defaultSubmitType);
|
||||
setHumanResponse(responses);
|
||||
setAcceptAllowed(hasAccept);
|
||||
} catch (e) {
|
||||
console.error("Error formatting and setting human response state", e);
|
||||
}
|
||||
}, [interrupt]);
|
||||
|
||||
const handleSubmit = async (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
if (!humanResponse) {
|
||||
toast("Error", {
|
||||
description: "Please enter a response.",
|
||||
duration: 5000,
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!threadId) {
|
||||
toast("Error", {
|
||||
description: "Please select a thread.",
|
||||
duration: 5000,
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let errorOccurred = false;
|
||||
initialHumanInterruptEditValue.current = {};
|
||||
|
||||
if (
|
||||
humanResponse.some((r) => ["response", "edit", "accept"].includes(r.type))
|
||||
) {
|
||||
setStreamFinished(false);
|
||||
|
||||
try {
|
||||
const humanResponseInput: HumanResponse[] = humanResponse.flatMap(
|
||||
(r) => {
|
||||
if (r.type === "edit") {
|
||||
if (r.acceptAllowed && !r.editsMade) {
|
||||
return {
|
||||
type: "accept",
|
||||
args: r.args,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "edit",
|
||||
args: r.args,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (r.type === "response" && !r.args) {
|
||||
// If response was allowed but no response was given, do not include in the response
|
||||
return [];
|
||||
}
|
||||
return {
|
||||
type: r.type,
|
||||
args: r.args,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const input = humanResponseInput.find(
|
||||
(r) => r.type === selectedSubmitType,
|
||||
);
|
||||
if (!input) {
|
||||
toast("Error", {
|
||||
description: "No response found.",
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setStreaming(true);
|
||||
const response = resumeRun(threadId, [input], {
|
||||
stream: true,
|
||||
});
|
||||
if (!response) {
|
||||
// This will only be undefined if the graph ID is not found
|
||||
// in this case, the method will trigger a toast for us.
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Success", {
|
||||
description: "Response submitted successfully.",
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
if (!errorOccurred) {
|
||||
setStreamFinished(true);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("Error sending human response", e);
|
||||
|
||||
if ("message" in e && e.message.includes("Invalid assistant ID")) {
|
||||
toast("Error: Invalid assistant ID", {
|
||||
description:
|
||||
"The provided assistant ID was not found in this graph. Please update the assistant ID in the settings and try again.",
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
toast("Error", {
|
||||
description: "Failed to submit response.",
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
errorOccurred = true;
|
||||
setStreaming(false);
|
||||
setStreamFinished(false);
|
||||
}
|
||||
|
||||
if (!errorOccurred) {
|
||||
setStreaming(false);
|
||||
setStreamFinished(false);
|
||||
}
|
||||
} else {
|
||||
setLoading(true);
|
||||
await resumeRun(threadId, humanResponse);
|
||||
|
||||
toast("Success", {
|
||||
description: "Response submitted successfully.",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleIgnore = async (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
if (!threadId) {
|
||||
toast("Error", {
|
||||
description: "Please select a thread.",
|
||||
duration: 5000,
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ignoreResponse = humanResponse.find((r) => r.type === "ignore");
|
||||
if (!ignoreResponse) {
|
||||
toast("Error", {
|
||||
description: "The selected thread does not support ignoring.",
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
initialHumanInterruptEditValue.current = {};
|
||||
|
||||
await resumeRun(threadId, [ignoreResponse]);
|
||||
|
||||
setLoading(false);
|
||||
toast("Successfully ignored thread", {
|
||||
duration: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleResolve = async (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
if (!threadId) {
|
||||
toast("Error", {
|
||||
description: "Please select a thread.",
|
||||
duration: 5000,
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
initialHumanInterruptEditValue.current = {};
|
||||
|
||||
await ignoreRun(threadId);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const supportsMultipleMethods =
|
||||
humanResponse.filter(
|
||||
(r) => r.type === "edit" || r.type === "accept" || r.type === "response",
|
||||
).length > 1;
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
handleIgnore,
|
||||
handleResolve,
|
||||
humanResponse,
|
||||
streaming,
|
||||
streamFinished,
|
||||
loading,
|
||||
supportsMultipleMethods,
|
||||
hasEdited,
|
||||
hasAddedResponse,
|
||||
acceptAllowed,
|
||||
setSelectedSubmitType,
|
||||
setHumanResponse,
|
||||
setHasAddedResponse,
|
||||
setHasEdited,
|
||||
initialHumanInterruptEditValue,
|
||||
};
|
||||
}
|
||||
68
src/components/thread/agent-inbox/index.tsx
Normal file
68
src/components/thread/agent-inbox/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { StateView } from "./components/state-view";
|
||||
import { ThreadActionsView } from "./components/thread-actions-view";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
|
||||
import { useStreamContext } from "@/providers/Stream";
|
||||
|
||||
interface ThreadViewProps {
|
||||
interrupt: HumanInterrupt;
|
||||
}
|
||||
|
||||
export function ThreadView({ interrupt }: ThreadViewProps) {
|
||||
const thread = useStreamContext();
|
||||
const [showDescription, setShowDescription] = useState(true);
|
||||
const [showState, setShowState] = useState(false);
|
||||
const showSidePanel = showDescription || showState;
|
||||
|
||||
const handleShowSidePanel = (
|
||||
showState: boolean,
|
||||
showDescription: boolean,
|
||||
) => {
|
||||
if (showState && showDescription) {
|
||||
console.error("Cannot show both state and description");
|
||||
return;
|
||||
}
|
||||
if (showState) {
|
||||
setShowDescription(false);
|
||||
setShowState(true);
|
||||
} else if (showDescription) {
|
||||
setShowState(false);
|
||||
setShowDescription(true);
|
||||
} else {
|
||||
setShowState(false);
|
||||
setShowDescription(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row w-full h-[750px] border-[1px] border-slate-200 rounded-2xl pb-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex overflow-y-auto",
|
||||
showSidePanel ? "lg:min-w-1/2 w-full" : "w-full",
|
||||
)}
|
||||
>
|
||||
<ThreadActionsView
|
||||
interrupt={interrupt}
|
||||
handleShowSidePanel={handleShowSidePanel}
|
||||
showState={showState}
|
||||
showDescription={showDescription}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
showSidePanel ? "flex" : "hidden",
|
||||
"overflow-y-auto lg:max-w-1/2 w-full",
|
||||
)}
|
||||
>
|
||||
<StateView
|
||||
handleShowSidePanel={handleShowSidePanel}
|
||||
description={interrupt.description}
|
||||
values={thread.values}
|
||||
view={showState ? "state" : "description"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/components/thread/agent-inbox/types.ts
Normal file
72
src/components/thread/agent-inbox/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { BaseMessage } from "@langchain/core/messages";
|
||||
import { Thread, ThreadStatus } from "@langchain/langgraph-sdk";
|
||||
import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt";
|
||||
|
||||
export type HumanResponseWithEdits = HumanResponse &
|
||||
(
|
||||
| { acceptAllowed?: false; editsMade?: never }
|
||||
| { acceptAllowed?: true; editsMade?: boolean }
|
||||
);
|
||||
|
||||
export type Email = {
|
||||
id: string;
|
||||
thread_id: string;
|
||||
from_email: string;
|
||||
to_email: string;
|
||||
subject: string;
|
||||
page_content: string;
|
||||
send_time: string | undefined;
|
||||
read?: boolean;
|
||||
status?: "in-queue" | "processing" | "hitl" | "done";
|
||||
};
|
||||
|
||||
export interface ThreadValues {
|
||||
email: Email;
|
||||
messages: BaseMessage[];
|
||||
triage: {
|
||||
logic: string;
|
||||
response: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ThreadData<
|
||||
ThreadValues extends Record<string, any> = Record<string, any>,
|
||||
> = {
|
||||
thread: Thread<ThreadValues>;
|
||||
} & (
|
||||
| {
|
||||
status: "interrupted";
|
||||
interrupts: HumanInterrupt[] | undefined;
|
||||
}
|
||||
| {
|
||||
status: "idle" | "busy" | "error";
|
||||
interrupts?: never;
|
||||
}
|
||||
);
|
||||
|
||||
export type ThreadStatusWithAll = ThreadStatus | "all";
|
||||
|
||||
export type SubmitType = "accept" | "response" | "edit";
|
||||
|
||||
export interface AgentInbox {
|
||||
/**
|
||||
* A unique identifier for the inbox.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The ID of the graph.
|
||||
*/
|
||||
graphId: string;
|
||||
/**
|
||||
* The URL of the deployment. Either a localhost URL, or a deployment URL.
|
||||
*/
|
||||
deploymentUrl: string;
|
||||
/**
|
||||
* Optional name for the inbox, used in the UI to label the inbox.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* Whether or not the inbox is selected.
|
||||
*/
|
||||
selected: boolean;
|
||||
}
|
||||
223
src/components/thread/agent-inbox/utils.ts
Normal file
223
src/components/thread/agent-inbox/utils.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { BaseMessage, isBaseMessage } from "@langchain/core/messages";
|
||||
import { format } from "date-fns";
|
||||
import { startCase } from "lodash";
|
||||
import { HumanResponseWithEdits, SubmitType } from "./types";
|
||||
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
|
||||
|
||||
export function prettifyText(action: string) {
|
||||
return startCase(action.replace(/_/g, " "));
|
||||
}
|
||||
|
||||
export function isArrayOfMessages(
|
||||
value: Record<string, any>[],
|
||||
): value is BaseMessage[] {
|
||||
if (
|
||||
value.every(isBaseMessage) ||
|
||||
(Array.isArray(value) &&
|
||||
value.every(
|
||||
(v) =>
|
||||
typeof v === "object" &&
|
||||
"id" in v &&
|
||||
"type" in v &&
|
||||
"content" in v &&
|
||||
"additional_kwargs" in v,
|
||||
))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function baseMessageObject(item: unknown): string {
|
||||
if (isBaseMessage(item)) {
|
||||
const contentText =
|
||||
typeof item.content === "string"
|
||||
? item.content
|
||||
: JSON.stringify(item.content, null);
|
||||
let toolCallText = "";
|
||||
if ("tool_calls" in item) {
|
||||
toolCallText = JSON.stringify(item.tool_calls, null);
|
||||
}
|
||||
if ("type" in item) {
|
||||
return `${item.type}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
|
||||
} else if ("_getType" in item) {
|
||||
return `${item._getType()}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
|
||||
}
|
||||
} else if (
|
||||
typeof item === "object" &&
|
||||
item &&
|
||||
"type" in item &&
|
||||
"content" in item
|
||||
) {
|
||||
const contentText =
|
||||
typeof item.content === "string"
|
||||
? item.content
|
||||
: JSON.stringify(item.content, null);
|
||||
let toolCallText = "";
|
||||
if ("tool_calls" in item) {
|
||||
toolCallText = JSON.stringify(item.tool_calls, null);
|
||||
}
|
||||
return `${item.type}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
|
||||
}
|
||||
|
||||
if (typeof item === "object") {
|
||||
return JSON.stringify(item, null);
|
||||
} else {
|
||||
return item as string;
|
||||
}
|
||||
}
|
||||
|
||||
export function unknownToPrettyDate(input: unknown): string | undefined {
|
||||
try {
|
||||
if (
|
||||
Object.prototype.toString.call(input) === "[object Date]" ||
|
||||
new Date(input as string)
|
||||
) {
|
||||
return format(new Date(input as string), "MM/dd/yyyy hh:mm a");
|
||||
}
|
||||
} catch (_) {
|
||||
// failed to parse date. no-op
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createDefaultHumanResponse(
|
||||
interrupt: HumanInterrupt,
|
||||
initialHumanInterruptEditValue: React.MutableRefObject<
|
||||
Record<string, string>
|
||||
>,
|
||||
): {
|
||||
responses: HumanResponseWithEdits[];
|
||||
defaultSubmitType: SubmitType | undefined;
|
||||
hasAccept: boolean;
|
||||
} {
|
||||
const responses: HumanResponseWithEdits[] = [];
|
||||
if (interrupt.config.allow_edit) {
|
||||
if (interrupt.config.allow_accept) {
|
||||
Object.entries(interrupt.action_request.args).forEach(([k, v]) => {
|
||||
let stringValue = "";
|
||||
if (typeof v === "string") {
|
||||
stringValue = v;
|
||||
} else {
|
||||
stringValue = JSON.stringify(v, null);
|
||||
}
|
||||
|
||||
if (
|
||||
!initialHumanInterruptEditValue.current ||
|
||||
!(k in initialHumanInterruptEditValue.current)
|
||||
) {
|
||||
initialHumanInterruptEditValue.current = {
|
||||
...initialHumanInterruptEditValue.current,
|
||||
[k]: stringValue,
|
||||
};
|
||||
} else if (
|
||||
k in initialHumanInterruptEditValue.current &&
|
||||
initialHumanInterruptEditValue.current[k] !== stringValue
|
||||
) {
|
||||
console.error(
|
||||
"KEY AND VALUE FOUND IN initialHumanInterruptEditValue.current THAT DOES NOT MATCH THE ACTION REQUEST",
|
||||
{
|
||||
key: k,
|
||||
value: stringValue,
|
||||
expectedValue: initialHumanInterruptEditValue.current[k],
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
responses.push({
|
||||
type: "edit",
|
||||
args: interrupt.action_request,
|
||||
acceptAllowed: true,
|
||||
editsMade: false,
|
||||
});
|
||||
} else {
|
||||
responses.push({
|
||||
type: "edit",
|
||||
args: interrupt.action_request,
|
||||
acceptAllowed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (interrupt.config.allow_respond) {
|
||||
responses.push({
|
||||
type: "response",
|
||||
args: "",
|
||||
});
|
||||
}
|
||||
|
||||
if (interrupt.config.allow_ignore) {
|
||||
responses.push({
|
||||
type: "ignore",
|
||||
args: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Set the submit type.
|
||||
// Priority: accept > response > edit
|
||||
const acceptAllowedConfig = interrupt.config.allow_accept;
|
||||
const ignoreAllowedConfig = interrupt.config.allow_ignore;
|
||||
|
||||
const hasResponse = responses.find((r) => r.type === "response");
|
||||
const hasAccept =
|
||||
responses.find((r) => r.acceptAllowed) || acceptAllowedConfig;
|
||||
const hasEdit = responses.find((r) => r.type === "edit");
|
||||
|
||||
let defaultSubmitType: SubmitType | undefined;
|
||||
if (hasAccept) {
|
||||
defaultSubmitType = "accept";
|
||||
} else if (hasResponse) {
|
||||
defaultSubmitType = "response";
|
||||
} else if (hasEdit) {
|
||||
defaultSubmitType = "edit";
|
||||
}
|
||||
|
||||
if (acceptAllowedConfig && !responses.find((r) => r.type === "accept")) {
|
||||
responses.push({
|
||||
type: "accept",
|
||||
args: null,
|
||||
});
|
||||
}
|
||||
if (ignoreAllowedConfig && !responses.find((r) => r.type === "ignore")) {
|
||||
responses.push({
|
||||
type: "ignore",
|
||||
args: null,
|
||||
});
|
||||
}
|
||||
|
||||
return { responses, defaultSubmitType, hasAccept: !!hasAccept };
|
||||
}
|
||||
|
||||
export function constructOpenInStudioURL(
|
||||
deploymentUrl: string,
|
||||
threadId?: string,
|
||||
) {
|
||||
const smithStudioURL = new URL("https://smith.langchain.com/studio/thread");
|
||||
// trim the trailing slash from deploymentUrl
|
||||
const trimmedDeploymentUrl = deploymentUrl.replace(/\/$/, "");
|
||||
|
||||
if (threadId) {
|
||||
smithStudioURL.pathname += `/${threadId}`;
|
||||
}
|
||||
|
||||
smithStudioURL.searchParams.append("baseUrl", trimmedDeploymentUrl);
|
||||
|
||||
return smithStudioURL.toString();
|
||||
}
|
||||
|
||||
export function haveArgsChanged(
|
||||
args: unknown,
|
||||
initialValues: Record<string, string>,
|
||||
): boolean {
|
||||
if (typeof args !== "object" || !args) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentValues = args as Record<string, string>;
|
||||
|
||||
return Object.entries(currentValues).some(([key, value]) => {
|
||||
const valueString = ["string", "number"].includes(typeof value)
|
||||
? value.toString()
|
||||
: JSON.stringify(value, null);
|
||||
return initialValues[key] !== valueString;
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { cn } from "@/lib/utils";
|
||||
import { ToolCalls, ToolResult } from "./tool-calls";
|
||||
import { MessageContentComplex } from "@langchain/core/messages";
|
||||
import { Fragment } from "react/jsx-runtime";
|
||||
import { isAgentInboxInterrupt } from "@/lib/is-hitl";
|
||||
import { ThreadView } from "../agent-inbox";
|
||||
|
||||
function CustomComponent({
|
||||
message,
|
||||
@@ -79,6 +81,7 @@ export function AssistantMessage({
|
||||
|
||||
const thread = useStreamContext();
|
||||
const meta = thread.getMessagesMetadata(message);
|
||||
const interrupt = thread.interrupt;
|
||||
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
|
||||
const anthropicStreamedToolCalls = Array.isArray(message.content)
|
||||
? parseAnthropicStreamedToolCalls(message.content)
|
||||
@@ -115,6 +118,9 @@ export function AssistantMessage({
|
||||
)) ||
|
||||
(hasToolCalls && <ToolCalls toolCalls={message.tool_calls} />)}
|
||||
<CustomComponent message={message} thread={thread} />
|
||||
{isAgentInboxInterrupt(interrupt?.value) && (
|
||||
<ThreadView interrupt={interrupt.value[0]} />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 items-center mr-auto transition-opacity",
|
||||
|
||||
@@ -19,6 +19,7 @@ const buttonVariants = cva(
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
brand: "bg-[#2F6868] hover:bg-[#2F6868]/90 border-[#2F6868] text-white",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
|
||||
26
src/components/ui/separator.tsx
Normal file
26
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
17
src/lib/is-hitl.ts
Normal file
17
src/lib/is-hitl.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
|
||||
|
||||
export function isAgentInboxInterrupt(
|
||||
value: unknown,
|
||||
): value is HumanInterrupt[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
"action_request" in value[0] &&
|
||||
typeof value[0].action_request === "object" &&
|
||||
"config" in value[0] &&
|
||||
typeof value[0].config === "object" &&
|
||||
"allow_respond" in value[0].config &&
|
||||
"allow_accept" in value[0].config &&
|
||||
"allow_edit" in value[0].config &&
|
||||
"allow_ignore" in value[0].config
|
||||
);
|
||||
}
|
||||
@@ -68,6 +68,8 @@ const StreamSession = ({
|
||||
},
|
||||
});
|
||||
|
||||
console.log("streamValue.interrupt", streamValue.interrupt);
|
||||
|
||||
return (
|
||||
<StreamContext.Provider value={streamValue}>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { validate } from "uuid";
|
||||
import { getApiKey } from "@/lib/api-key";
|
||||
import { Client, Thread } from "@langchain/langgraph-sdk";
|
||||
import { Client, Run, Thread } from "@langchain/langgraph-sdk";
|
||||
import { useQueryParam, StringParam } from "use-query-params";
|
||||
import {
|
||||
createContext,
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from "react";
|
||||
import { END } from "@langchain/langgraph/web";
|
||||
import { HumanResponse } from "@langchain/langgraph/prebuilt";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ThreadContextType {
|
||||
getThreads: () => Promise<Thread[]>;
|
||||
@@ -18,6 +21,21 @@ interface ThreadContextType {
|
||||
setThreads: Dispatch<SetStateAction<Thread[]>>;
|
||||
threadsLoading: boolean;
|
||||
setThreadsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
resumeRun: <TStream extends boolean = false>(
|
||||
threadId: string,
|
||||
response: HumanResponse[],
|
||||
options?: {
|
||||
stream?: TStream;
|
||||
},
|
||||
) => TStream extends true
|
||||
?
|
||||
| AsyncGenerator<{
|
||||
event: Record<string, any>;
|
||||
data: any;
|
||||
}>
|
||||
| undefined
|
||||
: Promise<Run> | undefined;
|
||||
ignoreRun: (threadId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const ThreadContext = createContext<ThreadContextType | undefined>(undefined);
|
||||
@@ -47,7 +65,6 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const getThreads = useCallback(async (): Promise<Thread[]> => {
|
||||
if (!apiUrl || !assistantId) return [];
|
||||
|
||||
const client = createClient(apiUrl, getApiKey() ?? undefined);
|
||||
|
||||
const threads = await client.threads.search({
|
||||
@@ -60,12 +77,76 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||
return threads;
|
||||
}, [apiUrl, assistantId]);
|
||||
|
||||
const resumeRun = <TStream extends boolean = false>(
|
||||
threadId: string,
|
||||
response: HumanResponse[],
|
||||
options?: {
|
||||
stream?: TStream;
|
||||
},
|
||||
): TStream extends true
|
||||
?
|
||||
| AsyncGenerator<{
|
||||
event: Record<string, any>;
|
||||
data: any;
|
||||
}>
|
||||
| undefined
|
||||
: Promise<Run> | undefined => {
|
||||
if (!apiUrl || !assistantId) return undefined;
|
||||
const client = createClient(apiUrl, getApiKey() ?? undefined);
|
||||
|
||||
try {
|
||||
if (options?.stream) {
|
||||
return client.runs.stream(threadId, assistantId, {
|
||||
command: {
|
||||
resume: response,
|
||||
},
|
||||
streamMode: "events",
|
||||
}) as any; // Type assertion needed due to conditional return type
|
||||
}
|
||||
return client.runs.create(threadId, assistantId, {
|
||||
command: {
|
||||
resume: response,
|
||||
},
|
||||
}) as any; // Type assertion needed due to conditional return type
|
||||
} catch (e: any) {
|
||||
console.error("Error sending human response", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const ignoreRun = async (threadId: string) => {
|
||||
if (!apiUrl || !assistantId) return;
|
||||
const client = createClient(apiUrl, getApiKey() ?? undefined);
|
||||
|
||||
try {
|
||||
await client.threads.updateState(threadId, {
|
||||
values: null,
|
||||
asNode: END,
|
||||
});
|
||||
|
||||
toast("Success", {
|
||||
description: "Ignored thread",
|
||||
duration: 3000,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error ignoring thread", e);
|
||||
toast("Error", {
|
||||
description: "Failed to ignore thread",
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
getThreads,
|
||||
threads,
|
||||
setThreads,
|
||||
threadsLoading,
|
||||
setThreadsLoading,
|
||||
resumeRun,
|
||||
ignoreRun,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user