Applied server side call to parse and save recipe (#5022)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user