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

@@ -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 };