import React, { useState, useRef, useEffect, useMemo } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { ArrowLeft, Play, Save, Rocket, Plus, Bot, UserCheck, Wrench, Ban, Zap, X, Copy, MousePointer2 } from 'lucide-react'; import { Button, Input, Badge } from '../components/UI'; import { Assistant, WorkflowNode, WorkflowEdge, Workflow } from '../types'; import { DebugDrawer } from './Assistants'; import { createWorkflow, fetchAssistants, fetchWorkflowById, updateWorkflow } from '../services/backendApi'; const toWorkflowNodeType = (type: WorkflowNode['type']): WorkflowNode['type'] => { if (type === 'conversation') return 'assistant'; if (type === 'human') return 'human_transfer'; return type; }; const nodeRef = (node: WorkflowNode): string => node.id || node.name; const edgeFromRef = (edge: WorkflowEdge): string => edge.fromNodeId || edge.from; const edgeToRef = (edge: WorkflowEdge): string => edge.toNodeId || edge.to; const getTemplateNodes = (templateType: string | null): WorkflowNode[] => { if (templateType === 'lead') { return [ { id: 'introduction', name: 'introduction', type: 'conversation', isStart: true, metadata: { position: { x: 100, y: 100 } }, prompt: "You are Morgan from GrowthPartners. Start with: 'Hello, this is Morgan from GrowthPartners. We help businesses improve their operational efficiency through custom software solutions. Do you have a few minutes to chat about how we might be able to help your business?'", messagePlan: { firstMessage: "Hello, this is Morgan from GrowthPartners. Do you have a few minutes to chat?" } }, { id: 'need_discovery', name: 'need_discovery', type: 'conversation', metadata: { position: { x: 450, y: 250 } }, prompt: "Conduct need discovery by asking about business challenges...", variableExtractionPlan: { output: [ { title: 'industry', type: 'string', description: 'user industry' }, { title: 'pain_points', type: 'string', description: 'main challenges' } ] } }, { id: 'hangup_node', name: 'hangup_node', type: 'end', metadata: { position: { x: 450, y: 550 } }, tool: { type: 'endCall', function: { name: 'hangup', parameters: {} }, messages: [{ type: 'request-start', content: 'Thank you for your time!', blocking: true }] } } ]; } return [ { id: 'start_node', name: 'start_node', type: 'conversation', isStart: true, metadata: { position: { x: 200, y: 200 } }, prompt: '欢迎对话系统...', messagePlan: { firstMessage: '你好!' } } ]; }; export const WorkflowEditorPage: React.FC = () => { const navigate = useNavigate(); const { id } = useParams(); const [searchParams] = useSearchParams(); // Template data for new workflows const templateName = searchParams.get('name'); const templateType = searchParams.get('template'); const [name, setName] = useState(templateName || '新工作流'); const [nodes, setNodes] = useState(() => getTemplateNodes(templateType)); const [edges, setEdges] = useState([]); const [createdAt, setCreatedAt] = useState(''); const [assistants, setAssistants] = useState([]); const [selectedNodeName, setSelectedNodeName] = useState(null); const [isAddMenuOpen, setIsAddMenuOpen] = useState(false); const [isDebugOpen, setIsDebugOpen] = useState(false); const [zoom, setZoom] = useState(1); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const [draggingNodeName, setDraggingNodeName] = useState(null); const [isPanning, setIsPanning] = useState(false); const dragOffset = useRef({ x: 0, y: 0 }); const panStart = useRef({ x: 0, y: 0 }); const selectedNode = nodes.find(n => n.name === selectedNodeName); const selectedNodeRef = selectedNode ? nodeRef(selectedNode) : null; const outgoingEdges = selectedNodeRef ? edges.filter((edge) => edgeFromRef(edge) === selectedNodeRef) : []; const resolvedEdges = useMemo(() => { return edges .map((edge, index) => { const from = nodes.find((node) => nodeRef(node) === edgeFromRef(edge)); const to = nodes.find((node) => nodeRef(node) === edgeToRef(edge)); if (!from || !to) return null; return { key: edge.id || `${edgeFromRef(edge)}->${edgeToRef(edge)}:${index}`, edge, from, to, }; }) .filter((item): item is { key: string; edge: WorkflowEdge; from: WorkflowNode; to: WorkflowNode } => Boolean(item)); }, [edges, nodes]); const workflowRuntimeMetadata = useMemo(() => { return { id: id || 'draft_workflow', name, nodes: nodes.map((node) => { const assistant = node.assistantId ? assistants.find((item) => item.id === node.assistantId) : undefined; return { id: nodeRef(node), name: node.name || nodeRef(node), type: toWorkflowNodeType(node.type), isStart: node.isStart, prompt: node.prompt, messagePlan: node.messagePlan, assistantId: node.assistantId, assistant: assistant ? { systemPrompt: node.prompt || assistant.prompt || '', greeting: node.messagePlan?.firstMessage || assistant.opener || '', } : undefined, tool: node.tool, metadata: node.metadata, }; }), edges: edges.map((edge, index) => { const fromNodeId = edgeFromRef(edge); const toNodeId = edgeToRef(edge); return { id: edge.id || `edge_${index + 1}`, fromNodeId, toNodeId, label: edge.label, priority: edge.priority ?? 100, condition: edge.condition || (edge.label ? { type: 'contains', source: 'user', value: edge.label } : { type: 'always' }), }; }), }; }, [assistants, edges, id, name, nodes]); // Scroll Zoom handler const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); const zoomSpeed = 0.001; const newZoom = Math.min(Math.max(zoom - e.deltaY * zoomSpeed, 0.2), 3); setZoom(newZoom); }; // Middle mouse click handler for panning const handleMouseDown = (e: React.MouseEvent) => { // Button 1 is middle mouse if (e.button === 1) { e.preventDefault(); setIsPanning(true); panStart.current = { x: e.clientX - panOffset.x, y: e.clientY - panOffset.y }; } }; const handleNodeMouseDown = (e: React.MouseEvent, nodeName: string) => { if (e.button !== 0) return; // Only left click for nodes e.stopPropagation(); setSelectedNodeName(nodeName); setDraggingNodeName(nodeName); const node = nodes.find(n => n.name === nodeName); if (node) { dragOffset.current = { x: e.clientX - node.metadata.position.x * zoom, y: e.clientY - node.metadata.position.y * zoom }; } }; useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (draggingNodeName) { setNodes(prev => prev.map(n => n.name === draggingNodeName ? { ...n, metadata: { ...n.metadata, position: { x: (e.clientX - dragOffset.current.x) / zoom, y: (e.clientY - dragOffset.current.y) / zoom } } } : n )); } else if (isPanning) { setPanOffset({ x: e.clientX - panStart.current.x, y: e.clientY - panStart.current.y }); } }; const handleMouseUp = (e: MouseEvent) => { setDraggingNodeName(null); setIsPanning(false); }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; }, [draggingNodeName, isPanning, zoom]); useEffect(() => { const loadData = async () => { try { const assistantList = await fetchAssistants(); setAssistants(assistantList); } catch (error) { console.error(error); } }; loadData(); }, []); useEffect(() => { if (!id) return; const loadWorkflow = async () => { try { const workflow = await fetchWorkflowById(id); setName(workflow.name); setNodes(workflow.nodes); setEdges(workflow.edges); setCreatedAt(workflow.createdAt); } catch (error) { console.error(error); alert('加载工作流失败。'); } }; loadWorkflow(); }, [id]); const addNode = (type: WorkflowNode['type']) => { const nodeId = `${type}_${Date.now()}`; const newNode: WorkflowNode = { id: nodeId, name: nodeId, type, metadata: { position: { x: (300 - panOffset.x) / zoom, y: (300 - panOffset.y) / zoom } }, prompt: type === 'conversation' ? '输入该节点的 Prompt...' : '', tool: type === 'end' ? { type: 'endCall', function: { name: 'hangup', parameters: {} } } : undefined }; setNodes([...nodes, newNode]); setIsAddMenuOpen(false); }; const updateNodeData = (field: string, value: any) => { if (!selectedNodeName) return; setNodes(prev => { const currentNode = prev.find((n) => n.name === selectedNodeName); if (!currentNode) return prev; const oldRef = nodeRef(currentNode); const updatedNodes = prev.map((node) => { if (node.name !== selectedNodeName) { if (field === 'isStart' && value === true) { return { ...node, isStart: false }; } return node; } if (field === 'isStart' && value === true) { return { ...node, isStart: true }; } return { ...node, [field]: value }; }); if (field === 'name') { const renamed = updatedNodes.find((n) => n.name === value); const newRef = renamed ? nodeRef(renamed) : String(value); setEdges((prevEdges) => prevEdges.map((edge) => { const from = edgeFromRef(edge); const to = edgeToRef(edge); if (from !== oldRef && to !== oldRef) return edge; const nextFrom = from === oldRef ? newRef : from; const nextTo = to === oldRef ? newRef : to; return { ...edge, fromNodeId: nextFrom, toNodeId: nextTo, from: nextFrom, to: nextTo, }; }) ); } return updatedNodes; }); }; const addEdgeFromSelected = () => { if (!selectedNode) return; const fromNodeId = nodeRef(selectedNode); const target = nodes.find((node) => nodeRef(node) !== fromNodeId); if (!target) return; const toNodeId = nodeRef(target); const edgeId = `edge_${Date.now()}`; setEdges((prev) => [ ...prev, { id: edgeId, fromNodeId, toNodeId, from: fromNodeId, to: toNodeId, condition: { type: 'always' }, }, ]); }; const updateOutgoingEdge = (edgeId: string, patch: Partial) => { setEdges((prev) => prev.map((edge, index) => { const idForCompare = edge.id || `${edgeFromRef(edge)}->${edgeToRef(edge)}:${index}`; if (idForCompare !== edgeId) return edge; const next = { ...edge, ...patch }; const fromNodeId = edgeFromRef(next); const toNodeId = edgeToRef(next); return { ...next, fromNodeId, toNodeId, from: fromNodeId, to: toNodeId, }; }) ); }; const removeOutgoingEdge = (edgeId: string) => { setEdges((prev) => prev.filter((edge, index) => { const idForCompare = edge.id || `${edgeFromRef(edge)}->${edgeToRef(edge)}:${index}`; return idForCompare !== edgeId; }) ); }; const handleSave = async () => { const now = new Date().toISOString().replace('T', ' ').substring(0, 16); const workflowPayload: Partial = { name, nodeCount: nodes.length, createdAt: createdAt || now, updatedAt: now, nodes, edges, }; try { if (id) { await updateWorkflow(id, workflowPayload); } else { await createWorkflow(workflowPayload); } alert('保存成功!工作流已同步至列表。'); navigate('/workflows'); } catch (error) { console.error(error); alert('保存失败,请稍后重试。'); } }; return (
{/* Editor Header */}
setName(e.target.value)} className="bg-transparent border-transparent focus:bg-white/5 font-bold text-sm h-8 w-48 shadow-none text-white" />
{/* Canvas Area */}
{/* Floating Controls */}
{isAddMenuOpen && (
)}
{Math.round(zoom * 100)}% | POS: {Math.round(panOffset.x)}, {Math.round(panOffset.y)}
{/* Scalable Container */}
{resolvedEdges.map(({ key, from, to, edge }) => { const x1 = from.metadata.position.x + 112; const y1 = from.metadata.position.y + 88; const x2 = to.metadata.position.x + 112; const y2 = to.metadata.position.y; const midY = (y1 + y2) / 2; const d = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`; return ( {(edge.label || edge.condition?.value) && ( {edge.label || edge.condition?.value} )} ); })} {nodes.map(node => (
handleNodeMouseDown(e, node.name)} style={{ left: node.metadata.position.x, top: node.metadata.position.y }} className={`absolute w-56 p-4 rounded-xl border bg-card/70 backdrop-blur-sm cursor-grab active:cursor-grabbing group transition-shadow ${selectedNodeName === node.name ? 'border-primary shadow-[0_0_30px_rgba(6,182,212,0.3)]' : 'border-white/10 hover:border-white/30'}`} >
{node.name}
{node.isStart && START}
{node.prompt || (node.tool ? `Action: ${node.tool.type}` : '待配置逻辑...')}
{/* Port simulation */}
))}
{/* Bottom Right: Minimap */}
Minimap
{nodes.map((n, i) => { // Simple scaling for minimap const mx = (n.metadata.position.x / 10) % 100; const my = (n.metadata.position.y / 10) % 100; return (
); })}
{/* Viewport visualization */}
{/* Navigator Guide */}
鼠标中键拖拽画布 | 滚轮缩放
{/* Right Settings Panel */} {selectedNode && (

节点设置

updateNodeData('name', e.target.value)} className="h-8 text-xs font-mono text-white bg-white/10" />
{selectedNode.type.toUpperCase()}
{(selectedNode.type === 'conversation' || selectedNode.type === 'assistant' || selectedNode.type === 'start') && ( <>