Spence/vision marketplace (#5904)
@@ -24,7 +24,7 @@ import { TabbedPairRoute } from './components/TabbedPairRoute';
|
||||
import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView';
|
||||
import SessionsView from './components/sessions/SessionsView';
|
||||
import SharedSessionView from './components/sessions/SharedSessionView';
|
||||
import SchedulesView from './components/schedule/SchedulesView';
|
||||
import MarketplaceView from './components/marketplace/MarketplaceView';
|
||||
import ProviderSettings from './components/settings/providers/ProviderSettingsPage';
|
||||
import { AppLayout } from './components/Layout/AppLayout';
|
||||
import { ChatProvider } from './contexts/ChatContext';
|
||||
@@ -139,8 +139,7 @@ const SessionsRoute = () => {
|
||||
};
|
||||
|
||||
const SchedulesRoute = () => {
|
||||
const navigate = useNavigate();
|
||||
return <SchedulesView onClose={() => navigate('/')} />;
|
||||
return <MarketplaceView />;
|
||||
};
|
||||
|
||||
const RecipesRoute = () => {
|
||||
|
||||
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
@@ -73,9 +73,9 @@ const menuItems: NavigationEntry[] = [
|
||||
{
|
||||
type: 'item',
|
||||
path: '/schedules',
|
||||
label: 'Scheduler',
|
||||
label: 'Marketplace',
|
||||
icon: Clock,
|
||||
tooltip: 'Manage scheduled runs',
|
||||
tooltip: 'Browse marketplace',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Home, History, FileText, Clock, Puzzle, Settings as SettingsIcon, GripVertical, Users, Hash } from 'lucide-react';
|
||||
import { Home, History, FileText, Clock, Puzzle, Settings as SettingsIcon, GripVertical, Users, Hash, ShoppingBag } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChatSmart } from '../icons';
|
||||
import { listSessions, getSessionInsights } from '../../api';
|
||||
@@ -258,10 +258,8 @@ export const TopNavigation: React.FC<TopNavigationProps> = ({ isExpanded, setIsE
|
||||
{
|
||||
id: 'scheduler',
|
||||
path: '/schedules',
|
||||
label: 'Scheduler',
|
||||
icon: Clock,
|
||||
getTag: () => `${scheduledTodayCount} today`,
|
||||
tagAlign: 'left',
|
||||
label: 'Marketplace',
|
||||
icon: ShoppingBag,
|
||||
},
|
||||
{
|
||||
id: 'extensions',
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import { z } from 'zod';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { FileText, Clock, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Button } from '../ui/button';
|
||||
import { Recipe } from '../../recipe';
|
||||
import { saveRecipe } from '../../recipe/recipeStorage';
|
||||
import { saveRecipe, listSavedRecipes } from '../../recipe/recipeStorage';
|
||||
import { toastSuccess, toastError } from '../../toasts';
|
||||
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
||||
import { RecipeNameField, recipeNameSchema } from './shared/RecipeNameField';
|
||||
import { generateRecipeNameFromTitle } from './shared/recipeNameUtils';
|
||||
import { validateJsonSchema, getValidationErrorMessages } from '../../recipe/validation';
|
||||
import { createSchedule } from '../../schedule';
|
||||
import cronstrue from 'cronstrue';
|
||||
|
||||
interface CreateRecipeFormProps {
|
||||
isOpen: boolean;
|
||||
@@ -27,14 +30,53 @@ const createRecipeSchema = z.object({
|
||||
jsonSchema: z.string(),
|
||||
recipeName: recipeNameSchema,
|
||||
global: z.boolean(),
|
||||
enableSchedule: z.boolean(),
|
||||
cronExpression: z.string(),
|
||||
scheduleId: z.string(),
|
||||
});
|
||||
|
||||
export default function CreateRecipeForm({ isOpen, onClose, onSuccess }: CreateRecipeFormProps) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [cronPreview, setCronPreview] = useState<string>('');
|
||||
const [existingRecipes, setExistingRecipes] = useState<string[]>([]);
|
||||
const [recipeNameWarning, setRecipeNameWarning] = useState<string>('');
|
||||
|
||||
// Handle Esc key for modal
|
||||
useEscapeKey(isOpen, onClose);
|
||||
|
||||
// Load existing recipe names when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
listSavedRecipes().then((recipes) => {
|
||||
setExistingRecipes(recipes.map((r) => r.name));
|
||||
}).catch((err) => {
|
||||
console.error('Failed to load existing recipes:', err);
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Check for duplicate recipe names
|
||||
const checkForDuplicate = (recipeName: string): string => {
|
||||
if (!recipeName) return recipeName;
|
||||
|
||||
let finalName = recipeName;
|
||||
let counter = 1;
|
||||
|
||||
while (existingRecipes.includes(finalName)) {
|
||||
finalName = `${recipeName}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
if (finalName !== recipeName) {
|
||||
setRecipeNameWarning(`Recipe name "${recipeName}" already exists. Using "${finalName}" instead.`);
|
||||
} else {
|
||||
setRecipeNameWarning('');
|
||||
}
|
||||
|
||||
return finalName;
|
||||
};
|
||||
|
||||
const createRecipeForm = useForm({
|
||||
defaultValues: {
|
||||
title: '',
|
||||
@@ -45,9 +87,12 @@ export default function CreateRecipeForm({ isOpen, onClose, onSuccess }: CreateR
|
||||
jsonSchema: '',
|
||||
recipeName: '',
|
||||
global: true,
|
||||
enableSchedule: false,
|
||||
cronExpression: '0 0 * * *', // Daily at midnight
|
||||
scheduleId: '',
|
||||
},
|
||||
validators: {
|
||||
onChange: createRecipeSchema,
|
||||
onSubmit: createRecipeSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
setCreating(true);
|
||||
@@ -86,11 +131,44 @@ export default function CreateRecipeForm({ isOpen, onClose, onSuccess }: CreateR
|
||||
response: jsonSchemaObj ? { json_schema: jsonSchemaObj } : undefined,
|
||||
};
|
||||
|
||||
await saveRecipe(recipe, {
|
||||
const savedRecipePath = await saveRecipe(recipe, {
|
||||
name: value.recipeName.trim(),
|
||||
global: value.global,
|
||||
});
|
||||
|
||||
// Create schedule if enabled
|
||||
if (value.enableSchedule && value.cronExpression && value.scheduleId) {
|
||||
try {
|
||||
// Get the recipe path - construct it based on global/local
|
||||
const recipeSource = value.global
|
||||
? `~/.config/goose/recipes/${value.recipeName.trim()}.yaml`
|
||||
: `.goose/recipes/${value.recipeName.trim()}.yaml`;
|
||||
|
||||
await createSchedule({
|
||||
id: value.scheduleId.trim(),
|
||||
recipe_source: recipeSource,
|
||||
cron: value.cronExpression.trim(),
|
||||
});
|
||||
|
||||
toastSuccess({
|
||||
title: value.recipeName.trim(),
|
||||
msg: 'Recipe and schedule created successfully',
|
||||
});
|
||||
} catch (scheduleError) {
|
||||
console.error('Failed to create schedule:', scheduleError);
|
||||
toastError({
|
||||
title: 'Schedule Creation Failed',
|
||||
msg: 'Recipe was created but schedule failed. You can create the schedule from the Scheduler view.',
|
||||
traceback: scheduleError instanceof Error ? scheduleError.message : String(scheduleError),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toastSuccess({
|
||||
title: value.recipeName.trim(),
|
||||
msg: 'Recipe created successfully',
|
||||
});
|
||||
}
|
||||
|
||||
// Reset dialog state
|
||||
createRecipeForm.reset({
|
||||
title: '',
|
||||
@@ -101,15 +179,14 @@ export default function CreateRecipeForm({ isOpen, onClose, onSuccess }: CreateR
|
||||
jsonSchema: '',
|
||||
recipeName: '',
|
||||
global: true,
|
||||
enableSchedule: false,
|
||||
cronExpression: '0 0 * * *',
|
||||
scheduleId: '',
|
||||
});
|
||||
setCronPreview('');
|
||||
onClose();
|
||||
|
||||
onSuccess();
|
||||
|
||||
toastSuccess({
|
||||
title: value.recipeName.trim(),
|
||||
msg: 'Recipe created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create recipe:', error);
|
||||
|
||||
@@ -124,40 +201,26 @@ export default function CreateRecipeForm({ isOpen, onClose, onSuccess }: CreateR
|
||||
},
|
||||
});
|
||||
|
||||
// Set default example values when the modal opens
|
||||
// Clear form when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Set example values like the original did
|
||||
createRecipeForm.setFieldValue('title', 'Python Development Assistant');
|
||||
createRecipeForm.setFieldValue(
|
||||
'description',
|
||||
'A helpful assistant for Python development tasks including coding, debugging, and code review.'
|
||||
);
|
||||
createRecipeForm.setFieldValue(
|
||||
'instructions',
|
||||
`You are an expert Python developer assistant. Help users with:
|
||||
|
||||
1. Writing clean, efficient Python code
|
||||
2. Debugging and troubleshooting issues
|
||||
3. Code review and optimization suggestions
|
||||
4. Best practices and design patterns
|
||||
5. Testing and documentation
|
||||
|
||||
Always provide clear explanations and working code examples.
|
||||
|
||||
Parameters you can use:
|
||||
- {{project_type}}: The type of Python project (web, data science, CLI, etc.)
|
||||
- {{python_version}}: Target Python version`
|
||||
);
|
||||
createRecipeForm.setFieldValue(
|
||||
'prompt',
|
||||
'What Python development task can I help you with today?'
|
||||
);
|
||||
createRecipeForm.setFieldValue('activities', 'coding, debugging, testing, documentation');
|
||||
createRecipeForm.setFieldValue('recipeName', 'python-development-assistant');
|
||||
createRecipeForm.setFieldValue('global', true);
|
||||
// Start with empty fields
|
||||
createRecipeForm.reset({
|
||||
title: '',
|
||||
description: '',
|
||||
instructions: '',
|
||||
prompt: '',
|
||||
activities: '',
|
||||
jsonSchema: '',
|
||||
recipeName: '',
|
||||
global: true,
|
||||
enableSchedule: false,
|
||||
cronExpression: '0 0 * * *',
|
||||
scheduleId: '',
|
||||
});
|
||||
setRecipeNameWarning('');
|
||||
}
|
||||
}, [isOpen, createRecipeForm]);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset form to default values
|
||||
@@ -170,7 +233,12 @@ Parameters you can use:
|
||||
jsonSchema: '',
|
||||
recipeName: '',
|
||||
global: true,
|
||||
enableSchedule: false,
|
||||
cronExpression: '0 0 * * *',
|
||||
scheduleId: '',
|
||||
});
|
||||
setCronPreview('');
|
||||
setRecipeNameWarning('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -205,14 +273,18 @@ Parameters you can use:
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.handleChange(value);
|
||||
// Auto-generate recipe name when title changes
|
||||
// Auto-generate recipe name when title changes and check for duplicates
|
||||
if (value.trim()) {
|
||||
const suggestedName = generateRecipeNameFromTitle(value);
|
||||
createRecipeForm.setFieldValue('recipeName', suggestedName);
|
||||
const finalName = checkForDuplicate(suggestedName);
|
||||
createRecipeForm.setFieldValue('recipeName', finalName);
|
||||
} else {
|
||||
createRecipeForm.setFieldValue('recipeName', '');
|
||||
setRecipeNameWarning('');
|
||||
}
|
||||
}}
|
||||
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 ${
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-text-muted/50 ${
|
||||
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||
}`}
|
||||
placeholder="Recipe title"
|
||||
@@ -225,6 +297,12 @@ Parameters you can use:
|
||||
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
|
||||
</p>
|
||||
)}
|
||||
{recipeNameWarning && (
|
||||
<div className="flex items-start gap-2 mt-2 p-2 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-800 dark:text-yellow-200">{recipeNameWarning}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
@@ -244,7 +322,7 @@ Parameters you can use:
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(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 ${
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-text-muted/50 ${
|
||||
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||
}`}
|
||||
placeholder="Brief description of what this recipe does"
|
||||
@@ -274,15 +352,17 @@ Parameters you can use:
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(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 ${
|
||||
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 placeholder:text-text-muted/50 ${
|
||||
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||
}`}
|
||||
placeholder="Detailed instructions for the AI agent..."
|
||||
rows={8}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Use {`{{parameter_name}}`} to define parameters that users can fill in
|
||||
</p>
|
||||
{field.state.value && (
|
||||
<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">
|
||||
{typeof field.state.meta.errors[0] === 'string'
|
||||
@@ -294,71 +374,220 @@ Parameters you can use:
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="prompt">
|
||||
{/* Recipe name is auto-generated from title - hidden from UI but kept in form state */}
|
||||
<createRecipeForm.Field name="recipeName">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-prompt"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Initial Prompt (Optional)
|
||||
<input
|
||||
type="hidden"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
{/* Schedule Section */}
|
||||
<createRecipeForm.Field name="enableSchedule">
|
||||
{(field) => (
|
||||
<div className="border border-border-subtle rounded-lg">
|
||||
<label className="flex items-center justify-between cursor-pointer p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-text-standard">
|
||||
Schedule this recipe
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={field.state.value}
|
||||
onClick={() => {
|
||||
const newValue = !field.state.value;
|
||||
field.handleChange(newValue);
|
||||
if (newValue) {
|
||||
const recipeName = createRecipeForm.getFieldValue('recipeName');
|
||||
createRecipeForm.setFieldValue('scheduleId', recipeName || 'scheduled-recipe');
|
||||
}
|
||||
}}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
|
||||
field.state.value ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
field.state.value ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
<textarea
|
||||
id="create-prompt"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(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="First message to send when the recipe starts..."
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{field.state.value && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="p-4 pt-0 space-y-4 border-t border-border-subtle">
|
||||
<createRecipeForm.Field name="cronExpression">
|
||||
{(cronField) => (
|
||||
<div>
|
||||
<label htmlFor="cron-expression" className="block text-sm font-medium text-text-standard mb-2">
|
||||
Schedule (Cron Expression)
|
||||
</label>
|
||||
<input
|
||||
id="cron-expression"
|
||||
type="text"
|
||||
value={cronField.state.value}
|
||||
onChange={(e) => {
|
||||
cronField.handleChange(e.target.value);
|
||||
try {
|
||||
const preview = cronstrue.toString(e.target.value);
|
||||
setCronPreview(preview);
|
||||
} catch (error) {
|
||||
setCronPreview('Invalid cron expression');
|
||||
}
|
||||
}}
|
||||
onBlur={cronField.handleBlur}
|
||||
className="w-full p-2 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
placeholder="0 0 * * * (Daily at midnight)"
|
||||
/>
|
||||
{cronPreview && (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
||||
{cronPreview}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Examples: "0 0 * * *" (daily), "0 */6 * * *" (every 6 hours), "0 9 * * 1" (Mondays at 9am)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="scheduleId">
|
||||
{(idField) => (
|
||||
<div>
|
||||
<label htmlFor="schedule-id" className="block text-sm font-medium text-text-standard mb-2">
|
||||
Schedule ID
|
||||
</label>
|
||||
<input
|
||||
id="schedule-id"
|
||||
type="text"
|
||||
value={idField.state.value}
|
||||
onChange={(e) => idField.handleChange(e.target.value)}
|
||||
onBlur={idField.handleBlur}
|
||||
className="w-full p-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"
|
||||
placeholder="schedule-name"
|
||||
/>
|
||||
{idField.state.value && (
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Unique identifier for this schedule
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="activities">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-activities"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
{/* Advanced Settings Collapsible */}
|
||||
<div className="border border-border-subtle rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-background-muted transition-colors rounded-lg"
|
||||
>
|
||||
<span className="text-sm font-medium text-text-standard">Advanced Settings</span>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp className="w-4 h-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{showAdvanced && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
Activities (Optional)
|
||||
</label>
|
||||
<input
|
||||
id="create-activities"
|
||||
type="text"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(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"
|
||||
placeholder="coding, debugging, testing, documentation (comma-separated)"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Comma-separated list of activities this recipe helps with
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
<div className="p-4 pt-0 space-y-4 border-t border-border-subtle">
|
||||
<createRecipeForm.Field name="prompt">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-prompt"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Initial Prompt (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="create-prompt"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(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="First message to send when the recipe starts..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="jsonSchema">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-json-schema"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Response JSON Schema (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="create-json-schema"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(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'
|
||||
}`}
|
||||
placeholder={`{
|
||||
<createRecipeForm.Field name="activities">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-activities"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Activities (Optional)
|
||||
</label>
|
||||
<input
|
||||
id="create-activities"
|
||||
type="text"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(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"
|
||||
placeholder="coding, debugging, testing, documentation (comma-separated)"
|
||||
/>
|
||||
{field.state.value && (
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Comma-separated list of activities this recipe helps with
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="jsonSchema">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-json-schema"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Response JSON Schema (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="create-json-schema"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(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'
|
||||
}`}
|
||||
placeholder={`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": {
|
||||
@@ -368,71 +597,64 @@ Parameters you can use:
|
||||
},
|
||||
"required": ["result"]
|
||||
}`}
|
||||
rows={6}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Define the expected structure of the AI's response using JSON Schema format
|
||||
</p>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{typeof field.state.meta.errors[0] === 'string'
|
||||
? field.state.meta.errors[0]
|
||||
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
rows={6}
|
||||
/>
|
||||
{field.state.value && (
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Define the expected structure of the AI's response using JSON Schema format
|
||||
</p>
|
||||
)}
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{typeof field.state.meta.errors[0] === 'string'
|
||||
? field.state.meta.errors[0]
|
||||
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="recipeName">
|
||||
{(field) => (
|
||||
<RecipeNameField
|
||||
id="create-recipe-name"
|
||||
value={field.state.value}
|
||||
onChange={field.handleChange}
|
||||
onBlur={field.handleBlur}
|
||||
errors={field.state.meta.errors.map((error) =>
|
||||
typeof error === 'string' ? error : error?.message || String(error)
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="global">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-standard mb-2">
|
||||
Save Location
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="create-save-location"
|
||||
checked={field.state.value === true}
|
||||
onChange={() => field.handleChange(true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Global - Available across all Goose sessions
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="create-save-location"
|
||||
checked={field.state.value === false}
|
||||
onChange={() => field.handleChange(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Directory - Available in the working directory
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
<createRecipeForm.Field name="global">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-standard mb-2">
|
||||
Save Location
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="create-save-location"
|
||||
checked={field.state.value === true}
|
||||
onChange={() => field.handleChange(true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Global - Available across all Goose sessions
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="create-save-location"
|
||||
checked={field.state.value === false}
|
||||
onChange={() => field.handleChange(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Directory - Available in the working directory
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipeStorage';
|
||||
import { FileText, Trash2, Bot, Calendar, AlertCircle } from 'lucide-react';
|
||||
import { FileText, Trash2, Bot, Calendar, AlertCircle, Clock, Play, Pause, Edit, Square, Eye, Filter } from 'lucide-react';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -13,9 +13,31 @@ import { deleteRecipe, RecipeManifestResponse } from '../../api';
|
||||
import CreateRecipeForm, { CreateRecipeButton } from './CreateRecipeForm';
|
||||
import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm';
|
||||
import { filterValidUsedParameters } from '../../utils/providerUtils';
|
||||
import {
|
||||
listSchedules,
|
||||
pauseSchedule,
|
||||
unpauseSchedule,
|
||||
deleteSchedule as deleteScheduleApi,
|
||||
killRunningJob,
|
||||
inspectRunningJob,
|
||||
ScheduledJob
|
||||
} from '../../schedule';
|
||||
import { CreateScheduleModal, NewSchedulePayload } from '../schedule/CreateScheduleModal';
|
||||
import { EditScheduleModal } from '../schedule/EditScheduleModal';
|
||||
import ScheduleDetailView from '../schedule/ScheduleDetailView';
|
||||
import cronstrue from 'cronstrue';
|
||||
import { formatToLocalDateWithTimezone } from '../../utils/date';
|
||||
|
||||
// Extended type that combines recipe with schedule info
|
||||
type RecipeWithSchedule = RecipeManifestResponse & {
|
||||
schedule?: ScheduledJob;
|
||||
};
|
||||
|
||||
type FilterType = 'all' | 'scheduled' | 'unscheduled';
|
||||
|
||||
export default function RecipesView() {
|
||||
const [savedRecipes, setSavedRecipes] = useState<RecipeManifestResponse[]>([]);
|
||||
const [schedules, setSchedules] = useState<ScheduledJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSkeleton, setShowSkeleton] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -23,10 +45,24 @@ export default function RecipesView() {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
const [previewDeeplink, setPreviewDeeplink] = useState<string>('');
|
||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||
|
||||
// Form dialog states
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [showScheduleModal, setShowScheduleModal] = useState(false);
|
||||
const [showEditScheduleModal, setShowEditScheduleModal] = useState(false);
|
||||
const [selectedRecipeForSchedule, setSelectedRecipeForSchedule] = useState<RecipeManifestResponse | null>(null);
|
||||
const [editingSchedule, setEditingSchedule] = useState<ScheduledJob | null>(null);
|
||||
|
||||
// Schedule detail view
|
||||
const [viewingScheduleId, setViewingScheduleId] = useState<string | null>(null);
|
||||
|
||||
// Action loading states
|
||||
const [pausingScheduleIds, setPausingScheduleIds] = useState<Set<string>>(new Set());
|
||||
const [deletingScheduleIds, setDeletingScheduleIds] = useState<Set<string>>(new Set());
|
||||
const [killingScheduleIds, setKillingScheduleIds] = useState<Set<string>>(new Set());
|
||||
const [inspectingScheduleIds, setInspectingScheduleIds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
loadSavedRecipes();
|
||||
@@ -57,8 +93,15 @@ export default function RecipesView() {
|
||||
setShowSkeleton(true);
|
||||
setShowContent(false);
|
||||
setError(null);
|
||||
const recipeManifestResponses = await listSavedRecipes();
|
||||
|
||||
// Load both recipes and schedules in parallel
|
||||
const [recipeManifestResponses, schedulesData] = await Promise.all([
|
||||
listSavedRecipes(),
|
||||
listSchedules().catch(() => []) // Don't fail if schedules can't load
|
||||
]);
|
||||
|
||||
setSavedRecipes(recipeManifestResponses);
|
||||
setSchedules(schedulesData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load recipes');
|
||||
console.error('Failed to load saved recipes:', err);
|
||||
@@ -150,65 +193,115 @@ export default function RecipesView() {
|
||||
selectedRecipe?.recipe.prompt,
|
||||
]);
|
||||
|
||||
// Helper to find schedule for a recipe
|
||||
const findScheduleForRecipe = useCallback((recipeId: string) => {
|
||||
return schedules.find(schedule => {
|
||||
// Match by recipe ID in the source path
|
||||
return schedule.source.includes(recipeId);
|
||||
});
|
||||
}, [schedules]);
|
||||
|
||||
// Render a recipe item
|
||||
const RecipeItem = ({
|
||||
recipeManifestResponse,
|
||||
recipeManifestResponse: { recipe, lastModified },
|
||||
recipeManifestResponse: { recipe, lastModified, id },
|
||||
}: {
|
||||
recipeManifestResponse: RecipeManifestResponse;
|
||||
}) => (
|
||||
<Card className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-base truncate max-w-[50vw]">{recipe.title}</h3>
|
||||
</div>
|
||||
<p className="text-text-muted text-sm mb-2 line-clamp-2">{recipe.description}</p>
|
||||
<div className="flex items-center text-xs text-text-muted">
|
||||
<Calendar className="w-3 h-3 mr-1" />
|
||||
{convertToLocaleDateString(lastModified)}
|
||||
</div>
|
||||
</div>
|
||||
}) => {
|
||||
const schedule = findScheduleForRecipe(id);
|
||||
|
||||
// Get human-readable cron if scheduled
|
||||
let readableCron = '';
|
||||
if (schedule) {
|
||||
try {
|
||||
readableCron = cronstrue.toString(schedule.cron);
|
||||
} catch (e) {
|
||||
readableCron = schedule.cron;
|
||||
}
|
||||
}
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleLoadRecipe(recipe);
|
||||
}}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Bot className="w-4 h-4 mr-1" />
|
||||
Use
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviewRecipe(recipeManifestResponse);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-1" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteRecipe(recipeManifestResponse);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
return (
|
||||
<Card className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-base truncate max-w-[50vw]">{recipe.title}</h3>
|
||||
{schedule && (
|
||||
<>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
Scheduled
|
||||
</span>
|
||||
{schedule.currently_running && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<span className="inline-block w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
{schedule.paused && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
|
||||
<Pause className="w-3 h-3 mr-1" />
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-muted text-sm mb-2 line-clamp-2">{recipe.description}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-text-muted">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-3 h-3 mr-1" />
|
||||
{convertToLocaleDateString(lastModified)}
|
||||
</div>
|
||||
{schedule && (
|
||||
<div className="flex items-center">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{readableCron}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleLoadRecipe(recipe);
|
||||
}}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Bot className="w-4 h-4 mr-1" />
|
||||
Use
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviewRecipe(recipeManifestResponse);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-1" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteRecipe(recipeManifestResponse);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Render skeleton loader for recipe items
|
||||
const RecipeSkeleton = () => (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { RecipeManifestResponse } from '../../api';
|
||||
import { ScheduledJob } from '../../schedule';
|
||||
|
||||
export type RecipeWithSchedule = RecipeManifestResponse & {
|
||||
schedule?: ScheduledJob;
|
||||
};
|
||||
|
||||
/**
|
||||
* Merges recipes with their corresponding schedules
|
||||
* Matches schedules to recipes based on the schedule's source path
|
||||
*/
|
||||
export function mergeRecipesWithSchedules(
|
||||
recipes: RecipeManifestResponse[],
|
||||
schedules: ScheduledJob[]
|
||||
): RecipeWithSchedule[] {
|
||||
return recipes.map((recipe) => {
|
||||
// Find schedule that matches this recipe's path
|
||||
// The schedule.source contains the recipe file path
|
||||
const schedule = schedules.find((s) => {
|
||||
// Extract filename from source path for comparison
|
||||
const sourcePath = s.source;
|
||||
const recipeId = recipe.id;
|
||||
|
||||
// Check if the schedule source contains the recipe ID
|
||||
// or if it matches the recipe name/path
|
||||
return sourcePath.includes(recipeId) || sourcePath.includes(recipe.name);
|
||||
});
|
||||
|
||||
return {
|
||||
...recipe,
|
||||
schedule,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters recipes based on schedule status
|
||||
*/
|
||||
export function filterRecipesByScheduleStatus(
|
||||
recipes: RecipeWithSchedule[],
|
||||
filterType: 'all' | 'scheduled' | 'unscheduled'
|
||||
): RecipeWithSchedule[] {
|
||||
switch (filterType) {
|
||||
case 'scheduled':
|
||||
return recipes.filter((r) => r.schedule !== undefined);
|
||||
case 'unscheduled':
|
||||
return recipes.filter((r) => r.schedule === undefined);
|
||||
case 'all':
|
||||
default:
|
||||
return recipes;
|
||||
}
|
||||
}
|
||||
@@ -564,7 +564,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||
<div className="bg-background-default px-8 pb-8 pt-16">
|
||||
<div className="flex flex-col page-transition">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h1 className="text-4xl font-light">Scheduler</h1>
|
||||
<h1 className="text-4xl font-light">Marketplace</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
|
||||