feat: edit model and extensions of a recipe from GUI (#6804)

Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
This commit is contained in:
Abhijay Jain
2026-02-12 05:18:39 +05:30
committed by GitHub
parent 8d59c2dced
commit 153872a47c
7 changed files with 587 additions and 12 deletions
@@ -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>;