Skip to main content
Glama
ToolBuilder.tsx47.2 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 { FileChip } from '@/src/components/ui/FileChip'; import { useToast } from '@/src/hooks/use-toast'; import { needsUIToTriggerDocFetch } from '@/src/lib/client-utils'; import { ExtendedSuperglueClient } from '@/src/lib/extended-superglue-client'; import { formatBytes, generateUniqueKey, MAX_TOTAL_FILE_SIZE_TOOLS, processAndExtractFile, sanitizeFileName, type UploadedFileInfo } from '@/src/lib/file-utils'; import { cn, composeUrl, getIntegrationIcon as getIntegrationIconName, getSimpleIcon, inputErrorStyles } from '@/src/lib/general-utils'; import { tokenRegistry } from '@/src/lib/token-registry'; import { Integration, IntegrationInput, Workflow as Tool, UpsertMode } from '@superglue/client'; import { ALLOWED_FILE_EXTENSIONS, generateDefaultFromSchema, integrationOptions } from "@superglue/shared"; import { waitForIntegrationProcessing } from '@superglue/shared/utils'; import { Validator } from 'jsonschema'; import { Check, Clock, FileJson, FileWarning, Globe, Key, Loader2, Paperclip, Pencil, Plus, Wrench, X } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { JsonCodeEditor } from '../editors/JsonCodeEditor'; import JsonSchemaEditor from '../editors/JsonSchemaEditor'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { Switch } from '../ui/switch'; import { Tabs, TabsList, TabsTrigger } from '../ui/tabs'; import { Textarea } from '../ui/textarea'; type ToolBuilderView = 'integrations' | 'instructions'; export interface BuildContext { integrationIds: string[]; instruction: string; payload: string; responseSchema: string; inputSchema: string | null; enforceInputSchema: boolean; uploadedFiles: UploadedFileInfo[]; filePayloads: Record<string, any>; } interface ToolBuilderProps { initialView?: ToolBuilderView; initialIntegrationIds?: string[]; initialInstruction?: string; initialPayload?: string; initialResponseSchema?: string; initialInputSchema?: string | null; initialFiles?: UploadedFileInfo[]; onToolBuilt: (tool: Tool, context: BuildContext) => void; onCancel?: () => void; mode?: 'build' | 'rebuild'; } // Check if a response schema is meaningful (not just empty or basic structure) const isMeaningfulResponseSchema = (schemaText: string | null): boolean => { if (!schemaText || !schemaText.trim()) { return false; } if (schemaText.trim() === '{}') { return false; } try { const schema = JSON.parse(schemaText); // Try to generate a default - if it fails or is empty, schema is not meaningful try { const defaultValue = generateDefaultFromSchema(schema); // Check if the generated default is an empty object if (typeof defaultValue === 'object' && defaultValue !== null) { return Object.keys(defaultValue).length > 0; } // If it's not an object or is null, consider it meaningful return true; } catch { // If we can't generate a default, it's not a meaningful schema return false; } } catch { // Invalid JSON return false; } }; export function ToolBuilder({ initialView = 'integrations', initialIntegrationIds = [], initialInstruction = '', initialPayload = '{}', initialResponseSchema = '', initialInputSchema = null, initialFiles = [], onToolBuilt, onCancel, mode = 'build' }: ToolBuilderProps) { const [view, setView] = useState<ToolBuilderView>(initialView); const [isBuilding, setIsBuilding] = useState(false); const { toast } = useToast(); const superglueConfig = useConfig(); const { integrations, pendingDocIds, loading, setPendingDocIds, refreshIntegrations } = useIntegrations(); const [instruction, setInstruction] = useState(initialInstruction); const [payload, setPayload] = useState(initialPayload); const [responseSchema, setResponseSchema] = useState(initialResponseSchema); const [inputSchema, setInputSchema] = useState<string | null>(initialInputSchema); const [enforceInputSchema, setEnforceInputSchema] = useState(true); // Always default to 'current' (generated from tool input) const [inputSchemaMode, setInputSchemaMode] = useState<'current' | 'custom'>('current'); const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false); const [suggestions, setSuggestions] = useState<string[]>([]); const [uploadedFiles, setUploadedFiles] = useState<UploadedFileInfo[]>(initialFiles); const [totalFileSize, setTotalFileSize] = useState(initialFiles.reduce((sum, f) => sum + f.size, 0)); const [isProcessingFiles, setIsProcessingFiles] = useState(false); const [filePayloads, setFilePayloads] = useState<Record<string, any>>({}); const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>(initialIntegrationIds); const [integrationSearch, setIntegrationSearch] = useState(''); const [showIntegrationForm, setShowIntegrationForm] = useState(false); const [integrationFormEdit, setIntegrationFormEdit] = useState<Integration | null>(null); const [validationErrors, setValidationErrors] = useState<Record<string, boolean>>({}); // Always start with all sections closed const [showPayloadSection, setShowPayloadSection] = useState(false); const [showFileUploadSection, setShowFileUploadSection] = useState(false); const [showResponseSchemaSection, setShowResponseSchemaSection] = useState(false); const [isPayloadValid, setIsPayloadValid] = useState(true); const textareaRef = useRef<HTMLTextAreaElement>(null); const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null); const client = useMemo(() => new ExtendedSuperglueClient({ endpoint: superglueConfig.superglueEndpoint, apiKey: tokenRegistry.getToken(), }), [superglueConfig.superglueEndpoint]); const { waitForIntegrationReady } = useMemo(() => ({ waitForIntegrationReady: (integrationIds: string[]) => { const clientAdapter = { getIntegration: (id: string) => client.getIntegration(id) }; return waitForIntegrationProcessing(clientAdapter, integrationIds); } }), [client]); useEffect(() => { if (view === 'instructions' && selectedIntegrationIds.length > 0 && !isGeneratingSuggestions) { setSuggestions([]); handleGenerateInstructions(); } }, [selectedIntegrationIds, view, integrations]); useEffect(() => { if (view !== 'instructions') { setValidationErrors({}); } }, [view]); // Auto-resize textarea when instruction changes (including initial mount) useEffect(() => { if (textareaRef.current && instruction) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; } }, [instruction]); // Validate payload against custom input schema useEffect(() => { if (validationTimeoutRef.current) { clearTimeout(validationTimeoutRef.current); } validationTimeoutRef.current = setTimeout(() => { // Only validate if custom schema mode is active and schema is set if (enforceInputSchema && inputSchemaMode === 'custom' && inputSchema) { try { const payloadData = JSON.parse(payload || '{}'); const mergedPayload = { ...payloadData, ...filePayloads }; const schemaObj = JSON.parse(inputSchema); const validator = new Validator(); const result = validator.validate(mergedPayload, schemaObj); setIsPayloadValid(result.valid); } catch (e) { setIsPayloadValid(false); } } else { setIsPayloadValid(true); } }, 300); return () => { if (validationTimeoutRef.current) { clearTimeout(validationTimeoutRef.current); } }; }, [payload, inputSchema, filePayloads, enforceInputSchema, inputSchemaMode]); 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); if (selectedIntegrationInputs.length === 0) { setIsGeneratingSuggestions(false); return; } 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 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 handleFilesUpload = async (files: File[]) => { setIsProcessingFiles(true); try { const newSize = files.reduce((sum, f) => sum + f.size, 0); if (totalFileSize + newSize > MAX_TOTAL_FILE_SIZE_TOOLS) { toast({ title: 'Size limit exceeded', description: `Total file size cannot exceed ${formatBytes(MAX_TOTAL_FILE_SIZE_TOOLS)}`, variant: 'destructive' }); return; } const existingKeys = Object.keys(filePayloads); const newFiles: UploadedFileInfo[] = []; for (const file of files) { try { const baseKey = sanitizeFileName(file.name, { removeExtension: true, lowercase: false }); 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 parsedData = await processAndExtractFile(file, client); setFilePayloads(prev => ({ ...prev, [key]: parsedData })); existingKeys.push(key); setUploadedFiles(prev => prev.map(f => f.key === key ? { ...f, status: 'ready' } : f )); } catch (error: any) { 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) => { const fileToRemove = uploadedFiles.find(f => f.key === key); if (!fileToRemove) return; setFilePayloads(prev => { const copy = { ...prev }; delete copy[key]; return copy; }); setUploadedFiles(prev => prev.filter(f => f.key !== key)); setTotalFileSize(prev => Math.max(0, prev - (fileToRemove.size || 0))); }; const handleBuildTool = async () => { const errors: Record<string, boolean> = {}; if (!instruction.trim()) errors.instruction = true; try { JSON.parse(payload || '{}'); } catch { errors.payload = true; } if (responseSchema && responseSchema.trim()) { try { JSON.parse(responseSchema); } catch { errors.responseSchema = true; } } setValidationErrors(errors); if (Object.keys(errors).length > 0) { toast({ title: 'Validation Error', description: 'Please fix the errors below before continuing.', variant: 'destructive', }); return; } setShowPayloadSection(false); setShowFileUploadSection(false); setShowResponseSchemaSection(false); setIsBuilding(true); try { const parsedPayload = JSON.parse(payload || '{}'); const effectivePayload = { ...parsedPayload, ...filePayloads }; const response = await client.buildWorkflow({ instruction: instruction, payload: effectivePayload, integrationIds: selectedIntegrationIds, responseSchema: responseSchema ? JSON.parse(responseSchema) : null, save: false }); if (!response) { throw new Error('Failed to build tool'); } const context: BuildContext = { integrationIds: selectedIntegrationIds, instruction, payload, responseSchema, inputSchema, enforceInputSchema, uploadedFiles, filePayloads }; onToolBuilt(response, context); } catch (error: any) { console.error('Error building tool:', error); toast({ title: 'Error Building Tool', description: error.message, variant: 'destructive', }); } finally { setIsBuilding(false); } }; const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { setInstruction(e.target.value); if (e.target.value.trim()) { setValidationErrors(prev => ({ ...prev, instruction: false })); } if (textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; } }; const handleNextFromIntegrations = () => { setView('instructions'); }; if (view === 'integrations') { return ( <div className="flex items-start justify-center pt-8"> <div className="w-full max-w-3xl mx-auto space-y-4"> <div className="text-center mb-6"> <h2 className="text-xl font-medium text-foreground mb-2"> Select integrations for your tool </h2> <p className="text-sm text-muted-foreground"> Choose one or more integrations, or choose none to create transform-only tools </p> </div> <div className="border rounded-2xl bg-card p-6 space-y-4"> <div className="flex items-center gap-3"> <Input placeholder="Search integrations..." value={integrationSearch} onChange={e => setIntegrationSearch(e.target.value)} className="h-10 flex-1" /> <Button variant="outline" size="sm" className="h-10 shrink-0" onClick={() => setShowIntegrationForm(true)}> <Plus className="mr-2 h-4 w-4" /> Add Integration </Button> </div> {loading ? ( <div className="h-[200px] bg-background" /> ) : integrations.length === 0 ? ( <div className="py-16 flex items-center justify-center"> <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="overflow-y-auto" style={{ maxHeight: 'calc(100vh - 450px)', minHeight: '200px' }}> <div className="space-y-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()) ); return ( <> {filteredIntegrations.map(sys => { const selected = selectedIntegrationIds.includes(sys.id); 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 ( <div key={sys.id} className={cn( "flex items-center justify-between rounded-lg 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-muted/30 border border-border hover:bg-muted/50 hover:border-border/80" )} 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 text-sm truncate">{sys.id}</span> <span className="text-xs text-muted-foreground truncate"> {composeUrl(sys.urlHost, sys.urlPath)} </span> </div> </div> <div className="flex gap-2 items-center"> <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> <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> )} <div className="flex justify-end mt-3"> <Button onClick={handleNextFromIntegrations} className="h-8 px-4 rounded-full flex-shrink-0" > {selectedIntegrationIds.length === 0 ? "Select None" : selectedIntegrationIds.length === 1 ? "Select 1 Integration" : `Select ${selectedIntegrationIds.length} Integrations`} </Button> </div> </div> {showIntegrationForm && typeof document !== 'undefined' && createPortal( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"> <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>, document.body )} </div> </div> ); } return ( <div className="flex flex-col items-center pt-8 h-full overflow-y-auto pb-8" style={{ scrollbarGutter: 'stable' }}> <div className="w-full max-w-3xl space-y-4"> <div className="text-center mb-4"> <h2 className="text-xl font-medium text-foreground"> What should your tool do for you? </h2> </div> <div className="flex flex-wrap gap-2 justify-center mb-4"> {selectedIntegrationIds.map(id => { const integration = integrations.find(i => i.id === id); if (!integration) return null; const iconName = getIntegrationIconName(integration); const icon = iconName ? getSimpleIcon(iconName) : null; return ( <button key={id} onClick={(e) => { e.preventDefault(); if (isBuilding) return; setSelectedIntegrationIds(ids => ids.filter(i => i !== id)); setSuggestions([]); if (selectedIntegrationIds.length > 1) { handleGenerateInstructions(); } }} disabled={isBuilding} className={cn( "group flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted border border-border transition-all", isBuilding ? "opacity-50 cursor-not-allowed" : "hover:bg-red-500/10 hover:border-red-500/50" )} title={isBuilding ? "Cannot modify while building" : "Click to remove"} > {icon ? ( <svg width="16" height="16" viewBox="0 0 24 24" fill={`#${icon.hex}`} className="flex-shrink-0" > <path d={icon.path} /> </svg> ) : ( <Globe className="h-4 w-4 flex-shrink-0 text-foreground" /> )} <span className="text-sm font-medium max-w-[120px] truncate"> {integration.id} </span> <X className="h-3 w-3 text-muted-foreground group-hover:text-red-500 transition-colors" /> </button> ); })} <button onClick={() => !isBuilding && setView('integrations')} disabled={isBuilding} className={cn( "flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted border border-dashed border-border transition-all", isBuilding ? "opacity-50 cursor-not-allowed" : "hover:bg-muted/80 hover:border-border/80" )} title={isBuilding ? "Cannot modify while building" : "Add integrations"} > <Plus className="h-4 w-4 flex-shrink-0 text-foreground" /> <span className="text-sm font-medium"> Add Integration </span> </button> </div> <div className="relative border rounded-2xl bg-card p-4"> <Textarea ref={textareaRef} id="instruction" value={instruction} onChange={handleTextareaChange} placeholder="Describe what you want this tool to achieve..." disabled={isBuilding} className={cn( "resize-none border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 scrollbar-thin scrollbar-thumb-rounded min-h-[80px]", validationErrors.instruction && inputErrorStyles, isBuilding && "opacity-50 cursor-not-allowed" )} rows={1} style={{ maxHeight: '200px', overflowY: instruction.split('\n').length > 8 ? 'auto' : 'hidden', scrollbarGutter: 'stable' }} /> <div className="flex justify-between items-center gap-2 mt-3"> <div className="flex gap-2"> <button onClick={() => { if (isBuilding) return; if (showFileUploadSection) setShowFileUploadSection(false); if (showResponseSchemaSection) setShowResponseSchemaSection(false); setShowPayloadSection(!showPayloadSection); if (!showPayloadSection && payload.trim() === '') { setPayload('{}'); setValidationErrors(prev => ({ ...prev, payload: false })); } }} disabled={isBuilding} className={cn( "text-xs px-3 py-1.5 rounded-full transition-all flex items-center gap-1.5 border", isBuilding && "opacity-50 cursor-not-allowed", (() => { const trimmedPayload = payload.trim(); const isEmptyPayload = !trimmedPayload || trimmedPayload === '{}'; if (isEmptyPayload) { return showPayloadSection ? "border-foreground/70 text-foreground hover:bg-accent/50" : "border-border text-muted-foreground hover:bg-accent/50"; } try { JSON.parse(trimmedPayload); return showPayloadSection ? "bg-[#FFD700]/40 border-[#FF8C00] text-foreground" : "bg-[#FFD700]/40 border-[#FFA500] text-foreground"; } catch { return showPayloadSection ? "bg-[#FFD700]/40 border-[#FF8C00] text-foreground" : "bg-[#FFD700]/40 border-[#FFA500] text-foreground"; } })() )} > <FileJson className="h-4 w-4" /> {(() => { const trimmedPayload = payload.trim(); const isEmptyPayload = !trimmedPayload || trimmedPayload === '{}'; // Check schema validation if custom schema is active if (!isPayloadValid && enforceInputSchema && inputSchemaMode === 'custom' && inputSchema) { return 'Input Does Not Match Schema'; } if (isEmptyPayload) { return 'Attach JSON Tool Input'; } try { JSON.parse(trimmedPayload); return 'JSON Tool Input Attached'; } catch { return 'Invalid Input JSON'; } })()} </button> <button onClick={() => { if (isBuilding) return; if (showPayloadSection) setShowPayloadSection(false); if (!showPayloadSection && payload.trim() === '') { setPayload('{}'); setValidationErrors(prev => ({ ...prev, payload: false })); } if (showResponseSchemaSection) setShowResponseSchemaSection(false); setShowFileUploadSection(!showFileUploadSection); }} disabled={isBuilding} className={cn( "text-xs px-3 py-1.5 rounded-full transition-all flex items-center gap-1.5 border", isBuilding && "opacity-50 cursor-not-allowed", uploadedFiles.length > 0 ? showFileUploadSection ? "bg-[#FFD700]/40 border-[#FF8C00] text-foreground" : "bg-[#FFD700]/40 border-[#FFA500] text-foreground" : showFileUploadSection ? "border-foreground/70 text-foreground hover:bg-accent/50" : "border-border text-muted-foreground hover:bg-accent/50" )} > <Paperclip className="h-4 w-4" /> {uploadedFiles.length > 0 ? `File Tool Input Attached (${uploadedFiles.length})` : 'Attach File Tool Input'} </button> <button onClick={() => { if (isBuilding) return; if (showPayloadSection) setShowPayloadSection(false); if (!showPayloadSection && payload.trim() === '') { setPayload('{}'); setValidationErrors(prev => ({ ...prev, payload: false })); } if (showFileUploadSection) setShowFileUploadSection(false); setShowResponseSchemaSection(!showResponseSchemaSection); }} disabled={isBuilding} className={cn( "text-xs px-3 py-1.5 rounded-full transition-all flex items-center gap-1.5 border", isBuilding && "opacity-50 cursor-not-allowed", (() => { const hasMeaningfulSchema = isMeaningfulResponseSchema(responseSchema); if (!hasMeaningfulSchema) { return showResponseSchemaSection ? "border-foreground/70 text-foreground hover:bg-accent/50" : "border-border text-muted-foreground hover:bg-accent/50"; } return showResponseSchemaSection ? "bg-[#FFD700]/40 border-[#FF8C00] text-foreground" : "bg-[#FFD700]/40 border-[#FFA500] text-foreground"; })() )} > <FileWarning className="h-4 w-4" /> {isMeaningfulResponseSchema(responseSchema) ? 'Tool Result Schema Defined' : 'Enforce Tool Result Schema'} </button> </div> <Button onClick={handleBuildTool} disabled={isBuilding || !instruction.trim() || !isPayloadValid} className="h-8 px-4 rounded-full flex-shrink-0 flex items-center gap-2" title={ !isPayloadValid ? 'Payload does not match custom input schema' : mode === 'rebuild' ? 'Rebuild Tool' : 'Build Tool' } > {isBuilding ? ( <> <Loader2 className="h-4 w-4 animate-spin" /> {mode === 'rebuild' ? 'Rebuilding...' : 'Building...'} </> ) : ( <> <Wrench className="h-4 w-4" /> {mode === 'rebuild' ? 'Rebuild' : 'Build'} </> )} </Button> </div> </div> {validationErrors.instruction && ( <p className="text-sm text-destructive text-center mt-2">Tool instruction is required</p> )} {showPayloadSection && ( <div className="space-y-3 border rounded-lg p-4 bg-card animate-fade-in mt-3" style={{ animationDelay: '0ms', animationFillMode: 'backwards' }}> <div className="space-y-3"> <div className="flex items-center justify-between"> <h4 className="font-medium text-sm">JSON Tool Input</h4> <div className="flex items-center gap-2"> <Label htmlFor="enforce-input-schema" className="text-xs cursor-pointer"> Enforce Input Schema </Label> <Switch id="enforce-input-schema" checked={enforceInputSchema} onCheckedChange={setEnforceInputSchema} className="custom-switch" /> </div> </div> <JsonCodeEditor value={payload} onChange={(val) => { setPayload(val); try { JSON.parse(val || ''); setValidationErrors(prev => ({ ...prev, payload: false })); } catch { setValidationErrors(prev => ({ ...prev, payload: true })); } }} minHeight="150px" maxHeight="300px" resizable={true} placeholder="{}" showValidation={true} /> {!isPayloadValid && enforceInputSchema && inputSchemaMode === 'custom' && inputSchema && ( <div className="mt-2 p-3 bg-destructive/10 border border-destructive/50 rounded-md"> <div className="flex items-start gap-2"> <FileWarning className="h-4 w-4 text-destructive mt-0.5 flex-shrink-0" /> <div className="space-y-1"> <p className="text-sm font-medium text-destructive"> Payload Does Not Match Custom Input Schema </p> <p className="text-xs text-destructive/90"> The JSON input above does not conform to your custom input schema. Fix the input or adjust the schema below. </p> </div> </div> </div> )} {enforceInputSchema && ( <div className="space-y-3 pt-3 border-t"> <h4 className="font-medium text-sm">Enforced Tool Input Schema</h4> <Tabs value={inputSchemaMode} onValueChange={(v) => { setInputSchemaMode(v as 'current' | 'custom'); if (v === 'custom' && !inputSchema) { setInputSchema('{"type":"object","properties":{}}'); } }} > <TabsList className="h-9 p-1 rounded-md w-full"> <TabsTrigger value="current" className="flex-1 h-full px-3 text-xs rounded-sm data-[state=active]:rounded-sm"> Use schema generated from tool input </TabsTrigger> <TabsTrigger value="custom" className="flex-1 h-full px-3 text-xs rounded-sm data-[state=active]:rounded-sm"> Use custom schema </TabsTrigger> </TabsList> </Tabs> {inputSchemaMode === 'custom' && ( <div className="space-y-2"> <p className="text-xs text-muted-foreground"> Define a JSON Schema here to validate the tool's input. </p> <JsonSchemaEditor value={inputSchema} onChange={setInputSchema} isOptional={false} showModeToggle={true} /> </div> )} </div> )} </div> </div> )} {showFileUploadSection && ( <div className="space-y-3 border rounded-lg p-4 bg-card animate-fade-in mt-3" style={{ animationDelay: '0ms', animationFillMode: 'backwards' }}> <div className="space-y-2"> <h4 className="font-medium text-sm">File Tool Input</h4> <input type="file" multiple accept={ALLOWED_FILE_EXTENSIONS.join(',')} onChange={async (e) => { const files = Array.from(e.target.files || []); if (files.length > 0) { await handleFilesUpload(files); } e.target.value = ''; }} className="hidden" id="file-upload-builder" /> {uploadedFiles.length > 0 && ( <div className="space-y-2 mb-3"> {uploadedFiles.map(file => ( <FileChip key={file.key} file={file} onRemove={handleFileRemove} size="default" rounded="md" showOriginalName={true} /> ))} </div> )} <div className="flex flex-col items-center gap-2"> <Button variant="outline" size="sm" onClick={() => document.getElementById('file-upload-builder')?.click()} disabled={isProcessingFiles} className="w-48" > {isProcessingFiles ? ( <> <Loader2 className="h-4 w-4 mr-2 animate-spin" /> Processing... </> ) : ( <> <Paperclip className="h-4 w-4 mr-2" /> Select Files </> )} </Button> <div className="text-xs text-muted-foreground text-center"> {formatBytes(totalFileSize)} / {formatBytes(MAX_TOTAL_FILE_SIZE_TOOLS)} </div> </div> </div> </div> )} {showResponseSchemaSection && ( <div className="border rounded-lg p-4 bg-card animate-fade-in mt-3" style={{ animationDelay: '0ms', animationFillMode: 'backwards' }}> <h4 className="font-medium text-sm mb-3">Tool Result Schema</h4> <p className="text-xs text-muted-foreground mt-2"> Define a JSON Schema to validate the tool's response </p> <JsonSchemaEditor value={responseSchema || null} onChange={(value) => { setResponseSchema(value || ''); if (value && value.trim()) { try { JSON.parse(value); setValidationErrors(prev => ({ ...prev, responseSchema: false })); } catch { setValidationErrors(prev => ({ ...prev, responseSchema: true })); } } else { setValidationErrors(prev => ({ ...prev, responseSchema: false })); } }} isOptional={true} showModeToggle={true} /> </div> )} </div> {suggestions.length > 0 && !instruction.trim() && !showPayloadSection && !showFileUploadSection && !showResponseSchemaSection && ( <div className="w-full max-w-4xl space-y-2 mt-4"> <p className="text-sm text-muted-foreground text-center">Suggestions</p> <div className="flex flex-wrap gap-2 justify-center"> {suggestions.map((suggestion, index) => ( <Button key={index} variant="outline" size="sm" onClick={() => setInstruction(suggestion)} className="text-sm h-auto py-2 px-4 font-normal animate-fade-in whitespace-normal text-left max-w-full" style={{ animationDelay: `${index * 150}ms`, animationFillMode: 'backwards' }} > {suggestion} </Button> ))} </div> </div> )} </div> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/superglue-ai/superglue'

If you have feedback or need assistance with the MCP directory API, please join our Discord server