separate workflow control and data edges

This commit is contained in:
RockChinQ
2026-07-01 22:01:06 +08:00
parent c588f9dfc1
commit 235b33c335
11 changed files with 457 additions and 124 deletions
@@ -323,6 +323,7 @@ class WorkflowService:
source_port=e.get('source_port', '') or e.get('sourceHandle', 'output'),
target_node=e.get('target_node', '') or e.get('target', ''),
target_port=e.get('target_port', '') or e.get('targetHandle', 'input'),
edge_type=e.get('edge_type') or (e.get('data', {}) or {}).get('edgeType') or 'legacy',
condition=e.get('condition') or (e.get('data', {}) or {}).get('condition'),
)
for e in definition.get('edges', [])
+1 -6
View File
@@ -441,12 +441,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
):
continue
# Check if this node's inputs are ready
incoming_nodes = set()
for source_id, edges in edge_map.items():
for edge in edges:
if edge.target_node == node.id:
incoming_nodes.add(source_id)
incoming_nodes = self._incoming_dependency_nodes(node.id, edge_map)
# If no incoming nodes, it's a start node
if not incoming_nodes:
+1
View File
@@ -52,6 +52,7 @@ class EdgeDefinition(pydantic.BaseModel):
source_port: str = 'output'
target_node: str
target_port: str = 'input'
edge_type: str = 'legacy' # control, data, or legacy (old mixed semantics)
condition: Optional[str] = None # Optional condition expression
+36 -10
View File
@@ -390,10 +390,12 @@ class WorkflowExecutor:
else:
inputs[key] = value
# Get inputs from connected upstream nodes via edges
# Get inputs from connected upstream nodes via data edges.
# Build a reverse map: for each incoming edge to this node, find the
# source node and the specific source/target port.
for edge in self._edges:
if not self._is_data_edge(edge):
continue
if edge.target_node != node.id:
continue
source_state = context.node_states.get(edge.source_node)
@@ -519,13 +521,8 @@ class WorkflowExecutor:
async def _inputs_ready(
self, node: NodeDefinition, edge_map: dict[str, list[EdgeDefinition]], context: ExecutionContext
) -> bool:
"""Check if all inputs for a node are ready"""
# Find all edges that connect to this node
incoming_nodes = set()
for source_id, edges in edge_map.items():
for edge in edges:
if edge.target_node == node.id:
incoming_nodes.add(source_id)
"""Check if all control predecessors and data providers are ready."""
incoming_nodes = self._incoming_dependency_nodes(node.id, edge_map)
# Check if all incoming nodes have completed
for source_id in incoming_nodes:
@@ -537,7 +534,7 @@ class WorkflowExecutor:
def _find_start_nodes(self, nodes: list[NodeDefinition], edges: list[EdgeDefinition]) -> list[NodeDefinition]:
"""Find nodes that have no incoming edges (start nodes)"""
target_nodes = {edge.target_node for edge in edges}
target_nodes = {edge.target_node for edge in edges if self._is_control_edge(edge) or self._is_data_edge(edge)}
start_nodes = [node for node in nodes if node.id not in target_nodes]
# Also check for trigger nodes
@@ -549,14 +546,43 @@ class WorkflowExecutor:
return start_nodes
def _build_edge_map(self, edges: list[EdgeDefinition]) -> dict[str, list[EdgeDefinition]]:
"""Build a map of source node ID to outgoing edges"""
"""Build a map of source node ID to outgoing control edges."""
edge_map: dict[str, list[EdgeDefinition]] = {}
for edge in edges:
if not self._is_control_edge(edge):
continue
if edge.source_node not in edge_map:
edge_map[edge.source_node] = []
edge_map[edge.source_node].append(edge)
return edge_map
def _edge_type(self, edge: EdgeDefinition) -> str:
edge_type = (getattr(edge, 'edge_type', None) or 'legacy').strip().lower()
if edge_type not in {'control', 'data', 'legacy'}:
return 'legacy'
return edge_type
def _is_control_edge(self, edge: EdgeDefinition) -> bool:
return self._edge_type(edge) in {'control', 'legacy'}
def _is_data_edge(self, edge: EdgeDefinition) -> bool:
return self._edge_type(edge) in {'data', 'legacy'}
def _incoming_dependency_nodes(
self, node_id: str, edge_map: dict[str, list[EdgeDefinition]]
) -> set[str]:
incoming_nodes: set[str] = set()
for source_id, edges in edge_map.items():
for edge in edges:
if edge.target_node == node_id:
incoming_nodes.add(source_id)
for edge in self._edges:
if self._is_data_edge(edge) and edge.target_node == node_id:
incoming_nodes.add(edge.source_node)
return incoming_nodes
def _record_execution_step(self, node: NodeDefinition, node_state: NodeState, context: ExecutionContext):
"""Record an execution step in the history"""
duration_ms = 0
@@ -51,6 +51,11 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { toast } from 'sonner';
import { resolveI18nLabel, maybeTranslateKey } from './workflow-i18n';
import {
WorkflowEdgeType,
isControlHandle,
normalizeWorkflowEdgeType,
} from './workflow-constants';
// Delegate to shared utility
const translateIfKey = (value: string | undefined): string | undefined => {
@@ -76,6 +81,29 @@ const getTypeLabel = (type: string | undefined): string => {
return translated === i18nKey ? type : translated;
};
const getEdgeTypeLabel = (
edgeType: WorkflowEdgeType,
t: (key: string, options?: { defaultValue: string }) => string,
): string => {
if (edgeType === 'control') {
return t('workflows.edgeTypes.control', { defaultValue: 'Control' });
}
if (edgeType === 'data') {
return t('workflows.edgeTypes.data', { defaultValue: 'Data' });
}
return t('workflows.edgeTypes.legacy', { defaultValue: 'Legacy' });
};
const getEdgeTypeBadgeClass = (edgeType: WorkflowEdgeType): string => {
if (edgeType === 'control') {
return 'border-orange-300 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950/40 dark:text-orange-300';
}
if (edgeType === 'data') {
return 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-800 dark:bg-sky-950/40 dark:text-sky-300';
}
return 'border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300';
};
interface PropertyPanelProps {
selectedNodeId: string | null;
selectedEdgeId: string | null;
@@ -275,7 +303,11 @@ export default function PropertyPanel({
}[] = [];
// Find all upstream nodes
const incomingEdges = edges.filter((e) => e.target === selectedNode.id);
const incomingEdges = edges.filter(
(e) =>
e.target === selectedNode.id &&
normalizeWorkflowEdgeType(e.data?.edgeType) !== 'control',
);
const upstreamNodeIds = incomingEdges.map((e) => e.source);
for (const nodeId of upstreamNodeIds) {
@@ -414,6 +446,17 @@ export default function PropertyPanel({
if (selectedEdge) {
const sourceNode = nodes.find((n) => n.id === selectedEdge.source);
const targetNode = nodes.find((n) => n.id === selectedEdge.target);
const edgeType = normalizeWorkflowEdgeType(selectedEdge.data?.edgeType);
const isDataLikeEdge = edgeType !== 'control';
const isControlLikeEdge = edgeType !== 'data';
const sourcePort =
selectedEdge.sourceHandle && !isControlHandle(selectedEdge.sourceHandle)
? selectedEdge.sourceHandle
: undefined;
const targetPort =
selectedEdge.targetHandle && !isControlHandle(selectedEdge.targetHandle)
? selectedEdge.targetHandle
: undefined;
return (
<TooltipProvider>
@@ -422,6 +465,12 @@ export default function PropertyPanel({
<h3 className="font-semibold text-sm flex items-center gap-2">
<ArrowRight className="size-4" />
{t('workflows.edgeProperties')}
<Badge
variant="outline"
className={`ml-auto text-xs ${getEdgeTypeBadgeClass(edgeType)}`}
>
{getEdgeTypeLabel(edgeType, t)}
</Badge>
</h3>
</div>
@@ -444,37 +493,56 @@ export default function PropertyPanel({
{targetNode?.data.label || selectedEdge.target}
</Badge>
</div>
{isDataLikeEdge && (
<div className="mt-3 flex items-center gap-2 text-xs min-w-0 w-full overflow-hidden">
<Badge
variant="outline"
className="font-mono text-[11px] truncate max-w-[42%] flex-shrink min-w-0 border-sky-300 text-sky-700 dark:border-sky-800 dark:text-sky-300"
>
{sourcePort || 'output'}
</Badge>
<ArrowRight className="size-3.5 text-sky-500 flex-shrink-0" />
<Badge
variant="outline"
className="font-mono text-[11px] truncate max-w-[42%] flex-shrink min-w-0 border-sky-300 text-sky-700 dark:border-sky-800 dark:text-sky-300"
>
{targetPort || 'input'}
</Badge>
</div>
)}
</div>
{/* Condition */}
<CollapsibleSection
title={t('workflows.condition')}
icon={Code}
badge={
selectedEdge.data?.condition ? (
<Badge
variant="secondary"
className="text-xs flex-shrink-0"
>
{t('workflows.hasCondition')}
</Badge>
) : null
}
>
<div className="space-y-2 w-full overflow-hidden">
<Textarea
value={selectedEdge.data?.condition || ''}
onChange={handleConditionChange}
placeholder={t('workflows.conditionPlaceholder')}
rows={4}
className="font-mono text-sm w-full"
/>
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<Info className="size-3.5 mt-0.5 flex-shrink-0" />
<p>{t('workflows.conditionHelp')}</p>
{isControlLikeEdge && (
<CollapsibleSection
title={t('workflows.condition')}
icon={Code}
badge={
selectedEdge.data?.condition ? (
<Badge
variant="secondary"
className="text-xs flex-shrink-0"
>
{t('workflows.hasCondition')}
</Badge>
) : null
}
>
<div className="space-y-2 w-full overflow-hidden">
<Textarea
value={selectedEdge.data?.condition || ''}
onChange={handleConditionChange}
placeholder={t('workflows.conditionPlaceholder')}
rows={4}
className="font-mono text-sm w-full"
/>
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<Info className="size-3.5 mt-0.5 flex-shrink-0" />
<p>{t('workflows.conditionHelp')}</p>
</div>
</div>
</div>
</CollapsibleSection>
</CollapsibleSection>
)}
<Separator />
@@ -705,4 +705,3 @@ export default function WorkflowEditorComponent() {
</ReactFlowProvider>
);
}
@@ -5,7 +5,6 @@ import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import {
PauseCircle,
Settings,
Loader2,
CheckCircle2,
XCircle,
@@ -19,21 +18,17 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
NODE_ICONS,
NODE_TYPE_I18N_KEYS,
getNodeTypeLabel,
CONTROL_SOURCE_HANDLE,
CONTROL_TARGET_HANDLE,
getIconComponent,
} from './workflow-constants';
import { resolveI18nLabel, maybeTranslateKey } from './workflow-i18n';
import { resolveI18nLabel } from './workflow-i18n';
import type { I18nObject } from '@/app/infra/entities/common';
// Use shared icon mapping
const nodeIcons = NODE_ICONS;
// Use shared i18n key mapping
const nodeTypeI18nKeys: Record<string, string> = Object.fromEntries(
Object.entries(NODE_TYPE_I18N_KEYS).map(([k, v]) => [k, v.labelKey]),
);
const DATA_PORT_INSET = 36;
const DATA_PORT_GAP = 48;
const MIN_NODE_WIDTH = 220;
const MAX_NODE_WIDTH = 320;
// Category colors with improved design
const categoryColors: Record<
@@ -192,14 +187,6 @@ function getPortLabel(
return translated === key ? fallbackName : translated;
}
// Helper function to extract i18n value from I18nObject (delegates to shared utility)
function extractI18nValue(
i18nObj: Record<string, string> | undefined,
_t: (key: string) => string,
): string {
return resolveI18nLabel(i18nObj);
}
// Helper function to get node type description: show the raw type name after the dot
function getNodeTypeDescription(
nodeType: string,
@@ -242,6 +229,30 @@ function WorkflowNodeComponent({ data, selected }: NodeProps) {
// Determine if this is a trigger node (no inputs)
const isTrigger = category === 'trigger';
const isTerminal =
nodeData.type === 'action.end' || nodeData.type.endsWith('.end');
const maxDataPortCount = Math.max(
isTrigger ? 0 : inputs.length,
outputs.length,
);
const naturalNodeWidth =
DATA_PORT_INSET * 2 +
Math.max(0, maxDataPortCount - 1) * DATA_PORT_GAP;
const nodeWidth = Math.min(
MAX_NODE_WIDTH,
Math.max(MIN_NODE_WIDTH, naturalNodeWidth),
);
const dataPortGap =
maxDataPortCount > 1
? Math.min(
DATA_PORT_GAP,
(nodeWidth - DATA_PORT_INSET * 2) / (maxDataPortCount - 1),
)
: 0;
const inputPortLeft = (index: number) => DATA_PORT_INSET + index * dataPortGap;
const outputPortRight = (index: number) =>
DATA_PORT_INSET + index * dataPortGap;
// Format execution duration
const formattedDuration = useMemo(() => {
@@ -256,7 +267,7 @@ function WorkflowNodeComponent({ data, selected }: NodeProps) {
<TooltipProvider>
<div
className={cn(
'min-w-[200px] max-w-[280px] rounded-xl border-2 shadow-lg transition-all duration-200',
'relative min-w-[220px] overflow-visible rounded-xl border-2 shadow-lg transition-all duration-200',
colors.bg,
colors.border,
selected &&
@@ -265,45 +276,104 @@ function WorkflowNodeComponent({ data, selected }: NodeProps) {
status === 'failed' &&
'shadow-red-200 dark:shadow-red-900/50 border-red-500',
)}
style={{ width: nodeWidth }}
>
{/* Control flow handles */}
{!isTrigger && (
<Tooltip>
<TooltipTrigger asChild>
<Handle
type="target"
position={Position.Left}
id={CONTROL_TARGET_HANDLE}
style={{
top: '50%',
background: '#f97316',
width: 16,
height: 16,
borderRadius: 4,
border: '2px solid white',
boxShadow: '0 2px 6px rgba(249,115,22,0.35)',
}}
className="!transition-transform hover:!scale-125"
/>
</TooltipTrigger>
<TooltipContent side="left">
<p className="font-medium">
{t('workflows.controlInput', { defaultValue: 'Control input' })}
</p>
</TooltipContent>
</Tooltip>
)}
{!isTerminal && (
<Tooltip>
<TooltipTrigger asChild>
<Handle
type="source"
position={Position.Right}
id={CONTROL_SOURCE_HANDLE}
style={{
top: '50%',
background: '#f97316',
width: 20,
height: 20,
borderRadius: 4,
border: '2px solid white',
boxShadow: '0 2px 6px rgba(249,115,22,0.35)',
}}
/>
</TooltipTrigger>
<TooltipContent side="right">
<p className="font-medium">
{t('workflows.controlOutput', {
defaultValue: 'Control output',
})}
</p>
</TooltipContent>
</Tooltip>
)}
{/* Input handles - only show if not trigger */}
{!isTrigger &&
inputs.map((input, index) => (
<Tooltip key={`input-${input.name}`}>
<TooltipTrigger asChild>
inputs.map((input, index) => {
const label = getPortLabel(
input.label,
input.name,
'workflows.nodeInputs',
t,
);
return (
<div key={`input-${input.name}`}>
<span
className="pointer-events-none absolute -top-7 z-10 max-w-20 truncate whitespace-nowrap text-center text-[10px] font-medium text-muted-foreground"
style={{
left: inputPortLeft(index),
transform: 'translateX(-50%)',
}}
>
{label}
</span>
<Handle
type="target"
position={Position.Left}
position={Position.Top}
id={input.name}
style={{
top:
inputs.length === 1
? '50%'
: `${((index + 1) / (inputs.length + 1)) * 100}%`,
left: inputPortLeft(index),
top: 0,
transform: 'translate(-50%, -50%)',
background: colors.handleBg,
width: 12,
height: 12,
width: 10,
height: 10,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}
className="!transition-transform hover:!scale-125"
/>
</TooltipTrigger>
<TooltipContent side="left">
<p className="font-medium">
{getPortLabel(
input.label,
input.name,
'workflows.nodeInputs',
t,
)}
</p>
{input.type && (
<p className="text-xs text-muted-foreground">{input.type}</p>
)}
</TooltipContent>
</Tooltip>
))}
</div>
);
})}
{/* Node content */}
<div className={cn('p-3 bg-gradient-to-b', colors.gradient)}>
@@ -389,42 +459,45 @@ function WorkflowNodeComponent({ data, selected }: NodeProps) {
</div>
{/* Output handles */}
{outputs.map((output, index) => (
<Tooltip key={`output-${output.name}`}>
<TooltipTrigger asChild>
{outputs.map((output, index) => {
const label = getPortLabel(
output.label,
output.name,
'workflows.nodeOutputs',
t,
);
return (
<div key={`output-${output.name}`}>
<span
className="pointer-events-none absolute -bottom-7 z-10 max-w-20 truncate whitespace-nowrap text-center text-[10px] font-medium text-muted-foreground"
style={{
right: outputPortRight(index),
transform: 'translateX(50%)',
}}
>
{label}
</span>
<Handle
type="source"
position={Position.Right}
position={Position.Bottom}
id={output.name}
style={{
top:
outputs.length === 1
? '50%'
: `${((index + 1) / (outputs.length + 1)) * 100}%`,
left: 'auto',
right: outputPortRight(index),
bottom: 0,
transform: 'translate(50%, 50%)',
background: colors.handleBg,
width: 12,
height: 12,
width: 10,
height: 10,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}
className="!transition-transform hover:!scale-125"
/>
</TooltipTrigger>
<TooltipContent side="right">
<p className="font-medium">
{getPortLabel(
output.label,
output.name,
'workflows.nodeOutputs',
t,
)}
</p>
{output.type && (
<p className="text-xs text-muted-foreground">{output.type}</p>
)}
</TooltipContent>
</Tooltip>
))}
</div>
);
})}
</div>
</TooltipProvider>
);
@@ -361,6 +361,44 @@ export const CATEGORY_ICONS: Record<string, React.ElementType> = {
integration: Plug,
};
// ─── Edge semantics ────────────────────────────────────────────────
export type WorkflowEdgeType = 'control' | 'data' | 'legacy';
export const CONTROL_SOURCE_HANDLE = '__control_out';
export const CONTROL_TARGET_HANDLE = '__control_in';
export function normalizeWorkflowEdgeType(
edgeType: string | undefined,
): WorkflowEdgeType {
if (edgeType === 'control' || edgeType === 'data' || edgeType === 'legacy') {
return edgeType;
}
return 'legacy';
}
export function isControlHandle(handleId: string | null | undefined): boolean {
return handleId === CONTROL_SOURCE_HANDLE || handleId === CONTROL_TARGET_HANDLE;
}
export function inferWorkflowEdgeType(
sourceHandle: string | null | undefined,
targetHandle: string | null | undefined,
): WorkflowEdgeType | null {
if (
sourceHandle === CONTROL_SOURCE_HANDLE &&
targetHandle === CONTROL_TARGET_HANDLE
) {
return 'control';
}
if (!isControlHandle(sourceHandle) && !isControlHandle(targetHandle)) {
return 'data';
}
return null;
}
// ─── Helpers ────────────────────────────────────────────────────────
/**
@@ -3,6 +3,7 @@ import {
Node,
Edge,
Connection,
MarkerType,
addEdge,
applyNodeChanges,
applyEdgeChanges,
@@ -16,7 +17,14 @@ import {
WorkflowNodeTypeMetadata,
WorkflowNodeCategory,
} from '@/app/infra/entities/api';
import { getNodeTypeLabel as sharedGetNodeTypeLabel } from '../components/workflow-editor/workflow-constants';
import {
CONTROL_SOURCE_HANDLE,
CONTROL_TARGET_HANDLE,
WorkflowEdgeType,
getNodeTypeLabel as sharedGetNodeTypeLabel,
inferWorkflowEdgeType,
normalizeWorkflowEdgeType,
} from '../components/workflow-editor/workflow-constants';
import { normalizeWorkflowNodeTypeMeta } from '../components/workflow-editor/workflow-node-metadata';
import i18n from '@/i18n';
@@ -34,6 +42,7 @@ export interface WorkflowEdge extends Edge {
data?: {
label?: string;
condition?: string;
edgeType?: WorkflowEdgeType;
};
}
@@ -120,12 +129,13 @@ interface WorkflowState {
onEdgesChange: (changes: EdgeChange<WorkflowEdge>[]) => void;
onConnect: (connection: Connection) => void;
addNode: (type: string, position: { x: number; y: number }) => void;
addNode: (type: string, position: { x: number; y: number }) => string;
updateNodeConfig: (nodeId: string, config: Record<string, unknown>) => void;
updateNodeLabel: (nodeId: string, label: string) => void;
deleteNode: (nodeId: string) => void;
// Edge operations
addControlEdge: (sourceNodeId: string, targetNodeId: string) => void;
deleteEdge: (edgeId: string) => void;
updateEdgeCondition: (edgeId: string, condition: string) => void;
@@ -193,6 +203,69 @@ const generateUuidLikeId = () => {
const generateNodeId = () => `node_${generateUuidLikeId()}`;
const generateEdgeId = () => `edge_${generateUuidLikeId()}`;
const getWorkflowEdgeType = (edge: WorkflowEdge): WorkflowEdgeType =>
normalizeWorkflowEdgeType(edge.data?.edgeType);
const withWorkflowEdgeStyle = (edge: WorkflowEdge): WorkflowEdge => {
const edgeType = getWorkflowEdgeType(edge);
if (edgeType === 'control') {
return {
...edge,
type: 'smoothstep',
animated: false,
style: {
...(edge.style || {}),
stroke: '#f97316',
strokeWidth: 3,
},
markerEnd: {
type: MarkerType.ArrowClosed,
width: 22,
height: 22,
color: '#f97316',
},
};
}
if (edgeType === 'data') {
return {
...edge,
type: 'default',
animated: false,
style: {
...(edge.style || {}),
stroke: '#38bdf8',
strokeWidth: 1.5,
strokeDasharray: '5 4',
},
markerEnd: {
type: MarkerType.Arrow,
width: 16,
height: 16,
color: '#38bdf8',
},
};
}
return {
...edge,
type: 'default',
animated: true,
style: {
...(edge.style || {}),
stroke: '#94a3b8',
strokeWidth: 2,
},
markerEnd: {
type: MarkerType.ArrowClosed,
width: 18,
height: 18,
color: '#94a3b8',
},
};
};
const defaultDebugContext: DebugContext = {
messageContent: '',
senderId: `user_${Date.now().toString(36)}`,
@@ -254,14 +327,29 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
},
onConnect: (connection) => {
const edgeType = inferWorkflowEdgeType(
connection.sourceHandle,
connection.targetHandle,
);
if (!edgeType) {
return;
}
const newEdge: WorkflowEdge = {
...connection,
id: generateEdgeId(),
type: 'default',
data: {
edgeType,
},
} as WorkflowEdge;
set((state) => ({
edges: addEdge(newEdge, state.edges) as WorkflowEdge[],
edges: addEdge(
withWorkflowEdgeStyle(newEdge),
state.edges,
) as WorkflowEdge[],
isDirty: true,
}));
get().pushHistory();
@@ -313,6 +401,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
isDirty: true,
}));
get().pushHistory();
return newNode.id;
},
// Update node config
@@ -349,6 +438,41 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
get().pushHistory();
},
// Add a control-flow edge between two nodes
addControlEdge: (sourceNodeId, targetNodeId) => {
if (!sourceNodeId || !targetNodeId || sourceNodeId === targetNodeId) {
return;
}
const existingControlEdge = get().edges.some(
(edge) =>
edge.source === sourceNodeId &&
edge.target === targetNodeId &&
normalizeWorkflowEdgeType(edge.data?.edgeType) === 'control',
);
if (existingControlEdge) {
return;
}
const edge = withWorkflowEdgeStyle({
id: generateEdgeId(),
source: sourceNodeId,
target: targetNodeId,
sourceHandle: CONTROL_SOURCE_HANDLE,
targetHandle: CONTROL_TARGET_HANDLE,
type: 'smoothstep',
data: {
edgeType: 'control',
},
} as WorkflowEdge);
set((state) => ({
edges: addEdge(edge, state.edges) as WorkflowEdge[],
isDirty: true,
}));
get().pushHistory();
},
// Delete edge
deleteEdge: (edgeId) => {
set((state) => ({
@@ -431,6 +555,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
}));
const workflowEdges: WorkflowEdgeDefinition[] = edges.map((edge) => ({
edge_type: getWorkflowEdgeType(edge),
id: edge.id,
source: edge.source,
target: edge.target,
@@ -462,14 +587,19 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.source_port,
targetHandle: edge.target_port,
sourceHandle:
edge.source_port ||
(edge.edge_type === 'control' ? CONTROL_SOURCE_HANDLE : undefined),
targetHandle:
edge.target_port ||
(edge.edge_type === 'control' ? CONTROL_TARGET_HANDLE : undefined),
type: 'default',
data: {
label: edge.label,
condition: edge.condition,
edgeType: normalizeWorkflowEdgeType(edge.edge_type),
},
}));
})).map(withWorkflowEdgeStyle);
set({ nodes, edges, isDirty: false });
get().pushHistory();
+1
View File
@@ -724,6 +724,7 @@ export interface WorkflowEdgeDefinition {
target: string;
source_port?: string;
target_port?: string;
edge_type?: 'control' | 'data' | 'legacy';
label?: string;
condition?: string;
}
@@ -32,6 +32,7 @@ export interface EdgeDefinition {
sourceHandle?: string;
target: string;
targetHandle?: string;
edge_type?: 'control' | 'data' | 'legacy';
condition?: string;
}