Update workflow feature with codex
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
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';
|
||||
@@ -7,10 +7,21 @@ 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,
|
||||
@@ -19,6 +30,7 @@ const getTemplateNodes = (templateType: string | null): WorkflowNode[] => {
|
||||
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 } },
|
||||
@@ -31,6 +43,7 @@ const getTemplateNodes = (templateType: string | null): WorkflowNode[] => {
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'hangup_node',
|
||||
name: 'hangup_node',
|
||||
type: 'end',
|
||||
metadata: { position: { x: 450, y: 550 } },
|
||||
@@ -44,6 +57,7 @@ const getTemplateNodes = (templateType: string | null): WorkflowNode[] => {
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: 'start_node',
|
||||
name: 'start_node',
|
||||
type: 'conversation',
|
||||
isStart: true,
|
||||
@@ -80,6 +94,66 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
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) => {
|
||||
@@ -172,8 +246,10 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
}, [id]);
|
||||
|
||||
const addNode = (type: WorkflowNode['type']) => {
|
||||
const nodeId = `${type}_${Date.now()}`;
|
||||
const newNode: WorkflowNode = {
|
||||
name: `${type}_${Date.now()}`,
|
||||
id: nodeId,
|
||||
name: nodeId,
|
||||
type,
|
||||
metadata: { position: { x: (300 - panOffset.x) / zoom, y: (300 - panOffset.y) / zoom } },
|
||||
prompt: type === 'conversation' ? '输入该节点的 Prompt...' : '',
|
||||
@@ -185,7 +261,95 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
|
||||
const updateNodeData = (field: string, value: any) => {
|
||||
if (!selectedNodeName) return;
|
||||
setNodes(prev => prev.map(n => n.name === selectedNodeName ? { ...n, [field]: value } : n));
|
||||
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 () => {
|
||||
@@ -286,9 +450,29 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
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={node.name}
|
||||
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'}`}
|
||||
@@ -324,7 +508,13 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
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'}`}
|
||||
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>
|
||||
);
|
||||
@@ -369,8 +559,23 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
<Badge variant="outline" className="w-fit">{selectedNode.type.toUpperCase()}</Badge>
|
||||
</div>
|
||||
|
||||
{selectedNode.type === 'conversation' && (
|
||||
{(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
|
||||
@@ -410,6 +615,73 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
<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>
|
||||
)}
|
||||
@@ -418,6 +690,15 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
<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',
|
||||
@@ -429,7 +710,10 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
voice: '',
|
||||
speed: 1,
|
||||
hotwords: [],
|
||||
}}
|
||||
}}
|
||||
voices={[]}
|
||||
llmModels={[]}
|
||||
asrModels={[]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -438,7 +722,10 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
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" />;
|
||||
|
||||
Reference in New Issue
Block a user