mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-14 16:36:07 +00:00
separate workflow control and data edges
This commit is contained in:
@@ -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', [])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user