Workflow editor (#735)
Co-authored-by: Muhammed Salih Altun <muhammedsalihaltun@gmail.com>
This commit is contained in:
107
skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx
Normal file
107
skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
Edge,
|
||||
Panel,
|
||||
ReactFlow,
|
||||
useEdgesState,
|
||||
useNodesInitialized,
|
||||
useNodesState,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { WorkflowHeader } from "./WorkflowHeader";
|
||||
import { AppNode, nodeTypes } from "./nodes";
|
||||
import "./reactFlowOverrideStyles.css";
|
||||
import { layout } from "./workflowEditorUtils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
initialNodes: Array<AppNode>;
|
||||
initialEdges: Array<Edge>;
|
||||
};
|
||||
|
||||
function FlowRenderer({ title, initialEdges, initialNodes }: Props) {
|
||||
const [rightSidePanelOpen, setRightSidePanelOpen] = useState(false);
|
||||
const [rightSidePanelContent, setRightSidePanelContent] = useState<
|
||||
"parameters" | "nodeLibrary" | null
|
||||
>(null);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
const nodesInitialized = useNodesInitialized();
|
||||
|
||||
function doLayout(nodes: Array<AppNode>, edges: Array<Edge>) {
|
||||
const layoutedElements = layout(nodes, edges);
|
||||
setNodes(layoutedElements.nodes);
|
||||
setEdges(layoutedElements.edges);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (nodesInitialized) {
|
||||
doLayout(nodes, edges);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodesInitialized]);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={(changes) => {
|
||||
const dimensionChanges = changes.filter(
|
||||
(change) => change.type === "dimensions",
|
||||
);
|
||||
const tempNodes = [...nodes];
|
||||
dimensionChanges.forEach((change) => {
|
||||
const node = tempNodes.find((node) => node.id === change.id);
|
||||
if (node) {
|
||||
if (node.measured?.width) {
|
||||
node.measured.width = change.dimensions?.width;
|
||||
}
|
||||
if (node.measured?.height) {
|
||||
node.measured.height = change.dimensions?.height;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (dimensionChanges.length > 0) {
|
||||
doLayout(tempNodes, edges);
|
||||
}
|
||||
onNodesChange(changes);
|
||||
}}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
colorMode="dark"
|
||||
fitView
|
||||
fitViewOptions={{
|
||||
maxZoom: 1,
|
||||
}}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} bgColor="#020617" />
|
||||
<Controls position="bottom-left" />
|
||||
<Panel position="top-center" className="h-20">
|
||||
<WorkflowHeader
|
||||
title={title}
|
||||
parametersPanelOpen={rightSidePanelOpen}
|
||||
onParametersClick={() => {
|
||||
setRightSidePanelOpen((open) => !open);
|
||||
setRightSidePanelContent("parameters");
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
{rightSidePanelOpen && (
|
||||
<Panel
|
||||
position="top-right"
|
||||
className="w-96 rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl"
|
||||
>
|
||||
{rightSidePanelContent === "parameters" && (
|
||||
<WorkflowParametersPanel />
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
|
||||
export { FlowRenderer };
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
type LayoutCallbackFunction = () => void;
|
||||
|
||||
const LayoutCallbackContext = createContext<LayoutCallbackFunction | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export { LayoutCallbackContext };
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ReactFlowProvider } from "@xyflow/react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
|
||||
import { FlowRenderer } from "./FlowRenderer";
|
||||
import { getElements } from "./workflowEditorUtils";
|
||||
|
||||
function WorkflowEditor() {
|
||||
const { workflowPermanentId } = useParams();
|
||||
|
||||
const { data: workflow, isLoading } = useWorkflowQuery({
|
||||
workflowPermanentId,
|
||||
});
|
||||
|
||||
// TODO
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workflow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elements = getElements(workflow.workflow_definition.blocks);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full">
|
||||
<ReactFlowProvider>
|
||||
<FlowRenderer
|
||||
title={workflow.title}
|
||||
initialNodes={elements.nodes}
|
||||
initialEdges={elements.edges}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkflowEditor };
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
ExitIcon,
|
||||
PlayIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
parametersPanelOpen: boolean;
|
||||
onParametersClick: () => void;
|
||||
};
|
||||
|
||||
function WorkflowHeader({
|
||||
title,
|
||||
parametersPanelOpen,
|
||||
onParametersClick,
|
||||
}: Props) {
|
||||
const { workflowPermanentId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full bg-slate-elevation2">
|
||||
<div className="flex h-full w-1/3 items-center pl-6">
|
||||
<div
|
||||
className="cursor-pointer rounded-full p-2 hover:bg-slate-elevation5"
|
||||
onClick={() => {
|
||||
navigate("/workflows");
|
||||
}}
|
||||
>
|
||||
<ExitIcon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-1/3 items-center justify-center">
|
||||
<span className="max-w-max truncate text-3xl">{title}</span>
|
||||
</div>
|
||||
<div className="flex h-full w-1/3 items-center justify-end gap-4 p-4">
|
||||
<Button variant="secondary" size="lg" onClick={onParametersClick}>
|
||||
<span className="mr-2">Parameters</span>
|
||||
{parametersPanelOpen ? (
|
||||
<ChevronUpIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-6 w-6" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
navigate(`/workflows/${workflowPermanentId}/run`);
|
||||
}}
|
||||
>
|
||||
<span className="mr-2">Run</span>
|
||||
<PlayIcon className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkflowHeader };
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import type { CodeBlockNode } from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CodeIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
|
||||
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||
|
||||
function CodeBlockNode({ data }: NodeProps<CodeBlockNode>) {
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="a"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="b"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
|
||||
<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">
|
||||
<CodeIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<span className="text-xs text-slate-400">Task Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Code Input</Label>
|
||||
<CodeEditor
|
||||
language="python"
|
||||
value={data.code}
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
}}
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CodeBlockNode };
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type CodeBlockNodeData = {
|
||||
code: string;
|
||||
editable: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type CodeBlockNode = Node<CodeBlockNodeData, "codeBlock">;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { DotsHorizontalIcon, DownloadIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import type { DownloadNode } from "./types";
|
||||
|
||||
function DownloadNode({ data }: NodeProps<DownloadNode>) {
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="a"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="b"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
|
||||
<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">
|
||||
<DownloadIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<span className="text-xs text-slate-400">Download Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-slate-400">File URL</Label>
|
||||
<Input
|
||||
value={data.url}
|
||||
onChange={() => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
// TODO
|
||||
}}
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DownloadNode };
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type DownloadNodeData = {
|
||||
url: string;
|
||||
editable: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type DownloadNode = Node<DownloadNodeData, "download">;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import type { FileParserNode } from "./types";
|
||||
import { CursorTextIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function FileParserNode({ data }: NodeProps<FileParserNode>) {
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="a"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="b"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
|
||||
<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">
|
||||
<CursorTextIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<span className="text-xs text-slate-400">File Parser Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-slate-400">File URL</span>
|
||||
<Input
|
||||
value={data.fileUrl}
|
||||
onChange={() => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
// TODO
|
||||
}}
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { FileParserNode };
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type FileParserNodeData = {
|
||||
fileUrl: string;
|
||||
editable: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type FileParserNode = Node<FileParserNodeData, "fileParser">;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { DotsHorizontalIcon, UpdateIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position, useNodes } from "@xyflow/react";
|
||||
import type { LoopNode } from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
||||
const nodes = useNodes();
|
||||
const children = nodes.filter((node) => node.parentId === id);
|
||||
const furthestDownChild: Node | null = children.reduce(
|
||||
(acc, child) => {
|
||||
if (!acc) {
|
||||
return child;
|
||||
}
|
||||
if (child.position.y > acc.position.y) {
|
||||
return child;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
null as Node | null,
|
||||
);
|
||||
|
||||
const childrenHeightExtent =
|
||||
(furthestDownChild?.measured?.height ?? 0) +
|
||||
(furthestDownChild?.position.y ?? 0) +
|
||||
24;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="a"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="b"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<div
|
||||
className="w-[60rem] rounded-md border-2 border-dashed border-slate-600 p-2"
|
||||
style={{
|
||||
height: childrenHeightExtent,
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<UpdateIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base">{data.label}</span>
|
||||
<span className="text-xs text-slate-400">Loop Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Loop Value</Label>
|
||||
<Input
|
||||
value={data.loopValue}
|
||||
onChange={() => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
// TODO
|
||||
}}
|
||||
placeholder="What value are you iterating over?"
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { LoopNode };
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type LoopNodeData = {
|
||||
loopValue: string;
|
||||
editable: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type LoopNode = Node<LoopNodeData, "loop">;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import type { SendEmailNode } from "./types";
|
||||
import { DotsHorizontalIcon, EnvelopeClosedIcon } from "@radix-ui/react-icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="a"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="b"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
|
||||
<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">
|
||||
<EnvelopeClosedIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<span className="text-xs text-slate-400">Send Email Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Recipient</Label>
|
||||
<Input
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
}}
|
||||
value={data.recipients.join(", ")}
|
||||
placeholder="example@gmail.com"
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Subject</Label>
|
||||
<Input
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
}}
|
||||
value={data.subject}
|
||||
placeholder="What is the gist?"
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Body</Label>
|
||||
<Input
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
}}
|
||||
value={data.body}
|
||||
placeholder="What would you like to say?"
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">File Attachments</Label>
|
||||
<Input
|
||||
value={data.fileAttachments?.join(", ") ?? ""}
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
}}
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center gap-10">
|
||||
<Label className="text-xs text-slate-300">
|
||||
Attach all downloaded files
|
||||
</Label>
|
||||
<Switch />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SendEmailNode };
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type SendEmailNodeData = {
|
||||
recipients: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
fileAttachments: string[] | null;
|
||||
editable: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type SendEmailNode = Node<SendEmailNodeData, "sendEmail">;
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import { useState } from "react";
|
||||
import { DotsHorizontalIcon, ListBulletIcon } from "@radix-ui/react-icons";
|
||||
import { TaskNodeDisplayModeSwitch } from "./TaskNodeDisplayModeSwitch";
|
||||
import type { TaskNodeDisplayMode } from "./types";
|
||||
import type { TaskNode } from "./types";
|
||||
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { DataSchema } from "../../../components/DataSchema";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TaskNodeErrorMapping } from "./TaskNodeErrorMapping";
|
||||
|
||||
function TaskNode({ data }: NodeProps<TaskNode>) {
|
||||
const [displayMode, setDisplayMode] = useState<TaskNodeDisplayMode>("basic");
|
||||
const { editable } = data;
|
||||
|
||||
const basicContent = (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">URL</Label>
|
||||
<AutoResizingTextarea
|
||||
value={data.url}
|
||||
className="nopan"
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
}}
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Goal</Label>
|
||||
<AutoResizingTextarea
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
}}
|
||||
value={data.navigationGoal}
|
||||
placeholder="What are you looking to do?"
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const advancedContent = (
|
||||
<>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={["content", "extraction", "limits"]}
|
||||
>
|
||||
<AccordionItem value="content">
|
||||
<AccordionTrigger>Content</AccordionTrigger>
|
||||
<AccordionContent className="pl-[1.5rem] pr-1">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">URL</Label>
|
||||
<AutoResizingTextarea
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
}}
|
||||
value={data.url}
|
||||
placeholder="https://"
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Goal</Label>
|
||||
<AutoResizingTextarea
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
}}
|
||||
value={data.navigationGoal}
|
||||
placeholder="What are you looking to do?"
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="extraction">
|
||||
<AccordionTrigger>Extraction</AccordionTrigger>
|
||||
<AccordionContent className="pl-[1.5rem] pr-1">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">
|
||||
Data Extraction Goal
|
||||
</Label>
|
||||
<AutoResizingTextarea
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
}}
|
||||
value={data.dataExtractionGoal}
|
||||
placeholder="What outputs are you looking to get?"
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
<DataSchema
|
||||
value={data.dataSchema}
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="limits">
|
||||
<AccordionTrigger>Limits</AccordionTrigger>
|
||||
<AccordionContent className="pl-[1.5rem] pr-1">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-normal text-slate-300">
|
||||
Max Retries
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
className="nopan w-44"
|
||||
value={data.maxRetries ?? 0}
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-normal text-slate-300">
|
||||
Max Steps Override
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
className="nopan w-44"
|
||||
value={data.maxStepsOverride ?? 0}
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Label className="text-xs font-normal text-slate-300">
|
||||
Allow Downloads
|
||||
</Label>
|
||||
<div className="w-44">
|
||||
<Switch
|
||||
checked={data.allowDownloads}
|
||||
onCheckedChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TaskNodeErrorMapping
|
||||
value={data.errorCodeMapping}
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="a"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="b"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
|
||||
<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">
|
||||
<ListBulletIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<span className="text-xs text-slate-400">Task Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<TaskNodeDisplayModeSwitch
|
||||
value={displayMode}
|
||||
onChange={setDisplayMode}
|
||||
/>
|
||||
{displayMode === "basic" && basicContent}
|
||||
{displayMode === "advanced" && advancedContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TaskNode };
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cn } from "@/util/utils";
|
||||
import { TaskNodeDisplayMode } from "./types";
|
||||
|
||||
type Props = {
|
||||
value: TaskNodeDisplayMode;
|
||||
onChange: (mode: TaskNodeDisplayMode) => void;
|
||||
};
|
||||
|
||||
function TaskNodeDisplayModeSwitch({ value, onChange }: Props) {
|
||||
return (
|
||||
<div className="flex w-fit gap-1 rounded-sm border border-slate-700 p-2">
|
||||
<div
|
||||
className={cn("cursor-pointer rounded-sm p-2 hover:bg-slate-700", {
|
||||
"bg-slate-700": value === "basic",
|
||||
})}
|
||||
onClick={() => {
|
||||
onChange("basic");
|
||||
}}
|
||||
>
|
||||
Basic
|
||||
</div>
|
||||
<div
|
||||
className={cn("cursor-pointer rounded-sm p-2 hover:bg-slate-700", {
|
||||
"bg-slate-700": value === "advanced",
|
||||
})}
|
||||
onClick={() => {
|
||||
onChange("advanced");
|
||||
}}
|
||||
>
|
||||
Advanced
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TaskNodeDisplayModeSwitch };
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||
|
||||
type Props = {
|
||||
value: Record<string, unknown> | null;
|
||||
onChange: (value: Record<string, unknown> | null) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function TaskNodeErrorMapping({ value, onChange, disabled }: Props) {
|
||||
if (value === null) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs font-normal text-slate-300">
|
||||
Error Messages
|
||||
</Label>
|
||||
<Checkbox
|
||||
checked={false}
|
||||
disabled={disabled}
|
||||
onCheckedChange={() => {
|
||||
onChange({});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs font-normal text-slate-300">
|
||||
Error Messages
|
||||
</Label>
|
||||
<Checkbox
|
||||
checked
|
||||
disabled={disabled}
|
||||
onCheckedChange={() => {
|
||||
onChange(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CodeEditor
|
||||
language="json"
|
||||
value={JSON.stringify(value, null, 2)}
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
// TODO
|
||||
}}
|
||||
className="nowheel nopan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TaskNodeErrorMapping };
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type TaskNodeData = {
|
||||
url: string;
|
||||
navigationGoal: string;
|
||||
dataExtractionGoal: string;
|
||||
errorCodeMapping: Record<string, string> | null;
|
||||
dataSchema: Record<string, unknown> | null;
|
||||
maxRetries: number | null;
|
||||
maxStepsOverride: number | null;
|
||||
allowDownloads: boolean;
|
||||
editable: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type TaskNode = Node<TaskNodeData, "task">;
|
||||
|
||||
export type TaskNodeDisplayMode = "basic" | "advanced";
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CursorTextIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import type { TextPromptNode } from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DataSchema } from "@/routes/workflows/components/DataSchema";
|
||||
|
||||
function TextPromptNode({ data }: NodeProps<TextPromptNode>) {
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="a"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="b"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
|
||||
<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">
|
||||
<CursorTextIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<span className="text-xs text-slate-400">Text Prompt Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Prompt</Label>
|
||||
<AutoResizingTextarea
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
}}
|
||||
value={data.prompt}
|
||||
placeholder="What do you want to generate?"
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<DataSchema
|
||||
value={data.jsonSchema}
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TextPromptNode };
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type TextPromptNodeData = {
|
||||
prompt: string;
|
||||
jsonSchema: Record<string, unknown> | null;
|
||||
editable: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type TextPromptNode = Node<TextPromptNodeData, "textPrompt">;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import type { UploadNode } from "./types";
|
||||
import { DotsHorizontalIcon, UploadIcon } from "@radix-ui/react-icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function UploadNode({ data }: NodeProps<UploadNode>) {
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="a"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="b"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<div className="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
|
||||
<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">
|
||||
<UploadIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<span className="text-xs text-slate-400">Upload Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-slate-400">File Path</Label>
|
||||
<Input
|
||||
value={data.path}
|
||||
onChange={() => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
// TODO
|
||||
}}
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { UploadNode };
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type UploadNodeData = {
|
||||
path: string;
|
||||
editable: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type UploadNode = Node<UploadNodeData, "upload">;
|
||||
38
skyvern-frontend/src/routes/workflows/editor/nodes/index.ts
Normal file
38
skyvern-frontend/src/routes/workflows/editor/nodes/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { memo } from "react";
|
||||
import { CodeBlockNode as CodeBlockNodeComponent } from "./CodeBlockNode/CodeBlockNode";
|
||||
import { CodeBlockNode } from "./CodeBlockNode/types";
|
||||
import { LoopNode as LoopNodeComponent } from "./LoopNode/LoopNode";
|
||||
import type { LoopNode } from "./LoopNode/types";
|
||||
import { SendEmailNode as SendEmailNodeComponent } from "./SendEmailNode/SendEmailNode";
|
||||
import type { SendEmailNode } from "./SendEmailNode/types";
|
||||
import { TaskNode as TaskNodeComponent } from "./TaskNode/TaskNode";
|
||||
import type { TaskNode } from "./TaskNode/types";
|
||||
import { TextPromptNode as TextPromptNodeComponent } from "./TextPromptNode/TextPromptNode";
|
||||
import type { TextPromptNode } from "./TextPromptNode/types";
|
||||
import type { FileParserNode } from "./FileParserNode/types";
|
||||
import { FileParserNode as FileParserNodeComponent } from "./FileParserNode/FileParserNode";
|
||||
import type { UploadNode } from "./UploadNode/types";
|
||||
import { UploadNode as UploadNodeComponent } from "./UploadNode/UploadNode";
|
||||
import type { DownloadNode } from "./DownloadNode/types";
|
||||
import { DownloadNode as DownloadNodeComponent } from "./DownloadNode/DownloadNode";
|
||||
|
||||
export type AppNode =
|
||||
| LoopNode
|
||||
| TaskNode
|
||||
| TextPromptNode
|
||||
| SendEmailNode
|
||||
| CodeBlockNode
|
||||
| FileParserNode
|
||||
| UploadNode
|
||||
| DownloadNode;
|
||||
|
||||
export const nodeTypes = {
|
||||
loop: memo(LoopNodeComponent),
|
||||
task: memo(TaskNodeComponent),
|
||||
textPrompt: memo(TextPromptNodeComponent),
|
||||
sendEmail: memo(SendEmailNodeComponent),
|
||||
codeBlock: memo(CodeBlockNodeComponent),
|
||||
fileParser: memo(FileParserNodeComponent),
|
||||
upload: memo(UploadNodeComponent),
|
||||
download: memo(DownloadNodeComponent),
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useWorkflowQuery } from "../../hooks/useWorkflowQuery";
|
||||
|
||||
function WorkflowParametersPanel() {
|
||||
const { workflowPermanentId } = useParams();
|
||||
|
||||
const { data: workflow, isLoading } = useWorkflowQuery({
|
||||
workflowPermanentId,
|
||||
});
|
||||
|
||||
if (isLoading || !workflow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workflowParameters = workflow.workflow_definition.parameters.filter(
|
||||
(parameter) => parameter.parameter_type === "workflow",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header>
|
||||
<h1 className="text-lg">Workflow Parameters</h1>
|
||||
<span className="text-sm text-slate-400">
|
||||
Create placeholder values that you can link in nodes. You will be
|
||||
prompted to fill them in before running your workflow.
|
||||
</span>
|
||||
</header>
|
||||
<section className="space-y-2">
|
||||
{workflowParameters.map((parameter) => {
|
||||
return (
|
||||
<div
|
||||
key={parameter.key}
|
||||
className="flex items-center gap-4 rounded-md bg-slate-elevation1 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm">{parameter.key}</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
{parameter.workflow_parameter_type}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkflowParametersPanel };
|
||||
@@ -0,0 +1,23 @@
|
||||
.react-flow__panel.top.center {
|
||||
margin: 0;
|
||||
top: 2rem;
|
||||
width: calc(100% - 3rem);
|
||||
}
|
||||
|
||||
.react-flow__panel.top.right {
|
||||
margin: 0;
|
||||
top: 7.75rem;
|
||||
right: 1.5rem;
|
||||
}
|
||||
|
||||
.react-flow__node-regular {
|
||||
@apply bg-slate-elevation3;
|
||||
}
|
||||
|
||||
.react-flow__handle-top {
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
.react-flow__handle-bottom {
|
||||
bottom: 3px;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Edge } from "@xyflow/react";
|
||||
import { AppNode } from "./nodes";
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
import type { WorkflowBlock } from "../types/workflowTypes";
|
||||
|
||||
function layoutUtil(
|
||||
nodes: Array<AppNode>,
|
||||
edges: Array<Edge>,
|
||||
options: Dagre.configUnion = {},
|
||||
): { nodes: Array<AppNode>; edges: Array<Edge> } {
|
||||
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "TB", ...options });
|
||||
|
||||
edges.forEach((edge) => g.setEdge(edge.source, edge.target));
|
||||
nodes.forEach((node) =>
|
||||
g.setNode(node.id, {
|
||||
...node,
|
||||
width: node.measured?.width ?? 0,
|
||||
height: node.measured?.height ?? 0,
|
||||
}),
|
||||
);
|
||||
|
||||
Dagre.layout(g);
|
||||
|
||||
return {
|
||||
nodes: nodes.map((node) => {
|
||||
const dagreNode = g.node(node.id);
|
||||
// We are shifting the dagre node position (anchor=center center) to the top left
|
||||
// so it matches the React Flow node anchor point (top left).
|
||||
const x = dagreNode.x - (node.measured?.width ?? 0) / 2;
|
||||
const y = dagreNode.y - (node.measured?.height ?? 0) / 2;
|
||||
|
||||
return { ...node, position: { x, y } };
|
||||
}),
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
function layout(
|
||||
nodes: Array<AppNode>,
|
||||
edges: Array<Edge>,
|
||||
): { nodes: Array<AppNode>; edges: Array<Edge> } {
|
||||
const loopNodes = nodes.filter(
|
||||
(node) => node.type === "loop" && !node.parentId,
|
||||
);
|
||||
const loopNodeChildren: Array<Array<AppNode>> = loopNodes.map(() => []);
|
||||
|
||||
loopNodes.forEach((node, index) => {
|
||||
const childNodes = nodes.filter((n) => n.parentId === node.id);
|
||||
const childEdges = edges.filter((edge) =>
|
||||
childNodes.some(
|
||||
(node) => node.id === edge.source || node.id === edge.target,
|
||||
),
|
||||
);
|
||||
const layouted = layoutUtil(childNodes, childEdges, {
|
||||
marginx: 240,
|
||||
marginy: 200,
|
||||
});
|
||||
loopNodeChildren[index] = layouted.nodes;
|
||||
});
|
||||
|
||||
const topLevelNodes = nodes.filter((node) => !node.parentId);
|
||||
|
||||
const topLevelNodesLayout = layoutUtil(topLevelNodes, edges);
|
||||
|
||||
return {
|
||||
nodes: topLevelNodesLayout.nodes.concat(loopNodeChildren.flat()),
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
function convertToNode(
|
||||
identifiers: { id: string; parentId?: string },
|
||||
block: WorkflowBlock,
|
||||
): AppNode {
|
||||
const common = {
|
||||
draggable: false,
|
||||
};
|
||||
switch (block.block_type) {
|
||||
case "task": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "task",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
url: block.url ?? "",
|
||||
navigationGoal: block.navigation_goal ?? "",
|
||||
dataExtractionGoal: block.data_extraction_goal ?? "",
|
||||
dataSchema: block.data_schema ?? null,
|
||||
errorCodeMapping: block.error_code_mapping ?? null,
|
||||
allowDownloads: block.complete_on_download ?? false,
|
||||
maxRetries: block.max_retries ?? null,
|
||||
maxStepsOverride: block.max_steps_per_run ?? null,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
case "code": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "codeBlock",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
code: block.code,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
case "send_email": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "sendEmail",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
body: block.body,
|
||||
fileAttachments: block.file_attachments,
|
||||
recipients: block.recipients,
|
||||
subject: block.subject,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
case "text_prompt": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "textPrompt",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
prompt: block.prompt,
|
||||
jsonSchema: block.json_schema ?? null,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
case "for_loop": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "loop",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
loopValue: block.loop_over.key,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
case "file_url_parser": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "fileParser",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
fileUrl: block.file_url,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
case "download_to_s3": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "download",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
url: block.url,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
case "upload_to_s3": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "upload",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
path: block.path,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getElements(
|
||||
blocks: Array<WorkflowBlock>,
|
||||
parentId?: string,
|
||||
): { nodes: Array<AppNode>; edges: Array<Edge> } {
|
||||
const nodes: Array<AppNode> = [];
|
||||
const edges: Array<Edge> = [];
|
||||
|
||||
blocks.forEach((block, index) => {
|
||||
const id = parentId ? `${parentId}-${index}` : String(index);
|
||||
const nextId = parentId ? `${parentId}-${index + 1}` : String(index + 1);
|
||||
nodes.push(convertToNode({ id, parentId }, block));
|
||||
if (block.block_type === "for_loop") {
|
||||
const subElements = getElements(block.loop_blocks, id);
|
||||
nodes.push(...subElements.nodes);
|
||||
edges.push(...subElements.edges);
|
||||
}
|
||||
if (index !== blocks.length - 1) {
|
||||
edges.push({
|
||||
id: `edge-${id}-${nextId}`,
|
||||
source: id,
|
||||
target: nextId,
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export { getElements, layout };
|
||||
Reference in New Issue
Block a user