diff --git a/skyvern-frontend/src/components/HelpTooltip.tsx b/skyvern-frontend/src/components/HelpTooltip.tsx index 4ca94b4db..9b2c6cb2e 100644 --- a/skyvern-frontend/src/components/HelpTooltip.tsx +++ b/skyvern-frontend/src/components/HelpTooltip.tsx @@ -15,7 +15,7 @@ function HelpTooltip({ content }: Props) { - + {content} diff --git a/skyvern-frontend/src/components/icons/ClickIcon.tsx b/skyvern-frontend/src/components/icons/ClickIcon.tsx new file mode 100644 index 000000000..0c18bc15b --- /dev/null +++ b/skyvern-frontend/src/components/icons/ClickIcon.tsx @@ -0,0 +1,26 @@ +type Props = { + className?: string; +}; + +function ClickIcon({ className }: Props) { + return ( + + + + ); +} + +export { ClickIcon }; diff --git a/skyvern-frontend/src/components/ui/tooltip.tsx b/skyvern-frontend/src/components/ui/tooltip.tsx index dd030d7ff..aa51e8bff 100644 --- a/skyvern-frontend/src/components/ui/tooltip.tsx +++ b/skyvern-frontend/src/components/ui/tooltip.tsx @@ -13,15 +13,17 @@ const TooltipContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, sideOffset = 4, ...props }, ref) => ( - + + + )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index c9ef9a897..c20b5db5b 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -74,6 +74,7 @@ import { isLoopNode, LoopNode } from "./nodes/LoopNode/types"; import { isTaskNode } from "./nodes/TaskNode/types"; import { useShouldNotifyWhenClosingTab } from "@/hooks/useShouldNotifyWhenClosingTab"; import { isValidationNode } from "./nodes/ValidationNode/types"; +import { isActionNode } from "./nodes/ActionNode/types"; function convertToParametersYAML( parameters: ParametersState, @@ -459,13 +460,28 @@ function FlowRenderer({ const errors: Array = []; const workflowBlockNodes = nodes.filter(isWorkflowBlockNode); - if (workflowBlockNodes[0]!.type === "validation") { + if ( + workflowBlockNodes.length > 0 && + workflowBlockNodes[0]!.type === "validation" + ) { const label = workflowBlockNodes[0]!.data.label; errors.push( - `${label}: Validation block can't be the first block in a workflow`, + `${label}: Validation block can't be the first block in a workflow.`, ); } + const actionNodes = nodes.filter(isActionNode); + actionNodes.forEach((node) => { + if (node.data.navigationGoal.length === 0) { + errors.push(`${node.data.label}: Action Instruction is required.`); + } + try { + JSON.parse(node.data.errorCodeMapping); + } catch { + errors.push(`${node.data.label}: Error messages is not valid JSON.`); + } + }); + // check loop node parameters const loopNodes: Array = nodes.filter(isLoopNode); const emptyLoopNodes = loopNodes.filter( @@ -474,7 +490,7 @@ function FlowRenderer({ if (emptyLoopNodes.length > 0) { emptyLoopNodes.forEach((node) => { errors.push( - `${node.data.label}: Loop value parameter must be selected`, + `${node.data.label}: Loop value parameter must be selected.`, ); }); } @@ -485,12 +501,12 @@ function FlowRenderer({ try { JSON.parse(node.data.dataSchema); } catch { - errors.push(`${node.data.label}: Data schema is not valid JSON`); + errors.push(`${node.data.label}: Data schema is not valid JSON.`); } try { JSON.parse(node.data.errorCodeMapping); } catch { - errors.push(`${node.data.label}: Error messages is not valid JSON`); + errors.push(`${node.data.label}: Error messages is not valid JSON.`); } }); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx new file mode 100644 index 000000000..ab8fd53e4 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx @@ -0,0 +1,318 @@ +import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; +import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler"; +import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; +import { useState } from "react"; +import { EditableNodeTitle } from "../components/EditableNodeTitle"; +import { NodeActionMenu } from "../NodeActionMenu"; +import { helpTooltipContent, type ActionNode } from "./types"; +import { HelpTooltip } from "@/components/HelpTooltip"; +import { Input } from "@/components/ui/input"; +import { fieldPlaceholders } from "./types"; +import { Checkbox } from "@/components/ui/checkbox"; +import { errorMappingExampleValue } from "../types"; +import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; +import { Switch } from "@/components/ui/switch"; +import { ClickIcon } from "@/components/icons/ClickIcon"; + +function ActionNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); + const { editable } = data; + const [label, setLabel] = useNodeLabelChangeHandler({ + id, + initialValue: data.label, + }); + const [inputs, setInputs] = useState({ + navigationGoal: data.navigationGoal, + errorCodeMapping: data.errorCodeMapping, + maxRetries: data.maxRetries, + allowDownloads: data.allowDownloads, + continueOnFailure: data.continueOnFailure, + cacheActions: data.cacheActions, + downloadSuffix: data.downloadSuffix, + totpVerificationUrl: data.totpVerificationUrl, + totpIdentifier: data.totpIdentifier, + }); + const deleteNodeCallback = useDeleteNodeCallback(); + + function handleChange(key: string, value: unknown) { + if (!editable) { + return; + } + setInputs({ ...inputs, [key]: value }); + updateNodeData(id, { [key]: value }); + } + + return ( +
+ + +
+
+
+
+ +
+
+ + Action Block +
+
+ { + deleteNodeCallback(id); + }} + /> +
+
+
+ + +
+ { + if (!editable) { + return; + } + handleChange("navigationGoal", event.target.value); + }} + value={inputs.navigationGoal} + className="nopan text-xs" + /> +
+ + + + + Advanced Settings + + +
+
+
+ + +
+ { + if (!editable) { + return; + } + const value = + event.target.value === "" + ? null + : Number(event.target.value); + handleChange("maxRetries", value); + }} + /> +
+
+
+
+ + +
+ { + if (!editable) { + return; + } + handleChange( + "errorCodeMapping", + checked + ? JSON.stringify(errorMappingExampleValue, null, 2) + : "null", + ); + }} + /> +
+ {inputs.errorCodeMapping !== "null" && ( +
+ { + if (!editable) { + return; + } + handleChange("errorCodeMapping", value); + }} + className="nowheel nopan" + fontSize={8} + /> +
+ )} +
+ +
+
+ + +
+
+ { + if (!editable) { + return; + } + handleChange("continueOnFailure", checked); + }} + /> +
+
+
+
+ + +
+
+ { + if (!editable) { + return; + } + handleChange("cacheActions", checked); + }} + /> +
+
+ +
+
+ + +
+
+ { + if (!editable) { + return; + } + handleChange("allowDownloads", checked); + }} + /> +
+
+
+
+ + +
+ { + if (!editable) { + return; + } + handleChange("downloadSuffix", event.target.value); + }} + /> +
+ +
+
+ + +
+ { + if (!editable) { + return; + } + handleChange("totpVerificationUrl", event.target.value); + }} + value={inputs.totpVerificationUrl ?? ""} + placeholder={fieldPlaceholders["totpVerificationUrl"]} + className="nopan text-xs" + /> +
+
+
+ + +
+ { + if (!editable) { + return; + } + handleChange("totpIdentifier", event.target.value); + }} + value={inputs.totpIdentifier ?? ""} + placeholder={fieldPlaceholders["totpIdentifier"]} + className="nopan text-xs" + /> +
+
+
+
+
+
+
+ ); +} + +export { ActionNode }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/types.ts new file mode 100644 index 000000000..e564c98be --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/types.ts @@ -0,0 +1,68 @@ +import type { Node } from "@xyflow/react"; +import { NodeBaseData } from "../types"; + +export type ActionNodeData = NodeBaseData & { + url: string; + navigationGoal: string; + errorCodeMapping: string; + maxRetries: number | null; + allowDownloads: boolean; + downloadSuffix: string | null; + parameterKeys: Array; + totpVerificationUrl: string | null; + totpIdentifier: string | null; + cacheActions: boolean; +}; + +export type ActionNode = Node; + +export const actionNodeDefaultData: ActionNodeData = { + label: "", + url: "", + navigationGoal: "", + errorCodeMapping: "null", + maxRetries: null, + allowDownloads: false, + downloadSuffix: null, + editable: true, + parameterKeys: [], + totpVerificationUrl: null, + totpIdentifier: null, + continueOnFailure: false, + cacheActions: false, +} as const; + +export function isActionNode(node: Node): node is ActionNode { + return node.type === "action"; +} + +export const helpTooltipContent = { + navigationGoal: + "Specify a single step or action you'd like Skyvern to complete. Actions are one-off tasks like filling a field or interacting with a specific element on the page.\n\nCurrently supported actions are click, input text, upload file, and select.", + maxRetries: + "Specify how many times you would like a task to retry upon failure.", + maxStepsOverride: + "Specify the maximum number of steps a task can take in total.", + completeOnDownload: + "Allow Skyvern to auto-complete the task when it downloads a file.", + fileSuffix: + "A file suffix that's automatically added to all downloaded files.", + errorCodeMapping: + "Knowing about why a task terminated can be important, specify error messages here.", + totpVerificationUrl: + "If you have an internal system for storing TOTP codes, link the endpoint here.", + totpIdentifier: + "If you are running multiple tasks or workflows at once, you will need to give the task an identifier to know that this TOTP goes with this task.", + continueOnFailure: + "Allow the workflow to continue if it encounters a failure.", + cacheActions: "Cache the actions of this task.", +} as const; + +export const fieldPlaceholders = { + navigationGoal: 'Input text into "Name" field.', + maxRetries: "Default: 3", + maxStepsOverride: "Default: 10", + downloadSuffix: "Add an ID for downloaded files", + totpVerificationUrl: "Provide your 2FA endpoint", + totpIdentifier: "Add an ID that links your TOTP to the task", +}; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx index 9f13d5d4c..cccab6698 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx @@ -228,7 +228,7 @@ function TaskNode({ id, data }: NodeProps) { Advanced Settings - +
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts index 8ffe6b9fd..82222e963 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts @@ -21,6 +21,8 @@ import { StartNode as StartNodeComponent } from "./StartNode/StartNode"; import type { StartNode } from "./StartNode/types"; import type { ValidationNode } from "./ValidationNode/types"; import { ValidationNode as ValidationNodeComponent } from "./ValidationNode/ValidationNode"; +import type { ActionNode } from "./ActionNode/types"; +import { ActionNode as ActionNodeComponent } from "./ActionNode/ActionNode"; export type UtilityNode = StartNode | NodeAdderNode; @@ -33,7 +35,8 @@ export type WorkflowBlockNode = | FileParserNode | UploadNode | DownloadNode - | ValidationNode; + | ValidationNode + | ActionNode; export function isUtilityNode(node: AppNode): node is UtilityNode { return node.type === "nodeAdder" || node.type === "start"; @@ -57,4 +60,5 @@ export const nodeTypes = { nodeAdder: memo(NodeAdderNodeComponent), start: memo(StartNodeComponent), validation: memo(ValidationNodeComponent), + action: memo(ActionNodeComponent), }; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx index 43056e3d0..35661f7d7 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx @@ -12,6 +12,7 @@ import { } from "@radix-ui/react-icons"; import { WorkflowBlockNode } from "../nodes"; import { AddNodeProps } from "../FlowRenderer"; +import { ClickIcon } from "@/components/icons/ClickIcon"; const nodeLibraryItems: Array<{ nodeType: NonNullable; @@ -75,6 +76,12 @@ const nodeLibraryItems: Array<{ title: "Validation Block", description: "Validate the state of the workflow or terminate", }, + { + nodeType: "action", + icon: , + title: "Action Block", + description: "Take a single action", + }, ]; type Props = { diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index cfe05fb4c..ceb9454b3 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -11,6 +11,7 @@ import type { WorkflowParameterValueType, } from "../types/workflowTypes"; import { + ActionBlockYAML, BlockYAML, CodeBlockYAML, DownloadToS3BlockYAML, @@ -50,6 +51,7 @@ import { textPromptNodeDefaultData } from "./nodes/TextPromptNode/types"; import { NodeBaseData } from "./nodes/types"; import { uploadNodeDefaultData } from "./nodes/UploadNode/types"; import { validationNodeDefaultData } from "./nodes/ValidationNode/types"; +import { actionNodeDefaultData } from "./nodes/ActionNode/types"; export const NEW_NODE_LABEL_PREFIX = "block_"; @@ -175,6 +177,26 @@ function convertToNode( }, }; } + case "action": { + return { + ...identifiers, + ...common, + type: "action", + data: { + ...commonData, + url: block.url ?? "", + navigationGoal: block.navigation_goal ?? "", + errorCodeMapping: JSON.stringify(block.error_code_mapping, null, 2), + allowDownloads: block.complete_on_download ?? false, + downloadSuffix: block.download_suffix ?? null, + maxRetries: block.max_retries ?? null, + parameterKeys: block.parameters.map((p) => p.key), + totpIdentifier: block.totp_identifier ?? null, + totpVerificationUrl: block.totp_verification_url ?? null, + cacheActions: block.cache_actions, + }, + }; + } case "code": { return { ...identifiers, @@ -476,6 +498,17 @@ function createNode( }, }; } + case "action": { + return { + ...identifiers, + ...common, + type: "action", + data: { + ...actionNodeDefaultData, + label, + }, + }; + } case "loop": { return { ...identifiers, @@ -601,11 +634,32 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML { block_type: "validation", complete_criterion: node.data.completeCriterion, terminate_criterion: node.data.terminateCriterion, + error_code_mapping: JSONParseSafe(node.data.errorCodeMapping) as Record< + string, + string + > | null, parameter_keys: node.data.parameterKeys, + }; + } + case "action": { + return { + ...base, + block_type: "action", + navigation_goal: node.data.navigationGoal, error_code_mapping: JSONParseSafe(node.data.errorCodeMapping) as Record< string, string > | null, + url: node.data.url, + ...(node.data.maxRetries !== null && { + max_retries: node.data.maxRetries, + }), + complete_on_download: node.data.allowDownloads, + download_suffix: node.data.downloadSuffix, + parameter_keys: node.data.parameterKeys, + totp_identifier: node.data.totpIdentifier, + totp_verification_url: node.data.totpVerificationUrl, + cache_actions: node.data.cacheActions, }; } case "sendEmail": { @@ -1089,6 +1143,23 @@ function convertBlocks(blocks: Array): Array { }; return blockYaml; } + case "action": { + const blockYaml: ActionBlockYAML = { + ...base, + block_type: "action", + url: block.url, + navigation_goal: block.navigation_goal, + error_code_mapping: block.error_code_mapping, + max_retries: block.max_retries, + complete_on_download: block.complete_on_download, + download_suffix: block.download_suffix, + parameter_keys: block.parameters.map((p) => p.key), + totp_identifier: block.totp_identifier, + totp_verification_url: block.totp_verification_url, + cache_actions: block.cache_actions, + }; + return blockYaml; + } case "for_loop": { const blockYaml: ForLoopBlockYAML = { ...base, diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts index d20345feb..fbdcf9352 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -110,6 +110,7 @@ export const WorkflowBlockType = { UploadToS3: "upload_to_s3", SendEmail: "send_email", FileURLParser: "file_url_parser", + Validation: "validation", }; export type WorkflowBlockType = @@ -197,6 +198,11 @@ export type ValidationBlock = WorkflowBlockBase & { parameters: Array; }; +export type ActionBlock = Omit & { + block_type: "action"; + parameters: Array; +}; + export type WorkflowBlock = | TaskBlock | ForLoopBlock @@ -206,7 +212,8 @@ export type WorkflowBlock = | DownloadToS3Block | SendEmailBlock | FileURLParserBlock - | ValidationBlock; + | ValidationBlock + | ActionBlock; export type WorkflowDefinition = { parameters: Array; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts index 7940c7c9c..deb440b9e 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts @@ -76,6 +76,7 @@ const BlockTypes = { SEND_EMAIL: "send_email", FILE_URL_PARSER: "file_url_parser", VALIDATION: "validation", + ACTION: "action", } as const; export type BlockType = (typeof BlockTypes)[keyof typeof BlockTypes]; @@ -89,7 +90,8 @@ export type BlockYAML = | SendEmailBlockYAML | FileUrlParserBlockYAML | ForLoopBlockYAML - | ValidationBlockYAML; + | ValidationBlockYAML + | ActionBlockYAML; export type BlockYAMLBase = { block_type: BlockType; @@ -123,6 +125,20 @@ export type ValidationBlockYAML = BlockYAMLBase & { parameter_keys?: Array | null; }; +export type ActionBlockYAML = BlockYAMLBase & { + block_type: "action"; + url: string | null; + navigation_goal: string | null; + error_code_mapping: Record | null; + max_retries?: number; + parameter_keys?: Array | null; + complete_on_download?: boolean; + download_suffix?: string | null; + totp_verification_url?: string | null; + totp_identifier?: string | null; + cache_actions: boolean; +}; + export type CodeBlockYAML = BlockYAMLBase & { block_type: "code"; code: string;