import 'reactflow/dist/style.css'; import { AlertCircle, Bot, Database, Flag, Play, Settings, StickyNote, Trash2, X, } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import ReactFlow, { addEdge, applyEdgeChanges, applyNodeChanges, Background, Connection, Controls, Edge, EdgeChange, Node, NodeChange, NodeTypes, ReactFlowProvider, useReactFlow, } from 'reactflow'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { MultiSelect } from '@/components/ui/multi-select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Sheet, SheetContent } from '@/components/ui/sheet'; import modelService from '../api/services/modelService'; import userService from '../api/services/userService'; import ArrowLeft from '../assets/arrow-left.svg'; import { WorkflowNode } from './types/workflow'; import { AgentNode, EndNode, NoteNode, SetStateNode, StartNode, } from './workflow/nodes'; import WorkflowPreview from './workflow/WorkflowPreview'; import type { Model } from '../models/types'; interface AgentNodeConfig { agent_type: 'classic' | 'react'; llm_name?: string; model_id?: string; system_prompt: string; prompt_template: string; output_variable?: string; stream_to_user: boolean; sources: string[]; tools: string[]; chunks?: string; retriever?: string; json_schema?: Record; } interface UserTool { id: string; name: string; displayName: string; } function WorkflowBuilderInner() { const navigate = useNavigate(); const { agentId } = useParams<{ agentId?: string }>(); const [searchParams] = useSearchParams(); const folderId = searchParams.get('folder_id'); const [workflowId, setWorkflowId] = useState( searchParams.get('workflow_id'), ); const reactFlowInstance = useReactFlow(); const [currentAgentId, setCurrentAgentId] = useState( agentId || null, ); const reactFlowWrapper = useRef(null); const [selectedNode, setSelectedNode] = useState(null); const [workflowName, setWorkflowName] = useState('New Workflow'); const [workflowDescription, setWorkflowDescription] = useState(''); const [showWorkflowSettings, setShowWorkflowSettings] = useState(false); const [isPublishing, setIsPublishing] = useState(false); const [publishErrors, setPublishErrors] = useState([]); const [errorContext, setErrorContext] = useState<'preview' | 'publish'>( 'publish', ); const [showNodeConfig, setShowNodeConfig] = useState(false); const [showPreview, setShowPreview] = useState(false); const configPanelRef = useRef(null); const workflowSettingsRef = useRef(null); const [availableModels, setAvailableModels] = useState([]); const [availableTools, setAvailableTools] = useState([]); const nodeTypes = useMemo( () => ({ start: StartNode, agent: AgentNode, end: EndNode, note: NoteNode, state: SetStateNode, }), [], ); const initialNodes: Node[] = useMemo( () => [ { id: 'start', type: 'start', data: { label: 'Start' }, position: { x: 250, y: 50 }, }, ], [], ); const [nodes, setNodes] = useState(initialNodes); const [edges, setEdges] = useState([]); const onNodesChange = useCallback( (changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)), [], ); const onEdgesChange = useCallback( (changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds)), [], ); const onConnect = useCallback( (params: Connection) => setEdges((eds) => addEdge(params, eds)), [], ); const onDragOver = useCallback((event: React.DragEvent) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }, []); const onDrop = useCallback( (event: React.DragEvent) => { event.preventDefault(); const type = event.dataTransfer.getData('application/reactflow'); if (!type) return; // Use screenToFlowPosition to correctly convert screen coordinates to flow coordinates // This accounts for viewport pan and zoom const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY, }); const baseNode: Node = { id: `${type}_${Date.now()}`, type, position, data: { title: `${type} node`, label: `${type} node`, }, }; if (type === 'agent') { baseNode.data.config = { agent_type: 'classic', system_prompt: 'You are a helpful assistant.', prompt_template: '', stream_to_user: true, sources: [], tools: [], } as AgentNodeConfig; } else if (type === 'state') { baseNode.data.title = 'Set State'; baseNode.data.variable = ''; baseNode.data.value = ''; } else if (type === 'note') { baseNode.data.title = 'Note'; baseNode.data.label = 'Note'; } setNodes((nds) => nds.concat(baseNode)); }, [reactFlowInstance], ); const handleNodeClick = useCallback( (_event: React.MouseEvent, node: Node) => { setSelectedNode(node); setShowNodeConfig(true); }, [], ); const handleDeleteNode = useCallback(() => { if (!selectedNode || selectedNode.type === 'start') return; setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id)); setEdges((eds) => eds.filter( (e) => e.source !== selectedNode.id && e.target !== selectedNode.id, ), ); setSelectedNode(null); setShowNodeConfig(false); }, [selectedNode]); const handleUpdateNodeData = useCallback( (data: Record) => { if (!selectedNode) return; setNodes((nds) => nds.map((n) => n.id === selectedNode.id ? { ...n, data: { ...n.data, ...data } } : n, ), ); setSelectedNode((prev) => prev ? { ...prev, data: { ...prev.data, ...data } } : null, ); }, [selectedNode], ); useEffect(() => { if (publishErrors.length > 0) { const timer = setTimeout(() => { setPublishErrors([]); }, 6000); return () => clearTimeout(timer); } }, [publishErrors.length]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Delete' && selectedNode) { handleDeleteNode(); } if (e.key === 'Escape') { setShowNodeConfig(false); setSelectedNode(null); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedNode, handleDeleteNode]); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; const isInsidePanel = configPanelRef.current?.contains(target) ?? false; const isInsideRadixPortal = target.closest('[data-radix-popper-content-wrapper]') !== null || target.closest('[data-radix-select-content]') !== null || target.closest('[role="listbox"]') !== null || target.closest('[cmdk-root]') !== null; if (!isInsidePanel && !isInsideRadixPortal) { setShowNodeConfig(false); } }; if (showNodeConfig) { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } }, [showNodeConfig]); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( workflowSettingsRef.current && !workflowSettingsRef.current.contains(e.target as HTMLElement) ) { setShowWorkflowSettings(false); } }; if (showWorkflowSettings) { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } }, [showWorkflowSettings]); useEffect(() => { const loadModelsAndTools = async () => { try { const modelsResponse = await modelService.getModels(null); if (modelsResponse.ok) { const modelsData = await modelsResponse.json(); setAvailableModels(modelService.transformModels(modelsData.models)); } const toolsResponse = await userService.getUserTools(null); if (toolsResponse.ok) { const toolsData = await toolsResponse.json(); setAvailableTools(toolsData.tools); } } catch (error) { console.error('Failed to load models or tools:', error); } }; loadModelsAndTools(); }, []); useEffect(() => { const loadAgentDetails = async () => { if (!agentId) return; try { const response = await userService.getAgent(agentId, null); if (!response.ok) throw new Error('Failed to fetch agent'); const agent = await response.json(); if (agent.agent_type === 'workflow' && agent.workflow) { setWorkflowId(agent.workflow); setCurrentAgentId(agent.id); setWorkflowName(agent.name); setWorkflowDescription(agent.description || ''); } } catch (error) { console.error('Failed to load agent:', error); } }; loadAgentDetails(); }, [agentId]); useEffect(() => { const loadWorkflow = async () => { if (!workflowId) return; try { const response = await userService.getWorkflow(workflowId, null); if (!response.ok) throw new Error('Failed to fetch workflow'); const responseData = await response.json(); const { workflow, nodes: apiNodes, edges: apiEdges } = responseData; setWorkflowName(workflow.name); setWorkflowDescription(workflow.description || ''); setNodes( apiNodes.map((n: WorkflowNode) => { const nodeData: Record = { title: n.title, label: n.title, }; if (n.type === 'agent' && n.data) { nodeData.config = n.data; } else if (n.data) { Object.assign(nodeData, n.data); } return { id: n.id, type: n.type, position: n.position, data: nodeData, }; }), ); setEdges( apiEdges.map( (e: { id: string; source: string; target: string; sourceHandle?: string; targetHandle?: string; }) => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle, targetHandle: e.targetHandle, }), ), ); // Fit view after loading with slight delay to ensure nodes are rendered setTimeout(() => { reactFlowInstance.fitView({ padding: 0.2, maxZoom: 0.8, duration: 300, }); }, 100); } catch (error) { console.error('Failed to load workflow:', error); } }; loadWorkflow(); }, [workflowId, reactFlowInstance]); const validateWorkflow = useCallback((): string[] => { const errors: string[] = []; if (!workflowName.trim()) { errors.push('Workflow name is required'); } const startNodes = nodes.filter((n) => n.type === 'start'); if (startNodes.length !== 1) { errors.push('Workflow must have exactly one start node'); } const endNodes = nodes.filter((n) => n.type === 'end'); if (endNodes.length === 0) { errors.push('Workflow must have at least one end node'); } const agentNodes = nodes.filter((n) => n.type === 'agent'); if (agentNodes.length === 0) { errors.push('Workflow must have at least one AI agent node'); } agentNodes.forEach((node) => { const config = node.data?.config; if (!config?.llm_name && !config?.model_id) { errors.push( `Agent "${node.data?.title || node.id}" must have a model selected`, ); } }); if (startNodes.length === 1) { const startId = startNodes[0].id; const hasOutgoing = edges.some((e) => e.source === startId); if (!hasOutgoing) { errors.push('Start node must be connected to another node'); } } endNodes.forEach((endNode) => { const hasIncoming = edges.some((e) => e.target === endNode.id); if (!hasIncoming) { errors.push( `End node "${endNode.id}" must have an incoming connection`, ); } }); const nodeIds = new Set(nodes.map((n) => n.id)); edges.forEach((edge) => { if (!nodeIds.has(edge.source)) { errors.push(`Edge references non-existent source node`); } if (!nodeIds.has(edge.target)) { errors.push(`Edge references non-existent target node`); } }); return errors; }, [workflowName, nodes, edges]); const handlePublish = useCallback(async () => { setPublishErrors([]); setErrorContext('publish'); const validationErrors = validateWorkflow(); if (validationErrors.length > 0) { setPublishErrors(validationErrors); return; } setIsPublishing(true); try { const workflowPayload = { name: workflowName, description: workflowDescription, nodes: nodes.map((n) => ({ id: n.id, type: n.type as 'start' | 'end' | 'agent' | 'note' | 'state', title: n.data.title || n.data.label || n.type, position: n.position, data: n.type === 'agent' ? n.data.config : n.data, })), edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle || undefined, targetHandle: e.targetHandle || undefined, })), }; let savedWorkflowId = workflowId; if (workflowId) { const updateResponse = await userService.updateWorkflow( workflowId, workflowPayload, null, ); if (!updateResponse.ok) { const errorData = await updateResponse.json().catch(() => ({})); throw new Error(errorData.message || 'Failed to update workflow'); } if (currentAgentId) { const agentFormData = new FormData(); agentFormData.append('name', workflowName); agentFormData.append( 'description', workflowDescription || `Workflow agent: ${workflowName}`, ); agentFormData.append('status', 'published'); const agentUpdateResponse = await userService.updateAgent( currentAgentId, agentFormData, null, ); if (!agentUpdateResponse.ok) { throw new Error('Failed to update agent'); } } } else { const createResponse = await userService.createWorkflow( workflowPayload, null, ); if (!createResponse.ok) { const errorData = await createResponse.json().catch(() => ({})); const backendErrors = errorData.errors || []; if (backendErrors.length > 0) { setPublishErrors(backendErrors); return; } throw new Error(errorData.message || 'Failed to create workflow'); } const responseData = await createResponse.json(); savedWorkflowId = responseData.id; const agentFormData = new FormData(); agentFormData.append('name', workflowName); agentFormData.append( 'description', workflowDescription || `Workflow agent: ${workflowName}`, ); agentFormData.append('agent_type', 'workflow'); agentFormData.append('status', 'published'); agentFormData.append('workflow', savedWorkflowId || ''); if (folderId) agentFormData.append('folder_id', folderId); const agentResponse = await userService.createAgent( agentFormData, null, ); if (!agentResponse.ok) throw new Error('Failed to create agent'); } navigate(folderId ? `/agents?folder=${folderId}` : '/agents'); } catch (error) { console.error('Failed to publish workflow:', error); setPublishErrors([ error instanceof Error ? error.message : 'Failed to publish workflow', ]); } finally { setIsPublishing(false); } }, [ workflowName, workflowDescription, nodes, edges, navigate, folderId, workflowId, currentAgentId, validateWorkflow, ]); return (
{showWorkflowSettings && (
setWorkflowName(e.target.value)} className="focus:ring-purple-30 w-full rounded-lg border border-[#E5E5E5] bg-white px-3 py-2 text-sm outline-none focus:ring-2 dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white" placeholder="Enter workflow name" />