MVP Debugger UI (#2888)

This commit is contained in:
Jonathan Dobson
2025-07-07 22:30:33 -04:00
committed by GitHub
parent d63053835f
commit acbdb15265
65 changed files with 2071 additions and 1022 deletions

View File

@@ -1,6 +1,6 @@
import { Status } from "@/api/types";
import { useEffect, useState, useRef, useCallback } from "react";
import { HandIcon, PlayIcon } from "@radix-ui/react-icons";
import { HandIcon, StopIcon } from "@radix-ui/react-icons";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { statusIsNotFinalized } from "@/routes/tasks/types";
@@ -317,13 +317,15 @@ function BrowserStream({
<div className="overlay-container">
<div className="overlay">
<Button
// className="take-control"
className={cn("take-control", { hide: userIsControlling })}
className={cn(
"take-control absolute bottom-[-1rem] left-[1rem]",
{ hide: userIsControlling },
)}
type="button"
onClick={() => setUserIsControlling(true)}
>
<HandIcon className="mr-2 h-4 w-4" />
take control
interact
</Button>
<div className="absolute bottom-[-1rem] right-[1rem]">
<Button
@@ -333,8 +335,8 @@ function BrowserStream({
type="button"
onClick={() => setUserIsControlling(false)}
>
<PlayIcon className="mr-2 h-4 w-4" />
run agent
<StopIcon className="mr-2 h-4 w-4" />
stop interacting
</Button>
</div>
</div>

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
interface HMS {
hour: number;
minute: number;
second: number;
}
interface Props {
startAt?: HMS;
}
function Timer({ startAt }: Props) {
const [time, setTime] = useState<HMS>({
hour: 0,
minute: 0,
second: 0,
});
useEffect(() => {
const start = performance.now();
const loop = () => {
const elapsed = performance.now() - start;
let seconds = Math.floor(elapsed / 1000);
let minutes = Math.floor(seconds / 60);
let hours = Math.floor(minutes / 60);
seconds = seconds % 60;
minutes = minutes % 60;
hours = hours % 24;
setTime(() => ({
hour: hours + (startAt?.hour ?? 0),
minute: minutes + (startAt?.minute ?? 0),
second: seconds + (startAt?.second ?? 0),
}));
rAF = requestAnimationFrame(loop);
};
let rAF = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rAF);
}, [startAt]);
return (
<div>
{String(time.hour).padStart(2, "0")}:
{String(time.minute).padStart(2, "0")}:
{String(time.second).padStart(2, "0")}
</div>
);
}
export { Timer };

View File

@@ -0,0 +1,17 @@
import { useEffect, useRef } from "react";
function useOnChange<T>(
value: T,
callback: (newValue: T, prevValue: T | undefined) => void,
) {
const prevValue = useRef<T>(value);
useEffect(() => {
if (prevValue.current !== undefined) {
callback(value, prevValue.current);
}
prevValue.current = value;
}, [value, callback]);
}
export { useOnChange };

View File

@@ -22,11 +22,16 @@ import { WorkflowPostRunParameters } from "./routes/workflows/workflowRun/Workfl
import { WorkflowRunOutput } from "./routes/workflows/workflowRun/WorkflowRunOutput";
import { WorkflowRunOverview } from "./routes/workflows/workflowRun/WorkflowRunOverview";
import { WorkflowRunRecording } from "./routes/workflows/workflowRun/WorkflowRunRecording";
import { DebugStoreProvider } from "@/store/DebugStoreContext";
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
element: (
<DebugStoreProvider>
<RootLayout />
</DebugStoreProvider>
),
children: [
{
index: true,
@@ -98,6 +103,14 @@ const router = createBrowserRouter([
index: true,
element: <Navigate to="runs" />,
},
{
path: "debug",
element: <WorkflowEditor />,
},
{
path: ":workflowRunId/:blockLabel/debug",
element: <WorkflowEditor />,
},
{
path: "edit",
element: <WorkflowEditor />,

View File

@@ -1,10 +1,15 @@
import { DiscordLogoIcon } from "@radix-ui/react-icons";
import GitHubButton from "react-github-btn";
import { Link, useMatch } from "react-router-dom";
import { Link, useMatch, useSearchParams } from "react-router-dom";
import { NavigationHamburgerMenu } from "./NavigationHamburgerMenu";
function Header() {
const match = useMatch("/workflows/:workflowPermanentId/edit");
const [searchParams] = useSearchParams();
const embed = searchParams.get("embed");
const match =
useMatch("/workflows/:workflowPermanentId/edit") ||
location.pathname.includes("debug") ||
embed === "true";
if (match) {
return null;

View File

@@ -4,18 +4,24 @@ import { cn } from "@/util/utils";
import { Outlet } from "react-router-dom";
import { Header } from "./Header";
import { Sidebar } from "./Sidebar";
import { useDebugStore } from "@/store/useDebugStore";
function RootLayout() {
const collapsed = useSidebarStore((state) => state.collapsed);
const embed = new URLSearchParams(window.location.search).get("embed");
const isEmbedded = embed === "true";
const debugStore = useDebugStore();
return (
<>
{!isEmbedded && <Sidebar />}
<div className="h-full w-full">
<Sidebar />
<Header />
<main
className={cn("lg:pb-4 lg:pl-64", {
"lg:pl-28": collapsed,
"lg:pl-4": isEmbedded,
"lg:pb-0": debugStore.isDebugMode,
})}
>
<Outlet />

View File

@@ -30,6 +30,8 @@ import { WorkflowParameterInput } from "./WorkflowParameterInput";
import { AxiosError } from "axios";
import { getLabelForWorkflowParameterType } from "./editor/workflowEditorUtils";
import { MAX_SCREENSHOT_SCROLLS_DEFAULT } from "./editor/nodes/Taskv2Node/types";
import { lsKeys } from "@/util/env";
type Props = {
workflowParameters: Array<WorkflowParameter>;
initialValues: Record<string, unknown>;
@@ -145,7 +147,7 @@ function RunWorkflowForm({
const navigate = useNavigate();
const queryClient = useQueryClient();
const browserSessionIdDefault = useLocalStorageFormDefault(
"skyvern.browserSessionId",
lsKeys.browserSessionId,
(initialValues.browserSessionId as string | undefined) ?? null,
);
const form = useForm<RunWorkflowFormType>({
@@ -162,11 +164,7 @@ function RunWorkflowForm({
});
const apiCredential = useApiCredential();
useSyncFormFieldToStorage(
form,
"browserSessionId",
"skyvern.browserSessionId",
);
useSyncFormFieldToStorage(form, "browserSessionId", lsKeys.browserSessionId);
const runWorkflowMutation = useMutation({
mutationFn: async (values: RunWorkflowFormType) => {

View File

@@ -41,6 +41,8 @@ import { type ApiCommandOptions } from "@/util/apiCommands";
function WorkflowRun() {
const [searchParams, setSearchParams] = useSearchParams();
const embed = searchParams.get("embed");
const isEmbedded = embed === "true";
const active = searchParams.get("active");
const { workflowRunId, workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
@@ -169,92 +171,94 @@ function WorkflowRun() {
return (
<div className="space-y-8">
<header className="flex justify-between">
<div className="space-y-3">
<div className="flex items-center gap-5">
{title}
{workflowRunIsLoading ? (
<Skeleton className="h-8 w-28" />
) : workflowRun ? (
<StatusBadge status={workflowRun?.status} />
) : null}
{!isEmbedded && (
<header className="flex justify-between">
<div className="space-y-3">
<div className="flex items-center gap-5">
{title}
{workflowRunIsLoading ? (
<Skeleton className="h-8 w-28" />
) : workflowRun ? (
<StatusBadge status={workflowRun?.status} />
) : null}
</div>
<h2 className="text-2xl text-slate-400">{workflowRunId}</h2>
</div>
<h2 className="text-2xl text-slate-400">{workflowRunId}</h2>
</div>
<div className="flex gap-2">
<CopyApiCommandDropdown
getOptions={() =>
({
method: "POST",
url: `${apiBaseUrl}/workflows/${workflowPermanentId}/run`,
body: {
data: workflowRun?.parameters,
proxy_location: "RESIDENTIAL",
},
headers: {
"Content-Type": "application/json",
"x-api-key": apiCredential ?? "<your-api-key>",
},
}) satisfies ApiCommandOptions
}
/>
<Button asChild variant="secondary">
<Link to={`/workflows/${workflowPermanentId}/edit`}>
<Pencil2Icon className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
{workflowRunIsRunningOrQueued && (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Cancel</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
Are you sure you want to cancel this workflow run?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Back</Button>
</DialogClose>
<Button
variant="destructive"
onClick={() => {
cancelWorkflowMutation.mutate();
}}
disabled={cancelWorkflowMutation.isPending}
>
{cancelWorkflowMutation.isPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
Cancel Workflow Run
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{workflowRunIsFinalized && !isTaskv2Run && (
<Button asChild>
<Link
to={`/workflows/${workflowPermanentId}/run`}
state={{
data: parameters,
proxyLocation,
webhookCallbackUrl: workflowRun?.webhook_callback_url ?? "",
maxScreenshotScrolls,
}}
>
<PlayIcon className="mr-2 h-4 w-4" />
Rerun
<div className="flex gap-2">
<CopyApiCommandDropdown
getOptions={() =>
({
method: "POST",
url: `${apiBaseUrl}/workflows/${workflowPermanentId}/run`,
body: {
data: workflowRun?.parameters,
proxy_location: "RESIDENTIAL",
},
headers: {
"Content-Type": "application/json",
"x-api-key": apiCredential ?? "<your-api-key>",
},
}) satisfies ApiCommandOptions
}
/>
<Button asChild variant="secondary">
<Link to={`/workflows/${workflowPermanentId}/edit`}>
<Pencil2Icon className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
)}
</div>
</header>
{workflowRunIsRunningOrQueued && (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Cancel</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
Are you sure you want to cancel this workflow run?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Back</Button>
</DialogClose>
<Button
variant="destructive"
onClick={() => {
cancelWorkflowMutation.mutate();
}}
disabled={cancelWorkflowMutation.isPending}
>
{cancelWorkflowMutation.isPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
Cancel Workflow Run
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{workflowRunIsFinalized && !isTaskv2Run && (
<Button asChild>
<Link
to={`/workflows/${workflowPermanentId}/run`}
state={{
data: parameters,
proxyLocation,
webhookCallbackUrl: workflowRun?.webhook_callback_url ?? "",
maxScreenshotScrolls,
}}
>
<PlayIcon className="mr-2 h-4 w-4" />
Rerun
</Link>
</Button>
)}
</div>
</header>
)}
{showOutputSection && (
<div
className={cn("grid gap-4 rounded-lg bg-slate-elevation1 p-4", {
@@ -307,26 +311,28 @@ function WorkflowRun() {
</div>
)}
{workflowFailureReason}
<SwitchBarNavigation
options={[
{
label: "Overview",
to: "overview",
},
{
label: "Output",
to: "output",
},
{
label: "Parameters",
to: "parameters",
},
{
label: "Recording",
to: "recording",
},
]}
/>
{!isEmbedded && (
<SwitchBarNavigation
options={[
{
label: "Overview",
to: "overview",
},
{
label: "Output",
to: "output",
},
{
label: "Parameters",
to: "parameters",
},
{
label: "Recording",
to: "recording",
},
]}
/>
)}
<div className="flex h-[42rem] gap-6">
<div className="w-2/3">
<Outlet />

View File

@@ -6,6 +6,7 @@ import { RunWorkflowForm } from "./RunWorkflowForm";
import { WorkflowApiResponse } from "./types/workflowTypes";
import { Skeleton } from "@/components/ui/skeleton";
import { ProxyLocation } from "@/api/types";
import { getInitialValues } from "./utils";
function WorkflowRunParameters() {
const credentialGetter = useCredentialGetter();
@@ -30,6 +31,7 @@ function WorkflowRunParameters() {
const proxyLocation = location.state
? (location.state.proxyLocation as ProxyLocation)
: null;
const maxScreenshotScrolls = location.state?.maxScreenshotScrolls ?? null;
const webhookCallbackUrl = location.state
@@ -40,43 +42,7 @@ function WorkflowRunParameters() {
? (location.state.extraHttpHeaders as Record<string, string>)
: null;
const initialValues = location.state?.data
? location.state.data
: workflowParameters?.reduce(
(acc, curr) => {
if (curr.workflow_parameter_type === "json") {
if (typeof curr.default_value === "string") {
acc[curr.key] = curr.default_value;
return acc;
}
if (curr.default_value) {
acc[curr.key] = JSON.stringify(curr.default_value, null, 2);
return acc;
}
}
if (
curr.default_value &&
curr.workflow_parameter_type === "boolean"
) {
acc[curr.key] = Boolean(curr.default_value);
return acc;
}
if (
curr.default_value === null &&
curr.workflow_parameter_type === "string"
) {
acc[curr.key] = "";
return acc;
}
if (curr.default_value) {
acc[curr.key] = curr.default_value;
return acc;
}
acc[curr.key] = null;
return acc;
},
{} as Record<string, unknown>,
);
const initialValues = getInitialValues(location, workflowParameters ?? []);
const header = (
<header className="space-y-5">

View File

@@ -1,9 +1,13 @@
import { cn } from "@/util/utils";
import { Outlet, useMatch } from "react-router-dom";
import { Outlet, useMatch, useSearchParams } from "react-router-dom";
function WorkflowsPageLayout() {
const match = useMatch("/workflows/:workflowPermanentId/edit");
const [searchParams] = useSearchParams();
const embed = searchParams.get("embed");
const match =
useMatch("/workflows/:workflowPermanentId/edit") ||
location.pathname.includes("debug") ||
embed === "true";
return (
<main
className={cn({

View File

@@ -10,8 +10,10 @@ import {
} from "@/components/ui/dialog";
import { toast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useOnChange } from "@/hooks/useOnChange";
import { useShouldNotifyWhenClosingTab } from "@/hooks/useShouldNotifyWhenClosingTab";
import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext";
import { useDebugStore } from "@/store/useDebugStore";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
import { ReloadIcon } from "@radix-ui/react-icons";
@@ -22,10 +24,12 @@ import {
Controls,
Edge,
Panel,
PanOnScrollMode,
ReactFlow,
useEdgesState,
useNodesInitialized,
useNodesState,
useReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { AxiosError } from "axios";
@@ -35,6 +39,7 @@ import { useBlocker, useParams } from "react-router-dom";
import { stringify as convertToYAML } from "yaml";
import {
AWSSecretParameter,
debuggableWorkflowBlockTypes,
WorkflowApiResponse,
WorkflowEditorParameterTypes,
WorkflowParameterTypes,
@@ -91,7 +96,7 @@ import {
nodeAdderNode,
startNode,
} from "./workflowEditorUtils";
import { cn } from "@/util/utils";
import { useAutoPan } from "./useAutoPan";
function convertToParametersYAML(
@@ -254,6 +259,8 @@ function FlowRenderer({
initialParameters,
workflow,
}: Props) {
const reactFlowInstance = useReactFlow();
const debugStore = useDebugStore();
const { workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
@@ -264,6 +271,7 @@ function FlowRenderer({
const [parameters, setParameters] =
useState<ParametersState>(initialParameters);
const [title, setTitle] = useState(initialTitle);
const [debuggableBlockCount, setDebuggableBlockCount] = useState(0);
const nodesInitialized = useNodesInitialized();
const { hasChanges, setHasChanges } = useWorkflowHasChangesStore();
useShouldNotifyWhenClosingTab(hasChanges);
@@ -379,6 +387,14 @@ function FlowRenderer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodesInitialized]);
useEffect(() => {
const blocks = getWorkflowBlocks(nodes, edges);
const debuggable = blocks.filter((block) =>
debuggableWorkflowBlockTypes.has(block.block_type),
);
setDebuggableBlockCount(debuggable.length);
}, [nodes, edges]);
async function handleSave() {
const blocks = getWorkflowBlocks(nodes, edges);
const settings = getWorkflowSettings(nodes);
@@ -590,6 +606,47 @@ function FlowRenderer({
useAutoPan(editorElementRef, nodes);
const zoomLock = 1 as const;
const yLockMax = 140 as const;
/**
* TODO(jdo): hack
*/
const getXLock = () => {
const hasForLoopNode = nodes.some((node) => node.type === "loop");
return hasForLoopNode ? 24 : 104;
};
useOnChange(debugStore.isDebugMode, (newValue) => {
const xLock = getXLock();
if (newValue) {
const currentY = reactFlowInstance.getViewport().y;
reactFlowInstance.setViewport({ x: xLock, y: currentY, zoom: zoomLock });
}
});
const constrainPan = (y: number) => {
const yLockMin = nodes.reduce(
(acc, node) => {
const nodeBottom = node.position.y + (node.height ?? 0);
if (nodeBottom > acc.value) {
return { value: nodeBottom };
}
return acc;
},
{ value: -Infinity },
);
const yLockMinValue = yLockMin.value;
const xLock = getXLock();
const newY = Math.max(-yLockMinValue + yLockMax, Math.min(yLockMax, y));
reactFlowInstance.setViewport({
x: xLock,
y: newY,
zoom: zoomLock,
});
};
return (
<>
<Dialog
@@ -677,16 +734,33 @@ function FlowRenderer({
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
colorMode="dark"
fitView
fitView={!debugStore.isDebugMode}
fitViewOptions={{
maxZoom: 1,
}}
deleteKeyCode={null}
onMove={(_, viewport) => {
const y = viewport.y;
debugStore.isDebugMode && constrainPan(y);
}}
maxZoom={debugStore.isDebugMode ? 1 : 2}
minZoom={debugStore.isDebugMode ? 1 : 0.5}
panOnDrag={!debugStore.isDebugMode}
panOnScroll={debugStore.isDebugMode}
panOnScrollMode={
debugStore.isDebugMode
? PanOnScrollMode.Vertical
: PanOnScrollMode.Free
}
zoomOnDoubleClick={!debugStore.isDebugMode}
zoomOnPinch={!debugStore.isDebugMode}
zoomOnScroll={!debugStore.isDebugMode}
>
<Background variant={BackgroundVariant.Dots} bgColor="#020617" />
<Controls position="bottom-left" />
<Panel position="top-center" className="h-20">
<Panel position="top-center" className={cn("h-20")}>
<WorkflowHeader
debuggableBlockCount={debuggableBlockCount}
title={title}
saving={saveWorkflowMutation.isPending}
onTitleChange={(newTitle) => {

View File

@@ -7,6 +7,7 @@ import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
import { FlowRenderer } from "./FlowRenderer";
import { getElements } from "./workflowEditorUtils";
import { LogoMinimized } from "@/components/LogoMinimized";
import { useDebugStore } from "@/store/useDebugStore";
import {
isDisplayedInWorkflowEditor,
WorkflowEditorParameterTypes,
@@ -16,8 +17,11 @@ import {
} from "../types/workflowTypes";
import { ParametersState } from "./types";
import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery";
import { WorkflowDebugOverviewWindow } from "./panels/WorkflowDebugOverviewWindow";
import { cn } from "@/util/utils";
function WorkflowEditor() {
const debugStore = useDebugStore();
const { workflowPermanentId } = useParams();
const setCollapsed = useSidebarStore((state) => {
return state.setCollapsed;
@@ -72,110 +76,123 @@ function WorkflowEditor() {
!isGlobalWorkflow,
);
return (
<div className="h-screen w-full">
<ReactFlowProvider>
<FlowRenderer
initialTitle={workflow.title}
initialNodes={elements.nodes}
initialEdges={elements.edges}
initialParameters={
workflow.workflow_definition.parameters
.filter((parameter) => isDisplayedInWorkflowEditor(parameter))
.map((parameter) => {
if (
parameter.parameter_type === WorkflowParameterTypes.Workflow
) {
if (
parameter.workflow_parameter_type ===
WorkflowParameterValueType.CredentialId
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.Credential,
credentialId: parameter.default_value as string,
description: parameter.description,
};
}
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.Workflow,
dataType: parameter.workflow_parameter_type,
defaultValue: parameter.default_value,
description: parameter.description,
};
} else if (
parameter.parameter_type === WorkflowParameterTypes.Context
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.Context,
sourceParameterKey: parameter.source.key,
description: parameter.description,
};
} else if (
parameter.parameter_type ===
WorkflowParameterTypes.Bitwarden_Sensitive_Information
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.Secret,
collectionId: parameter.bitwarden_collection_id,
identityKey: parameter.bitwarden_identity_key,
identityFields: parameter.bitwarden_identity_fields,
description: parameter.description,
};
} else if (
parameter.parameter_type ===
WorkflowParameterTypes.Bitwarden_Credit_Card_Data
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.CreditCardData,
collectionId: parameter.bitwarden_collection_id,
itemId: parameter.bitwarden_item_id,
description: parameter.description,
};
} else if (
parameter.parameter_type === WorkflowParameterTypes.Credential
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.Credential,
credentialId: parameter.credential_id,
description: parameter.description,
};
} else if (
parameter.parameter_type ===
WorkflowParameterTypes.OnePassword
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.OnePassword,
vaultId: parameter.vault_id,
itemId: parameter.item_id,
description: parameter.description,
};
} else if (
parameter.parameter_type ===
WorkflowParameterTypes.Bitwarden_Login_Credential
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.Credential,
collectionId: parameter.bitwarden_collection_id,
itemId: parameter.bitwarden_item_id,
urlParameterKey: parameter.url_parameter_key,
description: parameter.description,
};
}
return undefined;
})
.filter(Boolean) as ParametersState
const getInitialParameters = () => {
return workflow.workflow_definition.parameters
.filter((parameter) => isDisplayedInWorkflowEditor(parameter))
.map((parameter) => {
if (parameter.parameter_type === WorkflowParameterTypes.Workflow) {
if (
parameter.workflow_parameter_type ===
WorkflowParameterValueType.CredentialId
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.Credential,
credentialId: parameter.default_value as string,
description: parameter.description,
};
}
workflow={workflow}
/>
</ReactFlowProvider>
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.Workflow,
dataType: parameter.workflow_parameter_type,
defaultValue: parameter.default_value,
description: parameter.description,
};
} else if (
parameter.parameter_type === WorkflowParameterTypes.Context
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.Context,
sourceParameterKey: parameter.source.key,
description: parameter.description,
};
} else if (
parameter.parameter_type ===
WorkflowParameterTypes.Bitwarden_Sensitive_Information
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.Secret,
collectionId: parameter.bitwarden_collection_id,
identityKey: parameter.bitwarden_identity_key,
identityFields: parameter.bitwarden_identity_fields,
description: parameter.description,
};
} else if (
parameter.parameter_type ===
WorkflowParameterTypes.Bitwarden_Credit_Card_Data
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.CreditCardData,
collectionId: parameter.bitwarden_collection_id,
itemId: parameter.bitwarden_item_id,
description: parameter.description,
};
} else if (
parameter.parameter_type === WorkflowParameterTypes.Credential
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.Credential,
credentialId: parameter.credential_id,
description: parameter.description,
};
} else if (
parameter.parameter_type === WorkflowParameterTypes.OnePassword
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.OnePassword,
vaultId: parameter.vault_id,
itemId: parameter.item_id,
description: parameter.description,
};
} else if (
parameter.parameter_type ===
WorkflowParameterTypes.Bitwarden_Login_Credential
) {
return {
key: parameter.key,
parameterType: WorkflowEditorParameterTypes.Credential,
collectionId: parameter.bitwarden_collection_id,
itemId: parameter.bitwarden_item_id,
urlParameterKey: parameter.url_parameter_key,
description: parameter.description,
};
}
return undefined;
})
.filter(Boolean) as ParametersState;
};
return (
<div className="relative flex h-screen w-full">
<div
className={cn("h-full w-full", {
"w-[43.5rem] border-r border-slate-600": debugStore.isDebugMode,
})}
>
<ReactFlowProvider>
<FlowRenderer
initialEdges={elements.edges}
initialNodes={elements.nodes}
initialParameters={getInitialParameters()}
initialTitle={workflow.title}
workflow={workflow}
/>
</ReactFlowProvider>
</div>
{debugStore.isDebugMode && (
<div
className="relative h-full w-full p-6"
style={{ width: "calc(100% - 43.5rem)" }}
>
<WorkflowDebugOverviewWindow />
</div>
)}
</div>
);
}

View File

@@ -10,6 +10,7 @@ import {
ChevronDownIcon,
ChevronUpIcon,
CopyIcon,
Crosshair1Icon,
PlayIcon,
ReloadIcon,
} from "@radix-ui/react-icons";
@@ -18,8 +19,11 @@ import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery";
import { EditableNodeTitle } from "./nodes/components/EditableNodeTitle";
import { useCreateWorkflowMutation } from "../hooks/useCreateWorkflowMutation";
import { convert } from "./workflowEditorUtils";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
type Props = {
debuggableBlockCount: number;
title: string;
parametersPanelOpen: boolean;
onParametersClick: () => void;
@@ -29,6 +33,7 @@ type Props = {
};
function WorkflowHeader({
debuggableBlockCount,
title,
parametersPanelOpen,
onParametersClick,
@@ -36,10 +41,13 @@ function WorkflowHeader({
onTitleChange,
saving,
}: Props) {
const { workflowPermanentId } = useParams();
const { blockLabel: urlBlockLabel, workflowPermanentId } = useParams();
const { data: globalWorkflows } = useGlobalWorkflowsQuery();
const navigate = useNavigate();
const createWorkflowMutation = useCreateWorkflowMutation();
const debugStore = useDebugStore();
const anyBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel.length > 0;
if (!globalWorkflows) {
return null; // this should be loaded already by some other components
@@ -50,7 +58,11 @@ function WorkflowHeader({
);
return (
<div className="flex h-full w-full justify-between rounded-xl bg-slate-elevation2 px-6 py-5">
<div
className={cn(
"flex h-full w-full justify-between rounded-xl bg-slate-elevation2 px-6 py-5",
)}
>
<div className="flex h-full items-center">
<EditableNodeTitle
editable={true}
@@ -85,6 +97,21 @@ function WorkflowHeader({
</Button>
) : (
<>
<Button
size="lg"
variant={debugStore.isDebugMode ? "default" : "tertiary"}
disabled={debuggableBlockCount === 0 || anyBlockIsPlaying}
onClick={() => {
if (debugStore.isDebugMode) {
navigate(`/workflows/${workflowPermanentId}/edit`);
} else {
navigate(`/workflows/${workflowPermanentId}/debug`);
}
}}
>
<Crosshair1Icon className="mr-2 h-6 w-6" />
{debugStore.isDebugMode ? "End" : "Start Debugging"}
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -115,15 +142,17 @@ function WorkflowHeader({
<ChevronDownIcon className="h-6 w-6" />
)}
</Button>
<Button
size="lg"
onClick={() => {
navigate(`/workflows/${workflowPermanentId}/run`);
}}
>
<PlayIcon className="mr-2 h-6 w-6" />
Run
</Button>
{!debugStore.isDebugMode && (
<Button
size="lg"
onClick={() => {
navigate(`/workflows/${workflowPermanentId}/run`);
}}
>
<PlayIcon className="mr-2 h-6 w-6" />
Run
</Button>
)}
</>
)}
</div>

View File

@@ -6,8 +6,6 @@ import {
} 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,
@@ -17,8 +15,6 @@ import {
useReactFlow,
} from "@xyflow/react";
import { useState } from "react";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import type { ActionNode } from "./types";
import { HelpTooltip } from "@/components/HelpTooltip";
import { Checkbox } from "@/components/ui/checkbox";
@@ -28,14 +24,16 @@ import { Switch } from "@/components/ui/switch";
import { placeholders, helpTooltips } from "../../helpContent";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import { AppNode } from "..";
import { getAvailableOutputParameterKeys } from "../../workflowEditorUtils";
import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { RunEngineSelector } from "@/components/EngineSelector";
import { ModelSelector } from "@/components/ModelSelector";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { useParams } from "react-router-dom";
import { NodeHeader } from "../components/NodeHeader";
const urlTooltip =
"The URL Skyvern is navigating to. Leave this field blank to pick up from where the last block left off.";
@@ -44,13 +42,9 @@ const navigationGoalTooltip =
const navigationGoalPlaceholder = 'Input {{ name }} into "Name" field.';
function ActionNode({ id, data }: NodeProps<ActionNode>) {
function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
const { updateNodeData } = useReactFlow();
const { editable } = data;
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const { editable, debuggable, label } = data;
const [inputs, setInputs] = useState({
url: data.url,
navigationGoal: data.navigationGoal,
@@ -64,7 +58,11 @@ function ActionNode({ id, data }: NodeProps<ActionNode>) {
totpIdentifier: data.totpIdentifier,
engine: data.engine,
});
const deleteNodeCallback = useDeleteNodeCallback();
const { blockLabel: urlBlockLabel } = useParams();
const debugStore = useDebugStore();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const nodes = useNodes<AppNode>();
const edges = useEdges();
@@ -94,33 +92,29 @@ function ActionNode({ id, data }: NodeProps<ActionNode>) {
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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.Action}
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">Action Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</header>
<div className="space-y-4">
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={inputs.totpIdentifier}
totpUrl={inputs.totpVerificationUrl}
type={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">
@@ -169,7 +163,13 @@ function ActionNode({ id, data }: NodeProps<ActionNode>) {
</div>
</div>
<Separator />
<Accordion type="single" collapsible>
<Accordion
className={cn({
"pointer-events-none opacity-50": thisBlockIsPlaying,
})}
type="single"
collapsible
>
<AccordionItem value="advanced" className="border-b-0">
<AccordionTrigger className="py-0">
Advanced Settings

View File

@@ -1,6 +1,7 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { RunEngine } from "@/api/types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type ActionNodeData = NodeBaseData & {
url: string;
@@ -19,6 +20,7 @@ export type ActionNodeData = NodeBaseData & {
export type ActionNode = Node<ActionNodeData, "action">;
export const actionNodeDefaultData: ActionNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("action"),
label: "",
url: "",
navigationGoal: "",

View File

@@ -1,23 +1,22 @@
import { Label } from "@/components/ui/label";
import { WorkflowBlockInputSet } from "@/components/WorkflowBlockInputSet";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import { useState } from "react";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import type { CodeBlockNode } from "./types";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
const { updateNodeData } = useReactFlow();
const deleteNodeCallback = useDeleteNodeCallback();
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const [inputs, setInputs] = useState({
code: data.code,
parameterKeys: data.parameterKeys,
@@ -37,32 +36,24 @@ function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
id="b"
className="opacity-0"
/>
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
<div 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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.Code}
className="size-6"
/>
</div>
<div className="flex flex-col gap-1">
<EditableNodeTitle
value={label}
editable={data.editable}
onChange={setLabel}
titleClassName="text-base"
inputClassName="text-base"
/>
<span className="text-xs text-slate-400">Code Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</div>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type="code" // sic: the naming is not consistent
/>
<div className="space-y-2">
<Label className="text-xs text-slate-300">Input Parameters</Label>
<WorkflowBlockInputSet

View File

@@ -1,5 +1,6 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type CodeBlockNodeData = NodeBaseData & {
code: string;
@@ -20,6 +21,7 @@ const codeLead = `
`;
export const codeBlockNodeDefaultData: CodeBlockNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("code"),
editable: true,
label: "",
code: codeLead,

View File

@@ -1,22 +1,21 @@
import { HelpTooltip } from "@/components/HelpTooltip";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import { Handle, NodeProps, Position } from "@xyflow/react";
import { helpTooltips } from "../../helpContent";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import type { DownloadNode } from "./types";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function DownloadNode({ id, data }: NodeProps<DownloadNode>) {
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const deleteNodeCallback = useDeleteNodeCallback();
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
return (
<div>
@@ -32,32 +31,24 @@ function DownloadNode({ id, data }: NodeProps<DownloadNode>) {
id="b"
className="opacity-0"
/>
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
<div 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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.DownloadToS3}
className="size-6"
/>
</div>
<div className="flex flex-col gap-1">
<EditableNodeTitle
value={label}
editable={data.editable}
onChange={setLabel}
titleClassName="text-base"
inputClassName="text-base"
/>
<span className="text-xs text-slate-400">Download Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</div>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type="file_download" // sic: the naming is not consistent
/>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">

View File

@@ -1,6 +1,7 @@
import type { Node } from "@xyflow/react";
import { SKYVERN_DOWNLOAD_DIRECTORY } from "../../constants";
import { NodeBaseData } from "../types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type DownloadNodeData = NodeBaseData & {
url: string;
@@ -9,6 +10,7 @@ export type DownloadNodeData = NodeBaseData & {
export type DownloadNode = Node<DownloadNodeData, "download">;
export const downloadNodeDefaultData: DownloadNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("download_to_s3"),
editable: true,
label: "",
url: SKYVERN_DOWNLOAD_DIRECTORY,

View File

@@ -1,5 +1,4 @@
import { HelpTooltip } from "@/components/HelpTooltip";
import { ExtractIcon } from "@/components/icons/ExtractIcon";
import {
Accordion,
AccordionContent,
@@ -10,8 +9,6 @@ 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 { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import {
Handle,
NodeProps,
@@ -21,8 +18,6 @@ import {
useReactFlow,
} from "@xyflow/react";
import { useState } from "react";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { dataSchemaExampleValue } from "../types";
import type { ExtractionNode } from "./types";
@@ -35,14 +30,19 @@ import { WorkflowDataSchemaInputGroup } from "@/components/DataSchemaInputGroup/
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { RunEngineSelector } from "@/components/EngineSelector";
import { ModelSelector } from "@/components/ModelSelector";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function ExtractionNode({ id, data }: NodeProps<ExtractionNode>) {
function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) {
const { updateNodeData } = useReactFlow();
const { editable } = data;
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const [inputs, setInputs] = useState({
url: data.url,
dataExtractionGoal: data.dataExtractionGoal,
@@ -53,7 +53,6 @@ function ExtractionNode({ id, data }: NodeProps<ExtractionNode>) {
engine: data.engine,
model: data.model,
});
const deleteNodeCallback = useDeleteNodeCallback();
const nodes = useNodes<AppNode>();
const edges = useEdges();
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
@@ -82,29 +81,24 @@ function ExtractionNode({ id, data }: NodeProps<ExtractionNode>) {
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">
<ExtractIcon 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">Extraction Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</header>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type={type}
/>
<div className="space-y-2">
<div className="flex justify-between">
<div className="flex gap-2">

View File

@@ -1,6 +1,7 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { RunEngine } from "@/api/types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type ExtractionNodeData = NodeBaseData & {
url: string;
@@ -16,6 +17,7 @@ export type ExtractionNodeData = NodeBaseData & {
export type ExtractionNode = Node<ExtractionNodeData, "extraction">;
export const extractionNodeDefaultData: ExtractionNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("extraction"),
label: "",
url: "",
dataExtractionGoal: "",

View File

@@ -12,9 +12,6 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
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,
@@ -25,8 +22,6 @@ import {
} from "@xyflow/react";
import { useState } from "react";
import { helpTooltips, placeholders } from "../../helpContent";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { errorMappingExampleValue } from "../types";
import type { FileDownloadNode } from "./types";
import { AppNode } from "..";
@@ -35,6 +30,10 @@ import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { RunEngineSelector } from "@/components/EngineSelector";
import { ModelSelector } from "@/components/ModelSelector";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
const urlTooltip =
"The URL Skyvern is navigating to. Leave this field blank to pick up from where the last block left off.";
@@ -45,11 +44,12 @@ 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 { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const [inputs, setInputs] = useState({
url: data.url,
navigationGoal: data.navigationGoal,
@@ -63,14 +63,10 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
engine: data.engine,
model: data.model,
});
const deleteNodeCallback = useDeleteNodeCallback();
const nodes = useNodes<AppNode>();
const edges = useEdges();
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
function handleChange(key: string, value: unknown) {
if (!editable) {
return;
@@ -93,31 +89,24 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
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={cn(
"transform-origin-center w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4 transition-all",
{
"pointer-events-none bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={inputs.totpIdentifier}
totpUrl={inputs.totpVerificationUrl}
type="file_download" // sic: the naming for this block is not consistent
/>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between">

View File

@@ -1,6 +1,7 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { RunEngine } from "@/api/types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type FileDownloadNodeData = NodeBaseData & {
url: string;
@@ -19,6 +20,7 @@ export type FileDownloadNodeData = NodeBaseData & {
export type FileDownloadNode = Node<FileDownloadNodeData, "fileDownload">;
export const fileDownloadNodeDefaultData: FileDownloadNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("file_download"),
label: "",
url: "",
navigationGoal: "",

View File

@@ -1,28 +1,27 @@
import { HelpTooltip } from "@/components/HelpTooltip";
import { Label } from "@/components/ui/label";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import { useState } from "react";
import { helpTooltips } from "../../helpContent";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import { type FileParserNode } from "./types";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function FileParserNode({ id, data }: NodeProps<FileParserNode>) {
const { updateNodeData } = useReactFlow();
const deleteNodeCallback = useDeleteNodeCallback();
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const [inputs, setInputs] = useState({
fileUrl: data.fileUrl,
});
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
@@ -40,32 +39,24 @@ function FileParserNode({ id, data }: NodeProps<FileParserNode>) {
id="b"
className="opacity-0"
/>
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
<div 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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.FileURLParser}
className="size-6"
/>
</div>
<div className="flex flex-col gap-1">
<EditableNodeTitle
value={label}
editable={data.editable}
onChange={setLabel}
titleClassName="text-base"
inputClassName="text-base"
/>
<span className="text-xs text-slate-400">File Parser Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</div>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type="file_url_parser" // sic: the naming is not consistent
/>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between">

View File

@@ -1,5 +1,6 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type FileParserNodeData = NodeBaseData & {
fileUrl: string;
@@ -8,6 +9,7 @@ export type FileParserNodeData = NodeBaseData & {
export type FileParserNode = Node<FileParserNodeData, "fileParser">;
export const fileParserNodeDefaultData: FileParserNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("file_url_parser"),
editable: true,
label: "",
fileUrl: "",

View File

@@ -1,25 +1,24 @@
import { HelpTooltip } from "@/components/HelpTooltip";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import { helpTooltips } from "../../helpContent";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import { type FileUploadNode } from "./types";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { useState } from "react";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
const { updateNodeData } = useReactFlow();
const deleteNodeCallback = useDeleteNodeCallback();
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const [inputs, setInputs] = useState({
storageType: data.storageType,
@@ -52,32 +51,24 @@ function FileUploadNode({ id, data }: NodeProps<FileUploadNode>) {
id="b"
className="opacity-0"
/>
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
<div 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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.UploadToS3}
className="size-6"
/>
</div>
<div className="flex flex-col gap-1">
<EditableNodeTitle
value={label}
editable={data.editable}
onChange={setLabel}
titleClassName="text-base"
inputClassName="text-base"
/>
<span className="text-xs text-slate-400">File Upload Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</div>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type="file_upload" // sic: the naming is not consistent
/>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">

View File

@@ -1,5 +1,6 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type FileUploadNodeData = NodeBaseData & {
path: string;
@@ -14,6 +15,7 @@ export type FileUploadNodeData = NodeBaseData & {
export type FileUploadNode = Node<FileUploadNodeData, "fileUpload">;
export const fileUploadNodeDefaultData: FileUploadNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("upload_to_s3"),
editable: true,
storageType: "s3",
label: "",

View File

@@ -12,9 +12,6 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import {
Handle,
NodeProps,
@@ -25,10 +22,7 @@ import {
} from "@xyflow/react";
import { useState } from "react";
import { helpTooltips, placeholders } from "../../helpContent";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { errorMappingExampleValue } from "../types";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import type { LoginNode } from "./types";
import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect";
import { AppNode } from "..";
@@ -37,14 +31,19 @@ import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"
import { LoginBlockCredentialSelector } from "./LoginBlockCredentialSelector";
import { RunEngineSelector } from "@/components/EngineSelector";
import { ModelSelector } from "@/components/ModelSelector";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function LoginNode({ id, data }: NodeProps<LoginNode>) {
function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
const { updateNodeData } = useReactFlow();
const { editable } = data;
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const [inputs, setInputs] = useState({
url: data.url,
navigationGoal: data.navigationGoal,
@@ -59,7 +58,6 @@ function LoginNode({ id, data }: NodeProps<LoginNode>) {
engine: data.engine,
model: data.model,
});
const deleteNodeCallback = useDeleteNodeCallback();
const nodes = useNodes<AppNode>();
const edges = useEdges();
@@ -88,32 +86,24 @@ function LoginNode({ id, data }: NodeProps<LoginNode>) {
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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.Login}
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={cn(
"transform-origin-center w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4 transition-all",
{
"pointer-events-none bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={inputs.totpIdentifier}
totpUrl={inputs.totpVerificationUrl}
type={type}
/>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between">

View File

@@ -1,6 +1,7 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { RunEngine } from "@/api/types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type LoginNodeData = NodeBaseData & {
url: string;
@@ -20,6 +21,7 @@ export type LoginNodeData = NodeBaseData & {
export type LoginNode = Node<LoginNodeData, "login">;
export const loginNodeDefaultData: LoginNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("login"),
label: "",
url: "",
navigationGoal:

View File

@@ -1,9 +1,6 @@
import { HelpTooltip } from "@/components/HelpTooltip";
import { Label } from "@/components/ui/label";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import type { Node } from "@xyflow/react";
import {
Handle,
@@ -14,14 +11,15 @@ import {
} from "@xyflow/react";
import { AppNode } from "..";
import { helpTooltips } from "../../helpContent";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import type { LoopNode } from "./types";
import { useState } from "react";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { Checkbox } from "@/components/ui/checkbox";
import { getLoopNodeWidth } from "../../workflowEditorUtils";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function LoopNode({ id, data }: NodeProps<LoopNode>) {
const { updateNodeData } = useReactFlow();
@@ -30,14 +28,15 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
if (!node) {
throw new Error("Node not found"); // not possible
}
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const [inputs, setInputs] = useState({
loopVariableReference: data.loopVariableReference,
});
const deleteNodeCallback = useDeleteNodeCallback();
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
@@ -91,32 +90,24 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
}}
>
<div className="flex w-full justify-center">
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
<div 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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.ForLoop}
className="size-6"
/>
</div>
<div className="flex flex-col gap-1">
<EditableNodeTitle
value={label}
editable={data.editable}
onChange={setLabel}
titleClassName="text-base"
inputClassName="text-base"
/>
<span className="text-xs text-slate-400">Loop Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</div>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type="for_loop" // sic: the naming is not consistent
/>
<div className="space-y-2">
<div className="flex justify-between">
<div className="flex gap-2">

View File

@@ -1,5 +1,6 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type LoopNodeData = NodeBaseData & {
loopValue: string;
@@ -10,6 +11,7 @@ export type LoopNodeData = NodeBaseData & {
export type LoopNode = Node<LoopNodeData, "loop">;
export const loopNodeDefaultData: LoopNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("for_loop"),
editable: true,
label: "",
loopValue: "",

View File

@@ -13,9 +13,6 @@ import { Switch } from "@/components/ui/switch";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import {
Handle,
NodeProps,
@@ -26,10 +23,7 @@ import {
} from "@xyflow/react";
import { useState } from "react";
import { helpTooltips, placeholders } from "../../helpContent";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { errorMappingExampleValue } from "../types";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import type { NavigationNode } from "./types";
import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect";
import { AppNode } from "..";
@@ -37,32 +31,36 @@ import { getAvailableOutputParameterKeys } from "../../workflowEditorUtils";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { RunEngineSelector } from "@/components/EngineSelector";
import { ModelSelector } from "@/components/ModelSelector";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { useParams } from "react-router-dom";
import { NodeHeader } from "../components/NodeHeader";
function NavigationNode({ id, data }: NodeProps<NavigationNode>) {
function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
const { blockLabel: urlBlockLabel } = useParams();
const debugStore = useDebugStore();
const { updateNodeData } = useReactFlow();
const { editable } = data;
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const { editable, debuggable, label } = data;
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const [inputs, setInputs] = useState({
url: data.url,
navigationGoal: data.navigationGoal,
errorCodeMapping: data.errorCodeMapping,
maxStepsOverride: data.maxStepsOverride,
allowDownloads: data.allowDownloads,
continueOnFailure: data.continueOnFailure,
cacheActions: data.cacheActions,
downloadSuffix: data.downloadSuffix,
totpVerificationUrl: data.totpVerificationUrl,
totpIdentifier: data.totpIdentifier,
completeCriterion: data.completeCriterion,
terminateCriterion: data.terminateCriterion,
continueOnFailure: data.continueOnFailure,
downloadSuffix: data.downloadSuffix,
engine: data.engine,
model: data.model,
errorCodeMapping: data.errorCodeMapping,
includeActionHistoryInVerification: data.includeActionHistoryInVerification,
maxStepsOverride: data.maxStepsOverride,
model: data.model,
navigationGoal: data.navigationGoal,
terminateCriterion: data.terminateCriterion,
totpIdentifier: data.totpIdentifier,
totpVerificationUrl: data.totpVerificationUrl,
url: data.url,
});
const deleteNodeCallback = useDeleteNodeCallback();
const nodes = useNodes<AppNode>();
const edges = useEdges();
@@ -92,33 +90,29 @@ function NavigationNode({ id, data }: NodeProps<NavigationNode>) {
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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.Navigation}
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">Navigation Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</header>
<div className="space-y-4">
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
editable={editable}
disabled={elideFromDebugging}
nodeId={id}
totpIdentifier={inputs.totpIdentifier}
totpUrl={inputs.totpVerificationUrl}
type={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">
@@ -170,7 +164,13 @@ function NavigationNode({ id, data }: NodeProps<NavigationNode>) {
</div>
</div>
<Separator />
<Accordion type="single" collapsible>
<Accordion
className={cn({
"pointer-events-none opacity-50": thisBlockIsPlaying,
})}
type="single"
collapsible
>
<AccordionItem value="advanced" className="border-b-0">
<AccordionTrigger className="py-0">
Advanced Settings

View File

@@ -1,6 +1,7 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { RunEngine } from "@/api/types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type NavigationNodeData = NodeBaseData & {
url: string;
@@ -23,6 +24,7 @@ export type NavigationNodeData = NodeBaseData & {
export type NavigationNode = Node<NavigationNodeData, "navigation">;
export const navigationNodeDefaultData: NavigationNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("navigation"),
label: "",
url: "",
navigationGoal: "",

View File

@@ -1,31 +1,30 @@
import { HelpTooltip } from "@/components/HelpTooltip";
import { Label } from "@/components/ui/label";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import { useState } from "react";
import { helpTooltips } from "../../helpContent";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { dataSchemaExampleForFileExtraction } from "../types";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import { type PDFParserNode } from "./types";
import { WorkflowDataSchemaInputGroup } from "@/components/DataSchemaInputGroup/WorkflowDataSchemaInputGroup";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function PDFParserNode({ id, data }: NodeProps<PDFParserNode>) {
const { updateNodeData } = useReactFlow();
const deleteNodeCallback = useDeleteNodeCallback();
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const [inputs, setInputs] = useState({
fileUrl: data.fileUrl,
jsonSchema: data.jsonSchema,
});
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
function handleChange(key: string, value: unknown) {
if (!data.editable) {
@@ -51,32 +50,24 @@ function PDFParserNode({ id, data }: NodeProps<PDFParserNode>) {
id="b"
className="opacity-0"
/>
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
<div 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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.PDFParser}
className="size-6"
/>
</div>
<div className="flex flex-col gap-1">
<EditableNodeTitle
value={label}
editable={data.editable}
onChange={setLabel}
titleClassName="text-base"
inputClassName="text-base"
/>
<span className="text-xs text-slate-400">PDF Parser Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</div>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type="pdf_parser" // sic: the naming is not consistent
/>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between">

View File

@@ -1,6 +1,7 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { AppNode } from "..";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type PDFParserNodeData = NodeBaseData & {
fileUrl: string;
@@ -10,6 +11,7 @@ export type PDFParserNodeData = NodeBaseData & {
export type PDFParserNode = Node<PDFParserNodeData, "pdfParser">;
export const pdfParserNodeDefaultData: PDFParserNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("pdf_parser"),
editable: true,
label: "",
fileUrl: "",

View File

@@ -1,27 +1,26 @@
import { HelpTooltip } from "@/components/HelpTooltip";
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 { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import { useState } from "react";
import { helpTooltips } from "../../helpContent";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import { type SendEmailNode } from "./types";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
const { updateNodeData } = useReactFlow();
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const deleteNodeCallback = useDeleteNodeCallback();
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const [inputs, setInputs] = useState({
recipients: data.recipients,
subject: data.subject,
@@ -53,32 +52,24 @@ function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
id="b"
className="opacity-0"
/>
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
<div 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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.SendEmail}
className="size-6"
/>
</div>
<div className="flex flex-col gap-1">
<EditableNodeTitle
value={label}
editable={data.editable}
onChange={setLabel}
titleClassName="text-base"
inputClassName="text-base"
/>
<span className="text-xs text-slate-400">Send Email Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</div>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type="send_email" // sic: the naming is not consistent
/>
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-slate-300">Recipients</Label>

View File

@@ -8,6 +8,7 @@ import {
SMTP_USERNAME_PARAMETER_KEY,
} from "../../constants";
import { NodeBaseData } from "../types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type SendEmailNodeData = NodeBaseData & {
recipients: string;
@@ -24,6 +25,7 @@ export type SendEmailNodeData = NodeBaseData & {
export type SendEmailNode = Node<SendEmailNodeData, "sendEmail">;
export const sendEmailNodeDefaultData: SendEmailNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("send_email"),
recipients: "",
subject: "",
body: "",

View File

@@ -7,7 +7,7 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ProxyLocation } from "@/api/types";
import { useQuery } from "@tanstack/react-query";
import { Label } from "@/components/ui/label";
@@ -22,8 +22,10 @@ import { ModelSelector } from "@/components/ModelSelector";
import { WorkflowModel } from "@/routes/workflows/types/workflowTypes";
import { MAX_SCREENSHOT_SCROLLS_DEFAULT } from "../Taskv2Node/types";
import { KeyValueInput } from "@/components/KeyValueInput";
import { useWorkflowSettingsStore } from "@/store/WorkflowSettingsStore";
function StartNode({ id, data }: NodeProps<StartNode>) {
const workflowSettingsStore = useWorkflowSettingsStore();
const credentialGetter = useCredentialGetter();
const { updateNodeData } = useReactFlow();
@@ -59,6 +61,11 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
extraHttpHeaders: data.withWorkflowSettings ? data.extraHttpHeaders : null,
});
useEffect(() => {
workflowSettingsStore.setWorkflowSettings(inputs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputs]);
function handleChange(key: string, value: unknown) {
if (!data.editable) {
return;

View File

@@ -13,9 +13,6 @@ import { Switch } from "@/components/ui/switch";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import {
Handle,
NodeProps,
@@ -28,31 +25,31 @@ import { useState } from "react";
import { AppNode } from "..";
import { helpTooltips, placeholders } from "../../helpContent";
import { getAvailableOutputParameterKeys } from "../../workflowEditorUtils";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { dataSchemaExampleValue, errorMappingExampleValue } from "../types";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import { ParametersMultiSelect } from "./ParametersMultiSelect";
import type { TaskNode } from "./types";
import { WorkflowDataSchemaInputGroup } from "@/components/DataSchemaInputGroup/WorkflowDataSchemaInputGroup";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { RunEngineSelector } from "@/components/EngineSelector";
import { ModelSelector } from "@/components/ModelSelector";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function TaskNode({ id, data }: NodeProps<TaskNode>) {
function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
const { updateNodeData } = useReactFlow();
const { editable } = data;
const deleteNodeCallback = useDeleteNodeCallback();
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const nodes = useNodes<AppNode>();
const edges = useEdges();
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const [inputs, setInputs] = useState({
url: data.url,
navigationGoal: data.navigationGoal,
@@ -95,32 +92,24 @@ function TaskNode({ id, data }: NodeProps<TaskNode>) {
id="b"
className="opacity-0"
/>
<div className="w-[30rem] space-y-2 rounded-lg bg-slate-elevation3 px-6 py-4">
<div 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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.Task}
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">Task Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</div>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={inputs.totpIdentifier}
totpUrl={inputs.totpVerificationUrl}
type={type}
/>
<Accordion type="multiple" defaultValue={["content", "extraction"]}>
<AccordionItem value="content">
<AccordionTrigger>Content</AccordionTrigger>

View File

@@ -1,7 +1,7 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { RunEngine } from "@/api/types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type TaskNodeData = NodeBaseData & {
url: string;
navigationGoal: string;
@@ -25,6 +25,7 @@ export type TaskNodeData = NodeBaseData & {
export type TaskNode = Node<TaskNodeData, "task">;
export const taskNodeDefaultData: TaskNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("task"),
url: "",
navigationGoal: "",
dataExtractionGoal: "",

View File

@@ -9,28 +9,25 @@ import {
import { Input } from "@/components/ui/input";
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 { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import { useState } from "react";
import { helpTooltips, placeholders } from "../../helpContent";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { NodeActionMenu } from "../NodeActionMenu";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { MAX_STEPS_DEFAULT, type Taskv2Node } from "./types";
import { ModelSelector } from "@/components/ModelSelector";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function Taskv2Node({ id, data, type }: NodeProps<Taskv2Node>) {
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const { updateNodeData } = useReactFlow();
const { editable } = data;
const deleteNodeCallback = useDeleteNodeCallback();
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const [inputs, setInputs] = useState({
@@ -64,34 +61,24 @@ function Taskv2Node({ id, data, type }: NodeProps<Taskv2Node>) {
id="b"
className="opacity-0"
/>
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
<div 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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.Taskv2}
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">
Navigation v2 Block
</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</div>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={inputs.totpIdentifier}
totpUrl={inputs.totpVerificationUrl}
type="task_v2" // sic: the naming is not consistent
/>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between">

View File

@@ -1,5 +1,6 @@
import { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export const MAX_STEPS_DEFAULT = 25;
export const MAX_SCREENSHOT_SCROLLS_DEFAULT = 3;
@@ -16,6 +17,7 @@ export type Taskv2NodeData = NodeBaseData & {
export type Taskv2Node = Node<Taskv2NodeData, "taskv2">;
export const taskv2NodeDefaultData: Taskv2NodeData = {
debuggable: debuggableWorkflowBlockTypes.has("task_v2"),
label: "",
continueOnFailure: false,
editable: true,

View File

@@ -1,9 +1,6 @@
import { HelpTooltip } from "@/components/HelpTooltip";
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 { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import {
Handle,
NodeProps,
@@ -16,21 +13,26 @@ import { useState } from "react";
import { AppNode } from "..";
import { helpTooltips } from "../../helpContent";
import { getAvailableOutputParameterKeys } from "../../workflowEditorUtils";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import { type TextPromptNode } from "./types";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { WorkflowDataSchemaInputGroup } from "@/components/DataSchemaInputGroup/WorkflowDataSchemaInputGroup";
import { dataSchemaExampleValue } from "../types";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { ModelSelector } from "@/components/ModelSelector";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
const { updateNodeData } = useReactFlow();
const { editable } = data;
const deleteNodeCallback = useDeleteNodeCallback();
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const [inputs, setInputs] = useState({
prompt: data.prompt,
jsonSchema: data.jsonSchema,
@@ -41,11 +43,6 @@ function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
const edges = useEdges();
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
function handleChange(key: string, value: unknown) {
if (!editable) {
return;
@@ -70,32 +67,24 @@ function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
id="b"
className="opacity-0"
/>
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
<div 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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.TextPrompt}
className="size-6"
/>
</div>
<div className="flex flex-col gap-1">
<EditableNodeTitle
value={label}
editable={data.editable}
onChange={setLabel}
titleClassName="text-base"
inputClassName="text-base"
/>
<span className="text-xs text-slate-400">Text Prompt Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</div>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type="text_prompt" // sic: the naming is not consistent
/>
<div className="space-y-2">
<div className="flex justify-between">
<div className="flex gap-2">

View File

@@ -1,6 +1,7 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { AppNode } from "..";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type TextPromptNodeData = NodeBaseData & {
prompt: string;
@@ -11,6 +12,7 @@ export type TextPromptNodeData = NodeBaseData & {
export type TextPromptNode = Node<TextPromptNodeData, "textPrompt">;
export const textPromptNodeDefaultData: TextPromptNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("text_prompt"),
editable: true,
label: "",
prompt: "",

View File

@@ -1,25 +1,23 @@
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import type { URLNode } from "./types";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { useState } from "react";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { Label } from "@/components/ui/label";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { placeholders } from "../../helpContent";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function URLNode({ id, data, type }: NodeProps<URLNode>) {
const { updateNodeData } = useReactFlow();
const { editable } = data;
const deleteNodeCallback = useDeleteNodeCallback();
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
const [inputs, setInputs] = useState({
@@ -48,32 +46,24 @@ function URLNode({ id, data, type }: NodeProps<URLNode>) {
id="b"
className="opacity-0"
/>
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
<div 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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.URL}
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">Go to URL Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</div>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type="goto_url"
/>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between">

View File

@@ -1,5 +1,6 @@
import { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type URLNodeData = NodeBaseData & {
url: string;
@@ -8,6 +9,7 @@ export type URLNodeData = NodeBaseData & {
export type URLNode = Node<URLNodeData, "url">;
export const urlNodeDefaultData: URLNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("goto_url"),
label: "",
continueOnFailure: false,
url: "",

View File

@@ -1,22 +1,21 @@
import { HelpTooltip } from "@/components/HelpTooltip";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import { Handle, NodeProps, Position } from "@xyflow/react";
import { helpTooltips } from "../../helpContent";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import { type UploadNode } from "./types";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function UploadNode({ id, data }: NodeProps<UploadNode>) {
const deleteNodeCallback = useDeleteNodeCallback();
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
return (
<div>
@@ -32,32 +31,24 @@ function UploadNode({ id, data }: NodeProps<UploadNode>) {
id="b"
className="opacity-0"
/>
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
<div 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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.UploadToS3}
className="size-6"
/>
</div>
<div className="flex flex-col gap-1">
<EditableNodeTitle
value={label}
editable={data.editable}
onChange={setLabel}
titleClassName="text-base"
inputClassName="text-base"
/>
<span className="text-xs text-slate-400">Upload Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</div>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type="upload_to_s3" // sic: the naming is not consistent
/>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">

View File

@@ -1,6 +1,7 @@
import type { Node } from "@xyflow/react";
import { SKYVERN_DOWNLOAD_DIRECTORY } from "../../constants";
import { NodeBaseData } from "../types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type UploadNodeData = NodeBaseData & {
path: string;
@@ -10,6 +11,7 @@ export type UploadNodeData = NodeBaseData & {
export type UploadNode = Node<UploadNodeData, "upload">;
export const uploadNodeDefaultData: UploadNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("file_upload"),
editable: true,
label: "",
path: SKYVERN_DOWNLOAD_DIRECTORY,

View File

@@ -11,9 +11,6 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import {
Handle,
NodeProps,
@@ -24,31 +21,32 @@ import {
} from "@xyflow/react";
import { useState } from "react";
import { helpTooltips } from "../../helpContent";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { errorMappingExampleValue } from "../types";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import type { ValidationNode } from "./types";
import { AppNode } from "..";
import { getAvailableOutputParameterKeys } from "../../workflowEditorUtils";
import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { ModelSelector } from "@/components/ModelSelector";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function ValidationNode({ id, data }: NodeProps<ValidationNode>) {
function ValidationNode({ id, data, type }: NodeProps<ValidationNode>) {
const { updateNodeData } = useReactFlow();
const { editable } = data;
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const [inputs, setInputs] = useState({
completeCriterion: data.completeCriterion,
terminateCriterion: data.terminateCriterion,
errorCodeMapping: data.errorCodeMapping,
model: data.model,
});
const deleteNodeCallback = useDeleteNodeCallback();
const nodes = useNodes<AppNode>();
const edges = useEdges();
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
@@ -77,32 +75,24 @@ function ValidationNode({ id, data }: NodeProps<ValidationNode>) {
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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.Validation}
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">Validation Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</header>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type={type}
/>
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-slate-300">Complete if...</Label>

View File

@@ -1,6 +1,6 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type ValidationNodeData = NodeBaseData & {
completeCriterion: string;
terminateCriterion: string;
@@ -11,6 +11,7 @@ export type ValidationNodeData = NodeBaseData & {
export type ValidationNode = Node<ValidationNodeData, "validation">;
export const validationNodeDefaultData: ValidationNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("validation"),
label: "",
completeCriterion: "",
terminateCriterion: "",

View File

@@ -1,29 +1,27 @@
import { HelpTooltip } from "@/components/HelpTooltip";
import { Label } from "@/components/ui/label";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import { useState } from "react";
import { helpTooltips } from "../../helpContent";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import type { WaitNode } from "./types";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { Input } from "@/components/ui/input";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
import { NodeHeader } from "../components/NodeHeader";
import { useParams } from "react-router-dom";
function WaitNode({ id, data }: NodeProps<WaitNode>) {
function WaitNode({ id, data, type }: NodeProps<WaitNode>) {
const { updateNodeData } = useReactFlow();
const { editable } = data;
const [label, setLabel] = useNodeLabelChangeHandler({
id,
initialValue: data.label,
});
const { debuggable, editable, label } = data;
const debugStore = useDebugStore();
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
const { blockLabel: urlBlockLabel } = useParams();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const [inputs, setInputs] = useState({
waitInSeconds: data.waitInSeconds,
});
const deleteNodeCallback = useDeleteNodeCallback();
function handleChange(key: string, value: unknown) {
if (!editable) {
@@ -49,32 +47,24 @@ function WaitNode({ id, data }: NodeProps<WaitNode>) {
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">
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.Wait}
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">Wait Block</span>
</div>
</div>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(id);
}}
/>
</header>
<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 bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
disabled={elideFromDebugging}
editable={editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type={type}
/>
<div className="space-y-2">
<div className="flex justify-between">
<div className="flex gap-2">

View File

@@ -1,6 +1,6 @@
import type { Node } from "@xyflow/react";
import { NodeBaseData } from "../types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export type WaitNodeData = NodeBaseData & {
waitInSeconds: string;
};
@@ -8,6 +8,7 @@ export type WaitNodeData = NodeBaseData & {
export type WaitNode = Node<WaitNodeData, "wait">;
export const waitNodeDefaultData: WaitNodeData = {
debuggable: debuggableWorkflowBlockTypes.has("wait"),
label: "",
continueOnFailure: false,
editable: true,

View File

@@ -0,0 +1,391 @@
import { AxiosError } from "axios";
import { ReloadIcon, PlayIcon, StopIcon } from "@radix-ui/react-icons";
import { useEffect } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { ProxyLocation } from "@/api/types";
import { Timer } from "@/components/Timer";
import { toast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import {
debuggableWorkflowBlockTypes,
type WorkflowBlockType,
type WorkflowApiResponse,
} from "@/routes/workflows/types/workflowTypes";
import { getInitialValues } from "@/routes/workflows/utils";
import { useDebugStore } from "@/store/useDebugStore";
import {
useWorkflowSettingsStore,
type WorkflowSettingsState,
} from "@/store/WorkflowSettingsStore";
import { cn } from "@/util/utils";
import {
statusIsAFailureType,
statusIsFinalized,
statusIsRunningOrQueued,
} from "@/routes/tasks/types";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu";
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
import { lsKeys } from "@/util/env";
interface Props {
blockLabel: string; // today, this + wpid act as the identity of a block
disabled?: boolean;
editable: boolean;
nodeId: string;
totpIdentifier: string | null;
totpUrl: string | null;
type: WorkflowBlockType;
}
type Payload = Record<string, unknown> & {
block_labels: string[];
browser_session_id: string | null;
extra_http_headers: Record<string, string> | null;
max_screenshot_scrolls: number | null;
parameters: Record<string, unknown>;
proxy_location: ProxyLocation;
totp_identifier: string | null;
totp_url: string | null;
webhook_url: string | null;
workflow_id: string;
};
const blockTypeToTitle = (type: WorkflowBlockType): string => {
const parts = type.split("_");
const capCased = parts
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
return `${capCased} Block`;
};
const getPayload = (opts: {
blockLabel: string;
parameters: Record<string, unknown>;
totpIdentifier: string | null;
totpUrl: string | null;
workflowPermanentId: string;
workflowSettings: WorkflowSettingsState;
}): Payload => {
const webhook_url = opts.workflowSettings.webhookCallbackUrl.trim();
let extraHttpHeaders = null;
try {
extraHttpHeaders =
opts.workflowSettings.extraHttpHeaders === null
? null
: JSON.parse(opts.workflowSettings.extraHttpHeaders);
} catch (e: unknown) {
toast({
variant: "warning",
title: "Extra HTTP Headers",
description: "Invalid extra HTTP Headers JSON",
});
}
const stored = localStorage.getItem(lsKeys.optimisticBrowserSession);
let browserSessionId: string | null = null;
try {
const parsed = JSON.parse(stored ?? "");
const { browser_session_id } = parsed;
browserSessionId = browser_session_id as string;
} catch {
// pass
}
if (!browserSessionId) {
toast({
variant: "warning",
title: "Error",
description: "No browser session ID found",
});
} else {
toast({
variant: "default",
title: "Success",
description: `Browser session ID found: ${browserSessionId}`,
});
}
const payload: Payload = {
block_labels: [opts.blockLabel],
browser_session_id: browserSessionId,
extra_http_headers: extraHttpHeaders,
max_screenshot_scrolls: opts.workflowSettings.maxScreenshotScrollingTimes,
parameters: opts.parameters,
proxy_location: opts.workflowSettings.proxyLocation,
totp_identifier: opts.totpIdentifier,
totp_url: opts.totpUrl,
webhook_url: webhook_url.length > 0 ? webhook_url : null,
workflow_id: opts.workflowPermanentId,
};
return payload;
};
function NodeHeader({
blockLabel,
disabled = false,
editable,
nodeId,
totpIdentifier,
totpUrl,
type,
}: Props) {
const {
blockLabel: urlBlockLabel,
workflowPermanentId,
workflowRunId,
} = useParams();
const debugStore = useDebugStore();
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === blockLabel;
const anyBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel.length > 0;
const workflowSettingsStore = useWorkflowSettingsStore();
const [label, setLabel] = useNodeLabelChangeHandler({
id: nodeId,
initialValue: blockLabel,
});
const blockTitle = blockTypeToTitle(type);
const deleteNodeCallback = useDeleteNodeCallback();
const credentialGetter = useCredentialGetter();
const navigate = useNavigate();
const queryClient = useQueryClient();
const location = useLocation();
const isDebuggable = debuggableWorkflowBlockTypes.has(type);
const { data: workflowRun } = useWorkflowRunQuery();
const workflowRunIsRunningOrQueued =
workflowRun && statusIsRunningOrQueued(workflowRun);
useEffect(() => {
if (!workflowRun || !workflowPermanentId || !workflowRunId) {
return;
}
if (
workflowRunId === workflowRun?.workflow_run_id &&
statusIsFinalized(workflowRun)
) {
navigate(`/workflows/${workflowPermanentId}/debug`);
if (statusIsAFailureType(workflowRun)) {
toast({
variant: "destructive",
title: `Workflow Block ${urlBlockLabel}: ${workflowRun.status}`,
description: `Reason: ${workflowRun.failure_reason}`,
});
} else if (statusIsFinalized(workflowRun)) {
toast({
variant: "success",
title: `Workflow Block ${urlBlockLabel}: ${workflowRun.status}`,
});
}
}
}, [
urlBlockLabel,
navigate,
workflowPermanentId,
workflowRun,
workflowRunId,
]);
const runBlock = useMutation({
mutationFn: async () => {
if (!workflowPermanentId) {
console.error("There is no workflowPermanentId");
return;
}
const workflow = await queryClient.fetchQuery<WorkflowApiResponse>({
queryKey: ["block", "workflow", workflowPermanentId],
queryFn: async () => {
const client = await getClient(credentialGetter);
return client
.get(`/workflows/${workflowPermanentId}`)
.then((response) => response.data);
},
});
const workflowParameters =
workflow?.workflow_definition.parameters.filter(
(parameter) => parameter.parameter_type === "workflow",
);
const parameters = getInitialValues(location, workflowParameters ?? []);
const client = await getClient(credentialGetter, "sans-api-v1");
const body = getPayload({
blockLabel,
parameters,
totpIdentifier,
totpUrl,
workflowPermanentId,
workflowSettings: workflowSettingsStore,
});
return await client.post<Payload, { data: { run_id: string } }>(
"/run/workflows/blocks",
body,
);
},
onSuccess: (response) => {
if (!response) {
console.error("No response");
return;
}
toast({
variant: "success",
title: "Workflow block run started",
description: "The workflow block run has been started successfully",
});
navigate(
`/workflows/${workflowPermanentId}/${response.data.run_id}/${label}/debug`,
);
},
onError: (error: AxiosError) => {
const detail = (error.response?.data as { detail?: string })?.detail;
toast({
variant: "destructive",
title: "Failed to start workflow block run",
description: detail ?? error.message,
});
},
});
const cancelBlock = useMutation({
mutationFn: async () => {
const client = await getClient(credentialGetter);
return client
.post(`/workflows/runs/${workflowRunId}/cancel`)
.then((response) => response.data);
},
onSuccess: () => {
toast({
variant: "success",
title: "Workflow Canceled",
description: "The workflow has been successfully canceled.",
});
navigate(`/workflows/${workflowPermanentId}/debug`);
},
onError: (error) => {
toast({
variant: "destructive",
title: "Error",
description: error.message,
});
},
});
const handleOnPlay = () => {
runBlock.mutate();
};
const handleOnCancel = () => {
cancelBlock.mutate();
};
return (
<>
{thisBlockIsPlaying && (
<div className="flex w-full animate-[auto-height_1s_ease-in-out_forwards] items-center justify-between overflow-hidden">
<div className="pb-4">
<Timer />
</div>
<div className="pb-4">{workflowRun?.status ?? "pending"}</div>
</div>
)}
<header className="!mt-0 flex h-[2.75rem] justify-between gap-2">
<div
className={cn("flex gap-2", {
"opacity-50": thisBlockIsPlaying,
})}
>
<div className="flex h-[2.75rem] w-[2.75rem] items-center justify-center rounded border border-slate-600">
<WorkflowBlockIcon workflowBlockType={type} className="size-6" />
</div>
<div className="flex flex-col gap-1">
<EditableNodeTitle
value={blockLabel}
editable={editable}
onChange={setLabel}
titleClassName="text-base"
inputClassName="text-base"
/>
<span className="text-xs text-slate-400">{blockTitle}</span>
</div>
</div>
<div className="pointer-events-auto ml-auto flex items-center gap-2">
{thisBlockIsPlaying && workflowRunIsRunningOrQueued && (
<div className="ml-auto">
<button className="rounded p-1 hover:bg-red-500 hover:text-black disabled:opacity-50">
{cancelBlock.isPending ? (
<ReloadIcon className="size-6 animate-spin" />
) : (
<StopIcon
className="size-6"
onClick={() => {
handleOnCancel();
}}
/>
)}
</button>
</div>
)}
{debugStore.isDebugMode && isDebuggable && (
<button
disabled={anyBlockIsPlaying}
className={cn("rounded p-1 disabled:opacity-50", {
"hover:bg-muted": anyBlockIsPlaying,
})}
>
{runBlock.isPending ? (
<ReloadIcon className="size-6 animate-spin" />
) : (
<PlayIcon
className={cn("size-6", {
"fill-gray-500 text-gray-500":
anyBlockIsPlaying || !workflowPermanentId,
})}
onClick={() => {
handleOnPlay();
}}
/>
)}
</button>
)}
{disabled || debugStore.isDebugMode ? null : (
<div>
<div
className={cn("rounded p-1 hover:bg-muted", {
"pointer-events-none opacity-50": anyBlockIsPlaying,
})}
>
<NodeActionMenu
onDelete={() => {
deleteNodeCallback(nodeId);
}}
/>
</div>
</div>
)}
</div>
</header>
</>
);
}
export { NodeHeader };

View File

@@ -2,6 +2,7 @@ import { WorkflowBlockType } from "../../types/workflowTypes";
import type { WorkflowModel } from "../../types/workflowTypes";
export type NodeBaseData = {
debuggable: boolean;
label: string;
continueOnFailure: boolean;
editable: boolean;

View File

@@ -0,0 +1,392 @@
/**
* NOTE(jdo): this is not a "panel", in the react-flow sense. It's a floating,
* draggable, resizeable window, like on a desktop. But I am putting it here
* for now.
*/
import { Resizable } from "re-resizable";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { flushSync } from "react-dom";
import Draggable from "react-draggable";
import { useParams } from "react-router-dom";
import { useDebugStore } from "@/store/useDebugStore";
import { cn } from "@/util/utils";
/**
* TODO(jdo): extract this to a reusable Window component.
*/
function WorkflowDebugOverviewWindow() {
const debugStore = useDebugStore();
const isDebugMode = debugStore.isDebugMode;
const [position, setPosition] = useState({ x: 0, y: 0 });
const [size, setSize] = useState({
left: 0,
top: 0,
width: 800,
height: 680,
});
const [lastSize, setLastSize] = useState({
left: 0,
top: 0,
width: 800,
height: 680,
});
const [sizeBeforeMaximize, setSizeBeforeMaximize] = useState({
left: 0,
top: 0,
width: 800,
height: 680,
});
const [isMaximized, setIsMaximized] = useState(false);
const parentRef = useRef<HTMLDivElement>(null);
const resizableRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragStartSize, setDragStartSize] = useState<
| {
left: number;
top: number;
width: number;
height: number;
}
| undefined
>(undefined);
const onResize = useCallback(
({
delta,
direction,
size,
}: {
delta: { width: number; height: number };
direction: string;
size: { left: number; top: number; width: number; height: number };
}) => {
if (!dragStartSize) {
return;
}
const top =
resizableRef.current?.parentElement?.offsetTop ?? lastSize.top;
const left =
resizableRef.current?.parentElement?.offsetLeft ?? lastSize.left;
const width =
resizableRef.current?.parentElement?.offsetWidth ?? lastSize.width;
const height =
resizableRef.current?.parentElement?.offsetHeight ?? lastSize.height;
setLastSize({ top, left, width, height });
const directions = ["top", "left", "topLeft", "bottomLeft", "topRight"];
if (directions.indexOf(direction) !== -1) {
let newLeft = size.left;
let newTop = size.top;
if (direction === "bottomLeft") {
newLeft = dragStartSize.left - delta.width;
} else if (direction === "topRight") {
newTop = dragStartSize.top - delta.height;
} else {
newLeft = dragStartSize.left - delta.width;
newTop = dragStartSize.top - delta.height;
}
// TODO(follow-up): https://github.com/bokuweb/re-resizable/issues/868
flushSync(() => {
setSize({
...size,
left: newLeft,
top: newTop,
});
setPosition({ x: newLeft, y: newTop });
});
} else {
flushSync(() => {
setSize({
...size,
left: size.left,
top: size.top,
});
setPosition({ x: size.left, y: size.top });
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[dragStartSize],
);
/**
* Forces the sizing to take place after the resize is complete.
*
* TODO(jdo): emits warnings in the dev console. ref: https://github.com/bokuweb/re-resizable/issues/868
*/
useEffect(() => {
if (isResizing) {
return;
}
const width = lastSize.width;
const height = lastSize.height;
flushSync(() => {
setSize({
...size,
width,
height,
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isResizing]);
const onDrag = (position: { x: number; y: number }) => {
if (isMaximized) {
restore();
return;
}
setPosition({ x: position.x, y: position.y });
setSize({
...size,
left: position.x,
top: position.y,
});
setLastSize({
...size,
left: position.x,
top: position.y,
});
};
const onDblClickHeader = () => {
if (!isMaximized) {
maximize();
} else {
restore();
}
};
const maximize = () => {
const parent = parentRef.current;
if (!parent) {
console.warn("No parent - cannot maximize.");
return;
}
setSizeBeforeMaximize({
...size,
left: position.x,
top: position.y,
});
setIsMaximized(true);
setSize({
left: 0,
top: 0,
// has to take into account padding...hack
width: parent.offsetWidth - 16,
height: parent.offsetHeight - 16,
});
setPosition({ x: 0, y: 0 });
};
const restore = () => {
const restoreSize = sizeBeforeMaximize;
const position = isDragging
? { left: 0, top: 0 }
: {
left: restoreSize.left,
top: restoreSize.top,
};
setSize({
left: position.left,
top: position.top,
width: restoreSize.width,
height: restoreSize.height,
});
setPosition({ x: position.left, y: position.top });
setIsMaximized(false);
};
/**
* If maximized, need to retain max size during parent resizing.
*/
useLayoutEffect(() => {
const observer = new ResizeObserver(() => {
const parent = parentRef.current;
if (!parent) {
return;
}
if (isMaximized) {
setSize({
left: 0,
top: 0,
// has to take into account padding...hack
width: parent.offsetWidth - 16,
height: parent.offsetHeight - 16,
});
}
});
observer.observe(parentRef.current!);
return () => {
observer.disconnect();
};
}, [isMaximized]);
return !isDebugMode ? null : (
<div
ref={parentRef}
style={{
position: "absolute",
background: "transparent",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
padding: "0.5rem",
}}
>
<Draggable
handle=".my-panel-header"
position={position}
onStart={() => setIsDragging(true)}
onDrag={(_, data) => onDrag(data)}
onStop={() => setIsDragging(false)}
bounds="parent"
disabled={isResizing}
>
<Resizable
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#020817",
boxSizing: "border-box",
pointerEvents: "auto",
}}
className={cn("border-8 border-gray-900", {
"hover:border-slate-500": !isMaximized,
})}
bounds={parentRef.current ?? "parent"}
enable={
isMaximized
? false
: {
top: true,
right: true,
bottom: true,
left: true,
topRight: true,
bottomRight: true,
bottomLeft: true,
topLeft: true,
}
}
onResizeStart={() => {
if (isMaximized) {
return;
}
setIsResizing(true);
setDragStartSize({ ...size, left: position.x, top: position.y });
}}
onResize={(_, direction, __, delta) => {
if (isMaximized) {
return;
}
onResize({ delta, direction, size });
}}
onResizeStop={() => {
if (isMaximized) {
return;
}
setIsResizing(false);
setDragStartSize(undefined);
}}
defaultSize={size}
size={size}
>
<div
ref={resizableRef}
className="my-panel"
style={{
pointerEvents: "auto",
padding: "0px",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
}}
onDoubleClick={() => {
onDblClickHeader();
}}
>
<div className="my-panel-header w-full cursor-move bg-[#031827] p-3">
Live View
</div>
<WorkflowDebugOverviewWindowIframe />
</div>
</Resizable>
</Draggable>
</div>
);
}
function WorkflowDebugOverviewWindowIframe() {
const { workflowPermanentId: wpid, workflowRunId: wrid } = useParams();
const lastCompletePair = useRef<{ wpid: string; wrid: string } | null>(null);
if (wpid !== undefined && wrid !== undefined) {
lastCompletePair.current = {
wpid,
wrid,
};
}
const paramsToUse = useMemo(() => {
if (wpid && wrid) {
return { wpid, wrid };
}
return lastCompletePair.current;
}, [wpid, wrid]);
const origin = location.origin;
const dest = paramsToUse
? `${origin}/workflows/${paramsToUse.wpid}/${paramsToUse.wrid}/overview?embed=true`
: null;
return dest ? (
<div className="h-full w-full rounded-xl bg-[#020817] p-6">
<iframe src={dest} className="h-full w-full rounded-xl" />
</div>
) : (
<div className="h-full w-full rounded-xl bg-[#020817] p-6">
<p>Workflow not found</p>
</div>
);
}
export { WorkflowDebugOverviewWindow };

View File

@@ -99,6 +99,8 @@ import {
import { taskv2NodeDefaultData } from "./nodes/Taskv2Node/types";
import { urlNodeDefaultData } from "./nodes/URLNode/types";
import { fileUploadNodeDefaultData } from "./nodes/FileUploadNode/types";
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
export const NEW_NODE_LABEL_PREFIX = "block_";
function layoutUtil(
@@ -205,6 +207,7 @@ function convertToNode(
connectable: false,
};
const commonData: NodeBaseData = {
debuggable: debuggableWorkflowBlockTypes.has(block.block_type),
label: block.label,
continueOnFailure: block.continue_on_failure,
editable,

View File

@@ -214,6 +214,18 @@ export const WorkflowBlockTypes = {
URL: "goto_url",
} as const;
export const debuggableWorkflowBlockTypes: Set<WorkflowBlockType> = new Set([
"action",
"extraction",
"goto_url",
"login",
"navigation",
"task",
"task_v2",
"text_prompt",
"validation",
]);
export function isTaskVariantBlock(item: {
block_type: WorkflowBlockType;
}): boolean {

View File

@@ -0,0 +1,49 @@
import { useLocation } from "react-router-dom";
import type { WorkflowParameter } from "./types/workflowTypes";
type Location = ReturnType<typeof useLocation>;
export const getInitialValues = (
location: Location,
workflowParameters: WorkflowParameter[],
) => {
const iv = location.state?.data
? location.state.data
: workflowParameters?.reduce(
(acc, curr) => {
if (curr.workflow_parameter_type === "json") {
if (typeof curr.default_value === "string") {
acc[curr.key] = curr.default_value;
return acc;
}
if (curr.default_value) {
acc[curr.key] = JSON.stringify(curr.default_value, null, 2);
return acc;
}
}
if (
curr.default_value &&
curr.workflow_parameter_type === "boolean"
) {
acc[curr.key] = Boolean(curr.default_value);
return acc;
}
if (
curr.default_value === null &&
curr.workflow_parameter_type === "string"
) {
acc[curr.key] = "";
return acc;
}
if (curr.default_value) {
acc[curr.key] = curr.default_value;
return acc;
}
acc[curr.key] = null;
return acc;
},
{} as Record<string, unknown>,
);
return iv as Record<string, unknown>;
};

View File

@@ -0,0 +1,30 @@
import React, { createContext, useMemo } from "react";
import { useLocation } from "react-router-dom";
function useIsDebugMode() {
const location = useLocation();
return useMemo(
() => location.pathname.includes("debug"),
[location.pathname],
);
}
export type DebugStoreContextType = {
isDebugMode: boolean;
};
export const DebugStoreContext = createContext<
DebugStoreContextType | undefined
>(undefined);
export const DebugStoreProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const isDebugMode = useIsDebugMode();
return (
<DebugStoreContext.Provider value={{ isDebugMode }}>
{children}
</DebugStoreContext.Provider>
);
};

View File

@@ -0,0 +1,40 @@
import { create } from "zustand";
import { ProxyLocation } from "@/api/types";
export interface WorkflowModel {
model_name: string;
}
export interface WorkflowSettingsState {
webhookCallbackUrl: string;
proxyLocation: ProxyLocation;
persistBrowserSession: boolean;
model: WorkflowModel | null;
maxScreenshotScrollingTimes: number | null;
extraHttpHeaders: string | null;
setWorkflowSettings: (
settings: Partial<Omit<WorkflowSettingsState, "setWorkflowSettings">>,
) => void;
resetWorkflowSettings: () => void;
}
const defaultState: Omit<
WorkflowSettingsState,
"setWorkflowSettings" | "resetWorkflowSettings"
> = {
webhookCallbackUrl: "",
proxyLocation: ProxyLocation.Residential,
persistBrowserSession: false,
model: null,
maxScreenshotScrollingTimes: null,
extraHttpHeaders: null,
};
export const useWorkflowSettingsStore = create<WorkflowSettingsState>(
(set) => ({
...defaultState,
setWorkflowSettings: (settings) =>
set((state) => ({ ...state, ...settings })),
resetWorkflowSettings: () => set({ ...defaultState }),
}),
);

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { DebugStoreContext } from "./DebugStoreContext";
export function useDebugStore() {
const ctx = useContext(DebugStoreContext);
if (!ctx) {
throw new Error("useDebugStore must be used within a DebugStoreProvider");
}
return ctx;
}

View File

@@ -1,5 +1,6 @@
import { create } from "zustand";
import { AxiosInstance } from "axios";
import { lsKeys } from "@/util/env";
export interface BrowserSessionData {
browser_session_id: string | null;
@@ -10,7 +11,6 @@ interface OptimisticBrowserSessionIdState extends BrowserSessionData {
run: (client: AxiosInstance) => Promise<BrowserSessionData>;
}
const SESSION_KEY = "skyvern.optimisticBrowserSession";
const SESSION_TIMEOUT_MINUTES = 60;
export const useOptimisticallyRequestBrowserSessionId =
@@ -18,7 +18,7 @@ export const useOptimisticallyRequestBrowserSessionId =
browser_session_id: null,
expires_at: null,
run: async (client) => {
const stored = localStorage.getItem(SESSION_KEY);
const stored = localStorage.getItem(lsKeys.optimisticBrowserSession);
if (stored) {
try {
const parsed = JSON.parse(stored);
@@ -50,7 +50,7 @@ export const useOptimisticallyRequestBrowserSessionId =
expires_at: newExpiresAt,
});
localStorage.setItem(
SESSION_KEY,
lsKeys.optimisticBrowserSession,
JSON.stringify({
browser_session_id: newBrowserSessionId,
expires_at: newExpiresAt,

View File

@@ -21,10 +21,16 @@ if (!artifactApiBaseUrl) {
const apiPathPrefix = import.meta.env.VITE_API_PATH_PREFIX ?? "";
const lsKeys = {
browserSessionId: "skyvern.browserSessionId",
optimisticBrowserSession: "skyvern.optimisticBrowserSession",
};
export {
apiBaseUrl,
environment,
envCredential,
artifactApiBaseUrl,
apiPathPrefix,
lsKeys,
};