diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index 570036e2741a..6c34d069c53b 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -26,6 +26,7 @@ import { fetchSessionDetails, generateSessionId } from '../sessions'; import 'react-toastify/dist/ReactToastify.css'; import { useMessageStream } from '../hooks/useMessageStream'; import { SessionSummaryModal } from './context_management/SessionSummaryModal'; +import ParameterInputModal from './ParameterInputModal'; import { Recipe } from '../recipe'; import { ChatContextManagerProvider, @@ -35,6 +36,7 @@ import { ContextHandler } from './context_management/ContextHandler'; import { LocalMessageStorage } from '../utils/localMessageStorage'; import { useModelAndProvider } from './ModelAndProviderContext'; import { getCostForModel } from '../utils/costDatabase'; +import { updateSystemPromptWithParameters } from '../utils/providerUtils'; import { Message, createUserMessage, @@ -69,6 +71,17 @@ const isUserMessage = (message: Message): boolean => { return true; }; +const substituteParameters = (prompt: string, params: Record): string => { + let substitutedPrompt = prompt; + + for (const key in params) { + // Escape special characters in the key (parameter) and match optional whitespace + const regex = new RegExp(`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*}}`, 'g'); + substitutedPrompt = substitutedPrompt.replace(regex, params[key]); + } + return substitutedPrompt; +}; + export default function ChatView({ chat, setChat, @@ -114,6 +127,8 @@ function ChatContent({ const [localOutputTokens, setLocalOutputTokens] = useState(0); const [ancestorMessages, setAncestorMessages] = useState([]); const [droppedFiles, setDroppedFiles] = useState([]); + const [isParameterModalOpen, setIsParameterModalOpen] = useState(false); + const [recipeParameters, setRecipeParameters] = useState | null>(null); const [sessionCosts, setSessionCosts] = useState<{ [key: string]: { inputTokens: number; @@ -152,6 +167,16 @@ function ChatContent({ // Get recipeConfig directly from appConfig const recipeConfig = window.appConfig.get('recipeConfig') as Recipe | null; + // Show parameter modal if recipe has parameters and they haven't been set yet + useEffect(() => { + if (recipeConfig?.parameters && recipeConfig.parameters.length > 0) { + // If we have parameters and they haven't been set yet, open the modal. + if (!recipeParameters) { + setIsParameterModalOpen(true); + } + } + }, [recipeConfig, recipeParameters]); + // Store message in global history when it's added const storeMessageInHistory = useCallback((message: Message) => { if (isUserMessage(message)) { @@ -282,6 +307,7 @@ function ChatContent({ name: response.recipe.title || 'Untitled Recipe', // Does not exist on recipe type title: response.recipe.title || 'Untitled Recipe', description: response.recipe.description || '', + parameters: response.recipe.parameters || [], instructions: response.recipe.instructions || '', activities: response.recipe.activities || [], prompt: response.recipe.prompt || '', @@ -326,27 +352,48 @@ function ChatContent({ // Pre-fill input with recipe prompt instead of auto-sending it const initialPrompt = useMemo(() => { - return recipeConfig?.prompt || ''; - }, [recipeConfig?.prompt]); + if (!recipeConfig?.prompt) return ''; + + const hasRequiredParams = recipeConfig.parameters && recipeConfig.parameters.length > 0; + + // If params are required and have been collected, substitute them into the prompt. + if (hasRequiredParams && recipeParameters) { + return substituteParameters(recipeConfig.prompt, recipeParameters); + } + + // If there are no parameters, return the original prompt. + if (!hasRequiredParams) { + return recipeConfig.prompt; + } + + // Otherwise, we are waiting for parameters, so the input should be empty. + return ''; + }, [recipeConfig, recipeParameters]); // Auto-send the prompt for scheduled executions useEffect(() => { + const hasRequiredParams = recipeConfig?.parameters && recipeConfig.parameters.length > 0; + if ( recipeConfig?.isScheduledExecution && recipeConfig?.prompt && + (!hasRequiredParams || recipeParameters) && messages.length === 0 && !isLoading && readyForAutoUserPrompt ) { - console.log('Auto-sending prompt for scheduled execution:', recipeConfig.prompt); + // Substitute parameters if they exist + const finalPrompt = recipeParameters + ? substituteParameters(recipeConfig.prompt, recipeParameters) + : recipeConfig.prompt; - // Create and send the user message - const userMessage = createUserMessage(recipeConfig.prompt); + console.log('Auto-sending substituted prompt for scheduled execution:', finalPrompt); + + const userMessage = createUserMessage(finalPrompt); setLastInteractionTime(Date.now()); window.electron.startPowerSaveBlocker(); append(userMessage); - // Scroll to bottom after sending setTimeout(() => { if (scrollRef.current?.scrollToBottom) { scrollRef.current.scrollToBottom(); @@ -356,6 +403,8 @@ function ChatContent({ }, [ recipeConfig?.isScheduledExecution, recipeConfig?.prompt, + recipeConfig?.parameters, + recipeParameters, messages.length, isLoading, readyForAutoUserPrompt, @@ -363,6 +412,18 @@ function ChatContent({ setLastInteractionTime, ]); + const handleParameterSubmit = async (inputValues: Record) => { + setRecipeParameters(inputValues); + setIsParameterModalOpen(false); + + // Update the system prompt with parameter-substituted instructions + try { + await updateSystemPromptWithParameters(inputValues); + } catch (error) { + console.error('Failed to update system prompt with parameters:', error); + } + }; + // Handle submit const handleSubmit = (e: React.FormEvent) => { window.electron.startPowerSaveBlocker(); @@ -831,6 +892,13 @@ function ChatContent({ }} summaryContent={summaryContent} /> + {isParameterModalOpen && recipeConfig?.parameters && ( + setIsParameterModalOpen(false)} + /> + )} ); diff --git a/ui/desktop/src/components/ParameterInputModal.tsx b/ui/desktop/src/components/ParameterInputModal.tsx new file mode 100644 index 000000000000..481f13653022 --- /dev/null +++ b/ui/desktop/src/components/ParameterInputModal.tsx @@ -0,0 +1,162 @@ +import React, { useState, useEffect } from 'react'; +import { Parameter } from '../recipe'; +import { Button } from './ui/button'; + +interface ParameterInputModalProps { + parameters: Parameter[]; + onSubmit: (values: Record) => void; + onClose: () => void; +} + +const ParameterInputModal: React.FC = ({ + parameters, + onSubmit, + onClose, +}) => { + const [inputValues, setInputValues] = useState>({}); + const [validationErrors, setValidationErrors] = useState>({}); + const [showCancelOptions, setShowCancelOptions] = useState(false); + + // Pre-fill the form with default values from the recipe + useEffect(() => { + const initialValues: Record = {}; + parameters.forEach((param) => { + if (param.default) { + initialValues[param.key] = param.default; + } + }); + setInputValues(initialValues); + }, [parameters]); + + const handleChange = (name: string, value: string): void => { + setInputValues((prevValues: Record) => ({ ...prevValues, [name]: value })); + }; + + const handleSubmit = (): void => { + // Clear previous validation errors + setValidationErrors({}); + + // Check if all *required* parameters are filled + const requiredParams: Parameter[] = parameters.filter((p) => p.requirement === 'required'); + const errors: Record = {}; + + requiredParams.forEach((param) => { + const value = inputValues[param.key]?.trim(); + if (!value) { + errors[param.key] = `${param.description || param.key} is required`; + } + }); + + if (Object.keys(errors).length > 0) { + setValidationErrors(errors); + return; + } + + onSubmit(inputValues); + }; + + const handleCancel = (): void => { + // Always show cancel options if recipe has any parameters (required or optional) + const hasAnyParams = parameters.length > 0; + + if (hasAnyParams) { + setShowCancelOptions(true); + } else { + onClose(); + } + }; + + const handleCancelOption = (option: 'new-chat' | 'back-to-form'): void => { + if (option === 'new-chat') { + // Create a new chat window without recipe config + try { + const workingDir = window.appConfig.get('GOOSE_WORKING_DIR'); + console.log(`Creating new chat window without recipe, working dir: ${workingDir}`); + window.electron.createChatWindow(undefined, workingDir as string); + // Close the current window after creating the new one + window.electron.hideWindow(); + } catch (error) { + console.error('Error creating new window:', error); + // Fallback: just close the modal + onClose(); + } + } else { + setShowCancelOptions(false); // Go back to the parameter form + } + }; + + return ( +
+ {showCancelOptions ? ( + // Cancel options modal +
+

Cancel Recipe Setup

+

What would you like to do?

+
+ + +
+
+ ) : ( + // Main parameter form +
+

Recipe Parameters

+
+ {parameters.map((param) => ( +
+ + handleChange(param.key, e.target.value)} + className={`w-full p-3 border rounded-lg bg-bgSubtle text-textStandard focus:outline-none focus:ring-2 ${ + validationErrors[param.key] + ? 'border-red-500 focus:ring-red-500' + : 'border-borderSubtle focus:ring-borderProminent' + }`} + placeholder={param.default || `Enter value for ${param.key}...`} + /> + {validationErrors[param.key] && ( +

{validationErrors[param.key]}

+ )} +
+ ))} +
+ + +
+
+
+ )} +
+ ); +}; + +export default ParameterInputModal; diff --git a/ui/desktop/src/components/RecipeEditor.tsx b/ui/desktop/src/components/RecipeEditor.tsx index 7e1323e06261..17ff63e0e0d8 100644 --- a/ui/desktop/src/components/RecipeEditor.tsx +++ b/ui/desktop/src/components/RecipeEditor.tsx @@ -1,5 +1,7 @@ import { useState, useEffect } from 'react'; import { Recipe } from '../recipe'; +import { Parameter } from '../recipe/index'; + import { Buffer } from 'buffer'; import { FullExtensionConfig } from '../extensions'; import { Geese } from './icons/Geese'; @@ -11,6 +13,7 @@ import RecipeActivityEditor from './RecipeActivityEditor'; import RecipeInfoModal from './RecipeInfoModal'; import RecipeExpandableInfo from './RecipeExpandableInfo'; import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal'; +import ParameterInput from './parameter/ParameterInput'; import { saveRecipe, generateRecipeFilename } from '../recipe/recipeStorage'; import { toastSuccess, toastError } from '../toasts'; @@ -33,6 +36,10 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { const [instructions, setInstructions] = useState(config?.instructions || ''); const [prompt, setPrompt] = useState(config?.prompt || ''); const [activities, setActivities] = useState(config?.activities || []); + const [parameters, setParameters] = useState( + parseParametersFromInstructions(instructions) + ); + const [extensionOptions, setExtensionOptions] = useState([]); const [extensionsLoaded, setExtensionsLoaded] = useState(false); const [copied, setCopied] = useState(false); @@ -108,11 +115,39 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [recipeExtensions, extensionsLoaded]); + // Use effect to set parameters whenever instructions or prompt changes + useEffect(() => { + const instructionsParams = parseParametersFromInstructions(instructions); + const promptParams = parseParametersFromInstructions(prompt); + + // Combine parameters, ensuring no duplicates by key + const allParams = [...instructionsParams]; + promptParams.forEach((promptParam) => { + if (!allParams.some((param) => param.key === promptParam.key)) { + allParams.push(promptParam); + } + }); + + setParameters(allParams); + }, [instructions, prompt]); + const getCurrentConfig = (): Recipe => { - console.log('Creating config with:', { - selectedExtensions: recipeExtensions, - availableExtensions: extensionOptions, - recipeConfig, + // Transform the internal parameters state into the desired output format. + const formattedParameters = parameters.map((param) => { + const formattedParam: Parameter = { + key: param.key, + input_type: 'string', + requirement: param.requirement, + description: param.description, + }; + + // Add the 'default' key ONLY if the parameter is optional and has a default value. + if (param.requirement === 'optional' && param.default) { + // Note: `default` is a reserved keyword in JS, but assigning it as a property key like this is valid. + formattedParam.default = param.default; + } + + return formattedParam; }); const config = { @@ -122,6 +157,8 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { instructions, activities, prompt, + // Use the newly formatted parameters array in the final config object. + parameters: formattedParameters, extensions: recipeExtensions .map((name) => { const extension = extensionOptions.find((e) => e.name === name); @@ -142,6 +179,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { .filter(Boolean) as FullExtensionConfig[], }; console.log('Final config extensions:', config.extensions); + return config; }; @@ -170,6 +208,12 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { return Object.keys(newErrors).length === 0; }; + const handleParameterChange = (name: string, value: Partial) => { + setParameters((prev) => + prev.map((param) => (param.key === name ? { ...param, ...value } : param)) + ); + }; + const deeplink = generateDeepLink(getCurrentConfig()); const handleCopy = () => { @@ -261,6 +305,21 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { const subtitle = config?.title ? "You can edit the recipe below to change the agent's behavior in a new session." : 'Your custom agent recipe can be shared with others. Fill in the sections below to create!'; + + function parseParametersFromInstructions(instructions: string): Parameter[] { + const regex = /\{\{(.*?)\}\}/g; + const matches = [...instructions.matchAll(regex)]; + + return matches.map((match) => { + return { + key: match[1].trim(), + description: `Enter value for ${match[1].trim()}`, + requirement: 'required', + input_type: 'string', // Default to string; can be changed based on requirements + }; + }); + } + return (
{activeSection === 'none' && ( @@ -339,6 +398,13 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
{errors.instructions}
)}
+ {parameters.map((parameter: Parameter) => ( + handleParameterChange(name, value)} + /> + ))}
+ ) => void; +} + +const ParameterInput: React.FC = ({ parameter, onChange }) => { + // All values are derived directly from props, maintaining the controlled component pattern + const { key, description, requirement } = parameter; + const defaultValue = parameter.default || ''; + + return ( +
+

+ Parameter: {parameter.key} +

+ +
+ + onChange(key, { description: e.target.value })} + className="w-full p-3 border rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent" + placeholder={`E.g., "Enter the name for the new component"`} + /> +

This is the message the end-user will see.

+
+ + {/* Controls for requirement and default value */} +
+
+ + +
+ + {/* The default value input is only shown for optional parameters */} + {requirement === 'optional' && ( +
+ + onChange(key, { default: e.target.value })} + className="w-full p-3 border rounded-lg bg-bgApp text-textStandard" + placeholder="Enter default value" + /> +
+ )} +
+
+ ); +}; + +export default ParameterInput; diff --git a/ui/desktop/src/recipe/index.ts b/ui/desktop/src/recipe/index.ts index df8c802d6c74..5bf85f850465 100644 --- a/ui/desktop/src/recipe/index.ts +++ b/ui/desktop/src/recipe/index.ts @@ -2,12 +2,21 @@ import { Message } from '../types/message'; import { getApiUrl } from '../config'; import { FullExtensionConfig } from '../extensions'; +export interface Parameter { + key: string; + description: string; + input_type: string; + default?: string; + requirement: 'required' | 'optional' | 'user_prompt'; +} + export interface Recipe { title: string; description: string; instructions: string; prompt?: string; activities?: string[]; + parameters?: Parameter[]; author?: { contact?: string; metadata?: string; diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index a26f9f1a542a..ad48462e6859 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -52,6 +52,57 @@ 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 substitute parameters in text +const substituteParameters = (text: string, params: Record): string => { + let substitutedText = text; + + for (const key in params) { + // Escape special characters in the key (parameter) and match optional whitespace + const regex = new RegExp(`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*}}`, 'g'); + substitutedText = substitutedText.replace(regex, params[key]); + } + + return substitutedText; +}; + +/** + * Updates the system prompt with parameter-substituted instructions + * This should be called after recipe parameters are collected + */ +export const updateSystemPromptWithParameters = async ( + recipeParameters: Record +): Promise => { + try { + const recipeConfig = window.appConfig?.get?.('recipeConfig'); + const originalInstructions = (recipeConfig as { instructions?: string })?.instructions; + + if (!originalInstructions) { + return; + } + + // Substitute parameters in the instructions + const substitutedInstructions = substituteParameters(originalInstructions, recipeParameters); + + // Update the system prompt with substituted instructions + const response = await fetch(getApiUrl('/agent/prompt'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify({ + extension: `${desktopPromptBot}\nIMPORTANT instructions for you to operate as agent:\n${substitutedInstructions}`, + }), + }); + + if (!response.ok) { + console.warn(`Failed to update system prompt with parameters: ${response.statusText}`); + } + } catch (error) { + console.error('Error updating system prompt with parameters:', error); + } +}; + /** * Migrates extensions from localStorage to config.yaml (settings v2) * This function handles the migration from settings v1 to v2 by: