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,10 +67,484 @@ 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]);
// V2 Mode UI (simpler interface)
const renderV2Content = () => (
<>
<div
className={cn("space-y-4", {
"opacity-50": thisBlockIsPlaying,
})}
>
<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}
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,
})}
>
<div className="space-y-2">
<div className="flex justify-between">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">URL</Label>
<HelpTooltip content={helpTooltips["navigation"]["url"]} />
</div>
{isFirstWorkflowBlock ? (
<div className="flex justify-end text-xs text-slate-400">
Tip: Use the {"+"} button to add parameters!
</div>
) : null}
</div>
<WorkflowBlockInputTextarea
nodeId={id}
onChange={(value) => {
update({ url: value });
}}
value={data.url}
placeholder={placeholders["navigation"]["url"]}
className="nopan text-xs"
/>
</div>
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">Prompt</Label>
<HelpTooltip
content={helpTooltips["navigation"]["navigationGoal"]}
/>
</div>
<WorkflowBlockInputTextarea
aiImprove={AI_IMPROVE_CONFIGS.navigation.navigationGoal}
nodeId={id}
onChange={(value) => {
update({ navigationGoal: value });
}}
value={data.navigationGoal}
placeholder={placeholders["navigation"]["navigationGoal"]}
className="nopan text-xs"
/>
</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.
</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
className={cn({
"pointer-events-none opacity-50": thisBlockIsPlaying,
})}
type="single"
collapsible
onValueChange={() => rerender.bump()}
>
<AccordionItem value="advanced" className="border-b-0">
<AccordionTrigger className="py-0">
Advanced Settings
</AccordionTrigger>
<AccordionContent className="pl-6 pr-1 pt-1">
<div key={rerender.key} className="space-y-4">
<div className="space-y-2">
<ParametersMultiSelect
availableOutputParameters={outputParameterKeys}
parameters={data.parameterKeys}
onParametersChange={(parameterKeys) => {
update({ parameterKeys });
}}
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-slate-300">Complete if...</Label>
<WorkflowBlockInputTextarea
aiImprove={AI_IMPROVE_CONFIGS.navigation.completeCriterion}
nodeId={id}
onChange={(value) => {
update({ completeCriterion: value });
}}
value={data.completeCriterion}
className="nopan text-xs"
/>
</div>
<Separator />
<ModelSelector
className="nopan w-52 text-xs"
value={data.model}
onChange={(value) => {
update({ model: value });
}}
/>
<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={helpTooltips["navigation"]["maxStepsOverride"]}
/>
</div>
<Input
type="number"
placeholder={placeholders["navigation"]["maxStepsOverride"]}
className="nopan w-52 text-xs"
min="0"
value={data.maxStepsOverride ?? ""}
onChange={(event) => {
const value =
event.target.value === ""
? null
: Number(event.target.value);
update({ 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={helpTooltips["navigation"]["errorCodeMapping"]}
/>
</div>
<Checkbox
checked={data.errorCodeMapping !== "null"}
disabled={!editable}
onCheckedChange={(checked) => {
update({
errorCodeMapping: checked
? JSON.stringify(errorMappingExampleValue, null, 2)
: "null",
});
}}
/>
</div>
{data.errorCodeMapping !== "null" && (
<div>
<CodeEditor
language="json"
value={data.errorCodeMapping}
onChange={(value) => {
update({ errorCodeMapping: value });
}}
className="nopan"
fontSize={8}
/>
</div>
)}
</div>
<BlockExecutionOptions
continueOnFailure={data.continueOnFailure}
nextLoopOnFailure={data.nextLoopOnFailure}
includeActionHistoryInVerification={
data.includeActionHistoryInVerification
}
editable={editable}
isInsideForLoop={isInsideForLoop}
blockType="navigation"
showOptions={{
continueOnFailure: true,
nextLoopOnFailure: true,
includeActionHistoryInVerification: true,
}}
onContinueOnFailureChange={(checked) => {
update({ continueOnFailure: checked });
}}
onNextLoopOnFailureChange={(checked) => {
update({ nextLoopOnFailure: checked });
}}
onIncludeActionHistoryInVerificationChange={(checked) => {
update({
includeActionHistoryInVerification: checked,
});
}}
/>
<DisableCache
disableCache={data.disableCache}
editable={editable}
onDisableCacheChange={(disableCache) => {
update({ disableCache });
}}
/>
<Separator />
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Complete on Download
</Label>
<HelpTooltip
content={helpTooltips["navigation"]["completeOnDownload"]}
/>
</div>
<div className="w-52">
<Switch
checked={data.allowDownloads}
onCheckedChange={(checked) => {
update({ allowDownloads: checked });
}}
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
File Name
</Label>
<HelpTooltip
content={helpTooltips["navigation"]["fileSuffix"]}
/>
</div>
<WorkflowBlockInput
nodeId={id}
type="text"
placeholder={placeholders["navigation"]["downloadSuffix"]}
className="nopan w-52 text-xs"
value={data.downloadSuffix ?? ""}
onChange={(value) => {
update({ downloadSuffix: value });
}}
/>
</div>
<Separator />
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">
2FA Identifier
</Label>
<HelpTooltip
content={helpTooltips["navigation"]["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>
</>
);
return (
<Flippable facing={facing} preserveFrontsideHeight={true}>
<div>
@@ -101,317 +578,18 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
nodeId={id}
totpIdentifier={data.totpIdentifier}
totpUrl={data.totpVerificationUrl}
type={type}
type={isV2Mode ? "task_v2" : type}
/>
<div
className={cn("space-y-4", {
"opacity-50": thisBlockIsPlaying,
})}
>
<div className="space-y-2">
<div className="flex justify-between">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">URL</Label>
<HelpTooltip content={helpTooltips["navigation"]["url"]} />
</div>
{isFirstWorkflowBlock ? (
<div className="flex justify-end text-xs text-slate-400">
Tip: Use the {"+"} button to add parameters!
</div>
) : null}
</div>
<WorkflowBlockInputTextarea
nodeId={id}
onChange={(value) => {
update({ url: value });
}}
value={data.url}
placeholder={placeholders["navigation"]["url"]}
className="nopan text-xs"
/>
</div>
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">
Navigation Goal
</Label>
<HelpTooltip
content={helpTooltips["navigation"]["navigationGoal"]}
/>
</div>
<WorkflowBlockInputTextarea
aiImprove={AI_IMPROVE_CONFIGS.navigation.navigationGoal}
nodeId={id}
onChange={(value) => {
update({ navigationGoal: value });
}}
value={data.navigationGoal}
placeholder={placeholders["navigation"]["navigationGoal"]}
className="nopan text-xs"
/>
</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.
</div>
</div>
</div>
<Separator />
<Accordion
className={cn({
"pointer-events-none opacity-50": thisBlockIsPlaying,
})}
type="single"
collapsible
onValueChange={() => rerender.bump()}
>
<AccordionItem value="advanced" className="border-b-0">
<AccordionTrigger className="py-0">
Advanced Settings
</AccordionTrigger>
<AccordionContent className="pl-6 pr-1 pt-1">
<div key={rerender.key} className="space-y-4">
<div className="space-y-2">
<ParametersMultiSelect
availableOutputParameters={outputParameterKeys}
parameters={data.parameterKeys}
onParametersChange={(parameterKeys) => {
update({ parameterKeys });
}}
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-slate-300">
Complete if...
</Label>
<WorkflowBlockInputTextarea
aiImprove={
AI_IMPROVE_CONFIGS.navigation.completeCriterion
}
nodeId={id}
onChange={(value) => {
update({ completeCriterion: value });
}}
value={data.completeCriterion}
className="nopan text-xs"
/>
</div>
<Separator />
<ModelSelector
className="nopan w-52 text-xs"
value={data.model}
onChange={(value) => {
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">
Max Steps Override
</Label>
<HelpTooltip
content={helpTooltips["navigation"]["maxStepsOverride"]}
/>
</div>
<Input
type="number"
placeholder={
placeholders["navigation"]["maxStepsOverride"]
}
className="nopan w-52 text-xs"
min="0"
value={data.maxStepsOverride ?? ""}
onChange={(event) => {
const value =
event.target.value === ""
? null
: Number(event.target.value);
update({ 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={
helpTooltips["navigation"]["errorCodeMapping"]
}
/>
</div>
<Checkbox
checked={data.errorCodeMapping !== "null"}
disabled={!editable}
onCheckedChange={(checked) => {
update({
errorCodeMapping: checked
? JSON.stringify(
errorMappingExampleValue,
null,
2,
)
: "null",
});
}}
/>
</div>
{data.errorCodeMapping !== "null" && (
<div>
<CodeEditor
language="json"
value={data.errorCodeMapping}
onChange={(value) => {
update({ errorCodeMapping: value });
}}
className="nopan"
fontSize={8}
/>
</div>
)}
</div>
<BlockExecutionOptions
continueOnFailure={data.continueOnFailure}
nextLoopOnFailure={data.nextLoopOnFailure}
includeActionHistoryInVerification={
data.includeActionHistoryInVerification
}
editable={editable}
isInsideForLoop={isInsideForLoop}
blockType="navigation"
showOptions={{
continueOnFailure: true,
nextLoopOnFailure: true,
includeActionHistoryInVerification: true,
}}
onContinueOnFailureChange={(checked) => {
update({ continueOnFailure: checked });
}}
onNextLoopOnFailureChange={(checked) => {
update({ nextLoopOnFailure: checked });
}}
onIncludeActionHistoryInVerificationChange={(checked) => {
update({
includeActionHistoryInVerification: checked,
});
}}
/>
<DisableCache
disableCache={data.disableCache}
editable={editable}
onDisableCacheChange={(disableCache) => {
update({ disableCache });
}}
/>
<Separator />
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Complete on Download
</Label>
<HelpTooltip
content={
helpTooltips["navigation"]["completeOnDownload"]
}
/>
</div>
<div className="w-52">
<Switch
checked={data.allowDownloads}
onCheckedChange={(checked) => {
update({ allowDownloads: checked });
}}
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
File Name
</Label>
<HelpTooltip
content={helpTooltips["navigation"]["fileSuffix"]}
/>
</div>
<WorkflowBlockInput
nodeId={id}
type="text"
placeholder={placeholders["navigation"]["downloadSuffix"]}
className="nopan w-52 text-xs"
value={data.downloadSuffix ?? ""}
onChange={(value) => {
update({ downloadSuffix: value });
}}
/>
</div>
<Separator />
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">
2FA Identifier
</Label>
<HelpTooltip
content={helpTooltips["navigation"]["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>
{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.`);
}
}
});