sunatest / frontend /src /components /agents /workflows /conditional-workflow-builder.tsx
llama1's picture
Upload 781 files
5da4770 verified
'use client';
import React, { useState, useCallback, useMemo } from 'react';
import {
Plus,
Trash2,
AlertTriangle,
ChevronsUpDown,
Check,
MoreHorizontal
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
export interface ConditionalStep {
id: string;
name: string;
description: string;
type: 'instruction' | 'condition' | 'parallel' | 'sequence';
config: Record<string, any>;
conditions?: {
type: 'if' | 'else' | 'elseif';
expression?: string;
};
children?: ConditionalStep[];
order: number;
enabled?: boolean;
hasIssues?: boolean;
}
interface ConditionalWorkflowBuilderProps {
steps: ConditionalStep[];
onStepsChange: (steps: ConditionalStep[]) => void;
agentTools?: {
agentpress_tools: Array<{ name: string; description: string; icon?: string; enabled: boolean }>;
mcp_tools: Array<{ name: string; description: string; icon?: string; server?: string }>;
};
isLoadingTools?: boolean;
}
const normalizeToolName = (toolName: string, toolType: 'agentpress' | 'mcp') => {
if (toolType === 'agentpress') {
const agentPressMapping: Record<string, string> = {
'sb_shell_tool': 'Shell Tool',
'sb_files_tool': 'Files Tool',
'sb_browser_tool': 'Browser Tool',
'sb_deploy_tool': 'Deploy Tool',
'sb_expose_tool': 'Expose Tool',
'web_search_tool': 'Web Search',
'sb_vision_tool': 'Vision Tool',
'data_providers_tool': 'Data Providers',
};
return agentPressMapping[toolName] || toolName;
} else {
return toolName
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
};
export function ConditionalWorkflowBuilder({
steps,
onStepsChange,
agentTools,
isLoadingTools
}: ConditionalWorkflowBuilderProps) {
const [toolSearchOpen, setToolSearchOpen] = useState<{[key: string]: boolean}>({});
const [activeConditionTab, setActiveConditionTab] = useState<{[key: string]: string}>({});
steps.forEach((step, index) => {
console.log(`Step ${index}:`, {
name: step.name,
type: step.type,
hasChildren: !!step.children,
childrenCount: step.children?.length || 0,
children: step.children?.map(child => ({ name: child.name, type: child.type }))
});
});
const generateId = () => Math.random().toString(36).substr(2, 9);
const addStep = useCallback((parentId?: string, afterStepId?: string) => {
const newStep: ConditionalStep = {
id: generateId(),
name: 'Step',
description: '',
type: 'instruction',
config: {},
order: 0,
enabled: true,
};
const updateSteps = (items: ConditionalStep[]): ConditionalStep[] => {
if (!parentId) {
if (afterStepId) {
const index = items.findIndex(s => s.id === afterStepId);
return [...items.slice(0, index + 1), newStep, ...items.slice(index + 1)];
}
return [...items, newStep];
}
return items.map(step => {
if (step.id === parentId) {
return {
...step,
children: [...(step.children || []), newStep]
};
}
if (step.children) {
return {
...step,
children: updateSteps(step.children)
};
}
return step;
});
};
onStepsChange(updateSteps(steps));
}, [steps, onStepsChange]);
const addCondition = useCallback((afterStepId: string) => {
const ifStep: ConditionalStep = {
id: generateId(),
name: 'If',
description: '',
type: 'condition',
config: {},
conditions: { type: 'if', expression: '' },
children: [],
order: 0,
enabled: true,
hasIssues: true
};
const updateSteps = (items: ConditionalStep[]): ConditionalStep[] => {
const index = items.findIndex(s => s.id === afterStepId);
if (index !== -1) {
return [
...items.slice(0, index + 1),
ifStep,
...items.slice(index + 1)
];
}
return items.map(step => {
if (step.children) {
return {
...step,
children: updateSteps(step.children)
};
}
return step;
});
};
onStepsChange(updateSteps(steps));
}, [steps, onStepsChange]);
const addElseCondition = useCallback((siblingId: string) => {
const elseIfStep: ConditionalStep = {
id: generateId(),
name: 'Else If',
description: '',
type: 'condition',
config: {},
conditions: { type: 'elseif', expression: '' },
children: [],
order: 0,
enabled: true,
hasIssues: true
};
const updateSteps = (items: ConditionalStep[]): ConditionalStep[] => {
const index = items.findIndex(s => s.id === siblingId);
if (index !== -1) {
return [
...items.slice(0, index + 1),
elseIfStep,
...items.slice(index + 1)
];
}
return items.map(step => {
if (step.children) {
return {
...step,
children: updateSteps(step.children)
};
}
return step;
});
};
onStepsChange(updateSteps(steps));
}, [steps, onStepsChange]);
const addFinalElse = useCallback((siblingId: string) => {
const elseStep: ConditionalStep = {
id: generateId(),
name: 'Else',
description: '',
type: 'condition',
config: {},
conditions: { type: 'else' },
children: [],
order: 0,
enabled: true,
hasIssues: false
};
const updateSteps = (items: ConditionalStep[]): ConditionalStep[] => {
const index = items.findIndex(s => s.id === siblingId);
if (index !== -1) {
return [
...items.slice(0, index + 1),
elseStep,
...items.slice(index + 1)
];
}
return items.map(step => {
if (step.children) {
return {
...step,
children: updateSteps(step.children)
};
}
return step;
});
};
onStepsChange(updateSteps(steps));
}, [steps, onStepsChange]);
const updateStep = useCallback((stepId: string, updates: Partial<ConditionalStep>) => {
const updateSteps = (items: ConditionalStep[]): ConditionalStep[] => {
return items.map(step => {
if (step.id === stepId) {
const updatedStep = { ...step, ...updates };
if (updatedStep.type === 'instruction' && updatedStep.name && updatedStep.name !== 'New Step') {
updatedStep.hasIssues = false;
} else if (updatedStep.type === 'condition' &&
(updatedStep.conditions?.type === 'if' || updatedStep.conditions?.type === 'elseif') &&
updatedStep.conditions?.expression) {
updatedStep.hasIssues = false;
} else if (updatedStep.type === 'condition' && updatedStep.conditions?.type === 'else') {
updatedStep.hasIssues = false;
}
return updatedStep;
}
if (step.children) {
return {
...step,
children: updateSteps(step.children)
};
}
return step;
});
};
onStepsChange(updateSteps(steps));
}, [steps, onStepsChange]);
const removeStep = useCallback((stepId: string) => {
const removeFromSteps = (items: ConditionalStep[]): ConditionalStep[] => {
return items
.filter(step => step.id !== stepId)
.map(step => {
if (step.children) {
return {
...step,
children: removeFromSteps(step.children)
};
}
return step;
});
};
onStepsChange(removeFromSteps(steps));
}, [steps, onStepsChange]);
const getStepNumber = useCallback((stepId: string, items: ConditionalStep[] = steps, counter = { value: 0 }): number => {
for (const step of items) {
counter.value++;
if (step.id === stepId) {
return counter.value;
}
if (step.children && step.children.length > 0) {
const found = getStepNumber(stepId, step.children, counter);
if (found > 0) return found;
}
}
return 0;
}, [steps]);
const getConditionLetter = (index: number) => {
return String.fromCharCode(65 + index);
};
const renderConditionTabs = (conditionSteps: ConditionalStep[], groupKey: string) => {
const activeTabId = activeConditionTab[groupKey] || conditionSteps[0]?.id;
const activeStep = conditionSteps.find(s => s.id === activeTabId) || conditionSteps[0];
const hasElse = conditionSteps.some(step => step.conditions?.type === 'else');
const handleKeyDown = (e: React.KeyboardEvent, step: ConditionalStep) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
if (conditionSteps.length > 1 && !(conditionSteps.length === 1 && step.conditions?.type === 'if')) {
removeStep(step.id);
const remainingConditions = conditionSteps.filter(s => s.id !== step.id);
if (remainingConditions.length > 0) {
setActiveConditionTab(prev => ({ ...prev, [groupKey]: remainingConditions[0].id }));
}
}
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
{conditionSteps.map((step, index) => {
const letter = getConditionLetter(index);
const isActive = step.id === activeTabId;
const conditionType = step.conditions?.type === 'if' ? 'If' :
step.conditions?.type === 'elseif' ? 'Else If' :
step.conditions?.type === 'else' ? 'Else' : 'If';
return (
<button
key={step.id}
onClick={() => setActiveConditionTab(prev => ({ ...prev, [groupKey]: step.id }))}
onKeyDown={(e) => handleKeyDown(e, step)}
tabIndex={0}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm font-medium transition-all",
isActive
? "bg-primary text-primary-foreground border-primary shadow-sm"
: "bg-background border-border text-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<span className="font-mono text-xs">{letter}</span>
<span></span>
<span>{conditionType}</span>
{step.hasIssues && (
<AlertTriangle className="h-3 w-3 text-destructive" />
)}
</button>
);
})}
{!hasElse && (
<Button
variant="outline"
size="sm"
onClick={() => addElseCondition(conditionSteps[conditionSteps.length - 1].id)}
className="h-9 px-3 border-dashed text-xs"
>
<Plus className="h-3 w-3 mr-1" />
Else If
</Button>
)}
{!hasElse && (
<Button
variant="outline"
size="sm"
onClick={() => addFinalElse(conditionSteps[conditionSteps.length - 1].id)}
className="h-9 px-3 border-dashed text-xs"
>
<Plus className="h-3 w-3 mr-1" />
Else
</Button>
)}
</div>
{activeStep && (
<div className="bg-muted/50 rounded-lg p-4 border">
{(activeStep.conditions?.type === 'if' || activeStep.conditions?.type === 'elseif') ? (
<div className="space-y-3">
<Label className="text-sm font-medium">
{activeStep.conditions?.type === 'if' ? 'Condition' : 'Else If Condition'}
</Label>
<Input
type="text"
value={activeStep.conditions.expression || ''}
onChange={(e) => updateStep(activeStep.id, {
conditions: { ...activeStep.conditions, expression: e.target.value }
})}
placeholder="e.g., user asks about pricing"
className="w-full bg-transparent text-sm px-3 py-2 rounded-md"
/>
</div>
) : (
<div className="text-sm text-muted-foreground font-medium">
Otherwise (fallback condition)
</div>
)}
<div className="mt-4 space-y-3">
{activeStep.children && activeStep.children.length > 0 && (
<>
{activeStep.children.map((child, index) => renderStep(child, index + 1, true, activeStep.id))}
</>
)}
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() => addStep(activeStep.id)}
className="border-dashed text-xs"
>
<Plus className="h-3 w-3" />
Add step
</Button>
</div>
</div>
</div>
)}
</div>
);
};
const renderStep = (step: ConditionalStep, stepNumber: number, isNested: boolean = false, parentId?: string) => {
const isCondition = step.type === 'condition';
const isSequence = step.type === 'sequence';
if (isCondition) {
return null;
}
return (
<div key={step.id} className="group">
<div className="bg-card rounded-lg border shadow-sm p-4 transition-shadow">
<div className="flex items-start gap-4">
<div className="flex items-center gap-2 shrink-0">
{step.hasIssues && (
<AlertTriangle className="h-4 w-4 text-destructive" />
)}
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-sm font-medium text-muted-foreground">
{stepNumber}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-3">
{isSequence ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-primary" />
</div>
<span className="text-base font-medium">{step.description}</span>
</div>
) : (
<input
type="text"
value={step.name + ' ' + stepNumber}
onChange={(e) => updateStep(step.id, { name: e.target.value })}
placeholder="Step name"
className="w-full bg-transparent border-0 outline-none text-base font-medium placeholder:text-muted-foreground"
/>
)}
</div>
{!isSequence && step.description !== undefined && (
<input
type="text"
value={step.description}
onChange={(e) => updateStep(step.id, { description: e.target.value })}
placeholder="Add a description"
className="-mt-2 w-full bg-transparent border-0 outline-none text-sm text-muted-foreground placeholder:text-muted-foreground mb-3"
/>
)}
{!isSequence && (
<Popover
open={toolSearchOpen[step.id] || false}
onOpenChange={(open) => setToolSearchOpen(prev => ({ ...prev, [step.id]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={toolSearchOpen[step.id] || false}
className="h-9 w-full justify-between text-sm"
>
{step.config.tool_name ? (
<span className="flex items-center gap-2 text-sm">
{(() => {
const agentpressTool = agentTools?.agentpress_tools.find(t => t.name === step.config.tool_name);
if (agentpressTool) {
return (
<>
<span>{agentpressTool.icon || '🔧'}</span>
<span>{normalizeToolName(agentpressTool.name, 'agentpress')}</span>
</>
);
}
const mcpTool = agentTools?.mcp_tools.find(t => `${t.server}:${t.name}` === step.config.tool_name);
if (mcpTool) {
return (
<>
<span>{mcpTool.icon || '🔧'}</span>
<span>{normalizeToolName(mcpTool.name, 'mcp')}</span>
</>
);
}
return step.config.tool_name;
})()}
</span>
) : (
<span className="text-muted-foreground">Select tool (optional)</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command>
<CommandInput placeholder="Search tools..." className="h-9" />
<CommandEmpty>No tools found.</CommandEmpty>
<CommandList>
{isLoadingTools ? (
<CommandItem disabled>Loading tools...</CommandItem>
) : agentTools ? (
<>
{agentTools.agentpress_tools.filter(tool => tool.enabled).length > 0 && (
<CommandGroup heading="Default Tools">
{agentTools.agentpress_tools.filter(tool => tool.enabled).map((tool) => (
<CommandItem
key={tool.name}
value={`${normalizeToolName(tool.name, 'agentpress')} ${tool.name}`}
onSelect={() => {
updateStep(step.id, { config: { ...step.config, tool_name: tool.name } });
setToolSearchOpen(prev => ({ ...prev, [step.id]: false }));
}}
>
<div className="flex items-center gap-2">
<span>{tool.icon || '🔧'}</span>
<span>{normalizeToolName(tool.name, 'agentpress')}</span>
</div>
<Check
className={cn(
"ml-auto h-4 w-4",
step.config.tool_name === tool.name ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
)}
{agentTools.mcp_tools.length > 0 && (
<CommandGroup heading="External Tools">
{agentTools.mcp_tools.map((tool) => (
<CommandItem
key={`${tool.server || 'default'}-${tool.name}`}
value={`${normalizeToolName(tool.name, 'mcp')} ${tool.name} ${tool.server || ''}`}
onSelect={() => {
updateStep(step.id, { config: { ...step.config, tool_name: tool.server ? `${tool.server}:${tool.name}` : tool.name } });
setToolSearchOpen(prev => ({ ...prev, [step.id]: false }));
}}
>
<div className="flex items-center gap-2">
<span>{tool.icon || '🔧'}</span>
<span>{normalizeToolName(tool.name, 'mcp')}</span>
</div>
<Check
className={cn(
"ml-auto h-4 w-4",
step.config.tool_name === (tool.server ? `${tool.server}:${tool.name}` : tool.name) ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
)}
</>
) : (
<CommandItem disabled>Failed to load tools</CommandItem>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
{step.children && step.children.length > 0 && (
<div className="mt-4 space-y-4">
{step.children.map((child, index) => renderStep(child, index + 1, true, step.id))}
</div>
)}
</div>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity">
<MoreHorizontal className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="end">
<Button
variant="ghost"
size="sm"
onClick={() => removeStep(step.id)}
className="w-full justify-start text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete step
</Button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
);
};
const renderSteps = () => {
const result: React.ReactNode[] = [];
let stepCounter = 0;
let i = 0;
while (i < steps.length) {
const step = steps[i];
if (step.type === 'condition') {
const conditionGroup: ConditionalStep[] = [];
while (i < steps.length && steps[i].type === 'condition') {
conditionGroup.push(steps[i]);
i++;
}
stepCounter++;
result.push(
<div key={conditionGroup[0].id} className="bg-card rounded-lg border shadow-sm p-4 transition-shadow">
<div className="flex items-start gap-4">
<div className="flex items-center gap-2 shrink-0">
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-sm font-medium text-muted-foreground">
{stepCounter}
</div>
</div>
<div className="flex-1">
<div className="text-base font-medium mb-4">Add rule</div>
{renderConditionTabs(conditionGroup, conditionGroup[0].id)}
</div>
</div>
</div>
);
} else {
stepCounter++;
result.push(renderStep(step, stepCounter, false));
i++;
}
}
return result;
};
return (
<div className="space-y-6 max-w-4xl">
{steps.length === 0 ? (
<div className="text-center py-16">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<Plus className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">Start building your workflow</h3>
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
Add steps and conditions to create a smart workflow that adapts to different scenarios.
</p>
<Button
onClick={() => addStep()}
>
<Plus className="h-4 w-4" />
Add step
</Button>
</div>
) : (
<div className="space-y-6">
{renderSteps()}
<div className="flex justify-center pt-4">
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => addStep()}
className="border-dashed"
>
<Plus className="h-4 w-4" />
Add step
</Button>
<Button
variant="outline"
onClick={() => addCondition(steps[steps.length - 1]?.id || '')}
className="border-dashed"
>
<Plus className="h-4 w-4" />
Add rule
</Button>
</div>
</div>
</div>
)}
</div>
);
}