Add download node (#1273)

This commit is contained in:
Shuchang Zheng
2024-11-27 05:06:25 -08:00
committed by GitHub
parent 8760b967fd
commit a2d9b05bda
7 changed files with 524 additions and 3 deletions

View File

@@ -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<FileDownloadNode>) {
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 (
<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="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
<header className="flex h-[2.75rem] justify-between">
<div className="flex gap-2">
<div className="flex h-[2.75rem] w-[2.75rem] items-center justify-center rounded border border-slate-600">
<DownloadIcon className="size-6" />
</div>
<div className="flex flex-col gap-1">
<EditableNodeTitle
value={label}
editable={editable}
onChange={setLabel}
titleClassName="text-base"
inputClassName="text-base"
/>
<span className="text-xs text-slate-400">
File Download Block
</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</header>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">URL</Label>
<HelpTooltip content={urlTooltip} />
</div>
<AutoResizingTextarea
onChange={(event) => {
if (!editable) {
return;
}
handleChange("url", event.target.value);
}}
value={inputs.url}
placeholder={urlPlaceholder}
className="nopan text-xs"
/>
</div>
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">Download Goal</Label>
<HelpTooltip content={navigationGoalTooltip} />
</div>
<AutoResizingTextarea
onChange={(event) => {
if (!editable) {
return;
}
handleChange("navigationGoal", event.target.value);
}}
value={inputs.navigationGoal}
placeholder={navigationGoalPlaceholder}
className="nopan text-xs"
/>
</div>
<div className="rounded-md bg-slate-800 p-2 text-xs text-slate-400">
Once the file is downloaded, this block will complete.
</div>
</div>
<Separator />
<Accordion type="single" collapsible>
<AccordionItem value="advanced" className="border-b-0">
<AccordionTrigger className="py-0">
Advanced Settings
</AccordionTrigger>
<AccordionContent className="pl-6 pr-1 pt-1">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Max Retries
</Label>
<HelpTooltip
content={commonHelpTooltipContent["maxRetries"]}
/>
</div>
<Input
type="number"
placeholder={commonFieldPlaceholders["maxRetries"]}
className="nopan w-52 text-xs"
min="0"
value={inputs.maxRetries ?? ""}
onChange={(event) => {
if (!editable) {
return;
}
const value =
event.target.value === ""
? null
: Number(event.target.value);
handleChange("maxRetries", value);
}}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Max Steps Override
</Label>
<HelpTooltip
content={commonHelpTooltipContent["maxStepsOverride"]}
/>
</div>
<Input
type="number"
placeholder={commonFieldPlaceholders["maxStepsOverride"]}
className="nopan w-52 text-xs"
min="0"
value={inputs.maxStepsOverride ?? ""}
onChange={(event) => {
if (!editable) {
return;
}
const value =
event.target.value === ""
? null
: Number(event.target.value);
handleChange("maxStepsOverride", value);
}}
/>
</div>
<div className="space-y-2">
<div className="flex gap-4">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Error Messages
</Label>
<HelpTooltip
content={commonHelpTooltipContent["errorCodeMapping"]}
/>
</div>
<Checkbox
checked={inputs.errorCodeMapping !== "null"}
disabled={!editable}
onCheckedChange={(checked) => {
if (!editable) {
return;
}
handleChange(
"errorCodeMapping",
checked
? JSON.stringify(errorMappingExampleValue, null, 2)
: "null",
);
}}
/>
</div>
{inputs.errorCodeMapping !== "null" && (
<div>
<CodeEditor
language="json"
value={inputs.errorCodeMapping}
onChange={(value) => {
if (!editable) {
return;
}
handleChange("errorCodeMapping", value);
}}
className="nowheel nopan"
fontSize={8}
/>
</div>
)}
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Continue on Failure
</Label>
<HelpTooltip
content={commonHelpTooltipContent["continueOnFailure"]}
/>
</div>
<div className="w-52">
<Switch
checked={inputs.continueOnFailure}
onCheckedChange={(checked) => {
if (!editable) {
return;
}
handleChange("continueOnFailure", checked);
}}
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Cache Actions
</Label>
<HelpTooltip
content={commonHelpTooltipContent["cacheActions"]}
/>
</div>
<div className="w-52">
<Switch
checked={inputs.cacheActions}
onCheckedChange={(checked) => {
if (!editable) {
return;
}
handleChange("cacheActions", checked);
}}
/>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
File Suffix
</Label>
<HelpTooltip
content={commonHelpTooltipContent["fileSuffix"]}
/>
</div>
<Input
type="text"
placeholder={commonFieldPlaceholders["downloadSuffix"]}
className="nopan w-52 text-xs"
value={inputs.downloadSuffix ?? ""}
onChange={(event) => {
if (!editable) {
return;
}
handleChange("downloadSuffix", event.target.value);
}}
/>
</div>
<Separator />
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">
2FA Verification URL
</Label>
<HelpTooltip
content={commonHelpTooltipContent["totpVerificationUrl"]}
/>
</div>
<AutoResizingTextarea
onChange={(event) => {
if (!editable) {
return;
}
handleChange("totpVerificationUrl", event.target.value);
}}
value={inputs.totpVerificationUrl ?? ""}
placeholder={commonFieldPlaceholders["totpVerificationUrl"]}
className="nopan text-xs"
/>
</div>
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">
2FA Identifier
</Label>
<HelpTooltip
content={commonHelpTooltipContent["totpIdentifier"]}
/>
</div>
<AutoResizingTextarea
onChange={(event) => {
if (!editable) {
return;
}
handleChange("totpIdentifier", event.target.value);
}}
value={inputs.totpIdentifier ?? ""}
placeholder={commonFieldPlaceholders["totpIdentifier"]}
className="nopan text-xs"
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
);
}
export { FileDownloadNode };

View File

@@ -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<string>;
totpVerificationUrl: string | null;
totpIdentifier: string | null;
cacheActions: boolean;
};
export type FileDownloadNode = Node<FileDownloadNodeData, "fileDownload">;
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";
}

View File

@@ -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;

View File

@@ -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: <DownloadIcon className="size-6" />,
title: "File Download Block",
description: "Download a file",
},
];
type Props = {

View File

@@ -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,

View File

@@ -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<string, string> | null;
max_retries?: number;
max_steps_per_run?: number | null;
download_suffix?: string | null;
parameters: Array<WorkflowParameter>;
totp_verification_url?: string | null;
totp_identifier?: string | null;
cache_actions: boolean;
};
export type WorkflowDefinition = {
parameters: Array<Parameter>;
blocks: Array<WorkflowBlock>;

View File

@@ -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<string, string> | null;
max_retries?: number;
max_steps_per_run?: number | null;
parameter_keys?: Array<string> | 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;