feat: Add agent inbox as prebuilt gen ui component
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
"@radix-ui/react-avatar": "^1.1.3",
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@tailwindcss/postcss": "^4.0.9",
|
"@tailwindcss/postcss": "^4.0.9",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"esbuild-plugin-tailwindcss": "^2.0.1",
|
"esbuild-plugin-tailwindcss": "^2.0.1",
|
||||||
"framer-motion": "^12.4.9",
|
"framer-motion": "^12.4.9",
|
||||||
"katex": "^0.16.21",
|
"katex": "^0.16.21",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.476.0",
|
"lucide-react": "^0.476.0",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"prettier": "^3.5.2",
|
"prettier": "^3.5.2",
|
||||||
@@ -57,6 +59,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/node": "^22.13.5",
|
"@types/node": "^22.13.5",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
|
|||||||
6366
pnpm-lock.yaml
generated
6366
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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 { ToolCalls, ToolResult } from "./tool-calls";
|
||||||
import { MessageContentComplex } from "@langchain/core/messages";
|
import { MessageContentComplex } from "@langchain/core/messages";
|
||||||
import { Fragment } from "react/jsx-runtime";
|
import { Fragment } from "react/jsx-runtime";
|
||||||
|
import { isAgentInboxInterrupt } from "@/lib/is-hitl";
|
||||||
|
import { ThreadView } from "../agent-inbox";
|
||||||
|
|
||||||
function CustomComponent({
|
function CustomComponent({
|
||||||
message,
|
message,
|
||||||
@@ -79,6 +81,7 @@ export function AssistantMessage({
|
|||||||
|
|
||||||
const thread = useStreamContext();
|
const thread = useStreamContext();
|
||||||
const meta = thread.getMessagesMetadata(message);
|
const meta = thread.getMessagesMetadata(message);
|
||||||
|
const interrupt = thread.interrupt;
|
||||||
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
|
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
|
||||||
const anthropicStreamedToolCalls = Array.isArray(message.content)
|
const anthropicStreamedToolCalls = Array.isArray(message.content)
|
||||||
? parseAnthropicStreamedToolCalls(message.content)
|
? parseAnthropicStreamedToolCalls(message.content)
|
||||||
@@ -115,6 +118,9 @@ export function AssistantMessage({
|
|||||||
)) ||
|
)) ||
|
||||||
(hasToolCalls && <ToolCalls toolCalls={message.tool_calls} />)}
|
(hasToolCalls && <ToolCalls toolCalls={message.tool_calls} />)}
|
||||||
<CustomComponent message={message} thread={thread} />
|
<CustomComponent message={message} thread={thread} />
|
||||||
|
{isAgentInboxInterrupt(interrupt?.value) && (
|
||||||
|
<ThreadView interrupt={interrupt.value[0]} />
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex gap-2 items-center mr-auto transition-opacity",
|
"flex gap-2 items-center mr-auto transition-opacity",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const buttonVariants = cva(
|
|||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
brand: "bg-[#2F6868] hover:bg-[#2F6868]/90 border-[#2F6868] text-white",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
|||||||
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 (
|
return (
|
||||||
<StreamContext.Provider value={streamValue}>
|
<StreamContext.Provider value={streamValue}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { validate } from "uuid";
|
import { validate } from "uuid";
|
||||||
import { getApiKey } from "@/lib/api-key";
|
import { getApiKey } from "@/lib/api-key";
|
||||||
import { Client, Thread } from "@langchain/langgraph-sdk";
|
import { Client, Run, Thread } from "@langchain/langgraph-sdk";
|
||||||
import { useQueryParam, StringParam } from "use-query-params";
|
import { useQueryParam, StringParam } from "use-query-params";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
Dispatch,
|
Dispatch,
|
||||||
SetStateAction,
|
SetStateAction,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { END } from "@langchain/langgraph/web";
|
||||||
|
import { HumanResponse } from "@langchain/langgraph/prebuilt";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface ThreadContextType {
|
interface ThreadContextType {
|
||||||
getThreads: () => Promise<Thread[]>;
|
getThreads: () => Promise<Thread[]>;
|
||||||
@@ -18,6 +21,21 @@ interface ThreadContextType {
|
|||||||
setThreads: Dispatch<SetStateAction<Thread[]>>;
|
setThreads: Dispatch<SetStateAction<Thread[]>>;
|
||||||
threadsLoading: boolean;
|
threadsLoading: boolean;
|
||||||
setThreadsLoading: Dispatch<SetStateAction<boolean>>;
|
setThreadsLoading: Dispatch<SetStateAction<boolean>>;
|
||||||
|
resumeRun: <TStream extends boolean = false>(
|
||||||
|
threadId: string,
|
||||||
|
response: HumanResponse[],
|
||||||
|
options?: {
|
||||||
|
stream?: TStream;
|
||||||
|
},
|
||||||
|
) => TStream extends true
|
||||||
|
?
|
||||||
|
| AsyncGenerator<{
|
||||||
|
event: Record<string, any>;
|
||||||
|
data: any;
|
||||||
|
}>
|
||||||
|
| undefined
|
||||||
|
: Promise<Run> | undefined;
|
||||||
|
ignoreRun: (threadId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThreadContext = createContext<ThreadContextType | undefined>(undefined);
|
const ThreadContext = createContext<ThreadContextType | undefined>(undefined);
|
||||||
@@ -47,7 +65,6 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const getThreads = useCallback(async (): Promise<Thread[]> => {
|
const getThreads = useCallback(async (): Promise<Thread[]> => {
|
||||||
if (!apiUrl || !assistantId) return [];
|
if (!apiUrl || !assistantId) return [];
|
||||||
|
|
||||||
const client = createClient(apiUrl, getApiKey() ?? undefined);
|
const client = createClient(apiUrl, getApiKey() ?? undefined);
|
||||||
|
|
||||||
const threads = await client.threads.search({
|
const threads = await client.threads.search({
|
||||||
@@ -60,12 +77,76 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
|||||||
return threads;
|
return threads;
|
||||||
}, [apiUrl, assistantId]);
|
}, [apiUrl, assistantId]);
|
||||||
|
|
||||||
|
const resumeRun = <TStream extends boolean = false>(
|
||||||
|
threadId: string,
|
||||||
|
response: HumanResponse[],
|
||||||
|
options?: {
|
||||||
|
stream?: TStream;
|
||||||
|
},
|
||||||
|
): TStream extends true
|
||||||
|
?
|
||||||
|
| AsyncGenerator<{
|
||||||
|
event: Record<string, any>;
|
||||||
|
data: any;
|
||||||
|
}>
|
||||||
|
| undefined
|
||||||
|
: Promise<Run> | undefined => {
|
||||||
|
if (!apiUrl || !assistantId) return undefined;
|
||||||
|
const client = createClient(apiUrl, getApiKey() ?? undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (options?.stream) {
|
||||||
|
return client.runs.stream(threadId, assistantId, {
|
||||||
|
command: {
|
||||||
|
resume: response,
|
||||||
|
},
|
||||||
|
streamMode: "events",
|
||||||
|
}) as any; // Type assertion needed due to conditional return type
|
||||||
|
}
|
||||||
|
return client.runs.create(threadId, assistantId, {
|
||||||
|
command: {
|
||||||
|
resume: response,
|
||||||
|
},
|
||||||
|
}) as any; // Type assertion needed due to conditional return type
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Error sending human response", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ignoreRun = async (threadId: string) => {
|
||||||
|
if (!apiUrl || !assistantId) return;
|
||||||
|
const client = createClient(apiUrl, getApiKey() ?? undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.threads.updateState(threadId, {
|
||||||
|
values: null,
|
||||||
|
asNode: END,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast("Success", {
|
||||||
|
description: "Ignored thread",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error ignoring thread", e);
|
||||||
|
toast("Error", {
|
||||||
|
description: "Failed to ignore thread",
|
||||||
|
richColors: true,
|
||||||
|
closeButton: true,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
getThreads,
|
getThreads,
|
||||||
threads,
|
threads,
|
||||||
setThreads,
|
setThreads,
|
||||||
threadsLoading,
|
threadsLoading,
|
||||||
setThreadsLoading,
|
setThreadsLoading,
|
||||||
|
resumeRun,
|
||||||
|
ignoreRun,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user