Spence/vision marketplace (#5904)

This commit is contained in:
Spence
2025-11-27 13:33:34 -05:00
committed by GitHub
parent e926293921
commit 04d10cd30d
15 changed files with 1793 additions and 235 deletions
+2 -3
View File
@@ -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 = () => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

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',
File diff suppressed because it is too large Load Diff
@@ -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">
+148 -55
View File
@@ -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}