Merge Navigation V1 and Task V2 blocks into unified Browser Task block (#4695)

Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
Suchintan
2026-02-11 00:29:33 -05:00
committed by GitHub
parent 8c35adf3b9
commit 9f7311ccd0
7 changed files with 625 additions and 332 deletions

View File

@@ -6,23 +6,100 @@ import {
SelectTrigger,
SelectValue,
} from "./ui/select";
import { cn } from "@/util/utils";
type EngineOption = {
value: RunEngine;
label: string;
badge?: string;
badgeVariant?: "default" | "success" | "warning";
};
type Props = {
value: RunEngine | null;
onChange: (value: RunEngine) => void;
className?: string;
availableEngines?: Array<RunEngine>;
};
function RunEngineSelector({ value, onChange, className }: Props) {
const allEngineOptions: Array<EngineOption> = [
{
value: RunEngine.SkyvernV1,
label: "Skyvern 1.0",
badge: "Recommended",
badgeVariant: "success",
},
{
value: RunEngine.SkyvernV2,
label: "Skyvern 2.0",
badge: "Multi-Goal",
badgeVariant: "warning",
},
{
value: RunEngine.OpenaiCua,
label: "OpenAI CUA",
},
{
value: RunEngine.AnthropicCua,
label: "Anthropic CUA",
},
];
// Default engines for blocks that don't support V2 mode
const defaultEngines: Array<RunEngine> = [
RunEngine.SkyvernV1,
RunEngine.OpenaiCua,
RunEngine.AnthropicCua,
];
function BadgeLabel({ option }: { option: EngineOption }) {
return (
<div className="flex items-center gap-2">
<span>{option.label}</span>
{option.badge && (
<span
className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", {
"bg-green-500/20 text-green-400": option.badgeVariant === "success",
"bg-amber-500/20 text-amber-400": option.badgeVariant === "warning",
"bg-slate-500/20 text-slate-400":
option.badgeVariant === "default" || !option.badgeVariant,
})}
>
{option.badge}
</span>
)}
</div>
);
}
function RunEngineSelector({
value,
onChange,
className,
availableEngines,
}: Props) {
const engines = availableEngines ?? defaultEngines;
const engineOptions = allEngineOptions.filter((opt) =>
engines.includes(opt.value),
);
const selectedOption = engineOptions.find(
(opt) => opt.value === (value ?? RunEngine.SkyvernV1),
);
return (
<Select value={value ?? RunEngine.SkyvernV1} onValueChange={onChange}>
<SelectTrigger className={className}>
<SelectValue />
<SelectValue>
{selectedOption && <BadgeLabel option={selectedOption} />}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={RunEngine.SkyvernV1}>Skyvern 1.0</SelectItem>
<SelectItem value={RunEngine.OpenaiCua}>OpenAI CUA</SelectItem>
<SelectItem value={RunEngine.AnthropicCua}>Anthropic CUA</SelectItem>
{engineOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<BadgeLabel option={option} />
</SelectItem>
))}
</SelectContent>
</Select>
);

View File

@@ -27,6 +27,8 @@ export const baseHelpTooltipContent = {
"When inside a for loop, continue to the next iteration if this block fails.",
includeActionHistoryInVerification:
"Include the action history in the completion verification.",
engine:
"Skyvern 1.0: Fast, single-goal tasks. Skyvern 2.0: Complex, multi-goal tasks (slower).",
} as const;
export const basePlaceholderContent = {

View File

@@ -23,6 +23,7 @@ import { useState } from "react";
import { helpTooltips, placeholders } from "../../helpContent";
import { errorMappingExampleValue } from "../types";
import type { NavigationNode } from "./types";
import { MAX_STEPS_DEFAULT } from "./types";
import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect";
import { AppNode } from "..";
import {
@@ -35,9 +36,11 @@ import { ModelSelector } from "@/components/ModelSelector";
import { cn } from "@/util/utils";
import { useParams } from "react-router-dom";
import { NodeHeader } from "../components/NodeHeader";
import { NodeTabs } from "../components/NodeTabs";
import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
import { RunEngine } from "@/api/types";
import { DisableCache } from "../DisableCache";
import { BlockExecutionOptions } from "../components/BlockExecutionOptions";
@@ -64,45 +67,189 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
const update = useUpdate<NavigationNode["data"]>({ id, editable });
const isInsideForLoop = isNodeInsideForLoop(nodes, id);
// Determine if we're in V2 mode (Skyvern 2.0)
const isV2Mode = data.engine === RunEngine.SkyvernV2;
const handleEngineChange = (value: RunEngine) => {
const updates: Partial<NavigationNode["data"]> = { engine: value };
if (value === RunEngine.SkyvernV2) {
// Switching to V2 — clear V1-specific fields
updates.navigationGoal = "";
updates.completeCriterion = "";
updates.terminateCriterion = "";
updates.errorCodeMapping = "null";
updates.parameterKeys = [];
updates.maxRetries = null;
updates.maxStepsOverride = null;
updates.allowDownloads = false;
updates.downloadSuffix = null;
updates.includeActionHistoryInVerification = false;
} else if (data.engine === RunEngine.SkyvernV2) {
// Switching away from V2 — clear V2-specific fields
updates.prompt = "";
updates.maxSteps = MAX_STEPS_DEFAULT;
}
update(updates);
};
useEffect(() => {
setFacing(data.showCode ? "back" : "front");
}, [data.showCode]);
return (
<Flippable facing={facing} preserveFrontsideHeight={true}>
<div>
<Handle
type="source"
position={Position.Bottom}
id="a"
className="opacity-0"
/>
<Handle
type="target"
position={Position.Top}
id="b"
className="opacity-0"
/>
// V2 Mode UI (simpler interface)
const renderV2Content = () => (
<>
<div
className={cn(
"transform-origin-center w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4 transition-all",
{
"pointer-events-none": thisBlockIsPlaying,
"bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsTargetted,
},
data.comparisonColor,
)}
className={cn("space-y-4", {
"opacity-50": thisBlockIsPlaying,
})}
>
<NodeHeader
blockLabel={label}
editable={editable}
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">URL</Label>
<HelpTooltip content={helpTooltips["navigation"]["url"]} />
</div>
<WorkflowBlockInputTextarea
nodeId={id}
totpIdentifier={data.totpIdentifier}
totpUrl={data.totpVerificationUrl}
type={type}
onChange={(value) => {
update({ url: value });
}}
value={data.url}
placeholder={placeholders["taskv2"]["url"]}
className="nopan text-xs"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-slate-300">Prompt</Label>
{isFirstWorkflowBlock ? (
<div className="flex justify-end text-xs text-slate-400">
Tip: Use the {"+"} button to add parameters!
</div>
) : null}
</div>
<WorkflowBlockInputTextarea
aiImprove={AI_IMPROVE_CONFIGS.taskV2.prompt}
nodeId={id}
onChange={(value) => {
update({ prompt: value });
}}
value={data.prompt}
placeholder={placeholders["taskv2"]["prompt"]}
className="nopan text-xs"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">Engine</Label>
<HelpTooltip content={helpTooltips["navigation"]["engine"]} />
</div>
<RunEngineSelector
value={data.engine}
onChange={handleEngineChange}
className="nopan w-72 text-xs"
availableEngines={[
RunEngine.SkyvernV1,
RunEngine.SkyvernV2,
RunEngine.OpenaiCua,
RunEngine.AnthropicCua,
]}
/>
</div>
</div>
<Separator />
<Accordion
type="single"
collapsible
onValueChange={() => rerender.bump()}
>
<AccordionItem value="advanced" className="border-b-0">
<AccordionTrigger className="py-0">
Advanced Settings
</AccordionTrigger>
<AccordionContent key={rerender.key} className="pl-6 pr-1 pt-4">
<div className="space-y-4">
<ModelSelector
className="nopan w-52 text-xs"
value={data.model}
onChange={(value) => {
update({ model: value });
}}
/>
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">Max Steps</Label>
<HelpTooltip content={helpTooltips["taskv2"]["maxSteps"]} />
</div>
<Input
type="number"
placeholder={`${MAX_STEPS_DEFAULT}`}
className="nopan text-xs"
value={data.maxSteps ?? MAX_STEPS_DEFAULT}
onChange={(event) => {
update({
maxSteps: Number(event.target.value),
});
}}
/>
</div>
<Separator />
<DisableCache
disableCache={data.disableCache}
editable={editable}
onDisableCacheChange={(disableCache) => {
update({ disableCache });
}}
/>
<Separator />
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">
2FA Identifier
</Label>
<HelpTooltip
content={helpTooltips["taskv2"]["totpIdentifier"]}
/>
</div>
<WorkflowBlockInputTextarea
nodeId={id}
onChange={(value) => {
update({ totpIdentifier: value });
}}
value={data.totpIdentifier ?? ""}
placeholder={placeholders["navigation"]["totpIdentifier"]}
className="nopan text-xs"
/>
</div>
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">
2FA Verification URL
</Label>
<HelpTooltip
content={helpTooltips["task"]["totpVerificationUrl"]}
/>
</div>
<WorkflowBlockInputTextarea
nodeId={id}
onChange={(value) => {
update({ totpVerificationUrl: value });
}}
value={data.totpVerificationUrl ?? ""}
placeholder={placeholders["task"]["totpVerificationUrl"]}
className="nopan text-xs"
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</>
);
// V1 Mode UI (full navigation interface)
const renderV1Content = () => (
<>
<div
className={cn("space-y-4", {
"opacity-50": thisBlockIsPlaying,
@@ -133,9 +280,7 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
</div>
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">
Navigation Goal
</Label>
<Label className="text-xs text-slate-300">Prompt</Label>
<HelpTooltip
content={helpTooltips["navigation"]["navigationGoal"]}
/>
@@ -153,13 +298,30 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
</div>
<div className="rounded-md bg-slate-800 p-2">
<div className="space-y-1 text-xs text-slate-400">
Tip: Try to phrase your prompt as a goal with an explicit
completion criteria. While executing, Skyvern will take as many
actions as necessary to accomplish the goal. Use words like
"Complete" or "Terminate" to help Skyvern identify when it's
finished or when it should give up.
Tip: Try to phrase your prompt as a goal with an explicit completion
criteria. While executing, Skyvern will take as many actions as
necessary to accomplish the goal. Use words like "Complete" or
"Terminate" to help Skyvern identify when it's finished or when it
should give up.
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">Engine</Label>
<HelpTooltip content={helpTooltips["navigation"]["engine"]} />
</div>
<RunEngineSelector
value={data.engine}
onChange={handleEngineChange}
className="nopan w-72 text-xs"
availableEngines={[
RunEngine.SkyvernV1,
RunEngine.SkyvernV2,
RunEngine.OpenaiCua,
RunEngine.AnthropicCua,
]}
/>
</div>
</div>
<Separator />
<Accordion
@@ -186,13 +348,9 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-slate-300">
Complete if...
</Label>
<Label className="text-xs text-slate-300">Complete if...</Label>
<WorkflowBlockInputTextarea
aiImprove={
AI_IMPROVE_CONFIGS.navigation.completeCriterion
}
aiImprove={AI_IMPROVE_CONFIGS.navigation.completeCriterion}
nodeId={id}
onChange={(value) => {
update({ completeCriterion: value });
@@ -209,20 +367,6 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
update({ model: value });
}}
/>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Engine
</Label>
</div>
<RunEngineSelector
value={data.engine}
onChange={(value) => {
update({ engine: value });
}}
className="nopan w-52 text-xs"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
@@ -234,9 +378,7 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
</div>
<Input
type="number"
placeholder={
placeholders["navigation"]["maxStepsOverride"]
}
placeholder={placeholders["navigation"]["maxStepsOverride"]}
className="nopan w-52 text-xs"
min="0"
value={data.maxStepsOverride ?? ""}
@@ -256,9 +398,7 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
Error Messages
</Label>
<HelpTooltip
content={
helpTooltips["navigation"]["errorCodeMapping"]
}
content={helpTooltips["navigation"]["errorCodeMapping"]}
/>
</div>
<Checkbox
@@ -267,11 +407,7 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
onCheckedChange={(checked) => {
update({
errorCodeMapping: checked
? JSON.stringify(
errorMappingExampleValue,
null,
2,
)
? JSON.stringify(errorMappingExampleValue, null, 2)
: "null",
});
}}
@@ -331,9 +467,7 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
Complete on Download
</Label>
<HelpTooltip
content={
helpTooltips["navigation"]["completeOnDownload"]
}
content={helpTooltips["navigation"]["completeOnDownload"]}
/>
</div>
<div className="w-52">
@@ -408,10 +542,54 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
</AccordionContent>
</AccordionItem>
</Accordion>
</>
);
return (
<Flippable facing={facing} preserveFrontsideHeight={true}>
<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={cn(
"transform-origin-center w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4 transition-all",
{
"pointer-events-none": thisBlockIsPlaying,
"bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsTargetted,
},
data.comparisonColor,
)}
>
<NodeHeader
blockLabel={label}
editable={editable}
nodeId={id}
totpIdentifier={data.totpIdentifier}
totpUrl={data.totpVerificationUrl}
type={isV2Mode ? "task_v2" : type}
/>
{isV2Mode ? renderV2Content() : renderV1Content()}
<NodeTabs blockLabel={label} />
</div>
</div>
<BlockCodeEditor blockLabel={label} blockType={type} script={script} />
<BlockCodeEditor
blockLabel={label}
blockType={isV2Mode ? "task_v2" : type}
script={script}
/>
</Flippable>
);
}

View File

@@ -3,6 +3,8 @@ import { NodeBaseData } from "../types";
import { RunEngine } from "@/api/types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export const MAX_STEPS_DEFAULT = 25;
export type NavigationNodeData = NodeBaseData & {
url: string;
navigationGoal: string;
@@ -19,6 +21,9 @@ export type NavigationNodeData = NodeBaseData & {
totpIdentifier: string | null;
disableCache: boolean;
includeActionHistoryInVerification: boolean;
// V2-specific fields (used when engine is SkyvernV2)
prompt: string;
maxSteps: number | null;
};
export type NavigationNode = Node<NavigationNodeData, "navigation">;
@@ -44,6 +49,9 @@ export const navigationNodeDefaultData: NavigationNodeData = {
continueOnFailure: false,
disableCache: false,
includeActionHistoryInVerification: false,
// V2-specific fields
prompt: "",
maxSteps: MAX_STEPS_DEFAULT,
} as const;
export function isNavigationNode(node: Node): node is NavigationNode {

View File

@@ -38,7 +38,7 @@ type Props = {
// Mapping from WorkflowBlock.block_type to ReactFlow node.type
const BLOCK_TYPE_TO_NODE_TYPE: Record<string, string> = {
task: "task",
task_v2: "taskv2",
task_v2: "navigation", // task_v2 blocks are displayed as navigation nodes with V2 engine
validation: "validation",
action: "action",
navigation: "navigation",

View File

@@ -43,17 +43,6 @@ const nodeLibraryItems: Array<{
title: "Browser Task Block",
description: "Take actions to achieve a task.",
},
{
nodeType: "taskv2",
icon: (
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.Taskv2}
className="size-6"
/>
),
title: "Browser Task v2 Block",
description: "Achieve complex tasks with deep thinking.",
},
{
nodeType: "action",
icon: (

View File

@@ -106,6 +106,7 @@ import { actionNodeDefaultData, isActionNode } from "./nodes/ActionNode/types";
import {
isNavigationNode,
navigationNodeDefaultData,
MAX_STEPS_DEFAULT,
} from "./nodes/NavigationNode/types";
import {
extractionNodeDefaultData,
@@ -119,7 +120,6 @@ import {
isPdfParserNode,
pdfParserNodeDefaultData,
} from "./nodes/PDFParserNode/types";
import { taskv2NodeDefaultData } from "./nodes/Taskv2Node/types";
import { urlNodeDefaultData } from "./nodes/URLNode/types";
import { fileUploadNodeDefaultData } from "./nodes/FileUploadNode/types";
import {
@@ -527,19 +527,33 @@ function convertToNode(
};
}
case "task_v2": {
// Convert task_v2 blocks to navigation nodes with engine=SkyvernV2
return {
...identifiers,
...common,
type: "taskv2",
type: "navigation",
data: {
...commonData,
// V2-specific fields
prompt: block.prompt,
url: block.url ?? "",
maxSteps: block.max_steps,
maxSteps: block.max_steps ?? MAX_STEPS_DEFAULT,
disableCache: block.disable_cache ?? false,
totpIdentifier: block.totp_identifier,
totpVerificationUrl: block.totp_verification_url,
maxScreenshotScrolls: null,
// Set engine to SkyvernV2 to indicate V2 mode
engine: RunEngine.SkyvernV2,
// Default V1 fields (not used in V2 mode but needed for type compatibility)
navigationGoal: "",
errorCodeMapping: "null",
completeCriterion: "",
terminateCriterion: "",
maxRetries: null,
maxStepsOverride: null,
allowDownloads: false,
downloadSuffix: null,
parameterKeys: [],
includeActionHistoryInVerification: false,
},
};
}
@@ -602,6 +616,8 @@ function convertToNode(
engine: block.engine ?? RunEngine.SkyvernV1,
includeActionHistoryInVerification:
block.include_action_history_in_verification ?? false,
prompt: "",
maxSteps: MAX_STEPS_DEFAULT,
},
};
}
@@ -1699,13 +1715,15 @@ function createNode(
};
}
case "taskv2": {
// Redirect taskv2 creation to navigation with SkyvernV2 engine
return {
...identifiers,
...common,
type: "taskv2",
type: "navigation",
data: {
...taskv2NodeDefaultData,
...navigationNodeDefaultData,
label,
engine: RunEngine.SkyvernV2,
},
};
}
@@ -2164,6 +2182,20 @@ function getWorkflowBlock(
};
}
case "navigation": {
// If engine is SkyvernV2, convert to task_v2 block
if (node.data.engine === RunEngine.SkyvernV2) {
return {
...base,
block_type: "task_v2",
prompt: node.data.prompt,
max_steps: node.data.maxSteps,
totp_identifier: node.data.totpIdentifier,
totp_verification_url: node.data.totpVerificationUrl,
url: node.data.url,
disable_cache: node.data.disableCache ?? false,
};
}
// Otherwise, create a navigation block
return {
...base,
block_type: "navigation",
@@ -3931,8 +3963,15 @@ function getWorkflowErrors(nodes: Array<AppNode>): Array<string> {
const navigationNodes = nodes.filter(isNavigationNode);
navigationNodes.forEach((node) => {
if (node.data.navigationGoal.length === 0) {
errors.push(`${node.data.label}: Navigation goal is required.`);
// V2 mode uses prompt, V1 mode uses navigationGoal
if (node.data.engine === RunEngine.SkyvernV2) {
if (!node.data.prompt || node.data.prompt.length === 0) {
errors.push(`${node.data.label}: Prompt is required.`);
}
} else {
if (!node.data.navigationGoal || node.data.navigationGoal.length === 0) {
errors.push(`${node.data.label}: Prompt is required.`);
}
}
});