FE implementation of InteractionNode (#3821)
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user