From a2d9b05bdaf4192c1ba0847135d2b0e2ca3e4707 Mon Sep 17 00:00:00 2001 From: Shuchang Zheng Date: Wed, 27 Nov 2024 05:06:25 -0800 Subject: [PATCH] Add download node (#1273) --- .../FileDownloadNode/FileDownloadNode.tsx | 366 ++++++++++++++++++ .../editor/nodes/FileDownloadNode/types.ts | 37 ++ .../routes/workflows/editor/nodes/index.ts | 6 +- .../panels/WorkflowNodeLibraryPanel.tsx | 7 + .../workflows/editor/workflowEditorUtils.ts | 73 ++++ .../routes/workflows/types/workflowTypes.ts | 19 +- .../workflows/types/workflowYamlTypes.ts | 19 +- 7 files changed, 524 insertions(+), 3 deletions(-) create mode 100644 skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx create mode 100644 skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/types.ts diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx new file mode 100644 index 00000000..305565fb --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx @@ -0,0 +1,366 @@ +import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; +import { HelpTooltip } from "@/components/HelpTooltip"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; +import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; +import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler"; +import { DownloadIcon } from "@radix-ui/react-icons"; +import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; +import { useState } from "react"; +import { + commonFieldPlaceholders, + commonHelpTooltipContent, +} from "../../constants"; +import { EditableNodeTitle } from "../components/EditableNodeTitle"; +import { NodeActionMenu } from "../NodeActionMenu"; +import { errorMappingExampleValue } from "../types"; +import type { FileDownloadNode } from "./types"; + +const urlTooltip = + "The URL Skyvern is navigating to. Leave this field blank to pick up from where the last block left off."; +const urlPlaceholder = "https://"; +const navigationGoalTooltip = + "Give Skyvern an objective that describes how to download the file."; +const navigationGoalPlaceholder = "Tell Skyvern which file to download."; + +function FileDownloadNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); + const { editable } = data; + const [label, setLabel] = useNodeLabelChangeHandler({ + id, + initialValue: data.label, + }); + const [inputs, setInputs] = useState({ + url: data.url, + navigationGoal: data.navigationGoal, + errorCodeMapping: data.errorCodeMapping, + maxRetries: data.maxRetries, + maxStepsOverride: data.maxStepsOverride, + 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 ( +
+ + +
+
+
+
+ +
+
+ + + File Download Block + +
+
+ { + deleteNodeCallback(id); + }} + /> +
+
+
+
+ + +
+ { + if (!editable) { + return; + } + handleChange("url", event.target.value); + }} + value={inputs.url} + placeholder={urlPlaceholder} + className="nopan text-xs" + /> +
+
+
+ + +
+ { + if (!editable) { + return; + } + handleChange("navigationGoal", event.target.value); + }} + value={inputs.navigationGoal} + placeholder={navigationGoalPlaceholder} + className="nopan text-xs" + /> +
+
+ Once the file is downloaded, this block will complete. +
+
+ + + + + Advanced Settings + + +
+
+
+ + +
+ { + if (!editable) { + return; + } + const value = + event.target.value === "" + ? null + : Number(event.target.value); + handleChange("maxRetries", value); + }} + /> +
+
+
+ + +
+ { + if (!editable) { + return; + } + const value = + event.target.value === "" + ? null + : Number(event.target.value); + handleChange("maxStepsOverride", 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("downloadSuffix", event.target.value); + }} + /> +
+ +
+
+ + +
+ { + if (!editable) { + return; + } + handleChange("totpVerificationUrl", event.target.value); + }} + value={inputs.totpVerificationUrl ?? ""} + placeholder={commonFieldPlaceholders["totpVerificationUrl"]} + className="nopan text-xs" + /> +
+
+
+ + +
+ { + if (!editable) { + return; + } + handleChange("totpIdentifier", event.target.value); + }} + value={inputs.totpIdentifier ?? ""} + placeholder={commonFieldPlaceholders["totpIdentifier"]} + className="nopan text-xs" + /> +
+
+
+
+
+
+
+ ); +} + +export { FileDownloadNode }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/types.ts new file mode 100644 index 00000000..73eb18e2 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/types.ts @@ -0,0 +1,37 @@ +import type { Node } from "@xyflow/react"; +import { NodeBaseData } from "../types"; + +export type FileDownloadNodeData = NodeBaseData & { + url: string; + navigationGoal: string; + errorCodeMapping: string; + maxRetries: number | null; + maxStepsOverride: number | null; + downloadSuffix: string | null; + parameterKeys: Array; + totpVerificationUrl: string | null; + totpIdentifier: string | null; + cacheActions: boolean; +}; + +export type FileDownloadNode = Node; + +export const fileDownloadNodeDefaultData: FileDownloadNodeData = { + label: "", + url: "", + navigationGoal: "", + errorCodeMapping: "null", + maxRetries: null, + maxStepsOverride: null, + downloadSuffix: null, + editable: true, + parameterKeys: [], + totpVerificationUrl: null, + totpIdentifier: null, + continueOnFailure: false, + cacheActions: false, +} as const; + +export function isFileDownloadNode(node: Node): node is FileDownloadNode { + return node.type === "fileDownload"; +} diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts index 00d4f23b..ea590ac5 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts @@ -31,6 +31,8 @@ import { LoginNode } from "./LoginNode/types"; import { LoginNode as LoginNodeComponent } from "./LoginNode/LoginNode"; import { WaitNode } from "./WaitNode/types"; import { WaitNode as WaitNodeComponent } from "./WaitNode/WaitNode"; +import { FileDownloadNode } from "./FileDownloadNode/types"; +import { FileDownloadNode as FileDownloadNodeComponent } from "./FileDownloadNode/FileDownloadNode"; export type UtilityNode = StartNode | NodeAdderNode; @@ -48,7 +50,8 @@ export type WorkflowBlockNode = | NavigationNode | ExtractionNode | LoginNode - | WaitNode; + | WaitNode + | FileDownloadNode; export function isUtilityNode(node: AppNode): node is UtilityNode { return node.type === "nodeAdder" || node.type === "start"; @@ -77,4 +80,5 @@ export const nodeTypes = { extraction: memo(ExtractionNodeComponent), login: memo(LoginNodeComponent), wait: memo(WaitNodeComponent), + fileDownload: memo(FileDownloadNodeComponent), } as const; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx index 5a41767e..81ce6ca9 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx @@ -3,6 +3,7 @@ import { CheckCircledIcon, Cross2Icon, CursorTextIcon, + DownloadIcon, EnvelopeClosedIcon, FileIcon, ListBulletIcon, @@ -111,6 +112,12 @@ const nodeLibraryItems: Array<{ title: "Wait Block", description: "Wait for some time", }, + { + nodeType: "fileDownload", + icon: , + title: "File Download Block", + description: "Download a file", + }, ]; type Props = { diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index f53cfa72..247c27b7 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -29,6 +29,7 @@ import { ExtractionBlockYAML, LoginBlockYAML, WaitBlockYAML, + FileDownloadBlockYAML, } from "../types/workflowYamlTypes"; import { EMAIL_BLOCK_SENDER, @@ -79,6 +80,7 @@ import { } from "./nodes/ExtractionNode/types"; import { loginNodeDefaultData } from "./nodes/LoginNode/types"; import { waitNodeDefaultData } from "./nodes/WaitNode/types"; +import { fileDownloadNodeDefaultData } from "./nodes/FileDownloadNode/types"; import { ProxyLocation } from "@/api/types"; export const NEW_NODE_LABEL_PREFIX = "block_"; @@ -293,6 +295,26 @@ function convertToNode( }, }; } + case "file_download": { + return { + ...identifiers, + ...common, + type: "fileDownload", + data: { + ...commonData, + url: block.url ?? "", + navigationGoal: block.navigation_goal ?? "", + errorCodeMapping: JSON.stringify(block.error_code_mapping, null, 2), + 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, + maxStepsOverride: block.max_steps_per_run ?? null, + }, + }; + } case "code": { return { ...identifiers, @@ -667,6 +689,17 @@ function createNode( }, }; } + case "fileDownload": { + return { + ...identifiers, + ...common, + type: "fileDownload", + data: { + ...fileDownloadNodeDefaultData, + label, + }, + }; + } case "loop": { return { ...identifiers, @@ -888,6 +921,28 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML { wait_sec: node.data.waitInSeconds, }; } + case "fileDownload": { + return { + ...base, + block_type: "file_download", + title: node.data.label, + 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, + }), + max_steps_per_run: node.data.maxStepsOverride, + 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": { return { ...base, @@ -1473,6 +1528,24 @@ function convertBlocksToBlockYAML( }; return blockYaml; } + case "file_download": { + const blockYaml: FileDownloadBlockYAML = { + ...base, + block_type: "file_download", + url: block.url, + title: block.title, + navigation_goal: block.navigation_goal, + error_code_mapping: block.error_code_mapping, + max_retries: block.max_retries, + max_steps_per_run: block.max_steps_per_run, + 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 caa87303..9d65bc89 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -117,7 +117,8 @@ export type WorkflowBlock = | NavigationBlock | ExtractionBlock | LoginBlock - | WaitBlock; + | WaitBlock + | FileDownloadBlock; export const WorkflowBlockType = { Task: "task", @@ -134,6 +135,7 @@ export const WorkflowBlockType = { Extraction: "extraction", Login: "login", Wait: "wait", + FileDownload: "file_download", } as const; export type WorkflowBlockType = @@ -284,6 +286,21 @@ export type WaitBlock = WorkflowBlockBase & { wait_sec?: number; }; +export type FileDownloadBlock = WorkflowBlockBase & { + block_type: "file_download"; + url: string | null; + title: string; + navigation_goal: string | null; + error_code_mapping: Record | null; + max_retries?: number; + max_steps_per_run?: number | null; + download_suffix?: string | null; + parameters: Array; + totp_verification_url?: string | null; + totp_identifier?: string | null; + cache_actions: boolean; +}; + export type WorkflowDefinition = { parameters: Array; blocks: Array; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts index 648ff8ca..6e74f878 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts @@ -82,6 +82,7 @@ const BlockTypes = { EXTRACTION: "extraction", LOGIN: "login", WAIT: "wait", + FILE_DOWNLOAD: "file_download", } as const; export type BlockType = (typeof BlockTypes)[keyof typeof BlockTypes]; @@ -100,7 +101,8 @@ export type BlockYAML = | NavigationBlockYAML | ExtractionBlockYAML | LoginBlockYAML - | WaitBlockYAML; + | WaitBlockYAML + | FileDownloadBlockYAML; export type BlockYAMLBase = { block_type: BlockType; @@ -196,6 +198,21 @@ export type WaitBlockYAML = BlockYAMLBase & { wait_sec?: number; }; +export type FileDownloadBlockYAML = BlockYAMLBase & { + block_type: "file_download"; + url: string | null; + title?: string; + navigation_goal: string | null; + error_code_mapping: Record | null; + max_retries?: number; + max_steps_per_run?: number | null; + parameter_keys?: Array | null; + 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;