Update workflow feature with codex

This commit is contained in:
Xin Wang
2026-02-10 08:12:46 +08:00
parent 6b4391c423
commit bbeffa89ed
8 changed files with 1334 additions and 39 deletions

View File

@@ -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" />;