Add login block (#1264)

This commit is contained in:
Shuchang Zheng
2024-11-26 06:31:36 -08:00
committed by GitHub
parent ce684d22a9
commit 1de4df2f31
8 changed files with 569 additions and 3 deletions

View File

@@ -0,0 +1,53 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useWorkflowParametersState } from "../../useWorkflowParametersState";
import { useId } from "react";
type Props = {
value?: string;
onChange?: (value: string) => void;
};
function CredentialParameterSelector({ value, onChange }: Props) {
const [workflowParameters] = useWorkflowParametersState();
const credentialParameters = workflowParameters.filter(
(parameter) => parameter.parameterType === "credential",
);
const noneItemValue = useId();
return (
<Select
value={value}
onValueChange={(value) => {
if (value === noneItemValue) {
onChange?.("");
} else {
onChange?.(value);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a credential parameter" />
</SelectTrigger>
<SelectContent>
{credentialParameters.map((parameter) => (
<SelectItem key={parameter.key} value={parameter.key}>
{parameter.key}
</SelectItem>
))}
{credentialParameters.length === 0 && (
<SelectItem value={noneItemValue} key={noneItemValue}>
No credential parameters found
</SelectItem>
)}
</SelectContent>
</Select>
);
}
export { CredentialParameterSelector };

View File

@@ -0,0 +1,364 @@
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 { HelpTooltip } from "@/components/HelpTooltip";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { errorMappingExampleValue } from "../types";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { Switch } from "@/components/ui/switch";
import type { LoginNode } from "./types";
import {
commonFieldPlaceholders,
commonHelpTooltipContent,
} from "../../constants";
import { LockOpen1Icon } from "@radix-ui/react-icons";
import { CredentialParameterSelector } from "./CredentialParameterSelector";
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. Make sure to include when the task is complete, when it should self-terminate, and any guardrails.";
const navigationGoalPlaceholder = "Tell Skyvern what to do.";
function LoginNode({ id, data }: NodeProps<LoginNode>) {
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,
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">
<LockOpen1Icon 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">Login 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">Login 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="space-y-2">
<Label className="text-xs text-slate-300">Credential Key</Label>
<CredentialParameterSelector
value={
data.parameterKeys.length > 0
? data.parameterKeys[0]
: undefined
}
onChange={(value) => {
if (!editable) {
return;
}
updateNodeData(id, { parameterKeys: [value] });
}}
/>
</div>
<div className="rounded-md bg-slate-800 p-2">
<div className="space-y-1 text-xs text-slate-400">
<div>Credentials need to be added with the help of our team.</div>
<div>
Reach out to{" "}
<span className="text-slate-200">support@skyvern.com</span> for
assistance.
</div>
</div>
</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="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 { LoginNode };

View File

@@ -0,0 +1,36 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
export type LoginNodeData = NodeBaseData & {
url: string;
navigationGoal: string;
errorCodeMapping: string;
maxRetries: number | null;
maxStepsOverride: number | null;
parameterKeys: Array<string>;
totpVerificationUrl: string | null;
totpIdentifier: string | null;
cacheActions: boolean;
};
export type LoginNode = Node<LoginNodeData, "login">;
export const loginNodeDefaultData: LoginNodeData = {
label: "",
url: "",
navigationGoal:
"If you're not on the login page, navigate to login page and login using the credentials given. First, take actions on promotional popups or cookie prompts that could prevent taking other action on the web page. If you fail to login to find the login page or can't login after several trials, terminate. If login is completed, you're successful. ",
errorCodeMapping: "null",
maxRetries: null,
maxStepsOverride: null,
editable: true,
parameterKeys: [],
totpVerificationUrl: null,
totpIdentifier: null,
continueOnFailure: false,
cacheActions: false,
} as const;
export function isLoginNode(node: Node): node is LoginNode {
return node.type === "login";
}

View File

@@ -27,6 +27,8 @@ import { NavigationNode } from "./NavigationNode/types";
import { NavigationNode as NavigationNodeComponent } from "./NavigationNode/NavigationNode";
import { ExtractionNode } from "./ExtractionNode/types";
import { ExtractionNode as ExtractionNodeComponent } from "./ExtractionNode/ExtractionNode";
import { LoginNode } from "./LoginNode/types";
import { LoginNode as LoginNodeComponent } from "./LoginNode/LoginNode";
export type UtilityNode = StartNode | NodeAdderNode;
@@ -42,7 +44,8 @@ export type WorkflowBlockNode =
| ValidationNode
| ActionNode
| NavigationNode
| ExtractionNode;
| ExtractionNode
| LoginNode;
export function isUtilityNode(node: AppNode): node is UtilityNode {
return node.type === "nodeAdder" || node.type === "start";
@@ -69,4 +72,5 @@ export const nodeTypes = {
action: memo(ActionNodeComponent),
navigation: memo(NavigationNodeComponent),
extraction: memo(ExtractionNodeComponent),
login: memo(LoginNodeComponent),
} as const;

View File

@@ -6,6 +6,7 @@ import {
EnvelopeClosedIcon,
FileIcon,
ListBulletIcon,
LockOpen1Icon,
PlusIcon,
UpdateIcon,
UploadIcon,
@@ -97,6 +98,12 @@ const nodeLibraryItems: Array<{
title: "Extraction Block",
description: "Extract data from the page",
},
{
nodeType: "login",
icon: <LockOpen1Icon className="size-6" />,
title: "Login Block",
description: "Login to a website",
},
];
type Props = {

View File

@@ -26,6 +26,7 @@ import {
NavigationBlockYAML,
WorkflowCreateYAMLRequest,
ExtractionBlockYAML,
LoginBlockYAML,
} from "../types/workflowYamlTypes";
import {
EMAIL_BLOCK_SENDER,
@@ -69,6 +70,7 @@ import {
extractionNodeDefaultData,
isExtractionNode,
} from "./nodes/ExtractionNode/types";
import { loginNodeDefaultData } from "./nodes/LoginNode/types";
export const NEW_NODE_LABEL_PREFIX = "block_";
@@ -252,6 +254,25 @@ function convertToNode(
},
};
}
case "login": {
return {
...identifiers,
...common,
type: "login",
data: {
...commonData,
url: block.url ?? "",
navigationGoal: block.navigation_goal ?? "",
errorCodeMapping: JSON.stringify(block.error_code_mapping, null, 2),
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,
@@ -586,6 +607,17 @@ function createNode(
},
};
}
case "login": {
return {
...identifiers,
...common,
type: "login",
data: {
...loginNodeDefaultData,
label,
},
};
}
case "loop": {
return {
...identifiers,
@@ -779,6 +811,27 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML {
cache_actions: node.data.cacheActions,
};
}
case "login": {
return {
...base,
block_type: "login",
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,
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,
@@ -1315,6 +1368,23 @@ function convertBlocksToBlockYAML(
};
return blockYaml;
}
case "login": {
const blockYaml: LoginBlockYAML = {
...base,
block_type: "login",
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,
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

@@ -113,7 +113,8 @@ export type WorkflowBlock =
| ValidationBlock
| ActionBlock
| NavigationBlock
| ExtractionBlock;
| ExtractionBlock
| LoginBlock;
export const WorkflowBlockType = {
Task: "task",
@@ -128,6 +129,7 @@ export const WorkflowBlockType = {
Action: "action",
Navigation: "navigation",
Extraction: "extraction",
Login: "login",
} as const;
export type WorkflowBlockType =
@@ -259,6 +261,20 @@ export type ExtractionBlock = WorkflowBlockBase & {
cache_actions: boolean;
};
export type LoginBlock = WorkflowBlockBase & {
block_type: "login";
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;
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

@@ -79,6 +79,7 @@ const BlockTypes = {
ACTION: "action",
NAVIGATION: "navigation",
EXTRACTION: "extraction",
LOGIN: "login",
} as const;
export type BlockType = (typeof BlockTypes)[keyof typeof BlockTypes];
@@ -95,7 +96,8 @@ export type BlockYAML =
| ValidationBlockYAML
| ActionBlockYAML
| NavigationBlockYAML
| ExtractionBlockYAML;
| ExtractionBlockYAML
| LoginBlockYAML;
export type BlockYAMLBase = {
block_type: BlockType;
@@ -172,6 +174,20 @@ export type ExtractionBlockYAML = BlockYAMLBase & {
cache_actions: boolean;
};
export type LoginBlockYAML = BlockYAMLBase & {
block_type: "login";
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;
totp_verification_url?: string | null;
totp_identifier?: string | null;
cache_actions: boolean;
};
export type CodeBlockYAML = BlockYAMLBase & {
block_type: "code";
code: string;