diff --git a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx index 6e92500f927d..755b7c0707ac 100644 --- a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx +++ b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx @@ -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(() => { - 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, ]); diff --git a/ui/desktop/src/components/recipes/__tests__/CreateRecipeFromSessionModal.test.tsx b/ui/desktop/src/components/recipes/__tests__/CreateRecipeFromSessionModal.test.tsx index 64de8f243ad8..90e54b39267d 100644 --- a/ui/desktop/src/components/recipes/__tests__/CreateRecipeFromSessionModal.test.tsx +++ b/ui/desktop/src/components/recipes/__tests__/CreateRecipeFromSessionModal.test.tsx @@ -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', () => { diff --git a/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx b/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx new file mode 100644 index 000000000000..a311c6580324 --- /dev/null +++ b/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx @@ -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 ( +
+
+ +

+ Select which extensions should be available when running this recipe. Leave empty to use + default extensions. +

+ + setSearchQuery(e.target.value)} + className="mb-3" + /> + +

+ {activeCount} extension{activeCount !== 1 ? 's' : ''} selected +

+
+ +
+ {sortedExtensions.length === 0 ? ( +
+ {searchQuery ? 'No extensions found' : 'No extensions available'} +
+ ) : ( + sortedExtensions.map((ext) => { + const isSelected = selectedExtensionNames.has(ext.name); + return ( +
handleToggle(ext)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleToggle(ext); + } + }} + title={ext.description || ext.name} + > +
+
+ {formatExtensionName(ext.name)} +
+ {ext.description && ( +
{ext.description}
+ )} +
+
e.stopPropagation()} className="ml-4"> + handleToggle(ext)} + variant="mono" + /> +
+
+ ); + }) + )} +
+
+ ); +}; diff --git a/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx b/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx index 3909198b0e58..0c4a4c50a870 100644 --- a/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx +++ b/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx @@ -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' }`} /> - Advanced Options - Activities, parameters, response schema + Advanced Options + + Activities, parameters, model, extensions, response schema + @@ -460,6 +470,34 @@ export function RecipeFormFields({ }} + {/* Model and Provider Fields */} + + {(providerField: FormFieldApi) => ( + + {(modelField: FormFieldApi) => ( + providerField.handleChange(provider)} + onModelChange={(model) => modelField.handleChange(model)} + /> + )} + + )} + + + {/* Extensions Field */} + + {(field: FormFieldApi) => ( + + field.handleChange(extensions.length > 0 ? extensions : undefined) + } + /> + )} + + {/* JSON Schema Field */} {(field: FormFieldApi) => ( diff --git a/ui/desktop/src/components/recipes/shared/RecipeModelSelector.tsx b/ui/desktop/src/components/recipes/shared/RecipeModelSelector.tsx new file mode 100644 index 000000000000..d318157a7491 --- /dev/null +++ b/ui/desktop/src/components/recipes/shared/RecipeModelSelector.tsx @@ -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(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 ( +
+ {fetchError && ( +
+ {fetchError} +
+ )} +
+ +

+ Leave empty to use the default provider configured in settings +

+ onModelChange(e.target.value || undefined)} + /> + ) : ( +