diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index 2e0a9a4114aa..5497d3ba4120 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -3,8 +3,10 @@ import { useForm } from '@tanstack/react-form'; import { z } from 'zod'; import { Download } from 'lucide-react'; import { Button } from '../ui/button'; +import { Input } from '../ui/input'; import { Recipe, decodeRecipe } from '../../recipe'; import { saveRecipe } from '../../recipe/recipeStorage'; +import * as yaml from 'yaml'; import { toastSuccess, toastError } from '../../toasts'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { RecipeNameField, recipeNameSchema } from './shared/RecipeNameField'; @@ -17,17 +19,28 @@ interface ImportRecipeFormProps { } // Define Zod schema for the import form -const importRecipeSchema = z.object({ - deeplink: z - .string() - .min(1, 'Deeplink is required') - .refine( - (value) => value.trim().startsWith('goose://recipe?config='), - 'Invalid deeplink format. Expected: goose://recipe?config=...' - ), - recipeName: recipeNameSchema, - global: z.boolean(), -}); +const importRecipeSchema = z + .object({ + deeplink: z + .string() + .refine( + (value) => !value || value.trim().startsWith('goose://recipe?config='), + 'Invalid deeplink format. Expected: goose://recipe?config=...' + ), + yamlFile: z + .instanceof(File) + .nullable() + .refine((file) => { + if (!file) return true; + return file.size <= 1024 * 1024; + }, 'File is too large, max size is 1MB'), + recipeName: recipeNameSchema, + global: z.boolean(), + }) + .refine((data) => (data.deeplink && data.deeplink.trim()) || data.yamlFile, { + message: 'Either of deeplink or YAML file are required', + path: ['deeplink'], + }); export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportRecipeFormProps) { const [importing, setImporting] = useState(false); @@ -66,9 +79,23 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR } }; + const parseYamlFile = async (fileContent: string): Promise => { + const parsed = yaml.parse(fileContent); + + if (!parsed) { + throw new Error('YAML file is empty or contains invalid content'); + } + + // Handle both CLI format (flat structure) and Desktop format (nested under 'recipe' key) + const recipe = parsed.recipe || parsed; + + return recipe as Recipe; + }; + const importRecipeForm = useForm({ defaultValues: { deeplink: '', + yamlFile: null as File | null, recipeName: '', global: true, }, @@ -78,10 +105,18 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR onSubmit: async ({ value }) => { setImporting(true); try { - const recipe = await parseDeeplink(value.deeplink.trim()); + let recipe: Recipe; - if (!recipe) { - throw new Error('Invalid deeplink or recipe format'); + // Parse recipe from either deeplink or YAML file + if (value.deeplink && value.deeplink.trim()) { + const parsedRecipe = await parseDeeplink(value.deeplink.trim()); + if (!parsedRecipe) { + throw new Error('Invalid deeplink or recipe format'); + } + recipe = parsedRecipe; + } else { + const fileContent = await value.yamlFile!.text(); + recipe = await parseYamlFile(fileContent); } await saveRecipe(recipe, { @@ -92,6 +127,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR // Reset dialog state importRecipeForm.reset({ deeplink: '', + yamlFile: null, recipeName: '', global: true, }); @@ -121,6 +157,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR // Reset form to default values importRecipeForm.reset({ deeplink: '', + yamlFile: null, recipeName: '', global: true, }); @@ -165,6 +202,37 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR } }; + const handleYamlFileChange = async (file: File | undefined) => { + importRecipeForm.setFieldValue('yamlFile', file || null); + + if (file) { + try { + const fileContent = await file.text(); + const recipe = await parseYamlFile(fileContent); + if (recipe.title) { + const suggestedName = generateRecipeNameFromTitle(recipe.title); + + // Use the recipe name field's handleChange method if available + if (recipeNameFieldRef) { + recipeNameFieldRef.handleChange(suggestedName); + } else { + importRecipeForm.setFieldValue('recipeName', suggestedName); + } + } + } catch (error) { + // Silently handle parsing errors during auto-suggest + console.log('Could not parse YAML file for auto-suggest:', error); + } + } else { + // Clear the recipe name when file is removed + if (recipeNameFieldRef) { + recipeNameFieldRef.handleChange(''); + } else { + importRecipeForm.setFieldValue('recipeName', ''); + } + } + }; + if (!isOpen) return null; return ( @@ -180,40 +248,112 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR }} >
- - {(field) => ( -
- -