Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 41 additions & 27 deletions ui/desktop/src/components/recipes/ImportRecipeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const importRecipeSchema = z
(value) => !value || value.trim().startsWith('goose://recipe?config='),
'Invalid deeplink format. Expected: goose://recipe?config=...'
),
yamlFile: z
recipeUploadFile: z
.instanceof(File)
.nullable()
.refine((file) => {
Expand All @@ -42,8 +42,8 @@ const importRecipeSchema = z
recipeName: recipeNameSchema,
global: z.boolean(),
})
.refine((data) => (data.deeplink && data.deeplink.trim()) || data.yamlFile, {
message: 'Either of deeplink or YAML file are required',
.refine((data) => (data.deeplink && data.deeplink.trim()) || data.recipeUploadFile, {
message: 'Either of deeplink or recipe file are required',
path: ['deeplink'],
});

Expand Down Expand Up @@ -85,11 +85,24 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
}
};

const parseYamlFile = async (fileContent: string): Promise<Recipe> => {
const parsed = yaml.parse(fileContent);
const parseRecipeUploadFile = async (fileContent: string, fileName: string): Promise<Recipe> => {
const isJsonFile = fileName.toLowerCase().endsWith('.json');
let parsed;

try {
if (isJsonFile) {
parsed = JSON.parse(fileContent);
} else {
parsed = yaml.parse(fileContent);
}
} catch (error) {
throw new Error(
`Failed to parse ${isJsonFile ? 'JSON' : 'YAML'} file: ${error instanceof Error ? error.message : 'Invalid format'}`
);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yaml is a superset of json, so no need to parse them separately. more problematically though, recipes are not always correct yaml before parameter substitution. I don't think the client should do any parsing here really


if (!parsed) {
throw new Error('YAML file is empty or contains invalid content');
throw new Error(`${isJsonFile ? 'JSON' : 'YAML'} file is empty or contains invalid content`);
}

// Handle both CLI format (flat structure) and Desktop format (nested under 'recipe' key)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this comment or update it about legacy; there should be no Desktop format

Expand All @@ -101,7 +114,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
const importRecipeForm = useForm({
defaultValues: {
deeplink: '',
yamlFile: null as File | null,
recipeUploadFile: null as File | null,
recipeName: '',
global: true,
},
Expand All @@ -113,16 +126,16 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
try {
let recipe: Recipe;

// Parse recipe from either deeplink or YAML file
// Parse recipe from either deeplink or recipe 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);
const fileContent = await value.recipeUploadFile!.text();
recipe = await parseRecipeUploadFile(fileContent, value.recipeUploadFile!.name);
}

const validationResult = validateRecipe(recipe);
Expand All @@ -139,7 +152,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
// Reset dialog state
importRecipeForm.reset({
deeplink: '',
yamlFile: null,
recipeUploadFile: null,
recipeName: '',
global: true,
});
Expand Down Expand Up @@ -169,7 +182,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
// Reset form to default values
importRecipeForm.reset({
deeplink: '',
yamlFile: null,
recipeUploadFile: null,
recipeName: '',
global: true,
});
Expand Down Expand Up @@ -214,13 +227,13 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
}
};

const handleYamlFileChange = async (file: File | undefined) => {
importRecipeForm.setFieldValue('yamlFile', file || null);
const handleRecipeUploadChange = async (file: File | undefined) => {
importRecipeForm.setFieldValue('recipeUploadFile', file || null);

if (file) {
try {
const fileContent = await file.text();
const recipe = await parseYamlFile(fileContent);
const recipe = await parseRecipeUploadFile(fileContent, file.name);
if (recipe.title) {
const suggestedName = generateRecipeNameFromTitle(recipe.title);

Expand All @@ -233,7 +246,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
}
} catch (error) {
// Silently handle parsing errors during auto-suggest
console.log('Could not parse YAML file for auto-suggest:', error);
console.log('Could not parse recipe file for auto-suggest:', error);
}
} else {
// Clear the recipe name when file is removed
Expand Down Expand Up @@ -266,7 +279,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
<>
<importRecipeForm.Field name="deeplink">
{(field) => {
const isDisabled = values.yamlFile !== null;
const isDisabled = values.recipeUploadFile !== null;

return (
<div className={isDisabled ? 'opacity-50' : ''}>
Expand Down Expand Up @@ -320,30 +333,30 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
</div>
</div>

<importRecipeForm.Field name="yamlFile">
<importRecipeForm.Field name="recipeUploadFile">
{(field) => {
const hasDeeplink = values.deeplink?.trim();
const isDisabled = !!hasDeeplink;

return (
<div className={isDisabled ? 'opacity-50' : ''}>
<label
htmlFor="import-yaml-file"
htmlFor="import-recipe-file"
className="block text-sm font-medium text-text-standard mb-3"
>
Recipe YAML File
Recipe File
</label>
<div className="relative">
<Input
id="import-yaml-file"
id="import-recipe-file"
type="file"
accept=".yaml,.yml"
accept=".yaml,.yml,.json"
disabled={isDisabled}
onChange={(e) => {
handleYamlFileChange(e.target.files?.[0]);
handleRecipeUploadChange(e.target.files?.[0]);
}}
onBlur={field.handleBlur}
className={`${field.state.meta.errors.length > 0 ? 'border-red-500' : ''} ${
className={`file:pt-1 ${field.state.meta.errors.length > 0 ? 'border-red-500' : ''} ${
isDisabled ? 'cursor-not-allowed' : ''
}`}
/>
Expand All @@ -352,7 +365,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
<p
className={`text-xs mt-1 ${isDisabled ? 'text-gray-300' : 'text-text-muted'}`}
>
Upload a YAML file containing the recipe structure
Upload a YAML or JSON file containing the recipe structure
</p>
<button
type="button"
Expand Down Expand Up @@ -380,7 +393,8 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
</importRecipeForm.Subscribe>

<p className="text-xs text-text-muted">
Ensure you review contents of YAML files before adding them to your goose interface.
Ensure you review contents of recipe files before adding them to your goose
interface.
</p>

<importRecipeForm.Field name="recipeName">
Expand Down Expand Up @@ -481,7 +495,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
{JSON.stringify(getRecipeJsonSchema(), null, 2)}
</pre>
<p className="mt-4 text-blue-700 text-sm">
Your YAML file should follow this structure. Required fields are: title,
Your YAML or JSON file should follow this structure. Required fields are: title,
description, and either instructions or prompt.
</p>
</div>
Expand Down
Loading