various hitl buffs (#3828)

This commit is contained in:
Jonathan Dobson
2025-10-27 16:14:52 -04:00
committed by GitHub
parent 4bb9a650cc
commit c12c047768
5 changed files with 171 additions and 157 deletions

View File

@@ -95,94 +95,79 @@ function HumanInteractionNode({
</div> </div>
{/* TODO(jdo): 'instructions' allows templating; but it requires adding a column to the workflow_block_runs {/* 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) */} table, and I don't want to do that just yet (see /timeline endpoint) */}
<Input <WorkflowBlockInput
onChange={(event) => { nodeId={id}
update({ instructions: event.target.value }); onChange={(value) => {
update({ instructions: value });
}} }}
value={data.instructions} value={data.instructions}
placeholder="Please review and approve or reject to continue the workflow." placeholder="Please review and approve or reject to continue the workflow."
className="nopan text-xs" className="nopan text-xs"
/> />
</div> </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"> <div className="space-between flex items-center gap-2">
<Label className="text-xs text-slate-300">Timeout (seconds)</Label> <Label className="text-xs text-slate-300">Timeout (minutes)</Label>
<HelpTooltip content={timeoutTooltip} /> <HelpTooltip content={timeoutTooltip} />
<Input <Input
className="ml-auto w-16 text-right" className="ml-auto w-16 text-right"
value={data.timeoutSeconds} value={data.timeoutSeconds / 60}
placeholder="7200" placeholder="120"
onChange={(event) => { onChange={(event) => {
if (!editable) { if (!editable) {
return; return;
} }
const value = Number(event.target.value); const value = Number(event.target.value);
update({ timeoutSeconds: value }); update({ timeoutSeconds: value * 60 });
}} }}
/> />
</div> </div>
<div className="rounded-md bg-slate-800 p-2"> <div className="flex items-center justify-center gap-2 rounded-md bg-slate-800 p-2">
<span className="rounded bg-slate-700 p-1 text-lg">💡</span>
<div className="space-y-1 text-xs text-slate-400"> <div className="space-y-1 text-xs text-slate-400">
Tip: The workflow will pause and send an email notification to the The workflow will pause and send an email notification to the
recipients. The workflow continues or terminates based on the recipients. The workflow continues or terminates based on the
user's response. user's response.
</div> </div>
</div> </div>
<div className="space-y-4 rounded-md bg-slate-800 p-4">
<h2>Email Settings</h2>
<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>
</div> </div>
<Separator /> <Separator />
<Accordion <Accordion
@@ -194,44 +179,46 @@ function HumanInteractionNode({
collapsible collapsible
> >
<AccordionItem value="email" className="border-b-0"> <AccordionItem value="email" className="border-b-0">
<AccordionTrigger className="py-0">Email Settings</AccordionTrigger> <AccordionTrigger className="py-0">
Advanced Settings
</AccordionTrigger>
<AccordionContent className="pl-6 pr-1 pt-1"> <AccordionContent className="pl-6 pr-1 pt-1">
<div key={rerender.key} className="space-y-4"> <div key={rerender.key} className="space-y-4 pt-4">
<div className="space-y-2"> <div className="flex gap-4">
<Label className="text-xs text-slate-300">Recipients</Label> <div className="flex-1 space-y-2">
<WorkflowBlockInput <div className="flex gap-2">
nodeId={id} <Label className="text-xs text-slate-300">
onChange={(value) => { Negative Button Label
update({ recipients: value }); </Label>
}} <HelpTooltip content={negativeDescriptorTooltip} />
value={data.recipients} </div>
placeholder="example@gmail.com, example2@gmail.com..." <WorkflowBlockInput
className="nopan text-xs" nodeId={id}
/> onChange={(value) => {
</div> update({ negativeDescriptor: value });
<div className="space-y-2"> }}
<Label className="text-xs text-slate-300">Subject</Label> value={data.negativeDescriptor}
<WorkflowBlockInput placeholder="Reject"
nodeId={id} className="nopan text-xs"
onChange={(value) => { />
update({ subject: value }); </div>
}} <div className="flex-1 space-y-2">
value={data.subject} <div className="flex gap-2">
placeholder="Human interaction required for workflow run" <Label className="text-xs text-slate-300">
className="nopan text-xs" Positive Button Label
/> </Label>
</div> <HelpTooltip content={positiveDescriptorTooltip} />
<div className="space-y-2"> </div>
<Label className="text-xs text-slate-300">Body</Label> <WorkflowBlockInput
<WorkflowBlockInputTextarea nodeId={id}
nodeId={id} onChange={(value) => {
onChange={(value) => { update({ positiveDescriptor: value });
update({ body: value }); }}
}} value={data.positiveDescriptor}
value={data.body} placeholder="Approve"
placeholder="Your interaction is required for a workflow run!" className="nopan text-xs"
className="nopan text-xs" />
/> </div>
</div> </div>
</div> </div>
</AccordionContent> </AccordionContent>

View File

@@ -2318,8 +2318,10 @@ function getWorkflowErrors(nodes: Array<AppNode>): Array<string> {
}); });
const interactionNodes = nodes.filter(isHumanInteractionNode); const interactionNodes = nodes.filter(isHumanInteractionNode);
interactionNodes.forEach((/* node */) => { interactionNodes.forEach((node) => {
// pass for now if (node.data.recipients.trim().length === 0) {
errors.push(`${node.data.label}: Recipients is required.`);
}
}); });
const navigationNodes = nodes.filter(isNavigationNode); const navigationNodes = nodes.filter(isNavigationNode);

View File

@@ -58,6 +58,11 @@ export type WorkflowRunBlock = {
// for blocks in loop // for blocks in loop
current_value: string | null; current_value: string | null;
current_index: number | null; current_index: number | null;
// human interaction block
instructions?: string | null;
positive_descriptor?: string | null;
negative_descriptor?: string | null;
}; };
export type WorkflowRunTimelineBlockItem = { export type WorkflowRunTimelineBlockItem = {

View File

@@ -4,16 +4,26 @@ import { Button } from "@/components/ui/button";
import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "@/components/ui/use-toast"; import { toast } from "@/components/ui/use-toast";
import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery";
import { HumanInteractionBlock } from "../types/workflowTypes"; import { WorkflowRunBlock } from "../types/workflowRunTypes";
interface Props { interface Props {
humanInteractionBlock: HumanInteractionBlock; workflowRunBlock: WorkflowRunBlock;
} }
export function WorkflowRunHumanInteraction({ humanInteractionBlock }: Props) { export function WorkflowRunHumanInteraction({ workflowRunBlock }: Props) {
const credentialGetter = useCredentialGetter(); const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: workflowRun } = useWorkflowRunQuery(); const { data: workflowRun } = useWorkflowRunQuery();
@@ -21,11 +31,14 @@ export function WorkflowRunHumanInteraction({ humanInteractionBlock }: Props) {
workflowRun && workflowRun.status === WorkflowRunStatus.Paused; workflowRun && workflowRun.status === WorkflowRunStatus.Paused;
const buttonLayout = const buttonLayout =
humanInteractionBlock.positive_descriptor.length < 8 && (workflowRunBlock.positive_descriptor?.length ?? 0) < 8 &&
humanInteractionBlock.negative_descriptor.length < 8 (workflowRunBlock.negative_descriptor?.length ?? 0) < 8
? "inline" ? "inline"
: "stacked"; : "stacked";
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [choice, setChoice] = useState<"approve" | "reject" | null>(null);
const approveMutation = useMutation({ const approveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!workflowRun) { if (!workflowRun) {
@@ -45,8 +58,8 @@ export function WorkflowRunHumanInteraction({ humanInteractionBlock }: Props) {
toast({ toast({
variant: "success", variant: "success",
title: `${humanInteractionBlock.positive_descriptor}`, title: `${workflowRunBlock.positive_descriptor}`,
description: `Successfully chose: ${humanInteractionBlock.positive_descriptor}`, description: `Successfully chose: ${workflowRunBlock.positive_descriptor}`,
}); });
}, },
onError: (error) => { onError: (error) => {
@@ -77,8 +90,8 @@ export function WorkflowRunHumanInteraction({ humanInteractionBlock }: Props) {
toast({ toast({
variant: "success", variant: "success",
title: `${humanInteractionBlock.negative_descriptor}`, title: `${workflowRunBlock.negative_descriptor}`,
description: `Successfully chose: ${humanInteractionBlock.negative_descriptor}`, description: `Successfully chose: ${workflowRunBlock.negative_descriptor}`,
}); });
}, },
onError: (error) => { onError: (error) => {
@@ -96,18 +109,60 @@ export function WorkflowRunHumanInteraction({ humanInteractionBlock }: Props) {
return ( return (
<div className="mt-4 flex flex-col gap-4 rounded-md bg-slate-elevation4 p-4"> <div className="mt-4 flex flex-col gap-4 rounded-md bg-slate-elevation4 p-4">
<div className="text-sm">{humanInteractionBlock.instructions}</div> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{choice === "approve"
? workflowRunBlock.positive_descriptor
: workflowRunBlock.negative_descriptor}
</DialogTitle>
<DialogDescription>Are you sure?</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Back</Button>
</DialogClose>
<Button
variant={choice === "reject" ? "destructive" : "default"}
onClick={() => {
if (choice === "approve") {
approveMutation.mutate();
} else if (choice === "reject") {
rejectMutation.mutate();
}
}}
>
Proceed
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="text-sm">{workflowRunBlock.instructions}</div>
<div <div
className={cn("flex gap-2", { className={cn("flex gap-2", {
"justify-between": buttonLayout === "inline", "justify-between": buttonLayout === "inline",
"flex-col": buttonLayout === "stacked", "flex-col": buttonLayout === "stacked",
})} })}
> >
<Button variant="destructive" onClick={() => rejectMutation.mutate()}> <Button
<div>{humanInteractionBlock.negative_descriptor}</div> variant="destructive"
onClick={() => {
setChoice("reject");
setIsDialogOpen(true);
}}
>
<div>{workflowRunBlock.negative_descriptor}</div>
</Button> </Button>
<Button variant="default" onClick={() => approveMutation.mutate()}> <Button
<div>{humanInteractionBlock.positive_descriptor}</div> variant="default"
onClick={() => {
setChoice("approve");
setIsDialogOpen(true);
}}
>
<div>{workflowRunBlock.positive_descriptor}</div>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@ import {
ExternalLinkIcon, ExternalLinkIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { useCallback } from "react"; import { useCallback } from "react";
import { useParams, Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Status } from "@/api/types"; import { Status } from "@/api/types";
import { formatDuration, toDuration } from "@/routes/workflows/utils"; import { formatDuration, toDuration } from "@/routes/workflows/utils";
@@ -27,13 +27,8 @@ import {
WorkflowRunOverviewActiveElement, WorkflowRunOverviewActiveElement,
} from "./WorkflowRunOverview"; } from "./WorkflowRunOverview";
import { ThoughtCard } from "./ThoughtCard"; import { ThoughtCard } from "./ThoughtCard";
import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
import { ObserverThought } from "../types/workflowRunTypes"; import { ObserverThought } from "../types/workflowRunTypes";
import { import { isTaskVariantBlock } from "../types/workflowTypes";
HumanInteractionBlock,
isTaskVariantBlock,
type WorkflowApiResponse,
} from "../types/workflowTypes";
import { WorkflowRunHumanInteraction } from "./WorkflowRunHumanInteraction"; import { WorkflowRunHumanInteraction } from "./WorkflowRunHumanInteraction";
type Props = { type Props = {
@@ -45,28 +40,6 @@ 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,
@@ -75,12 +48,6 @@ 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 =
@@ -203,10 +170,8 @@ function WorkflowRunTimelineBlockItem({
) : null} ) : null}
</div> </div>
{humanInteractionBlock && ( {block.block_type === "human_interaction" && (
<WorkflowRunHumanInteraction <WorkflowRunHumanInteraction workflowRunBlock={block} />
humanInteractionBlock={humanInteractionBlock}
/>
)} )}
{actions.map((action, index) => { {actions.map((action, index) => {