FE implementation of InteractionNode (#3821)
This commit is contained in:
@@ -26,6 +26,7 @@ export const Status = {
|
|||||||
TimedOut: "timed_out",
|
TimedOut: "timed_out",
|
||||||
Canceled: "canceled",
|
Canceled: "canceled",
|
||||||
Skipped: "skipped",
|
Skipped: "skipped",
|
||||||
|
Paused: "paused",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Status = (typeof Status)[keyof typeof Status];
|
export type Status = (typeof Status)[keyof typeof Status];
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const buttonVariants = cva(
|
|||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90 font-bold",
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90 font-bold",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
"bg-red-900 text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
disabled:
|
disabled:
|
||||||
"hover:bg-accent hover:text-accent-foreground opacity-50 pointer-events-none",
|
"hover:bg-accent hover:text-accent-foreground opacity-50 pointer-events-none",
|
||||||
outline:
|
outline:
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export function statusIsNotFinalized({ status }: { status: Status }): boolean {
|
|||||||
return (
|
return (
|
||||||
status === Status.Created ||
|
status === Status.Created ||
|
||||||
status === Status.Queued ||
|
status === Status.Queued ||
|
||||||
status === Status.Running
|
status === Status.Running ||
|
||||||
|
status === Status.Paused
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { HelpTooltip } from "@/components/HelpTooltip";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||||
|
import { type HumanInteractionNode } from "./types";
|
||||||
|
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
|
||||||
|
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
|
||||||
|
import { cn } from "@/util/utils";
|
||||||
|
import { NodeHeader } from "../components/NodeHeader";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { statusIsRunningOrQueued } from "@/routes/tasks/types";
|
||||||
|
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
|
||||||
|
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { useRerender } from "@/hooks/useRerender";
|
||||||
|
|
||||||
|
const instructionsTooltip =
|
||||||
|
"Instructions shown to the user for review. Explain what needs to be reviewed and what action should be taken.";
|
||||||
|
const positiveDescriptorTooltip =
|
||||||
|
"Label for the positive action button (e.g., 'Approve', 'Continue', 'Yes').";
|
||||||
|
const negativeDescriptorTooltip =
|
||||||
|
"Label for the negative action button (e.g., 'Reject', 'Cancel', 'No').";
|
||||||
|
const timeoutTooltip =
|
||||||
|
"Time in seconds to wait for human interaction before timing out. Default is 2 hours (7200 seconds).";
|
||||||
|
|
||||||
|
function HumanInteractionNode({
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
type,
|
||||||
|
}: NodeProps<HumanInteractionNode>) {
|
||||||
|
const { editable, label } = data;
|
||||||
|
const { blockLabel: urlBlockLabel } = useParams();
|
||||||
|
const { data: workflowRun } = useWorkflowRunQuery();
|
||||||
|
const workflowRunIsRunningOrQueued =
|
||||||
|
workflowRun && statusIsRunningOrQueued(workflowRun);
|
||||||
|
const thisBlockIsTargetted =
|
||||||
|
urlBlockLabel !== undefined && urlBlockLabel === label;
|
||||||
|
const thisBlockIsPlaying =
|
||||||
|
workflowRunIsRunningOrQueued && thisBlockIsTargetted;
|
||||||
|
const update = useUpdate<HumanInteractionNode["data"]>({ id, editable });
|
||||||
|
const rerender = useRerender({ prefix: "accordian" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="a"
|
||||||
|
className="opacity-0"
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
id="b"
|
||||||
|
className="opacity-0"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"transform-origin-center w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4 transition-all",
|
||||||
|
{
|
||||||
|
"pointer-events-none": thisBlockIsPlaying,
|
||||||
|
"bg-slate-950 outline outline-2 outline-slate-300":
|
||||||
|
thisBlockIsTargetted,
|
||||||
|
},
|
||||||
|
data.comparisonColor,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<NodeHeader
|
||||||
|
blockLabel={label}
|
||||||
|
editable={editable}
|
||||||
|
nodeId={id}
|
||||||
|
totpIdentifier={null}
|
||||||
|
totpUrl={null}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn("space-y-4", {
|
||||||
|
"opacity-50": thisBlockIsPlaying,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">
|
||||||
|
Instructions For Human
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip content={instructionsTooltip} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* TODO(jdo): 'instructions' allows templating; but it requires adding a column to the workflow_block_runs
|
||||||
|
table, and I don't want to do that just yet (see /timeline endpoint) */}
|
||||||
|
<Input
|
||||||
|
onChange={(event) => {
|
||||||
|
update({ instructions: event.target.value });
|
||||||
|
}}
|
||||||
|
value={data.instructions}
|
||||||
|
placeholder="Please review and approve or reject to continue the workflow."
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">
|
||||||
|
Negative Button Label
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip content={negativeDescriptorTooltip} />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
onChange={(event) => {
|
||||||
|
update({ negativeDescriptor: event.target.value });
|
||||||
|
}}
|
||||||
|
value={data.negativeDescriptor}
|
||||||
|
placeholder="Reject"
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">
|
||||||
|
Positive Button Label
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip content={positiveDescriptorTooltip} />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
onChange={(event) => {
|
||||||
|
update({ positiveDescriptor: event.target.value });
|
||||||
|
}}
|
||||||
|
value={data.positiveDescriptor}
|
||||||
|
placeholder="Approve"
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* <div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">
|
||||||
|
Timeout (seconds)
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip content={timeoutTooltip} />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="nopan text-xs"
|
||||||
|
min="1"
|
||||||
|
value={data.timeoutSeconds}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = Number(event.target.value);
|
||||||
|
update({ timeoutSeconds: value });
|
||||||
|
}}
|
||||||
|
placeholder="7200"
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
|
<div className="space-between flex items-center gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">Timeout (seconds)</Label>
|
||||||
|
<HelpTooltip content={timeoutTooltip} />
|
||||||
|
<Input
|
||||||
|
className="ml-auto w-16 text-right"
|
||||||
|
value={data.timeoutSeconds}
|
||||||
|
placeholder="7200"
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = Number(event.target.value);
|
||||||
|
update({ timeoutSeconds: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-slate-800 p-2">
|
||||||
|
<div className="space-y-1 text-xs text-slate-400">
|
||||||
|
Tip: The workflow will pause and send an email notification to the
|
||||||
|
recipients. The workflow continues or terminates based on the
|
||||||
|
user's response.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<Accordion
|
||||||
|
className={cn({
|
||||||
|
"pointer-events-none opacity-50": thisBlockIsPlaying,
|
||||||
|
})}
|
||||||
|
type="single"
|
||||||
|
onValueChange={() => rerender.bump()}
|
||||||
|
collapsible
|
||||||
|
>
|
||||||
|
<AccordionItem value="email" className="border-b-0">
|
||||||
|
<AccordionTrigger className="py-0">Email Settings</AccordionTrigger>
|
||||||
|
<AccordionContent className="pl-6 pr-1 pt-1">
|
||||||
|
<div key={rerender.key} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-slate-300">Recipients</Label>
|
||||||
|
<WorkflowBlockInput
|
||||||
|
nodeId={id}
|
||||||
|
onChange={(value) => {
|
||||||
|
update({ recipients: value });
|
||||||
|
}}
|
||||||
|
value={data.recipients}
|
||||||
|
placeholder="example@gmail.com, example2@gmail.com..."
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-slate-300">Subject</Label>
|
||||||
|
<WorkflowBlockInput
|
||||||
|
nodeId={id}
|
||||||
|
onChange={(value) => {
|
||||||
|
update({ subject: value });
|
||||||
|
}}
|
||||||
|
value={data.subject}
|
||||||
|
placeholder="Human interaction required for workflow run"
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-slate-300">Body</Label>
|
||||||
|
<WorkflowBlockInputTextarea
|
||||||
|
nodeId={id}
|
||||||
|
onChange={(value) => {
|
||||||
|
update({ body: value });
|
||||||
|
}}
|
||||||
|
value={data.body}
|
||||||
|
placeholder="Your interaction is required for a workflow run!"
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HumanInteractionNode };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Node } from "@xyflow/react";
|
||||||
|
import {
|
||||||
|
EMAIL_BLOCK_SENDER,
|
||||||
|
SMTP_HOST_PARAMETER_KEY,
|
||||||
|
SMTP_PASSWORD_PARAMETER_KEY,
|
||||||
|
SMTP_PORT_PARAMETER_KEY,
|
||||||
|
SMTP_USERNAME_PARAMETER_KEY,
|
||||||
|
} from "../../constants";
|
||||||
|
import { NodeBaseData } from "../types";
|
||||||
|
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
|
||||||
|
|
||||||
|
export type HumanInteractionNodeData = NodeBaseData & {
|
||||||
|
instructions: string;
|
||||||
|
positiveDescriptor: string;
|
||||||
|
negativeDescriptor: string;
|
||||||
|
timeoutSeconds: number;
|
||||||
|
recipients: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
sender: string;
|
||||||
|
smtpHostSecretParameterKey?: string;
|
||||||
|
smtpPortSecretParameterKey?: string;
|
||||||
|
smtpUsernameSecretParameterKey?: string;
|
||||||
|
smtpPasswordSecretParameterKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HumanInteractionNode = Node<
|
||||||
|
HumanInteractionNodeData,
|
||||||
|
"human_interaction"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const humanInteractionNodeDefaultData: HumanInteractionNodeData = {
|
||||||
|
debuggable: debuggableWorkflowBlockTypes.has("human_interaction"),
|
||||||
|
instructions: "Please review and approve or reject to continue the workflow.",
|
||||||
|
positiveDescriptor: "Approve",
|
||||||
|
negativeDescriptor: "Reject",
|
||||||
|
timeoutSeconds: 60 * 60 * 2, // two hours
|
||||||
|
recipients: "",
|
||||||
|
subject: "Human interaction required for workflow run",
|
||||||
|
body: "Your interaction is required for a workflow run!",
|
||||||
|
editable: true,
|
||||||
|
label: "",
|
||||||
|
sender: EMAIL_BLOCK_SENDER,
|
||||||
|
smtpHostSecretParameterKey: SMTP_HOST_PARAMETER_KEY,
|
||||||
|
smtpPortSecretParameterKey: SMTP_PORT_PARAMETER_KEY,
|
||||||
|
smtpUsernameSecretParameterKey: SMTP_USERNAME_PARAMETER_KEY,
|
||||||
|
smtpPasswordSecretParameterKey: SMTP_PASSWORD_PARAMETER_KEY,
|
||||||
|
continueOnFailure: false,
|
||||||
|
model: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function isHumanInteractionNode(
|
||||||
|
node: Node,
|
||||||
|
): node is HumanInteractionNode {
|
||||||
|
return node.type === "human_interaction";
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
|
HandIcon,
|
||||||
ListBulletIcon,
|
ListBulletIcon,
|
||||||
LockOpen1Icon,
|
LockOpen1Icon,
|
||||||
StopwatchIcon,
|
StopwatchIcon,
|
||||||
@@ -71,6 +72,9 @@ function WorkflowBlockIcon({ workflowBlockType, className }: Props) {
|
|||||||
case "validation": {
|
case "validation": {
|
||||||
return <CheckCircledIcon className={className} />;
|
return <CheckCircledIcon className={className} />;
|
||||||
}
|
}
|
||||||
|
case "human_interaction": {
|
||||||
|
return <HandIcon className={className} />;
|
||||||
|
}
|
||||||
case "wait": {
|
case "wait": {
|
||||||
return <StopwatchIcon className={className} />;
|
return <StopwatchIcon className={className} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import { URLNode } from "./URLNode/types";
|
|||||||
import { URLNode as URLNodeComponent } from "./URLNode/URLNode";
|
import { URLNode as URLNodeComponent } from "./URLNode/URLNode";
|
||||||
import { HttpRequestNode } from "./HttpRequestNode/types";
|
import { HttpRequestNode } from "./HttpRequestNode/types";
|
||||||
import { HttpRequestNode as HttpRequestNodeComponent } from "./HttpRequestNode/HttpRequestNode";
|
import { HttpRequestNode as HttpRequestNodeComponent } from "./HttpRequestNode/HttpRequestNode";
|
||||||
|
import { HumanInteractionNode } from "./HumanInteractionNode/types";
|
||||||
|
import { HumanInteractionNode as HumanInteractionNodeComponent } from "./HumanInteractionNode/HumanInteractionNode";
|
||||||
|
|
||||||
export type UtilityNode = StartNode | NodeAdderNode;
|
export type UtilityNode = StartNode | NodeAdderNode;
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ export type WorkflowBlockNode =
|
|||||||
| FileUploadNode
|
| FileUploadNode
|
||||||
| DownloadNode
|
| DownloadNode
|
||||||
| ValidationNode
|
| ValidationNode
|
||||||
|
| HumanInteractionNode
|
||||||
| ActionNode
|
| ActionNode
|
||||||
| NavigationNode
|
| NavigationNode
|
||||||
| ExtractionNode
|
| ExtractionNode
|
||||||
@@ -93,6 +96,7 @@ export const nodeTypes = {
|
|||||||
validation: memo(ValidationNodeComponent),
|
validation: memo(ValidationNodeComponent),
|
||||||
action: memo(ActionNodeComponent),
|
action: memo(ActionNodeComponent),
|
||||||
navigation: memo(NavigationNodeComponent),
|
navigation: memo(NavigationNodeComponent),
|
||||||
|
human_interaction: memo(HumanInteractionNodeComponent),
|
||||||
extraction: memo(ExtractionNodeComponent),
|
extraction: memo(ExtractionNodeComponent),
|
||||||
login: memo(LoginNodeComponent),
|
login: memo(LoginNodeComponent),
|
||||||
wait: memo(WaitNodeComponent),
|
wait: memo(WaitNodeComponent),
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const workflowBlockTitle: {
|
|||||||
upload_to_s3: "Upload To S3",
|
upload_to_s3: "Upload To S3",
|
||||||
file_upload: "Cloud Storage",
|
file_upload: "Cloud Storage",
|
||||||
validation: "Validation",
|
validation: "Validation",
|
||||||
|
human_interaction: "Human Interaction",
|
||||||
wait: "Wait",
|
wait: "Wait",
|
||||||
pdf_parser: "PDF Parser",
|
pdf_parser: "PDF Parser",
|
||||||
task_v2: "Browser Task v2",
|
task_v2: "Browser Task v2",
|
||||||
|
|||||||
@@ -87,6 +87,17 @@ const nodeLibraryItems: Array<{
|
|||||||
title: "Validation Block",
|
title: "Validation Block",
|
||||||
description: "Validate completion criteria",
|
description: "Validate completion criteria",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
nodeType: "human_interaction",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.HumanInteraction}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "Human Interaction Block",
|
||||||
|
description: "Validate via human interaction",
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// nodeType: "task",
|
// nodeType: "task",
|
||||||
// icon: (
|
// icon: (
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
TextPromptBlockYAML,
|
TextPromptBlockYAML,
|
||||||
UploadToS3BlockYAML,
|
UploadToS3BlockYAML,
|
||||||
ValidationBlockYAML,
|
ValidationBlockYAML,
|
||||||
|
HumanInteractionBlockYAML,
|
||||||
NavigationBlockYAML,
|
NavigationBlockYAML,
|
||||||
WorkflowCreateYAMLRequest,
|
WorkflowCreateYAMLRequest,
|
||||||
ExtractionBlockYAML,
|
ExtractionBlockYAML,
|
||||||
@@ -87,6 +88,10 @@ import {
|
|||||||
isValidationNode,
|
isValidationNode,
|
||||||
validationNodeDefaultData,
|
validationNodeDefaultData,
|
||||||
} from "./nodes/ValidationNode/types";
|
} from "./nodes/ValidationNode/types";
|
||||||
|
import {
|
||||||
|
isHumanInteractionNode,
|
||||||
|
humanInteractionNodeDefaultData,
|
||||||
|
} from "./nodes/HumanInteractionNode/types";
|
||||||
import { actionNodeDefaultData, isActionNode } from "./nodes/ActionNode/types";
|
import { actionNodeDefaultData, isActionNode } from "./nodes/ActionNode/types";
|
||||||
import {
|
import {
|
||||||
isNavigationNode,
|
isNavigationNode,
|
||||||
@@ -336,6 +341,24 @@ function convertToNode(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "human_interaction": {
|
||||||
|
return {
|
||||||
|
...identifiers,
|
||||||
|
...common,
|
||||||
|
type: "human_interaction",
|
||||||
|
data: {
|
||||||
|
...commonData,
|
||||||
|
instructions: block.instructions,
|
||||||
|
positiveDescriptor: block.positive_descriptor,
|
||||||
|
negativeDescriptor: block.negative_descriptor,
|
||||||
|
timeoutSeconds: block.timeout_seconds,
|
||||||
|
recipients: block.recipients.join(", "),
|
||||||
|
subject: block.subject,
|
||||||
|
body: block.body,
|
||||||
|
sender: block.sender,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
case "extraction": {
|
case "extraction": {
|
||||||
return {
|
return {
|
||||||
...identifiers,
|
...identifiers,
|
||||||
@@ -831,6 +854,17 @@ function createNode(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "human_interaction": {
|
||||||
|
return {
|
||||||
|
...identifiers,
|
||||||
|
...common,
|
||||||
|
type: "human_interaction",
|
||||||
|
data: {
|
||||||
|
...humanInteractionNodeDefaultData,
|
||||||
|
label,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
case "action": {
|
case "action": {
|
||||||
return {
|
return {
|
||||||
...identifiers,
|
...identifiers,
|
||||||
@@ -1105,6 +1139,22 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML {
|
|||||||
parameter_keys: node.data.parameterKeys,
|
parameter_keys: node.data.parameterKeys,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "human_interaction": {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
block_type: "human_interaction",
|
||||||
|
instructions: node.data.instructions,
|
||||||
|
positive_descriptor: node.data.positiveDescriptor,
|
||||||
|
negative_descriptor: node.data.negativeDescriptor,
|
||||||
|
timeout_seconds: node.data.timeoutSeconds,
|
||||||
|
recipients: node.data.recipients
|
||||||
|
.split(",")
|
||||||
|
.map((recipient) => recipient.trim()),
|
||||||
|
subject: node.data.subject,
|
||||||
|
body: node.data.body,
|
||||||
|
sender: node.data.sender === "" ? EMAIL_BLOCK_SENDER : node.data.sender,
|
||||||
|
};
|
||||||
|
}
|
||||||
case "action": {
|
case "action": {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -1342,7 +1392,9 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error("Invalid node type for getWorkflowBlock");
|
throw new Error(
|
||||||
|
`Invalid node type, '${node.type}', for getWorkflowBlock`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1910,6 +1962,23 @@ function convertBlocksToBlockYAML(
|
|||||||
};
|
};
|
||||||
return blockYaml;
|
return blockYaml;
|
||||||
}
|
}
|
||||||
|
case "human_interaction": {
|
||||||
|
const blockYaml: HumanInteractionBlockYAML = {
|
||||||
|
...base,
|
||||||
|
block_type: "human_interaction",
|
||||||
|
// --
|
||||||
|
instructions: block.instructions,
|
||||||
|
positive_descriptor: block.positive_descriptor,
|
||||||
|
negative_descriptor: block.negative_descriptor,
|
||||||
|
timeout_seconds: block.timeout_seconds,
|
||||||
|
// --
|
||||||
|
sender: block.sender,
|
||||||
|
recipients: block.recipients,
|
||||||
|
subject: block.subject,
|
||||||
|
body: block.body,
|
||||||
|
};
|
||||||
|
return blockYaml;
|
||||||
|
}
|
||||||
case "action": {
|
case "action": {
|
||||||
const blockYaml: ActionBlockYAML = {
|
const blockYaml: ActionBlockYAML = {
|
||||||
...base,
|
...base,
|
||||||
@@ -2248,6 +2317,11 @@ function getWorkflowErrors(nodes: Array<AppNode>): Array<string> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const interactionNodes = nodes.filter(isHumanInteractionNode);
|
||||||
|
interactionNodes.forEach((/* node */) => {
|
||||||
|
// pass for now
|
||||||
|
});
|
||||||
|
|
||||||
const navigationNodes = nodes.filter(isNavigationNode);
|
const navigationNodes = nodes.filter(isNavigationNode);
|
||||||
navigationNodes.forEach((node) => {
|
navigationNodes.forEach((node) => {
|
||||||
if (node.data.navigationGoal.length === 0) {
|
if (node.data.navigationGoal.length === 0) {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ export type WorkflowBlock =
|
|||||||
| SendEmailBlock
|
| SendEmailBlock
|
||||||
| FileURLParserBlock
|
| FileURLParserBlock
|
||||||
| ValidationBlock
|
| ValidationBlock
|
||||||
|
| HumanInteractionBlock
|
||||||
| ActionBlock
|
| ActionBlock
|
||||||
| NavigationBlock
|
| NavigationBlock
|
||||||
| ExtractionBlock
|
| ExtractionBlock
|
||||||
@@ -222,6 +223,7 @@ export const WorkflowBlockTypes = {
|
|||||||
SendEmail: "send_email",
|
SendEmail: "send_email",
|
||||||
FileURLParser: "file_url_parser",
|
FileURLParser: "file_url_parser",
|
||||||
Validation: "validation",
|
Validation: "validation",
|
||||||
|
HumanInteraction: "human_interaction",
|
||||||
Action: "action",
|
Action: "action",
|
||||||
Navigation: "navigation",
|
Navigation: "navigation",
|
||||||
Extraction: "extraction",
|
Extraction: "extraction",
|
||||||
@@ -396,6 +398,20 @@ export type ValidationBlock = WorkflowBlockBase & {
|
|||||||
disable_cache?: boolean;
|
disable_cache?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HumanInteractionBlock = WorkflowBlockBase & {
|
||||||
|
block_type: "human_interaction";
|
||||||
|
|
||||||
|
instructions: string;
|
||||||
|
positive_descriptor: string;
|
||||||
|
negative_descriptor: string;
|
||||||
|
timeout_seconds: number;
|
||||||
|
|
||||||
|
sender: string;
|
||||||
|
recipients: Array<string>;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ActionBlock = WorkflowBlockBase & {
|
export type ActionBlock = WorkflowBlockBase & {
|
||||||
block_type: "action";
|
block_type: "action";
|
||||||
url: string | null;
|
url: string | null;
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export type BlockYAML =
|
|||||||
| FileUrlParserBlockYAML
|
| FileUrlParserBlockYAML
|
||||||
| ForLoopBlockYAML
|
| ForLoopBlockYAML
|
||||||
| ValidationBlockYAML
|
| ValidationBlockYAML
|
||||||
|
| HumanInteractionBlockYAML
|
||||||
| ActionBlockYAML
|
| ActionBlockYAML
|
||||||
| NavigationBlockYAML
|
| NavigationBlockYAML
|
||||||
| ExtractionBlockYAML
|
| ExtractionBlockYAML
|
||||||
@@ -185,6 +186,20 @@ export type ValidationBlockYAML = BlockYAMLBase & {
|
|||||||
parameter_keys?: Array<string> | null;
|
parameter_keys?: Array<string> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HumanInteractionBlockYAML = BlockYAMLBase & {
|
||||||
|
block_type: "human_interaction";
|
||||||
|
|
||||||
|
instructions: string;
|
||||||
|
positive_descriptor: string;
|
||||||
|
negative_descriptor: string;
|
||||||
|
timeout_seconds: number;
|
||||||
|
|
||||||
|
sender: string;
|
||||||
|
recipients: Array<string>;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ActionBlockYAML = BlockYAMLBase & {
|
export type ActionBlockYAML = BlockYAMLBase & {
|
||||||
block_type: "action";
|
block_type: "action";
|
||||||
url: string | null;
|
url: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { getClient } from "@/api/AxiosClient";
|
||||||
|
import { Status as WorkflowRunStatus } from "@/api/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||||
|
import { cn } from "@/util/utils";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { toast } from "@/components/ui/use-toast";
|
||||||
|
import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery";
|
||||||
|
import { HumanInteractionBlock } from "../types/workflowTypes";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
humanInteractionBlock: HumanInteractionBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowRunHumanInteraction({ humanInteractionBlock }: Props) {
|
||||||
|
const credentialGetter = useCredentialGetter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: workflowRun } = useWorkflowRunQuery();
|
||||||
|
const isPaused =
|
||||||
|
workflowRun && workflowRun.status === WorkflowRunStatus.Paused;
|
||||||
|
|
||||||
|
const buttonLayout =
|
||||||
|
humanInteractionBlock.positive_descriptor.length < 8 &&
|
||||||
|
humanInteractionBlock.negative_descriptor.length < 8
|
||||||
|
? "inline"
|
||||||
|
: "stacked";
|
||||||
|
|
||||||
|
const approveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!workflowRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||||
|
|
||||||
|
return await client.post(
|
||||||
|
`/workflows/runs/${workflowRun.workflow_run_id}/continue`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["workflowRun"],
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
variant: "success",
|
||||||
|
title: `${humanInteractionBlock.positive_descriptor}`,
|
||||||
|
description: `Successfully chose: ${humanInteractionBlock.positive_descriptor}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Interaction Failed",
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!workflowRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient(credentialGetter);
|
||||||
|
|
||||||
|
return await client.post(
|
||||||
|
`/workflows/runs/${workflowRun.workflow_run_id}/cancel`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["workflowRun"],
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
variant: "success",
|
||||||
|
title: `${humanInteractionBlock.negative_descriptor}`,
|
||||||
|
description: `Successfully chose: ${humanInteractionBlock.negative_descriptor}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Interaction Failed",
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isPaused) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex flex-col gap-4 rounded-md bg-slate-elevation4 p-4">
|
||||||
|
<div className="text-sm">{humanInteractionBlock.instructions}</div>
|
||||||
|
<div
|
||||||
|
className={cn("flex gap-2", {
|
||||||
|
"justify-between": buttonLayout === "inline",
|
||||||
|
"flex-col": buttonLayout === "stacked",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button variant="destructive" onClick={() => rejectMutation.mutate()}>
|
||||||
|
<div>{humanInteractionBlock.negative_descriptor}</div>
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" onClick={() => approveMutation.mutate()}>
|
||||||
|
<div>{humanInteractionBlock.positive_descriptor}</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ import {
|
|||||||
CubeIcon,
|
CubeIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
} from "@radix-ui/react-icons";
|
} from "@radix-ui/react-icons";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useParams, Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Status } from "@/api/types";
|
||||||
|
import { formatDuration, toDuration } from "@/routes/workflows/utils";
|
||||||
|
import { cn } from "@/util/utils";
|
||||||
import { workflowBlockTitle } from "../editor/nodes/types";
|
import { workflowBlockTitle } from "../editor/nodes/types";
|
||||||
import { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon";
|
import { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon";
|
||||||
import {
|
import {
|
||||||
@@ -20,14 +26,16 @@ import {
|
|||||||
ActionItem,
|
ActionItem,
|
||||||
WorkflowRunOverviewActiveElement,
|
WorkflowRunOverviewActiveElement,
|
||||||
} from "./WorkflowRunOverview";
|
} from "./WorkflowRunOverview";
|
||||||
import { cn } from "@/util/utils";
|
|
||||||
import { isTaskVariantBlock } from "../types/workflowTypes";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { Status } from "@/api/types";
|
|
||||||
import { formatDuration, toDuration } from "@/routes/workflows/utils";
|
|
||||||
import { ThoughtCard } from "./ThoughtCard";
|
import { ThoughtCard } from "./ThoughtCard";
|
||||||
|
import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
|
||||||
import { ObserverThought } from "../types/workflowRunTypes";
|
import { ObserverThought } from "../types/workflowRunTypes";
|
||||||
|
import {
|
||||||
|
HumanInteractionBlock,
|
||||||
|
isTaskVariantBlock,
|
||||||
|
type WorkflowApiResponse,
|
||||||
|
} from "../types/workflowTypes";
|
||||||
|
import { WorkflowRunHumanInteraction } from "./WorkflowRunHumanInteraction";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
activeItem: WorkflowRunOverviewActiveElement;
|
activeItem: WorkflowRunOverviewActiveElement;
|
||||||
block: WorkflowRunBlock;
|
block: WorkflowRunBlock;
|
||||||
@@ -37,6 +45,28 @@ type Props = {
|
|||||||
onThoughtCardClick: (thought: ObserverThought) => void;
|
onThoughtCardClick: (thought: ObserverThought) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getHumanInteractionBlock = (
|
||||||
|
block: WorkflowRunBlock,
|
||||||
|
workflow?: WorkflowApiResponse,
|
||||||
|
): HumanInteractionBlock | null => {
|
||||||
|
if (block.block_type !== "human_interaction") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workflow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = workflow.workflow_definition.blocks;
|
||||||
|
const candidate = blocks.find((b) => b.label === block.label);
|
||||||
|
|
||||||
|
if (!candidate || candidate.block_type !== "human_interaction") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate as HumanInteractionBlock;
|
||||||
|
};
|
||||||
|
|
||||||
function WorkflowRunTimelineBlockItem({
|
function WorkflowRunTimelineBlockItem({
|
||||||
activeItem,
|
activeItem,
|
||||||
block,
|
block,
|
||||||
@@ -45,6 +75,12 @@ function WorkflowRunTimelineBlockItem({
|
|||||||
onActionClick,
|
onActionClick,
|
||||||
onThoughtCardClick,
|
onThoughtCardClick,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { workflowPermanentId } = useParams();
|
||||||
|
const { data: workflow } = useWorkflowQuery({
|
||||||
|
workflowPermanentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const humanInteractionBlock = getHumanInteractionBlock(block, workflow);
|
||||||
const actions = block.actions ?? [];
|
const actions = block.actions ?? [];
|
||||||
|
|
||||||
const hasActiveAction =
|
const hasActiveAction =
|
||||||
@@ -167,6 +203,12 @@ function WorkflowRunTimelineBlockItem({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{humanInteractionBlock && (
|
||||||
|
<WorkflowRunHumanInteraction
|
||||||
|
humanInteractionBlock={humanInteractionBlock}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{actions.map((action, index) => {
|
{actions.map((action, index) => {
|
||||||
return (
|
return (
|
||||||
<ActionCard
|
<ActionCard
|
||||||
|
|||||||
Reference in New Issue
Block a user