diff --git a/ui/desktop/src/components/RecipeEditor.tsx b/ui/desktop/src/components/RecipeEditor.tsx index a621d32c..7f10d326 100644 --- a/ui/desktop/src/components/RecipeEditor.tsx +++ b/ui/desktop/src/components/RecipeEditor.tsx @@ -10,6 +10,7 @@ import { FixedExtensionEntry } from './ConfigContext'; import RecipeActivityEditor from './RecipeActivityEditor'; import RecipeInfoModal from './RecipeInfoModal'; import RecipeExpandableInfo from './RecipeExpandableInfo'; +import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal'; // import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList'; interface RecipeEditorProps { @@ -34,6 +35,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { const [extensionsLoaded, setExtensionsLoaded] = useState(false); const [copied, setCopied] = useState(false); const [isRecipeInfoModalOpen, setRecipeInfoModalOpen] = useState(false); + const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false); const [recipeInfoModelProps, setRecipeInfoModelProps] = useState<{ label: string; value: string; @@ -331,6 +333,13 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { {/* Action Buttons */}
+
); } diff --git a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx index 1c79d4c5..c06d45db 100644 --- a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx +++ b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx @@ -1,9 +1,12 @@ -import React, { useState, useEffect, FormEvent } from 'react'; +import React, { useState, useEffect, FormEvent, useCallback } from 'react'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Select } from '../ui/Select'; import cronstrue from 'cronstrue'; +import * as yaml from 'yaml'; +import { Buffer } from 'buffer'; +import { Recipe } from '../../recipe'; type FrequencyValue = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly'; @@ -26,6 +29,39 @@ interface CreateScheduleModalProps { apiErrorExternally: string | null; } +// Interface for clean extension in YAML +interface CleanExtension { + name: string; + type: 'stdio' | 'sse' | 'builtin' | 'frontend'; + cmd?: string; + args?: string[]; + uri?: string; + display_name?: string; + tools?: unknown[]; + instructions?: string; + env_keys?: string[]; + timeout?: number; + description?: string; + bundled?: boolean; +} + +// Interface for clean recipe in YAML +interface CleanRecipe { + title: string; + description: string; + instructions: string; + prompt?: string; + activities?: string[]; + extensions?: CleanExtension[]; + goosehints?: string; + context?: string[]; + profile?: string; + author?: { + contact?: string; + metadata?: string; + }; +} + const frequencies: FrequencyOption[] = [ { value: 'once', label: 'Once' }, { value: 'hourly', label: 'Hourly' }, @@ -51,6 +87,153 @@ const checkboxLabelClassName = 'flex items-center text-sm text-textStandard dark const checkboxInputClassName = 'h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 mr-2'; +type SourceType = 'file' | 'deeplink'; + +// Function to parse deep link and extract recipe config +function parseDeepLink(deepLink: string): Recipe | null { + try { + const url = new URL(deepLink); + if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) { + return null; + } + + const configParam = url.searchParams.get('config'); + if (!configParam) { + return null; + } + + const configJson = Buffer.from(configParam, 'base64').toString('utf-8'); + return JSON.parse(configJson) as Recipe; + } catch (error) { + console.error('Failed to parse deep link:', error); + return null; + } +} + +// Function to convert recipe to YAML +function recipeToYaml(recipe: Recipe): string { + // Create a clean recipe object for YAML conversion + const cleanRecipe: CleanRecipe = { + title: recipe.title, + description: recipe.description, + instructions: recipe.instructions, + }; + + if (recipe.prompt) { + cleanRecipe.prompt = recipe.prompt; + } + + if (recipe.activities && recipe.activities.length > 0) { + cleanRecipe.activities = recipe.activities; + } + + if (recipe.extensions && recipe.extensions.length > 0) { + cleanRecipe.extensions = recipe.extensions.map(ext => { + const cleanExt: CleanExtension = { + name: ext.name, + type: 'builtin', // Default type, will be overridden below + }; + + // Handle different extension types + if ('type' in ext && ext.type) { + cleanExt.type = ext.type as CleanExtension['type']; + + // Add type-specific fields based on the ExtensionConfig union types + switch (ext.type) { + case 'sse': + if ('uri' in ext && ext.uri) { + cleanExt.uri = ext.uri as string; + } + break; + case 'stdio': + if ('cmd' in ext && ext.cmd) { + cleanExt.cmd = ext.cmd as string; + } + if ('args' in ext && ext.args) { + cleanExt.args = ext.args as string[]; + } + break; + case 'builtin': + if ('display_name' in ext && ext.display_name) { + cleanExt.display_name = ext.display_name as string; + } + break; + case 'frontend': + if ('tools' in ext && ext.tools) { + cleanExt.tools = ext.tools as unknown[]; + } + if ('instructions' in ext && ext.instructions) { + cleanExt.instructions = ext.instructions as string; + } + break; + } + } else { + // Fallback: try to infer type from available fields + if ('cmd' in ext && ext.cmd) { + cleanExt.type = 'stdio'; + cleanExt.cmd = ext.cmd as string; + if ('args' in ext && ext.args) { + cleanExt.args = ext.args as string[]; + } + } else if ('command' in ext && ext.command) { + // Handle legacy 'command' field by converting to 'cmd' + cleanExt.type = 'stdio'; + cleanExt.cmd = ext.command as string; + } else if ('uri' in ext && ext.uri) { + cleanExt.type = 'sse'; + cleanExt.uri = ext.uri as string; + } else if ('tools' in ext && ext.tools) { + cleanExt.type = 'frontend'; + cleanExt.tools = ext.tools as unknown[]; + if ('instructions' in ext && ext.instructions) { + cleanExt.instructions = ext.instructions as string; + } + } else { + // Default to builtin if we can't determine type + cleanExt.type = 'builtin'; + } + } + + // Add common optional fields + if (ext.env_keys && ext.env_keys.length > 0) { + cleanExt.env_keys = ext.env_keys; + } + + if ('timeout' in ext && ext.timeout) { + cleanExt.timeout = ext.timeout as number; + } + + if ('description' in ext && ext.description) { + cleanExt.description = ext.description as string; + } + + if ('bundled' in ext && ext.bundled !== undefined) { + cleanExt.bundled = ext.bundled as boolean; + } + + return cleanExt; + }); + } + + if (recipe.goosehints) { + cleanRecipe.goosehints = recipe.goosehints; + } + + if (recipe.context && recipe.context.length > 0) { + cleanRecipe.context = recipe.context; + } + + if (recipe.profile) { + cleanRecipe.profile = recipe.profile; + } + + if (recipe.author) { + cleanRecipe.author = recipe.author; + } + + return yaml.stringify(cleanRecipe); +} + export const CreateScheduleModal: React.FC = ({ isOpen, onClose, @@ -59,7 +242,10 @@ export const CreateScheduleModal: React.FC = ({ apiErrorExternally, }) => { const [scheduleId, setScheduleId] = useState(''); + const [sourceType, setSourceType] = useState('file'); const [recipeSourcePath, setRecipeSourcePath] = useState(''); + const [deepLinkInput, setDeepLinkInput] = useState(''); + const [parsedRecipe, setParsedRecipe] = useState(null); const [frequency, setFrequency] = useState('daily'); const [selectedDate, setSelectedDate] = useState( () => new Date().toISOString().split('T')[0] @@ -72,9 +258,46 @@ export const CreateScheduleModal: React.FC = ({ const [readableCronExpression, setReadableCronExpression] = useState(''); const [internalValidationError, setInternalValidationError] = useState(null); + const handleDeepLinkChange = useCallback((value: string) => { + setDeepLinkInput(value); + setInternalValidationError(null); + + if (value.trim()) { + const recipe = parseDeepLink(value.trim()); + if (recipe) { + setParsedRecipe(recipe); + // Auto-populate schedule ID from recipe title if available + if (recipe.title && !scheduleId) { + const cleanId = recipe.title.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-'); + setScheduleId(cleanId); + } + } else { + setParsedRecipe(null); + setInternalValidationError('Invalid deep link format. Please use a goose://bot or goose://recipe link.'); + } + } else { + setParsedRecipe(null); + } + }, [scheduleId]); + + useEffect(() => { + // Check for pending deep link when modal opens + if (isOpen) { + const pendingDeepLink = localStorage.getItem('pendingScheduleDeepLink'); + if (pendingDeepLink) { + localStorage.removeItem('pendingScheduleDeepLink'); + setSourceType('deeplink'); + handleDeepLinkChange(pendingDeepLink); + } + } + }, [isOpen, handleDeepLinkChange]); + const resetForm = () => { setScheduleId(''); + setSourceType('file'); setRecipeSourcePath(''); + setDeepLinkInput(''); + setParsedRecipe(null); setFrequency('daily'); setSelectedDate(new Date().toISOString().split('T')[0]); setSelectedTime('09:00'); @@ -195,10 +418,48 @@ export const CreateScheduleModal: React.FC = ({ setInternalValidationError('Schedule ID is required.'); return; } - if (!recipeSourcePath) { - setInternalValidationError('Recipe source file is required.'); - return; + + let finalRecipeSource = ''; + + if (sourceType === 'file') { + if (!recipeSourcePath) { + setInternalValidationError('Recipe source file is required.'); + return; + } + finalRecipeSource = recipeSourcePath; + } else if (sourceType === 'deeplink') { + if (!deepLinkInput.trim()) { + setInternalValidationError('Deep link is required.'); + return; + } + if (!parsedRecipe) { + setInternalValidationError('Invalid deep link. Please check the format.'); + return; + } + + try { + // Convert recipe to YAML and save to a temporary file + const yamlContent = recipeToYaml(parsedRecipe); + console.log('Generated YAML content:', yamlContent); // Debug log + const tempFileName = `schedule-${scheduleId}-${Date.now()}.yaml`; + const tempDir = window.electron.getConfig().GOOSE_WORKING_DIR || '.'; + const tempFilePath = `${tempDir}/${tempFileName}`; + + // Write the YAML file + const writeSuccess = await window.electron.writeFile(tempFilePath, yamlContent); + if (!writeSuccess) { + setInternalValidationError('Failed to create temporary recipe file.'); + return; + } + + finalRecipeSource = tempFilePath; + } catch (error) { + console.error('Failed to convert recipe to YAML:', error); + setInternalValidationError('Failed to process the recipe from deep link.'); + return; + } } + if ( !derivedCronExpression || derivedCronExpression.includes('Invalid') || @@ -216,7 +477,7 @@ export const CreateScheduleModal: React.FC = ({ const newSchedulePayload: NewSchedulePayload = { id: scheduleId.trim(), - recipe_source: recipeSourcePath, + recipe_source: finalRecipeSource, cron: derivedCronExpression, }; @@ -232,7 +493,7 @@ export const CreateScheduleModal: React.FC = ({ return (
- +

Create New Schedule @@ -268,22 +529,79 @@ export const CreateScheduleModal: React.FC = ({ required />

+
- - - {recipeSourcePath && ( -

- Selected: {recipeSourcePath} -

- )} + +
+
+ + +
+ + {sourceType === 'file' && ( +
+ + {recipeSourcePath && ( +

+ Selected: {recipeSourcePath} +

+ )} +
+ )} + + {sourceType === 'deeplink' && ( +
+ handleDeepLinkChange(e.target.value)} + placeholder="Paste goose://bot or goose://recipe link here..." + /> + {parsedRecipe && ( +
+

+ ✓ Recipe parsed successfully +

+

+ Title: {parsedRecipe.title} +

+

+ Description: {parsedRecipe.description} +

+
+ )} +
+ )} +
+