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,108 +95,42 @@ 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> <div className="space-y-4 rounded-md bg-slate-800 p-4">
<Separator /> <h2>Email Settings</h2>
<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"> <div className="space-y-2">
<Label className="text-xs text-slate-300">Recipients</Label> <Label className="text-xs text-slate-300">Recipients</Label>
<WorkflowBlockInput <WorkflowBlockInput
@@ -234,6 +168,59 @@ function HumanInteractionNode({
/> />
</div> </div>
</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">
Advanced Settings
</AccordionTrigger>
<AccordionContent className="pl-6 pr-1 pt-1">
<div key={rerender.key} className="space-y-4 pt-4">
<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>
<WorkflowBlockInput
nodeId={id}
onChange={(value) => {
update({ negativeDescriptor: 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>
<WorkflowBlockInput
nodeId={id}
onChange={(value) => {
update({ positiveDescriptor: value });
}}
value={data.positiveDescriptor}
placeholder="Approve"
className="nopan text-xs"
/>
</div>
</div>
</div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>

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) => {