import React, { useState, useEffect, useCallback } from 'react'; import { useForm } from '@tanstack/react-form'; import { Recipe, generateDeepLink, Parameter } from '../../recipe'; import { Check, ExternalLink, Play, Save, X } from 'lucide-react'; import { Geese } from '../icons/Geese'; import Copy from '../icons/Copy'; import { ExtensionConfig } from '../ConfigContext'; import { Button } from '../ui/button'; import { RecipeFormFields } from './shared/RecipeFormFields'; import { RecipeFormData } from './shared/recipeFormSchema'; import { toastSuccess, toastError } from '../../toasts'; import { saveRecipe } from '../../recipe/recipe_management'; import { errorMessage } from '../../utils/conversionUtils'; interface CreateEditRecipeModalProps { isOpen: boolean; onClose: (wasSaved?: boolean) => void; recipe?: Recipe; isCreateMode?: boolean; recipeId?: string | null; } export default function CreateEditRecipeModal({ isOpen, onClose, recipe, isCreateMode = false, recipeId, }: CreateEditRecipeModalProps) { const getInitialValues = React.useCallback((): RecipeFormData => { if (recipe) { return { title: recipe.title || '', description: recipe.description || '', instructions: recipe.instructions || '', prompt: recipe.prompt || '', activities: recipe.activities || [], parameters: recipe.parameters || [], jsonSchema: recipe.response?.json_schema ? JSON.stringify(recipe.response.json_schema, null, 2) : '', }; } return { title: '', description: '', instructions: '', prompt: '', activities: [], parameters: [], jsonSchema: '', }; }, [recipe]); const form = useForm({ defaultValues: getInitialValues(), }); // Helper functions to get values from form - using state to trigger re-renders const [title, setTitle] = useState(form.state.values.title); const [description, setDescription] = useState(form.state.values.description); const [instructions, setInstructions] = useState(form.state.values.instructions); const [prompt, setPrompt] = useState(form.state.values.prompt); const [activities, setActivities] = useState(form.state.values.activities); const [parameters, setParameters] = useState(form.state.values.parameters); const [jsonSchema, setJsonSchema] = useState(form.state.values.jsonSchema); // Subscribe to form changes to update local state useEffect(() => { return form.store.subscribe(() => { setTitle(form.state.values.title); setDescription(form.state.values.description); setInstructions(form.state.values.instructions); setPrompt(form.state.values.prompt); setActivities(form.state.values.activities); setParameters(form.state.values.parameters); setJsonSchema(form.state.values.jsonSchema); }); }, [form]); const [copied, setCopied] = useState(false); const [isSaving, setIsSaving] = useState(false); const [recipeExtensions] = useState(() => { return recipe?.extensions ?? undefined; }); // Reset form when recipe changes useEffect(() => { if (recipe) { const newValues = getInitialValues(); form.reset(newValues); } }, [recipe, form, getInitialValues]); const getCurrentRecipe = useCallback((): Recipe => { // Transform the internal parameters state into the desired output format. const formattedParameters = parameters.map((param) => { const formattedParam: Parameter = { key: param.key, input_type: param.input_type || 'string', requirement: param.requirement, description: param.description, }; // Add the 'default' key ONLY if the parameter is optional and has a default value. if (param.requirement === 'optional' && param.default) { formattedParam.default = param.default; } // Add options for select input type if (param.input_type === 'select' && param.options) { formattedParam.options = param.options.filter((opt) => opt.trim() !== ''); // Filter empty options when saving } return formattedParam; }); // Parse response schema if provided let responseConfig = undefined; if (jsonSchema && jsonSchema.trim()) { try { const parsedSchema = JSON.parse(jsonSchema); responseConfig = { json_schema: parsedSchema }; } catch (error) { console.warn('Invalid JSON schema provided:', error); // If JSON is invalid, don't include response config } } const extensions = recipeExtensions?.map((extension) => 'envs' in extension ? { ...extension, envs: undefined } : extension ) as ExtensionConfig[] | undefined; return { ...recipe, title, description, instructions, activities, prompt: prompt || undefined, parameters: formattedParameters, response: responseConfig, extensions, }; }, [ recipe, title, description, instructions, activities, prompt, parameters, jsonSchema, recipeExtensions, ]); const requiredFieldsAreFilled = () => { return title.trim() && description.trim() && (instructions.trim() || (prompt || '').trim()); }; const validateForm = () => { const basicValidation = title.trim() && description.trim() && (instructions.trim() || (prompt || '').trim()); // If JSON schema is provided, it must be valid if (jsonSchema && jsonSchema.trim()) { try { JSON.parse(jsonSchema); } catch { return false; // Invalid JSON schema fails validation } } return basicValidation; }; const [deeplink, setDeeplink] = useState(''); const [isGeneratingDeeplink, setIsGeneratingDeeplink] = useState(false); // Generate deeplink whenever recipe configuration changes useEffect(() => { let isCancelled = false; const generateLink = async () => { if ( !title.trim() || !description.trim() || (!instructions.trim() && !(prompt || '').trim()) ) { setDeeplink(''); return; } setIsGeneratingDeeplink(true); try { const currentRecipe = getCurrentRecipe(); const link = await generateDeepLink(currentRecipe); if (!isCancelled) { setDeeplink(link); } } catch (error) { console.error('Failed to generate deeplink:', error); if (!isCancelled) { setDeeplink('Error generating deeplink'); } } finally { if (!isCancelled) { setIsGeneratingDeeplink(false); } } }; generateLink(); return () => { isCancelled = true; }; }, [ title, description, instructions, prompt, activities, parameters, jsonSchema, recipeExtensions, getCurrentRecipe, ]); const handleCopy = () => { if (!deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink') { return; } navigator.clipboard .writeText(deeplink) .then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }) .catch((err) => { console.error('Failed to copy the text:', err); }); }; const handleSaveRecipeClick = async () => { if (!validateForm()) { toastError({ title: 'Validation Failed', msg: 'Please fill in all required fields and ensure JSON schema is valid.', }); return; } setIsSaving(true); try { const recipe = getCurrentRecipe(); await saveRecipe(recipe, recipeId); onClose(true); toastSuccess({ title: (recipe.title || '').trim(), msg: 'Recipe saved successfully', }); } catch (error) { console.error('Failed to save recipe:', error); toastError({ title: 'Save Failed', msg: `Failed to save recipe: ${errorMessage(error, 'Unknown error')}`, traceback: errorMessage(error), }); } finally { setIsSaving(false); } }; const handleSaveAndRunRecipeClick = async () => { if (!validateForm()) { toastError({ title: 'Validation Failed', msg: 'Please fill in all required fields and ensure JSON schema is valid.', }); return; } setIsSaving(true); try { const recipe = getCurrentRecipe(); let saved_recipe_id = await saveRecipe(recipe, recipeId); // Close modal first onClose(true); // Open recipe in a new window instead of navigating in the same window window.electron.createChatWindow( undefined, undefined, undefined, undefined, undefined, saved_recipe_id ); toastSuccess({ title: recipe.title, msg: 'Recipe saved and launched successfully', }); } catch (error) { console.error('Failed to save and run recipe:', error); toastError({ title: 'Save and Run Failed', msg: `Failed to save and run recipe: ${errorMessage(error, 'Unknown error')}`, traceback: errorMessage(error), }); } finally { setIsSaving(false); } }; if (!isOpen) return null; return (
{/* Header */}

{isCreateMode ? 'Create Recipe' : 'View/edit recipe'}

{isCreateMode ? 'Create a new recipe to define agent behavior and capabilities for reusable chat sessions.' : "You can edit the recipe below to change the agent's behavior in a new session."}{' '} Learn more

{/* Content */}
{/* Deep Link Display */} {requiredFieldsAreFilled() && (
Copy this link to share with friends or paste directly in Chrome to open
{isGeneratingDeeplink ? 'Generating deeplink...' : deeplink || 'Click to generate deeplink'}
)}
{/* Footer */}
); }