From cc6fedbf603b1fa5aed32405294d0bd16596616a Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Fri, 6 Feb 2026 10:59:37 +1100 Subject: [PATCH] basic functionality for ai driven recipe creation (WIP) --- .../components/recipes/RecipeBuilder/PLAN.md | 165 ++++++++++ .../RecipeBuilder/RecipeBuilderChat.tsx | 225 ++++++++++++++ .../RecipeBuilder/RecipeBuilderEdit.tsx | 112 +++++++ .../RecipeBuilder/RecipeBuilderHeader.tsx | 66 ++++ .../RecipeBuilder/RecipeBuilderTest.tsx | 283 ++++++++++++++++++ .../recipes/RecipeBuilder/index.tsx | 118 ++++++++ .../RecipeBuilder/recipeBuilderRecipe.ts | 62 ++++ .../recipes/RecipeBuilder/recipeExtractor.ts | 70 +++++ .../components/recipes/RecipeBuilder/types.ts | 36 +++ .../src/components/recipes/RecipesView.tsx | 24 ++ 10 files changed, 1161 insertions(+) create mode 100644 ui/desktop/src/components/recipes/RecipeBuilder/PLAN.md create mode 100644 ui/desktop/src/components/recipes/RecipeBuilder/RecipeBuilderChat.tsx create mode 100644 ui/desktop/src/components/recipes/RecipeBuilder/RecipeBuilderEdit.tsx create mode 100644 ui/desktop/src/components/recipes/RecipeBuilder/RecipeBuilderHeader.tsx create mode 100644 ui/desktop/src/components/recipes/RecipeBuilder/RecipeBuilderTest.tsx create mode 100644 ui/desktop/src/components/recipes/RecipeBuilder/index.tsx create mode 100644 ui/desktop/src/components/recipes/RecipeBuilder/recipeBuilderRecipe.ts create mode 100644 ui/desktop/src/components/recipes/RecipeBuilder/recipeExtractor.ts create mode 100644 ui/desktop/src/components/recipes/RecipeBuilder/types.ts diff --git a/ui/desktop/src/components/recipes/RecipeBuilder/PLAN.md b/ui/desktop/src/components/recipes/RecipeBuilder/PLAN.md new file mode 100644 index 0000000000..96b7b20b36 --- /dev/null +++ b/ui/desktop/src/components/recipes/RecipeBuilder/PLAN.md @@ -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 | diff --git a/ui/desktop/src/components/recipes/RecipeBuilder/RecipeBuilderChat.tsx b/ui/desktop/src/components/recipes/RecipeBuilder/RecipeBuilderChat.tsx new file mode 100644 index 0000000000..4246ff8c72 --- /dev/null +++ b/ui/desktop/src/components/recipes/RecipeBuilder/RecipeBuilderChat.tsx @@ -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(null); + const [sessionError, setSessionError] = useState(null); + const [isInitializing, setIsInitializing] = useState(true); + const [inputValue, setInputValue] = useState(''); + const scrollRef = useRef(null); + const inputRef = useRef(null); + const lastExtractedYamlRef = useRef(null); + // Track the last recipe generated by the AI (for detecting user edits in Edit view) + const lastAIRecipeRef = useRef(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 ( +
+
+

Failed to Start Chat

+

{sessionError || sessionLoadError}

+
+
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ {messages.length === 0 ? ( +
+

Recipe Builder

+

+ Tell me what kind of recipe you'd like to create, and I'll help you build it. +

+
+ ) : ( + + )} + {isStreaming && ( +
+ +
+ )} +
+
+ +
+
+