basic functionality for ai driven recipe creation (WIP)

This commit is contained in:
Lifei Zhou
2026-02-06 10:59:37 +11:00
parent 98ba4a9cb1
commit cc6fedbf60
10 changed files with 1161 additions and 0 deletions
@@ -0,0 +1,165 @@
# AI Recipe Builder - Requirements & Implementation Plan
## Overview
A conversational interface for creating Goose recipes with AI assistance, manual editing, and testing capabilities.
---
## Layout
```
┌─────────────────────────────────────────────────────────┐
│ [Chat] [Edit] [Test] [Save] [X] │
├────────────────────────────┬────────────────────────────┤
│ │ │
│ Main View │ Test Panel │
│ (Chat OR Edit - swap) │ (optional, toggle) │
│ │ │
└────────────────────────────┴────────────────────────────┘
```
### Header Buttons
| Button | Behavior |
|--------|----------|
| [Chat] | Switch main view to Chat (active style when selected) |
| [Edit] | Switch main view to Edit form (active style when selected) |
| [Test] | Toggle test panel on/off. Disabled until recipe exists |
| [Save] | Save recipe. Disabled until recipe has title + description |
| [X] | Close builder, return to RecipesView |
---
## State
```typescript
currentView: 'chat' | 'edit' // Which main view is shown
testPanelOpen: boolean // Whether test panel is visible
recipe: Recipe | null // Single source of truth
testSessionId: string | null // Active test session
testRecipeSnapshot: Recipe | null // Recipe version test is running
```
---
## Sync Rules
| Event | Action |
|-------|--------|
| AI outputs YAML in chat | Extract recipe → update `recipe` state |
| User edits field in Edit view | Update `recipe` state immediately |
| User sends chat message (if recipe was edited) | Prepend current `recipe` as YAML context |
| User clicks "Start Test" | Copy `recipe` to `testRecipeSnapshot`, start session |
| `recipe` changes while test open | Show "Recipe changed. [Restart]" warning |
| User closes test panel | Terminate test session |
---
## User Flows
### 1. Entry
- Opens in Chat view, full width
- [Edit] enabled (can create manually)
- [Test] and [Save] disabled (no recipe yet)
### 2. Create via Chat
- User chats with AI
- AI outputs YAML recipe
- `recipe` state populated from extraction
- [Test] and [Save] become enabled
### 3. Create via Edit
- User clicks [Edit]
- Empty form shown
- User fills required fields (title, description)
- `recipe` state populated
- [Test] and [Save] become enabled
### 4. Switch Chat ↔ Edit
- Click [Chat] or [Edit] to swap main view
- Both views read/write same `recipe` state
- Edit view always shows latest recipe (AI or user edits)
### 5. Test
- Click [Test] → Panel opens on right side
- Shows "Start Test" button initially
- Click "Start Test" → Session starts with current recipe
- Recipe changes → Warning banner with [Restart] button
- Click [Test] again → Panel closes, session terminated
### 6. Save
- Click [Save] → Recipe saved to disk
- Shows success toast
- Calls `onRecipeSaved` callback
---
## File Structure
```
RecipeBuilder/
├── index.tsx # Main container, state management, layout
├── RecipeBuilderHeader.tsx # Header with view toggle and action buttons
├── RecipeBuilderChat.tsx # Chat view with AI conversation
├── RecipeBuilderEdit.tsx # Edit view with form fields
├── RecipeBuilderTest.tsx # Test panel component
├── recipeBuilderRecipe.ts # Recipe that guides AI to build recipes
├── recipeExtractor.ts # Extract recipe from AI YAML output
├── types.ts # TypeScript types
└── PLAN.md # This file
```
---
## Implementation Phases
### Phase 1: Foundation ✅ COMPLETE
- [x] Create folder structure + types.ts
- [x] Create index.tsx - empty shell with state
- [x] Create RecipeBuilderHeader.tsx with buttons
- [x] Add entry point in RecipesView
### Phase 2: Edit View ✅ COMPLETE
- [x] Wire up Edit view using RecipeFormFields
- [x] Connect form to `recipe` state
- [x] Handle empty state (no recipe yet)
- [x] Enable Save when recipe is valid
### Phase 3: Chat View ✅ COMPLETE
- [x] Create recipeBuilderRecipe.ts (AI guidance)
- [x] Create recipeExtractor.ts (YAML parsing)
- [x] Create RecipeBuilderChat.tsx
- [x] Initialize AI session on mount
- [x] Extract recipe from AI output → update state
### Phase 4: Chat ↔ Edit Sync ✅ COMPLETE
- [x] Switch Chat → Edit shows current recipe in form
- [x] Switch Edit → Chat syncs edits on next message
- [x] Prepend recipe YAML when user sends message after editing
### Phase 5: Test Panel ✅ COMPLETE
- [x] Create RecipeBuilderTest.tsx
- [x] Start test session with current recipe
- [x] Detect recipe changes, show warning
- [x] Restart test with updated recipe
- [x] Close panel terminates session
### Phase 6: Polish ✅ COMPLETE
- [x] Loading states (LoadingGoose in chat and test)
- [x] Error handling (error displays in chat and test)
- [x] Animations/transitions (CSS transitions for panel resize)
- [x] Edge cases (empty recipe, panel toggle cleanup)
---
## Decisions Made
| Question | Decision |
|----------|----------|
| Edit always shows latest (AI or user)? | Yes - single source of truth |
| Warn on unsaved changes when closing? | No (not implementing now) |
| Test session on panel close? | Terminate |
| Edit view with no recipe? | Show empty form, user can create manually |
| Reuse existing edit UI? | Yes - use RecipeFormFields from shared/ |
| Track builder/test sessions? | Yes - add to activeSessions, appear in Sessions view |
@@ -0,0 +1,225 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Message, startAgent, recipeToYaml } from '../../../api';
import { Recipe, stripEmptyExtensions } from '../../../recipe';
import { useChatStream } from '../../../hooks/useChatStream';
import { ChatState } from '../../../types/chatState';
import { ScrollArea, ScrollAreaHandle } from '../../ui/scroll-area';
import ProgressiveMessageList from '../../ProgressiveMessageList';
import LoadingGoose from '../../LoadingGoose';
import { recipeBuilderRecipe } from './recipeBuilderRecipe';
import { extractYamlFromMessage, parseYamlToRecipe } from './recipeExtractor';
import { getInitialWorkingDir } from '../../../utils/workingDir';
import { UserInput } from '../../../types/message';
import { Send } from 'lucide-react';
import { Button } from '../../ui/button';
import { RecipeBuilderChatProps } from './types';
export default function RecipeBuilderChat({ recipe, onRecipeChange }: RecipeBuilderChatProps) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionError, setSessionError] = useState<string | null>(null);
const [isInitializing, setIsInitializing] = useState(true);
const [inputValue, setInputValue] = useState('');
const scrollRef = useRef<ScrollAreaHandle>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const lastExtractedYamlRef = useRef<string | null>(null);
// Track the last recipe generated by the AI (for detecting user edits in Edit view)
const lastAIRecipeRef = useRef<Recipe | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const workingDir = getInitialWorkingDir();
const response = await startAgent({
body: {
working_dir: workingDir,
recipe: stripEmptyExtensions(recipeBuilderRecipe),
},
throwOnError: true,
});
if (cancelled) return;
setSessionId(response.data.id);
setIsInitializing(false);
} catch (error) {
if (cancelled) return;
console.error('Failed to start recipe builder session:', error);
setSessionError(error instanceof Error ? error.message : 'Failed to start session');
setIsInitializing(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const onStreamFinish = useCallback(() => {}, []);
const {
messages,
chatState,
handleSubmit: submitToChat,
sessionLoadError,
stopStreaming,
} = useChatStream({
sessionId: sessionId || '',
onStreamFinish,
});
// Only extract recipe when streaming is complete to avoid parsing incomplete YAML
useEffect(() => {
if (messages.length === 0) return;
if (chatState !== ChatState.Idle) return; // Wait for streaming to complete
const lastMessage = messages[messages.length - 1];
if (lastMessage.role !== 'assistant') return;
const yamlString = extractYamlFromMessage(lastMessage);
if (!yamlString || yamlString === lastExtractedYamlRef.current) return;
lastExtractedYamlRef.current = yamlString;
(async () => {
const extractedRecipe = await parseYamlToRecipe(yamlString);
if (extractedRecipe) {
// Track the last AI-generated recipe for detecting user edits
lastAIRecipeRef.current = extractedRecipe;
onRecipeChange(extractedRecipe);
}
})();
}, [messages, chatState, onRecipeChange]);
const isUserMessage = useCallback((message: Message) => message.role === 'user', []);
useEffect(() => {
if (!isInitializing && chatState === ChatState.Idle && inputRef.current) {
inputRef.current.focus();
}
}, [isInitializing, chatState]);
const isLoading = isInitializing || chatState === ChatState.LoadingConversation;
const isStreaming =
chatState === ChatState.Streaming ||
chatState === ChatState.Thinking ||
chatState === ChatState.Compacting;
const handleSubmit = useCallback(async () => {
if (!inputValue.trim() || isStreaming) return;
let messageText = inputValue;
// Check if the recipe was edited in the Edit view (different from last AI-generated)
const wasEditedExternally =
recipe !== null && JSON.stringify(recipe) !== JSON.stringify(lastAIRecipeRef.current);
if (wasEditedExternally) {
try {
// Convert current recipe to YAML and prepend as context
const response = await recipeToYaml({ body: { recipe } });
if (response.data?.yaml) {
messageText = `I've updated the recipe. Here's the current version:\n\n\`\`\`yaml\n${response.data.yaml}\`\`\`\n\n${inputValue}`;
// Update lastAIRecipeRef to the current recipe to avoid prepending again
lastAIRecipeRef.current = recipe;
}
} catch (error) {
console.error('Failed to convert recipe to YAML:', error);
// Continue without prepending if conversion fails
}
}
const input: UserInput = { msg: messageText, images: [] };
submitToChat(input);
setInputValue('');
}, [inputValue, isStreaming, submitToChat, recipe]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit]
);
if (sessionError || sessionLoadError) {
return (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-4 rounded-lg max-w-md">
<h3 className="font-semibold mb-2">Failed to Start Chat</h3>
<p className="text-sm">{sessionError || sessionLoadError}</p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center">
<LoadingGoose />
</div>
);
}
return (
<div className="flex flex-col flex-1 min-h-0 bg-background-default">
<ScrollArea ref={scrollRef} className="flex-1 min-h-0 px-4" autoScroll>
<div className="py-6">
{messages.length === 0 ? (
<div className="text-center text-textSubtle py-8">
<p className="text-lg mb-2">Recipe Builder</p>
<p className="text-sm">
Tell me what kind of recipe you'd like to create, and I'll help you build it.
</p>
</div>
) : (
<ProgressiveMessageList
messages={messages}
chat={{ sessionId: sessionId! }}
isUserMessage={isUserMessage}
/>
)}
{isStreaming && (
<div className="flex justify-center py-4">
<LoadingGoose chatState={chatState} />
</div>
)}
</div>
</ScrollArea>
<div className="p-4 border-t border-borderSubtle">
<div className="flex gap-2">
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe the recipe you want to create..."
className="flex-1 p-3 border border-borderSubtle rounded-lg bg-background-default text-textStandard resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={2}
disabled={isStreaming}
/>
<div className="flex flex-col gap-2">
{isStreaming ? (
<Button onClick={stopStreaming} variant="outline" size="sm" className="h-full">
Stop
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!inputValue.trim()}
size="sm"
className="h-full"
>
<Send className="w-4 h-4" />
</Button>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
import { useEffect, useCallback, useRef } from 'react';
import { useForm } from '@tanstack/react-form';
import { Recipe } from '../../../recipe';
import { ScrollArea } from '../../ui/scroll-area';
import { RecipeFormFields } from '../shared/RecipeFormFields';
import { RecipeFormData } from '../shared/recipeFormSchema';
interface RecipeBuilderEditProps {
recipe: Recipe | null;
onRecipeChange: (recipe: Recipe) => void;
}
const emptyFormData: RecipeFormData = {
title: '',
description: '',
instructions: '',
prompt: '',
activities: [],
parameters: [],
jsonSchema: '',
};
function recipeToFormData(recipe: Recipe | null): RecipeFormData {
if (!recipe) return emptyFormData;
return {
title: recipe.title || '',
description: recipe.description || '',
instructions: recipe.instructions || '',
prompt: recipe.prompt || '',
activities: recipe.activities || [],
parameters: recipe.parameters || [],
jsonSchema: recipe.response?.json_schema
? JSON.stringify(recipe.response.json_schema, null, 2)
: '',
};
}
function formDataToRecipe(data: RecipeFormData): Recipe {
let responseConfig = undefined;
if (data.jsonSchema && data.jsonSchema.trim()) {
try {
const parsedSchema = JSON.parse(data.jsonSchema);
responseConfig = { json_schema: parsedSchema };
} catch {
// Invalid JSON, skip
}
}
return {
version: '1.0.0',
title: data.title,
description: data.description,
instructions: data.instructions || undefined,
prompt: data.prompt || undefined,
activities: data.activities.length > 0 ? data.activities : undefined,
parameters: data.parameters.length > 0 ? data.parameters : undefined,
response: responseConfig,
};
}
export default function RecipeBuilderEdit({ recipe, onRecipeChange }: RecipeBuilderEditProps) {
const lastExternalRecipeRef = useRef<Recipe | null>(null);
const isInternalUpdateRef = useRef(false);
const form = useForm({
defaultValues: recipeToFormData(recipe),
});
useEffect(() => {
const isSameRecipe = JSON.stringify(recipe) === JSON.stringify(lastExternalRecipeRef.current);
if (!isSameRecipe && !isInternalUpdateRef.current) {
lastExternalRecipeRef.current = recipe;
form.reset(recipeToFormData(recipe));
}
isInternalUpdateRef.current = false;
}, [recipe, form]);
useEffect(() => {
return form.store.subscribe(() => {
const formData = form.state.values;
const newRecipe = formDataToRecipe(formData);
if (newRecipe.title || newRecipe.description || newRecipe.instructions) {
isInternalUpdateRef.current = true;
lastExternalRecipeRef.current = newRecipe;
onRecipeChange(newRecipe);
}
});
}, [form, onRecipeChange]);
const handleFieldChange = useCallback(
(field: keyof RecipeFormData, value: string) => {
form.setFieldValue(field, value);
},
[form]
);
return (
<ScrollArea className="flex-1 h-full">
<div className="p-6 bg-background-default min-h-full">
<RecipeFormFields
form={form}
onTitleChange={(v) => handleFieldChange('title', v)}
onDescriptionChange={(v) => handleFieldChange('description', v)}
onInstructionsChange={(v) => handleFieldChange('instructions', v)}
onPromptChange={(v) => handleFieldChange('prompt', v)}
onJsonSchemaChange={(v) => handleFieldChange('jsonSchema', v)}
/>
</div>
</ScrollArea>
);
}
@@ -0,0 +1,66 @@
import { MessageSquare, Pencil, Play, Save, X } from 'lucide-react';
import { Button } from '../../ui/button';
import { RecipeBuilderHeaderProps } from './types';
export default function RecipeBuilderHeader({
currentView,
onViewChange,
testPanelOpen,
onTestPanelToggle,
canTest,
canSave,
isSaving,
onSave,
onClose,
}: RecipeBuilderHeaderProps) {
return (
<div className="flex items-center justify-between px-4 py-3 border-b border-borderSubtle bg-background-default">
<div className="flex items-center gap-1">
<Button
variant={currentView === 'chat' ? 'default' : 'ghost'}
size="sm"
onClick={() => onViewChange('chat')}
className="flex items-center gap-2"
>
<MessageSquare className="w-4 h-4" />
Chat
</Button>
<Button
variant={currentView === 'edit' ? 'default' : 'ghost'}
size="sm"
onClick={() => onViewChange('edit')}
className="flex items-center gap-2"
>
<Pencil className="w-4 h-4" />
Edit
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant={testPanelOpen ? 'default' : 'outline'}
size="sm"
onClick={onTestPanelToggle}
disabled={!canTest}
className="flex items-center gap-2"
>
<Play className="w-4 h-4" />
Test
</Button>
<Button
variant="outline"
size="sm"
onClick={onSave}
disabled={!canSave || isSaving}
className="flex items-center gap-2"
>
<Save className="w-4 h-4" />
{isSaving ? 'Saving...' : 'Save'}
</Button>
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 w-8 p-0">
<X className="w-5 h-5" />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,283 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Message, startAgent, stopAgent } from '../../../api';
import { stripEmptyExtensions } from '../../../recipe';
import { useChatStream } from '../../../hooks/useChatStream';
import { ChatState } from '../../../types/chatState';
import { ScrollArea, ScrollAreaHandle } from '../../ui/scroll-area';
import ProgressiveMessageList from '../../ProgressiveMessageList';
import LoadingGoose from '../../LoadingGoose';
import { getInitialWorkingDir } from '../../../utils/workingDir';
import { UserInput } from '../../../types/message';
import { Send, RefreshCw, AlertTriangle } from 'lucide-react';
import { Button } from '../../ui/button';
import { RecipeBuilderTestProps } from './types';
import ParameterInputModal from '../../ParameterInputModal';
import { substituteParameters } from '../../../utils/providerUtils';
export default function RecipeBuilderTest({
recipe,
testRecipeSnapshot,
onStart,
onRestart,
isOpen,
}: RecipeBuilderTestProps) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionError, setSessionError] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false);
const [inputValue, setInputValue] = useState('');
const scrollRef = useRef<ScrollAreaHandle>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Detect if recipe has changed since test started
const recipeChanged =
testRecipeSnapshot !== null && JSON.stringify(recipe) !== JSON.stringify(testRecipeSnapshot);
// Cleanup session when panel closes
useEffect(() => {
if (!isOpen && sessionId) {
stopAgent({ body: { session_id: sessionId } }).catch((error) => {
console.error('Failed to stop test session:', error);
});
setSessionId(null);
setSessionError(null);
}
}, [isOpen, sessionId]);
const handleStartTest = useCallback(async () => {
setIsStarting(true);
setSessionError(null);
try {
const workingDir = getInitialWorkingDir();
const response = await startAgent({
body: {
working_dir: workingDir,
recipe: stripEmptyExtensions(recipe),
},
throwOnError: true,
});
setSessionId(response.data.id);
onStart();
} catch (error) {
console.error('Failed to start test session:', error);
setSessionError(error instanceof Error ? error.message : 'Failed to start test');
} finally {
setIsStarting(false);
}
}, [recipe, onStart]);
useEffect(() => {
if (isOpen && !sessionId && !isStarting && !sessionError) {
handleStartTest();
}
}, [isOpen, sessionId, isStarting, sessionError, handleStartTest]);
const handleRestart = useCallback(async () => {
if (sessionId) {
try {
await stopAgent({ body: { session_id: sessionId } });
} catch (error) {
console.error('Failed to stop previous test session:', error);
}
}
setSessionId(null);
setSessionError(null);
setInputValue('');
setIsStarting(true);
try {
const workingDir = getInitialWorkingDir();
const response = await startAgent({
body: {
working_dir: workingDir,
recipe: stripEmptyExtensions(recipe),
},
throwOnError: true,
});
setSessionId(response.data.id);
onRestart();
} catch (error) {
console.error('Failed to restart test session:', error);
setSessionError(error instanceof Error ? error.message : 'Failed to restart test');
} finally {
setIsStarting(false);
}
}, [recipe, sessionId, onRestart]);
const onStreamFinish = useCallback(() => {}, []);
const {
messages,
chatState,
session,
handleSubmit: submitToChat,
sessionLoadError,
stopStreaming,
setRecipeUserParams,
} = useChatStream({
sessionId: sessionId || '',
onStreamFinish,
});
const hasParameters = recipe.parameters && recipe.parameters.length > 0;
const needsParameterInput = hasParameters && !session?.user_recipe_values;
const handleParameterSubmit = useCallback(
async (values: Record<string, string>) => {
await setRecipeUserParams(values);
if (recipe.prompt) {
setInputValue(substituteParameters(recipe.prompt, values));
}
},
[recipe.prompt, setRecipeUserParams]
);
useEffect(() => {
if (!sessionId || !recipe.prompt || inputValue) return;
if (hasParameters && !session?.user_recipe_values) return;
const prompt = session?.user_recipe_values
? substituteParameters(recipe.prompt, session.user_recipe_values)
: recipe.prompt;
setInputValue(prompt);
}, [sessionId, recipe.prompt, hasParameters, session?.user_recipe_values, inputValue]);
const isUserMessage = useCallback((message: Message) => message.role === 'user', []);
useEffect(() => {
if (sessionId && chatState === ChatState.Idle && inputRef.current) {
inputRef.current.focus();
}
}, [sessionId, chatState]);
const isStreaming =
chatState === ChatState.Streaming ||
chatState === ChatState.Thinking ||
chatState === ChatState.Compacting;
const handleSubmit = useCallback(() => {
if (!inputValue.trim() || isStreaming) return;
const input: UserInput = { msg: inputValue, images: [] };
submitToChat(input);
setInputValue('');
}, [inputValue, isStreaming, submitToChat]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit]
);
// Show loading state
if (isStarting) {
return (
<div className="flex-1 flex items-center justify-center">
<LoadingGoose />
</div>
);
}
// Show error state
if (sessionError || sessionLoadError) {
return (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-4 rounded-lg max-w-md">
<h3 className="font-semibold mb-2">Failed to Start Test</h3>
<p className="text-sm mb-4">{sessionError || sessionLoadError}</p>
<Button onClick={handleStartTest} variant="outline" size="sm">
Try Again
</Button>
</div>
</div>
);
}
if (needsParameterInput) {
return (
<ParameterInputModal
parameters={recipe.parameters!}
onSubmit={handleParameterSubmit}
onClose={() => {}}
/>
);
}
return (
<div className="flex flex-col h-full">
{recipeChanged && (
<div className="flex items-center justify-between gap-2 px-4 py-2 bg-yellow-100 dark:bg-yellow-900/30 border-b border-yellow-300 dark:border-yellow-700">
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200 text-sm">
<AlertTriangle className="w-4 h-4" />
<span>Recipe has changed</span>
</div>
<Button onClick={handleRestart} variant="outline" size="sm" className="gap-1">
<RefreshCw className="w-3 h-3" />
Restart
</Button>
</div>
)}
{/* Chat area */}
<ScrollArea ref={scrollRef} className="flex-1 min-h-0 px-4" autoScroll>
<div className="py-6">
{messages.length === 0 ? (
<div className="text-center text-textSubtle py-8">
<p className="text-sm">Send a message to test your recipe.</p>
</div>
) : (
<ProgressiveMessageList
messages={messages}
chat={{ sessionId: sessionId! }}
isUserMessage={isUserMessage}
/>
)}
{isStreaming && (
<div className="flex justify-center py-4">
<LoadingGoose chatState={chatState} />
</div>
)}
</div>
</ScrollArea>
{/* Input area */}
<div className="p-4 border-t border-borderSubtle">
<div className="flex gap-2">
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Test your recipe..."
className="flex-1 p-3 border border-borderSubtle rounded-lg bg-background-default text-textStandard resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={2}
disabled={isStreaming}
/>
<div className="flex flex-col gap-2">
{isStreaming ? (
<Button onClick={stopStreaming} variant="outline" size="sm" className="h-full">
Stop
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!inputValue.trim()}
size="sm"
className="h-full"
>
<Send className="w-4 h-4" />
</Button>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
import { useState, useCallback } from 'react';
import { Recipe } from '../../../recipe';
import { MainPanelLayout } from '../../Layout/MainPanelLayout';
import { saveRecipe } from '../../../recipe/recipe_management';
import { toastSuccess, toastError } from '../../../toasts';
import { errorMessage } from '../../../utils/conversionUtils';
import RecipeBuilderHeader from './RecipeBuilderHeader';
import RecipeBuilderEdit from './RecipeBuilderEdit';
import RecipeBuilderChat from './RecipeBuilderChat';
import RecipeBuilderTest from './RecipeBuilderTest';
import { RecipeBuilderView } from './types';
interface RecipeBuilderProps {
onClose: () => void;
onRecipeSaved?: (recipeId: string) => void;
}
export default function RecipeBuilder({ onClose, onRecipeSaved }: RecipeBuilderProps) {
const [currentView, setCurrentView] = useState<RecipeBuilderView>('chat');
const [testPanelOpen, setTestPanelOpen] = useState(false);
const [recipe, setRecipe] = useState<Recipe | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Track the recipe version when test was started (for change detection)
const [testRecipeSnapshot, setTestRecipeSnapshot] = useState<Recipe | null>(null);
const canTest = recipe !== null;
const canSave = recipe !== null && !!recipe.title && !!recipe.description;
const handleSave = useCallback(async () => {
if (!recipe) return;
try {
setIsSaving(true);
const recipeId = await saveRecipe(recipe);
toastSuccess({
title: 'Recipe saved',
msg: `"${recipe.title}" has been saved successfully`,
});
onRecipeSaved?.(recipeId);
} catch (error) {
toastError({
title: 'Save failed',
msg: errorMessage(error, 'Failed to save recipe'),
});
} finally {
setIsSaving(false);
}
}, [recipe, onRecipeSaved]);
const handleTestPanelToggle = useCallback(() => {
setTestPanelOpen((prev) => {
// Clear snapshot when closing panel
if (prev) {
setTestRecipeSnapshot(null);
}
return !prev;
});
}, []);
const handleTestStart = useCallback(() => {
// Take a snapshot of the current recipe when test starts
setTestRecipeSnapshot(recipe);
}, [recipe]);
const handleTestRestart = useCallback(() => {
// Update snapshot to current recipe on restart
setTestRecipeSnapshot(recipe);
}, [recipe]);
return (
<MainPanelLayout backgroundColor="bg-background-muted" removeTopPadding={true}>
<div className="flex flex-col h-full">
<RecipeBuilderHeader
currentView={currentView}
onViewChange={setCurrentView}
testPanelOpen={testPanelOpen}
onTestPanelToggle={handleTestPanelToggle}
canTest={canTest}
canSave={canSave}
isSaving={isSaving}
onSave={handleSave}
onClose={onClose}
/>
<div className="flex flex-1 min-h-0">
<div
className={`relative flex flex-col flex-1 min-h-0 transition-all duration-300 ${
testPanelOpen ? 'w-1/2 border-r border-borderSubtle' : 'w-full'
}`}
>
<div
className={`absolute inset-0 flex flex-col ${currentView === 'chat' ? 'block' : 'hidden'}`}
>
<RecipeBuilderChat recipe={recipe} onRecipeChange={setRecipe} />
</div>
<div
className={`absolute inset-0 flex flex-col ${currentView === 'edit' ? 'block' : 'hidden'}`}
>
<RecipeBuilderEdit recipe={recipe} onRecipeChange={setRecipe} />
</div>
</div>
{testPanelOpen && recipe && (
<div className="w-1/2 flex flex-col min-h-0 bg-background-default">
<RecipeBuilderTest
recipe={recipe}
testRecipeSnapshot={testRecipeSnapshot}
onStart={handleTestStart}
onRestart={handleTestRestart}
isOpen={testPanelOpen}
/>
</div>
)}
</div>
</div>
</MainPanelLayout>
);
}
@@ -0,0 +1,62 @@
import { Recipe } from '../../../recipe';
export const recipeBuilderRecipe: Recipe = {
version: '1.0.0',
title: 'Recipe Builder Assistant',
description: 'An AI assistant that helps users create Goose recipes through conversation',
instructions: `You are a Recipe Builder Assistant helping users create Goose recipes.
## Your Goal
Guide the user through creating a recipe by asking questions and understanding their needs. Once you have enough information, output a complete recipe in YAML format.
## Recipe Structure
A Goose recipe has these fields:
- **title** (required): Short, descriptive name (3-100 chars)
- **description** (required): Brief explanation of what the recipe does (10-500 chars)
- **instructions** (required): Detailed instructions for the AI. This is the system prompt that tells the AI how to behave and what to do. Use parameter_name syntax for parameters.
- **prompt** (optional): Initial user message to start the conversation
- **parameters** (optional): Input values the user provides when running the recipe
- **activities** (optional): Predefined actions/messages for the AI to execute
- **extensions** (optional): Required MCP extensions
## Parameter Types
Parameters can have these input_types: string, number, boolean, date, file, select
Parameters can have these requirements: required, optional, user_prompt
## Conversation Flow
1. Ask what the user wants their recipe to do
2. Understand the specific use case and requirements
3. Ask clarifying questions about:
- What inputs/parameters are needed?
- What should the AI do step by step?
- Are there any specific tools or extensions needed?
4. Once you have enough information, output the recipe
## Output Format
When you have enough information, output the recipe in a YAML code block like this:
\`\`\`yaml
version: "1.0.0"
title: "Recipe Title"
description: "What this recipe does"
instructions: |
Your detailed instructions here.
Use parameter_name for parameters.
prompt: "Optional initial prompt"
parameters:
- key: "parameter_name"
description: "What this parameter is for"
input_type: "string"
requirement: "required"
\`\`\`
## Important Guidelines
- Keep the title concise and action-oriented
- Make descriptions clear and user-friendly
- Write instructions that are detailed enough for the AI to follow
- Only include parameters that are actually used in instructions or prompt
- Ask clarifying questions if the user's request is vague
- Suggest improvements to make the recipe more effective
Start by greeting the user and asking what kind of recipe they'd like to create.`,
};
@@ -0,0 +1,70 @@
import { Recipe, parseRecipeFromFile } from '../../../recipe';
import { Message, MessageContent } from '../../../api';
export interface ExtractedRecipe {
recipe: Recipe;
yamlString: string;
}
export function extractYamlFromText(text: string): string | null {
const yamlBlockRegex = /```(?:yaml|yml)\n([\s\S]*?)```/gi;
const matches = [...text.matchAll(yamlBlockRegex)];
if (matches.length === 0) {
return null;
}
return matches[matches.length - 1][1].trim();
}
export async function parseYamlToRecipe(yamlString: string): Promise<Recipe | null> {
try {
const recipe = await parseRecipeFromFile(yamlString);
return recipe;
} catch (error) {
console.error('Failed to parse YAML:', error);
return null;
}
}
export function extractYamlFromMessage(message: Message): string | null {
if (message.role !== 'assistant') {
return null;
}
const textContent = message.content.find((c: MessageContent) => c.type === 'text') as
| { type: 'text'; text: string }
| undefined;
if (!textContent) {
return null;
}
return extractYamlFromText(textContent.text);
}
export async function extractRecipeFromMessage(message: Message): Promise<ExtractedRecipe | null> {
const yamlString = extractYamlFromMessage(message);
if (!yamlString) {
return null;
}
const recipe = await parseYamlToRecipe(yamlString);
if (!recipe) {
return null;
}
return { recipe, yamlString };
}
export async function extractRecipeFromMessages(
messages: Message[]
): Promise<ExtractedRecipe | null> {
for (let i = messages.length - 1; i >= 0; i--) {
const result = await extractRecipeFromMessage(messages[i]);
if (result) {
return result;
}
}
return null;
}
@@ -0,0 +1,36 @@
import { Recipe } from '../../../recipe';
export type RecipeBuilderView = 'chat' | 'edit';
export interface RecipeBuilderState {
currentView: RecipeBuilderView;
testPanelOpen: boolean;
recipe: Recipe | null;
testSessionId: string | null;
testRecipeSnapshot: Recipe | null;
}
export interface RecipeBuilderHeaderProps {
currentView: RecipeBuilderView;
onViewChange: (view: RecipeBuilderView) => void;
testPanelOpen: boolean;
onTestPanelToggle: () => void;
canTest: boolean;
canSave: boolean;
isSaving: boolean;
onSave: () => void;
onClose: () => void;
}
export interface RecipeBuilderChatProps {
recipe: Recipe | null;
onRecipeChange: (recipe: Recipe) => void;
}
export interface RecipeBuilderTestProps {
recipe: Recipe;
testRecipeSnapshot: Recipe | null;
onStart: () => void;
onRestart: () => void;
isOpen: boolean;
}
@@ -14,6 +14,7 @@ import {
Share2,
Copy,
Download,
Sparkles,
} from 'lucide-react';
import { ScrollArea } from '../ui/scroll-area';
import { Card } from '../ui/card';
@@ -32,6 +33,7 @@ import {
} from '../../api';
import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm';
import CreateEditRecipeModal from './CreateEditRecipeModal';
import RecipeBuilder from './RecipeBuilder';
import { generateDeepLink, Recipe, stripEmptyExtensions } from '../../recipe';
import { useNavigation } from '../../hooks/useNavigation';
import { CronPicker } from '../schedule/CronPicker';
@@ -72,6 +74,7 @@ export default function RecipesView() {
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [showAIBuilder, setShowAIBuilder] = useState(false);
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
const [scheduleRecipeManifest, setScheduleRecipeManifest] = useState<RecipeManifest | null>(null);
@@ -678,6 +681,18 @@ export default function RecipesView() {
);
};
if (showAIBuilder) {
return (
<RecipeBuilder
onClose={() => setShowAIBuilder(false)}
onRecipeSaved={() => {
setShowAIBuilder(false);
loadSavedRecipes();
}}
/>
);
}
return (
<>
<MainPanelLayout>
@@ -687,6 +702,15 @@ export default function RecipesView() {
<div className="flex justify-between items-center mb-1">
<h1 className="text-4xl font-light">Recipes</h1>
<div className="flex gap-2">
<Button
onClick={() => setShowAIBuilder(true)}
variant="default"
size="sm"
className="flex items-center gap-2"
>
<Sparkles className="w-4 h-4" />
AI Recipe Builder
</Button>
<Button
onClick={() => setShowCreateDialog(true)}
variant="outline"