Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useWorkflowStore } from "@/app/store/workflow.store"; | |
| import { DefaultNode } from "@/components/workflow/default-node"; | |
| import { WorkflowPanel } from "@/components/workflow/workflow-panel"; | |
| import { | |
| ReactFlow, | |
| Background, | |
| Panel, | |
| Edge, | |
| applyNodeChanges, | |
| OnNodesChange, | |
| OnEdgesChange, | |
| applyEdgeChanges, | |
| addEdge, | |
| OnConnect, | |
| OnSelectionChangeFunc, | |
| NodeMouseHandler, | |
| IsValidConnection, | |
| Connection, | |
| } from "@xyflow/react"; | |
| import "@xyflow/react/dist/style.css"; | |
| import { DBWorkflow } from "app-types/workflow"; | |
| import { extractWorkflowDiff } from "lib/ai/workflow/extract-workflow-diff"; | |
| import { | |
| convertUIEdgeToDBEdge, | |
| convertUINodeToDBNode, | |
| } from "lib/ai/workflow/shared.workflow"; | |
| import { NodeKind, UINode } from "lib/ai/workflow/workflow.interface"; | |
| import { wouldCreateCycle } from "lib/ai/workflow/would-create-cycle"; | |
| import { createDebounce, fetcher, generateUUID } from "lib/utils"; | |
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| import useSWR from "swr"; | |
| import { safe } from "ts-safe"; | |
| const nodeTypes = { | |
| default: DefaultNode, | |
| }; | |
| const debounce = createDebounce(); | |
| const fitViewOptions = { | |
| duration: 500, | |
| padding: 1, | |
| }; | |
| export default function Workflow({ | |
| initialNodes, | |
| initialEdges, | |
| workflowId, | |
| hasEditAccess, | |
| }: { | |
| workflowId: string; | |
| initialNodes: UINode[]; | |
| hasEditAccess?: boolean; | |
| initialEdges: Edge[]; | |
| }) { | |
| const { init, addProcess, processIds } = useWorkflowStore(); | |
| const [nodes, setNodes] = useState<UINode[]>(initialNodes); | |
| const [edges, setEdges] = useState<Edge[]>(initialEdges); | |
| const isProcessing = useMemo( | |
| () => processIds.length > 0, | |
| [processIds.length], | |
| ); | |
| const { data: workflow } = useSWR<DBWorkflow>( | |
| `/api/workflow/${workflowId}`, | |
| fetcher, | |
| { | |
| onSuccess: (workflow) => { | |
| init(workflow, hasEditAccess); | |
| }, | |
| }, | |
| ); | |
| const [activeNodeIds, setActiveNodeIds] = useState<string[]>([]); | |
| const snapshot = useRef({ nodes: initialNodes, edges: initialEdges }); | |
| const editable = useMemo(() => { | |
| return !isProcessing && hasEditAccess && !workflow?.isPublished; | |
| }, [isProcessing, hasEditAccess, workflow?.isPublished]); | |
| const save = async () => { | |
| if (workflow?.isPublished) return; | |
| const diff = extractWorkflowDiff(snapshot.current, { nodes, edges }); | |
| if ( | |
| diff.deleteEdges.length || | |
| diff.deleteNodes.length || | |
| diff.updateEdges.length || | |
| diff.updateNodes.length | |
| ) { | |
| const stop = addProcess(); | |
| await safe() | |
| .map(() => saveWorkflow(workflowId, diff)) | |
| .ifOk(() => { | |
| snapshot.current = { | |
| edges, | |
| nodes, | |
| }; | |
| }) | |
| .ifFail(() => { | |
| window.location.reload(); | |
| }) | |
| .watch(stop) | |
| .unwrap(); | |
| } | |
| }; | |
| const selectedNode = useMemo(() => { | |
| return nodes.findLast((node) => node.selected); | |
| }, [nodes]); | |
| const onNodesChange: OnNodesChange = useCallback( | |
| (changes) => { | |
| if (!editable) { | |
| setNodes((nds) => { | |
| let updatedNodes = nds; | |
| changes.forEach((change) => { | |
| if (change.type === "select") { | |
| updatedNodes = applyNodeChanges( | |
| [change], | |
| updatedNodes, | |
| ) as UINode[]; | |
| } else if (change.type === "replace" && "item" in change) { | |
| const newNode = change.item as UINode; | |
| updatedNodes = updatedNodes.map((node) => { | |
| if (node.id === change.id) { | |
| return { | |
| ...node, | |
| data: { | |
| ...node.data, | |
| runtime: newNode.data.runtime, | |
| }, | |
| }; | |
| } | |
| return node; | |
| }); | |
| } | |
| }); | |
| return updatedNodes; | |
| }); | |
| return; | |
| } | |
| setNodes((nds) => applyNodeChanges(changes, nds) as UINode[]); | |
| }, | |
| [editable], | |
| ); | |
| const onEdgesChange: OnEdgesChange = useCallback( | |
| (changes) => { | |
| if (!editable) return; | |
| setEdges((eds) => applyEdgeChanges(changes, eds)); | |
| }, | |
| [editable], | |
| ); | |
| const onConnect: OnConnect = useCallback( | |
| (connection) => { | |
| if (!editable) return; | |
| setEdges((eds) => | |
| addEdge( | |
| { | |
| ...connection, | |
| id: generateUUID(), | |
| }, | |
| eds, | |
| ), | |
| ); | |
| }, | |
| [editable], | |
| ); | |
| const onSelectionChange: OnSelectionChangeFunc = useCallback( | |
| ({ nodes: selectedNodes }) => { | |
| setActiveNodeIds(selectedNodes.map((node) => node.id)); | |
| }, | |
| [], | |
| ); | |
| const onNodeMouseEnter: NodeMouseHandler = useCallback((_, node) => { | |
| setActiveNodeIds((prev) => { | |
| return prev.includes(node.id) ? prev : [...prev, node.id]; | |
| }); | |
| }, []); | |
| const onNodeMouseLeave: NodeMouseHandler = useCallback((_, node) => { | |
| setActiveNodeIds((prev) => prev.filter((id) => id !== node.id)); | |
| }, []); | |
| const isValidConnection: IsValidConnection = useCallback( | |
| (connection) => { | |
| if (!editable) return false; | |
| if (connection.source === connection.target) return false; | |
| return !wouldCreateCycle(connection as Connection, edges as Connection[]); | |
| }, | |
| [editable, edges], | |
| ); | |
| const { errorIds, runningIds, successIds } = useMemo(() => { | |
| return nodes.reduce( | |
| (acc, prev) => { | |
| if (prev.data.runtime?.status === "fail") { | |
| acc.errorIds.push(prev.id); | |
| } | |
| if (prev.data.runtime?.status === "running") { | |
| acc.runningIds.push(prev.id); | |
| } | |
| if (prev.data.runtime?.status === "success") { | |
| acc.successIds.push(prev.id); | |
| } | |
| return acc; | |
| }, | |
| { | |
| errorIds: [] as string[], | |
| runningIds: [] as string[], | |
| successIds: [] as string[], | |
| }, | |
| ); | |
| }, [nodes]); | |
| const styledEdges = useMemo(() => { | |
| return edges.map((edge) => { | |
| const isConnected = | |
| activeNodeIds.includes(edge.source) || | |
| activeNodeIds.includes(edge.target); | |
| const isErrorEdge = | |
| errorIds.includes(edge.target) && | |
| (successIds.includes(edge.source) || errorIds.includes(edge.source)); | |
| const isRunningEdge = | |
| runningIds.includes(edge.target) && successIds.includes(edge.source); | |
| const isSuccessEdge = | |
| successIds.includes(edge.target) && | |
| (successIds.includes(edge.source) || runningIds.includes(edge.source)); | |
| return { | |
| ...edge, | |
| style: { | |
| ...edge.style, | |
| stroke: isErrorEdge | |
| ? "var(--destructive)" | |
| : isRunningEdge || isSuccessEdge | |
| ? "#05df72" | |
| : isConnected | |
| ? "oklch(62.3% 0.214 259.815)" | |
| : undefined, | |
| strokeWidth: 2, | |
| transition: "stroke 0.3s", | |
| }, | |
| animated: runningIds.includes(edge.source), | |
| }; | |
| }); | |
| }, [edges, activeNodeIds, errorIds, runningIds]); | |
| useEffect(() => { | |
| const debounceDelay = | |
| snapshot.current.nodes.length !== nodes.length || | |
| snapshot.current.edges.length !== edges.length | |
| ? 200 | |
| : 10000; | |
| debounce(save, debounceDelay); | |
| }, [nodes, edges]); | |
| useEffect(() => { | |
| setNodes((nds) => { | |
| return nds.map((node) => { | |
| if (node.data.kind === NodeKind.Input && !node.selected) { | |
| return { ...node, selected: true }; | |
| } | |
| return node; | |
| }); | |
| }); | |
| }, []); | |
| useEffect(() => { | |
| init(workflow, hasEditAccess); | |
| }, [workflow, hasEditAccess]); | |
| return ( | |
| <div className="w-full h-full relative text-de text-gree-4"> | |
| <ReactFlow | |
| fitView | |
| deleteKeyCode={null} | |
| nodes={nodes} | |
| maxZoom={1.4} | |
| minZoom={0.1} | |
| edges={styledEdges} | |
| multiSelectionKeyCode={null} | |
| id={workflowId} | |
| nodeTypes={nodeTypes} | |
| onNodesChange={onNodesChange} | |
| onEdgesChange={onEdgesChange} | |
| onSelectionChange={onSelectionChange} | |
| isValidConnection={isValidConnection} | |
| onConnect={onConnect} | |
| onNodeMouseEnter={onNodeMouseEnter} | |
| onNodeMouseLeave={onNodeMouseLeave} | |
| fitViewOptions={fitViewOptions} | |
| > | |
| <Background gap={12} size={0.6} /> | |
| <Panel position="top-right" className="z-20!"> | |
| {workflow && ( | |
| <WorkflowPanel | |
| hasEditAccess={hasEditAccess} | |
| addProcess={addProcess} | |
| onSave={save} | |
| selectedNode={selectedNode} | |
| workflow={workflow} | |
| isProcessing={isProcessing} | |
| /> | |
| )} | |
| </Panel> | |
| <Panel | |
| position="top-left" | |
| className="h-full w-full m-0! pointer-events-none!" | |
| > | |
| <div className="z-10 absolute inset-0 w-full h-1/12 bg-gradient-to-b to-90% from-background to-transparent pointer-events-none" /> | |
| <div className="z-10 absolute inset-0 w-1/12 h-full bg-gradient-to-r from-background to-transparent pointer-events-none" /> | |
| <div className="z-10 absolute left-0 bottom-0 w-full h-1/12 bg-gradient-to-t from-background to-transparent pointer-events-none" /> | |
| <div className="z-10 absolute right-0 bottom-0 w-1/12 h-full bg-gradient-to-l from-background to-transparent pointer-events-none" /> | |
| </Panel> | |
| </ReactFlow> | |
| </div> | |
| ); | |
| } | |
| function saveWorkflow( | |
| workflowId: string, | |
| diff: ReturnType<typeof extractWorkflowDiff>, | |
| ) { | |
| return fetch(`/api/workflow/${workflowId}/structure`, { | |
| method: "POST", | |
| body: JSON.stringify({ | |
| nodes: diff.updateNodes.map((node) => | |
| convertUINodeToDBNode(workflowId, node), | |
| ), | |
| edges: diff.updateEdges.map((edge) => | |
| convertUIEdgeToDBEdge(workflowId, edge), | |
| ), | |
| deleteNodes: diff.deleteNodes.map((node) => node.id), | |
| deleteEdges: diff.deleteEdges.map((edge) => edge.id), | |
| }), | |
| }).then((res) => { | |
| if (res.status >= 400) { | |
| throw new Error(String(res.statusText || res.status || "Error")); | |
| } | |
| }); | |
| } | |