mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
fix: make schedule section visible in recipe modal
- Remove recipeTitle requirement from schedule section - Handle empty titles with 'untitled-recipe' default - Change modal height from h-[90vh] to max-h-[85vh]
This commit is contained in:
@@ -372,7 +372,7 @@ export default function CreateEditRecipeModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background-default border border-borderSubtle rounded-lg w-[90vw] max-w-4xl h-[90vh] flex flex-col">
|
||||
<div className="bg-background-default border border-borderSubtle rounded-lg w-[90vw] max-w-4xl max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-borderSubtle">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -381,12 +381,12 @@ export default function CreateEditRecipeModal({
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-medium text-textProminent">
|
||||
{isCreateMode ? 'Create Recipe' : 'View/edit recipe'}
|
||||
{isCreateMode ? 'Create Command' : 'View/edit command'}
|
||||
</h1>
|
||||
<p className="text-textSubtle text-sm">
|
||||
{isCreateMode
|
||||
? 'Create a new recipe to define agent behavior and capabilities.'
|
||||
: "You can edit the recipe below to change the agent's behavior in a new session."}
|
||||
? 'Create a new command to define agent behavior and capabilities.'
|
||||
: "You can edit the command below to change the agent's behavior in a new session."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -401,17 +401,15 @@ export default function CreateEditRecipeModal({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<RecipeFormFields form={form} />
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 pb-6">
|
||||
<RecipeFormFields
|
||||
form={form}
|
||||
recipeTitle={title}
|
||||
scheduleConfig={scheduleConfig || undefined}
|
||||
onScheduleConfigChange={setScheduleConfig}
|
||||
/>
|
||||
|
||||
|
||||
{/* Schedule Configuration Section */}
|
||||
<div className="mt-6">
|
||||
<ScheduleConfigSection
|
||||
recipeTitle={title}
|
||||
value={scheduleConfig || undefined}
|
||||
onChange={setScheduleConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Deep Link Display */}
|
||||
{requiredFieldsAreFilled() && (
|
||||
@@ -480,7 +478,7 @@ export default function CreateEditRecipeModal({
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{isSaving ? 'Saving...' : 'Save Recipe'}
|
||||
{isSaving ? 'Saving...' : 'Save Command'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveAndRunRecipeClick}
|
||||
@@ -490,7 +488,7 @@ export default function CreateEditRecipeModal({
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
{isSaving ? 'Saving...' : 'Save & Run Recipe'}
|
||||
{isSaving ? 'Saving...' : 'Save & Run Command'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Parameter } from '../../../recipe';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import ParameterInput from '../../parameter/ParameterInput';
|
||||
import RecipeActivityEditor from '../RecipeActivityEditor';
|
||||
import JsonSchemaEditor from './JsonSchemaEditor';
|
||||
import InstructionsEditor from './InstructionsEditor';
|
||||
import { Button } from '../../ui/button';
|
||||
import { RecipeFormApi } from './recipeFormSchema';
|
||||
import { ScheduleConfigSection, ScheduleConfig } from '../../shared/ScheduleConfigSection';
|
||||
|
||||
// Type for field API to avoid linting issues - use any to bypass complex type constraints
|
||||
// Type for field API to avoid linting issues
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type FormFieldApi<_T = any> = any;
|
||||
|
||||
interface RecipeFormFieldsProps {
|
||||
// Form instance from parent
|
||||
form: RecipeFormApi;
|
||||
|
||||
// Event handlers
|
||||
onTitleChange?: (value: string) => void;
|
||||
onDescriptionChange?: (value: string) => void;
|
||||
onInstructionsChange?: (value: string) => void;
|
||||
onPromptChange?: (value: string) => void;
|
||||
onJsonSchemaChange?: (value: string) => void;
|
||||
// Schedule configuration props
|
||||
recipeTitle?: string;
|
||||
scheduleConfig?: ScheduleConfig;
|
||||
onScheduleConfigChange?: (config: ScheduleConfig | null) => void;
|
||||
}
|
||||
|
||||
export const extractTemplateVariables = (content: string): string[] => {
|
||||
@@ -31,17 +33,13 @@ export const extractTemplateVariables = (content: string): string[] => {
|
||||
|
||||
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 validVarRegex = /^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*$/;
|
||||
if (validVarRegex.test(variable)) {
|
||||
variables.push(variable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
};
|
||||
|
||||
@@ -52,32 +50,30 @@ export function RecipeFormFields({
|
||||
onInstructionsChange,
|
||||
onPromptChange,
|
||||
onJsonSchemaChange,
|
||||
recipeTitle,
|
||||
scheduleConfig,
|
||||
onScheduleConfigChange,
|
||||
}: RecipeFormFieldsProps) {
|
||||
const [showJsonSchemaEditor, setShowJsonSchemaEditor] = useState(false);
|
||||
// Advanced configuration state
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Other states
|
||||
const [showInstructionsEditor, setShowInstructionsEditor] = useState(false);
|
||||
const [newParameterName, setNewParameterName] = useState('');
|
||||
const [expandedParameters, setExpandedParameters] = useState<Set<string>>(new Set());
|
||||
|
||||
// Force re-render when instructions, prompt, or activities change
|
||||
const [_forceRender, setForceRender] = useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
return form.store.subscribe(() => {
|
||||
// Force re-render when any form field changes to update parameter usage indicators
|
||||
setForceRender((prev) => prev + 1);
|
||||
});
|
||||
}, [form.store]);
|
||||
|
||||
const parseParametersFromInstructions = React.useCallback(
|
||||
(instructions: string, prompt?: string, activities?: string[]): Parameter[] => {
|
||||
(instructions: string, prompt?: string): Parameter[] => {
|
||||
const instructionVars = extractTemplateVariables(instructions);
|
||||
const promptVars = prompt ? extractTemplateVariables(prompt) : [];
|
||||
const activityVars = activities
|
||||
? activities.flatMap((activity) => extractTemplateVariables(activity))
|
||||
: [];
|
||||
|
||||
// Combine and deduplicate
|
||||
const allVars = [...new Set([...instructionVars, ...promptVars, ...activityVars])];
|
||||
const allVars = [...new Set([...instructionVars, ...promptVars])];
|
||||
|
||||
return allVars.map((key: string) => ({
|
||||
key,
|
||||
@@ -89,76 +85,36 @@ export function RecipeFormFields({
|
||||
[]
|
||||
);
|
||||
|
||||
// Function to update parameters based on current field values
|
||||
const updateParametersFromFields = React.useCallback(() => {
|
||||
const currentValues = form.state.values;
|
||||
const { instructions, prompt, activities, parameters: currentParams } = currentValues;
|
||||
const { instructions, prompt, parameters: currentParams } = currentValues;
|
||||
|
||||
const newParams = parseParametersFromInstructions(instructions, prompt, activities);
|
||||
|
||||
// Separate manually added parameters (those not found in instructions/prompt/activities)
|
||||
const newParams = parseParametersFromInstructions(instructions, prompt);
|
||||
const manualParams = currentParams.filter((param: Parameter) => {
|
||||
// Only keep manual params that have a valid key and are not found in the parsed params
|
||||
return (
|
||||
param.key && param.key.trim() && !newParams.some((newParam) => newParam.key === param.key)
|
||||
);
|
||||
return !newParams.some((np) => np.key === param.key);
|
||||
});
|
||||
|
||||
// Combine parsed parameters with manually added ones, filtering out empty ones
|
||||
const combinedParams = [
|
||||
...newParams.map((newParam) => {
|
||||
const existing = currentParams.find((cp: Parameter) => cp.key === newParam.key);
|
||||
return existing ? { ...existing } : newParam;
|
||||
}),
|
||||
...manualParams,
|
||||
].filter((param: Parameter) => param.key && param.key.trim()) as Parameter[];
|
||||
|
||||
// Only update if parameters actually changed
|
||||
const currentParamKeys = currentParams.map((p: Parameter) => p.key).sort();
|
||||
const newParamKeys = combinedParams.map((p) => p.key).sort();
|
||||
|
||||
if (JSON.stringify(currentParamKeys) !== JSON.stringify(newParamKeys)) {
|
||||
form.setFieldValue('parameters', combinedParams);
|
||||
}
|
||||
const allParams = [...newParams, ...manualParams];
|
||||
form.setFieldValue('parameters', allParams);
|
||||
}, [form, parseParametersFromInstructions]);
|
||||
|
||||
const isParameterUsed = (
|
||||
paramKey: string,
|
||||
instructions: string,
|
||||
prompt?: string,
|
||||
activities?: string[]
|
||||
): boolean => {
|
||||
const regex = new RegExp(
|
||||
`\\{\\{\\s*${paramKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\}\\}`,
|
||||
'g'
|
||||
);
|
||||
const usedInInstructions = regex.test(instructions);
|
||||
const usedInPrompt = prompt ? regex.test(prompt) : false;
|
||||
const usedInActivities = activities
|
||||
? activities.some((activity) => {
|
||||
// For activities, we need to check the full activity string, including message: prefixes
|
||||
return regex.test(activity);
|
||||
})
|
||||
: false;
|
||||
return usedInInstructions || usedInPrompt || usedInActivities;
|
||||
};
|
||||
React.useEffect(() => {
|
||||
updateParametersFromFields();
|
||||
}, [updateParametersFromFields]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="recipe-form">
|
||||
{/* Title Field */}
|
||||
<div className="space-y-4">
|
||||
{/* REQUIRED: Title Field */}
|
||||
<form.Field name="title">
|
||||
{(field: FormFieldApi<string>) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="recipe-title"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
<label htmlFor="recipe-title" className="block text-sm font-medium text-text-standard mb-2">
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="recipe-title"
|
||||
type="text"
|
||||
value={field.state.value}
|
||||
value={field.state.value || ''}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
onTitleChange?.(e.target.value);
|
||||
@@ -167,7 +123,7 @@ export function RecipeFormFields({
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||
}`}
|
||||
placeholder="Recipe title"
|
||||
placeholder="Give your command a descriptive name"
|
||||
data-testid="title-input"
|
||||
/>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
@@ -177,314 +133,264 @@ export function RecipeFormFields({
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{/* Description Field */}
|
||||
<form.Field name="description">
|
||||
{(field: FormFieldApi<string>) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="recipe-description"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="recipe-description"
|
||||
type="text"
|
||||
value={field.state.value}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
onDescriptionChange?.(e.target.value);
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||
}`}
|
||||
placeholder="Brief description of what this recipe does"
|
||||
data-testid="description-input"
|
||||
/>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">{field.state.meta.errors[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{/* Instructions Field */}
|
||||
{/* REQUIRED: Instructions Field */}
|
||||
<form.Field name="instructions">
|
||||
{(field: FormFieldApi<string>) => (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label
|
||||
htmlFor="recipe-instructions"
|
||||
className="block text-sm font-medium text-text-standard"
|
||||
>
|
||||
<label htmlFor="recipe-instructions" className="block text-sm font-medium text-text-standard">
|
||||
Instructions <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setShowInstructionsEditor(true)}
|
||||
variant="outline"
|
||||
onClick={() => setShowInstructionsEditor(!showInstructionsEditor)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
Open Editor
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
id="recipe-instructions"
|
||||
value={field.state.value}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
onInstructionsChange?.(e.target.value);
|
||||
}}
|
||||
onBlur={() => {
|
||||
field.handleBlur();
|
||||
updateParametersFromFields();
|
||||
}}
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono text-sm ${
|
||||
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||
}`}
|
||||
placeholder="Detailed instructions for the AI, hidden from the user..."
|
||||
rows={8}
|
||||
data-testid="instructions-input"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Use {`{{parameter_name}}`} to define parameters that users can fill in
|
||||
</p>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">{field.state.meta.errors[0]}</p>
|
||||
)}
|
||||
|
||||
{/* Instructions Editor Modal */}
|
||||
<InstructionsEditor
|
||||
isOpen={showInstructionsEditor}
|
||||
onClose={() => setShowInstructionsEditor(false)}
|
||||
value={field.state.value}
|
||||
onChange={(value) => {
|
||||
field.handleChange(value);
|
||||
onInstructionsChange?.(value);
|
||||
updateParametersFromFields();
|
||||
}}
|
||||
error={field.state.meta.errors.length > 0 ? field.state.meta.errors[0] : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{/* Initial Prompt Field */}
|
||||
<form.Field name="prompt">
|
||||
{(field: FormFieldApi<string | undefined>) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="recipe-prompt"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Initial Prompt
|
||||
</label>
|
||||
<p className="text-xs text-text-muted mt-2 mb-2">
|
||||
(Optional - Instructions or Prompt are required)
|
||||
</p>
|
||||
<textarea
|
||||
id="recipe-prompt"
|
||||
value={field.state.value || ''}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
onPromptChange?.(e.target.value);
|
||||
}}
|
||||
onBlur={() => {
|
||||
field.handleBlur();
|
||||
updateParametersFromFields();
|
||||
}}
|
||||
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
placeholder="Pre-filled prompt when the recipe starts..."
|
||||
rows={3}
|
||||
data-testid="prompt-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{/* Activities Field */}
|
||||
<form.Field name="activities">
|
||||
{(field: FormFieldApi<string[]>) => (
|
||||
<div>
|
||||
<RecipeActivityEditor
|
||||
activities={field.state.value}
|
||||
setActivities={(activities) => field.handleChange(activities)}
|
||||
onBlur={updateParametersFromFields}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{/* Parameters Field */}
|
||||
<form.Field name="parameters">
|
||||
{(field: FormFieldApi<Parameter[]>) => {
|
||||
const handleAddParameter = () => {
|
||||
if (newParameterName.trim()) {
|
||||
const newParam: Parameter = {
|
||||
key: newParameterName.trim(),
|
||||
description: `Enter value for ${newParameterName.trim()}`,
|
||||
input_type: 'string',
|
||||
requirement: 'required',
|
||||
};
|
||||
field.handleChange([...field.state.value, newParam]);
|
||||
setNewParameterName('');
|
||||
// Expand the newly added parameter by default
|
||||
setExpandedParameters((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(newParam.key);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddParameter();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteParameter = (parameterKey: string) => {
|
||||
const updatedParams = field.state.value.filter(
|
||||
(param: Parameter) => param.key !== parameterKey
|
||||
);
|
||||
field.handleChange(updatedParams);
|
||||
// Remove from expanded set if it was expanded
|
||||
setExpandedParameters((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(parameterKey);
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleExpanded = (parameterKey: string) => {
|
||||
setExpandedParameters((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(parameterKey)) {
|
||||
newSet.delete(parameterKey);
|
||||
} else {
|
||||
newSet.add(parameterKey);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-md text-textProminent mb-2 font-bold">Parameters</label>
|
||||
<p className="text-textSubtle text-sm space-y-2 pb-4">
|
||||
Parameters will be automatically detected from {`{{parameter_name}}`} syntax in
|
||||
instructions/prompt/activities or you can manually add them below.
|
||||
</p>
|
||||
|
||||
{/* Add Parameter Input - Always Visible */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newParameterName}
|
||||
onChange={(e) => setNewParameterName(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Enter parameter name..."
|
||||
className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddParameter}
|
||||
disabled={!newParameterName.trim()}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
Add parameter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{field.state.value.length > 0 &&
|
||||
field.state.value
|
||||
.filter((parameter: Parameter) => parameter.key && parameter.key.trim()) // Filter out empty parameters
|
||||
.map((parameter: Parameter) => {
|
||||
const currentValues = form.state.values;
|
||||
const isUnused = !isParameterUsed(
|
||||
parameter.key,
|
||||
currentValues.instructions,
|
||||
currentValues.prompt,
|
||||
currentValues.activities
|
||||
);
|
||||
|
||||
return (
|
||||
<ParameterInput
|
||||
key={parameter.key}
|
||||
parameter={parameter}
|
||||
isUnused={isUnused}
|
||||
isExpanded={expandedParameters.has(parameter.key)}
|
||||
onToggleExpanded={handleToggleExpanded}
|
||||
onDelete={handleDeleteParameter}
|
||||
onChange={(name, value) => {
|
||||
const updatedParams = field.state.value.map((param: Parameter) =>
|
||||
param.key === name ? { ...param, ...value } : param
|
||||
);
|
||||
field.handleChange(updatedParams);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
|
||||
{/* JSON Schema Field */}
|
||||
<form.Field name="jsonSchema">
|
||||
{(field: FormFieldApi<string | undefined>) => (
|
||||
<div>
|
||||
<label className="block text-md text-textProminent mb-2 font-bold">
|
||||
Response JSON Schema
|
||||
</label>
|
||||
<p className="text-textSubtle text-sm space-y-2 pb-4">
|
||||
Define the expected structure of the AI's response using JSON Schema format
|
||||
</p>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setShowJsonSchemaEditor(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
Open Editor
|
||||
{showInstructionsEditor ? 'Hide' : 'Show'} Editor
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{field.state.value && field.state.value.trim() && (
|
||||
<div
|
||||
className={`border rounded-lg p-3 bg-background-muted ${
|
||||
{showInstructionsEditor ? (
|
||||
<InstructionsEditor
|
||||
value={field.state.value || ''}
|
||||
onChange={(value) => {
|
||||
field.handleChange(value);
|
||||
onInstructionsChange?.(value);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
id="recipe-instructions"
|
||||
value={field.state.value || ''}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
onInstructionsChange?.(e.target.value);
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono text-sm ${
|
||||
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||
}`}
|
||||
>
|
||||
<pre className="text-xs font-mono text-text-standard whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
|
||||
{field.state.value}
|
||||
</pre>
|
||||
</div>
|
||||
placeholder="Detailed instructions for the AI, hidden from the user..."
|
||||
rows={8}
|
||||
data-testid="instructions-input"
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">{field.state.meta.errors[0]}</p>
|
||||
)}
|
||||
|
||||
{/* JSON Schema Editor Modal */}
|
||||
<JsonSchemaEditor
|
||||
isOpen={showJsonSchemaEditor}
|
||||
onClose={() => setShowJsonSchemaEditor(false)}
|
||||
value={field.state.value || ''}
|
||||
onChange={(value) => {
|
||||
field.handleChange(value);
|
||||
onJsonSchemaChange?.(value);
|
||||
}}
|
||||
error={field.state.meta.errors.length > 0 ? field.state.meta.errors[0] : undefined}
|
||||
/>
|
||||
<p className="text-text-muted text-xs mt-1">
|
||||
Use {`{{variable_name}}`} syntax to create parameters
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{/* SCHEDULE CONFIGURATION - Moved above Advanced Configuration */}
|
||||
{onScheduleConfigChange && (
|
||||
<ScheduleConfigSection
|
||||
recipeTitle={recipeTitle}
|
||||
value={scheduleConfig}
|
||||
onChange={onScheduleConfigChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ADVANCED CONFIGURATION - Collapsible Section */}
|
||||
<div className="border border-border-subtle rounded-lg p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="w-full flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-text-prominent">
|
||||
Advanced Configuration
|
||||
</h3>
|
||||
<span className="text-xs text-text-muted">(Optional)</span>
|
||||
</div>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp className="w-5 h-5 text-text-muted" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-6 space-y-6 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
{/* Description Field */}
|
||||
<form.Field name="description">
|
||||
{(field: FormFieldApi<string>) => (
|
||||
<div>
|
||||
<label htmlFor="recipe-description" className="block text-sm font-medium text-text-standard mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="recipe-description"
|
||||
value={field.state.value || ''}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
onDescriptionChange?.(e.target.value);
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none ${
|
||||
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||
}`}
|
||||
placeholder="Brief description of what this command does"
|
||||
rows={3}
|
||||
data-testid="description-input"
|
||||
/>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">{field.state.meta.errors[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{/* Initial Prompt Field */}
|
||||
<form.Field name="prompt">
|
||||
{(field: FormFieldApi<string | undefined>) => (
|
||||
<div>
|
||||
<label htmlFor="recipe-prompt" className="block text-sm font-medium text-text-standard mb-2">
|
||||
Initial Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="recipe-prompt"
|
||||
value={field.state.value || ''}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
onPromptChange?.(e.target.value);
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
placeholder="Pre-filled prompt when the command starts..."
|
||||
rows={3}
|
||||
data-testid="prompt-input"
|
||||
/>
|
||||
<p className="text-text-muted text-xs mt-1">
|
||||
This message will appear in the chat when the command starts
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{/* Parameters Field */}
|
||||
<form.Field name="parameters">
|
||||
{(field: FormFieldApi<Parameter[]>) => {
|
||||
const handleAddParameter = () => {
|
||||
if (newParameterName.trim()) {
|
||||
const newParam: Parameter = {
|
||||
key: newParameterName.trim(),
|
||||
description: `Enter value for ${newParameterName.trim()}`,
|
||||
requirement: 'required',
|
||||
input_type: 'string',
|
||||
};
|
||||
field.handleChange([...field.state.value, newParam]);
|
||||
setNewParameterName('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveParameter = (index: number) => {
|
||||
const updated = field.state.value.filter((_: Parameter, i: number) => i !== index);
|
||||
field.handleChange(updated);
|
||||
};
|
||||
|
||||
const handleUpdateParameter = (index: number, updated: Parameter) => {
|
||||
const newParams = [...field.state.value];
|
||||
newParams[index] = updated;
|
||||
field.handleChange(newParams);
|
||||
};
|
||||
|
||||
const toggleExpanded = (key: string) => {
|
||||
const newExpanded = new Set(expandedParameters);
|
||||
if (newExpanded.has(key)) {
|
||||
newExpanded.delete(key);
|
||||
} else {
|
||||
newExpanded.add(key);
|
||||
}
|
||||
setExpandedParameters(newExpanded);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddParameter();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-standard mb-2">
|
||||
Parameters
|
||||
</label>
|
||||
<p className="text-text-muted text-sm mb-4">
|
||||
Parameters will be automatically detected from {`{{parameter_name}}`} syntax in
|
||||
instructions/prompt or you can manually add them below.
|
||||
</p>
|
||||
|
||||
{/* Add parameter input */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newParameterName}
|
||||
onChange={(e) => setNewParameterName(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Enter parameter name..."
|
||||
className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddParameter}
|
||||
disabled={!newParameterName.trim()}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-sm font-medium transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Parameters list */}
|
||||
{field.state.value.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{field.state.value.map((param: Parameter, index: number) => (
|
||||
<ParameterInput
|
||||
key={param.key}
|
||||
parameter={param}
|
||||
isExpanded={expandedParameters.has(param.key)}
|
||||
onToggleExpand={() => toggleExpanded(param.key)}
|
||||
onUpdate={(updated) => handleUpdateParameter(index, updated)}
|
||||
onRemove={() => handleRemoveParameter(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
|
||||
{/* JSON Schema Field */}
|
||||
<form.Field name="jsonSchema">
|
||||
{(field: FormFieldApi<string | undefined>) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-standard mb-2">
|
||||
Response JSON Schema
|
||||
</label>
|
||||
<p className="text-text-muted text-sm mb-4">
|
||||
Define a JSON schema to structure the AI's response format.
|
||||
</p>
|
||||
<JsonSchemaEditor
|
||||
value={field.state.value || ''}
|
||||
onChange={(value) => {
|
||||
field.handleChange(value);
|
||||
onJsonSchemaChange?.(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,26 +15,30 @@ export type RecipeParameter = z.infer<typeof parameterSchema>;
|
||||
|
||||
// Main recipe form schema
|
||||
export const recipeFormSchema = z.object({
|
||||
// REQUIRED FIELDS
|
||||
title: z
|
||||
.string()
|
||||
.min(1, 'Title is required')
|
||||
.min(3, 'Title must be at least 3 characters')
|
||||
.max(100, 'Title must be 100 characters or less'),
|
||||
|
||||
description: z
|
||||
.string()
|
||||
.min(1, 'Description is required')
|
||||
.min(10, 'Description must be at least 10 characters')
|
||||
.max(500, 'Description must be 500 characters or less'),
|
||||
|
||||
instructions: z
|
||||
.string()
|
||||
.min(1, 'Instructions are required')
|
||||
.min(20, 'Instructions must be at least 20 characters'),
|
||||
|
||||
prompt: z.string().optional(),
|
||||
// OPTIONAL FIELDS
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => !val || val.length >= 10, {
|
||||
message: 'Description must be at least 10 characters if provided',
|
||||
})
|
||||
.refine((val) => !val || val.length <= 500, {
|
||||
message: 'Description must be 500 characters or less',
|
||||
}),
|
||||
|
||||
activities: z.array(z.string()).default([]),
|
||||
prompt: z.string().optional(),
|
||||
|
||||
parameters: z.array(parameterSchema).default([]),
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ export const ScheduleConfigSection: React.FC<ScheduleConfigSectionProps> = ({
|
||||
}) => {
|
||||
// Generate schedule ID from recipe title
|
||||
const generateScheduleId = (title: string): string => {
|
||||
if (!title || title.trim() === '') {
|
||||
return 'untitled-recipe';
|
||||
}
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
|
||||
Reference in New Issue
Block a user