Applied server side call to parse and save recipe (#5022)

This commit is contained in:
Lifei Zhou
2025-10-09 15:45:46 +11:00
committed by GitHub
parent fc7836d649
commit 1396d315e5
42 changed files with 868 additions and 2126 deletions
+2
View File
@@ -157,6 +157,7 @@ function BaseChatContent({
// Use shared recipe manager
const {
recipe,
recipeId,
recipeParameters,
filteredParameters,
initialPrompt,
@@ -478,6 +479,7 @@ function BaseChatContent({
sessionCosts={sessionCosts}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
recipe={recipe}
recipeId={recipeId}
recipeAccepted={recipeAccepted}
initialPrompt={initialPrompt}
toolCount={toolCount || 0}
+3
View File
@@ -82,6 +82,7 @@ interface ChatInputProps {
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
disableAnimation?: boolean;
recipe?: Recipe | null;
recipeId?: string | null;
recipeAccepted?: boolean;
initialPrompt?: string;
toolCount: number;
@@ -109,6 +110,7 @@ export default function ChatInput({
sessionCosts,
setIsGoosehintsModalOpen,
recipe,
recipeId,
recipeAccepted,
initialPrompt,
toolCount,
@@ -1619,6 +1621,7 @@ export default function ChatInput({
setView={setView}
alerts={alerts}
recipe={recipe}
recipeId={recipeId}
hasMessages={messages.length > 0}
/>
</div>
@@ -4,33 +4,30 @@ import { Recipe, generateDeepLink, Parameter } from '../../recipe';
import { Geese } from '../icons/Geese';
import Copy from '../icons/Copy';
import { Check, Save, Calendar, X, Play } from 'lucide-react';
import { ExtensionConfig, useConfig } from '../ConfigContext';
import { FixedExtensionEntry } from '../ConfigContext';
import { ExtensionConfig } from '../ConfigContext';
import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal';
import { Button } from '../ui/button';
import { RecipeFormFields } from './shared/RecipeFormFields';
import { RecipeFormData } from './shared/recipeFormSchema';
import { saveRecipe, generateRecipeFilename } from '../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../toasts';
import { saveRecipe } from '../../recipe/recipe_management';
interface CreateEditRecipeModalProps {
isOpen: boolean;
onClose: (wasSaved?: boolean) => void;
recipe?: Recipe;
recipeName?: string;
isCreateMode?: boolean;
recipeId?: string | null;
}
export default function CreateEditRecipeModal({
isOpen,
onClose,
recipe,
recipeName: initialRecipeName,
isCreateMode = false,
recipeId,
}: CreateEditRecipeModalProps) {
const { getExtensions } = useConfig();
const getInitialValues = React.useCallback((): RecipeFormData => {
if (recipe) {
return {
@@ -43,8 +40,6 @@ export default function CreateEditRecipeModal({
jsonSchema: recipe.response?.json_schema
? JSON.stringify(recipe.response.json_schema, null, 2)
: '',
recipeName: initialRecipeName || '',
global: true,
};
}
return {
@@ -55,10 +50,8 @@ export default function CreateEditRecipeModal({
activities: [],
parameters: [],
jsonSchema: '',
recipeName: '',
global: true,
};
}, [recipe, initialRecipeName]);
}, [recipe]);
const form = useForm({
defaultValues: getInitialValues(),
@@ -83,22 +76,16 @@ export default function CreateEditRecipeModal({
setActivities(form.state.values.activities);
setParameters(form.state.values.parameters);
setJsonSchema(form.state.values.jsonSchema);
setRecipeName(form.state.values.recipeName);
setGlobal(form.state.values.global);
});
}, [form]);
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
const [copied, setCopied] = useState(false);
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [recipeName, setRecipeName] = useState(form.state.values.recipeName);
const [global, setGlobal] = useState(form.state.values.global);
const [isSaving, setIsSaving] = useState(false);
// Initialize selected extensions for the recipe
const [recipeExtensions] = useState<string[]>(() => {
const [recipeExtensions] = useState<ExtensionConfig[]>(() => {
if (recipe?.extensions) {
return recipe.extensions.map((ext) => ext.name);
return recipe.extensions;
}
return [];
});
@@ -111,31 +98,6 @@ export default function CreateEditRecipeModal({
}
}, [recipe, form, getInitialValues]);
// Load extensions when modal opens
useEffect(() => {
if (isOpen && !extensionsLoaded) {
const loadExtensions = async () => {
try {
const extensions = await getExtensions(false);
console.log('Loading extensions for recipe modal');
if (extensions && extensions.length > 0) {
const initializedExtensions = extensions.map((ext) => ({
...ext,
enabled: recipeExtensions.includes(ext.name),
}));
setExtensionOptions(initializedExtensions);
setExtensionsLoaded(true);
}
} catch (error) {
console.error('Failed to load extensions:', error);
}
};
loadExtensions();
}
}, [isOpen, getExtensions, recipeExtensions, extensionsLoaded]);
const getCurrentRecipe = useCallback((): Recipe => {
// Transform the internal parameters state into the desired output format.
const formattedParameters = parameters.map((param) => {
@@ -180,22 +142,10 @@ export default function CreateEditRecipeModal({
prompt: prompt || undefined,
parameters: formattedParameters,
response: responseConfig,
extensions: recipeExtensions
.map((name) => {
const extension = extensionOptions.find((e) => e.name === name);
if (!extension) return null;
// Create a clean copy of the extension configuration
const { enabled: _enabled, ...cleanExtension } = extension;
// Remove legacy envs which could potentially include secrets
if ('envs' in cleanExtension) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { envs: _envs, ...finalExtension } = cleanExtension as any;
return finalExtension;
}
return cleanExtension;
})
.filter(Boolean) as ExtensionConfig[],
// Strip envs to avoid leaking secrets
extensions: recipeExtensions.map((extension) =>
'envs' in extension ? { ...extension, envs: undefined } : extension
) as ExtensionConfig[],
};
}, [
recipe,
@@ -207,7 +157,6 @@ export default function CreateEditRecipeModal({
parameters,
jsonSchema,
recipeExtensions,
extensionOptions,
]);
const requiredFieldsAreFilled = () => {
@@ -312,15 +261,12 @@ export default function CreateEditRecipeModal({
try {
const recipe = getCurrentRecipe();
await saveRecipe(recipe, {
name: (recipeName || '').trim(),
global: global,
});
await saveRecipe(recipe, recipeId);
onClose(true);
toastSuccess({
title: (recipeName || '').trim(),
title: (recipe.title || '').trim(),
msg: 'Recipe saved successfully',
});
} catch (error) {
@@ -348,21 +294,25 @@ export default function CreateEditRecipeModal({
setIsSaving(true);
try {
const recipe = getCurrentRecipe();
const recipeName = generateRecipeFilename(recipe);
await saveRecipe(recipe, {
name: recipeName,
global: true,
});
await saveRecipe(recipe, recipeId);
// Close modal first
onClose(true);
// Open recipe in a new window instead of navigating in the same window
window.electron.createChatWindow(undefined, undefined, undefined, undefined, recipe);
window.electron.createChatWindow(
undefined,
undefined,
undefined,
undefined,
recipe,
undefined,
recipeId ?? undefined
);
toastSuccess({
title: recipeName,
title: recipe.title,
msg: 'Recipe saved and launched successfully',
});
} catch (error) {
@@ -509,7 +459,8 @@ export default function CreateEditRecipeModal({
undefined,
undefined,
undefined,
'schedules'
'schedules',
undefined
);
// Store the deep link in localStorage for the schedules view to pick up
localStorage.setItem('pendingScheduleDeepLink', deepLink);
@@ -9,7 +9,7 @@ import { RecipeFormData } from './shared/recipeFormSchema';
import { createRecipe } from '../../api/sdk.gen';
import { RecipeParameter } from './shared/recipeFormSchema';
import { toastError } from '../../toasts';
import { generateRecipeFilename } from '../../recipe/recipeStorage';
import { saveRecipe } from '../../recipe/recipe_management';
interface CreateRecipeFromSessionModalProps {
isOpen: boolean;
@@ -91,7 +91,6 @@ export default function CreateRecipeFromSessionModal({
form.setFieldValue('instructions', recipe.instructions || '');
form.setFieldValue('activities', recipe.activities || []);
form.setFieldValue('parameters', recipe.parameters || []);
form.setFieldValue('recipeName', generateRecipeFilename(recipe));
if (recipe.response?.json_schema) {
form.setFieldValue(
@@ -184,12 +183,7 @@ export default function CreateRecipeFromSessionModal({
extensions: [], // Will be populated based on current extensions
};
const { saveRecipe } = await import('../../recipe/recipeStorage');
await saveRecipe(recipe, {
name: formData.recipeName || formData.title,
title: formData.title,
global: formData.global,
});
await saveRecipe(recipe, null);
onRecipeCreated?.(recipe);
onClose();
@@ -5,17 +5,11 @@ 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 { RecipeTitleField } from './shared/RecipeTitleField';
import { listSavedRecipes } from '../../recipe/recipeStorage';
import {
validateRecipe,
getValidationErrorMessages,
getRecipeJsonSchema,
} from '../../recipe/validation';
import { getRecipeJsonSchema } from '../../recipe/validation';
import { saveRecipe } from '../../recipe/recipe_management';
import { parseRecipe } from '../../api';
interface ImportRecipeFormProps {
isOpen: boolean;
@@ -39,16 +33,6 @@ const importRecipeSchema = z
if (!file) return true;
return file.size <= 1024 * 1024;
}, 'File is too large, max size is 1MB'),
recipeTitle: z
.string()
.min(1, 'Recipe title is required')
.max(100, 'Recipe title must be 100 characters or less')
.refine((title) => title.trim().length > 0, 'Recipe title cannot be empty')
.refine(
(title) => /^[^<>:"/\\|?*]+$/.test(title.trim()),
'Recipe title contains invalid characters (< > : " / \\ | ? *)'
),
global: z.boolean(),
})
.refine((data) => (data.deeplink && data.deeplink.trim()) || data.recipeUploadFile, {
message: 'Either of deeplink or recipe file are required',
@@ -91,57 +75,28 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
}
};
const parseRecipeUploadFile = async (fileContent: string, fileName: string): Promise<Recipe> => {
const isJsonFile = fileName.toLowerCase().endsWith('.json');
let parsed;
const parseRecipeFromFile = async (fileContent: string): Promise<Recipe> => {
try {
if (isJsonFile) {
parsed = JSON.parse(fileContent);
} else {
parsed = yaml.parse(fileContent);
}
let response = await parseRecipe({
body: {
content: fileContent,
},
throwOnError: true,
});
return response.data.recipe;
} catch (error) {
throw new Error(
`Failed to parse ${isJsonFile ? 'JSON' : 'YAML'} file: ${error instanceof Error ? error.message : 'Invalid format'}`
);
}
if (!parsed) {
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)
const recipe = parsed.recipe || parsed;
return recipe as Recipe;
};
const validateTitleUniqueness = async (title: string): Promise<string | undefined> => {
if (!title.trim()) return undefined;
try {
const existingRecipes = await listSavedRecipes();
const titleExists = existingRecipes.some(
(recipe) => recipe.recipe.title?.toLowerCase() === title.toLowerCase()
);
if (titleExists) {
return `A recipe with the same title already exists`;
let error_message = 'unknown error';
if (typeof error === 'object' && error !== null && 'message' in error) {
error_message = error.message as string;
}
} catch (error) {
console.warn('Failed to validate title uniqueness:', error);
throw new Error(error_message);
}
return undefined;
};
const importRecipeForm = useForm({
defaultValues: {
deeplink: '',
recipeUploadFile: null as File | null,
recipeTitle: '',
global: true,
},
validators: {
onChange: importRecipeSchema,
@@ -160,41 +115,22 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
recipe = parsedRecipe;
} else {
const fileContent = await value.recipeUploadFile!.text();
recipe = await parseRecipeUploadFile(fileContent, value.recipeUploadFile!.name);
recipe = await parseRecipeFromFile(fileContent);
}
recipe.title = value.recipeTitle.trim();
const titleValidationError = await validateTitleUniqueness(value.recipeTitle.trim());
if (titleValidationError) {
throw new Error(titleValidationError);
}
const validationResult = validateRecipe(recipe);
if (!validationResult.success) {
const errorMessages = getValidationErrorMessages(validationResult.errors);
throw new Error(`Recipe validation failed: ${errorMessages.join(', ')}`);
}
await saveRecipe(recipe, {
name: '',
title: value.recipeTitle.trim(),
global: value.global,
});
await saveRecipe(recipe, null);
// Reset dialog state
importRecipeForm.reset({
deeplink: '',
recipeUploadFile: null,
recipeTitle: '',
global: true,
});
onClose();
onSuccess();
toastSuccess({
title: value.recipeTitle.trim(),
title: recipe.title.trim(),
msg: 'Recipe imported successfully',
});
} catch (error) {
@@ -215,14 +151,10 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
importRecipeForm.reset({
deeplink: '',
recipeUploadFile: null,
recipeTitle: '',
global: true,
});
onClose();
};
let recipeTitleFieldRef: { handleChange: (value: string) => void } | null = null;
const handleDeeplinkChange = async (
value: string,
field: { handleChange: (value: string) => void }
@@ -231,26 +163,13 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
if (value.trim()) {
try {
const recipe = await parseDeeplink(value.trim());
if (recipe && recipe.title) {
if (recipeTitleFieldRef) {
recipeTitleFieldRef.handleChange(recipe.title);
} else {
importRecipeForm.setFieldValue('recipeTitle', recipe.title);
}
}
await parseDeeplink(value.trim());
} catch (error) {
toastError({
title: 'Invalid Deeplink',
msg: `The deeplink format is invalid: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
}
} else {
if (recipeTitleFieldRef) {
recipeTitleFieldRef.handleChange('');
} else {
importRecipeForm.setFieldValue('recipeTitle', '');
}
}
};
@@ -260,26 +179,13 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
if (file) {
try {
const fileContent = await file.text();
const recipe = await parseRecipeUploadFile(fileContent, file.name);
if (recipe.title) {
if (recipeTitleFieldRef) {
recipeTitleFieldRef.handleChange(recipe.title);
} else {
importRecipeForm.setFieldValue('recipeTitle', recipe.title);
}
}
await parseRecipeFromFile(fileContent);
} catch (error) {
toastError({
title: 'Invalid Recipe File',
msg: `The recipe file format is invalid: ${error instanceof Error ? error.message : 'Unknown error'}`,
msg: error instanceof Error ? error.message : 'Unknown error',
});
}
} else {
if (recipeTitleFieldRef) {
recipeTitleFieldRef.handleChange('');
} else {
importRecipeForm.setFieldValue('recipeTitle', '');
}
}
};
@@ -421,61 +327,6 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
Ensure you review contents of recipe files before adding them to your goose
interface.
</p>
<importRecipeForm.Field name="recipeTitle">
{(field) => {
// Store reference to the field for programmatic updates
recipeTitleFieldRef = field;
return (
<RecipeTitleField
id="import-recipe-title"
value={field.state.value}
onChange={field.handleChange}
onBlur={field.handleBlur}
errors={field.state.meta.errors.map((error) =>
typeof error === 'string' ? error : error?.message || String(error)
)}
/>
);
}}
</importRecipeForm.Field>
<importRecipeForm.Field name="global">
{(field) => (
<div>
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="import-save-location"
checked={field.state.value === true}
onChange={() => field.handleChange(true)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="import-save-location"
checked={field.state.value === false}
onChange={() => field.handleChange(false)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
)}
</importRecipeForm.Field>
</div>
<div className="flex justify-end space-x-3 mt-6">
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipeStorage';
import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipe_management';
import { FileText, Edit, Trash2, Play, Calendar, AlertCircle, Link } from 'lucide-react';
import { ScrollArea } from '../ui/scroll-area';
import { Card } from '../ui/card';
@@ -65,7 +65,7 @@ export default function RecipesView() {
}
};
const handleLoadRecipe = async (recipe: Recipe) => {
const handleLoadRecipe = async (recipe: Recipe, recipeId: string) => {
try {
// onLoadRecipe is not working for loading recipes. It looks correct
// but the instructions are not flowing through to the server.
@@ -82,7 +82,8 @@ export default function RecipesView() {
undefined, // version
undefined, // resumeSessionId
recipe, // recipe config
undefined // view type
undefined, // view type,
recipeId // recipe id
);
// }
} catch (err) {
@@ -98,7 +99,7 @@ export default function RecipesView() {
buttons: ['Cancel', 'Delete'],
defaultId: 0,
title: 'Delete Recipe',
message: `Are you sure you want to delete "${recipeManifest.name}"?`,
message: `Are you sure you want to delete "${recipeManifest.recipe.title}"?`,
detail: 'Recipe file will be deleted.',
});
@@ -110,7 +111,7 @@ export default function RecipesView() {
await deleteRecipe({ body: { id: recipeManifest.id } });
await loadSavedRecipes();
toastSuccess({
title: recipeManifest.name,
title: recipeManifest.recipe.title,
msg: 'Recipe deleted successfully',
});
} catch (err) {
@@ -174,7 +175,7 @@ export default function RecipesView() {
<Button
onClick={(e) => {
e.stopPropagation();
handleLoadRecipe(recipe);
handleLoadRecipe(recipe, recipeManifestResponse.id);
}}
size="sm"
className="h-8 w-8 p-0"
@@ -339,7 +340,7 @@ export default function RecipesView() {
isOpen={showEditor}
onClose={handleEditorClose}
recipe={selectedRecipe.recipe}
recipeName={selectedRecipe.name}
recipeId={selectedRecipe.id}
/>
)}
@@ -13,7 +13,7 @@ vi.mock('../../../toasts', () => ({
toastError: vi.fn(),
}));
vi.mock('../../../recipe/recipeStorage', () => ({
vi.mock('../../../recipe/recipe_management', () => ({
saveRecipe: vi.fn(),
}));
@@ -146,8 +146,6 @@ describe('CreateRecipeFromSessionModal', () => {
expect(screen.getByDisplayValue('Analyzed instructions with {{param1}}')).toBeInTheDocument();
const promptInput = screen.getByTestId('prompt-input');
expect(promptInput).toBeInTheDocument();
const recipeNameInput = screen.getByTestId('recipe-name-input');
expect(recipeNameInput).toBeInTheDocument();
});
it('shows recipe form fields after analysis', async () => {
@@ -164,21 +162,6 @@ describe('CreateRecipeFromSessionModal', () => {
expect(screen.getByTestId('description-input')).toBeInTheDocument();
expect(screen.getByTestId('instructions-input')).toBeInTheDocument();
expect(screen.getByTestId('prompt-input')).toBeInTheDocument();
expect(screen.getByTestId('recipe-name-input')).toBeInTheDocument();
});
it('shows save location options', async () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await waitFor(
() => {
expect(screen.getByTestId('save-location-field')).toBeInTheDocument();
},
{ timeout: 2000 }
);
expect(screen.getByTestId('global-radio')).toBeInTheDocument();
expect(screen.getByTestId('directory-radio')).toBeInTheDocument();
});
});
@@ -201,21 +184,6 @@ describe('CreateRecipeFromSessionModal', () => {
expect(screen.getByDisplayValue('Modified Title')).toBeInTheDocument();
});
it('allows changing save location', async () => {
const user = userEvent.setup();
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await waitFor(
() => {
expect(screen.getByTestId('directory-radio')).toBeInTheDocument();
},
{ timeout: 2000 }
);
await user.click(screen.getByTestId('directory-radio'));
expect(screen.getByTestId('directory-radio')).toBeChecked();
});
it('validates required fields', async () => {
const user = userEvent.setup();
render(<CreateRecipeFromSessionModal {...defaultProps} />);
@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { Parameter } from '../../../recipe';
import { RecipeNameField } from './RecipeNameField';
import ParameterInput from '../../parameter/ParameterInput';
import RecipeActivityEditor from '../RecipeActivityEditor';
@@ -24,8 +23,6 @@ interface RecipeFormFieldsProps {
onInstructionsChange?: (value: string) => void;
onPromptChange?: (value: string) => void;
onJsonSchemaChange?: (value: string) => void;
onRecipeNameChange?: (value: string) => void;
onGlobalChange?: (value: boolean) => void;
}
export function RecipeFormFields({
@@ -35,8 +32,6 @@ export function RecipeFormFields({
onInstructionsChange,
onPromptChange,
onJsonSchemaChange,
onRecipeNameChange,
onGlobalChange,
}: RecipeFormFieldsProps) {
const [showJsonSchemaEditor, setShowJsonSchemaEditor] = useState(false);
const [showInstructionsEditor, setShowInstructionsEditor] = useState(false);
@@ -460,71 +455,6 @@ export function RecipeFormFields({
</div>
)}
</form.Field>
{/* Recipe Name Field */}
<form.Field name="recipeName">
{(field: FormFieldApi<string | undefined>) => (
<div>
<div data-testid="recipe-name-field">
<RecipeNameField
id="recipe-name-field"
value={field.state.value || ''}
onChange={(value) => {
field.handleChange(value);
onRecipeNameChange?.(value);
}}
onBlur={field.handleBlur}
errors={field.state.meta.errors}
/>
</div>
</div>
)}
</form.Field>
{/* Save Location Field */}
<form.Field name="global">
{(field: FormFieldApi<boolean>) => (
<div data-testid="save-location-field">
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={field.state.value === true}
onChange={() => {
field.handleChange(true);
onGlobalChange?.(true);
}}
className="mr-2"
data-testid="global-radio"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={field.state.value === false}
onChange={() => {
field.handleChange(false);
onGlobalChange?.(false);
}}
className="mr-2"
data-testid="directory-radio"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
)}
</form.Field>
</div>
);
}
@@ -1,45 +0,0 @@
interface RecipeTitleFieldProps {
id: string;
value: string;
onChange: (value: string) => void;
onBlur: () => void;
errors: string[];
label?: string;
required?: boolean;
disabled?: boolean;
}
export function RecipeTitleField({
id,
value,
onChange,
onBlur,
errors,
label = 'Recipe Title',
required = true,
disabled = false,
}: RecipeTitleFieldProps) {
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-text-standard mb-2">
{label} {required && <span className="text-red-500">*</span>}
</label>
<input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={disabled}
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
placeholder="My Recipe Title"
/>
<p className="text-xs text-text-muted mt-1">
This will be the display name shown in your recipe library
</p>
{errors.length > 0 && <p className="text-red-500 text-sm mt-1">{errors[0]}</p>}
</div>
);
}
@@ -1,212 +0,0 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Recipe } from '../../../recipe';
import { saveRecipe, generateRecipeFilename } from '../../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../../toasts';
import { useEscapeKey } from '../../../hooks/useEscapeKey';
import { Play } from 'lucide-react';
interface SaveRecipeDialogProps {
isOpen: boolean;
onClose: (wasSaved?: boolean) => void;
onSuccess?: () => void;
recipe: Recipe;
suggestedName?: string;
showSaveAndRun?: boolean;
onSaveAndRun?: (recipe: Recipe) => void;
}
export default function SaveRecipeDialog({
isOpen,
onClose,
onSuccess,
recipe,
suggestedName,
showSaveAndRun = false,
onSaveAndRun,
}: SaveRecipeDialogProps) {
const [saveRecipeName, setSaveRecipeName] = useState(
suggestedName || generateRecipeFilename(recipe)
);
const [saveGlobal, setSaveGlobal] = useState(true);
const [saving, setSaving] = useState(false);
useEscapeKey(isOpen, onClose);
React.useEffect(() => {
if (isOpen) {
setSaveRecipeName(suggestedName || generateRecipeFilename(recipe));
setSaveGlobal(true);
setSaving(false);
}
}, [isOpen, suggestedName, recipe]);
const handleSaveRecipe = async () => {
if (!saveRecipeName.trim()) {
return;
}
setSaving(true);
try {
if (!recipe.title || !recipe.description || !recipe.instructions) {
throw new Error('Invalid recipe configuration: missing required fields');
}
await saveRecipe(recipe, {
name: saveRecipeName.trim(),
global: saveGlobal,
});
setSaveRecipeName('');
onClose(true);
toastSuccess({
title: saveRecipeName.trim(),
msg: 'Recipe saved successfully',
});
onSuccess?.();
} catch (error) {
console.error('Failed to save recipe:', error);
toastError({
title: 'Save Failed',
msg: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setSaving(false);
}
};
const handleSaveAndRunRecipe = async () => {
if (!saveRecipeName.trim()) {
return;
}
setSaving(true);
try {
if (!recipe.title || !recipe.description || !recipe.instructions) {
throw new Error('Invalid recipe configuration: missing required fields');
}
await saveRecipe(recipe, {
name: saveRecipeName.trim(),
global: saveGlobal,
});
setSaveRecipeName('');
onClose(true);
toastSuccess({
title: saveRecipeName.trim(),
msg: 'Recipe saved and launched successfully',
});
// Launch the recipe in a new window
onSaveAndRun?.(recipe);
onSuccess?.();
} catch (error) {
console.error('Failed to save and run recipe:', error);
toastError({
title: 'Save and Run Failed',
msg: `Failed to save and run recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setSaving(false);
}
};
const handleClose = () => {
setSaveRecipeName('');
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[500] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-96 max-w-[90vw]">
<h3 className="text-lg font-medium text-text-standard mb-4">Save Recipe</h3>
<div className="space-y-4">
<div>
<label
htmlFor="recipe-name"
className="block text-sm font-medium text-text-standard mb-2"
>
Recipe Name
</label>
<input
id="recipe-name"
type="text"
value={saveRecipeName}
onChange={(e) => setSaveRecipeName(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter recipe name"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={saveGlobal}
onChange={() => setSaveGlobal(true)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={!saveGlobal}
onChange={() => setSaveGlobal(false)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<Button type="button" onClick={handleClose} variant="ghost" disabled={saving}>
Cancel
</Button>
<Button
onClick={handleSaveRecipe}
disabled={!saveRecipeName.trim() || saving}
variant="outline"
>
{saving ? 'Saving...' : 'Save Recipe'}
</Button>
{showSaveAndRun && (
<Button
onClick={handleSaveAndRunRecipe}
disabled={!saveRecipeName.trim() || saving}
variant="default"
className="inline-flex items-center justify-center gap-2"
>
<Play className="w-4 h-4" />
{saving ? 'Saving...' : 'Save & Run Recipe'}
</Button>
)}
</div>
</div>
</div>
);
}
@@ -16,8 +16,6 @@ describe('RecipeFormFields', () => {
activities: [],
parameters: [],
jsonSchema: '',
recipeName: '',
global: true,
...initialValues,
};
@@ -129,19 +127,6 @@ describe('RecipeFormFields', () => {
});
});
describe('Always Visible Fields', () => {
it('always shows recipe name field', () => {
render(<TestWrapper />);
expect(screen.getByText('Recipe Name')).toBeInTheDocument();
});
it('always shows save location field', () => {
render(<TestWrapper />);
expect(screen.getByText('Save Location')).toBeInTheDocument();
expect(screen.getByText('Global - Available across all Goose sessions')).toBeInTheDocument();
});
});
describe('Pre-filled Values', () => {
it('displays pre-filled form values', () => {
const initialValues: Partial<RecipeFormData> = {
@@ -262,8 +247,6 @@ describe('RecipeFormFields', () => {
activities: [],
parameters: [],
jsonSchema: '',
recipeName: '',
global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -355,8 +338,6 @@ describe('RecipeFormFields', () => {
},
],
jsonSchema: '',
recipeName: '',
global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -522,8 +503,6 @@ describe('RecipeFormFields', () => {
},
],
jsonSchema: '',
recipeName: '',
global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -597,8 +576,6 @@ describe('RecipeFormFields', () => {
},
],
jsonSchema: '',
recipeName: '',
global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -17,8 +17,6 @@ describe('recipeFormSchema', () => {
},
],
jsonSchema: '{"type": "object"}',
recipeName: 'test_recipe',
global: true,
};
describe('Zod Schema Validation', () => {
@@ -154,16 +152,6 @@ describe('recipeFormSchema', () => {
expect(result.success).toBe(true);
});
it('rejects invalid JSON schema', () => {
const invalidData = { ...validFormData, jsonSchema: 'invalid json' };
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
const jsonError = result.error.issues.find((issue) => issue.path.includes('jsonSchema'));
expect(jsonError?.message).toBe('Invalid JSON schema format');
}
});
it('allows empty JSON schema', () => {
const validData = { ...validFormData, jsonSchema: '' };
const result = recipeFormSchema.safeParse(validData);
@@ -177,31 +165,6 @@ describe('recipeFormSchema', () => {
});
});
describe('Recipe Name Validation', () => {
it('allows empty recipe name', () => {
const validData = { ...validFormData, recipeName: '' };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('allows undefined recipe name', () => {
const validData = { ...validFormData, recipeName: undefined };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('rejects invalid recipe name characters', () => {
// The regex /^[^<>:"/\\|?*]+$/ rejects these specific characters
const invalidData = { ...validFormData, recipeName: 'invalid<name' };
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
const nameError = result.error.issues.find((issue) => issue.path.includes('recipeName'));
expect(nameError?.message).toContain('invalid characters');
}
});
});
describe('Parameter Validation', () => {
it('validates parameters with all required fields', () => {
const validData = {
@@ -305,20 +268,6 @@ describe('recipeFormSchema', () => {
});
});
describe('Global Field Validation', () => {
it('validates global field as boolean true', () => {
const validData = { ...validFormData, global: true };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('validates global field as boolean false', () => {
const validData = { ...validFormData, global: false };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
});
describe('Multiple Validation Errors', () => {
it('handles multiple validation errors', () => {
const invalidData = {
@@ -326,7 +275,6 @@ describe('recipeFormSchema', () => {
title: 'AB', // Too short
description: 'Short', // Too short
instructions: 'Short', // Too short
jsonSchema: 'invalid json',
};
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
@@ -339,7 +287,6 @@ describe('recipeFormSchema', () => {
expect(result.error.issues.some((issue) => issue.path.includes('instructions'))).toBe(
true
);
expect(result.error.issues.some((issue) => issue.path.includes('jsonSchema'))).toBe(true);
}
});
});
@@ -1,5 +1,4 @@
import { z } from 'zod';
import { validateJsonSchema } from '../../../recipe/validation';
// Zod schema for Parameter - matching API RecipeParameter type
const parameterSchema = z.object({
@@ -39,29 +38,7 @@ export const recipeFormSchema = z.object({
parameters: z.array(parameterSchema).default([]),
jsonSchema: z
.string()
.optional()
.refine((value) => {
if (!value || !value.trim()) return true;
try {
const parsed = JSON.parse(value.trim());
const validationResult = validateJsonSchema(parsed);
return validationResult.success;
} catch {
return false;
}
}, 'Invalid JSON schema format'),
recipeName: z
.string()
.optional()
.refine((name) => {
if (!name || !name.trim()) return true;
return /^[^<>:"/\\|?*]+$/.test(name.trim());
}, 'Recipe name contains invalid characters (< > : " / \\ | ? *)'),
global: z.boolean().default(true),
jsonSchema: z.string().optional(),
});
export type RecipeFormData = z.infer<typeof recipeFormSchema>;
@@ -6,7 +6,7 @@ import { Select } from '../ui/Select';
import cronstrue from 'cronstrue';
import * as yaml from 'yaml';
import { Recipe, decodeRecipe } from '../../recipe';
import { getStorageDirectory } from '../../recipe/recipeStorage';
import { getStorageDirectory } from '../../recipe/recipe_management';
import ClockIcon from '../../assets/clock-icon.svg';
type FrequencyValue = 'once' | 'every' | 'daily' | 'weekly' | 'monthly';
@@ -1,4 +1,4 @@
import { Sliders, ChefHat, Bot, Eye, Save } from 'lucide-react';
import { Sliders, ChefHat, Bot, Eye } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { SwitchModelModal } from '../subcomponents/SwitchModelModal';
@@ -17,9 +17,7 @@ import { getProviderMetadata } from '../modelInterface';
import { Alert } from '../../../alerts';
import BottomMenuAlertPopover from '../../../bottom_menu/BottomMenuAlertPopover';
import { Recipe } from '../../../../recipe';
import { generateRecipeFilename } from '../../../../recipe/recipeStorage';
import CreateEditRecipeModal from '../../../recipes/CreateEditRecipeModal';
import SaveRecipeDialog from '../../../recipes/shared/SaveRecipeDialog';
interface ModelsBottomBarProps {
sessionId: string | null;
@@ -27,6 +25,7 @@ interface ModelsBottomBarProps {
setView: (view: View) => void;
alerts: Alert[];
recipe?: Recipe | null;
recipeId?: string | null;
hasMessages?: boolean; // Add prop to know if there are messages to create a recipe from
}
@@ -36,6 +35,7 @@ export default function ModelsBottomBar({
setView,
alerts,
recipe,
recipeId,
hasMessages = false,
}: ModelsBottomBarProps) {
const {
@@ -54,9 +54,6 @@ export default function ModelsBottomBar({
const [isLeadWorkerActive, setIsLeadWorkerActive] = useState(false);
const [providerDefaultModel, setProviderDefaultModel] = useState<string | null>(null);
// Save recipe dialog state
const [showSaveDialog, setShowSaveDialog] = useState(false);
// View recipe modal state
const [showViewRecipeModal, setShowViewRecipeModal] = useState(false);
@@ -174,13 +171,6 @@ export default function ModelsBottomBar({
}
};
// Handle save recipe - show save dialog
const handleSaveRecipeClick = () => {
if (recipe) {
setShowSaveDialog(true);
}
};
return (
<div className="relative flex items-center" ref={dropdownRef}>
<BottomMenuAlertPopover alerts={alerts} />
@@ -219,10 +209,6 @@ export default function ModelsBottomBar({
<span>View/Edit Recipe</span>
<Eye className="ml-auto h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSaveRecipeClick}>
<span>Save Recipe</span>
<Save className="ml-auto h-4 w-4" />
</DropdownMenuItem>
</>
)}
@@ -256,15 +242,6 @@ export default function ModelsBottomBar({
<LeadWorkerSettings isOpen={isLeadWorkerModalOpen} onClose={handleLeadWorkerModalClose} />
) : null}
{/* Save Recipe Dialog */}
{showSaveDialog && recipe && (
<SaveRecipeDialog
isOpen={showSaveDialog}
onClose={() => setShowSaveDialog(false)}
recipe={recipe}
/>
)}
{/* View Recipe Modal */}
{/* todo: we don't have the actual recipe name when in chat only in recipes list view so we generate it for now */}
{recipe && (
@@ -272,7 +249,7 @@ export default function ModelsBottomBar({
isOpen={showViewRecipeModal}
onClose={() => setShowViewRecipeModal(false)}
recipe={recipe}
recipeName={generateRecipeFilename(recipe)}
recipeId={recipeId}
/>
)}
</div>