Desktop json recipes upload (#4629)

This commit is contained in:
Amed Rodriguez
2025-09-12 16:05:26 -07:00
committed by GitHub
parent 178573ce95
commit 62cd2661a1
@@ -32,7 +32,7 @@ const importRecipeSchema = z
(value) => !value || value.trim().startsWith('goose://recipe?config='), (value) => !value || value.trim().startsWith('goose://recipe?config='),
'Invalid deeplink format. Expected: goose://recipe?config=...' 'Invalid deeplink format. Expected: goose://recipe?config=...'
), ),
yamlFile: z recipeUploadFile: z
.instanceof(File) .instanceof(File)
.nullable() .nullable()
.refine((file) => { .refine((file) => {
@@ -42,8 +42,8 @@ const importRecipeSchema = z
recipeName: recipeNameSchema, recipeName: recipeNameSchema,
global: z.boolean(), global: z.boolean(),
}) })
.refine((data) => (data.deeplink && data.deeplink.trim()) || data.yamlFile, { .refine((data) => (data.deeplink && data.deeplink.trim()) || data.recipeUploadFile, {
message: 'Either of deeplink or YAML file are required', message: 'Either of deeplink or recipe file are required',
path: ['deeplink'], path: ['deeplink'],
}); });
@@ -85,11 +85,24 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
} }
}; };
const parseYamlFile = async (fileContent: string): Promise<Recipe> => { const parseRecipeUploadFile = async (fileContent: string, fileName: string): Promise<Recipe> => {
const parsed = yaml.parse(fileContent); 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'}`
);
}
if (!parsed) { 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) // Handle both CLI format (flat structure) and Desktop format (nested under 'recipe' key)
@@ -101,7 +114,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
const importRecipeForm = useForm({ const importRecipeForm = useForm({
defaultValues: { defaultValues: {
deeplink: '', deeplink: '',
yamlFile: null as File | null, recipeUploadFile: null as File | null,
recipeName: '', recipeName: '',
global: true, global: true,
}, },
@@ -113,7 +126,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
try { try {
let recipe: Recipe; 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()) { if (value.deeplink && value.deeplink.trim()) {
const parsedRecipe = await parseDeeplink(value.deeplink.trim()); const parsedRecipe = await parseDeeplink(value.deeplink.trim());
if (!parsedRecipe) { if (!parsedRecipe) {
@@ -121,8 +134,8 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
} }
recipe = parsedRecipe; recipe = parsedRecipe;
} else { } else {
const fileContent = await value.yamlFile!.text(); const fileContent = await value.recipeUploadFile!.text();
recipe = await parseYamlFile(fileContent); recipe = await parseRecipeUploadFile(fileContent, value.recipeUploadFile!.name);
} }
const validationResult = validateRecipe(recipe); const validationResult = validateRecipe(recipe);
@@ -139,7 +152,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
// Reset dialog state // Reset dialog state
importRecipeForm.reset({ importRecipeForm.reset({
deeplink: '', deeplink: '',
yamlFile: null, recipeUploadFile: null,
recipeName: '', recipeName: '',
global: true, global: true,
}); });
@@ -169,7 +182,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
// Reset form to default values // Reset form to default values
importRecipeForm.reset({ importRecipeForm.reset({
deeplink: '', deeplink: '',
yamlFile: null, recipeUploadFile: null,
recipeName: '', recipeName: '',
global: true, global: true,
}); });
@@ -214,13 +227,13 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
} }
}; };
const handleYamlFileChange = async (file: File | undefined) => { const handleRecipeUploadChange = async (file: File | undefined) => {
importRecipeForm.setFieldValue('yamlFile', file || null); importRecipeForm.setFieldValue('recipeUploadFile', file || null);
if (file) { if (file) {
try { try {
const fileContent = await file.text(); const fileContent = await file.text();
const recipe = await parseYamlFile(fileContent); const recipe = await parseRecipeUploadFile(fileContent, file.name);
if (recipe.title) { if (recipe.title) {
const suggestedName = generateRecipeNameFromTitle(recipe.title); const suggestedName = generateRecipeNameFromTitle(recipe.title);
@@ -233,7 +246,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
} }
} catch (error) { } catch (error) {
// Silently handle parsing errors during auto-suggest // 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 { } else {
// Clear the recipe name when file is removed // Clear the recipe name when file is removed
@@ -266,7 +279,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
<> <>
<importRecipeForm.Field name="deeplink"> <importRecipeForm.Field name="deeplink">
{(field) => { {(field) => {
const isDisabled = values.yamlFile !== null; const isDisabled = values.recipeUploadFile !== null;
return ( return (
<div className={isDisabled ? 'opacity-50' : ''}> <div className={isDisabled ? 'opacity-50' : ''}>
@@ -320,7 +333,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
</div> </div>
</div> </div>
<importRecipeForm.Field name="yamlFile"> <importRecipeForm.Field name="recipeUploadFile">
{(field) => { {(field) => {
const hasDeeplink = values.deeplink?.trim(); const hasDeeplink = values.deeplink?.trim();
const isDisabled = !!hasDeeplink; const isDisabled = !!hasDeeplink;
@@ -328,22 +341,22 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
return ( return (
<div className={isDisabled ? 'opacity-50' : ''}> <div className={isDisabled ? 'opacity-50' : ''}>
<label <label
htmlFor="import-yaml-file" htmlFor="import-recipe-file"
className="block text-sm font-medium text-text-standard mb-3" className="block text-sm font-medium text-text-standard mb-3"
> >
Recipe YAML File Recipe File
</label> </label>
<div className="relative"> <div className="relative">
<Input <Input
id="import-yaml-file" id="import-recipe-file"
type="file" type="file"
accept=".yaml,.yml" accept=".yaml,.yml,.json"
disabled={isDisabled} disabled={isDisabled}
onChange={(e) => { onChange={(e) => {
handleYamlFileChange(e.target.files?.[0]); handleRecipeUploadChange(e.target.files?.[0]);
}} }}
onBlur={field.handleBlur} 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' : '' isDisabled ? 'cursor-not-allowed' : ''
}`} }`}
/> />
@@ -352,7 +365,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
<p <p
className={`text-xs mt-1 ${isDisabled ? 'text-gray-300' : 'text-text-muted'}`} 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> </p>
<button <button
type="button" type="button"
@@ -380,7 +393,8 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
</importRecipeForm.Subscribe> </importRecipeForm.Subscribe>
<p className="text-xs text-text-muted"> <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> </p>
<importRecipeForm.Field name="recipeName"> <importRecipeForm.Field name="recipeName">
@@ -481,7 +495,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
{JSON.stringify(getRecipeJsonSchema(), null, 2)} {JSON.stringify(getRecipeJsonSchema(), null, 2)}
</pre> </pre>
<p className="mt-4 text-blue-700 text-sm"> <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. description, and either instructions or prompt.
</p> </p>
</div> </div>