Workflow editor (#735)

Co-authored-by: Muhammed Salih Altun <muhammedsalihaltun@gmail.com>
This commit is contained in:
Kerem Yilmaz
2024-08-26 21:31:42 +03:00
committed by GitHub
parent d21b6e6adc
commit 3502093200
48 changed files with 2803 additions and 193 deletions

View File

@@ -113,7 +113,7 @@ export type ApiKeyApiResponse = {
valid: boolean;
};
export const WorkflowParameterType = {
export const WorkflowParameterValueType = {
String: "string",
Integer: "integer",
Float: "float",
@@ -122,16 +122,28 @@ export const WorkflowParameterType = {
FileURL: "file_url",
} as const;
export type WorkflowParameterValueType =
(typeof WorkflowParameterValueType)[keyof typeof WorkflowParameterValueType];
export const WorkflowParameterType = {
Workflow: "workflow",
Context: "context",
Output: "output",
AWS_Secret: "aws_secret",
Bitwarden_Login_Credential: "bitwarden_login_credential",
Bitwarden_Sensitive_Information: "bitwarden_sensitive_information",
} as const;
export type WorkflowParameterType =
(typeof WorkflowParameterType)[keyof typeof WorkflowParameterType];
export type WorkflowParameter = {
workflow_parameter_id: string;
workflow_parameter_type: WorkflowParameterType;
key: string;
description: string | null;
workflow_parameter_id: string;
parameter_type: WorkflowParameterType;
workflow_parameter_type: WorkflowParameterValueType;
workflow_id: string;
parameter_type: "workflow"; // TODO other values
default_value?: string;
created_at: string | null;
modified_at: string | null;

View File

@@ -0,0 +1,38 @@
import { useLayoutEffect, useRef } from "react";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/util/utils";
type Props = React.ComponentProps<typeof Textarea>;
function AutoResizingTextarea(props: Props) {
const ref = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
// size the textarea correctly on first render
if (!ref.current) {
return;
}
ref.current.style.height = `${ref.current.scrollHeight + 2}px`;
}, []);
function setSize() {
if (!ref.current) {
return;
}
ref.current.style.height = "auto";
ref.current.style.height = `${ref.current.scrollHeight + 2}px`;
}
return (
<Textarea
{...props}
onKeyDown={setSize}
onInput={setSize}
ref={ref}
rows={1}
className={cn("min-h-0 resize-none overflow-y-hidden", props.className)}
/>
);
}
export { AutoResizingTextarea };

View File

@@ -8,6 +8,7 @@ import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { toast } from "./ui/use-toast";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
export type FileInputValue =
| {
@@ -36,8 +37,6 @@ function showFileSizeError() {
function FileUpload({ value, onChange }: Props) {
const credentialGetter = useCredentialGetter();
const [file, setFile] = useState<File | null>(null);
const [fileUrl, setFileUrl] = useState<string>("");
const [highlight, setHighlight] = useState(false);
const inputId = useId();
const uploadFileMutation = useMutation({
@@ -92,37 +91,44 @@ function FileUpload({ value, onChange }: Props) {
onChange(null);
}
if (value === null) {
return (
<div className="flex gap-4">
<div className="w-1/2">
const isManualUpload =
typeof value === "object" && value !== null && file && "s3uri" in value;
return (
<Tabs
className="h-36 w-full"
defaultValue="upload"
value={value === null ? undefined : isManualUpload ? "upload" : "fileURL"}
onValueChange={(value) => {
if (value === "upload") {
onChange(null);
} else {
onChange("");
}
}}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="upload">Upload</TabsTrigger>
<TabsTrigger value="fileURL">File URL</TabsTrigger>
</TabsList>
<TabsContent value="upload">
{isManualUpload && ( // redundant check for ts compiler
<div className="flex h-full items-center gap-4">
<a href={value.presignedUrl} className="underline">
<span>{file.name}</span>
</a>
<Button onClick={() => reset()}>Change</Button>
</div>
)}
{value === null && (
<Label
htmlFor={inputId}
className={cn(
"flex w-full cursor-pointer items-center justify-center border border-dashed py-8",
{
"border-slate-500": highlight,
},
"flex w-full cursor-pointer items-center justify-center border border-dashed py-8 hover:border-slate-500",
)}
onDragEnter={(event) => {
event.preventDefault();
event.stopPropagation();
setHighlight(true);
}}
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
setHighlight(true);
}}
onDragLeave={(event) => {
event.preventDefault();
event.stopPropagation();
setHighlight(false);
}}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
setHighlight(false);
if (
event.dataTransfer.files &&
event.dataTransfer.files.length > 0
@@ -155,49 +161,21 @@ function FileUpload({ value, onChange }: Props) {
</span>
</div>
</Label>
</div>
<div className="flex flex-col items-center justify-center before:flex before:bg-slate-600 before:content-['']">
OR
</div>
<div className="w-1/2">
)}
</TabsContent>
<TabsContent value="fileURL">
<div className="space-y-2">
<Label>File URL</Label>
<div className="flex gap-2">
{typeof value === "string" && (
<Input
value={fileUrl}
onChange={(e) => setFileUrl(e.target.value)}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
<Button
onClick={() => {
onChange(fileUrl);
}}
>
Save
</Button>
</div>
)}
</div>
</div>
);
}
if (typeof value === "string") {
return (
<div className="flex items-center gap-4">
<span>{value}</span>
<Button onClick={() => reset()}>Change</Button>
</div>
);
}
if (typeof value === "object" && file && "s3uri" in value) {
return (
<div className="flex items-center gap-4">
<a href={value.presignedUrl} className="underline">
<span>{file.name}</span>
</a>
<Button onClick={() => reset()}>Change</Button>
</div>
);
}
</TabsContent>
</Tabs>
);
}
export { FileUpload };

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/util/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -0,0 +1,10 @@
import { useEffect } from "react";
function useMountEffect(callback: () => void) {
return useEffect(() => {
callback();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
export { useMountEffect };

View File

@@ -70,7 +70,11 @@
--input: 215.3 25% 26.7%;
--ring: 212.7 26.8% 83.9%;
--slate-elevation-2: 228 37% 11%;
--slate-elevation-1: 228 45% 9%;
--slate-elevation-2: 228 37% 10.6%;
--slate-elevation-3: 227 30% 12%;
--slate-elevation-4: 231 26% 14%;
--slate-elevation-5: 230 22% 16%;
}
}

View File

@@ -19,6 +19,7 @@ import { WorkflowPage } from "./routes/workflows/WorkflowPage";
import { WorkflowRunParameters } from "./routes/workflows/WorkflowRunParameters";
import { RetryTask } from "./routes/tasks/create/retry/RetryTask";
import { WorkflowRun } from "./routes/workflows/WorkflowRun";
import { WorkflowEditor } from "./routes/workflows/editor/WorkflowEditor";
const router = createBrowserRouter([
{
@@ -101,12 +102,16 @@ const router = createBrowserRouter([
children: [
{
index: true,
element: <WorkflowPage />,
element: <WorkflowEditor />,
},
{
path: "run",
element: <WorkflowRunParameters />,
},
{
path: "runs",
element: <WorkflowPage />,
},
{
path: ":workflowRunId",
element: <WorkflowRun />,

View File

@@ -0,0 +1,38 @@
import { DiscordLogoIcon } from "@radix-ui/react-icons";
import GitHubButton from "react-github-btn";
import { Link, useMatch } from "react-router-dom";
function Header() {
const match = useMatch("/workflows/:workflowPermanentId");
if (match) {
return null;
}
return (
<header>
<div className="flex h-24 items-center justify-end gap-4 px-6">
<Link
to="https://discord.com/invite/fG2XXEuQX3"
target="_blank"
rel="noopener noreferrer"
>
<DiscordLogoIcon className="h-7 w-7" />
</Link>
<div className="h-7">
<GitHubButton
href="https://github.com/skyvern-ai/skyvern"
data-color-scheme="no-preference: dark; light: dark; dark: dark;"
data-size="large"
data-show-count="true"
aria-label="Star skyvern-ai/skyvern on GitHub"
>
Star
</GitHubButton>
</div>
</div>
</header>
);
}
export { Header };

View File

@@ -1,17 +1,13 @@
import { Link, Outlet } from "react-router-dom";
import { Toaster } from "@/components/ui/toaster";
import { SideNav } from "./SideNav";
import {
DiscordLogoIcon,
PinLeftIcon,
PinRightIcon,
} from "@radix-ui/react-icons";
import { PinLeftIcon, PinRightIcon } from "@radix-ui/react-icons";
import { Logo } from "@/components/Logo";
import GitHubButton from "react-github-btn";
import { useState } from "react";
import { cn } from "@/util/utils";
import { Button } from "@/components/ui/button";
import { LogoMinimized } from "@/components/LogoMinimized";
import { Header } from "./Header";
function RootLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
@@ -54,26 +50,7 @@ function RootLayout() {
</div>
</div>
</aside>
<div className="flex h-24 items-center justify-end gap-4 px-6">
<Link
to="https://discord.com/invite/fG2XXEuQX3"
target="_blank"
rel="noopener noreferrer"
>
<DiscordLogoIcon className="h-7 w-7" />
</Link>
<div className="h-7">
<GitHubButton
href="https://github.com/skyvern-ai/skyvern"
data-color-scheme="no-preference: dark; light: dark; dark: dark;"
data-size="large"
data-show-count="true"
aria-label="Star skyvern-ai/skyvern on GitHub"
>
Star
</GitHubButton>
</div>
</div>
<Header />
<main
className={cn("pb-4 pl-64", {
"pl-28": sidebarCollapsed,

View File

@@ -1,13 +1,6 @@
import { getClient } from "@/api/AxiosClient";
import { WorkflowParameter } from "@/api/types";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
@@ -15,7 +8,15 @@ import { useParams } from "react-router-dom";
import { WorkflowParameterInput } from "./WorkflowParameterInput";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/use-toast";
import { ReloadIcon } from "@radix-ui/react-icons";
import { PlayIcon, ReloadIcon } from "@radix-ui/react-icons";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type Props = {
workflowParameters: Array<WorkflowParameter>;
@@ -92,60 +93,91 @@ function RunWorkflowForm({ workflowParameters, initialValues }: Props) {
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{workflowParameters?.map((parameter) => {
return (
<FormField
key={parameter.key}
control={form.control}
name={parameter.key}
rules={{
validate: (value) => {
if (
parameter.workflow_parameter_type === "json" &&
typeof value === "string"
) {
try {
JSON.parse(value);
return true;
} catch (e) {
return "Invalid JSON";
}
}
},
}}
render={({ field }) => {
return (
<FormItem>
<FormLabel>{parameter.key}</FormLabel>
<FormControl>
<WorkflowParameterInput
type={parameter.workflow_parameter_type}
value={field.value}
onChange={field.onChange}
/>
</FormControl>
{parameter.description && (
<FormDescription>
{parameter.description}
</FormDescription>
)}
{form.formState.errors[parameter.key] && (
<div className="text-destructive">
{form.formState.errors[parameter.key]?.message}
</div>
)}
</FormItem>
);
}}
/>
);
})}
<Button type="submit" disabled={runWorkflowMutation.isPending}>
{runWorkflowMutation.isPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
Run workflow
</Button>
<Table>
<TableHeader className="bg-slate-elevation2 text-slate-400 [&_tr]:border-b-0">
<TableRow className="rounded-lg px-6 [&_th:first-child]:pl-6 [&_th]:py-4">
<TableHead className="w-1/3 text-sm text-slate-400">
Parameter Name
</TableHead>
<TableHead className="w-1/3 text-sm text-slate-400">
Description
</TableHead>
<TableHead className="w-1/3 text-sm text-slate-400">
Input
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workflowParameters?.map((parameter) => {
return (
<FormField
key={parameter.key}
control={form.control}
name={parameter.key}
rules={{
validate: (value) => {
if (
parameter.workflow_parameter_type === "json" &&
typeof value === "string"
) {
try {
JSON.parse(value);
return true;
} catch (e) {
return "Invalid JSON";
}
}
},
}}
render={({ field }) => {
return (
<TableRow className="[&_td:first-child]:pl-6 [&_td:last-child]:pr-6 [&_td]:py-4">
<TableCell className="w-1/3">
<div className="flex h-8 w-fit items-center rounded-sm bg-slate-elevation3 p-3">
{parameter.key}
</div>
</TableCell>
<TableCell className="w-1/3">
<div>{parameter.description}</div>
</TableCell>
<TableCell className="w-1/3">
<FormItem>
<FormControl>
<WorkflowParameterInput
type={parameter.workflow_parameter_type}
value={field.value}
onChange={field.onChange}
/>
</FormControl>
{form.formState.errors[parameter.key] && (
<div className="text-destructive">
{
form.formState.errors[parameter.key]
?.message
}
</div>
)}
</FormItem>
</TableCell>
</TableRow>
);
}}
/>
);
})}
</TableBody>
</Table>
<div className="flex justify-end">
<Button type="submit" disabled={runWorkflowMutation.isPending}>
{runWorkflowMutation.isPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
{!runWorkflowMutation.isPending && (
<PlayIcon className="mr-2 h-4 w-4" />
)}
Run workflow
</Button>
</div>
</form>
</Form>
</div>

View File

@@ -85,7 +85,9 @@ function WorkflowPage() {
)}
</div>
<Button asChild>
<Link to="run">Create New Run</Link>
<Link to={`/workflows/${workflowPermanentId}/run`}>
Create New Run
</Link>
</Button>
</header>
<div className="space-y-4">
@@ -124,7 +126,9 @@ function WorkflowPage() {
);
return;
}
navigate(`${workflowRun.workflow_run_id}`);
navigate(
`/workflows/${workflowPermanentId}/${workflowRun.workflow_run_id}`,
);
}}
className="cursor-pointer"
>

View File

@@ -1,22 +1,34 @@
import { WorkflowParameterType } from "@/api/types";
import { WorkflowParameterValueType } from "@/api/types";
import { FileInputValue, FileUpload } from "@/components/FileUpload";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { CodeEditor } from "./components/CodeEditor";
type Props = {
type: WorkflowParameterType;
type: WorkflowParameterValueType;
value: unknown;
onChange: (value: unknown) => void;
};
function WorkflowParameterInput({ type, value, onChange }: Props) {
if (type === "json" || type === "string") {
if (type === "json") {
return (
<Textarea
<CodeEditor
language="json"
onChange={(value) => onChange(value)}
value={
typeof value === "string" ? value : JSON.stringify(value, null, 2)
}
fontSize={12}
/>
);
}
if (type === "string") {
return (
<Input
value={value as string}
onChange={(e) => onChange(e.target.value)}
rows={5}
/>
);
}

View File

@@ -1,11 +1,11 @@
import { getClient } from "@/api/AxiosClient";
import { WorkflowApiResponse, WorkflowParameterType } from "@/api/types";
import { WorkflowApiResponse, WorkflowParameterValueType } from "@/api/types";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { RunWorkflowForm } from "./RunWorkflowForm";
function defaultValue(type: WorkflowParameterType) {
function defaultValue(type: WorkflowParameterValueType) {
switch (type) {
case "string":
return "";
@@ -76,8 +76,12 @@ function WorkflowRunParameters() {
return (
<div className="space-y-8">
<header>
<h1 className="text-2xl font-semibold">Workflow Run Parameters</h1>
<header className="space-y-5">
<h1 className="text-3xl">Run Parameters</h1>
<h2 className="text-lg text-slate-400">
Fill the placeholder values that you have linked throughout your
workflow.
</h2>
</header>
<RunWorkflowForm
initialValues={initialValues}

View File

@@ -1,6 +1,7 @@
import { getClient } from "@/api/AxiosClient";
import { WorkflowApiResponse, WorkflowRunApiResponse } from "@/api/types";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/button";
import {
Pagination,
PaginationContent,
@@ -17,9 +18,20 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { basicTimeFormat } from "@/util/timeFormat";
import { cn } from "@/util/utils";
import {
CounterClockwiseClockIcon,
Pencil2Icon,
PlayIcon,
} from "@radix-ui/react-icons";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
import { WorkflowsBetaAlertCard } from "./WorkflowsBetaAlertCard";
@@ -80,9 +92,10 @@ function Workflows() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-1/3">ID</TableHead>
<TableHead className="w-1/3">Title</TableHead>
<TableHead className="w-1/3">Created At</TableHead>
<TableHead className="w-1/4">ID</TableHead>
<TableHead className="w-1/4">Status</TableHead>
<TableHead className="w-1/4">Created At</TableHead>
<TableHead className="w-1/4"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -97,22 +110,7 @@ function Workflows() {
) : (
workflows?.map((workflow) => {
return (
<TableRow
key={workflow.workflow_permanent_id}
onClick={(event) => {
if (event.ctrlKey || event.metaKey) {
window.open(
window.location.origin +
`/workflows/${workflow.workflow_permanent_id}`,
"_blank",
"noopener,noreferrer",
);
return;
}
navigate(`${workflow.workflow_permanent_id}`);
}}
className="cursor-pointer"
>
<TableRow key={workflow.workflow_permanent_id}>
<TableCell className="w-1/3">
{workflow.workflow_permanent_id}
</TableCell>
@@ -120,6 +118,64 @@ function Workflows() {
<TableCell className="w-1/3">
{basicTimeFormat(workflow.created_at)}
</TableCell>
<TableCell>
<div className="flex gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={() => {
navigate(
`/workflows/${workflow.workflow_permanent_id}/runs`,
);
}}
>
<CounterClockwiseClockIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Past Runs</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={() => {
navigate(
`/workflows/${workflow.workflow_permanent_id}`,
);
}}
>
<Pencil2Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Open in Editor</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={() => {
navigate(
`/workflows/${workflow.workflow_permanent_id}/run`,
);
}}
>
<PlayIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create New Run</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
);
})

View File

@@ -1,8 +1,15 @@
import { Outlet } from "react-router-dom";
import { cn } from "@/util/utils";
import { Outlet, useMatch } from "react-router-dom";
function WorkflowsPageLayout() {
const match = useMatch("/workflows/:workflowPermanentId");
return (
<main className="container mx-auto px-8">
<main
className={cn({
"container mx-auto px-8": !match,
})}
>
<Outlet />
</main>
);

View File

@@ -0,0 +1,41 @@
import CodeMirror from "@uiw/react-codemirror";
import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python";
import { tokyoNightStorm } from "@uiw/codemirror-theme-tokyo-night-storm";
import { cn } from "@/util/utils";
type Props = {
value: string;
onChange: (value: string) => void;
language: "python" | "json";
disabled?: boolean;
minHeight?: string;
className?: string;
fontSize?: number;
};
function CodeEditor({
value,
onChange,
minHeight,
language,
className,
fontSize = 8,
}: Props) {
const extensions = language === "json" ? [json()] : [python()];
return (
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
theme={tokyoNightStorm}
minHeight={minHeight}
className={cn("cursor-auto", className)}
style={{
fontSize: fontSize,
}}
/>
);
}
export { CodeEditor };

View File

@@ -0,0 +1,50 @@
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { CodeEditor } from "./CodeEditor";
type Props = {
value: Record<string, unknown> | null;
onChange: (value: Record<string, unknown> | null) => void;
};
function DataSchema({ value, onChange }: Props) {
if (value === null) {
return (
<div className="flex gap-2">
<Label className="text-xs text-slate-300">Data Schema</Label>
<Checkbox
checked={false}
onCheckedChange={() => {
onChange({});
}}
/>
</div>
);
}
return (
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">Data Schema</Label>
<Checkbox
checked
onCheckedChange={() => {
onChange(null);
}}
/>
</div>
<div>
<CodeEditor
language="json"
value={JSON.stringify(value, null, 2)}
onChange={() => {
// TODO
}}
className="nowheel nopan"
/>
</div>
</div>
);
}
export { DataSchema };

View 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 };

View File

@@ -0,0 +1,9 @@
import { createContext } from "react";
type LayoutCallbackFunction = () => void;
const LayoutCallbackContext = createContext<LayoutCallbackFunction | undefined>(
undefined,
);
export { LayoutCallbackContext };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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">;

View File

@@ -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 };

View File

@@ -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">;

View File

@@ -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 };

View File

@@ -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">;

View File

@@ -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 };

View File

@@ -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">;

View File

@@ -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 };

View File

@@ -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">;

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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";

View File

@@ -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 };

View File

@@ -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">;

View File

@@ -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 };

View File

@@ -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">;

View 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),
};

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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 };

View File

@@ -0,0 +1,23 @@
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useQuery } from "@tanstack/react-query";
import { WorkflowApiResponse } from "../types/workflowTypes";
type Props = {
workflowPermanentId?: string;
};
function useWorkflowQuery({ workflowPermanentId }: Props) {
const credentialGetter = useCredentialGetter();
return useQuery<WorkflowApiResponse>({
queryKey: ["workflow", workflowPermanentId],
queryFn: async () => {
const client = await getClient(credentialGetter);
return client
.get(`/workflows/${workflowPermanentId}`)
.then((response) => response.data);
},
});
}
export { useWorkflowQuery };

View File

@@ -0,0 +1,217 @@
export type WorkflowParameterBase = {
parameter_type: WorkflowParameterType;
key: string;
description: string | null;
};
export type AWSSecretParameter = WorkflowParameterBase & {
parameter_type: "aws_secret";
workflow_id: string;
aws_secret_parameter_id: string;
aws_key: string;
created_at: string;
modified_at: string;
deleted_at: string | null;
};
export type BitwardenLoginCredentialParameter = WorkflowParameterBase & {
parameter_type: "bitwarden_login_credential";
workflow_id: string;
bitwarden_login_credential_parameter_id: string;
bitwarden_client_id_aws_secret_key: string;
bitwarden_client_secret_aws_secret_key: string;
bitwarden_master_password_aws_secret_key: string;
bitwarden_collection_id: string;
url_parameter_key: string;
created_at: string;
modified_at: string;
deleted_at: string | null;
};
export type BitwardenSensitiveInformationParameter = WorkflowParameterBase & {
parameter_type: "bitwarden_sensitive_information";
workflow_id: string;
bitwarden_sensitive_information_parameter_id: string;
bitwarden_client_id_aws_secret_key: string;
bitwarden_client_secret_aws_secret_key: string;
bitwarden_master_password_aws_secret_key: string;
bitwarden_collection_id: string;
bitwarden_identity_key: string;
bitwarden_identity_fields: string;
created_at: string;
modified_at: string;
deleted_at: string | null;
};
export type WorkflowParameter = WorkflowParameterBase & {
parameter_type: "workflow";
workflow_id: string;
workflow_parameter_id: string;
workflow_parameter_type: WorkflowParameterValueType;
default_value: unknown;
created_at: string;
modified_at: string;
deleted_at: string | null;
};
export type ContextParameter = WorkflowParameterBase & {
parameter_type: "context";
source: WorkflowParameter;
value: unknown;
};
export type OutputParameter = WorkflowParameterBase & {
parameter_type: "output";
output_parameter_id: string;
workflow_id: string;
created_at: string;
modified_at: string;
deleted_at: string | null;
};
export const WorkflowParameterValueType = {
String: "string",
Integer: "integer",
Float: "float",
Boolean: "boolean",
JSON: "json",
FileURL: "file_url",
} as const;
export type WorkflowParameterValueType =
(typeof WorkflowParameterValueType)[keyof typeof WorkflowParameterValueType];
export const WorkflowParameterType = {
Workflow: "workflow",
Context: "context",
Output: "output",
AWS_Secret: "aws_secret",
Bitwarden_Login_Credential: "bitwarden_login_credential",
Bitwarden_Sensitive_Information: "bitwarden_sensitive_information",
} as const;
export type WorkflowParameterType =
(typeof WorkflowParameterType)[keyof typeof WorkflowParameterType];
export type Parameter =
| WorkflowParameter
| OutputParameter
| ContextParameter
| BitwardenLoginCredentialParameter
| BitwardenSensitiveInformationParameter
| AWSSecretParameter;
export const WorkflowBlockType = {
Task: "task",
ForLoop: "for_loop",
Code: "code",
TextPrompt: "text_prompt",
DownloadToS3: "download_to_s3",
UploadToS3: "upload_to_s3",
SendEmail: "send_email",
FileURLParser: "file_url_parser",
};
export type WorkflowBlockType =
(typeof WorkflowBlockType)[keyof typeof WorkflowBlockType];
export type WorkflowBlockBase = {
label: string;
block_type: WorkflowBlockType;
output_parameter: OutputParameter;
continue_on_failure: boolean;
};
export type TaskBlock = WorkflowBlockBase & {
block_type: "task";
url: string | null;
title: string;
navigation_goal: string | null;
data_extraction_goal: string | null;
data_schema: Record<string, unknown> | null;
error_code_mapping: Record<string, string> | null;
max_retries?: number;
max_steps_per_run?: number | null;
parameters: Array<WorkflowParameter>;
complete_on_download?: boolean;
};
export type ForLoopBlock = WorkflowBlockBase & {
block_type: "for_loop";
loop_over: WorkflowParameter;
loop_blocks: Array<WorkflowBlock>;
};
export type CodeBlock = WorkflowBlockBase & {
block_type: "code";
code: string;
parameters: Array<WorkflowParameter>;
};
export type TextPromptBlock = WorkflowBlockBase & {
block_type: "text_prompt";
llm_key: string;
prompt: string;
parameters: Array<WorkflowParameter>;
json_schema: Record<string, unknown> | null;
};
export type DownloadToS3Block = WorkflowBlockBase & {
block_type: "download_to_s3";
url: string;
};
export type UploadToS3Block = WorkflowBlockBase & {
block_type: "upload_to_s3";
path: string;
};
export type SendEmailBlock = WorkflowBlockBase & {
block_type: "send_email";
smtp_host: AWSSecretParameter;
smtp_port: AWSSecretParameter;
smtp_username: AWSSecretParameter;
smtp_password: AWSSecretParameter;
sender: string;
recipients: Array<string>;
subject: string;
body: string;
file_attachments: Array<string>;
};
export type FileURLParserBlock = WorkflowBlockBase & {
block_type: "file_url_parser";
file_url: string;
file_type: "csv";
};
export type WorkflowBlock =
| TaskBlock
| ForLoopBlock
| TextPromptBlock
| CodeBlock
| UploadToS3Block
| DownloadToS3Block
| SendEmailBlock
| FileURLParserBlock;
export type WorkflowDefinition = {
parameters: Array<WorkflowParameter>;
blocks: Array<WorkflowBlock>;
};
export type WorkflowApiResponse = {
workflow_id: string;
organization_id: string;
is_saved_task: boolean;
title: string;
workflow_permanent_id: string;
version: number;
description: string;
workflow_definition: WorkflowDefinition;
proxy_location: string;
webhook_callback_url: string;
created_at: string;
modified_at: string;
deleted_at: string | null;
};