Files
tkmind_go/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx
T
Abhijay Jain 79f539f8af feat: ability to manage sub recipes in desktop ui (#6360)
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
Signed-off-by: Abhijay Jain <Abhijay007j@gmail.com>
2026-03-23 15:34:58 +00:00

507 lines
16 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import { useForm } from '@tanstack/react-form';
import { Recipe, generateDeepLink, Parameter } from '../../recipe';
import { Check, ExternalLink, Play, Save, X } from 'lucide-react';
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';
import { toastSuccess, toastError } from '../../toasts';
import { saveRecipe } from '../../recipe/recipe_management';
import { errorMessage } from '../../utils/conversionUtils';
interface CreateEditRecipeModalProps {
isOpen: boolean;
onClose: (wasSaved?: boolean) => void;
recipe?: Recipe;
isCreateMode?: boolean;
recipeId?: string | null;
onRecipeSaved?: (savedRecipeId: string) => void;
}
export default function CreateEditRecipeModal({
isOpen,
onClose,
recipe,
isCreateMode = false,
recipeId,
onRecipeSaved,
}: CreateEditRecipeModalProps) {
const getInitialValues = React.useCallback((): RecipeFormData => {
if (recipe) {
return {
title: recipe.title || '',
description: recipe.description || '',
instructions: recipe.instructions || '',
prompt: recipe.prompt || '',
activities: recipe.activities || [],
parameters: recipe.parameters || [],
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,
subRecipes: (recipe.sub_recipes || []).map((sr) => ({
name: sr.name,
path: sr.path,
description: sr.description || undefined,
values: sr.values || undefined,
sequential_when_repeated: sr.sequential_when_repeated ?? false,
})),
};
}
return {
title: '',
description: '',
instructions: '',
prompt: '',
activities: [],
parameters: [],
jsonSchema: '',
model: undefined,
provider: undefined,
extensions: undefined,
subRecipes: [],
};
}, [recipe]);
const form = useForm({
defaultValues: getInitialValues(),
});
// Helper functions to get values from form - using state to trigger re-renders
const [title, setTitle] = useState(form.state.values.title);
const [description, setDescription] = useState(form.state.values.description);
const [instructions, setInstructions] = useState(form.state.values.instructions);
const [prompt, setPrompt] = useState(form.state.values.prompt);
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);
const [subRecipes, setSubRecipes] = useState(form.state.values.subRecipes);
// Subscribe to form changes to update local state
useEffect(() => {
return form.store.subscribe(() => {
setTitle(form.state.values.title);
setDescription(form.state.values.description);
setInstructions(form.state.values.instructions);
setPrompt(form.state.values.prompt);
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);
setSubRecipes(form.state.values.subRecipes);
});
}, [form]);
const [copied, setCopied] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Reset form when recipe changes
useEffect(() => {
if (recipe) {
const newValues = getInitialValues();
form.reset(newValues);
}
}, [recipe, form, getInitialValues]);
const getCurrentRecipe = useCallback((): Recipe => {
// Transform the internal parameters state into the desired output format.
const formattedParameters = parameters.map((param) => {
const formattedParam: Parameter = {
key: param.key,
input_type: param.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) {
formattedParam.default = param.default;
}
// Add options for select input type
if (param.input_type === 'select' && param.options) {
formattedParam.options = param.options.filter((opt) => opt.trim() !== ''); // Filter empty options when saving
}
return formattedParam;
});
// Parse response schema if provided
let responseConfig = undefined;
if (jsonSchema && jsonSchema.trim()) {
try {
const parsedSchema = JSON.parse(jsonSchema);
responseConfig = { json_schema: parsedSchema };
} catch (error) {
console.warn('Invalid JSON schema provided:', error);
// If JSON is invalid, don't include response config
}
}
// Format subrecipes for API (convert from form data to API format)
const formattedSubRecipes =
subRecipes.length > 0
? subRecipes.map((subRecipe) => ({
name: subRecipe.name,
path: subRecipe.path,
description: subRecipe.description || undefined,
values:
subRecipe.values && Object.keys(subRecipe.values).length > 0
? subRecipe.values
: undefined,
sequential_when_repeated: subRecipe.sequential_when_repeated,
}))
: undefined;
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,
description,
instructions,
activities,
prompt: prompt || undefined,
parameters: formattedParameters,
response: responseConfig,
sub_recipes: formattedSubRecipes,
extensions: cleanedExtensions,
settings,
};
}, [
recipe,
title,
description,
instructions,
activities,
prompt,
parameters,
jsonSchema,
subRecipes,
model,
provider,
extensions,
]);
const requiredFieldsAreFilled = () => {
return title.trim() && description.trim() && (instructions.trim() || (prompt || '').trim());
};
const validateForm = () => {
const basicValidation =
title.trim() && description.trim() && (instructions.trim() || (prompt || '').trim());
// If JSON schema is provided, it must be valid
if (jsonSchema && jsonSchema.trim()) {
try {
JSON.parse(jsonSchema);
} catch {
return false; // Invalid JSON schema fails validation
}
}
return basicValidation;
};
const [deeplink, setDeeplink] = useState('');
const [isGeneratingDeeplink, setIsGeneratingDeeplink] = useState(false);
// Generate deeplink whenever recipe configuration changes
useEffect(() => {
let isCancelled = false;
const generateLink = async () => {
if (
!title.trim() ||
!description.trim() ||
(!instructions.trim() && !(prompt || '').trim())
) {
setDeeplink('');
return;
}
setIsGeneratingDeeplink(true);
try {
const currentRecipe = getCurrentRecipe();
const link = await generateDeepLink(currentRecipe);
if (!isCancelled) {
setDeeplink(link);
}
} catch (error) {
console.error('Failed to generate deeplink:', error);
if (!isCancelled) {
setDeeplink('Error generating deeplink');
}
} finally {
if (!isCancelled) {
setIsGeneratingDeeplink(false);
}
}
};
generateLink();
return () => {
isCancelled = true;
};
}, [
title,
description,
instructions,
prompt,
activities,
parameters,
jsonSchema,
subRecipes,
model,
provider,
extensions,
getCurrentRecipe,
]);
const handleCopy = () => {
if (!deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink') {
return;
}
navigator.clipboard
.writeText(deeplink)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch((err) => {
console.error('Failed to copy the text:', err);
});
};
const handleSaveRecipeClick = async () => {
if (!validateForm()) {
toastError({
title: 'Validation Failed',
msg: 'Please fill in all required fields and ensure JSON schema is valid.',
});
return;
}
setIsSaving(true);
try {
const recipe = getCurrentRecipe();
const { id: savedRecipeId } = await saveRecipe(recipe, recipeId);
if (onRecipeSaved) {
onRecipeSaved(savedRecipeId);
}
onClose(true);
toastSuccess({
title: (recipe.title || '').trim(),
msg: 'Recipe saved successfully',
});
} catch (error) {
console.error('Failed to save recipe:', error);
toastError({
title: 'Save Failed',
msg: `Failed to save recipe: ${errorMessage(error, 'Unknown error')}`,
traceback: errorMessage(error),
});
} finally {
setIsSaving(false);
}
};
const handleSaveAndRunRecipeClick = async () => {
if (!validateForm()) {
toastError({
title: 'Validation Failed',
msg: 'Please fill in all required fields and ensure JSON schema is valid.',
});
return;
}
setIsSaving(true);
try {
const recipe = getCurrentRecipe();
const { id: savedId } = await saveRecipe(recipe, recipeId);
onClose(true);
window.electron.createChatWindow({ recipeId: savedId });
toastSuccess({
title: recipe.title,
msg: 'Recipe saved and launched successfully',
});
} catch (error) {
console.error('Failed to save and run recipe:', error);
toastError({
title: 'Save and Run Failed',
msg: `Failed to save and run recipe: ${errorMessage(error, 'Unknown error')}`,
traceback: errorMessage(error),
});
} finally {
setIsSaving(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[400] flex items-center justify-center bg-black/50">
<div className="bg-background-primary border border-border-primary rounded-lg w-[90vw] max-w-4xl h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border-primary">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-background-primary rounded-full flex items-center justify-center">
<Geese className="w-6 h-6 text-iconProminent" />
</div>
<div>
<h1 className="text-xl font-medium text-text-primary">
{isCreateMode ? 'Create Recipe' : 'View/edit recipe'}
</h1>
<p className="text-text-secondary text-sm">
{isCreateMode
? 'Create a new recipe to define agent behavior and capabilities for reusable chat sessions.'
: "You can edit the recipe below to change the agent's behavior in a new session."}{' '}
<a
href="https://block.github.io/goose/docs/guides/recipes/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-500 hover:text-blue-600 hover:underline"
>
Learn more
<ExternalLink className="w-3 h-3" />
</a>
</p>
</div>
</div>
<Button
onClick={() => onClose(false)}
variant="ghost"
size="sm"
className="p-2 hover:bg-background-secondary rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<RecipeFormFields form={form} />
{/* Deep Link Display */}
{requiredFieldsAreFilled() && (
<div className="w-full p-4 bg-background-secondary rounded-lg mt-6">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-text-secondary">
Copy this link to share with friends or paste directly in Chrome to open
</div>
<Button
onClick={handleCopy}
variant="ghost"
size="sm"
disabled={
!deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink'
}
className="ml-4 p-2 hover:bg-background-primary rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4 text-iconSubtle" />
)}
<span className="ml-1 text-sm text-text-secondary">
{copied ? 'Copied!' : 'Copy'}
</span>
</Button>
</div>
<div
onClick={handleCopy}
className="text-sm truncate font-mono cursor-pointer text-text-primary"
>
{isGeneratingDeeplink
? 'Generating deeplink...'
: deeplink || 'Click to generate deeplink'}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-border-primary">
<Button
onClick={() => onClose(false)}
variant="ghost"
className="px-4 py-2 text-text-secondary rounded-lg hover:bg-background-secondary transition-colors"
>
Close
</Button>
<div className="flex gap-3">
<Button
onClick={handleSaveRecipeClick}
disabled={!requiredFieldsAreFilled() || isSaving}
variant="outline"
size="default"
className="inline-flex items-center justify-center gap-2 px-4 py-2"
>
<Save className="w-4 h-4" />
{isSaving ? 'Saving...' : 'Save Recipe'}
</Button>
<Button
onClick={handleSaveAndRunRecipeClick}
disabled={!requiredFieldsAreFilled() || isSaving}
variant="default"
size="default"
className="inline-flex items-center justify-center gap-2 px-4 py-2"
>
<Play className="w-4 h-4" />
{isSaving ? 'Saving...' : 'Save & Run Recipe'}
</Button>
</div>
</div>
</div>
</div>
);
}