403 lines
20 KiB
TypeScript
403 lines
20 KiB
TypeScript
|
||
import React, { useState, useRef, useEffect } 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 { mockAssistants, mockKnowledgeBases, mockWorkflows } from '../services/mockData';
|
||
import { WorkflowNode, WorkflowEdge, Workflow } from '../types';
|
||
import { DebugDrawer } from './Assistants';
|
||
|
||
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');
|
||
|
||
// Find initial workflow or create a new one
|
||
const existingWf = mockWorkflows.find(w => w.id === id);
|
||
const [name, setName] = useState(templateName || existingWf?.name || '新工作流');
|
||
const [nodes, setNodes] = useState<WorkflowNode[]>(() => {
|
||
if (existingWf) return existingWf.nodes;
|
||
if (templateType === 'lead') {
|
||
return [
|
||
{
|
||
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?" }
|
||
},
|
||
{
|
||
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' }
|
||
]
|
||
}
|
||
},
|
||
{
|
||
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 [
|
||
{
|
||
name: 'start_node',
|
||
type: 'conversation',
|
||
isStart: true,
|
||
metadata: { position: { x: 200, y: 200 } },
|
||
prompt: '欢迎对话系统...',
|
||
messagePlan: { firstMessage: '你好!' }
|
||
}
|
||
];
|
||
});
|
||
const [edges, setEdges] = useState<WorkflowEdge[]>(existingWf?.edges || []);
|
||
|
||
const [selectedNodeName, setSelectedNodeName] = useState<string | null>(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<string | null>(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);
|
||
|
||
// 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]);
|
||
|
||
const addNode = (type: WorkflowNode['type']) => {
|
||
const newNode: WorkflowNode = {
|
||
name: `${type}_${Date.now()}`,
|
||
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 => prev.map(n => n.name === selectedNodeName ? { ...n, [field]: value } : n));
|
||
};
|
||
|
||
const handleSave = () => {
|
||
const now = new Date().toISOString().replace('T', ' ').substring(0, 16);
|
||
const updatedWorkflow: Workflow = {
|
||
id: id || `wf_${Date.now()}`,
|
||
name,
|
||
nodeCount: nodes.length,
|
||
createdAt: existingWf?.createdAt || now,
|
||
updatedAt: now,
|
||
nodes,
|
||
edges,
|
||
};
|
||
|
||
if (id) {
|
||
const idx = mockWorkflows.findIndex(w => w.id === id);
|
||
if (idx !== -1) mockWorkflows[idx] = updatedWorkflow;
|
||
} else {
|
||
mockWorkflows.push(updatedWorkflow);
|
||
}
|
||
alert('保存成功!工作流已同步至列表。');
|
||
navigate('/workflows');
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col h-[calc(100vh-6rem)] relative bg-card/10 border border-white/5 rounded-2xl overflow-hidden animate-in fade-in duration-300">
|
||
{/* Editor Header */}
|
||
<header className="h-14 border-b border-white/5 bg-white/[0.02] flex items-center justify-between px-4 shrink-0">
|
||
<div className="flex items-center space-x-3">
|
||
<Button variant="ghost" size="icon" onClick={() => navigate('/workflows')} className="h-8 w-8">
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</Button>
|
||
<div className="h-4 w-px bg-white/10"></div>
|
||
<Input
|
||
value={name}
|
||
onChange={e => 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"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<Button variant="outline" size="sm" onClick={() => setIsDebugOpen(true)}>
|
||
<Play className="mr-1.5 h-3.5 w-3.5" /> 调试
|
||
</Button>
|
||
<Button variant="secondary" size="sm" onClick={handleSave}>
|
||
<Save className="mr-1.5 h-3.5 w-3.5" /> 保存
|
||
</Button>
|
||
<Button size="sm" onClick={() => alert('发布成功!')}>
|
||
<Rocket className="mr-1.5 h-3.5 w-3.5" /> 发布
|
||
</Button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Canvas Area */}
|
||
<div
|
||
className="flex-1 relative bg-[radial-gradient(rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:24px_24px] overflow-hidden select-none cursor-crosshair"
|
||
onWheel={handleWheel}
|
||
onMouseDown={handleMouseDown}
|
||
>
|
||
{/* Floating Controls */}
|
||
<div className="absolute top-4 left-4 z-10 flex flex-col items-start gap-2">
|
||
<Button
|
||
className="rounded-xl shadow-lg h-10 px-5 bg-primary text-primary-foreground font-semibold hover:bg-primary/90"
|
||
onClick={() => setIsAddMenuOpen(!isAddMenuOpen)}
|
||
>
|
||
<Plus className="mr-2 h-4 w-4" /> {isAddMenuOpen ? '取消' : '添加节点'}
|
||
</Button>
|
||
{isAddMenuOpen && (
|
||
<div className="bg-background/90 backdrop-blur-xl border border-white/10 rounded-xl p-1.5 w-48 shadow-2xl animate-in slide-in-from-top-2">
|
||
<button onClick={() => addNode('conversation')} className="flex items-center w-full p-2.5 hover:bg-primary/10 rounded-lg transition-colors text-sm text-left text-white">
|
||
<Bot className="w-4 h-4 mr-3 text-primary" /> <span>对话节点</span>
|
||
</button>
|
||
<button onClick={() => addNode('tool')} className="flex items-center w-full p-2.5 hover:bg-purple-400/10 rounded-lg transition-colors text-sm text-left text-white">
|
||
<Wrench className="w-4 h-4 mr-3 text-purple-400" /> <span>工具节点</span>
|
||
</button>
|
||
<button onClick={() => addNode('human')} className="flex items-center w-full p-2.5 hover:bg-orange-400/10 rounded-lg transition-colors text-sm text-left text-white">
|
||
<UserCheck className="w-4 h-4 mr-3 text-orange-400" /> <span>转人工</span>
|
||
</button>
|
||
<button onClick={() => addNode('end')} className="flex items-center w-full p-2.5 hover:bg-destructive/10 rounded-lg transition-colors text-sm text-left text-destructive">
|
||
<Ban className="w-4 h-4 mr-3" /> <span>结束对话</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-2 py-1 text-[10px] text-muted-foreground font-mono">
|
||
<span>{Math.round(zoom * 100)}%</span>
|
||
<span className="opacity-30">|</span>
|
||
<span>POS: {Math.round(panOffset.x)}, {Math.round(panOffset.y)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Scalable Container */}
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{
|
||
transform: `translate(${panOffset.x}px, ${panOffset.y}px) scale(${zoom})`,
|
||
transformOrigin: '0 0'
|
||
}}
|
||
>
|
||
{nodes.map(node => (
|
||
<div
|
||
key={node.name}
|
||
onMouseDown={(e) => 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'}`}
|
||
>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center space-x-2 overflow-hidden">
|
||
<NodeIcon type={node.type} />
|
||
<span className="font-semibold text-[10px] truncate uppercase tracking-tight text-white">{node.name}</span>
|
||
</div>
|
||
{node.isStart && <Badge variant="default" className="scale-75 origin-right">START</Badge>}
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground line-clamp-3 italic opacity-60 leading-relaxed">
|
||
{node.prompt || (node.tool ? `Action: ${node.tool.type}` : '待配置逻辑...')}
|
||
</div>
|
||
|
||
{/* Port simulation */}
|
||
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background opacity-0 group-hover:opacity-100 cursor-crosshair transition-opacity shadow-[0_0_5px_rgba(6,182,212,0.5)]"></div>
|
||
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-muted rounded-full border-2 border-background opacity-0 group-hover:opacity-100 cursor-crosshair transition-opacity"></div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Bottom Right: Minimap */}
|
||
<div className="absolute bottom-6 right-6 w-48 h-32 bg-black/60 border border-white/10 rounded-2xl shadow-2xl pointer-events-none overflow-hidden flex flex-col backdrop-blur-md">
|
||
<div className="p-2 border-b border-white/5 bg-white/5 text-[9px] text-muted-foreground font-bold tracking-[0.2em] text-center uppercase">Minimap</div>
|
||
<div className="flex-1 relative p-3 opacity-60">
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<div className="relative w-full h-full">
|
||
{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 (
|
||
<div
|
||
key={i}
|
||
className={`absolute w-3 h-2 rounded-sm ${n.type === 'conversation' ? 'bg-primary' : n.type === 'end' ? 'bg-destructive' : 'bg-white/40'}`}
|
||
style={{ left: `${20 + mx}%`, top: `${20 + my}%` }}
|
||
></div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
{/* Viewport visualization */}
|
||
<div
|
||
className="absolute border border-primary/40 bg-primary/5 rounded shadow-sm"
|
||
style={{ width: '40%', height: '40%', left: '30%', top: '30%' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Navigator Guide */}
|
||
<div className="absolute bottom-4 left-4 text-[10px] text-muted-foreground/30 font-mono flex items-center gap-2">
|
||
<MousePointer2 className="w-3 h-3" /> 鼠标中键拖拽画布 | 滚轮缩放
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right Settings Panel */}
|
||
{selectedNode && (
|
||
<div className="absolute right-0 top-14 bottom-0 w-80 border-l border-white/10 bg-background/95 backdrop-blur-2xl flex flex-col animate-in slide-in-from-right duration-300 z-20 shadow-[-10px_0_30px_rgba(0,0,0,0.5)]">
|
||
<div className="p-4 border-b border-white/5 flex items-center justify-between bg-white/5">
|
||
<h3 className="text-sm font-bold uppercase tracking-widest text-primary">节点设置</h3>
|
||
<Button variant="ghost" size="icon" onClick={() => setSelectedNodeName(null)} className="h-8 w-8">
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto p-5 space-y-6 custom-scrollbar">
|
||
<div className="space-y-2">
|
||
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest">唯一标识 (Node Name)</label>
|
||
<Input
|
||
value={selectedNode.name}
|
||
onChange={e => updateNodeData('name', e.target.value)}
|
||
className="h-8 text-xs font-mono text-white bg-white/10"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest">节点类型</label>
|
||
<Badge variant="outline" className="w-fit">{selectedNode.type.toUpperCase()}</Badge>
|
||
</div>
|
||
|
||
{selectedNode.type === 'conversation' && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest">Prompt (指令)</label>
|
||
<textarea
|
||
className="w-full h-48 bg-white/5 border border-white/10 rounded-lg p-3 text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 text-white placeholder:text-muted-foreground/30 leading-relaxed"
|
||
value={selectedNode.prompt || ''}
|
||
onChange={e => updateNodeData('prompt', e.target.value)}
|
||
placeholder="输入该节点的业务规则、语气要求和回复逻辑..."
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest">First Message (开场白)</label>
|
||
<Input
|
||
value={selectedNode.messagePlan?.firstMessage || ''}
|
||
onChange={e => updateNodeData('messagePlan', { ...selectedNode.messagePlan, firstMessage: e.target.value })}
|
||
className="h-8 text-xs text-white"
|
||
placeholder="进入该节点时 AI 主动发起的消息..."
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{selectedNode.type === 'end' && (
|
||
<div className="p-4 rounded-xl border border-destructive/20 bg-destructive/5 text-xs text-destructive/80 space-y-2">
|
||
<p className="font-bold">结束对话节点</p>
|
||
<p>到达此节点后,系统将根据配置执行挂断操作。</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="pt-4 border-t border-white/5">
|
||
<label className="flex items-center gap-3 cursor-pointer group">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedNode.isStart}
|
||
onChange={e => updateNodeData('isStart', e.target.checked)}
|
||
className="w-4 h-4 rounded accent-primary border-white/10 bg-white/5"
|
||
/>
|
||
<span className="text-[10px] text-muted-foreground group-hover:text-primary transition-colors uppercase font-mono tracking-widest">起始节点 (Start Node)</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Debug Side Drawer */}
|
||
<DebugDrawer
|
||
isOpen={isDebugOpen}
|
||
onClose={() => setIsDebugOpen(false)}
|
||
assistant={mockAssistants[0]}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const NodeIcon = ({ type }: { type: WorkflowNode['type'] }) => {
|
||
switch (type) {
|
||
case 'conversation': return <Bot className="h-4 w-4 text-primary" />;
|
||
case 'human': return <UserCheck className="h-4 w-4 text-orange-400" />;
|
||
case 'tool': return <Wrench className="h-4 w-4 text-purple-400" />;
|
||
case 'end': return <Ban className="h-4 w-4 text-destructive" />;
|
||
default: return <MousePointer2 className="h-4 w-4" />;
|
||
}
|
||
};
|