Feat: Support Recipe Parameters in Goose desktop app (#3155)

This commit is contained in:
Aaron Goldsmith
2025-07-02 12:40:09 -07:00
committed by GitHub
parent 8a9300cff0
commit 2e97621348
6 changed files with 435 additions and 10 deletions

View File

@@ -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, string>): 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<number>(0);
const [ancestorMessages, setAncestorMessages] = useState<Message[]>([]);
const [droppedFiles, setDroppedFiles] = useState<string[]>([]);
const [isParameterModalOpen, setIsParameterModalOpen] = useState(false);
const [recipeParameters, setRecipeParameters] = useState<Record<string, string> | 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<string, string>) => {
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 && (
<ParameterInputModal
parameters={recipeConfig.parameters}
onSubmit={handleParameterSubmit}
onClose={() => setIsParameterModalOpen(false)}
/>
)}
</div>
</CurrentModelContext.Provider>
);

View File

@@ -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<string, string>) => void;
onClose: () => void;
}
const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
parameters,
onSubmit,
onClose,
}) => {
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [showCancelOptions, setShowCancelOptions] = useState(false);
// Pre-fill the form with default values from the recipe
useEffect(() => {
const initialValues: Record<string, string> = {};
parameters.forEach((param) => {
if (param.default) {
initialValues[param.key] = param.default;
}
});
setInputValues(initialValues);
}, [parameters]);
const handleChange = (name: string, value: string): void => {
setInputValues((prevValues: Record<string, string>) => ({ ...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<string, string> = {};
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 (
<div className="fixed inset-0 backdrop-blur-sm z-50 flex justify-center items-center animate-[fadein_200ms_ease-in]">
{showCancelOptions ? (
// Cancel options modal
<div className="bg-bgApp border border-borderSubtle rounded-xl p-8 shadow-2xl w-full max-w-md">
<h2 className="text-xl font-bold text-textProminent mb-4">Cancel Recipe Setup</h2>
<p className="text-textStandard mb-6">What would you like to do?</p>
<div className="flex flex-col gap-3">
<Button
onClick={() => handleCancelOption('back-to-form')}
variant="default"
size="lg"
className="w-full rounded-full"
>
Back to Parameter Form
</Button>
<Button
onClick={() => handleCancelOption('new-chat')}
variant="outline"
size="lg"
className="w-full rounded-full"
>
Start New Chat (No Recipe)
</Button>
</div>
</div>
) : (
// Main parameter form
<div className="bg-bgApp border border-borderSubtle rounded-xl p-8 shadow-2xl w-full max-w-lg">
<h2 className="text-xl font-bold text-textProminent mb-6">Recipe Parameters</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{parameters.map((param) => (
<div key={param.key}>
<label className="block text-md font-medium text-textStandard mb-2">
{param.description || param.key}
{param.requirement === 'required' && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type="text"
value={inputValues[param.key] || ''}
onChange={(e) => 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] && (
<p className="text-red-500 text-sm mt-1">{validationErrors[param.key]}</p>
)}
</div>
))}
<div className="flex justify-end gap-4 pt-6">
<Button
type="button"
onClick={handleCancel}
variant="outline"
size="default"
className="rounded-full"
>
Cancel
</Button>
<Button type="submit" variant="default" size="default" className="rounded-full">
Start Recipe
</Button>
</div>
</form>
</div>
)}
</div>
);
};
export default ParameterInputModal;

View File

@@ -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<string[]>(config?.activities || []);
const [parameters, setParameters] = useState<Parameter[]>(
parseParametersFromInstructions(instructions)
);
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
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<Parameter>) => {
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 (
<div className="flex flex-col w-full h-screen bg-bgApp max-w-3xl mx-auto">
{activeSection === 'none' && (
@@ -339,6 +398,13 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
<div className="text-red-500 text-sm mt-1">{errors.instructions}</div>
)}
</div>
{parameters.map((parameter: Parameter) => (
<ParameterInput
key={parameter.key}
parameter={parameter}
onChange={(name, value) => handleParameterChange(name, value)}
/>
))}
<div className="pt-3 pb-6 border-b-2 border-borderSubtle">
<RecipeExpandableInfo
infoLabel="Initial Prompt"
@@ -420,6 +486,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
</div>
</div>
</div>
<RecipeInfoModal
infoLabel={recipeInfoModelProps?.label}
originalValue={recipeInfoModelProps?.value}

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { Parameter } from '../../recipe';
interface ParameterInputProps {
parameter: Parameter;
onChange: (name: string, updatedParameter: Partial<Parameter>) => void;
}
const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange }) => {
// All values are derived directly from props, maintaining the controlled component pattern
const { key, description, requirement } = parameter;
const defaultValue = parameter.default || '';
return (
<div className="parameter-input my-4 p-4 border rounded-lg bg-bgSubtle shadow-sm">
<h3 className="text-lg font-bold text-textProminent mb-4">
Parameter: <code className="bg-bgApp px-2 py-1 rounded-md">{parameter.key}</code>
</h3>
<div className="mb-4">
<label className="block text-md text-textStandard mb-2 font-semibold">description</label>
<input
type="text"
value={description || ''}
onChange={(e) => 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"`}
/>
<p className="text-sm text-textSubtle mt-1">This is the message the end-user will see.</p>
</div>
{/* Controls for requirement and default value */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-md text-textStandard mb-2 font-semibold">Requirement</label>
<select
className="w-full p-3 border rounded-lg bg-bgApp text-textStandard"
value={requirement}
onChange={(e) =>
onChange(key, { requirement: e.target.value as Parameter['requirement'] })
}
>
<option value="required">Required</option>
<option value="optional">Optional</option>
</select>
</div>
{/* The default value input is only shown for optional parameters */}
{requirement === 'optional' && (
<div>
<label className="block text-md text-textStandard mb-2 font-semibold">
Default Value
</label>
<input
type="text"
value={defaultValue}
onChange={(e) => onChange(key, { default: e.target.value })}
className="w-full p-3 border rounded-lg bg-bgApp text-textStandard"
placeholder="Enter default value"
/>
</div>
)}
</div>
</div>
);
};
export default ParameterInput;

View File

@@ -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;

View File

@@ -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, string>): 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<string, string>
): Promise<void> => {
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: