ToolCreateStepper.tsx•44.7 kB
import { useConfig } from '@/src/app/config-context';
import { useIntegrations } from '@/src/app/integrations-context';
import { getAuthBadge } from '@/src/app/integrations/page';
import { IntegrationForm } from '@/src/components/integrations/IntegrationForm';
import { useToast } from '@/src/hooks/use-toast';
import { needsUIToTriggerDocFetch } from '@/src/lib/client-utils';
import { formatBytes, generateUniqueKey, MAX_TOTAL_FILE_SIZE, sanitizeFileName, type UploadedFileInfo } from '@/src/lib/file-utils';
import { cn, composeUrl, getIntegrationIcon as getIntegrationIconName, getSimpleIcon, inputErrorStyles } from '@/src/lib/utils';
import { Integration, IntegrationInput, SuperglueClient, Workflow as Tool, UpsertMode } from '@superglue/client';
import { integrationOptions } from "@superglue/shared";
import { waitForIntegrationProcessing } from '@superglue/shared/utils';
import { ArrowRight, Check, Clock, Globe, Key, Loader2, Pencil, Plus, X } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import Prism from 'prismjs';
import 'prismjs/components/prism-json';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Switch } from '../ui/switch';
import { Textarea } from '../ui/textarea';
import { DocStatus } from '../utils/DocStatusSpinner';
import { HelpTooltip } from '../utils/HelpTooltip';
import { StepIndicator, TOOL_CREATE_STEPS } from '../utils/StepIndicator';
import { ToolCreateSuccess } from './ToolCreateSuccess';
import { PayloadSpotlight } from './ToolMiniStepCards';
import ToolPlayground, { ToolPlaygroundHandle } from './ToolPlayground';
type ToolCreateStep = 'integrations' | 'prompt' | 'review' | 'success';
interface ToolCreateStepperProps {
onComplete?: () => void;
}
class ExtendedSuperglueClient extends SuperglueClient {
async generateInstructions(integrations: IntegrationInput[]): Promise<string[]> {
let instructions: string[] = [];
const response = await fetch(`${this['endpoint']}/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this['apiKey']}`
},
body: JSON.stringify({
query: `
query GenerateInstructions($integrations: [IntegrationInput!]!) {
generateInstructions(integrations: $integrations)
}
`,
variables: { integrations }
})
});
const result = await response.json();
instructions = result.data.generateInstructions;
if (instructions.length === 1 && instructions[0].startsWith('Error:')) {
throw new Error(instructions[0].replace('Error: ', ''));
}
return instructions;
}
}
export function ToolCreateStepper({ onComplete }: ToolCreateStepperProps) {
const [step, setStep] = useState<ToolCreateStep>('integrations');
const [isBuilding, setIsBuilding] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isExecuting, setIsExecuting] = useState(false);
const [isStopping, setIsStopping] = useState(false);
const { toast } = useToast();
const router = useRouter();
const searchParams = useSearchParams();
const superglueConfig = useConfig();
const playgroundRef = useRef<ToolPlaygroundHandle>(null);
const { integrations, pendingDocIds, loading, setPendingDocIds, refreshIntegrations } = useIntegrations();
const preselectedIntegrationId = searchParams.get('integration');
const [instruction, setInstruction] = useState('');
const [payload, setPayload] = useState('{}');
const [currentTool, setCurrentTool] = useState<Tool | null>(null);
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [selfHealingEnabled, setSelfHealingEnabled] = useState(true);
const [shouldStopExecution, setShouldStopExecution] = useState(false);
// File upload state
const [uploadedFiles, setUploadedFiles] = useState<UploadedFileInfo[]>([]);
const [totalFileSize, setTotalFileSize] = useState(0);
const [isProcessingFiles, setIsProcessingFiles] = useState(false);
const [filePayloads, setFilePayloads] = useState<Record<string, any>>({});
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>(() => {
return preselectedIntegrationId && integrations.some(i => i.id === preselectedIntegrationId)
? [preselectedIntegrationId]
: [];
});
const [integrationSearch, setIntegrationSearch] = useState('');
const [showIntegrationForm, setShowIntegrationForm] = useState(false);
const [integrationFormEdit, setIntegrationFormEdit] = useState<Integration | null>(null);
const [validationErrors, setValidationErrors] = useState<Record<string, boolean>>({});
const client = useMemo(() => new ExtendedSuperglueClient({
endpoint: superglueConfig.superglueEndpoint,
apiKey: superglueConfig.superglueApiKey,
}), [superglueConfig.superglueEndpoint, superglueConfig.superglueApiKey]);
const { waitForIntegrationReady } = useMemo(() => ({
waitForIntegrationReady: (integrationIds: string[]) => {
const clientAdapter = {
getIntegration: (id: string) => client.getIntegration(id)
};
return waitForIntegrationProcessing(clientAdapter, integrationIds);
}
}), [client]);
useEffect(() => {
if (preselectedIntegrationId && integrations.length > 0 && selectedIntegrationIds.length === 0) {
if (integrations.some(i => i.id === preselectedIntegrationId)) {
setSelectedIntegrationIds([preselectedIntegrationId]);
}
}
}, [preselectedIntegrationId, integrations, selectedIntegrationIds.length]);
useEffect(() => {
if (step === 'prompt') {
setCurrentTool(null);
}
if (step !== 'prompt') {
setValidationErrors({});
}
}, [step]);
const highlightJson = (code: string) => {
return Prism.highlight(code, Prism.languages.json, 'json');
};
const hasDocumentation = (integration: Integration) => {
return !!(integration.documentationUrl?.trim() && !pendingDocIds.has(integration.id));
};
const handleIntegrationFormSave = async (integration: Integration): Promise<Integration | null> => {
setShowIntegrationForm(false);
setIntegrationFormEdit(null);
try {
const mode = integrationFormEdit ? UpsertMode.UPDATE : UpsertMode.CREATE;
const savedIntegration = await client.upsertIntegration(integration.id, integration, mode);
const willTriggerDocFetch = needsUIToTriggerDocFetch(savedIntegration, integrationFormEdit);
if (willTriggerDocFetch) {
setPendingDocIds(prev => new Set([...prev, savedIntegration.id]));
waitForIntegrationReady([savedIntegration.id]).then(() => {
setPendingDocIds(prev => new Set([...prev].filter(id => id !== savedIntegration.id)));
}).catch((error) => {
console.error('Error waiting for docs:', error);
setPendingDocIds(prev => new Set([...prev].filter(id => id !== savedIntegration.id)));
});
}
setSelectedIntegrationIds(ids => {
const newIds = ids.filter(id => id !== (integrationFormEdit?.id || integration.id));
newIds.push(savedIntegration.id);
return newIds;
});
await refreshIntegrations();
return savedIntegration;
} catch (error) {
console.error('Error saving integration:', error);
toast({
title: 'Error Saving Integration',
description: error instanceof Error ? error.message : 'Failed to save integration',
variant: 'destructive',
});
return null;
}
};
const handleIntegrationFormCancel = () => {
setShowIntegrationForm(false);
setIntegrationFormEdit(null);
};
const handleSaveTool = async (tool: Tool) => {
try {
setIsSaving(true);
const currentToolState = playgroundRef.current?.getCurrentTool();
const toolToSave = currentToolState || tool;
const saved = await client.upsertWorkflow(toolToSave.id, toolToSave as any);
if (!saved) throw new Error('Failed to save tool');
toast({
title: 'Tool saved',
description: `"${saved.id}" saved successfully`
});
setCurrentTool(saved);
setStep('success');
} catch (e: any) {
toast({
title: 'Error saving tool',
description: e.message || 'Unknown error',
variant: 'destructive'
});
throw e;
} finally {
setIsSaving(false);
}
};
const handleExecuteTool = async () => {
try {
setIsExecuting(true);
setShouldStopExecution(false);
setIsStopping(false);
await playgroundRef.current?.executeTool({ selfHealing: selfHealingEnabled });
} finally {
setIsExecuting(false);
setIsStopping(false);
}
};
const handleStopExecution = () => {
setShouldStopExecution(true);
setIsStopping(true);
toast({
title: "Stopping tool",
description: "Tool will stop after the current step completes",
});
};
const handleNext = async () => {
const steps: ToolCreateStep[] = ['integrations', 'prompt', 'review', 'success'];
const currentIndex = steps.indexOf(step);
if (step === 'integrations') {
if (selectedIntegrationIds.length > 0) {
setIsGeneratingSuggestions(true);
try {
await handleGenerateInstructions();
} finally {
setIsGeneratingSuggestions(false);
}
}
setStep(steps[currentIndex + 1]);
} else if (step === 'prompt') {
const errors: Record<string, boolean> = {};
if (!instruction.trim()) errors.instruction = true;
try {
JSON.parse(payload || '{}');
} catch {
errors.payload = true;
}
setValidationErrors(errors);
if (Object.keys(errors).length > 0) {
toast({
title: 'Validation Error',
description: 'Please fix the errors below before continuing.',
variant: 'destructive',
});
return;
}
setIsBuilding(true);
try {
const parsedPayload = JSON.parse(payload || '{}');
const effectivePayload = { ...parsedPayload, ...filePayloads };
const response = await client.buildWorkflow({
instruction: instruction,
payload: effectivePayload,
integrationIds: selectedIntegrationIds,
save: false
});
if (!response) {
throw new Error('Failed to build tool');
}
setCurrentTool(response);
setStep(steps[currentIndex + 1]);
} catch (error: any) {
console.error('Error building tool:', error);
toast({
title: 'Error Building Tool',
description: error.message,
variant: 'destructive',
});
} finally {
setIsBuilding(false);
}
} else if (step === 'success') {
if (onComplete) {
onComplete();
} else {
router.push('/');
}
}
};
const handleBack = () => {
const steps: ToolCreateStep[] = ['integrations', 'prompt', 'review', 'success'];
const currentIndex = steps.indexOf(step);
if (step === 'integrations') {
router.push('/configs');
return;
}
if (currentIndex > 0) {
setStep(steps[currentIndex - 1]);
}
};
const handleClose = () => {
if (onComplete) {
onComplete();
} else {
router.push('/'); // Default redirect
}
};
const toIntegrationInput = (i: Integration): IntegrationInput => ({
id: i.id,
urlHost: i.urlHost,
urlPath: i.urlPath,
documentationUrl: i.documentationUrl,
documentation: i.documentation,
credentials: i.credentials,
});
const handleGenerateInstructions = async () => {
if (selectedIntegrationIds.length === 0) {
return;
}
setIsGeneratingSuggestions(true);
try {
const selectedIntegrationInputs = selectedIntegrationIds
.map(id => integrations.find(i => i.id === id))
.filter(Boolean)
.map(toIntegrationInput);
try {
const suggestionsText = await client.generateInstructions(selectedIntegrationInputs);
const suggestionsArray = suggestionsText.filter(s => s.trim());
setSuggestions(suggestionsArray);
} catch (error: any) {
toast({
title: 'Error Connecting to LLM',
description: "Please check your LLM configuration. \nError Details: \n" + error.message,
variant: 'destructive',
});
setSuggestions([]);
}
} catch (error: any) {
toast({
title: 'Error Generating Suggestions',
description: error.message,
variant: 'destructive',
});
} finally {
setIsGeneratingSuggestions(false);
}
};
const handleFilesUpload = async (files: File[]) => {
setIsProcessingFiles(true);
try {
// Check total size limit
const newSize = files.reduce((sum, f) => sum + f.size, 0);
if (totalFileSize + newSize > MAX_TOTAL_FILE_SIZE) {
toast({
title: 'Size limit exceeded',
description: `Total file size cannot exceed ${formatBytes(MAX_TOTAL_FILE_SIZE)}`,
variant: 'destructive'
});
return;
}
const existingKeys = Object.keys(filePayloads);
const newFiles: UploadedFileInfo[] = [];
for (const file of files) {
try {
// Generate unique key
const baseKey = sanitizeFileName(file.name);
const key = generateUniqueKey(baseKey, [...existingKeys, ...newFiles.map(f => f.key)]);
const fileInfo: UploadedFileInfo = {
name: file.name,
size: file.size,
key,
status: 'processing'
};
newFiles.push(fileInfo);
setUploadedFiles(prev => [...prev, fileInfo]);
const extractResult = await client.extract({
file: file
});
if (!extractResult.success) {
throw new Error(extractResult.error || 'Failed to extract data');
}
const parsedData = extractResult.data;
setFilePayloads(prev => ({ ...prev, [key]: parsedData }));
existingKeys.push(key);
setUploadedFiles(prev => prev.map(f =>
f.key === key ? { ...f, status: 'ready' } : f
));
} catch (error: any) {
// Update file status with error
const fileInfo = newFiles.find(f => f.name === file.name);
if (fileInfo) {
setUploadedFiles(prev => prev.map(f =>
f.key === fileInfo.key
? { ...f, status: 'error', error: error.message }
: f
));
}
toast({
title: 'File processing failed',
description: `Failed to parse ${file.name}: ${error.message}`,
variant: 'destructive'
});
}
}
setTotalFileSize(prev => prev + newSize);
} finally {
setIsProcessingFiles(false);
}
};
const handleFileRemove = (key: string) => {
// Find the file to remove
const fileToRemove = uploadedFiles.find(f => f.key === key);
if (!fileToRemove) return;
// Update file payloads map
setFilePayloads(prev => {
const copy = { ...prev };
delete copy[key];
return copy;
});
// Update files list and total size
setUploadedFiles(prev => prev.filter(f => f.key !== key));
setTotalFileSize(prev => Math.max(0, prev - fileToRemove.size));
};
return (
<div className="flex-1 flex flex-col h-full p-6">
<div className="flex-none mb-4">
<div className="flex flex-col lg:flex-row items-center justify-between gap-4 mb-4">
<h1 className="text-2xl font-semibold">
{step === 'success' ? 'Tool Created!' : 'Create New Tool'}
</h1>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="bg-gradient-to-r from-blue-500/10 to-purple-500/10 border border-blue-200/50 hover:border-blue-300/50 text-blue-600 hover:text-blue-700 text-sm px-4 py-1 h-8 rounded-full animate-pulse shrink-0"
onClick={() => window.open('https://cal.com/superglue/onboarding', '_blank')}
>
✨ Get help from our team
</Button>
<Button variant="ghost" size="icon" className="shrink-0" onClick={handleClose}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<StepIndicator currentStep={step} steps={TOOL_CREATE_STEPS} />
</div>
<div className="flex-1 overflow-hidden flex flex-col">
<div className="overflow-y-auto px-1 min-h-0">
{step === 'integrations' && (
<div className="space-y-4">
<div className="mb-4 flex items-center justify-between gap-4">
<h3 className="font-medium">
Select one or more integrations to use in your tool. You can add new integrations as needed.
</h3>
<Button variant="outline" size="sm" className="h-9 shrink-0" onClick={() => setShowIntegrationForm(true)}>
<Plus className="mr-2 h-4 w-4" /> Add Integration
</Button>
</div>
<div className="overflow-y-auto">
{loading ? (
<div className="h-full bg-background" />
) : integrations.length === 0 ? (
<div className="h-[320px] flex items-center justify-center bg-background">
<p className="text-sm text-muted-foreground italic">
No integrations added yet. Define the APIs or data sources your tool will use.
</p>
</div>
) : (
<div className="gap-2 flex flex-col">
<div className="flex items-center justify-between py-2 pr-4 text-sm font-medium text-foreground border-b gap-4">
<Input
placeholder="Search integrations..."
value={integrationSearch}
onChange={e => setIntegrationSearch(e.target.value)}
className="h-8 text-sm flex-1"
/>
<div className="flex items-center gap-2">
{(() => {
const filteredIntegrations = integrations.filter(sys =>
integrationSearch === '' ||
sys.id.toLowerCase().includes(integrationSearch.toLowerCase()) ||
sys.urlHost.toLowerCase().includes(integrationSearch.toLowerCase()) ||
sys.urlPath.toLowerCase().includes(integrationSearch.toLowerCase())
);
const filteredIds = filteredIntegrations.map(i => i.id);
const selectedCount = filteredIds.filter(id => selectedIntegrationIds.includes(id)).length;
const allSelected = filteredIds.length > 0 && selectedCount === filteredIds.length;
return (
<span className="text-xs text-muted-foreground">
{allSelected || selectedCount > 0 ? 'Unselect all' : 'Select all'}
</span>
);
})()}
<button
className={cn(
"h-5 w-5 rounded border-2 transition-all duration-200 flex items-center justify-center",
(() => {
const filteredIntegrations = integrations.filter(sys =>
integrationSearch === '' ||
sys.id.toLowerCase().includes(integrationSearch.toLowerCase()) ||
sys.urlHost.toLowerCase().includes(integrationSearch.toLowerCase()) ||
sys.urlPath.toLowerCase().includes(integrationSearch.toLowerCase())
);
const filteredIds = filteredIntegrations.map(i => i.id);
const selectedCount = filteredIds.filter(id => selectedIntegrationIds.includes(id)).length;
const allSelected = filteredIds.length > 0 && selectedCount === filteredIds.length;
const someSelected = selectedCount > 0 && selectedCount < filteredIds.length;
if (allSelected || someSelected) {
return "bg-primary border-primary";
}
return "bg-background border-input hover:border-primary/50";
})()
)}
onClick={() => {
const filteredIntegrations = integrations.filter(sys =>
integrationSearch === '' ||
sys.id.toLowerCase().includes(integrationSearch.toLowerCase()) ||
sys.urlHost.toLowerCase().includes(integrationSearch.toLowerCase()) ||
sys.urlPath.toLowerCase().includes(integrationSearch.toLowerCase())
);
const filteredIds = filteredIntegrations.map(i => i.id);
const selectedCount = filteredIds.filter(id => selectedIntegrationIds.includes(id)).length;
const allSelected = filteredIds.length > 0 && selectedCount === filteredIds.length;
if (allSelected || selectedCount > 0) {
setSelectedIntegrationIds(ids => ids.filter(id => !filteredIds.includes(id)));
} else {
setSelectedIntegrationIds(ids => [...new Set([...ids, ...filteredIds])]);
}
}}
>
{(() => {
const filteredIntegrations = integrations.filter(sys =>
integrationSearch === '' ||
sys.id.toLowerCase().includes(integrationSearch.toLowerCase()) ||
sys.urlHost.toLowerCase().includes(integrationSearch.toLowerCase()) ||
sys.urlPath.toLowerCase().includes(integrationSearch.toLowerCase())
);
const filteredIds = filteredIntegrations.map(i => i.id);
const selectedCount = filteredIds.filter(id => selectedIntegrationIds.includes(id)).length;
const allSelected = filteredIds.length > 0 && selectedCount === filteredIds.length;
const someSelected = selectedCount > 0 && selectedCount < filteredIds.length;
if (allSelected) {
return <Check className="h-3 w-3 text-primary-foreground" />;
} else if (someSelected) {
return <div className="h-0.5 w-2.5 bg-primary-foreground" />;
}
return null;
})()}
</button>
</div>
</div>
{selectedIntegrationIds.length === 0 && integrations.length > 0 && (
<div>
<div className="text-xs text-muted-foreground flex items-center gap-1.5 bg-muted/50 py-2 px-4 rounded-md">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
No integrations selected - you can create transform-only tools or add integrations for API calls
</div>
</div>
)}
{(() => {
const filteredIntegrations = integrations.filter(sys =>
integrationSearch === '' ||
sys.id.toLowerCase().includes(integrationSearch.toLowerCase()) ||
sys.urlHost.toLowerCase().includes(integrationSearch.toLowerCase()) ||
sys.urlPath.toLowerCase().includes(integrationSearch.toLowerCase())
);
return (
<>
{filteredIntegrations.map(sys => {
const selected = selectedIntegrationIds.includes(sys.id);
return (
<div
key={sys.id}
className={cn(
"flex items-center justify-between rounded-md px-4 py-3 transition-all duration-200 cursor-pointer",
selected
? "bg-primary/10 dark:bg-primary/40 border border-primary/50 dark:border-primary/60 hover:bg-primary/15 dark:hover:bg-primary/25"
: "bg-background border border-transparent hover:bg-accent/50 hover:border-border"
)}
onClick={() => {
if (selected) {
setSelectedIntegrationIds(ids => ids.filter(i => i !== sys.id));
} else {
setSelectedIntegrationIds(ids => [...ids, sys.id]);
}
}}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
{(() => {
const iconName = getIntegrationIconName(sys);
const icon = iconName ? getSimpleIcon(iconName) : null;
return icon ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill={`#${icon.hex}`}
className="flex-shrink-0"
>
<path d={icon.path} />
</svg>
) : (
<Globe className="h-5 w-5 flex-shrink-0 text-foreground" />
);
})()}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate max-w-[200px]">{sys.id}</span>
<span className="text-xs text-muted-foreground truncate max-w-[240px]">
{composeUrl(sys.urlHost, sys.urlPath)}
</span>
</div>
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<DocStatus
pending={pendingDocIds.has(sys.id)}
hasDocumentation={hasDocumentation(sys)}
/>
{(() => {
const badge = getAuthBadge(sys);
const colorClasses = {
blue: 'text-blue-800 dark:text-blue-300 bg-blue-500/10',
amber: 'text-amber-800 dark:text-amber-300 bg-amber-500/10',
green: 'text-green-800 dark:text-green-300 bg-green-500/10'
};
return (
<span className={`text-xs ${colorClasses[badge.color]} px-2 py-0.5 rounded flex items-center gap-1`}>
{badge.icon === 'clock' ? <Clock className="h-3 w-3" /> : <Key className="h-3 w-3" />}
{badge.label}
</span>
);
})()}
</div>
</div>
</div>
<div className="flex gap-2 items-center">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed"
onClick={e => {
e.stopPropagation();
setIntegrationFormEdit(sys);
setShowIntegrationForm(true);
}}
disabled={pendingDocIds.has(sys.id)}
title={pendingDocIds.has(sys.id) ? "Documentation is being processed" : "Edit integration"}
>
<Pencil className="h-4 w-4" />
</Button>
<button
className={cn(
"h-5 w-5 rounded border-2 transition-all duration-200 flex items-center justify-center",
selected
? "bg-primary border-primary"
: "bg-background border-input hover:border-primary/50"
)}
onClick={(e) => {
e.stopPropagation();
if (selected) {
setSelectedIntegrationIds(ids => ids.filter(i => i !== sys.id));
} else {
setSelectedIntegrationIds(ids => [...ids, sys.id]);
}
}}
>
{selected && <Check className="h-3 w-3 text-primary-foreground" />}
</button>
</div>
</div>
);
})}
{filteredIntegrations.length === 0 && integrationSearch.trim() !== '' && (
<div
className="flex items-center justify-between rounded-md px-4 py-3 transition-all duration-200 cursor-pointer bg-background border border-dashed border-muted-foreground/30 hover:bg-accent/50 hover:border-muted-foreground/50"
onClick={() => setShowIntegrationForm(true)}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="h-5 w-5 flex-shrink-0 rounded-full border-2 border-dashed border-muted-foreground/50 flex items-center justify-center">
<Plus className="h-3 w-3 text-muted-foreground" />
</div>
<div className="flex flex-col min-w-0">
<span className="font-medium text-muted-foreground">
Create "{integrationSearch}" integration
</span>
<span className="text-xs text-muted-foreground">
Add a new integration for this API
</span>
</div>
</div>
<div className="flex gap-2 items-center">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={e => {
e.stopPropagation();
setShowIntegrationForm(true);
}}
title="Create new integration"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
);
})()}
</div>
)}
</div>
{showIntegrationForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div className="bg-background rounded-xl max-w-2xl w-full p-0">
<IntegrationForm
modal={true}
integration={integrationFormEdit || undefined}
onSave={handleIntegrationFormSave}
onCancel={handleIntegrationFormCancel}
integrationOptions={integrationOptions}
getSimpleIcon={getSimpleIcon}
/>
</div>
</div>
)}
</div>
)}
{step === 'prompt' && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="instruction">Tool Instruction*</Label>
<HelpTooltip text="Describe what you want this tool to achieve using the integrations you defined. Be specific!" />
<div className="relative">
<Textarea
id="instruction"
value={instruction}
onChange={(e) => {
setInstruction(e.target.value);
if (e.target.value.trim()) {
setValidationErrors(prev => ({ ...prev, instruction: false }));
}
}}
placeholder="e.g., 'Fetch customer details from CRM using the input email, then get their recent orders from productApi.'"
className={cn("min-h-64", validationErrors.instruction && inputErrorStyles)}
/>
{suggestions.length > 0 && !instruction && (
<div className="absolute bottom-0 p-3 pointer-events-none w-full">
<div className="flex gap-2 overflow-x-auto whitespace-nowrap w-full pointer-events-auto">
{suggestions.map((suggestion, index) => (
<Button
key={index}
variant="outline"
className="text-sm py-2 px-4 h-auto font-normal bg-background/80 hover:bg-accent hover:text-accent-foreground transition-colors flex items-center gap-2 pointer-events-auto"
onClick={() => setInstruction(suggestion)}
>
<ArrowRight className="h-3 w-3" />
{suggestion}
</Button>
))}
</div>
</div>
)}
</div>
{validationErrors.instruction && (
<p className="text-sm text-destructive mt-1">Tool instruction is required</p>
)}
</div>
{isGeneratingSuggestions && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
<div className="space-y-1">
<Label htmlFor="payload">Initial Payload (Optional)</Label>
<HelpTooltip text="Provide the initial payload for the tool. Uploaded files (CSV, JSON, XML, Excel) will be automatically parsed and merged with the manual payload when the tool executes." />
<div className={cn(
validationErrors.payload && "ring-2 ring-destructive ring-offset-2"
)}>
<PayloadSpotlight
payloadText={payload}
inputSchema={null}
onChange={(code) => {
setPayload(code);
try {
JSON.parse(code || '{}');
setValidationErrors(prev => ({ ...prev, payload: false }));
} catch (e) {
setValidationErrors(prev => ({ ...prev, payload: true }));
}
}}
onInputSchemaChange={() => { }}
readOnly={false}
onFilesUpload={handleFilesUpload}
uploadedFiles={uploadedFiles}
onFileRemove={handleFileRemove}
isProcessingFiles={isProcessingFiles}
totalFileSize={totalFileSize}
/>
</div>
{validationErrors.payload && (
<p className="text-xs text-destructive mt-1">Invalid JSON format</p>
)}
</div>
</div>
)}
{step === 'review' && currentTool && (
<div className="w-full">
<ToolPlayground
ref={playgroundRef}
embedded={true}
initialTool={currentTool}
initialPayload={payload}
initialInstruction={instruction}
integrations={integrations}
onSave={handleSaveTool}
onInstructionEdit={() => setStep('prompt')}
selfHealingEnabled={selfHealingEnabled}
onSelfHealingChange={setSelfHealingEnabled}
shouldStopExecution={shouldStopExecution}
onStopExecution={handleStopExecution}
uploadedFiles={uploadedFiles}
onFilesUpload={handleFilesUpload}
onFileRemove={handleFileRemove}
isProcessingFiles={isProcessingFiles}
totalFileSize={totalFileSize}
filePayloads={filePayloads}
headerActions={(
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 mr-2">
<Label htmlFor="wcs-selfHealing" className="text-xs flex items-center gap-1">
<span>Self-healing</span>
</Label>
<div className="flex items-center">
<Switch
id="wcs-selfHealing"
className="custom-switch"
checked={selfHealingEnabled}
onCheckedChange={setSelfHealingEnabled}
/>
<div className="ml-1 flex items-center">
<HelpTooltip text="Enable self-healing during execution. Slower, but can auto-fix failures in tool steps and transformation code." />
</div>
</div>
</div>
{isExecuting ? (
<Button
variant="destructive"
onClick={handleStopExecution}
disabled={isSaving || isStopping}
className="h-9 px-4"
>
{isStopping ? "Stopping..." : "Stop Execution"}
</Button>
) : (
<Button
variant="success"
onClick={handleExecuteTool}
disabled={isSaving || isExecuting}
className="h-9 px-4"
>
Test Tool
</Button>
)}
<Button
variant="default"
onClick={() => playgroundRef.current?.saveTool()}
disabled={isSaving}
className="h-9 px-5 shadow-md border border-primary/40"
>
{isSaving ? "Saving..." : "Save & Complete"}
</Button>
</div>
)}
/>
</div>
)}
{step === 'success' && currentTool && (
<div className="space-y-4">
<p className="text-lg font-medium">
Tool{' '}
<span className="font-mono text-base bg-muted px-2 py-0.5 rounded">
{currentTool.id}
</span>{' '}
created successfully!
</p>
<p>
You can now use this tool ID in the "Tools" page or call it via the API/SDK.
</p>
<ToolCreateSuccess
currentTool={currentTool}
credentials={
Object.values(integrations).reduce((acc, sys: any) => {
return {
...acc,
...Object.entries(sys.credentials || {}).reduce(
(obj, [name, value]) => ({ ...obj, [`${sys.id}_${name}`]: value }),
{}
),
};
}, {})
}
payload={(() => {
try {
return JSON.parse(payload || '{}');
} catch {
return {};
}
})()}
/>
<div className="flex gap-2 mt-6">
<Button variant="outline" onClick={() => router.push(`/tools/${currentTool.id}`)}>
Go to Tool
</Button>
<Button variant="outline" onClick={() => router.push('/')}>
View All Tools
</Button>
</div>
</div>
)}
</div>
</div>
<div className="flex-none mt-4 pt-4 border-t flex justify-between items-center">
<Button
variant="outline"
onClick={handleBack}
disabled={
(step === 'integrations' && showIntegrationForm) ||
isBuilding || isSaving
}
>
Back
</Button>
<div className="flex gap-2">
{step !== 'review' && step !== 'success' && (
<Button
onClick={handleNext}
disabled={
isBuilding ||
isSaving ||
isGeneratingSuggestions
}
>
{isBuilding ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Building...</> :
isSaving ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving...</> :
isGeneratingSuggestions ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Generating...</> :
'Next'}
</Button>
)}
</div>
</div>
</div>
);
}