feat: edit model and extensions of a recipe from GUI (#6804)
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { Geese } from '../icons/Geese';
|
||||
import Copy from '../icons/Copy';
|
||||
import { ExtensionConfig } from '../ConfigContext';
|
||||
import { Button } from '../ui/button';
|
||||
import type { Settings } from '../../api';
|
||||
|
||||
import { RecipeFormFields } from './shared/RecipeFormFields';
|
||||
import { RecipeFormData } from './shared/recipeFormSchema';
|
||||
@@ -40,6 +41,9 @@ export default function CreateEditRecipeModal({
|
||||
jsonSchema: recipe.response?.json_schema
|
||||
? JSON.stringify(recipe.response.json_schema, null, 2)
|
||||
: '',
|
||||
model: recipe.settings?.goose_model ?? undefined,
|
||||
provider: recipe.settings?.goose_provider ?? undefined,
|
||||
extensions: recipe.extensions || undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -50,6 +54,9 @@ export default function CreateEditRecipeModal({
|
||||
activities: [],
|
||||
parameters: [],
|
||||
jsonSchema: '',
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
extensions: undefined,
|
||||
};
|
||||
}, [recipe]);
|
||||
|
||||
@@ -65,6 +72,9 @@ export default function CreateEditRecipeModal({
|
||||
const [activities, setActivities] = useState(form.state.values.activities);
|
||||
const [parameters, setParameters] = useState(form.state.values.parameters);
|
||||
const [jsonSchema, setJsonSchema] = useState(form.state.values.jsonSchema);
|
||||
const [model, setModel] = useState(form.state.values.model);
|
||||
const [provider, setProvider] = useState(form.state.values.provider);
|
||||
const [extensions, setExtensions] = useState(form.state.values.extensions);
|
||||
|
||||
// Subscribe to form changes to update local state
|
||||
useEffect(() => {
|
||||
@@ -76,15 +86,14 @@ export default function CreateEditRecipeModal({
|
||||
setActivities(form.state.values.activities);
|
||||
setParameters(form.state.values.parameters);
|
||||
setJsonSchema(form.state.values.jsonSchema);
|
||||
setModel(form.state.values.model);
|
||||
setProvider(form.state.values.provider);
|
||||
setExtensions(form.state.values.extensions);
|
||||
});
|
||||
}, [form]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [recipeExtensions] = useState<ExtensionConfig[] | undefined>(() => {
|
||||
return recipe?.extensions ?? undefined;
|
||||
});
|
||||
|
||||
// Reset form when recipe changes
|
||||
useEffect(() => {
|
||||
if (recipe) {
|
||||
@@ -128,10 +137,32 @@ export default function CreateEditRecipeModal({
|
||||
}
|
||||
}
|
||||
|
||||
const extensions = recipeExtensions?.map((extension) =>
|
||||
'envs' in extension ? { ...extension, envs: undefined } : extension
|
||||
const cleanedExtensions = extensions?.map(
|
||||
(extension: ExtensionConfig & { envs?: unknown; enabled?: boolean }) => {
|
||||
const { envs: _envs, enabled: _enabled, ...rest } = extension;
|
||||
return rest;
|
||||
}
|
||||
) as ExtensionConfig[] | undefined;
|
||||
|
||||
const mergedSettings: Settings = {
|
||||
...(recipe?.settings || {}),
|
||||
};
|
||||
if (model !== undefined) {
|
||||
mergedSettings.goose_model = model || null;
|
||||
} else if ('goose_model' in mergedSettings) {
|
||||
delete mergedSettings.goose_model;
|
||||
}
|
||||
if (provider !== undefined) {
|
||||
mergedSettings.goose_provider = provider || null;
|
||||
} else if ('goose_provider' in mergedSettings) {
|
||||
delete mergedSettings.goose_provider;
|
||||
}
|
||||
const settings = Object.values(mergedSettings).some(
|
||||
(value) => value !== undefined && value !== null
|
||||
)
|
||||
? mergedSettings
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...recipe,
|
||||
title,
|
||||
@@ -141,7 +172,8 @@ export default function CreateEditRecipeModal({
|
||||
prompt: prompt || undefined,
|
||||
parameters: formattedParameters,
|
||||
response: responseConfig,
|
||||
extensions,
|
||||
extensions: cleanedExtensions,
|
||||
settings,
|
||||
};
|
||||
}, [
|
||||
recipe,
|
||||
@@ -152,7 +184,9 @@ export default function CreateEditRecipeModal({
|
||||
prompt,
|
||||
parameters,
|
||||
jsonSchema,
|
||||
recipeExtensions,
|
||||
model,
|
||||
provider,
|
||||
extensions,
|
||||
]);
|
||||
|
||||
const requiredFieldsAreFilled = () => {
|
||||
@@ -224,7 +258,9 @@ export default function CreateEditRecipeModal({
|
||||
activities,
|
||||
parameters,
|
||||
jsonSchema,
|
||||
recipeExtensions,
|
||||
model,
|
||||
provider,
|
||||
extensions,
|
||||
getCurrentRecipe,
|
||||
]);
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ vi.mock('../../../recipe/recipe_management', () => ({
|
||||
saveRecipe: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../ConfigContext', () => ({
|
||||
useConfig: () => ({
|
||||
extensionsList: [],
|
||||
getExtensions: vi.fn().mockResolvedValue([]),
|
||||
getProviders: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockCreateRecipe = vi.mocked(createRecipe);
|
||||
|
||||
describe('CreateRecipeFromSessionModal', () => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import { ExtensionConfig } from '../../../api';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Switch } from '../../ui/switch';
|
||||
import { formatExtensionName } from '../../settings/extensions/subcomponents/ExtensionList';
|
||||
|
||||
interface RecipeExtensionSelectorProps {
|
||||
selectedExtensions: ExtensionConfig[];
|
||||
onExtensionsChange: (extensions: ExtensionConfig[]) => void;
|
||||
}
|
||||
|
||||
export const RecipeExtensionSelector = ({
|
||||
selectedExtensions,
|
||||
onExtensionsChange,
|
||||
}: RecipeExtensionSelectorProps) => {
|
||||
const { extensionsList: allExtensions } = useConfig();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const selectedExtensionNames = new Set(selectedExtensions.map((ext) => ext.name));
|
||||
|
||||
const extensionMap = new Map(allExtensions.map((ext) => [ext.name, ext]));
|
||||
|
||||
selectedExtensions.forEach((ext) => {
|
||||
if (!extensionMap.has(ext.name)) {
|
||||
extensionMap.set(ext.name, { ...ext, enabled: true });
|
||||
}
|
||||
});
|
||||
|
||||
const displayExtensions = Array.from(extensionMap.values());
|
||||
|
||||
const handleToggle = (extensionConfig: ExtensionConfig) => {
|
||||
const isSelected = selectedExtensionNames.has(extensionConfig.name);
|
||||
|
||||
if (isSelected) {
|
||||
onExtensionsChange(selectedExtensions.filter((ext) => ext.name !== extensionConfig.name));
|
||||
} else {
|
||||
const { enabled: _enabled, ...cleanExtension } = extensionConfig as ExtensionConfig & {
|
||||
enabled?: boolean;
|
||||
};
|
||||
onExtensionsChange([...selectedExtensions, cleanExtension]);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredExtensions = displayExtensions.filter((ext) => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
ext.name.toLowerCase().includes(query) ||
|
||||
(ext.description && ext.description.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
const sortedExtensions = [...filteredExtensions].sort((a, b) => {
|
||||
const aSelected = selectedExtensionNames.has(a.name);
|
||||
const bSelected = selectedExtensionNames.has(b.name);
|
||||
|
||||
if (aSelected !== bSelected) return aSelected ? -1 : 1;
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const activeCount = selectedExtensions.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-md text-textProminent mb-2 font-bold">
|
||||
Extensions (Optional)
|
||||
</label>
|
||||
<p className="text-textSubtle text-sm mb-4">
|
||||
Select which extensions should be available when running this recipe. Leave empty to use
|
||||
default extensions.
|
||||
</p>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search extensions..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="mb-3"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-textSubtle mb-3 text-right">
|
||||
{activeCount} extension{activeCount !== 1 ? 's' : ''} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto border border-borderSubtle rounded-lg">
|
||||
{sortedExtensions.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-textSubtle">
|
||||
{searchQuery ? 'No extensions found' : 'No extensions available'}
|
||||
</div>
|
||||
) : (
|
||||
sortedExtensions.map((ext) => {
|
||||
const isSelected = selectedExtensionNames.has(ext.name);
|
||||
return (
|
||||
<div
|
||||
key={ext.name}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-bgSubtle transition-colors cursor-pointer border-b border-borderSubtle last:border-b-0"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => handleToggle(ext)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleToggle(ext);
|
||||
}
|
||||
}}
|
||||
title={ext.description || ext.name}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-textStandard">
|
||||
{formatExtensionName(ext.name)}
|
||||
</div>
|
||||
{ext.description && (
|
||||
<div className="text-xs text-textSubtle truncate mt-1">{ext.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="ml-4">
|
||||
<Switch
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggle(ext)}
|
||||
variant="mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Parameter } from '../../../recipe';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { ExtensionConfig } from '../../../api';
|
||||
|
||||
import ParameterInput from '../../parameter/ParameterInput';
|
||||
import RecipeActivityEditor from '../RecipeActivityEditor';
|
||||
@@ -9,6 +10,8 @@ import InstructionsEditor from './InstructionsEditor';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../ui/collapsible';
|
||||
import { RecipeFormApi, RecipeFormData } from './recipeFormSchema';
|
||||
import { RecipeModelSelector } from './RecipeModelSelector';
|
||||
import { RecipeExtensionSelector } from './RecipeExtensionSelector';
|
||||
|
||||
// Type for field API to avoid linting issues - use any to bypass complex type constraints
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -149,7 +152,12 @@ export function RecipeFormFields({
|
||||
const hasActivities = Boolean(values.activities && values.activities.length > 0);
|
||||
const hasParameters = Boolean(values.parameters && values.parameters.length > 0);
|
||||
const hasJsonSchema = Boolean(values.jsonSchema && values.jsonSchema.trim());
|
||||
return hasActivities || hasParameters || hasJsonSchema;
|
||||
const hasModel = Boolean(values.model && values.model.trim());
|
||||
const hasProvider = Boolean(values.provider && values.provider.trim());
|
||||
const hasExtensions = Boolean(values.extensions && values.extensions.length > 0);
|
||||
return (
|
||||
hasActivities || hasParameters || hasJsonSchema || hasModel || hasProvider || hasExtensions
|
||||
);
|
||||
}, []);
|
||||
|
||||
const [advancedOpen, setAdvancedOpen] = useState(() => checkHasAdvancedData(form.state.values));
|
||||
@@ -324,8 +332,10 @@ export function RecipeFormFields({
|
||||
advancedOpen ? 'rotate-0' : '-rotate-90'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-default">Advanced Options</span>
|
||||
<span className="text-xs text-text-muted">Activities, parameters, response schema</span>
|
||||
<span className="text-sm font-medium text-textStandard">Advanced Options</span>
|
||||
<span className="text-xs text-textSubtle">
|
||||
Activities, parameters, model, extensions, response schema
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="mt-4 space-y-4 pl-6 border-l-2 border-border-default ml-2">
|
||||
@@ -460,6 +470,34 @@ export function RecipeFormFields({
|
||||
}}
|
||||
</form.Field>
|
||||
|
||||
{/* Model and Provider Fields */}
|
||||
<form.Field name="provider">
|
||||
{(providerField: FormFieldApi<string | undefined>) => (
|
||||
<form.Field name="model">
|
||||
{(modelField: FormFieldApi<string | undefined>) => (
|
||||
<RecipeModelSelector
|
||||
selectedProvider={providerField.state.value}
|
||||
selectedModel={modelField.state.value}
|
||||
onProviderChange={(provider) => providerField.handleChange(provider)}
|
||||
onModelChange={(model) => modelField.handleChange(model)}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{/* Extensions Field */}
|
||||
<form.Field name="extensions">
|
||||
{(field: FormFieldApi<ExtensionConfig[] | undefined>) => (
|
||||
<RecipeExtensionSelector
|
||||
selectedExtensions={field.state.value || []}
|
||||
onExtensionsChange={(extensions) =>
|
||||
field.handleChange(extensions.length > 0 ? extensions : undefined)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{/* JSON Schema Field */}
|
||||
<form.Field name="jsonSchema">
|
||||
{(field: FormFieldApi<string | undefined>) => (
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Select } from '../../ui/Select';
|
||||
import { Input } from '../../ui/input';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import { fetchModelsForProviders } from '../../settings/models/modelInterface';
|
||||
|
||||
interface RecipeModelSelectorProps {
|
||||
selectedProvider?: string;
|
||||
selectedModel?: string;
|
||||
onProviderChange: (provider: string | undefined) => void;
|
||||
onModelChange: (model: string | undefined) => void;
|
||||
}
|
||||
|
||||
export const RecipeModelSelector = ({
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
}: RecipeModelSelectorProps) => {
|
||||
const { getProviders } = useConfig();
|
||||
const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [modelOptions, setModelOptions] = useState<
|
||||
{ options: { value: string; label: string; provider: string }[] }[]
|
||||
>([]);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [isCustomModel, setIsCustomModel] = useState(false);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setFetchError(null);
|
||||
const providersResponse = await getProviders(false);
|
||||
const activeProviders = providersResponse.filter((provider) => provider.is_configured);
|
||||
|
||||
setProviderOptions([
|
||||
{ value: '', label: 'Use default provider' },
|
||||
...activeProviders.map(({ metadata, name }) => ({
|
||||
value: name,
|
||||
label: metadata.display_name,
|
||||
})),
|
||||
]);
|
||||
|
||||
setLoadingModels(true);
|
||||
const results = await fetchModelsForProviders(activeProviders);
|
||||
|
||||
const groupedOptions: {
|
||||
options: { value: string; label: string; provider: string }[];
|
||||
}[] = [];
|
||||
|
||||
results.forEach(({ provider: p, models, error }) => {
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelList = models || [];
|
||||
const options = modelList.map((m) => ({
|
||||
value: m,
|
||||
label: m,
|
||||
provider: p.name,
|
||||
}));
|
||||
|
||||
if (p.metadata.allows_unlisted_models) {
|
||||
options.push({
|
||||
value: `__custom__:${p.name}`,
|
||||
label: 'Enter a model not listed...',
|
||||
provider: p.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.length > 0) {
|
||||
groupedOptions.push({ options });
|
||||
}
|
||||
});
|
||||
|
||||
setModelOptions(groupedOptions);
|
||||
} catch (error) {
|
||||
console.error('Failed to load providers:', error);
|
||||
setFetchError('Failed to fetch models. Please try again later.');
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
})();
|
||||
}, [getProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingModels && selectedModel && selectedProvider) {
|
||||
const allModels = modelOptions.flatMap((group) => group.options);
|
||||
const modelExists = allModels.some(
|
||||
(opt) => opt.value === selectedModel && opt.provider === selectedProvider
|
||||
);
|
||||
if (!modelExists) {
|
||||
setIsCustomModel(true);
|
||||
}
|
||||
}
|
||||
}, [loadingModels, modelOptions, selectedModel, selectedProvider]);
|
||||
|
||||
const filteredModelOptions = selectedProvider
|
||||
? modelOptions.filter((group) => group.options[0]?.provider === selectedProvider)
|
||||
: [];
|
||||
|
||||
const handleProviderChange = useCallback(
|
||||
(newValue: unknown) => {
|
||||
const option = newValue as { value: string; label: string } | null;
|
||||
const providerValue = option?.value || undefined;
|
||||
onProviderChange(providerValue === '' ? undefined : providerValue);
|
||||
onModelChange(undefined);
|
||||
setIsCustomModel(false);
|
||||
},
|
||||
[onProviderChange, onModelChange]
|
||||
);
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
(newValue: unknown) => {
|
||||
const option = newValue as { value: string; label: string; provider: string } | null;
|
||||
if (option?.value.startsWith('__custom__:')) {
|
||||
setIsCustomModel(true);
|
||||
onModelChange(undefined);
|
||||
} else {
|
||||
setIsCustomModel(false);
|
||||
onModelChange(option?.value || undefined);
|
||||
}
|
||||
},
|
||||
[onModelChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{fetchError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{fetchError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-textStandard mb-2">
|
||||
Provider (Optional)
|
||||
</label>
|
||||
<p className="text-xs text-textSubtle mb-2">
|
||||
Leave empty to use the default provider configured in settings
|
||||
</p>
|
||||
<Select
|
||||
options={providerOptions}
|
||||
value={
|
||||
selectedProvider
|
||||
? providerOptions.find((opt) => opt.value === selectedProvider) || null
|
||||
: providerOptions.find((opt) => opt.value === '') || null
|
||||
}
|
||||
onChange={handleProviderChange}
|
||||
placeholder="Select provider"
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-sm font-medium text-textStandard">Model (Optional)</label>
|
||||
{isCustomModel && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsCustomModel(false);
|
||||
onModelChange(undefined);
|
||||
}}
|
||||
className="text-xs text-textSubtle hover:underline"
|
||||
type="button"
|
||||
>
|
||||
Back to model list
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-textSubtle mb-2">
|
||||
Leave empty to use the default model for the selected provider
|
||||
</p>
|
||||
{isCustomModel ? (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter custom model name"
|
||||
value={selectedModel || ''}
|
||||
onChange={(e) => onModelChange(e.target.value || undefined)}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
options={loadingModels ? [] : filteredModelOptions}
|
||||
value={
|
||||
loadingModels
|
||||
? { value: '', label: 'Loading models…', isDisabled: true }
|
||||
: selectedModel
|
||||
? { value: selectedModel, label: selectedModel }
|
||||
: null
|
||||
}
|
||||
onChange={handleModelChange}
|
||||
placeholder="Select a model"
|
||||
isClearable
|
||||
isDisabled={loadingModels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,14 @@ import { useForm } from '@tanstack/react-form';
|
||||
import { RecipeFormFields, extractTemplateVariables } from '../RecipeFormFields';
|
||||
import { type RecipeFormData } from '../recipeFormSchema';
|
||||
|
||||
vi.mock('../../../ConfigContext', () => ({
|
||||
useConfig: () => ({
|
||||
extensionsList: [],
|
||||
getExtensions: vi.fn().mockResolvedValue([]),
|
||||
getProviders: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}));
|
||||
|
||||
const expandAdvancedSection = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
const advancedTrigger = screen.getByRole('button', { name: /advanced options/i });
|
||||
const activitiesField = screen.queryByText('Activities');
|
||||
@@ -24,6 +32,9 @@ describe('RecipeFormFields', () => {
|
||||
activities: [],
|
||||
parameters: [],
|
||||
jsonSchema: '',
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
extensions: undefined,
|
||||
...initialValues,
|
||||
};
|
||||
|
||||
@@ -275,6 +286,9 @@ describe('RecipeFormFields', () => {
|
||||
activities: [],
|
||||
parameters: [],
|
||||
jsonSchema: '',
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
extensions: undefined,
|
||||
} as RecipeFormData,
|
||||
onSubmit: async ({ value }) => {
|
||||
console.log('Form submitted:', value);
|
||||
@@ -368,6 +382,9 @@ describe('RecipeFormFields', () => {
|
||||
},
|
||||
],
|
||||
jsonSchema: '',
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
extensions: undefined,
|
||||
} as RecipeFormData,
|
||||
onSubmit: async ({ value }) => {
|
||||
console.log('Form submitted:', value);
|
||||
@@ -535,6 +552,9 @@ describe('RecipeFormFields', () => {
|
||||
},
|
||||
],
|
||||
jsonSchema: '',
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
extensions: undefined,
|
||||
} as RecipeFormData,
|
||||
onSubmit: async ({ value }) => {
|
||||
console.log('Form submitted:', value);
|
||||
@@ -608,6 +628,9 @@ describe('RecipeFormFields', () => {
|
||||
},
|
||||
],
|
||||
jsonSchema: '',
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
extensions: undefined,
|
||||
} as RecipeFormData,
|
||||
onSubmit: async ({ value }) => {
|
||||
console.log('Form submitted:', value);
|
||||
@@ -847,4 +870,134 @@ describe('RecipeFormFields', () => {
|
||||
expect(result).toEqual(['user_name', 'user_id', 'email_address', 'app_name']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model and Extension Selection', () => {
|
||||
it('renders model and extension selectors in advanced options', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TestWrapper />);
|
||||
|
||||
await expandAdvancedSection(user);
|
||||
|
||||
expect(screen.getByText('Provider (Optional)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Extensions (Optional)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows selecting provider and model', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const TestComponent = () => {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
title: 'Test Recipe',
|
||||
description: 'Test',
|
||||
instructions: 'Test',
|
||||
prompt: 'Test',
|
||||
activities: [],
|
||||
parameters: [],
|
||||
jsonSchema: '',
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
extensions: undefined,
|
||||
} as RecipeFormData,
|
||||
onSubmit: async ({ value }) => {
|
||||
onSubmit(value);
|
||||
},
|
||||
});
|
||||
|
||||
return <RecipeFormFields form={form} />;
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
await expandAdvancedSection(user);
|
||||
|
||||
expect(screen.getByText('Provider (Optional)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows selecting extensions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const TestComponent = () => {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
title: 'Test Recipe',
|
||||
description: 'Test',
|
||||
instructions: 'Test',
|
||||
prompt: 'Test',
|
||||
activities: [],
|
||||
parameters: [],
|
||||
jsonSchema: '',
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
extensions: undefined,
|
||||
} as RecipeFormData,
|
||||
onSubmit: async ({ value }) => {
|
||||
console.log('Form submitted:', value);
|
||||
},
|
||||
});
|
||||
|
||||
return <RecipeFormFields form={form} />;
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
await expandAdvancedSection(user);
|
||||
|
||||
expect(screen.getByText('Extensions (Optional)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills model and provider from initial values', async () => {
|
||||
const user = userEvent.setup();
|
||||
const initialValues: Partial<RecipeFormData> = {
|
||||
title: 'Test Recipe',
|
||||
description: 'Test',
|
||||
instructions: 'Test',
|
||||
prompt: 'Test',
|
||||
model: 'gpt-4o',
|
||||
provider: 'openai',
|
||||
};
|
||||
|
||||
const TestComponent = () => {
|
||||
const form = useTestForm(initialValues);
|
||||
return <RecipeFormFields form={form} />;
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
await expandAdvancedSection(user);
|
||||
|
||||
expect(screen.getByText('Provider (Optional)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills extensions from initial values', async () => {
|
||||
const user = userEvent.setup();
|
||||
const initialValues: Partial<RecipeFormData> = {
|
||||
title: 'Test Recipe',
|
||||
description: 'Test',
|
||||
instructions: 'Test',
|
||||
prompt: 'Test',
|
||||
extensions: [
|
||||
{
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
display_name: 'Developer',
|
||||
timeout: 300,
|
||||
bundled: true,
|
||||
description: 'Developer extension',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const TestComponent = () => {
|
||||
const form = useTestForm(initialValues);
|
||||
return <RecipeFormFields form={form} />;
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
await expandAdvancedSection(user);
|
||||
|
||||
expect(screen.getByText('Extensions (Optional)')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 extension selected')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { ExtensionConfig } from '../../../api';
|
||||
|
||||
// Zod schema for Parameter - matching API RecipeParameter type
|
||||
const parameterSchema = z.object({
|
||||
@@ -39,6 +40,12 @@ export const recipeFormSchema = z.object({
|
||||
parameters: z.array(parameterSchema).default([]),
|
||||
|
||||
jsonSchema: z.string().optional(),
|
||||
|
||||
model: z.string().optional(),
|
||||
|
||||
provider: z.string().optional(),
|
||||
|
||||
extensions: z.array(z.custom<ExtensionConfig>()).optional(),
|
||||
});
|
||||
|
||||
export type RecipeFormData = z.infer<typeof recipeFormSchema>;
|
||||
|
||||
Reference in New Issue
Block a user