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 type { Settings } from '../../api'; 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; onRecipeSaved?: (savedRecipeId: string) => void; } export default function CreateEditRecipeModal({ isOpen, onClose, recipe, isCreateMode = false, recipeId, onRecipeSaved, }: 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) : '', model: recipe.settings?.goose_model ?? undefined, provider: recipe.settings?.goose_provider ?? undefined, extensions: recipe.extensions || undefined, subRecipes: (recipe.sub_recipes || []).map((sr) => ({ name: sr.name, path: sr.path, description: sr.description || undefined, values: sr.values || undefined, sequential_when_repeated: sr.sequential_when_repeated ?? false, })), }; } return { title: '', description: '', instructions: '', prompt: '', activities: [], parameters: [], jsonSchema: '', model: undefined, provider: undefined, extensions: undefined, subRecipes: [], }; }, [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); const [model, setModel] = useState(form.state.values.model); const [provider, setProvider] = useState(form.state.values.provider); const [extensions, setExtensions] = useState(form.state.values.extensions); const [subRecipes, setSubRecipes] = useState(form.state.values.subRecipes); // 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); setModel(form.state.values.model); setProvider(form.state.values.provider); setExtensions(form.state.values.extensions); setSubRecipes(form.state.values.subRecipes); }); }, [form]); const [copied, setCopied] = useState(false); const [isSaving, setIsSaving] = useState(false); // 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 } } // Format subrecipes for API (convert from form data to API format) const formattedSubRecipes = subRecipes.length > 0 ? subRecipes.map((subRecipe) => ({ name: subRecipe.name, path: subRecipe.path, description: subRecipe.description || undefined, values: subRecipe.values && Object.keys(subRecipe.values).length > 0 ? subRecipe.values : undefined, sequential_when_repeated: subRecipe.sequential_when_repeated, })) : undefined; const cleanedExtensions = extensions?.map( (extension: ExtensionConfig & { envs?: unknown; enabled?: boolean }) => { const { envs: _envs, enabled: _enabled, ...rest } = extension; return rest; } ) as ExtensionConfig[] | undefined; const mergedSettings: Settings = { ...(recipe?.settings || {}), }; if (model !== undefined) { mergedSettings.goose_model = model || null; } else if ('goose_model' in mergedSettings) { delete mergedSettings.goose_model; } if (provider !== undefined) { mergedSettings.goose_provider = provider || null; } else if ('goose_provider' in mergedSettings) { delete mergedSettings.goose_provider; } const settings = Object.values(mergedSettings).some( (value) => value !== undefined && value !== null ) ? mergedSettings : undefined; return { ...recipe, title, description, instructions, activities, prompt: prompt || undefined, parameters: formattedParameters, response: responseConfig, sub_recipes: formattedSubRecipes, extensions: cleanedExtensions, settings, }; }, [ recipe, title, description, instructions, activities, prompt, parameters, jsonSchema, subRecipes, model, provider, extensions, ]); 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, subRecipes, model, provider, extensions, 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(); const { id: savedRecipeId } = await saveRecipe(recipe, recipeId); if (onRecipeSaved) { onRecipeSaved(savedRecipeId); } 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(); const { id: savedId } = await saveRecipe(recipe, recipeId); onClose(true); window.electron.createChatWindow({ recipeId: savedId }); 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 */}
); }