diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 2696c2b5e128..4e44796b943a 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -103,6 +103,8 @@ pub struct SaveRecipeRequest { #[derive(Debug, Serialize, ToSchema)] pub struct SaveRecipeResponse { id: String, + file_name: String, + file_path: String, } #[derive(Debug, Deserialize, ToSchema)] pub struct ParseRecipeRequest { @@ -459,9 +461,18 @@ async fn save_recipe( }; match local_recipes::save_recipe_to_file(request.recipe, file_path.clone()) { - Ok(save_file_path) => Ok(Json(SaveRecipeResponse { - id: short_id_from_path(&save_file_path.display().to_string()), - })), + Ok(save_file_path) => { + let file_name = save_file_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let file_path_str = save_file_path.display().to_string(); + Ok(Json(SaveRecipeResponse { + id: short_id_from_path(&file_path_str), + file_name, + file_path: file_path_str, + })) + } Err(e) => Err(ErrorResponse { message: e.to_string(), status: StatusCode::INTERNAL_SERVER_ERROR, diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 2e3671b272bb..ebefc12cfaa7 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -7562,9 +7562,17 @@ "SaveRecipeResponse": { "type": "object", "required": [ - "id" + "id", + "file_name", + "file_path" ], "properties": { + "file_name": { + "type": "string" + }, + "file_path": { + "type": "string" + }, "id": { "type": "string" } diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 6add7592e84e..4dc2143cc48d 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -1173,6 +1173,8 @@ export type SaveRecipeRequest = { }; export type SaveRecipeResponse = { + file_name: string; + file_path: string; id: string; }; diff --git a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx index 2ef546bdeb19..115066992a0e 100644 --- a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx +++ b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx @@ -20,6 +20,7 @@ interface CreateEditRecipeModalProps { recipe?: Recipe; isCreateMode?: boolean; recipeId?: string | null; + onRecipeSaved?: (savedRecipeId: string) => void; } export default function CreateEditRecipeModal({ @@ -28,6 +29,7 @@ export default function CreateEditRecipeModal({ recipe, isCreateMode = false, recipeId, + onRecipeSaved, }: CreateEditRecipeModalProps) { const getInitialValues = React.useCallback((): RecipeFormData => { if (recipe) { @@ -44,6 +46,13 @@ export default function CreateEditRecipeModal({ 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 { @@ -57,6 +66,7 @@ export default function CreateEditRecipeModal({ model: undefined, provider: undefined, extensions: undefined, + subRecipes: [], }; }, [recipe]); @@ -75,6 +85,7 @@ export default function CreateEditRecipeModal({ 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(() => { @@ -89,6 +100,7 @@ export default function CreateEditRecipeModal({ 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); @@ -137,6 +149,21 @@ export default function CreateEditRecipeModal({ } } + // 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; @@ -172,6 +199,7 @@ export default function CreateEditRecipeModal({ prompt: prompt || undefined, parameters: formattedParameters, response: responseConfig, + sub_recipes: formattedSubRecipes, extensions: cleanedExtensions, settings, }; @@ -184,6 +212,7 @@ export default function CreateEditRecipeModal({ prompt, parameters, jsonSchema, + subRecipes, model, provider, extensions, @@ -258,6 +287,7 @@ export default function CreateEditRecipeModal({ activities, parameters, jsonSchema, + subRecipes, model, provider, extensions, @@ -293,7 +323,11 @@ export default function CreateEditRecipeModal({ try { const recipe = getCurrentRecipe(); - await saveRecipe(recipe, recipeId); + const { id: savedRecipeId } = await saveRecipe(recipe, recipeId); + + if (onRecipeSaved) { + onRecipeSaved(savedRecipeId); + } onClose(true); @@ -327,9 +361,8 @@ export default function CreateEditRecipeModal({ try { const recipe = getCurrentRecipe(); - const savedId = await saveRecipe(recipe, recipeId); + const { id: savedId } = await saveRecipe(recipe, recipeId); - // Close modal first onClose(true); window.electron.createChatWindow({ recipeId: savedId }); diff --git a/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx b/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx index d77a65f9dc91..0266c61aba0c 100644 --- a/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx +++ b/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx @@ -40,6 +40,7 @@ export default function CreateRecipeFromSessionModal({ activities: [] as string[], parameters: [] as RecipeParameter[], jsonSchema: '', + subRecipes: [], recipeName: '', global: true, } as RecipeFormData, @@ -156,6 +157,20 @@ export default function CreateRecipeFromSessionModal({ setIsCreating(true); try { + const formattedSubRecipes = + formData.subRecipes.length > 0 + ? formData.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 recipe: Recipe = { title: formData.title, description: formData.description, @@ -180,9 +195,10 @@ export default function CreateRecipeFromSessionModal({ json_schema: JSON.parse(formData.jsonSchema), } : undefined, + sub_recipes: formattedSubRecipes, }; - const recipeId = await saveRecipe(recipe, null); + const { id: recipeId } = await saveRecipe(recipe, null); onRecipeCreated?.(recipe); onClose(); diff --git a/ui/desktop/src/components/recipes/__tests__/CreateRecipeFromSessionModal.test.tsx b/ui/desktop/src/components/recipes/__tests__/CreateRecipeFromSessionModal.test.tsx index 90e54b39267d..0b2ec726f30b 100644 --- a/ui/desktop/src/components/recipes/__tests__/CreateRecipeFromSessionModal.test.tsx +++ b/ui/desktop/src/components/recipes/__tests__/CreateRecipeFromSessionModal.test.tsx @@ -14,7 +14,7 @@ vi.mock('../../../toasts', () => ({ })); vi.mock('../../../recipe/recipe_management', () => ({ - saveRecipe: vi.fn(), + saveRecipe: vi.fn().mockResolvedValue({ id: 'mock-recipe-id', fileName: 'mock-recipe.yaml' }), })); vi.mock('../../ConfigContext', () => ({ diff --git a/ui/desktop/src/components/recipes/shared/CreateSubRecipeInline.tsx b/ui/desktop/src/components/recipes/shared/CreateSubRecipeInline.tsx new file mode 100644 index 000000000000..8be60b87fb66 --- /dev/null +++ b/ui/desktop/src/components/recipes/shared/CreateSubRecipeInline.tsx @@ -0,0 +1,309 @@ +import { useState, useCallback } from 'react'; +import { useForm } from '@tanstack/react-form'; +import { X, Save, Loader2 } from 'lucide-react'; +import { Button } from '../../ui/button'; +import { toastSuccess, toastError } from '../../../toasts'; +import { saveRecipe } from '../../../recipe/recipe_management'; +import { Recipe } from '../../../recipe'; +import { SubRecipeFormData } from './recipeFormSchema'; +import { useEscapeKey } from '../../../hooks/useEscapeKey'; +import KeyValueEditor from './KeyValueEditor'; + +interface CreateSubRecipeInlineProps { + isOpen: boolean; + onClose: () => void; + onSubRecipeSaved: (subRecipe: SubRecipeFormData) => void; + existingSubRecipes?: SubRecipeFormData[]; +} + +export default function CreateSubRecipeInline({ + isOpen, + onClose, + onSubRecipeSaved, + existingSubRecipes = [], +}: CreateSubRecipeInlineProps) { + useEscapeKey(isOpen, onClose); + + const form = useForm({ + defaultValues: { + title: '', + description: '', + instructions: '', + prompt: '', + activities: [], + parameters: [], + jsonSchema: '', + subRecipes: [], + }, + }); + + const [name, setName] = useState(''); + const [toolDescription, setToolDescription] = useState(''); + const [sequentialWhenRepeated, setSequentialWhenRepeated] = useState(false); + const [values, setValues] = useState>({}); + const [isSaving, setIsSaving] = useState(false); + + const handleSave = useCallback(async () => { + const formValues = form.state.values; + + if ( + !name.trim() || + !formValues.title.trim() || + !formValues.description.trim() || + !formValues.instructions.trim() + ) { + toastError({ + title: 'Validation Failed', + msg: 'Name, title, recipe description, and instructions are required.', + }); + return; + } + + const trimmedName = name.trim(); + if (existingSubRecipes.some((sr) => sr.name === trimmedName)) { + toastError({ + title: 'Duplicate Name', + msg: `A subrecipe named "${trimmedName}" already exists. Please use a unique name.`, + }); + return; + } + + setIsSaving(true); + try { + const recipe: Recipe = { + version: '1.0.0', + title: formValues.title.trim(), + description: formValues.description.trim(), + instructions: formValues.instructions.trim(), + }; + + const { filePath } = await saveRecipe(recipe, null); + + const subRecipe: SubRecipeFormData = { + name: trimmedName, + path: filePath, + description: toolDescription.trim() || undefined, + sequential_when_repeated: sequentialWhenRepeated, + values: Object.keys(values).length > 0 ? values : undefined, + }; + + toastSuccess({ + title: formValues.title.trim(), + msg: 'Subrecipe created successfully', + }); + + onSubRecipeSaved(subRecipe); + onClose(); + form.reset(); + setName(''); + setToolDescription(''); + setSequentialWhenRepeated(false); + setValues({}); + } catch (error) { + console.error('Failed to save subrecipe:', error); + + toastError({ + title: 'Save Failed', + msg: `Failed to save subrecipe: ${error instanceof Error ? error.message : 'Unknown error'}`, + }); + } finally { + setIsSaving(false); + } + }, [form, name, toolDescription, sequentialWhenRepeated, values, existingSubRecipes, onSubRecipeSaved, onClose]); + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+

Create New Subrecipe

+

+ Create a simple recipe to use as a callable tool in your main recipe +

+
+ +
+ + {/* Content */} +
+ {/* Name Field */} +
+ + setName(e.target.value)} + className="w-full p-3 border border-border-subtle rounded-lg bg-background-primary text-text-standard focus:outline-none focus:ring-2 focus:ring-ring" + placeholder="e.g., security_scan" + /> +

+ Unique identifier used to generate the tool name +

+
+ + {/* Title Field */} + + {(field) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + className="w-full p-3 border border-border-subtle rounded-lg bg-background-primary text-text-standard focus:outline-none focus:ring-2 focus:ring-ring" + placeholder="e.g., Security Analysis Tool" + /> +
+ )} +
+ + {/* Recipe Description Field */} + + {(field) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + className="w-full p-3 border border-border-subtle rounded-lg bg-background-primary text-text-standard focus:outline-none focus:ring-2 focus:ring-ring" + placeholder="What this recipe does when executed" + /> +
+ )} +
+ + {/* Instructions Field */} + + {(field) => ( +
+ +