mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-24 13:26:08 +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'),
|
source_port=e.get('source_port', '') or e.get('sourceHandle', 'output'),
|
||||||
target_node=e.get('target_node', '') or e.get('target', ''),
|
target_node=e.get('target_node', '') or e.get('target', ''),
|
||||||
target_port=e.get('target_port', '') or e.get('targetHandle', 'input'),
|
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'),
|
condition=e.get('condition') or (e.get('data', {}) or {}).get('condition'),
|
||||||
)
|
)
|
||||||
for e in definition.get('edges', [])
|
for e in definition.get('edges', [])
|
||||||
|
|||||||
@@ -441,12 +441,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
|
|||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if this node's inputs are ready
|
incoming_nodes = self._incoming_dependency_nodes(node.id, edge_map)
|
||||||
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)
|
|
||||||
|
|
||||||
# If no incoming nodes, it's a start node
|
# If no incoming nodes, it's a start node
|
||||||
if not incoming_nodes:
|
if not incoming_nodes:
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class EdgeDefinition(pydantic.BaseModel):
|
|||||||
source_port: str = 'output'
|
source_port: str = 'output'
|
||||||
target_node: str
|
target_node: str
|
||||||
target_port: str = 'input'
|
target_port: str = 'input'
|
||||||
|
edge_type: str = 'legacy' # control, data, or legacy (old mixed semantics)
|
||||||
condition: Optional[str] = None # Optional condition expression
|
condition: Optional[str] = None # Optional condition expression
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -390,10 +390,12 @@ class WorkflowExecutor:
|
|||||||
else:
|
else:
|
||||||
inputs[key] = value
|
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
|
# Build a reverse map: for each incoming edge to this node, find the
|
||||||
# source node and the specific source/target port.
|
# source node and the specific source/target port.
|
||||||
for edge in self._edges:
|
for edge in self._edges:
|
||||||
|
if not self._is_data_edge(edge):
|
||||||
|
continue
|
||||||
if edge.target_node != node.id:
|
if edge.target_node != node.id:
|
||||||
continue
|
continue
|
||||||
source_state = context.node_states.get(edge.source_node)
|
source_state = context.node_states.get(edge.source_node)
|
||||||
@@ -519,13 +521,8 @@ class WorkflowExecutor:
|
|||||||
async def _inputs_ready(
|
async def _inputs_ready(
|
||||||
self, node: NodeDefinition, edge_map: dict[str, list[EdgeDefinition]], context: ExecutionContext
|
self, node: NodeDefinition, edge_map: dict[str, list[EdgeDefinition]], context: ExecutionContext
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if all inputs for a node are ready"""
|
"""Check if all control predecessors and data providers are ready."""
|
||||||
# Find all edges that connect to this node
|
incoming_nodes = self._incoming_dependency_nodes(node.id, edge_map)
|
||||||
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 incoming nodes have completed
|
# Check if all incoming nodes have completed
|
||||||
for source_id in incoming_nodes:
|
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]:
|
def _find_start_nodes(self, nodes: list[NodeDefinition], edges: list[EdgeDefinition]) -> list[NodeDefinition]:
|
||||||
"""Find nodes that have no incoming edges (start nodes)"""
|
"""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]
|
start_nodes = [node for node in nodes if node.id not in target_nodes]
|
||||||
|
|
||||||
# Also check for trigger nodes
|
# Also check for trigger nodes
|
||||||
@@ -549,14 +546,43 @@ class WorkflowExecutor:
|
|||||||
return start_nodes
|
return start_nodes
|
||||||
|
|
||||||
def _build_edge_map(self, edges: list[EdgeDefinition]) -> dict[str, list[EdgeDefinition]]:
|
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]] = {}
|
edge_map: dict[str, list[EdgeDefinition]] = {}
|
||||||
for edge in edges:
|
for edge in edges:
|
||||||
|
if not self._is_control_edge(edge):
|
||||||
|
continue
|
||||||
if edge.source_node not in edge_map:
|
if edge.source_node not in edge_map:
|
||||||
edge_map[edge.source_node] = []
|
edge_map[edge.source_node] = []
|
||||||
edge_map[edge.source_node].append(edge)
|
edge_map[edge.source_node].append(edge)
|
||||||
return edge_map
|
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):
|
def _record_execution_step(self, node: NodeDefinition, node_state: NodeState, context: ExecutionContext):
|
||||||
"""Record an execution step in the history"""
|
"""Record an execution step in the history"""
|
||||||
duration_ms = 0
|
duration_ms = 0
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { resolveI18nLabel, maybeTranslateKey } from './workflow-i18n';
|
import { resolveI18nLabel, maybeTranslateKey } from './workflow-i18n';
|
||||||
|
import {
|
||||||
|
WorkflowEdgeType,
|
||||||
|
isControlHandle,
|
||||||
|
normalizeWorkflowEdgeType,
|
||||||
|
} from './workflow-constants';
|
||||||
|
|
||||||
// Delegate to shared utility
|
// Delegate to shared utility
|
||||||
const translateIfKey = (value: string | undefined): string | undefined => {
|
const translateIfKey = (value: string | undefined): string | undefined => {
|
||||||
@@ -76,6 +81,29 @@ const getTypeLabel = (type: string | undefined): string => {
|
|||||||
return translated === i18nKey ? type : translated;
|
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 {
|
interface PropertyPanelProps {
|
||||||
selectedNodeId: string | null;
|
selectedNodeId: string | null;
|
||||||
selectedEdgeId: string | null;
|
selectedEdgeId: string | null;
|
||||||
@@ -275,7 +303,11 @@ export default function PropertyPanel({
|
|||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
// Find all upstream nodes
|
// 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);
|
const upstreamNodeIds = incomingEdges.map((e) => e.source);
|
||||||
|
|
||||||
for (const nodeId of upstreamNodeIds) {
|
for (const nodeId of upstreamNodeIds) {
|
||||||
@@ -414,6 +446,17 @@ export default function PropertyPanel({
|
|||||||
if (selectedEdge) {
|
if (selectedEdge) {
|
||||||
const sourceNode = nodes.find((n) => n.id === selectedEdge.source);
|
const sourceNode = nodes.find((n) => n.id === selectedEdge.source);
|
||||||
const targetNode = nodes.find((n) => n.id === selectedEdge.target);
|
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 (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -422,6 +465,12 @@ export default function PropertyPanel({
|
|||||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||||
<ArrowRight className="size-4" />
|
<ArrowRight className="size-4" />
|
||||||
{t('workflows.edgeProperties')}
|
{t('workflows.edgeProperties')}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`ml-auto text-xs ${getEdgeTypeBadgeClass(edgeType)}`}
|
||||||
|
>
|
||||||
|
{getEdgeTypeLabel(edgeType, t)}
|
||||||
|
</Badge>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -444,37 +493,56 @@ export default function PropertyPanel({
|
|||||||
{targetNode?.data.label || selectedEdge.target}
|
{targetNode?.data.label || selectedEdge.target}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Condition */}
|
{/* Condition */}
|
||||||
<CollapsibleSection
|
{isControlLikeEdge && (
|
||||||
title={t('workflows.condition')}
|
<CollapsibleSection
|
||||||
icon={Code}
|
title={t('workflows.condition')}
|
||||||
badge={
|
icon={Code}
|
||||||
selectedEdge.data?.condition ? (
|
badge={
|
||||||
<Badge
|
selectedEdge.data?.condition ? (
|
||||||
variant="secondary"
|
<Badge
|
||||||
className="text-xs flex-shrink-0"
|
variant="secondary"
|
||||||
>
|
className="text-xs flex-shrink-0"
|
||||||
{t('workflows.hasCondition')}
|
>
|
||||||
</Badge>
|
{t('workflows.hasCondition')}
|
||||||
) : null
|
</Badge>
|
||||||
}
|
) : null
|
||||||
>
|
}
|
||||||
<div className="space-y-2 w-full overflow-hidden">
|
>
|
||||||
<Textarea
|
<div className="space-y-2 w-full overflow-hidden">
|
||||||
value={selectedEdge.data?.condition || ''}
|
<Textarea
|
||||||
onChange={handleConditionChange}
|
value={selectedEdge.data?.condition || ''}
|
||||||
placeholder={t('workflows.conditionPlaceholder')}
|
onChange={handleConditionChange}
|
||||||
rows={4}
|
placeholder={t('workflows.conditionPlaceholder')}
|
||||||
className="font-mono text-sm w-full"
|
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" />
|
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||||
<p>{t('workflows.conditionHelp')}</p>
|
<Info className="size-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<p>{t('workflows.conditionHelp')}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CollapsibleSection>
|
||||||
</CollapsibleSection>
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
|||||||
@@ -705,4 +705,3 @@ export default function WorkflowEditorComponent() {
|
|||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
PauseCircle,
|
PauseCircle,
|
||||||
Settings,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
@@ -19,21 +18,17 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import {
|
import {
|
||||||
NODE_ICONS,
|
CONTROL_SOURCE_HANDLE,
|
||||||
NODE_TYPE_I18N_KEYS,
|
CONTROL_TARGET_HANDLE,
|
||||||
getNodeTypeLabel,
|
|
||||||
getIconComponent,
|
getIconComponent,
|
||||||
} from './workflow-constants';
|
} from './workflow-constants';
|
||||||
import { resolveI18nLabel, maybeTranslateKey } from './workflow-i18n';
|
import { resolveI18nLabel } from './workflow-i18n';
|
||||||
import type { I18nObject } from '@/app/infra/entities/common';
|
import type { I18nObject } from '@/app/infra/entities/common';
|
||||||
|
|
||||||
// Use shared icon mapping
|
const DATA_PORT_INSET = 36;
|
||||||
const nodeIcons = NODE_ICONS;
|
const DATA_PORT_GAP = 48;
|
||||||
|
const MIN_NODE_WIDTH = 220;
|
||||||
// Use shared i18n key mapping
|
const MAX_NODE_WIDTH = 320;
|
||||||
const nodeTypeI18nKeys: Record<string, string> = Object.fromEntries(
|
|
||||||
Object.entries(NODE_TYPE_I18N_KEYS).map(([k, v]) => [k, v.labelKey]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Category colors with improved design
|
// Category colors with improved design
|
||||||
const categoryColors: Record<
|
const categoryColors: Record<
|
||||||
@@ -192,14 +187,6 @@ function getPortLabel(
|
|||||||
return translated === key ? fallbackName : translated;
|
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
|
// Helper function to get node type description: show the raw type name after the dot
|
||||||
function getNodeTypeDescription(
|
function getNodeTypeDescription(
|
||||||
nodeType: string,
|
nodeType: string,
|
||||||
@@ -242,6 +229,30 @@ function WorkflowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
|
|
||||||
// Determine if this is a trigger node (no inputs)
|
// Determine if this is a trigger node (no inputs)
|
||||||
const isTrigger = category === 'trigger';
|
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
|
// Format execution duration
|
||||||
const formattedDuration = useMemo(() => {
|
const formattedDuration = useMemo(() => {
|
||||||
@@ -256,7 +267,7 @@ function WorkflowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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.bg,
|
||||||
colors.border,
|
colors.border,
|
||||||
selected &&
|
selected &&
|
||||||
@@ -265,45 +276,104 @@ function WorkflowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
status === 'failed' &&
|
status === 'failed' &&
|
||||||
'shadow-red-200 dark:shadow-red-900/50 border-red-500',
|
'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 */}
|
{/* Input handles - only show if not trigger */}
|
||||||
{!isTrigger &&
|
{!isTrigger &&
|
||||||
inputs.map((input, index) => (
|
inputs.map((input, index) => {
|
||||||
<Tooltip key={`input-${input.name}`}>
|
const label = getPortLabel(
|
||||||
<TooltipTrigger asChild>
|
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
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Top}
|
||||||
id={input.name}
|
id={input.name}
|
||||||
style={{
|
style={{
|
||||||
top:
|
left: inputPortLeft(index),
|
||||||
inputs.length === 1
|
top: 0,
|
||||||
? '50%'
|
transform: 'translate(-50%, -50%)',
|
||||||
: `${((index + 1) / (inputs.length + 1)) * 100}%`,
|
|
||||||
background: colors.handleBg,
|
background: colors.handleBg,
|
||||||
width: 12,
|
width: 10,
|
||||||
height: 12,
|
height: 10,
|
||||||
border: '2px solid white',
|
border: '2px solid white',
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
}}
|
}}
|
||||||
className="!transition-transform hover:!scale-125"
|
className="!transition-transform hover:!scale-125"
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</div>
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Node content */}
|
{/* Node content */}
|
||||||
<div className={cn('p-3 bg-gradient-to-b', colors.gradient)}>
|
<div className={cn('p-3 bg-gradient-to-b', colors.gradient)}>
|
||||||
@@ -389,42 +459,45 @@ function WorkflowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Output handles */}
|
{/* Output handles */}
|
||||||
{outputs.map((output, index) => (
|
{outputs.map((output, index) => {
|
||||||
<Tooltip key={`output-${output.name}`}>
|
const label = getPortLabel(
|
||||||
<TooltipTrigger asChild>
|
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
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Bottom}
|
||||||
id={output.name}
|
id={output.name}
|
||||||
style={{
|
style={{
|
||||||
top:
|
left: 'auto',
|
||||||
outputs.length === 1
|
right: outputPortRight(index),
|
||||||
? '50%'
|
bottom: 0,
|
||||||
: `${((index + 1) / (outputs.length + 1)) * 100}%`,
|
transform: 'translate(50%, 50%)',
|
||||||
background: colors.handleBg,
|
background: colors.handleBg,
|
||||||
width: 12,
|
width: 10,
|
||||||
height: 12,
|
height: 10,
|
||||||
border: '2px solid white',
|
border: '2px solid white',
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
}}
|
}}
|
||||||
className="!transition-transform hover:!scale-125"
|
className="!transition-transform hover:!scale-125"
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</div>
|
||||||
<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>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -361,6 +361,44 @@ export const CATEGORY_ICONS: Record<string, React.ElementType> = {
|
|||||||
integration: Plug,
|
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 ────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Node,
|
Node,
|
||||||
Edge,
|
Edge,
|
||||||
Connection,
|
Connection,
|
||||||
|
MarkerType,
|
||||||
addEdge,
|
addEdge,
|
||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
@@ -16,7 +17,14 @@ import {
|
|||||||
WorkflowNodeTypeMetadata,
|
WorkflowNodeTypeMetadata,
|
||||||
WorkflowNodeCategory,
|
WorkflowNodeCategory,
|
||||||
} from '@/app/infra/entities/api';
|
} 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 { normalizeWorkflowNodeTypeMeta } from '../components/workflow-editor/workflow-node-metadata';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
@@ -34,6 +42,7 @@ export interface WorkflowEdge extends Edge {
|
|||||||
data?: {
|
data?: {
|
||||||
label?: string;
|
label?: string;
|
||||||
condition?: string;
|
condition?: string;
|
||||||
|
edgeType?: WorkflowEdgeType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,12 +129,13 @@ interface WorkflowState {
|
|||||||
onEdgesChange: (changes: EdgeChange<WorkflowEdge>[]) => void;
|
onEdgesChange: (changes: EdgeChange<WorkflowEdge>[]) => void;
|
||||||
onConnect: (connection: Connection) => 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;
|
updateNodeConfig: (nodeId: string, config: Record<string, unknown>) => void;
|
||||||
updateNodeLabel: (nodeId: string, label: string) => void;
|
updateNodeLabel: (nodeId: string, label: string) => void;
|
||||||
deleteNode: (nodeId: string) => void;
|
deleteNode: (nodeId: string) => void;
|
||||||
|
|
||||||
// Edge operations
|
// Edge operations
|
||||||
|
addControlEdge: (sourceNodeId: string, targetNodeId: string) => void;
|
||||||
deleteEdge: (edgeId: string) => void;
|
deleteEdge: (edgeId: string) => void;
|
||||||
updateEdgeCondition: (edgeId: string, condition: string) => void;
|
updateEdgeCondition: (edgeId: string, condition: string) => void;
|
||||||
|
|
||||||
@@ -193,6 +203,69 @@ const generateUuidLikeId = () => {
|
|||||||
const generateNodeId = () => `node_${generateUuidLikeId()}`;
|
const generateNodeId = () => `node_${generateUuidLikeId()}`;
|
||||||
const generateEdgeId = () => `edge_${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 = {
|
const defaultDebugContext: DebugContext = {
|
||||||
messageContent: '',
|
messageContent: '',
|
||||||
senderId: `user_${Date.now().toString(36)}`,
|
senderId: `user_${Date.now().toString(36)}`,
|
||||||
@@ -254,14 +327,29 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onConnect: (connection) => {
|
onConnect: (connection) => {
|
||||||
|
const edgeType = inferWorkflowEdgeType(
|
||||||
|
connection.sourceHandle,
|
||||||
|
connection.targetHandle,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!edgeType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newEdge: WorkflowEdge = {
|
const newEdge: WorkflowEdge = {
|
||||||
...connection,
|
...connection,
|
||||||
id: generateEdgeId(),
|
id: generateEdgeId(),
|
||||||
type: 'default',
|
type: 'default',
|
||||||
|
data: {
|
||||||
|
edgeType,
|
||||||
|
},
|
||||||
} as WorkflowEdge;
|
} as WorkflowEdge;
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
edges: addEdge(newEdge, state.edges) as WorkflowEdge[],
|
edges: addEdge(
|
||||||
|
withWorkflowEdgeStyle(newEdge),
|
||||||
|
state.edges,
|
||||||
|
) as WorkflowEdge[],
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
}));
|
}));
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
@@ -313,6 +401,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
|||||||
isDirty: true,
|
isDirty: true,
|
||||||
}));
|
}));
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
|
return newNode.id;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update node config
|
// Update node config
|
||||||
@@ -349,6 +438,41 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
|||||||
get().pushHistory();
|
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
|
// Delete edge
|
||||||
deleteEdge: (edgeId) => {
|
deleteEdge: (edgeId) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -431,6 +555,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const workflowEdges: WorkflowEdgeDefinition[] = edges.map((edge) => ({
|
const workflowEdges: WorkflowEdgeDefinition[] = edges.map((edge) => ({
|
||||||
|
edge_type: getWorkflowEdgeType(edge),
|
||||||
id: edge.id,
|
id: edge.id,
|
||||||
source: edge.source,
|
source: edge.source,
|
||||||
target: edge.target,
|
target: edge.target,
|
||||||
@@ -462,14 +587,19 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
|||||||
id: edge.id,
|
id: edge.id,
|
||||||
source: edge.source,
|
source: edge.source,
|
||||||
target: edge.target,
|
target: edge.target,
|
||||||
sourceHandle: edge.source_port,
|
sourceHandle:
|
||||||
targetHandle: edge.target_port,
|
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',
|
type: 'default',
|
||||||
data: {
|
data: {
|
||||||
label: edge.label,
|
label: edge.label,
|
||||||
condition: edge.condition,
|
condition: edge.condition,
|
||||||
|
edgeType: normalizeWorkflowEdgeType(edge.edge_type),
|
||||||
},
|
},
|
||||||
}));
|
})).map(withWorkflowEdgeStyle);
|
||||||
|
|
||||||
set({ nodes, edges, isDirty: false });
|
set({ nodes, edges, isDirty: false });
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
|
|||||||
@@ -724,6 +724,7 @@ export interface WorkflowEdgeDefinition {
|
|||||||
target: string;
|
target: string;
|
||||||
source_port?: string;
|
source_port?: string;
|
||||||
target_port?: string;
|
target_port?: string;
|
||||||
|
edge_type?: 'control' | 'data' | 'legacy';
|
||||||
label?: string;
|
label?: string;
|
||||||
condition?: string;
|
condition?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface EdgeDefinition {
|
|||||||
sourceHandle?: string;
|
sourceHandle?: string;
|
||||||
target: string;
|
target: string;
|
||||||
targetHandle?: string;
|
targetHandle?: string;
|
||||||
|
edge_type?: 'control' | 'data' | 'legacy';
|
||||||
condition?: string;
|
condition?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user