Applied server side call to parse and save recipe (#5022)
This commit is contained in:
@@ -931,7 +931,6 @@
|
||||
"tags": [
|
||||
"Recipe Management"
|
||||
],
|
||||
"summary": "Create a Recipe configuration from the current session",
|
||||
"operationId": "create_recipe",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -3619,7 +3618,6 @@
|
||||
"RecipeManifestResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"recipe",
|
||||
"lastModified",
|
||||
"id"
|
||||
@@ -3631,9 +3629,6 @@
|
||||
"lastModified": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"recipe": {
|
||||
"$ref": "#/components/schemas/Recipe"
|
||||
}
|
||||
@@ -3843,10 +3838,6 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"is_global": {
|
||||
"type": "boolean",
|
||||
"nullable": true
|
||||
},
|
||||
"recipe": {
|
||||
"$ref": "#/components/schemas/Recipe"
|
||||
}
|
||||
|
||||
@@ -274,9 +274,6 @@ export const startTetrateSetup = <ThrowOnError extends boolean = false>(options?
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Recipe configuration from the current session
|
||||
*/
|
||||
export const createRecipe = <ThrowOnError extends boolean = false>(options: Options<CreateRecipeData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<CreateRecipeResponses, CreateRecipeErrors, ThrowOnError>({
|
||||
url: '/recipes/create',
|
||||
|
||||
@@ -571,7 +571,6 @@ export type Recipe = {
|
||||
export type RecipeManifestResponse = {
|
||||
id: string;
|
||||
lastModified: string;
|
||||
name: string;
|
||||
recipe: Recipe;
|
||||
};
|
||||
|
||||
@@ -650,7 +649,6 @@ export type RunNowResponse = {
|
||||
|
||||
export type SaveRecipeRequest = {
|
||||
id?: string | null;
|
||||
is_global?: boolean | null;
|
||||
recipe: Recipe;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -52,7 +52,6 @@ export function useAgent(): UseAgentReturn {
|
||||
const [recipeFromAppConfig, setRecipeFromAppConfig] = useState<Recipe | null>(
|
||||
(window.appConfig.get('recipe') as Recipe) || null
|
||||
);
|
||||
|
||||
const { getExtensions, addExtension, read } = useConfig();
|
||||
|
||||
const resetChat = useCallback(() => {
|
||||
|
||||
@@ -286,8 +286,12 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => {
|
||||
});
|
||||
};
|
||||
|
||||
const recipeId: string | null =
|
||||
(window.appConfig.get('recipeId') as string | null | undefined) ?? null;
|
||||
|
||||
return {
|
||||
recipe: finalRecipe,
|
||||
recipeId,
|
||||
recipeParameters,
|
||||
filteredParameters,
|
||||
initialPrompt,
|
||||
|
||||
+28
-12
@@ -509,7 +509,8 @@ const createChat = async (
|
||||
recipe?: Recipe, // Recipe configuration when already loaded, takes precedence over deeplink
|
||||
viewType?: string,
|
||||
recipeDeeplink?: string, // Raw deeplink used as a fallback when recipe is not loaded. Required on new windows as we need to wait for the window to load before decoding.
|
||||
scheduledJobId?: string // Scheduled job ID if applicable
|
||||
scheduledJobId?: string, // Scheduled job ID if applicable
|
||||
recipeId?: string
|
||||
) => {
|
||||
// Initialize variables for process and configuration
|
||||
let port = 0;
|
||||
@@ -581,6 +582,7 @@ const createChat = async (
|
||||
GOOSE_BASE_URL_SHARE: baseUrlShare,
|
||||
GOOSE_VERSION: version,
|
||||
recipe: recipe,
|
||||
recipeId: recipeId,
|
||||
}),
|
||||
],
|
||||
partition: 'persist:goose', // Add this line to ensure persistence
|
||||
@@ -1961,18 +1963,32 @@ async function appMain() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('create-chat-window', (_, query, dir, version, resumeSessionId, recipe, viewType) => {
|
||||
if (!dir?.trim()) {
|
||||
const recentDirs = loadRecentDirs();
|
||||
dir = recentDirs.length > 0 ? recentDirs[0] : undefined;
|
||||
ipcMain.on(
|
||||
'create-chat-window',
|
||||
(_, query, dir, version, resumeSessionId, recipe, viewType, recipeId) => {
|
||||
if (!dir?.trim()) {
|
||||
const recentDirs = loadRecentDirs();
|
||||
dir = recentDirs.length > 0 ? recentDirs[0] : undefined;
|
||||
}
|
||||
|
||||
// Log the recipe for debugging
|
||||
console.log('Creating chat window with recipe:', recipe);
|
||||
|
||||
// Pass recipe as part of viewOptions when viewType is recipeEditor
|
||||
createChat(
|
||||
app,
|
||||
query,
|
||||
dir,
|
||||
version,
|
||||
resumeSessionId,
|
||||
recipe,
|
||||
viewType,
|
||||
undefined,
|
||||
undefined,
|
||||
recipeId
|
||||
);
|
||||
}
|
||||
|
||||
// Log the recipe for debugging
|
||||
console.log('Creating chat window with recipe:', recipe);
|
||||
|
||||
// Pass recipe as part of viewOptions when viewType is recipeEditor
|
||||
createChat(app, query, dir, version, resumeSessionId, recipe, viewType);
|
||||
});
|
||||
);
|
||||
|
||||
ipcMain.on('notify', (_event, data) => {
|
||||
try {
|
||||
|
||||
@@ -53,7 +53,8 @@ type ElectronAPI = {
|
||||
version?: string,
|
||||
resumeSessionId?: string,
|
||||
recipe?: Recipe,
|
||||
viewType?: string
|
||||
viewType?: string,
|
||||
recipeId?: string
|
||||
) => void;
|
||||
logInfo: (txt: string) => void;
|
||||
showNotification: (data: NotificationData) => void;
|
||||
@@ -140,9 +141,19 @@ const electronAPI: ElectronAPI = {
|
||||
version?: string,
|
||||
resumeSessionId?: string,
|
||||
recipe?: Recipe,
|
||||
viewType?: string
|
||||
viewType?: string,
|
||||
recipeId?: string
|
||||
) =>
|
||||
ipcRenderer.send('create-chat-window', query, dir, version, resumeSessionId, recipe, viewType),
|
||||
ipcRenderer.send(
|
||||
'create-chat-window',
|
||||
query,
|
||||
dir,
|
||||
version,
|
||||
resumeSessionId,
|
||||
recipe,
|
||||
viewType,
|
||||
recipeId
|
||||
),
|
||||
logInfo: (txt: string) => ipcRenderer.send('logInfo', txt),
|
||||
showNotification: (data: NotificationData) => ipcRenderer.send('notify', data),
|
||||
showMessageBox: (options: MessageBoxOptions) => ipcRenderer.invoke('show-message-box', options),
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { listRecipes, RecipeManifestResponse } from '../api';
|
||||
import { Recipe } from './index';
|
||||
import * as yaml from 'yaml';
|
||||
import { validateRecipe, getValidationErrorMessages } from './validation';
|
||||
|
||||
export interface SaveRecipeOptions {
|
||||
name: string;
|
||||
title?: string;
|
||||
global?: boolean; // true for global (~/.config/goose/recipes/), false for project-specific (.goose/recipes/)
|
||||
}
|
||||
|
||||
export interface SavedRecipe {
|
||||
name: string;
|
||||
recipe: Recipe;
|
||||
isGlobal: boolean;
|
||||
lastModified: Date;
|
||||
isArchived?: boolean;
|
||||
filename: string; // The actual filename used
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a recipe name to be safe for use as a filename
|
||||
*/
|
||||
function sanitizeRecipeName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9-_\s]/g, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a lastModified value that could be a string or Date
|
||||
*/
|
||||
function parseLastModified(val: string | Date): Date {
|
||||
return val instanceof Date ? val : new Date(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage directory path for recipes
|
||||
*/
|
||||
export function getStorageDirectory(isGlobal: boolean): string {
|
||||
if (isGlobal) {
|
||||
return '~/.config/goose/recipes';
|
||||
} else {
|
||||
// For directory recipes, build absolute path using working directory
|
||||
const workingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
|
||||
return `${workingDir}/.goose/recipes`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file path for a recipe based on its name
|
||||
*/
|
||||
function getRecipeFilePath(recipeName: string, isGlobal: boolean): string {
|
||||
const dir = getStorageDirectory(isGlobal);
|
||||
return `${dir}/${recipeName}.yaml`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save recipe to file
|
||||
*/
|
||||
async function saveRecipeToFile(recipe: SavedRecipe): Promise<boolean> {
|
||||
const filePath = getRecipeFilePath(recipe.name, recipe.isGlobal);
|
||||
|
||||
// Ensure directory exists
|
||||
const dirPath = getStorageDirectory(recipe.isGlobal);
|
||||
await window.electron.ensureDirectory(dirPath);
|
||||
|
||||
// Convert to YAML and save
|
||||
const yamlContent = yaml.stringify(recipe);
|
||||
return await window.electron.writeFile(filePath, yamlContent);
|
||||
}
|
||||
/**
|
||||
* Save a recipe to a file using IPC.
|
||||
*/
|
||||
export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Promise<string> {
|
||||
const { name, title, global = true } = options;
|
||||
|
||||
let sanitizedName: string;
|
||||
|
||||
if (title) {
|
||||
recipe.title = title.trim();
|
||||
sanitizedName = generateRecipeFilename(recipe);
|
||||
if (!sanitizedName) {
|
||||
throw new Error('Invalid recipe title - cannot generate filename');
|
||||
}
|
||||
} else {
|
||||
// This branch should now be considered deprecated and will be removed once the same functionality
|
||||
// is incorporated in CreateRecipeForm
|
||||
sanitizedName = sanitizeRecipeName(name);
|
||||
if (!sanitizedName) {
|
||||
throw new Error('Invalid recipe name');
|
||||
}
|
||||
}
|
||||
|
||||
const validationResult = validateRecipe(recipe);
|
||||
if (!validationResult.success) {
|
||||
const errorMessages = getValidationErrorMessages(validationResult.errors);
|
||||
throw new Error(`Recipe validation failed: ${errorMessages.join(', ')}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Create saved recipe object
|
||||
const savedRecipe: SavedRecipe = {
|
||||
name: sanitizedName,
|
||||
filename: sanitizedName,
|
||||
recipe: recipe,
|
||||
isGlobal: global,
|
||||
lastModified: new Date(),
|
||||
isArchived: false,
|
||||
};
|
||||
|
||||
// Save to file
|
||||
const success = await saveRecipeToFile(savedRecipe);
|
||||
|
||||
if (!success) {
|
||||
throw new Error('Failed to save recipe file');
|
||||
}
|
||||
|
||||
// Return identifier for the saved recipe
|
||||
return `${global ? 'global' : 'local'}:${sanitizedName}`;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSavedRecipes(): Promise<RecipeManifestResponse[]> {
|
||||
try {
|
||||
const listRecipeResponse = await listRecipes();
|
||||
return listRecipeResponse?.data?.recipe_manifest_responses ?? [];
|
||||
} catch (error) {
|
||||
console.warn('Failed to list saved recipes:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToLocaleDateString(lastModified: string): string {
|
||||
if (lastModified) {
|
||||
return parseLastModified(lastModified).toLocaleDateString();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a suggested filename for a recipe based on its title.
|
||||
*
|
||||
* @param recipe The recipe to generate a filename for
|
||||
* @returns A sanitized filename suitable for use as a recipe name
|
||||
*/
|
||||
export function generateRecipeFilename(recipe: Recipe): string {
|
||||
const baseName = recipe.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-zA-Z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.trim();
|
||||
|
||||
return baseName || 'untitled-recipe';
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifestResponse } from '../api';
|
||||
|
||||
export async function saveRecipe(recipe: Recipe, recipeId?: string | null): Promise<void> {
|
||||
try {
|
||||
await saveRecipeApi({
|
||||
body: {
|
||||
recipe,
|
||||
id: recipeId,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
} catch (error) {
|
||||
let error_message = 'unknown error';
|
||||
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||
error_message = error.message as string;
|
||||
}
|
||||
throw new Error(error_message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSavedRecipes(): Promise<RecipeManifestResponse[]> {
|
||||
try {
|
||||
const listRecipeResponse = await listRecipes();
|
||||
return listRecipeResponse?.data?.recipe_manifest_responses ?? [];
|
||||
} catch (error) {
|
||||
console.warn('Failed to list saved recipes:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseLastModified(val: string | Date): Date {
|
||||
return val instanceof Date ? val : new Date(val);
|
||||
}
|
||||
|
||||
export function convertToLocaleDateString(lastModified: string): string {
|
||||
if (lastModified) {
|
||||
return parseLastModified(lastModified).toLocaleDateString();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getStorageDirectory(isGlobal: boolean): string {
|
||||
if (isGlobal) {
|
||||
return '~/.config/goose/recipes';
|
||||
} else {
|
||||
// For directory recipes, build absolute path using working directory
|
||||
const workingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
|
||||
return `${workingDir}/.goose/recipes`;
|
||||
}
|
||||
}
|
||||
@@ -1,379 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateRecipe,
|
||||
validateJsonSchema,
|
||||
getValidationErrorMessages,
|
||||
getRecipeJsonSchema,
|
||||
} from './validation';
|
||||
import type { Recipe } from '../api/types.gen';
|
||||
import { getRecipeJsonSchema } from './validation';
|
||||
|
||||
describe('Recipe Validation', () => {
|
||||
const validRecipe: Recipe = {
|
||||
version: '1.0.0',
|
||||
title: 'Test Recipe',
|
||||
description: 'A test recipe for validation',
|
||||
instructions: 'Do something useful',
|
||||
activities: ['Test activity 1', 'Test activity 2'],
|
||||
extensions: [
|
||||
{
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
display_name: 'Developer',
|
||||
description: 'Developer',
|
||||
timeout: 300,
|
||||
bundled: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const validRecipeWithPrompt: Recipe = {
|
||||
version: '1.0.0',
|
||||
title: 'Prompt Recipe',
|
||||
description: 'A recipe using prompt instead of instructions',
|
||||
prompt: 'You are a helpful assistant',
|
||||
activities: ['Help users'],
|
||||
extensions: [
|
||||
{
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
description: 'Developer',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const validRecipeWithParameters: Recipe = {
|
||||
version: '1.0.0',
|
||||
title: 'Parameterized Recipe',
|
||||
description: 'A recipe with parameters',
|
||||
instructions: 'Process the file at {{ file_path }}',
|
||||
parameters: [
|
||||
{
|
||||
key: 'file_path',
|
||||
input_type: 'string',
|
||||
requirement: 'required',
|
||||
description: 'Path to the file to process',
|
||||
},
|
||||
],
|
||||
activities: ['Process file'],
|
||||
extensions: [
|
||||
{
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
description: 'developer',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const validRecipeWithAuthor: Recipe = {
|
||||
version: '1.0.0',
|
||||
title: 'Authored Recipe',
|
||||
author: {
|
||||
contact: 'test@example.com',
|
||||
},
|
||||
description: 'A recipe with author information',
|
||||
instructions: 'Do something',
|
||||
activities: ['Activity'],
|
||||
extensions: [
|
||||
{
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
description: 'developer',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('validateRecipe', () => {
|
||||
describe('valid recipes', () => {
|
||||
it('validates a basic valid recipe', () => {
|
||||
const result = validateRecipe(validRecipe);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.data).toEqual(validRecipe);
|
||||
});
|
||||
|
||||
it('validates a recipe with prompt instead of instructions', () => {
|
||||
const result = validateRecipe(validRecipeWithPrompt);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.data).toEqual(validRecipeWithPrompt);
|
||||
});
|
||||
|
||||
it('validates a recipe with parameters', () => {
|
||||
const result = validateRecipe(validRecipeWithParameters);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.data).toEqual(validRecipeWithParameters);
|
||||
});
|
||||
|
||||
it('validates a recipe with author information', () => {
|
||||
const result = validateRecipe(validRecipeWithAuthor);
|
||||
if (!result.success) {
|
||||
console.log('Author validation errors:', result.errors);
|
||||
}
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
});
|
||||
|
||||
it('validates a recipe with minimal required fields', () => {
|
||||
const minimalRecipe = {
|
||||
version: '1.0.0',
|
||||
title: 'Minimal',
|
||||
description: 'Minimal recipe',
|
||||
instructions: 'Do something',
|
||||
activities: ['Activity'],
|
||||
extensions: [],
|
||||
};
|
||||
|
||||
const result = validateRecipe(minimalRecipe);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid recipes', () => {
|
||||
it('rejects recipe without title', () => {
|
||||
const invalidRecipe = {
|
||||
...validRecipe,
|
||||
title: undefined,
|
||||
};
|
||||
|
||||
const result = validateRecipe(invalidRecipe);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects recipe without description', () => {
|
||||
const invalidRecipe = {
|
||||
...validRecipe,
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
const result = validateRecipe(invalidRecipe);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('allows recipe without version (version is optional)', () => {
|
||||
const recipeWithoutVersion = {
|
||||
...validRecipe,
|
||||
version: undefined,
|
||||
};
|
||||
|
||||
const result = validateRecipe(recipeWithoutVersion);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects recipe without instructions or prompt', () => {
|
||||
const invalidRecipe = {
|
||||
...validRecipe,
|
||||
instructions: undefined,
|
||||
prompt: undefined,
|
||||
};
|
||||
|
||||
const result = validateRecipe(invalidRecipe);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Either instructions or prompt must be provided');
|
||||
});
|
||||
|
||||
it('validates recipe with minimal extension structure', () => {
|
||||
const recipeWithMinimalExtension = {
|
||||
...validRecipe,
|
||||
extensions: [
|
||||
{
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
description: 'description',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = validateRecipe(recipeWithMinimalExtension);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('validates recipe with incomplete parameter structure', () => {
|
||||
const recipeWithIncompleteParam = {
|
||||
...validRecipe,
|
||||
parameters: [
|
||||
{
|
||||
key: 'test',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = validateRecipe(recipeWithIncompleteParam);
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-object input', () => {
|
||||
const result = validateRecipe('not an object');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects null input', () => {
|
||||
const result = validateRecipe(null);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects undefined input', () => {
|
||||
const result = validateRecipe(undefined);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty arrays gracefully', () => {
|
||||
const recipeWithEmptyArrays = {
|
||||
...validRecipe,
|
||||
activities: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
};
|
||||
|
||||
const result = validateRecipe(recipeWithEmptyArrays);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('handles extra properties', () => {
|
||||
const recipeWithExtra = {
|
||||
...validRecipe,
|
||||
extraField: 'should be ignored or handled gracefully',
|
||||
};
|
||||
|
||||
const result = validateRecipe(recipeWithExtra);
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles very long strings', () => {
|
||||
const longString = 'a'.repeat(10000);
|
||||
const recipeWithLongStrings = {
|
||||
...validRecipe,
|
||||
title: longString,
|
||||
description: longString,
|
||||
instructions: longString,
|
||||
};
|
||||
|
||||
const result = validateRecipe(recipeWithLongStrings);
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateJsonSchema', () => {
|
||||
describe('valid JSON schemas', () => {
|
||||
it('validates a simple JSON schema', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
},
|
||||
required: ['name'],
|
||||
};
|
||||
|
||||
const result = validateJsonSchema(schema);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.data).toEqual(schema);
|
||||
});
|
||||
|
||||
it('validates null schema', () => {
|
||||
const result = validateJsonSchema(null);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.data).toBe(null);
|
||||
});
|
||||
|
||||
it('validates undefined schema', () => {
|
||||
const result = validateJsonSchema(undefined);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.data).toBe(undefined);
|
||||
});
|
||||
|
||||
it('validates complex JSON schema', () => {
|
||||
const schema = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
properties: {
|
||||
users: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
profile: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateJsonSchema(schema);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(schema);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid JSON schemas', () => {
|
||||
it('rejects string input', () => {
|
||||
const result = validateJsonSchema('not an object');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('JSON Schema must be an object');
|
||||
});
|
||||
|
||||
it('rejects number input', () => {
|
||||
const result = validateJsonSchema(42);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('JSON Schema must be an object');
|
||||
});
|
||||
|
||||
it('rejects boolean input', () => {
|
||||
const result = validateJsonSchema(true);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('JSON Schema must be an object');
|
||||
});
|
||||
|
||||
it('validates array input as valid JSON schema', () => {
|
||||
const result = validateJsonSchema(['not', 'an', 'object']);
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('helper functions', () => {
|
||||
describe('getValidationErrorMessages', () => {
|
||||
it('returns the same array of error messages', () => {
|
||||
const errors = ['title: Required', 'description: Required', 'Invalid format'];
|
||||
const messages = getValidationErrorMessages(errors);
|
||||
expect(messages).toEqual(errors);
|
||||
expect(messages).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const errors: string[] = [];
|
||||
const messages = getValidationErrorMessages(errors);
|
||||
expect(messages).toHaveLength(0);
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecipeJsonSchema', () => {
|
||||
it('returns a valid JSON schema object', () => {
|
||||
const schema = getRecipeJsonSchema();
|
||||
@@ -401,162 +29,4 @@ describe('Recipe Validation', () => {
|
||||
expect(schema1).toEqual(schema2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling and edge cases', () => {
|
||||
it('handles validation errors gracefully', () => {
|
||||
// Test with malformed data that might cause validation to throw
|
||||
const malformedData = {
|
||||
version: { not: 'a string' },
|
||||
title: ['not', 'a', 'string'],
|
||||
description: 123,
|
||||
instructions: null,
|
||||
activities: 'not an array',
|
||||
extensions: 'not an array',
|
||||
};
|
||||
|
||||
const result = validateRecipe(malformedData);
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles circular references gracefully', () => {
|
||||
const circularObj: Record<string, unknown> = { title: 'Test' };
|
||||
(circularObj as Record<string, unknown>).self = circularObj;
|
||||
|
||||
const result = validateRecipe(circularObj);
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles very deep nested objects', () => {
|
||||
let deepObj: Record<string, unknown> = {
|
||||
version: '1.0.0',
|
||||
title: 'Deep',
|
||||
description: 'Test',
|
||||
};
|
||||
let current: Record<string, unknown> = deepObj;
|
||||
|
||||
// Create a deeply nested structure
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const nested = { level: i };
|
||||
current.nested = nested;
|
||||
current = nested as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const result = validateRecipe(deepObj);
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world recipe examples', () => {
|
||||
it('validates readme-bot style recipe', () => {
|
||||
const readmeBotRecipe = {
|
||||
version: '1.0.0',
|
||||
title: 'Readme Bot',
|
||||
author: {
|
||||
contact: 'DOsinga',
|
||||
},
|
||||
description: 'Generates or updates a readme',
|
||||
instructions: 'You are a documentation expert',
|
||||
activities: [
|
||||
'Scan project directory for documentation context',
|
||||
'Generate a new README draft',
|
||||
'Compare new draft with existing README.md',
|
||||
],
|
||||
extensions: [
|
||||
{
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
display_name: 'Developer',
|
||||
timeout: 300,
|
||||
bundled: true,
|
||||
},
|
||||
],
|
||||
prompt: "Here's what to do step by step: 1. The current folder is a software project...",
|
||||
};
|
||||
|
||||
const result = validateRecipe(readmeBotRecipe);
|
||||
if (!result.success) {
|
||||
console.log('ReadmeBot validation errors:', result.errors);
|
||||
}
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
});
|
||||
|
||||
it('validates lint-my-code style recipe with parameters', () => {
|
||||
const lintRecipe = {
|
||||
version: '1.0.0',
|
||||
title: 'Lint My Code',
|
||||
author: {
|
||||
contact: 'iandouglas',
|
||||
},
|
||||
description:
|
||||
'Analyzes code files for syntax and layout issues using available linting tools',
|
||||
instructions:
|
||||
'You are a code quality expert that helps identify syntax and layout issues in code files',
|
||||
activities: [
|
||||
'Detect file type and programming language',
|
||||
'Check for available linting tools in the project',
|
||||
'Run appropriate linters for syntax and layout checking',
|
||||
'Provide recommendations if no linters are found',
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
key: 'file_path',
|
||||
input_type: 'string',
|
||||
requirement: 'required',
|
||||
description: 'Path to the file you want to lint',
|
||||
},
|
||||
],
|
||||
extensions: [
|
||||
{
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
display_name: 'Developer',
|
||||
timeout: 300,
|
||||
bundled: true,
|
||||
},
|
||||
],
|
||||
prompt:
|
||||
'I need you to lint the file at {{ file_path }} for syntax and layout issues only...',
|
||||
};
|
||||
|
||||
const result = validateRecipe(lintRecipe);
|
||||
if (!result.success) {
|
||||
console.log('LintRecipe validation errors:', result.errors);
|
||||
}
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
});
|
||||
|
||||
it('validates 404Portfolio style recipe with multiple extensions', () => {
|
||||
const portfolioRecipe = {
|
||||
version: '1.0.0',
|
||||
title: '404Portfolio',
|
||||
description: 'Create personalized, creative 404 pages using public profile data',
|
||||
instructions: 'Create an engaging 404 error page that tells a creative story...',
|
||||
activities: [
|
||||
'Build error page from GitHub repos',
|
||||
'Generate error page from dev.to blog posts',
|
||||
'Create a 404 page featuring Bluesky bio',
|
||||
],
|
||||
extensions: [
|
||||
{
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
description: 'developer',
|
||||
},
|
||||
{
|
||||
type: 'builtin',
|
||||
name: 'computercontroller',
|
||||
description: 'computercontroller',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = validateRecipe(portfolioRecipe);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { z } from 'zod';
|
||||
import type { Recipe } from '../api/types.gen';
|
||||
|
||||
/**
|
||||
* OpenAPI-based validation utilities for Recipe objects.
|
||||
*
|
||||
@@ -115,285 +112,6 @@ function resolveRefs(
|
||||
return schema;
|
||||
}
|
||||
|
||||
export type RecipeValidationResult = {
|
||||
success: boolean;
|
||||
errors: string[];
|
||||
data?: Recipe | unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts an OpenAPI schema to a Zod schema dynamically
|
||||
*/
|
||||
function openApiSchemaToZod(schema: Record<string, unknown>): z.ZodTypeAny {
|
||||
if (!schema) {
|
||||
return z.any();
|
||||
}
|
||||
|
||||
// Handle different schema types
|
||||
switch (schema.type) {
|
||||
case 'string': {
|
||||
let stringSchema = z.string();
|
||||
if (typeof schema.minLength === 'number') {
|
||||
stringSchema = stringSchema.min(schema.minLength);
|
||||
}
|
||||
if (typeof schema.maxLength === 'number') {
|
||||
stringSchema = stringSchema.max(schema.maxLength);
|
||||
}
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return z.enum(schema.enum as [string, ...string[]]);
|
||||
}
|
||||
if (schema.format === 'date-time') {
|
||||
stringSchema = stringSchema.datetime();
|
||||
}
|
||||
if (typeof schema.pattern === 'string') {
|
||||
stringSchema = stringSchema.regex(new RegExp(schema.pattern));
|
||||
}
|
||||
return schema.nullable ? stringSchema.nullable() : stringSchema;
|
||||
}
|
||||
|
||||
case 'number':
|
||||
case 'integer': {
|
||||
let numberSchema = schema.type === 'integer' ? z.number().int() : z.number();
|
||||
if (typeof schema.minimum === 'number') {
|
||||
numberSchema = numberSchema.min(schema.minimum);
|
||||
}
|
||||
if (typeof schema.maximum === 'number') {
|
||||
numberSchema = numberSchema.max(schema.maximum);
|
||||
}
|
||||
return schema.nullable ? numberSchema.nullable() : numberSchema;
|
||||
}
|
||||
|
||||
case 'boolean':
|
||||
return schema.nullable ? z.boolean().nullable() : z.boolean();
|
||||
|
||||
case 'array': {
|
||||
const itemSchema = schema.items
|
||||
? openApiSchemaToZod(schema.items as Record<string, unknown>)
|
||||
: z.any();
|
||||
let arraySchema = z.array(itemSchema);
|
||||
if (typeof schema.minItems === 'number') {
|
||||
arraySchema = arraySchema.min(schema.minItems);
|
||||
}
|
||||
if (typeof schema.maxItems === 'number') {
|
||||
arraySchema = arraySchema.max(schema.maxItems);
|
||||
}
|
||||
return schema.nullable ? arraySchema.nullable() : arraySchema;
|
||||
}
|
||||
|
||||
case 'object':
|
||||
if (schema.properties && typeof schema.properties === 'object') {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
||||
shape[propName] = openApiSchemaToZod(propSchema as Record<string, unknown>);
|
||||
}
|
||||
|
||||
// Make optional properties optional based on required array
|
||||
const optionalShape: Record<string, z.ZodTypeAny> = {};
|
||||
const requiredFields =
|
||||
schema.required && Array.isArray(schema.required) ? schema.required : [];
|
||||
|
||||
for (const [propName, zodSchema] of Object.entries(shape)) {
|
||||
if (requiredFields.includes(propName)) {
|
||||
optionalShape[propName] = zodSchema;
|
||||
} else {
|
||||
optionalShape[propName] = zodSchema.optional();
|
||||
}
|
||||
}
|
||||
|
||||
let objectSchema = z.object(optionalShape);
|
||||
|
||||
if (schema.additionalProperties === true) {
|
||||
return schema.nullable
|
||||
? objectSchema.passthrough().nullable()
|
||||
: objectSchema.passthrough();
|
||||
} else if (schema.additionalProperties === false) {
|
||||
return schema.nullable ? objectSchema.strict().nullable() : objectSchema.strict();
|
||||
}
|
||||
|
||||
return schema.nullable ? objectSchema.nullable() : objectSchema;
|
||||
}
|
||||
return schema.nullable ? z.record(z.any()).nullable() : z.record(z.any());
|
||||
|
||||
default:
|
||||
// Handle $ref, allOf, oneOf, anyOf, etc.
|
||||
if (typeof schema.$ref === 'string') {
|
||||
// Resolve the $ref and convert the resolved schema to Zod
|
||||
const resolvedSchema = resolveRefs(schema, openApiSpec as Record<string, unknown>);
|
||||
// If resolution changed the schema, convert the resolved version
|
||||
if (resolvedSchema !== schema) {
|
||||
return openApiSchemaToZod(resolvedSchema);
|
||||
}
|
||||
// If resolution failed, fall back to z.any()
|
||||
return z.any();
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.allOf)) {
|
||||
// Intersection of all schemas
|
||||
return schema.allOf.reduce((acc: z.ZodTypeAny, subSchema: unknown) => {
|
||||
return acc.and(openApiSchemaToZod(subSchema as Record<string, unknown>));
|
||||
}, z.any());
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf)) {
|
||||
// Union of schemas
|
||||
const schemaArray = (schema.oneOf || schema.anyOf) as unknown[];
|
||||
const schemas = schemaArray.map((subSchema: unknown) =>
|
||||
openApiSchemaToZod(subSchema as Record<string, unknown>)
|
||||
);
|
||||
return z.union(schemas as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]);
|
||||
}
|
||||
|
||||
return z.any();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a value against an OpenAPI schema using Zod
|
||||
*/
|
||||
function validateAgainstSchema(value: unknown, schema: Record<string, unknown>): string[] {
|
||||
if (!schema) {
|
||||
return ['Schema not found'];
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve $refs in the schema before converting to Zod
|
||||
const resolvedSchema = resolveRefs(schema, openApiSpec as Record<string, unknown>);
|
||||
const zodSchema = openApiSchemaToZod(resolvedSchema);
|
||||
const result = zodSchema.safeParse(value);
|
||||
|
||||
if (result.success) {
|
||||
return [];
|
||||
} else {
|
||||
return result.error.errors.map((err) => {
|
||||
const path = err.path.length > 0 ? `${err.path.join('.')}: ` : '';
|
||||
return `${path}${err.message}`;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return [`Schema conversion error: ${error instanceof Error ? error.message : 'Unknown error'}`];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a recipe object against the OpenAPI-derived schema.
|
||||
* This provides structural validation that automatically stays in sync
|
||||
* with the backend's OpenAPI specification.
|
||||
*/
|
||||
export function validateRecipe(recipe: unknown): RecipeValidationResult {
|
||||
try {
|
||||
const schema = getRecipeSchema();
|
||||
if (!schema) {
|
||||
return {
|
||||
success: false,
|
||||
errors: ['Recipe schema not found in OpenAPI specification'],
|
||||
};
|
||||
}
|
||||
|
||||
const errors = validateAgainstSchema(recipe, schema as Record<string, unknown>);
|
||||
|
||||
// Additional business logic validation
|
||||
if (typeof recipe === 'object' && recipe !== null) {
|
||||
const recipeObj = recipe as Partial<Recipe>;
|
||||
if (!recipeObj.instructions && !recipeObj.prompt) {
|
||||
errors.push('Either instructions or prompt must be provided');
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
errors: [],
|
||||
data: recipe as Recipe,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
errors,
|
||||
data: undefined,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [`Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`],
|
||||
data: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema validation for the response.json_schema field.
|
||||
* Uses basic structural validation instead of AJV to avoid CSP eval security issues.
|
||||
*/
|
||||
export function validateJsonSchema(schema: unknown): RecipeValidationResult {
|
||||
try {
|
||||
// Allow null/undefined schemas
|
||||
if (schema === null || schema === undefined) {
|
||||
return { success: true, errors: [], data: schema as unknown };
|
||||
}
|
||||
|
||||
if (typeof schema !== 'object') {
|
||||
return {
|
||||
success: false,
|
||||
errors: ['JSON Schema must be an object'],
|
||||
data: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check for valid JSON Schema structure
|
||||
if (schemaObj.type && typeof schemaObj.type !== 'string' && !Array.isArray(schemaObj.type)) {
|
||||
errors.push('Invalid type field: must be a string or array');
|
||||
}
|
||||
|
||||
// Check for valid properties structure if it exists
|
||||
if (schemaObj.properties && typeof schemaObj.properties !== 'object') {
|
||||
errors.push('Invalid properties field: must be an object');
|
||||
}
|
||||
|
||||
// Check for valid required array if it exists
|
||||
if (schemaObj.required && !Array.isArray(schemaObj.required)) {
|
||||
errors.push('Invalid required field: must be an array');
|
||||
}
|
||||
|
||||
// Check for valid items structure if it exists (for array types)
|
||||
if (schemaObj.items && typeof schemaObj.items !== 'object' && !Array.isArray(schemaObj.items)) {
|
||||
errors.push('Invalid items field: must be an object or array');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
errors: errors.map((err) => `Invalid JSON Schema: ${err}`),
|
||||
data: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
errors: [],
|
||||
data: schema as unknown,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
`JSON Schema validation error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
],
|
||||
data: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to format validation error messages
|
||||
*/
|
||||
export function getValidationErrorMessages(errors: string[]): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a JSON schema representation derived directly from the OpenAPI specification.
|
||||
* This schema is used for documentation in form help text.
|
||||
|
||||
Reference in New Issue
Block a user