mirror of
https://github.com/arc53/DocsGPT.git
synced 2026-02-22 12:21:39 +00:00
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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
118
frontend/src/agents/workflow/nodes/ConditionNode.tsx
Normal file
118
frontend/src/agents/workflow/nodes/ConditionNode.tsx
Normal 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);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user