diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index aace7068a8f0..b2568c398302 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -205,6 +205,7 @@ function BaseChatContent({ // Use shared recipe manager const { recipeConfig, + filteredParameters, initialPrompt, isGeneratingRecipe, isParameterModalOpen, @@ -546,9 +547,9 @@ function BaseChatContent({ /> {/* Recipe Parameter Modal */} - {isParameterModalOpen && recipeConfig?.parameters && ( + {isParameterModalOpen && filteredParameters.length > 0 && ( setIsParameterModalOpen(false)} /> diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx index f2bb4053b4e3..29f3b1a45931 100644 --- a/ui/desktop/src/components/recipes/RecipesView.tsx +++ b/ui/desktop/src/components/recipes/RecipesView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipeStorage'; import { FileText, Trash2, Bot, Calendar, AlertCircle } from 'lucide-react'; import { ScrollArea } from '../ui/scroll-area'; @@ -12,6 +12,7 @@ import { useEscapeKey } from '../../hooks/useEscapeKey'; import { deleteRecipe, RecipeManifestResponse } from '../../api'; import CreateRecipeForm, { CreateRecipeButton } from './CreateRecipeForm'; import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm'; +import { filterValidUsedParameters } from '../../utils/providerUtils'; export default function RecipesView() { const [savedRecipes, setSavedRecipes] = useState([]); @@ -134,6 +135,21 @@ export default function RecipesView() { } }; + const filteredPreviewParameters = useMemo(() => { + if (!selectedRecipe?.recipe.parameters) { + return []; + } + + return filterValidUsedParameters(selectedRecipe.recipe.parameters, { + instructions: selectedRecipe.recipe.instructions || undefined, + prompt: selectedRecipe.recipe.prompt || undefined, + }); + }, [ + selectedRecipe?.recipe.parameters, + selectedRecipe?.recipe.instructions, + selectedRecipe?.recipe.prompt, + ]); + // Render a recipe item const RecipeItem = ({ recipeManifestResponse, @@ -410,11 +426,11 @@ export default function RecipesView() { )} - {selectedRecipe.recipe.parameters && selectedRecipe.recipe.parameters.length > 0 && ( + {filteredPreviewParameters && filteredPreviewParameters.length > 0 && (

Parameters

- {selectedRecipe.recipe.parameters.map((param, index) => ( + {filteredPreviewParameters.map((param, index) => (
{ // Transform the internal parameters state into the desired output format. const formattedParameters = parameters.map((param) => { @@ -459,7 +462,7 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM )}
- {parameters.map((parameter: Parameter) => ( + {filteredParameters.map((parameter: Parameter) => ( { + if (!finalRecipeConfig?.parameters) { + return []; + } + return filterValidUsedParameters(finalRecipeConfig.parameters, { + prompt: finalRecipeConfig.prompt || undefined, + instructions: finalRecipeConfig.instructions || undefined, + }); + }, [finalRecipeConfig]); + + // Check if template variables are actually used in the recipe content + const requiresParameters = useMemo(() => { + return filteredParameters.length > 0; + }, [filteredParameters]); const hasParameters = !!recipeParameters; const hasMessages = messages.length > 0; useEffect(() => { @@ -89,15 +107,12 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) = return ''; } - const hasRequiredParams = - finalRecipeConfig.parameters && finalRecipeConfig.parameters.length > 0; - - if (hasRequiredParams && recipeParameters) { + if (requiresParameters && recipeParameters) { return substituteParameters(finalRecipeConfig.prompt, recipeParameters); } return finalRecipeConfig.prompt; - }, [finalRecipeConfig, recipeParameters, recipeAccepted]); + }, [finalRecipeConfig, recipeParameters, recipeAccepted, requiresParameters]); const handleParameterSubmit = async (inputValues: Record) => { setRecipeParameters(inputValues); @@ -138,13 +153,10 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) = isLoading: boolean, onAutoExecute?: () => void ) => { - const hasRequiredParams = - finalRecipeConfig?.parameters && finalRecipeConfig.parameters.length > 0; - if ( finalRecipeConfig?.isScheduledExecution && finalRecipeConfig?.prompt && - (!hasRequiredParams || recipeParameters) && + (!requiresParameters || recipeParameters) && messages.length === 0 && !isLoading && readyForAutoUserPrompt && @@ -235,6 +247,7 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) = return { recipeConfig: finalRecipeConfig, + filteredParameters, initialPrompt, isGeneratingRecipe, isParameterModalOpen, diff --git a/ui/desktop/src/utils/__tests__/providerUtils.test.ts b/ui/desktop/src/utils/__tests__/providerUtils.test.ts new file mode 100644 index 000000000000..e7e3d72fb2bf --- /dev/null +++ b/ui/desktop/src/utils/__tests__/providerUtils.test.ts @@ -0,0 +1,516 @@ +import { describe, it, expect } from 'vitest'; +import { + extractTemplateVariables, + filterValidUsedParameters, + substituteParameters, +} from '../providerUtils'; +import type { RecipeParameter } from '../../api'; + +describe('providerUtils', () => { + describe('extractTemplateVariables', () => { + it('should extract simple template variables', () => { + const content = 'Hello {{name}}, welcome to {{app}}!'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name', 'app']); + }); + + it('should extract variables with underscores', () => { + const content = 'User: {{user_name}}, ID: {{user_id}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['user_name', 'user_id']); + }); + + it('should extract variables that start with underscore', () => { + const content = 'Private: {{_private}}, Internal: {{__internal}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['_private', '__internal']); + }); + + it('should handle variables with numbers', () => { + const content = 'Item {{item1}}, Version {{version2_0}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['item1', 'version2_0']); + }); + + it('should trim whitespace from variables', () => { + const content = 'Hello {{ name }}, welcome to {{ app }}!'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name', 'app']); + }); + + it('should ignore invalid variable names with spaces', () => { + const content = 'Invalid: {{user name}}, Valid: {{username}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['username']); + }); + + it('should ignore invalid variable names with dots', () => { + const content = 'Invalid: {{user.name}}, Valid: {{user_name}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['user_name']); + }); + + it('should ignore invalid variable names with pipes', () => { + const content = 'Invalid: {{name|upper}}, Valid: {{name}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name']); + }); + + it('should ignore invalid variable names with special characters', () => { + const content = 'Invalid: {{user@name}}, {{user-name}}, {{user$name}}, Valid: {{username}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['username']); + }); + + it('should ignore variables starting with numbers', () => { + const content = 'Invalid: {{1name}}, {{2user}}, Valid: {{name1}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name1']); + }); + + it('should remove duplicates', () => { + const content = 'Hello {{name}}, goodbye {{name}}, welcome {{app}}, use {{app}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name', 'app']); + }); + + it('should handle empty content', () => { + const content = ''; + const result = extractTemplateVariables(content); + expect(result).toEqual([]); + }); + + it('should handle content with no variables', () => { + const content = 'This is just plain text with no variables.'; + const result = extractTemplateVariables(content); + expect(result).toEqual([]); + }); + + it('should handle single braces (not template variables)', () => { + const content = 'This {is} not a {template} variable but {{this}} is.'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['this']); + }); + + it('should handle malformed template syntax', () => { + const content = 'Malformed: {{{name}}}, {{name}}, {name}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name']); + }); + + it('should handle empty variable names', () => { + const content = 'Empty: {{}}, Valid: {{name}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name']); + }); + + it('should handle variables with only whitespace', () => { + const content = 'Whitespace: {{ }}, Valid: {{name}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name']); + }); + + it('should ignore complex template expressions with dots and pipes', () => { + const content = + 'Complex: {{steps.fetch_payment_data.data.payments.totalEdgeCount | number_format}}, Valid: {{simple_param}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['simple_param']); + }); + + it('should handle complex mixed content', () => { + const content = ` + Welcome {{user_name}}! + + Your account details: + - ID: {{user_id}} + - Email: {{email_address}} + - Invalid: {{user.email}} + - Invalid: {{user name}} + - Invalid: {{1invalid}} + + Thank you for using {{app_name}}! + `; + const result = extractTemplateVariables(content); + expect(result).toEqual(['user_name', 'user_id', 'email_address', 'app_name']); + }); + }); + + describe('filterValidUsedParameters', () => { + const createParameter = ( + key: string, + description = '', + requirement: 'required' | 'optional' | 'user_prompt' = 'optional' + ): RecipeParameter => ({ + key, + description, + input_type: 'string', + requirement, + }); + + it('should filter parameters to only include valid ones used in content', () => { + const parameters = [ + createParameter('valid_param'), + createParameter('invalid param'), // has space + createParameter('unused_param'), + createParameter('used_param'), + ]; + + const recipeContent = { + prompt: 'Use {{valid_param}} and {{used_param}}', + instructions: 'Additional {{valid_param}} usage', + }; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([createParameter('valid_param'), createParameter('used_param')]); + }); + + it('should handle parameters used only in prompt', () => { + const parameters = [createParameter('prompt_param'), createParameter('unused_param')]; + + const recipeContent = { + prompt: 'Use {{prompt_param}}', + instructions: 'No parameters here', + }; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([createParameter('prompt_param')]); + }); + + it('should handle parameters used only in instructions', () => { + const parameters = [createParameter('instruction_param'), createParameter('unused_param')]; + + const recipeContent = { + prompt: 'No parameters here', + instructions: 'Use {{instruction_param}}', + }; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([createParameter('instruction_param')]); + }); + + it('should remove duplicate parameters (keep first occurrence)', () => { + const parameters = [ + createParameter('duplicate_param', 'First occurrence'), + createParameter('duplicate_param', 'Second occurrence'), + createParameter('unique_param'), + ]; + + const recipeContent = { + prompt: 'Use {{duplicate_param}} and {{unique_param}}', + }; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([ + createParameter('duplicate_param', 'First occurrence'), + createParameter('unique_param'), + ]); + }); + + it('should filter out parameters with invalid names', () => { + const parameters = [ + createParameter('valid_param'), + createParameter('invalid param'), // space + createParameter('invalid.param'), // dot + createParameter('invalid|param'), // pipe + createParameter('invalid-param'), // dash + createParameter('invalid@param'), // at symbol + createParameter('1invalid'), // starts with number + createParameter('_valid_param'), // starts with underscore (valid) + ]; + + const recipeContent = { + prompt: + 'Use all: {{valid_param}} {{invalid param}} {{invalid.param}} {{invalid|param}} {{invalid-param}} {{invalid@param}} {{1invalid}} {{_valid_param}}', + }; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([createParameter('valid_param'), createParameter('_valid_param')]); + }); + + it('should handle empty parameters array', () => { + const parameters: RecipeParameter[] = []; + const recipeContent = { + prompt: 'Use {{some_param}}', + }; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([]); + }); + + it('should handle undefined parameters', () => { + const parameters = undefined; + const recipeContent = { + prompt: 'Use {{some_param}}', + }; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([]); + }); + + it('should handle non-array parameters', () => { + const parameters = {} as unknown as RecipeParameter[]; // Invalid type + const recipeContent = { + prompt: 'Use {{some_param}}', + }; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([]); + }); + + it('should handle empty recipe content', () => { + const parameters = [createParameter('param1'), createParameter('param2')]; + const recipeContent = {}; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([]); + }); + + it('should handle recipe content with empty strings', () => { + const parameters = [createParameter('param1'), createParameter('param2')]; + const recipeContent = { + prompt: '', + instructions: '', + }; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([]); + }); + + it('should handle recipe content with undefined values', () => { + const parameters = [createParameter('param1'), createParameter('param2')]; + const recipeContent = { + prompt: undefined, + instructions: undefined, + }; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([]); + }); + + it('should preserve parameter properties', () => { + const parameters = [ + { + key: 'test_param', + description: 'A test parameter', + input_type: 'string' as const, + requirement: 'required' as const, + }, + ]; + + const recipeContent = { + prompt: 'Use {{test_param}}', + }; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([ + { + key: 'test_param', + description: 'A test parameter', + input_type: 'string', + requirement: 'required', + }, + ]); + }); + + it('should filter out complex template expressions with dots and pipes', () => { + const parameters = [ + createParameter('steps.fetch_payment_data.data.payments.totalEdgeCount | number_format'), // complex invalid + createParameter('simple_param'), // valid + createParameter('another.invalid.param'), // invalid with dots + createParameter('valid_param'), // valid + ]; + + const recipeContent = { + prompt: + 'Use {{steps.fetch_payment_data.data.payments.totalEdgeCount | number_format}} and {{simple_param}}', + instructions: 'Also use {{another.invalid.param}} and {{valid_param}}', + }; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([createParameter('simple_param'), createParameter('valid_param')]); + }); + + it('should handle complex recipe content with multiple parameter usages', () => { + const parameters = [ + createParameter('user_name'), + createParameter('user_email'), + createParameter('app_name'), + createParameter('invalid param'), + createParameter('unused_param'), + createParameter('version_number'), + ]; + + const recipeContent = { + prompt: ` + Welcome {{user_name}}! + + Your details: + - Name: {{user_name}} + - Email: {{user_email}} + `, + instructions: ` + Please use {{app_name}} version {{version_number}}. + + Contact {{user_email}} for support. + Invalid usage: {{invalid param}} + `, + }; + + const result = filterValidUsedParameters(parameters, recipeContent); + expect(result).toEqual([ + createParameter('user_name'), + createParameter('user_email'), + createParameter('app_name'), + createParameter('version_number'), + ]); + }); + }); + + describe('substituteParameters', () => { + it('should substitute simple parameters', () => { + const text = 'Hello {{name}}, welcome to {{app}}!'; + const params = { name: 'John', app: 'MyApp' }; + const result = substituteParameters(text, params); + expect(result).toBe('Hello John, welcome to MyApp!'); + }); + + it('should handle parameters with underscores', () => { + const text = 'User: {{user_name}}, ID: {{user_id}}'; + const params = { user_name: 'john_doe', user_id: '12345' }; + const result = substituteParameters(text, params); + expect(result).toBe('User: john_doe, ID: 12345'); + }); + + it('should handle parameters with whitespace in template', () => { + const text = 'Hello {{ name }}, welcome to {{ app }}!'; + const params = { name: 'John', app: 'MyApp' }; + const result = substituteParameters(text, params); + expect(result).toBe('Hello John, welcome to MyApp!'); + }); + + it('should handle multiple occurrences of same parameter', () => { + const text = 'Hello {{name}}, goodbye {{name}}!'; + const params = { name: 'John' }; + const result = substituteParameters(text, params); + expect(result).toBe('Hello John, goodbye John!'); + }); + + it('should leave unmatched parameters unchanged', () => { + const text = 'Hello {{name}}, welcome to {{app}}!'; + const params = { name: 'John' }; // missing 'app' + const result = substituteParameters(text, params); + expect(result).toBe('Hello John, welcome to {{app}}!'); + }); + + it('should handle empty parameters object', () => { + const text = 'Hello {{name}}, welcome to {{app}}!'; + const params = {}; + const result = substituteParameters(text, params); + expect(result).toBe('Hello {{name}}, welcome to {{app}}!'); + }); + + it('should handle text with no parameters', () => { + const text = 'This is just plain text.'; + const params = { name: 'John' }; + const result = substituteParameters(text, params); + expect(result).toBe('This is just plain text.'); + }); + + it('should handle empty text', () => { + const text = ''; + const params = { name: 'John' }; + const result = substituteParameters(text, params); + expect(result).toBe(''); + }); + + it('should handle parameters with special characters in values', () => { + const text = 'Message: {{message}}'; + const params = { message: 'Hello $world! (test) [array] {object}' }; + const result = substituteParameters(text, params); + expect(result).toBe('Message: Hello $world! (test) [array] {object}'); + }); + + it('should handle parameters with regex special characters in keys', () => { + const text = 'Value: {{test_param}}'; + const params = { test_param: 'test value' }; + const result = substituteParameters(text, params); + expect(result).toBe('Value: test value'); + }); + + it('should handle parameters with newlines in values', () => { + const text = 'Content: {{content}}'; + const params = { content: 'Line 1\nLine 2\nLine 3' }; + const result = substituteParameters(text, params); + expect(result).toBe('Content: Line 1\nLine 2\nLine 3'); + }); + + it('should handle complex substitution scenario', () => { + const text = ` + Welcome {{user_name}}! + + Your account details: + - ID: {{user_id}} + - Email: {{user_email}} + - App: {{app_name}} + + Thank you for using {{app_name}}! + `; + + const params = { + user_name: 'John Doe', + user_id: '12345', + user_email: 'john@example.com', + app_name: 'MyApp', + }; + + const result = substituteParameters(text, params); + const expected = ` + Welcome John Doe! + + Your account details: + - ID: 12345 + - Email: john@example.com + - App: MyApp + + Thank you for using MyApp! + `; + + expect(result).toBe(expected); + }); + + it('should handle single braces (not template variables)', () => { + const text = 'This {is} not a {template} but {{this}} is.'; + const params = { this: 'replaced' }; + const result = substituteParameters(text, params); + expect(result).toBe('This {is} not a {template} but replaced is.'); + }); + + it('should handle malformed template syntax gracefully', () => { + const text = 'Malformed: {{{name}}}, Normal: {{name}}'; + const params = { name: 'John' }; + const result = substituteParameters(text, params); + expect(result).toBe('Malformed: {John}, Normal: John'); + }); + + it('should handle parameters with numeric values', () => { + const text = 'Count: {{count}}, Price: {{price}}'; + const params = { count: '5', price: '19.99' }; + const result = substituteParameters(text, params); + expect(result).toBe('Count: 5, Price: 19.99'); + }); + + it('should handle parameters with boolean-like values', () => { + const text = 'Enabled: {{enabled}}, Active: {{active}}'; + const params = { enabled: 'true', active: 'false' }; + const result = substituteParameters(text, params); + expect(result).toBe('Enabled: true, Active: false'); + }); + + it('should handle parameters with empty string values', () => { + const text = 'Name: {{name}}, Value: {{value}}'; + const params = { name: '', value: 'test' }; + const result = substituteParameters(text, params); + expect(result).toBe('Name: , Value: test'); + }); + }); +}); diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index b2218aa0c6f0..9479367de988 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -46,6 +46,83 @@ You can also validate your output after you have generated it to ensure it meets There may be (but not always) some tools mentioned in the instructions which you can check are available to this instance of goose (and try to help the user if they are not or find alternatives). `; +// Helper function to extract template variables from text (matches backend logic) +export const extractTemplateVariables = (content: string): string[] => { + const templateVarRegex = /\{\{(.*?)\}\}/g; + const variables: string[] = []; + let match; + + while ((match = templateVarRegex.exec(content)) !== null) { + const variable = match[1].trim(); + + if (variable && !variables.includes(variable)) { + // Filter out complex variables that aren't valid parameter names + // This matches the backend logic in filter_complex_variables() + const isValid = isValidParameterName(variable); + + if (isValid) { + variables.push(variable); + } + } + } + + return variables; +}; + +// Helper function to check if a variable name is valid for parameters +// Matches backend regex: r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*$" +const isValidParameterName = (variable: string): boolean => { + const validVarRegex = /^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*$/; + return validVarRegex.test(variable); +}; + +// Helper function to filter recipe parameters to only show valid ones that are actually used +export const filterValidUsedParameters = ( + parameters: RecipeParameter[] | undefined, + recipeContent: { prompt?: string; instructions?: string } +): RecipeParameter[] => { + if (!parameters || !Array.isArray(parameters)) { + return []; + } + + // Extract all template variables used in the recipe content + const promptVariables = recipeContent.prompt + ? extractTemplateVariables(recipeContent.prompt) + : []; + const instructionVariables = recipeContent.instructions + ? extractTemplateVariables(recipeContent.instructions) + : []; + const allUsedVariables = [...new Set([...promptVariables, ...instructionVariables])]; + + // Filter parameters to only include: + // 1. Parameters with valid names (no spaces, dots, pipes, etc.) + // 2. Parameters that are actually used in the recipe content + // 3. Remove duplicates (keep first occurrence) + const seenKeys = new Set(); + + return parameters.filter((param) => { + // Check if parameter key is valid (no spaces, special characters) + const isValid = isValidParameterName(param.key); + if (!isValid) { + return false; + } + + // Check if parameter is actually used in the recipe content + const isUsed = allUsedVariables.includes(param.key); + if (!isUsed) { + return false; + } + + // Remove duplicates (keep first occurrence) + if (seenKeys.has(param.key)) { + return false; + } + + seenKeys.add(param.key); + return true; + }); +}; + // Helper function to substitute parameters in text export const substituteParameters = (text: string, params: Record): string => { let substitutedText = text;