feat: condition node functionality with CEL evaluation in Workflows (#2280)

* feat: add condition node functionality with CEL evaluation

- Introduced ConditionNode to support conditional branching in workflows.
- Implemented CEL evaluation for state updates and condition expressions.
- Updated WorkflowEngine to handle condition nodes and their execution logic.
- Enhanced validation for workflows to ensure condition nodes have at least two outgoing edges and valid expressions.
- Modified frontend components to support new condition node type and its configuration.
- Added necessary types and interfaces for condition cases and state operations.
- Updated requirements to include cel-python for expression evaluation.

* mini-fixes

* feat(workflow): improve UX

---------

Co-authored-by: Alex <a@tushynski.me>
This commit is contained in:
Siddhant Rai
2026-02-17 22:59:48 +05:30
committed by GitHub
parent 8aa44c415b
commit 2a3f0e455a
13 changed files with 1592 additions and 244 deletions

View File

@@ -1,4 +1,20 @@
export type NodeType = 'start' | 'end' | 'agent' | 'note' | 'state';
export type NodeType = 'start' | 'end' | 'agent' | 'note' | 'state' | 'condition';
export interface ConditionCase {
name?: string;
expression: string;
sourceHandle: string;
}
export interface ConditionNodeConfig {
mode: 'simple' | 'advanced';
cases: ConditionCase[];
}
export interface StateOperationConfig {
expression: string;
target_variable: string;
}
export interface WorkflowEdge {
id: string;

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import {
Circle,
Database,
Flag,
GitBranch,
Loader2,
MessageSquare,
Play,
@@ -53,6 +54,7 @@ const NODE_ICONS: Record<string, React.ReactNode> = {
end: <Flag className="h-3 w-3" />,
note: <StickyNote className="h-3 w-3" />,
state: <Database className="h-3 w-3" />,
condition: <GitBranch className="h-3 w-3" />,
};
const NODE_COLORS: Record<string, string> = {
@@ -61,6 +63,7 @@ const NODE_COLORS: Record<string, string> = {
end: 'text-gray-600 dark:text-gray-400',
note: 'text-yellow-600 dark:text-yellow-400',
state: 'text-blue-600 dark:text-blue-400',
condition: 'text-orange-600 dark:text-orange-400',
};
function ExecutionDetails({
@@ -267,20 +270,17 @@ function WorkflowMiniMap({
case 'failed':
return 'bg-red-100 dark:bg-red-900/30 border-red-300 dark:border-red-700';
default:
if (nodeType === 'start') {
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
}
if (nodeType === 'agent') {
return 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800';
}
if (nodeType === 'end') {
return 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700';
}
return 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700';
}
};
const executedOrder = new Map(executionSteps.map((s, i) => [s.nodeId, i]));
const sortedNodes = [...nodes].sort((a, b) => {
const aIdx = executedOrder.get(a.id);
const bIdx = executedOrder.get(b.id);
if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
if (aIdx !== undefined) return -1;
if (bIdx !== undefined) return 1;
if (a.type === 'start') return -1;
if (b.type === 'start') return 1;
if (a.type === 'end') return 1;

View File

@@ -5,7 +5,7 @@ interface BaseNodeProps {
title: string;
children?: ReactNode;
selected?: boolean;
type?: 'start' | 'end' | 'default' | 'state' | 'agent';
type?: 'start' | 'end' | 'default' | 'state' | 'agent' | 'condition';
icon?: ReactNode;
handles?: {
source?: boolean;
@@ -40,6 +40,9 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
} else if (type === 'state') {
iconBg = 'bg-gray-100 dark:bg-gray-800';
iconColor = 'text-gray-600 dark:text-gray-400';
} else if (type === 'condition') {
iconBg = 'bg-orange-100 dark:bg-orange-900/30';
iconColor = 'text-orange-600 dark:text-orange-400';
}
return (

View File

@@ -0,0 +1,118 @@
import { GitBranch } from 'lucide-react';
import { memo } from 'react';
import { Handle, NodeProps, Position } from 'reactflow';
import { ConditionCase } from '../../types/workflow';
type ConditionNodeData = {
label?: string;
title?: string;
config?: {
mode?: 'simple' | 'advanced';
cases?: ConditionCase[];
};
};
const ROW_HEIGHT = 18;
const HEADER_HEIGHT = 52;
const PADDING_BOTTOM = 8;
function getNodeHeight(caseCount: number): number {
return (
HEADER_HEIGHT + Math.max(caseCount + 1, 2) * ROW_HEIGHT + PADDING_BOTTOM
);
}
function getHandleTop(index: number, total: number): string {
const offset = HEADER_HEIGHT;
return `${offset + ROW_HEIGHT * index + ROW_HEIGHT / 2}px`;
}
const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
const title = data.title || data.label || 'If / Else';
const cases = data.config?.cases || [];
const totalOutputs = cases.length + 1;
const height = getNodeHeight(cases.length);
return (
<div
className={`relative rounded-2xl border bg-white shadow-md transition-all dark:bg-[#2C2C2C] ${
selected
? 'border-violets-are-blue dark:ring-violets-are-blue scale-105 ring-2 ring-purple-300'
: 'border-gray-200 hover:shadow-lg dark:border-[#3A3A3A]'
}`}
style={{ minWidth: 180, maxWidth: 220, height }}
>
<Handle
type="target"
position={Position.Left}
isConnectable
className="hover:bg-violets-are-blue! top-1/2! -left-1! h-3! w-3! rounded-full! border-2! border-white! bg-gray-400! transition-colors dark:border-[#2C2C2C]!"
/>
<div className="flex items-center gap-3 px-3 py-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400">
<GitBranch size={14} />
</div>
<div className="min-w-0 flex-1 pr-2">
<div
className="truncate text-sm font-semibold text-gray-900 dark:text-white"
title={title}
>
{title}
</div>
<div className="text-[10px] text-gray-500 uppercase">
{data.config?.mode || 'simple'}
</div>
</div>
</div>
<div className="flex flex-col px-3">
{cases.map((c, i) => (
<div
key={c.sourceHandle}
className="flex items-center gap-1"
style={{ height: ROW_HEIGHT }}
>
<span className="shrink-0 text-xs font-medium text-orange-600 dark:text-orange-400">
{i === 0 ? 'If' : 'Else if'}
</span>
{c.name && (
<span
className="truncate text-xs text-gray-600 dark:text-gray-400"
title={c.name}
>
{c.name}
</span>
)}
</div>
))}
<div className="flex items-center gap-1" style={{ height: ROW_HEIGHT }}>
<span className="text-xs font-medium text-gray-500">Else</span>
</div>
</div>
{cases.map((c, i) => (
<Handle
key={c.sourceHandle}
type="source"
position={Position.Right}
id={c.sourceHandle}
isConnectable
style={{ top: getHandleTop(i, totalOutputs) }}
className="hover:bg-violets-are-blue! -right-1! h-3! w-3! rounded-full! border-2! border-white! bg-orange-400! transition-colors dark:border-[#2C2C2C]!"
/>
))}
<Handle
type="source"
position={Position.Right}
id="else"
isConnectable
style={{ top: getHandleTop(cases.length, totalOutputs) }}
className="hover:bg-violets-are-blue! -right-1! h-3! w-3! rounded-full! border-2! border-white! bg-gray-400! transition-colors dark:border-[#2C2C2C]!"
/>
</div>
);
};
export default memo(ConditionNode);

View File

@@ -2,6 +2,7 @@ import { Database } from 'lucide-react';
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { StateOperationConfig } from '../../types/workflow';
import { BaseNode } from './BaseNode';
type SetStateNodeData = {
@@ -9,10 +10,16 @@ type SetStateNodeData = {
title?: string;
variable?: string;
value?: string;
config?: {
operations?: StateOperationConfig[];
};
};
const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
const title = data.title || data.label || 'Set State';
const operations = data.config?.operations || [];
const hasLegacy = !operations.length && data.variable;
return (
<BaseNode
title={title}
@@ -22,22 +29,31 @@ const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
handles={{ source: true, target: true }}
>
<div className="flex flex-col gap-1">
{data.variable && (
{operations.length > 0 ? (
<div
className="truncate text-[10px] text-gray-500 uppercase"
title={`Variable: ${data.variable}`}
className="truncate text-[10px] text-gray-500"
title={`${operations.length} operation(s)`}
>
{data.variable}
{operations.length} variable{operations.length !== 1 ? 's' : ''}
</div>
)}
{data.value && (
<div
className="truncate text-xs text-blue-600 dark:text-blue-400"
title={`Value: ${data.value}`}
>
{data.value}
</div>
)}
) : hasLegacy ? (
<>
<div
className="truncate text-[10px] text-gray-500 uppercase"
title={`Variable: ${data.variable}`}
>
{data.variable}
</div>
{data.value && (
<div
className="truncate text-xs text-blue-600 dark:text-blue-400"
title={`Value: ${data.value}`}
>
{data.value}
</div>
)}
</>
) : null}
</div>
</BaseNode>
);

View File

@@ -1,7 +1,9 @@
import React, { memo } from 'react';
import { Bot, Flag, Play, StickyNote } from 'lucide-react';
import { memo } from 'react';
import { BaseNode } from './BaseNode';
import ConditionNode from './ConditionNode';
import SetStateNode from './SetStateNode';
import { Play, Bot, StickyNote, Flag } from 'lucide-react';
export const StartNode = memo(function StartNode({
selected,
@@ -142,3 +144,4 @@ export const NoteNode = memo(function NoteNode({
});
export { SetStateNode };
export { ConditionNode };

View File

@@ -43,7 +43,7 @@ export default function WrapperModal({
const modalContent = (
<div
className="fixed top-0 left-0 z-30 flex h-screen w-screen items-center justify-center"
className="fixed top-0 left-0 z-[100] flex h-screen w-screen items-center justify-center"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
onMouseDown={(e: React.MouseEvent) => e.stopPropagation()}
>