mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
basic functionality for ai driven recipe creation (WIP)
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user