Files
AI-VideoAssistant/web/pages/WorkflowEditor.tsx
Xin Wang eecde9f0fb Integrate React Query for data management and enhance Debug Preferences
- Added React Query for managing API calls related to assistants and voices.
- Introduced `useAssistantsQuery` and `useVoicesQuery` hooks for fetching data.
- Implemented mutations for creating, updating, and deleting voices using React Query.
- Integrated a global `QueryClient` for managing query states and configurations.
- Refactored components to utilize the new query hooks, improving data handling and performance.
- Added a Zustand store for managing debug preferences, including WebSocket URL and audio settings.
2026-03-02 22:50:57 +08:00

723 lines
33 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { WorkflowNode, WorkflowEdge, Workflow } from '../types';
import { DebugDrawer } from './Assistants';
import { createWorkflow, fetchWorkflowById, updateWorkflow } from '../services/backendApi';
import { useAssistantsQuery } from '../services/queries';
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<WorkflowNode[]>(() => getTemplateNodes(templateType));
const [edges, setEdges] = useState<WorkflowEdge[]>([]);
const [createdAt, setCreatedAt] = useState('');
const { data: assistants = [] } = useAssistantsQuery();
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);
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(() => {
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<WorkflowEdge>) => {
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<Workflow> = {
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 (
<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'
}}
>
<svg className="absolute inset-0 pointer-events-none overflow-visible">
{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 (
<g key={key}>
<path d={d} stroke="rgba(148,163,184,0.55)" strokeWidth={2} fill="none" />
{(edge.label || edge.condition?.value) && (
<text x={(x1 + x2) / 2} y={midY - 6} fill="rgba(226,232,240,0.8)" fontSize={10} textAnchor="middle">
{edge.label || edge.condition?.value}
</text>
)}
</g>
);
})}
</svg>
{nodes.map(node => (
<div
key={nodeRef(node)}
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' || n.type === 'assistant' || n.type === 'start'
? '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' || selectedNode.type === 'assistant' || selectedNode.type === 'start') && (
<>
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest"></label>
<select
value={selectedNode.assistantId || ''}
onChange={(e) => updateNodeData('assistantId', e.target.value || undefined)}
className="w-full h-8 bg-white/5 border border-white/10 rounded-md px-2 text-xs text-white focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value=""> Prompt</option>
{assistants.map((assistant) => (
<option key={assistant.id} value={assistant.id}>
{assistant.name}
</option>
))}
</select>
</div>
<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 className="pt-4 border-t border-white/5 space-y-3">
<div className="flex items-center justify-between">
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest"></label>
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={addEdgeFromSelected}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
{outgoingEdges.length === 0 && (
<p className="text-[11px] text-muted-foreground"></p>
)}
{outgoingEdges.map((edge, index) => {
const edgeId = edge.id || `${edgeFromRef(edge)}->${edgeToRef(edge)}:${index}`;
const keyword = edge.condition?.value || edge.label || '';
return (
<div key={edgeId} className="rounded-lg border border-white/10 p-3 space-y-2 bg-white/5">
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground"> #{index + 1}</span>
<button
className="text-[10px] text-destructive hover:underline"
onClick={() => removeOutgoingEdge(edgeId)}
>
</button>
</div>
<div className="space-y-1">
<label className="text-[10px] text-muted-foreground"></label>
<select
value={edgeToRef(edge)}
onChange={(e) =>
updateOutgoingEdge(edgeId, {
toNodeId: e.target.value,
to: e.target.value,
})
}
className="w-full h-8 bg-black/20 border border-white/10 rounded-md px-2 text-xs text-white focus:outline-none"
>
{nodes
.filter((node) => nodeRef(node) !== selectedNodeRef)
.map((node) => (
<option key={nodeRef(node)} value={nodeRef(node)}>
{node.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-[10px] text-muted-foreground">=always</label>
<Input
value={keyword}
onChange={(e) => {
const v = e.target.value;
updateOutgoingEdge(edgeId, {
label: v || undefined,
condition: v
? { type: 'contains', source: 'user', value: v }
: { type: 'always' },
});
}}
className="h-8 text-xs"
placeholder="例如:退款 / 投诉 / 结束"
/>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
{/* Debug Side Drawer */}
<DebugDrawer
isOpen={isDebugOpen}
onClose={() => setIsDebugOpen(false)}
sessionMetadataExtras={{ workflow: workflowRuntimeMetadata }}
onProtocolEvent={(event) => {
if (event?.type !== 'workflow.node.entered') return;
const incomingNodeId = String(event.nodeId || '');
const matched = nodes.find((node) => nodeRef(node) === incomingNodeId || node.name === incomingNodeId);
if (matched) {
setSelectedNodeName(matched.name);
}
}}
assistant={assistants[0] || {
id: 'debug',
name: 'Debug Assistant',
callCount: 0,
opener: 'Hello!',
prompt: '',
knowledgeBaseId: '',
language: 'zh',
voice: '',
speed: 1,
hotwords: [],
}}
voices={[]}
llmModels={[]}
asrModels={[]}
/>
</div>
);
};
const NodeIcon = ({ type }: { type: WorkflowNode['type'] }) => {
switch (type) {
case 'conversation': return <Bot className="h-4 w-4 text-primary" />;
case 'assistant': return <Bot className="h-4 w-4 text-primary" />;
case 'start': return <Bot className="h-4 w-4 text-cyan-300" />;
case 'human': return <UserCheck className="h-4 w-4 text-orange-400" />;
case 'human_transfer': 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" />;
}
};