mirror of https://github.com/kortix-ai/suna.git
auth page refactor
This commit is contained in:
parent
414eb23949
commit
73f919e28d
|
@ -5,9 +5,10 @@ import { useParams, useRouter } from 'next/navigation';
|
|||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Edit2,
|
||||
Settings,
|
||||
GitBranch,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
@ -19,6 +20,7 @@ import { useCreateAgentWorkflow, useUpdateAgentWorkflow, useAgentWorkflows } fro
|
|||
import { CreateWorkflowRequest, UpdateWorkflowRequest } from '@/hooks/react-query/agents/workflow-utils';
|
||||
import { useAgentTools } from '@/hooks/react-query/agents/use-agent-tools';
|
||||
import { ConditionalWorkflowBuilder, ConditionalStep } from '@/components/agents/workflows/conditional-workflow-builder';
|
||||
import { ReactFlowWorkflowBuilder } from '@/components/workflows/react-flow/ReactFlowWorkflowBuilder';
|
||||
|
||||
const convertToNestedJSON = (steps: ConditionalStep[]): any[] => {
|
||||
let globalOrder = 1;
|
||||
|
@ -42,7 +44,6 @@ const convertToNestedJSON = (steps: ConditionalStep[]): any[] => {
|
|||
return jsonStep;
|
||||
});
|
||||
};
|
||||
|
||||
return convertStepsWithNesting(steps);
|
||||
};
|
||||
|
||||
|
@ -51,6 +52,11 @@ const reconstructFromNestedJSON = (nestedSteps: any[]): ConditionalStep[] => {
|
|||
|
||||
const convertStepsFromNested = (stepList: any[]): ConditionalStep[] => {
|
||||
return stepList.map((step) => {
|
||||
console.log('Converting nested step:', step.name, step.type);
|
||||
if (step.children && step.children.length > 0) {
|
||||
console.log(` Step ${step.name} has ${step.children.length} children:`, step.children.map((c: any) => c.name));
|
||||
}
|
||||
|
||||
const conditionalStep: ConditionalStep = {
|
||||
id: step.id || Math.random().toString(36).substr(2, 9),
|
||||
name: step.name,
|
||||
|
@ -59,17 +65,23 @@ const reconstructFromNestedJSON = (nestedSteps: any[]): ConditionalStep[] => {
|
|||
config: step.config || {},
|
||||
order: step.order || step.step_order || 0,
|
||||
enabled: step.enabled !== false,
|
||||
hasIssues: step.hasIssues || false
|
||||
hasIssues: step.hasIssues || false,
|
||||
children: [] // Initialize children array
|
||||
};
|
||||
|
||||
// Handle condition metadata
|
||||
if (step.type === 'condition' && step.conditions) {
|
||||
console.log(` Adding conditions to ${step.name}:`, step.conditions);
|
||||
conditionalStep.conditions = step.conditions;
|
||||
}
|
||||
|
||||
// Handle children - this is crucial for nested conditions
|
||||
if (step.children && Array.isArray(step.children) && step.children.length > 0) {
|
||||
console.log(` Converting ${step.children.length} children for ${step.name}`);
|
||||
conditionalStep.children = convertStepsFromNested(step.children);
|
||||
} else {
|
||||
conditionalStep.children = [];
|
||||
}
|
||||
|
||||
console.log(` Converted step ${step.name}:`, conditionalStep);
|
||||
return conditionalStep;
|
||||
});
|
||||
};
|
||||
|
@ -201,8 +213,17 @@ export default function WorkflowPage() {
|
|||
const [triggerPhrase, setTriggerPhrase] = useState('');
|
||||
const [isDefault, setIsDefault] = useState(false);
|
||||
const [steps, setSteps] = useState<ConditionalStep[]>([]);
|
||||
|
||||
// Debug wrapper for setSteps
|
||||
const setStepsWithDebug = useCallback((newSteps: ConditionalStep[]) => {
|
||||
console.log('=== PARENT: setSteps called ===');
|
||||
console.log('New steps:', newSteps);
|
||||
console.log('New steps count:', newSteps.length);
|
||||
setSteps(newSteps);
|
||||
}, []);
|
||||
const [isSettingsPopoverOpen, setIsSettingsPopoverOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(isEditing);
|
||||
const [useReactFlow, setUseReactFlow] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && workflows.length > 0) {
|
||||
|
@ -215,15 +236,38 @@ export default function WorkflowPage() {
|
|||
let treeSteps: ConditionalStep[];
|
||||
try {
|
||||
// Check if workflow has proper nested structure
|
||||
if (workflow.steps.some((step: any) => step.children && Array.isArray(step.children))) {
|
||||
const hasNestedStructure = workflow.steps.some((step: any) => step.children && Array.isArray(step.children) && step.children.length > 0);
|
||||
console.log('Has nested structure:', hasNestedStructure);
|
||||
console.log('First few steps to check:', workflow.steps.slice(0, 3));
|
||||
|
||||
if (hasNestedStructure) {
|
||||
console.log('Using reconstructFromNestedJSON');
|
||||
treeSteps = reconstructFromNestedJSON(workflow.steps);
|
||||
} else {
|
||||
console.log('Using reconstructFromFlatJSON');
|
||||
treeSteps = reconstructFromFlatJSON(workflow.steps);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error reconstructing workflow steps, using fallback:', error);
|
||||
treeSteps = reconstructFromFlatJSON(workflow.steps);
|
||||
}
|
||||
|
||||
console.log('=== LOADING EXISTING WORKFLOW ===');
|
||||
console.log('Raw workflow from API:', workflow);
|
||||
console.log('Raw workflow steps:', workflow.steps);
|
||||
console.log('Loaded workflow steps:', treeSteps);
|
||||
console.log('Loaded steps count:', treeSteps.length);
|
||||
|
||||
// Debug: Check if steps have children
|
||||
treeSteps.forEach((step, index) => {
|
||||
console.log(`Step ${index}: ${step.name} (${step.type})`);
|
||||
if (step.children && step.children.length > 0) {
|
||||
console.log(` Has ${step.children.length} children:`, step.children.map(c => `${c.name} (${c.type})`));
|
||||
} else {
|
||||
console.log(' No children');
|
||||
}
|
||||
});
|
||||
|
||||
setSteps(treeSteps);
|
||||
setIsLoading(false);
|
||||
} else if (!isLoadingWorkflows) {
|
||||
|
@ -239,7 +283,14 @@ export default function WorkflowPage() {
|
|||
toast.error('Please enter a workflow name');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== SAVING WORKFLOW ===');
|
||||
console.log('Steps before conversion:', steps);
|
||||
console.log('Steps count:', steps.length);
|
||||
|
||||
const nestedSteps = convertToNestedJSON(steps);
|
||||
console.log('Nested steps after conversion:', nestedSteps);
|
||||
|
||||
try {
|
||||
if (isEditing) {
|
||||
const updateRequest: UpdateWorkflowRequest = {
|
||||
|
@ -336,6 +387,29 @@ export default function WorkflowPage() {
|
|||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setUseReactFlow(!useReactFlow)}
|
||||
className="h-8"
|
||||
>
|
||||
{useReactFlow ? <ToggleRight className="h-3.5 w-3.5" /> : <ToggleLeft className="h-3.5 w-3.5" />}
|
||||
{useReactFlow ? 'Visual Builder' : 'List Builder'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log('=== DEBUG LOG ===');
|
||||
console.log('Current workflow steps:', steps);
|
||||
console.log('Steps count:', steps.length);
|
||||
console.log('Nested JSON:', convertToNestedJSON(steps));
|
||||
}}
|
||||
className="h-8"
|
||||
>
|
||||
Debug Log
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={createWorkflowMutation.isPending || updateWorkflowMutation.isPending}
|
||||
|
@ -348,17 +422,27 @@ export default function WorkflowPage() {
|
|||
: 'Save Workflow'
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="p-6">
|
||||
<div className={useReactFlow ? "h-full" : "max-w-4xl mx-auto"}>
|
||||
<div className={useReactFlow ? "h-full" : "p-6"}>
|
||||
{useReactFlow ? (
|
||||
<ReactFlowWorkflowBuilder
|
||||
steps={steps}
|
||||
onStepsChange={setStepsWithDebug}
|
||||
agentTools={agentTools}
|
||||
isLoadingTools={isLoadingTools}
|
||||
/>
|
||||
) : (
|
||||
<ConditionalWorkflowBuilder
|
||||
steps={steps}
|
||||
onStepsChange={setSteps}
|
||||
onStepsChange={setStepsWithDebug}
|
||||
agentTools={agentTools}
|
||||
isLoadingTools={isLoadingTools}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -4,10 +4,8 @@ import Link from 'next/link';
|
|||
import { SubmitButton } from '@/components/ui/submit-button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import GoogleSignIn from '@/components/GoogleSignIn';
|
||||
import { FlickeringGrid } from '@/components/home/ui/flickering-grid';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
import { useState, useEffect, useRef, Suspense } from 'react';
|
||||
import { useScroll } from 'motion/react';
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { signIn, signUp, forgotPassword } from './actions';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
|
@ -29,6 +27,9 @@ import {
|
|||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import GitHubSignIn from '@/components/GithubSignIn';
|
||||
import { KortixLogo } from '@/components/sidebar/kortix-logo';
|
||||
import { FlickeringGrid } from '@/components/home/ui/flickering-grid';
|
||||
import { Ripple } from '@/components/ui/ripple';
|
||||
|
||||
function LoginContent() {
|
||||
const router = useRouter();
|
||||
|
@ -39,20 +40,15 @@ function LoginContent() {
|
|||
const message = searchParams.get('message');
|
||||
|
||||
const isSignUp = mode === 'signup';
|
||||
const tablet = useMediaQuery('(max-width: 1024px)');
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const { scrollY } = useScroll();
|
||||
|
||||
// Redirect if user is already logged in, checking isLoading state
|
||||
useEffect(() => {
|
||||
if (!isLoading && user) {
|
||||
router.push(returnUrl || '/dashboard');
|
||||
}
|
||||
}, [user, isLoading, router, returnUrl]);
|
||||
|
||||
// Determine if message is a success message
|
||||
const isSuccessMessage =
|
||||
message &&
|
||||
(message.includes('Check your email') ||
|
||||
|
@ -83,30 +79,6 @@ function LoginContent() {
|
|||
}
|
||||
}, [isSuccessMessage]);
|
||||
|
||||
// Detect when scrolling is active to reduce animation complexity
|
||||
useEffect(() => {
|
||||
const unsubscribe = scrollY.on('change', () => {
|
||||
setIsScrolling(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (scrollTimeout.current) {
|
||||
clearTimeout(scrollTimeout.current);
|
||||
}
|
||||
|
||||
// Set a new timeout
|
||||
scrollTimeout.current = setTimeout(() => {
|
||||
setIsScrolling(false);
|
||||
}, 300); // Wait 300ms after scroll stops
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (scrollTimeout.current) {
|
||||
clearTimeout(scrollTimeout.current);
|
||||
}
|
||||
};
|
||||
}, [scrollY]);
|
||||
|
||||
const handleSignIn = async (prevState: any, formData: FormData) => {
|
||||
if (returnUrl) {
|
||||
formData.append('returnUrl', returnUrl);
|
||||
|
@ -222,368 +194,283 @@ function LoginContent() {
|
|||
// Show loading spinner while checking auth state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className="flex flex-col items-center justify-center min-h-screen w-full">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
</main>
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Registration success view
|
||||
if (registrationSuccess) {
|
||||
return (
|
||||
<main className="flex flex-col items-center justify-center min-h-screen w-full">
|
||||
<div className="w-full divide-y divide-border">
|
||||
<section className="w-full relative overflow-hidden">
|
||||
<div className="relative flex flex-col items-center w-full px-6">
|
||||
{/* Background elements from the original view */}
|
||||
<div className="absolute left-0 top-0 h-[600px] md:h-[800px] w-1/3 -z-10 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-background z-10" />
|
||||
<div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-background via-background/90 to-transparent z-10" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-background via-background/90 to-transparent z-10" />
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 top-0 h-[600px] md:h-[800px] w-1/3 -z-10 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-l from-transparent via-transparent to-background z-10" />
|
||||
<div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-background via-background/90 to-transparent z-10" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-background via-background/90 to-transparent z-10" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-1/4 top-0 h-[600px] md:h-[800px] -z-20 bg-background rounded-b-xl"></div>
|
||||
|
||||
{/* Success content */}
|
||||
<div className="relative z-10 pt-24 pb-8 max-w-xl mx-auto h-full w-full flex flex-col gap-2 items-center justify-center">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="bg-green-50 dark:bg-green-950/20 rounded-full p-4 mb-6">
|
||||
<MailCheck className="h-12 w-12 text-green-500 dark:text-green-400" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-medium tracking-tighter text-center text-balance text-primary mb-4">
|
||||
Check your email
|
||||
</h1>
|
||||
|
||||
<p className="text-base md:text-lg text-center text-muted-foreground font-medium text-balance leading-relaxed tracking-tight max-w-md mb-2">
|
||||
We've sent a confirmation link to:
|
||||
</p>
|
||||
|
||||
<p className="text-lg font-medium mb-6">
|
||||
{registrationEmail || 'your email address'}
|
||||
</p>
|
||||
|
||||
<div className="bg-green-50 dark:bg-green-950/20 border border-green-100 dark:border-green-900/50 rounded-lg p-6 mb-8 max-w-md w-full">
|
||||
<p className="text-sm text-green-800 dark:text-green-400 leading-relaxed">
|
||||
Click the link in the email to activate your account. If
|
||||
you don't see the email, check your spam folder.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full max-w-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex h-12 items-center justify-center w-full text-center rounded-full border border-border bg-background hover:bg-accent/20 transition-all"
|
||||
>
|
||||
Return to home
|
||||
</Link>
|
||||
<button
|
||||
onClick={resetRegistrationSuccess}
|
||||
className="flex h-12 items-center justify-center w-full text-center rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-all shadow-md"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div className="text-center">
|
||||
<div className="bg-green-50 dark:bg-green-950/20 rounded-full p-4 mb-6 inline-flex">
|
||||
<MailCheck className="h-12 w-12 text-green-500 dark:text-green-400" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h1 className="text-3xl font-semibold text-foreground mb-4">
|
||||
Check your email
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-2">
|
||||
We've sent a confirmation link to:
|
||||
</p>
|
||||
|
||||
<p className="text-lg font-medium mb-6">
|
||||
{registrationEmail || 'your email address'}
|
||||
</p>
|
||||
|
||||
<div className="bg-green-50 dark:bg-green-950/20 border border-green-100 dark:border-green-900/50 rounded-lg p-4 mb-8">
|
||||
<p className="text-sm text-green-800 dark:text-green-400">
|
||||
Click the link in the email to activate your account. If you don't see the email, check your spam folder.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex h-11 items-center justify-center px-6 text-center rounded-lg border border-border bg-background hover:bg-accent transition-colors"
|
||||
>
|
||||
Return to home
|
||||
</Link>
|
||||
<button
|
||||
onClick={resetRegistrationSuccess}
|
||||
className="flex h-11 items-center justify-center px-6 text-center rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-center justify-center min-h-screen w-full">
|
||||
<div className="w-full divide-y divide-border">
|
||||
{/* Hero-like header with flickering grid */}
|
||||
<section className="w-full relative overflow-hidden">
|
||||
<div className="relative flex flex-col items-center w-full px-6">
|
||||
{/* Left side flickering grid with gradient fades */}
|
||||
<div className="absolute left-0 top-0 h-[600px] md:h-[800px] w-1/3 -z-10 overflow-hidden">
|
||||
{/* Horizontal fade from left to right */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-background z-10" />
|
||||
|
||||
{/* Vertical fade from top */}
|
||||
<div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-background via-background/90 to-transparent z-10" />
|
||||
|
||||
{/* Vertical fade to bottom */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-background via-background/90 to-transparent z-10" />
|
||||
|
||||
<div className="h-full w-full">
|
||||
<FlickeringGrid
|
||||
className="h-full w-full"
|
||||
squareSize={mounted && tablet ? 2 : 2.5}
|
||||
gridGap={mounted && tablet ? 2 : 2.5}
|
||||
color="var(--secondary)"
|
||||
maxOpacity={0.4}
|
||||
flickerChance={isScrolling ? 0.01 : 0.03}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side flickering grid with gradient fades */}
|
||||
<div className="absolute right-0 top-0 h-[600px] md:h-[800px] w-1/3 -z-10 overflow-hidden">
|
||||
{/* Horizontal fade from right to left */}
|
||||
<div className="absolute inset-0 bg-gradient-to-l from-transparent via-transparent to-background z-10" />
|
||||
|
||||
{/* Vertical fade from top */}
|
||||
<div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-background via-background/90 to-transparent z-10" />
|
||||
|
||||
{/* Vertical fade to bottom */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-background via-background/90 to-transparent z-10" />
|
||||
|
||||
<div className="h-full w-full">
|
||||
<FlickeringGrid
|
||||
className="h-full w-full"
|
||||
squareSize={mounted && tablet ? 2 : 2.5}
|
||||
gridGap={mounted && tablet ? 2 : 2.5}
|
||||
color="var(--secondary)"
|
||||
maxOpacity={0.4}
|
||||
flickerChance={isScrolling ? 0.01 : 0.03}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center content background with rounded bottom */}
|
||||
<div className="absolute inset-x-1/4 top-0 h-[600px] md:h-[800px] -z-20 bg-background rounded-b-xl"></div>
|
||||
|
||||
{/* Header content */}
|
||||
<div className="relative z-10 pt-24 pb-8 max-w-md mx-auto h-full w-full flex flex-col gap-2 items-center justify-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="group border border-border/50 bg-background hover:bg-accent/20 rounded-full text-sm h-8 px-3 flex items-center gap-2 transition-all duration-200 shadow-sm mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium text-muted-foreground text-xs tracking-wide">
|
||||
<div className="min-h-screen bg-background relative">
|
||||
<div className="absolute top-6 left-6 z-10">
|
||||
<Link href="/" className="flex items-center">
|
||||
<KortixLogo size={28} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex min-h-screen">
|
||||
<div className="flex-1 flex items-center justify-center p-4 lg:p-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to home
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-medium tracking-tighter text-center text-balance text-primary">
|
||||
{isSignUp ? 'Join Suna' : 'Welcome back'}
|
||||
</h1>
|
||||
<p className="text-base md:text-lg text-center text-muted-foreground font-medium text-balance leading-relaxed tracking-tight mt-2 mb-6">
|
||||
{isSignUp
|
||||
? 'Create your account and start building with AI'
|
||||
: 'Sign in to your account to continue'}
|
||||
</p>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{isSignUp ? 'Create your account' : 'Log into your account'}
|
||||
</h1>
|
||||
</div>
|
||||
{message && !isSuccessMessage && (
|
||||
<div className="mb-6 p-4 rounded-lg flex items-center gap-3 bg-destructive/10 border border-destructive/20 text-destructive">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="text-sm">{message}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3 mb-6">
|
||||
<GoogleSignIn returnUrl={returnUrl || undefined} />
|
||||
<GitHubSignIn returnUrl={returnUrl || undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth form card */}
|
||||
<div className="relative z-10 flex justify-center px-6 pb-24">
|
||||
<div className="w-full max-w-md rounded-xl bg-[#F3F4F6] dark:bg-[#F9FAFB]/[0.02] border border-border p-8">
|
||||
{/* Non-registration related messages */}
|
||||
{message && !isSuccessMessage && (
|
||||
<div className="mb-6 p-4 rounded-lg flex items-center gap-3 bg-secondary/10 border border-secondary/20 text-secondary">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0 text-secondary" />
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-background text-muted-foreground">
|
||||
or continue with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<form className="space-y-4">
|
||||
<div>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
className="h-11 rounded-xl"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
className="h-11 rounded-xl"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{isSignUp && (
|
||||
<div>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
className="h-11 rounded-xl"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth Sign In */}
|
||||
<div className="w-full flex flex-col gap-3 mb-6">
|
||||
<div className="w-full">
|
||||
<GoogleSignIn returnUrl={returnUrl || undefined} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<GitHubSignIn returnUrl={returnUrl || undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-8">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-[#F3F4F6] dark:bg-[#F9FAFB]/[0.02] text-muted-foreground">
|
||||
or continue with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form className="space-y-4">
|
||||
<div>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
className="h-12 rounded-full bg-background border-border"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
className="h-12 rounded-full bg-background border-border"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSignUp && (
|
||||
<div>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
className="h-12 rounded-full bg-background border-border"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
{!isSignUp ? (
|
||||
<>
|
||||
<SubmitButton
|
||||
formAction={handleSignIn}
|
||||
className="w-full h-12 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-all shadow-md"
|
||||
pendingText="Signing in..."
|
||||
>
|
||||
Sign in
|
||||
</SubmitButton>
|
||||
|
||||
<Link
|
||||
href={`/auth?mode=signup${returnUrl ? `&returnUrl=${returnUrl}` : ''}`}
|
||||
className="flex h-12 items-center justify-center w-full text-center rounded-full border border-border bg-background hover:bg-accent/20 transition-all"
|
||||
>
|
||||
Create new account
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SubmitButton
|
||||
formAction={handleSignUp}
|
||||
className="w-full h-12 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-all shadow-md"
|
||||
pendingText="Creating account..."
|
||||
>
|
||||
Sign up
|
||||
</SubmitButton>
|
||||
|
||||
<Link
|
||||
href={`/auth${returnUrl ? `?returnUrl=${returnUrl}` : ''}`}
|
||||
className="flex h-12 items-center justify-center w-full text-center rounded-full border border-border bg-background hover:bg-accent/20 transition-all"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isSignUp && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForgotPasswordOpen(true)}
|
||||
className="text-sm text-primary hover:underline"
|
||||
<div className="space-y-3 pt-2">
|
||||
{!isSignUp ? (
|
||||
<>
|
||||
<SubmitButton
|
||||
formAction={handleSignIn}
|
||||
className="w-full h-11 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
pendingText="Signing in..."
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
Sign in
|
||||
</SubmitButton>
|
||||
|
||||
<div className="mt-8 text-center text-xs text-muted-foreground">
|
||||
By continuing, you agree to our{' '}
|
||||
<Link href="/terms" className="text-primary hover:underline">
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link href="/privacy" className="text-primary hover:underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link
|
||||
href={`/auth?mode=signup${returnUrl ? `&returnUrl=${returnUrl}` : ''}`}
|
||||
className="text-sm flex h-11 items-center justify-center w-full text-center border border-border bg-background hover:bg-accent transition-colors rounded-xl"
|
||||
>
|
||||
Create new account
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SubmitButton
|
||||
formAction={handleSignUp}
|
||||
className="w-full h-11 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
pendingText="Creating account..."
|
||||
>
|
||||
Sign up
|
||||
</SubmitButton>
|
||||
|
||||
<Link
|
||||
href={`/auth${returnUrl ? `?returnUrl=${returnUrl}` : ''}`}
|
||||
className="flex h-11 items-center justify-center w-full text-center border border-border bg-background hover:bg-accent transition-colors rounded-md"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isSignUp && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForgotPasswordOpen(true)}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
<div className="mt-8 text-center text-xs text-muted-foreground">
|
||||
By continuing, you agree to our{' '}
|
||||
<Link href="/legal?tab=terms" className="text-primary hover:underline">
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link href="/legal?tab=privacy" className="text-primary hover:underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="hidden lg:flex flex-1 items-center justify-center bg-gradient-to-br from-primary/5 via-background to-primary/10 relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-60">
|
||||
<Ripple />
|
||||
</div>
|
||||
<div className="absolute inset-0">
|
||||
<FlickeringGrid
|
||||
className="h-full w-full mix-blend-soft-light opacity-30"
|
||||
squareSize={3}
|
||||
gridGap={3}
|
||||
color="var(--primary)"
|
||||
maxOpacity={0.6}
|
||||
flickerChance={0.02}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0">
|
||||
<FlickeringGrid
|
||||
className="h-full w-full mix-blend-overlay opacity-20"
|
||||
squareSize={1.5}
|
||||
gridGap={1.5}
|
||||
color="var(--secondary)"
|
||||
maxOpacity={0.3}
|
||||
flickerChance={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Forgot Password Dialog */}
|
||||
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
|
||||
<DialogContent className="sm:max-w-md rounded-xl bg-[#F3F4F6] dark:bg-[#17171A] border border-border [&>button]:hidden">
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-xl font-medium">
|
||||
Reset Password
|
||||
</DialogTitle>
|
||||
<DialogTitle>Reset Password</DialogTitle>
|
||||
<button
|
||||
onClick={() => setForgotPasswordOpen(false)}
|
||||
className="rounded-full p-1 hover:bg-muted transition-colors"
|
||||
className="rounded-sm p-1 hover:bg-accent transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Enter your email address and we'll send you a link to reset your
|
||||
password.
|
||||
<DialogDescription>
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleForgotPassword} className="space-y-4 py-4">
|
||||
<form onSubmit={handleForgotPassword} className="space-y-4">
|
||||
<Input
|
||||
id="forgot-password-email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={forgotPasswordEmail}
|
||||
onChange={(e) => setForgotPasswordEmail(e.target.value)}
|
||||
className="h-12 rounded-full bg-background border-border"
|
||||
className="h-11 rounded-xl"
|
||||
required
|
||||
/>
|
||||
|
||||
{forgotPasswordStatus.message && (
|
||||
<div
|
||||
className={`p-4 rounded-lg flex items-center gap-3 ${
|
||||
className={`p-3 rounded-md flex items-center gap-3 ${
|
||||
forgotPasswordStatus.success
|
||||
? 'bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900/50 text-green-800 dark:text-green-400'
|
||||
: 'bg-secondary/10 border border-secondary/20 text-secondary'
|
||||
: 'bg-destructive/10 border border-destructive/20 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{forgotPasswordStatus.success ? (
|
||||
<CheckCircle className="h-5 w-5 flex-shrink-0 text-green-500 dark:text-green-400" />
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0 text-secondary" />
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{forgotPasswordStatus.message}
|
||||
</span>
|
||||
<span className="text-sm">{forgotPasswordStatus.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex sm:justify-start gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="h-12 px-6 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-all shadow-md"
|
||||
>
|
||||
Send Reset Link
|
||||
</button>
|
||||
<DialogFooter className="gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForgotPasswordOpen(false)}
|
||||
className="h-12 px-6 rounded-full border border-border bg-background hover:bg-accent/20 transition-all"
|
||||
className="h-10 px-4 border border-border bg-background hover:bg-accent transition-colors rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="h-10 px-4 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors rounded-md"
|
||||
>
|
||||
Send Reset Link
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -591,9 +478,9 @@ export default function Login() {
|
|||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="flex flex-col items-center justify-center min-h-screen w-full">
|
||||
<div className="w-12 h-12 rounded-full border-4 border-primary border-t-transparent animate-spin"></div>
|
||||
</main>
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LoginContent />
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
EdgeProps,
|
||||
getSmoothStepPath,
|
||||
} from '@xyflow/react';
|
||||
import useEdgeClick from '../hooks/useEdgeClick';
|
||||
|
||||
export default function WorkflowEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style,
|
||||
markerEnd,
|
||||
label,
|
||||
labelStyle,
|
||||
labelBgStyle,
|
||||
}: EdgeProps) {
|
||||
const onClick = useEdgeClick(id);
|
||||
|
||||
const [edgePath, edgeCenterX, edgeCenterY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
borderRadius: 8, // Add some rounding to the perpendicular bends
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} style={style} path={edgePath} markerEnd={markerEnd} />
|
||||
<EdgeLabelRenderer>
|
||||
{label && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(${sourceX + 50}px, ${sourceY + 30}px) translate(-50%, -50%)`,
|
||||
background: labelBgStyle?.fill || '#fff',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
fontSize: labelStyle?.fontSize || 12,
|
||||
color: labelStyle?.fill || '#666',
|
||||
pointerEvents: 'none',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
style={{
|
||||
transform: `translate(${edgeCenterX}px, ${edgeCenterY}px) translate(-50%, -50%)`,
|
||||
}}
|
||||
onClick={onClick}
|
||||
className="edge-button nodrag nopan"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import WorkflowEdge from './WorkflowEdge';
|
||||
|
||||
const edgeTypes = {
|
||||
workflow: WorkflowEdge,
|
||||
};
|
||||
|
||||
export default edgeTypes;
|
|
@ -0,0 +1,169 @@
|
|||
import { memo, useState } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { GitBranch, MoreHorizontal, Edit2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { getConditionLabel } from '../utils';
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
|
||||
const ConditionNode = ({ id, data, selected }: any) => {
|
||||
const { setNodes } = useReactFlow();
|
||||
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [editData, setEditData] = useState({
|
||||
conditionType: data.conditionType || 'if',
|
||||
expression: data.expression || '',
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) => {
|
||||
if (node.id === id) {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
conditionType: editData.conditionType,
|
||||
expression: editData.expression,
|
||||
},
|
||||
};
|
||||
}
|
||||
return node;
|
||||
})
|
||||
);
|
||||
setIsEditOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`react-flow__node-condition group ${selected ? 'selected' : ''}`}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} />
|
||||
<div className="condition-node-content flex-col relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
<span className="text-xs font-medium">{getConditionLabel(data.conditionType)}</span>
|
||||
</div>
|
||||
{data.expression && data.conditionType !== 'else' && (
|
||||
<div className="text-[10px] mt-0.5 text-amber-800 truncate max-w-[100px]" title={data.expression}>
|
||||
{data.expression}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute -top-8 right-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 bg-white rounded shadow-sm p-1">
|
||||
<Popover open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-sm mb-3">Edit Condition</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Condition Type</Label>
|
||||
<Select
|
||||
value={editData.conditionType}
|
||||
onValueChange={(value) => setEditData({ ...editData, conditionType: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="if">If</SelectItem>
|
||||
<SelectItem value="elseif">Else If</SelectItem>
|
||||
<SelectItem value="else">Else</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{editData.conditionType !== 'else' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Expression</Label>
|
||||
<Input
|
||||
value={editData.expression}
|
||||
onChange={(e) => setEditData({ ...editData, expression: e.target.value })}
|
||||
placeholder="e.g., user asks about pricing"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditData({
|
||||
conditionType: data.conditionType || 'if',
|
||||
expression: data.expression || '',
|
||||
});
|
||||
setIsEditOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
data.onDelete(id);
|
||||
}}
|
||||
className="w-full justify-start text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete condition
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ConditionNode);
|
|
@ -0,0 +1,309 @@
|
|||
import { memo, useState } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { AlertTriangle, MoreHorizontal, Edit2, Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
|
||||
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(' ');
|
||||
}
|
||||
};
|
||||
|
||||
const StepNode = ({ id, data, selected }: any) => {
|
||||
const { setNodes } = useReactFlow();
|
||||
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isToolSelectOpen, setIsToolSelectOpen] = useState(false);
|
||||
const [editData, setEditData] = useState({
|
||||
name: data.name || '',
|
||||
description: data.description || '',
|
||||
tool: data.tool || '',
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) => {
|
||||
if (node.id === id) {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
name: editData.name,
|
||||
description: editData.description,
|
||||
tool: editData.tool,
|
||||
hasIssues: !editData.name || editData.name === 'New Step',
|
||||
},
|
||||
};
|
||||
}
|
||||
return node;
|
||||
})
|
||||
);
|
||||
setIsEditOpen(false);
|
||||
};
|
||||
|
||||
const getDisplayToolName = (toolName: string) => {
|
||||
if (!toolName) return null;
|
||||
|
||||
if (data.agentTools) {
|
||||
const agentpressTool = data.agentTools.agentpress_tools?.find((t: any) => t.name === toolName);
|
||||
if (agentpressTool) {
|
||||
return normalizeToolName(agentpressTool.name, 'agentpress');
|
||||
}
|
||||
|
||||
const mcpTool = data.agentTools.mcp_tools?.find((t: any) =>
|
||||
`${t.server}:${t.name}` === toolName || t.name === toolName
|
||||
);
|
||||
if (mcpTool) {
|
||||
return normalizeToolName(mcpTool.name, 'mcp');
|
||||
}
|
||||
}
|
||||
|
||||
return toolName;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`react-flow__node-step ${selected ? 'selected' : ''}`}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} />
|
||||
<div className="workflow-node-content">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{data.hasIssues && (
|
||||
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
)}
|
||||
<h3 className="font-medium text-sm">
|
||||
{data.name} {data.stepNumber ? data.stepNumber : ''}
|
||||
</h3>
|
||||
</div>
|
||||
{data.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{data.description}</p>
|
||||
)}
|
||||
{data.tool && (
|
||||
<div className="text-xs text-primary mt-2">
|
||||
🔧 {getDisplayToolName(data.tool)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Popover open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-sm mb-3">Edit Step</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input
|
||||
value={editData.name}
|
||||
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
|
||||
placeholder="Step name"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Textarea
|
||||
value={editData.description}
|
||||
onChange={(e) => setEditData({ ...editData, description: e.target.value })}
|
||||
placeholder="Step description (optional)"
|
||||
rows={2}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Tool (optional)</Label>
|
||||
<Popover open={isToolSelectOpen} onOpenChange={setIsToolSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={isToolSelectOpen}
|
||||
className="w-full justify-between h-8 text-sm"
|
||||
>
|
||||
{editData.tool ? (
|
||||
<span className="text-sm">{getDisplayToolName(editData.tool)}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select tool</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search tools..." className="h-8" />
|
||||
<CommandEmpty>No tools found.</CommandEmpty>
|
||||
<CommandList>
|
||||
{data.isLoadingTools ? (
|
||||
<CommandItem disabled>Loading tools...</CommandItem>
|
||||
) : data.agentTools ? (
|
||||
<>
|
||||
{data.agentTools.agentpress_tools?.filter((tool: any) => tool.enabled).length > 0 && (
|
||||
<CommandGroup heading="Default Tools">
|
||||
{data.agentTools.agentpress_tools.filter((tool: any) => tool.enabled).map((tool: any) => (
|
||||
<CommandItem
|
||||
key={tool.name}
|
||||
value={`${normalizeToolName(tool.name, 'agentpress')} ${tool.name}`}
|
||||
onSelect={() => {
|
||||
setEditData({ ...editData, tool: tool.name });
|
||||
setIsToolSelectOpen(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-3 w-3",
|
||||
editData.tool === tool.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{data.agentTools.mcp_tools?.length > 0 && (
|
||||
<CommandGroup heading="External Tools">
|
||||
{data.agentTools.mcp_tools.map((tool: any) => (
|
||||
<CommandItem
|
||||
key={`${tool.server || 'default'}-${tool.name}`}
|
||||
value={`${normalizeToolName(tool.name, 'mcp')} ${tool.name} ${tool.server || ''}`}
|
||||
onSelect={() => {
|
||||
setEditData({ ...editData, tool: tool.server ? `${tool.server}:${tool.name}` : tool.name });
|
||||
setIsToolSelectOpen(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-3 w-3",
|
||||
editData.tool === (tool.server ? `${tool.server}:${tool.name}` : tool.name) ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<CommandItem disabled>No tools available</CommandItem>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditData({
|
||||
name: data.name || '',
|
||||
description: data.description || '',
|
||||
tool: data.tool || '',
|
||||
});
|
||||
setIsEditOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
data.onDelete(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>
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(StepNode);
|
|
@ -0,0 +1,9 @@
|
|||
import StepNode from './StepNode';
|
||||
import ConditionNode from './ConditionNode';
|
||||
|
||||
const nodeTypes = {
|
||||
step: StepNode,
|
||||
condition: ConditionNode,
|
||||
};
|
||||
|
||||
export default nodeTypes;
|
|
@ -0,0 +1,407 @@
|
|||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Edge,
|
||||
Node,
|
||||
ProOptions,
|
||||
ReactFlowProvider,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
Connection,
|
||||
Controls,
|
||||
Panel,
|
||||
BackgroundVariant,
|
||||
} from '@xyflow/react';
|
||||
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import './index.css';
|
||||
|
||||
import useLayout from './hooks/useLayout';
|
||||
import nodeTypes from './NodeTypes';
|
||||
import edgeTypes from './EdgeTypes';
|
||||
import { ConditionalStep } from '@/components/agents/workflows/conditional-workflow-builder';
|
||||
import { uuid } from './utils';
|
||||
import { convertWorkflowToReactFlow, convertReactFlowToWorkflow } from './utils/conversion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, GitBranch } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const proOptions: ProOptions = { account: 'paid-pro', hideAttribution: true };
|
||||
|
||||
interface ReactFlowWorkflowBuilderProps {
|
||||
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;
|
||||
}
|
||||
|
||||
function ReactFlowWorkflowBuilderInner({
|
||||
steps,
|
||||
onStepsChange,
|
||||
agentTools,
|
||||
isLoadingTools
|
||||
}: ReactFlowWorkflowBuilderProps) {
|
||||
// This hook call ensures that the layout is re-calculated every time the graph changes
|
||||
useLayout();
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
|
||||
// Debug wrapper for node changes
|
||||
const onNodesChangeDebug = useCallback((changes: any) => {
|
||||
console.log('=== NODES CHANGED ===');
|
||||
console.log('Node changes:', changes);
|
||||
onNodesChange(changes);
|
||||
}, [onNodesChange]);
|
||||
|
||||
// Debug wrapper for edge changes
|
||||
const onEdgesChangeDebug = useCallback((changes: any) => {
|
||||
console.log('=== EDGES CHANGED ===');
|
||||
console.log('Edge changes:', changes);
|
||||
onEdgesChange(changes);
|
||||
}, [onEdgesChange]);
|
||||
const [isInternalUpdate, setIsInternalUpdate] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
const lastConvertedSteps = useRef<string>('');
|
||||
|
||||
// Debug: Monitor nodes/edges state changes
|
||||
useEffect(() => {
|
||||
console.log('=== NODES STATE CHANGED ===');
|
||||
console.log('Current nodes count:', nodes.length);
|
||||
console.log('Current nodes:', nodes.map(n => ({ id: n.id, name: n.data.name })));
|
||||
}, [nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('=== EDGES STATE CHANGED ===');
|
||||
console.log('Current edges count:', edges.length);
|
||||
console.log('Current edges:', edges.map(e => ({ id: e.id, source: e.source, target: e.target })));
|
||||
}, [edges]);
|
||||
|
||||
// Sync React Flow state back to parent
|
||||
useEffect(() => {
|
||||
console.log('=== SYNC USEEFFECT TRIGGERED ===');
|
||||
console.log('isInternalUpdate:', isInternalUpdate);
|
||||
console.log('nodes.length:', nodes.length);
|
||||
|
||||
if (!isInternalUpdate && nodes.length > 0) {
|
||||
console.log('=== SYNC: Converting React Flow to workflow ===');
|
||||
const convertedSteps = convertReactFlowToWorkflow(nodes, edges);
|
||||
const convertedStepsStr = JSON.stringify(convertedSteps);
|
||||
|
||||
console.log('Converted steps:', convertedSteps);
|
||||
console.log('Previous steps hash:', lastConvertedSteps.current);
|
||||
console.log('Current steps hash:', convertedStepsStr);
|
||||
|
||||
if (convertedStepsStr !== lastConvertedSteps.current) {
|
||||
console.log('Steps changed, updating parent');
|
||||
console.log('Calling onStepsChange with:', convertedSteps);
|
||||
lastConvertedSteps.current = convertedStepsStr;
|
||||
onStepsChange(convertedSteps);
|
||||
} else {
|
||||
console.log('Steps unchanged, skipping update');
|
||||
}
|
||||
} else {
|
||||
console.log('Skipping sync - isInternalUpdate:', isInternalUpdate, 'nodes.length:', nodes.length);
|
||||
}
|
||||
}, [nodes, edges, isInternalUpdate, onStepsChange]);
|
||||
|
||||
// Initialize from steps or with default
|
||||
useEffect(() => {
|
||||
console.log('=== INITIALIZATION ===');
|
||||
console.log('Steps from parent:', steps);
|
||||
console.log('Steps count:', steps.length);
|
||||
|
||||
setIsInternalUpdate(true);
|
||||
|
||||
if (steps.length > 0) {
|
||||
console.log('Converting existing steps to React Flow format');
|
||||
// Convert existing steps to React Flow format
|
||||
const { nodes: convertedNodes, edges: convertedEdges } = convertWorkflowToReactFlow(steps);
|
||||
|
||||
console.log('Converted nodes:', convertedNodes);
|
||||
console.log('Converted edges:', convertedEdges);
|
||||
|
||||
// Add agentTools and handlers to each node
|
||||
const nodesWithTools = convertedNodes.map(node => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
onDelete: handleNodeDelete,
|
||||
agentTools,
|
||||
isLoadingTools,
|
||||
}
|
||||
}));
|
||||
|
||||
setNodes(nodesWithTools);
|
||||
setEdges(convertedEdges);
|
||||
} else if (nodes.length === 0) {
|
||||
console.log('No steps from parent, creating default node');
|
||||
// Initialize with default node
|
||||
const defaultNodes: Node[] = [
|
||||
{
|
||||
id: '1',
|
||||
data: {
|
||||
name: 'Start',
|
||||
description: 'Click to add steps or use the Add Node button',
|
||||
onDelete: handleNodeDelete,
|
||||
agentTools,
|
||||
isLoadingTools,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
type: 'step',
|
||||
},
|
||||
];
|
||||
|
||||
setNodes(defaultNodes);
|
||||
}
|
||||
|
||||
// Reset flag after state updates
|
||||
setTimeout(() => {
|
||||
console.log('Resetting isInternalUpdate flag');
|
||||
setIsInternalUpdate(false);
|
||||
}, 100);
|
||||
}, []); // Only run on mount
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
||||
[setEdges]
|
||||
);
|
||||
|
||||
const handleNodeDelete = useCallback((nodeId: string) => {
|
||||
setNodes((nds) => nds.filter((node) => node.id !== nodeId));
|
||||
setEdges((eds) => eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));
|
||||
}, [setNodes, setEdges]);
|
||||
|
||||
const handleNodeClick = useCallback((event: React.MouseEvent, node: Node) => {
|
||||
setSelectedNode(node.id);
|
||||
}, []);
|
||||
|
||||
const handlePaneClick = useCallback(() => {
|
||||
setSelectedNode(null);
|
||||
}, []);
|
||||
|
||||
const addNewStep = useCallback(() => {
|
||||
console.log('=== ADDING NEW STEP ===');
|
||||
|
||||
const newNodeId = uuid();
|
||||
|
||||
// Calculate position based on existing nodes
|
||||
const existingNodes = nodes;
|
||||
let position = { x: 0, y: 0 };
|
||||
let sourceNode = null;
|
||||
|
||||
if (selectedNode) {
|
||||
// Add after selected node
|
||||
sourceNode = nodes.find(n => n.id === selectedNode);
|
||||
if (sourceNode) {
|
||||
position = { x: sourceNode.position.x, y: sourceNode.position.y + 150 };
|
||||
}
|
||||
} else if (existingNodes.length > 0) {
|
||||
// Find the bottommost node
|
||||
const bottomNode = existingNodes.reduce((prev, current) =>
|
||||
prev.position.y > current.position.y ? prev : current
|
||||
);
|
||||
sourceNode = bottomNode;
|
||||
position = { x: bottomNode.position.x, y: bottomNode.position.y + 150 };
|
||||
}
|
||||
|
||||
const newNode: Node = {
|
||||
id: newNodeId,
|
||||
type: 'step',
|
||||
position,
|
||||
data: {
|
||||
name: 'New Step',
|
||||
description: '',
|
||||
hasIssues: true,
|
||||
onDelete: handleNodeDelete,
|
||||
agentTools,
|
||||
isLoadingTools,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('Adding new node:', newNode);
|
||||
console.log('Current nodes count:', nodes.length);
|
||||
console.log('Source node:', sourceNode);
|
||||
|
||||
setNodes((nds) => {
|
||||
console.log('Setting nodes - before:', nds.length);
|
||||
const newNodes = [...nds, newNode];
|
||||
console.log('Setting nodes - after:', newNodes.length);
|
||||
return newNodes;
|
||||
});
|
||||
|
||||
// Connect to source node if exists
|
||||
if (sourceNode) {
|
||||
const newEdge = {
|
||||
id: `${sourceNode.id}->${newNodeId}`,
|
||||
source: sourceNode.id,
|
||||
target: newNodeId,
|
||||
type: 'workflow',
|
||||
};
|
||||
|
||||
console.log('Adding new edge:', newEdge);
|
||||
|
||||
setEdges((eds) => {
|
||||
console.log('Setting edges - before:', eds.length);
|
||||
const newEdges = [...eds, newEdge];
|
||||
console.log('Setting edges - after:', newEdges.length);
|
||||
return newEdges;
|
||||
});
|
||||
}
|
||||
}, [nodes, selectedNode, handleNodeDelete, setNodes, setEdges, agentTools, isLoadingTools]);
|
||||
|
||||
const addConditionBranch = useCallback((type: 'if' | 'if-else' | 'if-elseif-else') => {
|
||||
if (!selectedNode) {
|
||||
toast.error('Please select a node first to add conditions');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceNode = nodes.find(n => n.id === selectedNode);
|
||||
if (!sourceNode) return;
|
||||
|
||||
const conditions: Array<{ type: 'if' | 'elseif' | 'else'; id: string }> = [];
|
||||
|
||||
// Create condition nodes based on type
|
||||
if (type === 'if') {
|
||||
conditions.push({ type: 'if', id: uuid() });
|
||||
} else if (type === 'if-else') {
|
||||
conditions.push({ type: 'if', id: uuid() });
|
||||
conditions.push({ type: 'else', id: uuid() });
|
||||
} else {
|
||||
conditions.push({ type: 'if', id: uuid() });
|
||||
conditions.push({ type: 'elseif', id: uuid() });
|
||||
conditions.push({ type: 'else', id: uuid() });
|
||||
}
|
||||
|
||||
const newNodes: Node[] = [];
|
||||
const newEdges: Edge[] = [];
|
||||
const xSpacing = 300;
|
||||
const startX = sourceNode.position.x - ((conditions.length - 1) * xSpacing / 2);
|
||||
|
||||
// Create condition nodes
|
||||
conditions.forEach((condition, index) => {
|
||||
const conditionNode: Node = {
|
||||
id: condition.id,
|
||||
type: 'condition',
|
||||
position: {
|
||||
x: startX + index * xSpacing,
|
||||
y: sourceNode.position.y + 150,
|
||||
},
|
||||
data: {
|
||||
conditionType: condition.type,
|
||||
expression: condition.type !== 'else' ? '' : undefined,
|
||||
onDelete: handleNodeDelete,
|
||||
},
|
||||
};
|
||||
|
||||
newNodes.push(conditionNode);
|
||||
|
||||
// Connect source to condition
|
||||
newEdges.push({
|
||||
id: `${sourceNode.id}->${condition.id}`,
|
||||
source: sourceNode.id,
|
||||
target: condition.id,
|
||||
type: 'workflow',
|
||||
label: condition.type === 'if' ? 'if' :
|
||||
condition.type === 'elseif' ? 'else if' : 'else',
|
||||
labelStyle: { fill: '#666', fontSize: 12 },
|
||||
labelBgStyle: { fill: '#fff' },
|
||||
});
|
||||
});
|
||||
|
||||
// Add all new nodes and edges
|
||||
setNodes((nds) => [...nds, ...newNodes]);
|
||||
setEdges((eds) => [...eds, ...newEdges]);
|
||||
}, [selectedNode, nodes, handleNodeDelete, setNodes, setEdges]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full border rounded-none border-none bg-muted/10 react-flow-container">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChangeDebug}
|
||||
onEdgesChange={onEdgesChangeDebug}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
proOptions={proOptions}
|
||||
fitView
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.5}
|
||||
maxZoom={1.5}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
zoomOnDoubleClick={false}
|
||||
deleteKeyCode={null}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls />
|
||||
|
||||
<Panel position="top-left" className="react-flow__panel">
|
||||
<div className="workflow-panel-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addNewStep}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Step
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Add Condition
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => addConditionBranch('if')}>
|
||||
<span className="text-xs font-mono mr-2">if</span>
|
||||
Single condition
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => addConditionBranch('if-else')}>
|
||||
<span className="text-xs font-mono mr-2">if-else</span>
|
||||
Two branches
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => addConditionBranch('if-elseif-else')}>
|
||||
<span className="text-xs font-mono mr-2">if-elif-else</span>
|
||||
Three branches
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<div className="workflow-panel-selection">
|
||||
Node selected: {nodes.find(n => n.id === selectedNode)?.data.name || selectedNode}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReactFlowWorkflowBuilder(props: ReactFlowWorkflowBuilderProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ReactFlowWorkflowBuilderInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
import { EdgeProps, useReactFlow } from '@xyflow/react';
|
||||
import { uuid } from '../utils';
|
||||
|
||||
// This hook implements the logic for clicking the button on a workflow edge
|
||||
// On edge click: create a node in between the two nodes that are connected by the edge
|
||||
function useEdgeClick(id: EdgeProps['id']) {
|
||||
const { setEdges, setNodes, getNode, getEdge } = useReactFlow();
|
||||
|
||||
const handleEdgeClick = () => {
|
||||
// First retrieve the edge object to get the source and target id
|
||||
const edge = getEdge(id);
|
||||
|
||||
if (!edge) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Retrieve the target node to get its position
|
||||
const targetNode = getNode(edge.target);
|
||||
|
||||
if (!targetNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a unique id for newly added elements
|
||||
const insertNodeId = uuid();
|
||||
|
||||
// Get source node to access agentTools
|
||||
const sourceNode = getNode(edge.source);
|
||||
|
||||
// This is the node object that will be added in between source and target node
|
||||
const insertNode = {
|
||||
id: insertNodeId,
|
||||
// Place the node at the current position of the target (prevents jumping)
|
||||
position: { x: targetNode.position.x, y: targetNode.position.y },
|
||||
data: {
|
||||
name: 'New Step',
|
||||
description: '',
|
||||
hasIssues: true,
|
||||
onDelete: () => {},
|
||||
// Pass through agentTools from source node
|
||||
agentTools: sourceNode?.data.agentTools,
|
||||
isLoadingTools: sourceNode?.data.isLoadingTools,
|
||||
},
|
||||
type: 'step',
|
||||
};
|
||||
|
||||
// New connection from source to new node
|
||||
const sourceEdge = {
|
||||
id: `${edge.source}->${insertNodeId}`,
|
||||
source: edge.source,
|
||||
target: insertNodeId,
|
||||
type: 'workflow',
|
||||
};
|
||||
|
||||
// New connection from new node to target
|
||||
const targetEdge = {
|
||||
id: `${insertNodeId}->${edge.target}`,
|
||||
source: insertNodeId,
|
||||
target: edge.target,
|
||||
type: 'workflow',
|
||||
};
|
||||
|
||||
// Remove the edge that was clicked as we have a new connection with a node inbetween
|
||||
setEdges((edges) =>
|
||||
edges.filter((e) => e.id !== id).concat([sourceEdge, targetEdge])
|
||||
);
|
||||
|
||||
// Insert the node between the source and target node in the react flow state
|
||||
setNodes((nodes) => {
|
||||
const targetNodeIndex = nodes.findIndex(
|
||||
(node) => node.id === edge.target
|
||||
);
|
||||
|
||||
return [
|
||||
...nodes.slice(0, targetNodeIndex),
|
||||
insertNode,
|
||||
...nodes.slice(targetNodeIndex, nodes.length),
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
return handleEdgeClick;
|
||||
}
|
||||
|
||||
export default useEdgeClick;
|
|
@ -0,0 +1,173 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
useReactFlow,
|
||||
useStore,
|
||||
Node,
|
||||
Edge,
|
||||
ReactFlowState,
|
||||
} from '@xyflow/react';
|
||||
|
||||
// Simple vertical layout function with better handling of dynamic nodes
|
||||
function layoutNodes(nodes: Node[], edges: Edge[]): Node[] {
|
||||
if (nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create adjacency lists for both directions
|
||||
const children: Map<string, string[]> = new Map();
|
||||
const parents: Map<string, string[]> = new Map();
|
||||
|
||||
// Initialize
|
||||
nodes.forEach(node => {
|
||||
children.set(node.id, []);
|
||||
parents.set(node.id, []);
|
||||
});
|
||||
|
||||
// Build graph
|
||||
edges.forEach(edge => {
|
||||
const childList = children.get(edge.source) || [];
|
||||
childList.push(edge.target);
|
||||
children.set(edge.source, childList);
|
||||
|
||||
const parentList = parents.get(edge.target) || [];
|
||||
parentList.push(edge.source);
|
||||
parents.set(edge.target, parentList);
|
||||
});
|
||||
|
||||
// Find root nodes (nodes with no parents)
|
||||
const roots = nodes.filter(node => (parents.get(node.id) || []).length === 0);
|
||||
|
||||
// If no roots, use first node
|
||||
if (roots.length === 0 && nodes.length > 0) {
|
||||
roots.push(nodes[0]);
|
||||
}
|
||||
|
||||
// Calculate levels using BFS
|
||||
const levels: Map<string, number> = new Map();
|
||||
const visited: Set<string> = new Set();
|
||||
const queue: Array<{id: string, level: number}> = [];
|
||||
|
||||
// Start from all roots
|
||||
roots.forEach(root => {
|
||||
queue.push({id: root.id, level: 0});
|
||||
visited.add(root.id);
|
||||
levels.set(root.id, 0);
|
||||
});
|
||||
|
||||
// BFS to assign levels
|
||||
while (queue.length > 0) {
|
||||
const {id, level} = queue.shift()!;
|
||||
|
||||
const nodeChildren = children.get(id) || [];
|
||||
nodeChildren.forEach(childId => {
|
||||
if (!visited.has(childId)) {
|
||||
visited.add(childId);
|
||||
levels.set(childId, level + 1);
|
||||
queue.push({id: childId, level: level + 1});
|
||||
} else {
|
||||
// Update level if we found a longer path
|
||||
const currentLevel = levels.get(childId) || 0;
|
||||
if (level + 1 > currentLevel) {
|
||||
levels.set(childId, level + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Group nodes by level
|
||||
const nodesByLevel: Map<number, Node[]> = new Map();
|
||||
let maxLevel = 0;
|
||||
|
||||
nodes.forEach(node => {
|
||||
const level = levels.get(node.id) || 0;
|
||||
maxLevel = Math.max(maxLevel, level);
|
||||
const nodesAtLevel = nodesByLevel.get(level) || [];
|
||||
nodesAtLevel.push(node);
|
||||
nodesByLevel.set(level, nodesAtLevel);
|
||||
});
|
||||
|
||||
// Position nodes
|
||||
const xSpacing = 200; // Reduced from 250 to 180 for tighter horizontal spacing
|
||||
const ySpacing = 120; // Reduced from 150 to 120 for tighter vertical spacing
|
||||
const layoutedNodes: Node[] = [];
|
||||
|
||||
// Calculate positions level by level
|
||||
for (let level = 0; level <= maxLevel; level++) {
|
||||
const nodesAtLevel = nodesByLevel.get(level) || [];
|
||||
|
||||
if (nodesAtLevel.length > 0) {
|
||||
// Sort nodes at this level by their parent's x position for better alignment
|
||||
if (level > 0) {
|
||||
nodesAtLevel.sort((a, b) => {
|
||||
const aParents = parents.get(a.id) || [];
|
||||
const bParents = parents.get(b.id) || [];
|
||||
|
||||
if (aParents.length > 0 && bParents.length > 0) {
|
||||
const aParentX = layoutedNodes.find(n => n.id === aParents[0])?.position.x || 0;
|
||||
const bParentX = layoutedNodes.find(n => n.id === bParents[0])?.position.x || 0;
|
||||
return aParentX - bParentX;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate x positions
|
||||
const totalWidth = (nodesAtLevel.length - 1) * xSpacing;
|
||||
const startX = -totalWidth / 2;
|
||||
|
||||
nodesAtLevel.forEach((node, index) => {
|
||||
layoutedNodes.push({
|
||||
...node,
|
||||
position: {
|
||||
x: startX + index * xSpacing,
|
||||
y: level * ySpacing
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return layoutedNodes;
|
||||
}
|
||||
|
||||
// This is the store selector that is used for triggering the layout
|
||||
const nodeCountSelector = (state: ReactFlowState) => state.nodeLookup.size;
|
||||
|
||||
function useLayout() {
|
||||
const initial = useRef(true);
|
||||
const nodeCount = useStore(nodeCountSelector);
|
||||
const { getNodes, setNodes, getEdges, fitView } = useReactFlow();
|
||||
const previousNodeCount = useRef(nodeCount);
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = getNodes();
|
||||
const edges = getEdges();
|
||||
|
||||
// Only re-layout if nodes were added/removed or on initial load
|
||||
if (nodeCount !== previousNodeCount.current || initial.current) {
|
||||
// Run the layout
|
||||
const layoutedNodes = layoutNodes(nodes, edges);
|
||||
|
||||
// Update node positions
|
||||
setNodes(layoutedNodes);
|
||||
|
||||
// Fit view
|
||||
setTimeout(() => {
|
||||
fitView({
|
||||
duration: 200,
|
||||
padding: 0.2,
|
||||
maxZoom: 1.2,
|
||||
});
|
||||
}, 50);
|
||||
|
||||
if (initial.current) {
|
||||
initial.current = false;
|
||||
}
|
||||
|
||||
previousNodeCount.current = nodeCount;
|
||||
}
|
||||
}, [nodeCount, getEdges, getNodes, setNodes, fitView]);
|
||||
}
|
||||
|
||||
export default useLayout;
|
|
@ -0,0 +1,386 @@
|
|||
/* React Flow Workflow Builder Styles */
|
||||
|
||||
.react-flow-container {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.react-flow__handle {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.react-flow__node {
|
||||
width: 240px;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.react-flow__node:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.dark .react-flow__node {
|
||||
border: 1px solid #374151;
|
||||
background: #1f2937 !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dark .react-flow__node:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.react-flow__node-step {
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark .react-flow__node-step {
|
||||
border: 1px solid #374151;
|
||||
background: #1f2937 !important;
|
||||
}
|
||||
|
||||
.react-flow__node-step.selected {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.react-flow__node-condition {
|
||||
width: 140px;
|
||||
height: 80px;
|
||||
border: 1px solid #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1) !important;
|
||||
border-radius: 24px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.react-flow__node-condition:hover {
|
||||
background: rgba(245, 158, 11, 0.15) !important;
|
||||
}
|
||||
|
||||
.react-flow__node-condition.selected {
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.dark .react-flow__node-condition {
|
||||
border-color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.15) !important;
|
||||
}
|
||||
|
||||
.dark .react-flow__node-condition:hover {
|
||||
background: rgba(245, 158, 11, 0.2) !important;
|
||||
}
|
||||
|
||||
.react-flow__edge-workflow {
|
||||
stroke: hsl(var(--muted-foreground));
|
||||
stroke-width: 2;
|
||||
transition: stroke 0.2s ease;
|
||||
}
|
||||
|
||||
.react-flow__edge-workflow:hover {
|
||||
stroke: hsl(var(--foreground));
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.react-flow__edge-workflow.selected {
|
||||
stroke: hsl(var(--primary));
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
/* Arrow marker styling */
|
||||
.react-flow__arrowhead {
|
||||
fill: hsl(var(--muted-foreground));
|
||||
transition: fill 0.2s ease;
|
||||
}
|
||||
|
||||
.react-flow__edge-workflow:hover .react-flow__arrowhead {
|
||||
fill: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.react-flow__edge-workflow.selected .react-flow__arrowhead {
|
||||
fill: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.edge-button {
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.edge-button:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.dark .edge-button {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
color: #9ca3af;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dark .edge-button:hover {
|
||||
background: #374151;
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Node content styles */
|
||||
.workflow-node-content {
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.workflow-node-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.workflow-node-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.workflow-node-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dark .workflow-node-title {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.workflow-node-description {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dark .workflow-node-description {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.workflow-node-tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dark .workflow-node-tool {
|
||||
background: #374151;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.workflow-node-tool-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.workflow-node-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.react-flow__node:hover .workflow-node-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.workflow-node-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dark .workflow-node-status {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.workflow-node-status.has-issues {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.workflow-node-status.completed {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.condition-node-content {
|
||||
padding: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #d97706;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.condition-node-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.condition-node-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.condition-node-expression {
|
||||
font-size: 9px;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dark .condition-node-content {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* Panel styling */
|
||||
.react-flow__panel {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dark .react-flow__panel {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.workflow-panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workflow-panel-selection {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Background styling */
|
||||
.react-flow__background {
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
.dark .react-flow__background {
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
}
|
||||
|
||||
/* Controls styling */
|
||||
.react-flow__controls {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .react-flow__controls {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.react-flow__controls-button {
|
||||
background: hsl(var(--card));
|
||||
border: none;
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.react-flow__controls-button:hover {
|
||||
background: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse-primary {
|
||||
0%, 100% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0.4); }
|
||||
50% { box-shadow: 0 0 0 8px hsl(var(--primary) / 0); }
|
||||
}
|
||||
|
||||
.workflow-node-pulse {
|
||||
animation: pulse-primary 2s infinite;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.react-flow__node {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.react-flow__node-condition {
|
||||
width: 120px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.workflow-node-content {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
export const uuid = (): string =>
|
||||
new Date().getTime().toString(36) + Math.random().toString(36).slice(2);
|
||||
|
||||
export const getStepLabel = (stepNumber: number): string => {
|
||||
return `Step ${stepNumber}`;
|
||||
};
|
||||
|
||||
export const getConditionLabel = (conditionType: 'if' | 'elseif' | 'else'): string => {
|
||||
switch (conditionType) {
|
||||
case 'if':
|
||||
return 'If';
|
||||
case 'elseif':
|
||||
return 'Else If';
|
||||
case 'else':
|
||||
return 'Otherwise';
|
||||
default:
|
||||
return 'Condition';
|
||||
}
|
||||
};
|
|
@ -0,0 +1,411 @@
|
|||
import { Node, Edge } from '@xyflow/react';
|
||||
import { ConditionalStep } from '@/components/agents/workflows/conditional-workflow-builder';
|
||||
import { uuid } from '../utils';
|
||||
|
||||
interface ConversionResult {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
}
|
||||
|
||||
export function convertWorkflowToReactFlow(steps: ConditionalStep[]): ConversionResult {
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
console.log('=== convertWorkflowToReactFlow ===');
|
||||
console.log('Input steps:', steps);
|
||||
console.log('Steps count:', steps.length);
|
||||
|
||||
if (steps.length === 0) {
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// Helper function to process steps recursively
|
||||
const processSteps = (
|
||||
stepList: ConditionalStep[],
|
||||
parentId: string | null = null,
|
||||
yOffset: number = 0,
|
||||
xOffset: number = 0
|
||||
): { nodes: Node[]; edges: Edge[]; lastNodes: string[]; maxY: number } => {
|
||||
console.log(`Processing ${stepList.length} steps at Y:${yOffset}, parent: ${parentId}`);
|
||||
stepList.forEach((step, index) => {
|
||||
console.log(` Step ${index}: ${step.name} (${step.type}) - ${step.children?.length || 0} children`);
|
||||
});
|
||||
|
||||
let previousNodeId = parentId;
|
||||
let currentY = yOffset;
|
||||
let lastNodes: string[] = [];
|
||||
const localNodes: Node[] = [];
|
||||
const localEdges: Edge[] = [];
|
||||
|
||||
for (let i = 0; i < stepList.length; i++) {
|
||||
const step = stepList[i];
|
||||
|
||||
// Check if this is the start of a condition group
|
||||
if (step.type === 'condition') {
|
||||
// Collect all consecutive conditions (if/elseif/else group)
|
||||
const conditionGroup: ConditionalStep[] = [];
|
||||
let j = i;
|
||||
|
||||
while (j < stepList.length && stepList[j].type === 'condition') {
|
||||
conditionGroup.push(stepList[j]);
|
||||
j++;
|
||||
}
|
||||
|
||||
// Process the condition group as branches
|
||||
const branchResult = processConditionBranches(
|
||||
conditionGroup,
|
||||
previousNodeId,
|
||||
currentY,
|
||||
xOffset
|
||||
);
|
||||
|
||||
localNodes.push(...branchResult.nodes);
|
||||
localEdges.push(...branchResult.edges);
|
||||
nodes.push(...branchResult.nodes);
|
||||
edges.push(...branchResult.edges);
|
||||
|
||||
// Update tracking
|
||||
currentY = branchResult.maxY;
|
||||
lastNodes = branchResult.lastNodes;
|
||||
|
||||
// Skip the conditions we just processed
|
||||
i = j - 1;
|
||||
} else {
|
||||
// Regular step node
|
||||
const nodeId = step.id || uuid();
|
||||
|
||||
const node: Node = {
|
||||
id: nodeId,
|
||||
type: 'step',
|
||||
position: { x: xOffset, y: currentY },
|
||||
data: {
|
||||
name: step.name,
|
||||
description: step.description,
|
||||
hasIssues: step.hasIssues,
|
||||
tool: step.config?.tool_name,
|
||||
},
|
||||
};
|
||||
|
||||
localNodes.push(node);
|
||||
nodes.push(node);
|
||||
|
||||
// Connect to previous node
|
||||
if (previousNodeId) {
|
||||
const edge = {
|
||||
id: `${previousNodeId}->${nodeId}`,
|
||||
source: previousNodeId,
|
||||
target: nodeId,
|
||||
type: 'workflow',
|
||||
};
|
||||
localEdges.push(edge);
|
||||
edges.push(edge);
|
||||
}
|
||||
|
||||
// Check if this step has nested condition children
|
||||
if (step.children && step.children.length > 0) {
|
||||
const conditionChildren = step.children.filter(child => child.type === 'condition');
|
||||
const regularChildren = step.children.filter(child => child.type !== 'condition');
|
||||
|
||||
if (conditionChildren.length > 0) {
|
||||
console.log(`Step ${step.name} has ${conditionChildren.length} condition children`);
|
||||
|
||||
// Process nested conditions as branches - tighter spacing
|
||||
const branchResult = processConditionBranches(
|
||||
conditionChildren,
|
||||
nodeId,
|
||||
currentY + 120, // Reduced from 150 to 120 for tighter spacing
|
||||
xOffset
|
||||
);
|
||||
|
||||
localNodes.push(...branchResult.nodes);
|
||||
localEdges.push(...branchResult.edges);
|
||||
nodes.push(...branchResult.nodes);
|
||||
edges.push(...branchResult.edges);
|
||||
|
||||
// Update tracking
|
||||
currentY = branchResult.maxY;
|
||||
lastNodes = branchResult.lastNodes;
|
||||
} else if (regularChildren.length > 0) {
|
||||
// Process regular children recursively
|
||||
const childResult = processSteps(
|
||||
regularChildren,
|
||||
nodeId,
|
||||
currentY + 150, // Reduced from 150 to 120 for consistency
|
||||
xOffset
|
||||
);
|
||||
|
||||
localNodes.push(...childResult.nodes);
|
||||
localEdges.push(...childResult.edges);
|
||||
nodes.push(...childResult.nodes);
|
||||
edges.push(...childResult.edges);
|
||||
|
||||
currentY = childResult.maxY;
|
||||
lastNodes = childResult.lastNodes;
|
||||
} else {
|
||||
previousNodeId = nodeId;
|
||||
lastNodes = [nodeId];
|
||||
currentY += 120; // Reduced from 150 to 120 for consistency
|
||||
}
|
||||
} else {
|
||||
previousNodeId = nodeId;
|
||||
lastNodes = [nodeId];
|
||||
currentY += 120; // Reduced from 150 to 120 for consistency
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: localNodes, edges: localEdges, lastNodes, maxY: currentY };
|
||||
};
|
||||
|
||||
// Helper function to process condition branches
|
||||
const processConditionBranches = (
|
||||
conditions: ConditionalStep[],
|
||||
parentId: string | null,
|
||||
yOffset: number,
|
||||
xOffset: number
|
||||
): { nodes: Node[]; edges: Edge[]; lastNodes: string[]; maxY: number } => {
|
||||
const branchNodes: Node[] = [];
|
||||
const branchEdges: Edge[] = [];
|
||||
const branchLastNodes: string[] = [];
|
||||
let maxY = yOffset;
|
||||
|
||||
// Calculate x positions for branches - natural spacing
|
||||
const branchCount = conditions.length;
|
||||
const xSpacing = 200; // Clean spacing for React Flow
|
||||
const startX = xOffset - ((branchCount - 1) * xSpacing / 2);
|
||||
|
||||
// Process each condition as a branch
|
||||
conditions.forEach((condition, index) => {
|
||||
const conditionNodeId = condition.id || uuid();
|
||||
const branchXPos = startX + index * xSpacing;
|
||||
|
||||
// Create condition label node with natural positioning
|
||||
const conditionNode: Node = {
|
||||
id: conditionNodeId,
|
||||
type: 'condition',
|
||||
position: { x: branchXPos, y: yOffset + 100 },
|
||||
data: {
|
||||
conditionType: condition.conditions?.type || 'if',
|
||||
expression: condition.conditions?.expression || '',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
||||
branchNodes.push(conditionNode);
|
||||
|
||||
// Connect parent to condition
|
||||
if (parentId) {
|
||||
branchEdges.push({
|
||||
id: `${parentId}->${conditionNodeId}`,
|
||||
source: parentId,
|
||||
target: conditionNodeId,
|
||||
type: 'workflow',
|
||||
label: condition.conditions?.type === 'if' ? 'if' :
|
||||
condition.conditions?.type === 'elseif' ? 'else if' : 'else',
|
||||
labelStyle: { fill: '#666', fontSize: 12 },
|
||||
labelBgStyle: { fill: '#fff' },
|
||||
});
|
||||
}
|
||||
|
||||
// Process children of this condition
|
||||
if (condition.children && condition.children.length > 0) {
|
||||
const childResult = processSteps(
|
||||
condition.children,
|
||||
conditionNodeId,
|
||||
yOffset + 180, // Natural spacing for React Flow
|
||||
branchXPos
|
||||
);
|
||||
|
||||
branchNodes.push(...childResult.nodes);
|
||||
branchEdges.push(...childResult.edges);
|
||||
|
||||
// Track the last nodes in this branch
|
||||
branchLastNodes.push(...childResult.lastNodes);
|
||||
maxY = Math.max(maxY, childResult.maxY);
|
||||
} else {
|
||||
// Empty branch - condition is the end
|
||||
branchLastNodes.push(conditionNodeId);
|
||||
maxY = Math.max(maxY, yOffset + 180);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: branchNodes,
|
||||
edges: branchEdges,
|
||||
lastNodes: branchLastNodes,
|
||||
maxY,
|
||||
};
|
||||
};
|
||||
|
||||
// Process all top-level steps
|
||||
const result = processSteps(steps, null, 0, 0);
|
||||
|
||||
console.log('=== Final React Flow result ===');
|
||||
console.log('Nodes:', nodes.length);
|
||||
console.log('Edges:', edges.length);
|
||||
console.log('Node names:', nodes.map(n => `${n.id}:${n.data.name}(${n.type})`));
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// Simple, clean conversion approach
|
||||
export function convertReactFlowToWorkflow(nodes: Node[], edges: Edge[]): ConditionalStep[] {
|
||||
console.log('=== CLEAN convertReactFlowToWorkflow ===');
|
||||
console.log('Input nodes:', nodes.map(n => `${n.id}:${n.data?.name || 'unnamed'}(${n.type})`));
|
||||
console.log('Input edges:', edges.map(e => `${e.source}->${e.target}(${e.label || 'no-label'})`));
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build adjacency map
|
||||
const childrenMap = new Map<string, Node[]>();
|
||||
const parentMap = new Map<string, string>();
|
||||
const edgeLabels = new Map<string, string>();
|
||||
|
||||
nodes.forEach(node => {
|
||||
childrenMap.set(node.id, []);
|
||||
});
|
||||
|
||||
edges.forEach(edge => {
|
||||
const children = childrenMap.get(edge.source) || [];
|
||||
const targetNode = nodes.find(n => n.id === edge.target);
|
||||
if (targetNode) {
|
||||
children.push(targetNode);
|
||||
parentMap.set(edge.target, edge.source);
|
||||
if (edge.label) {
|
||||
edgeLabels.set(edge.target, String(edge.label));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find root
|
||||
const rootNode = nodes.find(node => !parentMap.has(node.id));
|
||||
if (!rootNode) {
|
||||
console.log('No root node found');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('Root node:', rootNode.id, rootNode.data?.name);
|
||||
|
||||
// Track visited nodes to prevent duplicates
|
||||
const visited = new Set<string>();
|
||||
|
||||
// Convert node to step
|
||||
const convertNode = (node: Node): ConditionalStep => {
|
||||
// Mark as visited
|
||||
visited.add(node.id);
|
||||
|
||||
const nodeData = node.data as any;
|
||||
const children = childrenMap.get(node.id) || [];
|
||||
|
||||
if (node.type === 'step') {
|
||||
const step: ConditionalStep = {
|
||||
id: node.id,
|
||||
name: nodeData.name || 'Unnamed Step',
|
||||
description: nodeData.description || '',
|
||||
type: 'instruction',
|
||||
config: nodeData.tool ? { tool_name: nodeData.tool } : {},
|
||||
order: 0,
|
||||
enabled: true,
|
||||
hasIssues: nodeData.hasIssues || false,
|
||||
children: []
|
||||
};
|
||||
|
||||
// Separate condition children from regular children
|
||||
const conditionChildren = children.filter(child => child.type === 'condition');
|
||||
const regularChildren = children.filter(child => child.type !== 'condition');
|
||||
|
||||
console.log(`Step ${step.name}: ${conditionChildren.length} conditions, ${regularChildren.length} regular children`);
|
||||
|
||||
// Process condition children as nested conditions
|
||||
conditionChildren.forEach(conditionChild => {
|
||||
if (!visited.has(conditionChild.id)) {
|
||||
const conditionStep = convertNode(conditionChild);
|
||||
step.children!.push(conditionStep);
|
||||
}
|
||||
});
|
||||
|
||||
// Return step with regular children to be processed as siblings
|
||||
return step;
|
||||
} else if (node.type === 'condition') {
|
||||
const edgeLabel = edgeLabels.get(node.id) || 'Condition';
|
||||
|
||||
const step: ConditionalStep = {
|
||||
id: node.id,
|
||||
name: edgeLabel,
|
||||
description: '',
|
||||
type: 'condition',
|
||||
config: {},
|
||||
conditions: {
|
||||
type: nodeData.conditionType || 'if',
|
||||
expression: nodeData.expression || '',
|
||||
},
|
||||
order: 0,
|
||||
enabled: true,
|
||||
hasIssues: false,
|
||||
children: []
|
||||
};
|
||||
|
||||
// Process all children of this condition
|
||||
children.forEach(child => {
|
||||
if (!visited.has(child.id)) {
|
||||
const childStep = convertNode(child);
|
||||
step.children!.push(childStep);
|
||||
|
||||
// Process any regular children as siblings
|
||||
const childRegularChildren = (childrenMap.get(child.id) || []).filter(c => c.type !== 'condition');
|
||||
childRegularChildren.forEach(grandchild => {
|
||||
if (!visited.has(grandchild.id)) {
|
||||
const grandchildStep = convertNode(grandchild);
|
||||
step.children!.push(grandchildStep);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return {
|
||||
id: node.id,
|
||||
name: 'Unknown',
|
||||
description: '',
|
||||
type: 'instruction',
|
||||
config: {},
|
||||
order: 0,
|
||||
enabled: true,
|
||||
hasIssues: true,
|
||||
children: []
|
||||
};
|
||||
};
|
||||
|
||||
// Process from root
|
||||
const result: ConditionalStep[] = [];
|
||||
const rootStep = convertNode(rootNode);
|
||||
result.push(rootStep);
|
||||
|
||||
// Process any unvisited regular children of root as siblings
|
||||
const rootRegularChildren = (childrenMap.get(rootNode.id) || []).filter(child =>
|
||||
child.type !== 'condition' && !visited.has(child.id)
|
||||
);
|
||||
|
||||
rootRegularChildren.forEach(child => {
|
||||
const childStep = convertNode(child);
|
||||
result.push(childStep);
|
||||
});
|
||||
|
||||
console.log('=== Final clean workflow result ===');
|
||||
console.log('Steps:', result.length);
|
||||
console.log('Visited nodes:', visited.size, 'of', nodes.length);
|
||||
console.log('Step details:', result.map(s => ({
|
||||
name: s.name,
|
||||
type: s.type,
|
||||
childrenCount: s.children?.length || 0,
|
||||
conditions: s.conditions?.type || 'none'
|
||||
})));
|
||||
|
||||
return result;
|
||||
}
|
Loading…
Reference in New Issue