FE implementation of InteractionNode (#3821)

This commit is contained in:
Jonathan Dobson
2025-10-24 21:12:08 -04:00
committed by GitHub
parent 454c00b10a
commit 2f87e3ab48
14 changed files with 594 additions and 9 deletions

View File

@@ -26,6 +26,7 @@ export const Status = {
TimedOut: "timed_out",
Canceled: "canceled",
Skipped: "skipped",
Paused: "paused",
} as const;
export type Status = (typeof Status)[keyof typeof Status];

View File

@@ -8,7 +8,7 @@ const buttonVariants = cva(
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90 font-bold",
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:
"hover:bg-accent hover:text-accent-foreground opacity-50 pointer-events-none",
outline:

View File

@@ -21,7 +21,8 @@ export function statusIsNotFinalized({ status }: { status: Status }): boolean {
return (
status === Status.Created ||
status === Status.Queued ||
status === Status.Running
status === Status.Running ||
status === Status.Paused
);
}

View File

@@ -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 };

View File

@@ -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";
}

View File

@@ -9,6 +9,7 @@ import {
ExternalLinkIcon,
FileTextIcon,
GlobeIcon,
HandIcon,
ListBulletIcon,
LockOpen1Icon,
StopwatchIcon,
@@ -71,6 +72,9 @@ function WorkflowBlockIcon({ workflowBlockType, className }: Props) {
case "validation": {
return <CheckCircledIcon className={className} />;
}
case "human_interaction": {
return <HandIcon className={className} />;
}
case "wait": {
return <StopwatchIcon className={className} />;
}

View File

@@ -43,6 +43,8 @@ import { URLNode } from "./URLNode/types";
import { URLNode as URLNodeComponent } from "./URLNode/URLNode";
import { HttpRequestNode } from "./HttpRequestNode/types";
import { HttpRequestNode as HttpRequestNodeComponent } from "./HttpRequestNode/HttpRequestNode";
import { HumanInteractionNode } from "./HumanInteractionNode/types";
import { HumanInteractionNode as HumanInteractionNodeComponent } from "./HumanInteractionNode/HumanInteractionNode";
export type UtilityNode = StartNode | NodeAdderNode;
@@ -57,6 +59,7 @@ export type WorkflowBlockNode =
| FileUploadNode
| DownloadNode
| ValidationNode
| HumanInteractionNode
| ActionNode
| NavigationNode
| ExtractionNode
@@ -93,6 +96,7 @@ export const nodeTypes = {
validation: memo(ValidationNodeComponent),
action: memo(ActionNodeComponent),
navigation: memo(NavigationNodeComponent),
human_interaction: memo(HumanInteractionNodeComponent),
extraction: memo(ExtractionNodeComponent),
login: memo(LoginNodeComponent),
wait: memo(WaitNodeComponent),

View File

@@ -50,6 +50,7 @@ export const workflowBlockTitle: {
upload_to_s3: "Upload To S3",
file_upload: "Cloud Storage",
validation: "Validation",
human_interaction: "Human Interaction",
wait: "Wait",
pdf_parser: "PDF Parser",
task_v2: "Browser Task v2",

View File

@@ -87,6 +87,17 @@ const nodeLibraryItems: Array<{
title: "Validation Block",
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",
// icon: (

View File

@@ -31,6 +31,7 @@ import {
TextPromptBlockYAML,
UploadToS3BlockYAML,
ValidationBlockYAML,
HumanInteractionBlockYAML,
NavigationBlockYAML,
WorkflowCreateYAMLRequest,
ExtractionBlockYAML,
@@ -87,6 +88,10 @@ import {
isValidationNode,
validationNodeDefaultData,
} from "./nodes/ValidationNode/types";
import {
isHumanInteractionNode,
humanInteractionNodeDefaultData,
} from "./nodes/HumanInteractionNode/types";
import { actionNodeDefaultData, isActionNode } from "./nodes/ActionNode/types";
import {
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": {
return {
...identifiers,
@@ -831,6 +854,17 @@ function createNode(
},
};
}
case "human_interaction": {
return {
...identifiers,
...common,
type: "human_interaction",
data: {
...humanInteractionNodeDefaultData,
label,
},
};
}
case "action": {
return {
...identifiers,
@@ -1105,6 +1139,22 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML {
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": {
return {
...base,
@@ -1342,7 +1392,9 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML {
};
}
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;
}
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": {
const blockYaml: ActionBlockYAML = {
...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);
navigationNodes.forEach((node) => {
if (node.data.navigationGoal.length === 0) {

View File

@@ -200,6 +200,7 @@ export type WorkflowBlock =
| SendEmailBlock
| FileURLParserBlock
| ValidationBlock
| HumanInteractionBlock
| ActionBlock
| NavigationBlock
| ExtractionBlock
@@ -222,6 +223,7 @@ export const WorkflowBlockTypes = {
SendEmail: "send_email",
FileURLParser: "file_url_parser",
Validation: "validation",
HumanInteraction: "human_interaction",
Action: "action",
Navigation: "navigation",
Extraction: "extraction",
@@ -396,6 +398,20 @@ export type ValidationBlock = WorkflowBlockBase & {
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 & {
block_type: "action";
url: string | null;

View File

@@ -127,6 +127,7 @@ export type BlockYAML =
| FileUrlParserBlockYAML
| ForLoopBlockYAML
| ValidationBlockYAML
| HumanInteractionBlockYAML
| ActionBlockYAML
| NavigationBlockYAML
| ExtractionBlockYAML
@@ -185,6 +186,20 @@ export type ValidationBlockYAML = BlockYAMLBase & {
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 & {
block_type: "action";
url: string | null;

View File

@@ -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>
);
}

View File

@@ -4,6 +4,12 @@ import {
CubeIcon,
ExternalLinkIcon,
} 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 { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon";
import {
@@ -20,14 +26,16 @@ import {
ActionItem,
WorkflowRunOverviewActiveElement,
} 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 { useWorkflowQuery } from "../hooks/useWorkflowQuery";
import { ObserverThought } from "../types/workflowRunTypes";
import {
HumanInteractionBlock,
isTaskVariantBlock,
type WorkflowApiResponse,
} from "../types/workflowTypes";
import { WorkflowRunHumanInteraction } from "./WorkflowRunHumanInteraction";
type Props = {
activeItem: WorkflowRunOverviewActiveElement;
block: WorkflowRunBlock;
@@ -37,6 +45,28 @@ type Props = {
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({
activeItem,
block,
@@ -45,6 +75,12 @@ function WorkflowRunTimelineBlockItem({
onActionClick,
onThoughtCardClick,
}: Props) {
const { workflowPermanentId } = useParams();
const { data: workflow } = useWorkflowQuery({
workflowPermanentId,
});
const humanInteractionBlock = getHumanInteractionBlock(block, workflow);
const actions = block.actions ?? [];
const hasActiveAction =
@@ -167,6 +203,12 @@ function WorkflowRunTimelineBlockItem({
) : null}
</div>
{humanInteractionBlock && (
<WorkflowRunHumanInteraction
humanInteractionBlock={humanInteractionBlock}
/>
)}
{actions.map((action, index) => {
return (
<ActionCard