From 454e4a47f4ab845c466ff930ea8b3e568576a41e Mon Sep 17 00:00:00 2001 From: Zaki Ali Date: Fri, 11 Apr 2025 13:28:29 -0700 Subject: [PATCH] UI Recipe/Custom Agents (#2119) Co-authored-by: Kalvin Chau --- crates/goose-server/src/routes/mod.rs | 2 + crates/goose-server/src/routes/recipe.rs | 89 +++++ ui/desktop/src/App.tsx | 292 +++++++++++++- ui/desktop/src/botConfig.ts | 7 - ui/desktop/src/components/AgentHeader.tsx | 31 ++ ui/desktop/src/components/ChatView.tsx | 188 +++------ ui/desktop/src/components/ConfigContext.tsx | 55 ++- ui/desktop/src/components/LayingEggLoader.tsx | 30 ++ ui/desktop/src/components/RecipeEditor.tsx | 377 ++++++++++++++++++ ui/desktop/src/components/Splash.tsx | 43 +- ui/desktop/src/components/SplashPills.tsx | 14 +- ui/desktop/src/components/icons/Bars.tsx | 11 + .../src/components/icons/ChevronRight.tsx | 29 ++ ui/desktop/src/components/icons/Geese.tsx | 46 +++ ui/desktop/src/components/icons/index.tsx | 2 + .../src/components/ui/DeepLinkModal.tsx | 26 +- ui/desktop/src/extensions.tsx | 25 +- ui/desktop/src/main.ts | 216 +++++++--- ui/desktop/src/preload.ts | 19 +- ui/desktop/src/recipe/index.ts | 59 +++ ui/desktop/src/utils/providerUtils.ts | 6 +- 21 files changed, 1321 insertions(+), 246 deletions(-) create mode 100644 crates/goose-server/src/routes/recipe.rs delete mode 100644 ui/desktop/src/botConfig.ts create mode 100644 ui/desktop/src/components/AgentHeader.tsx create mode 100644 ui/desktop/src/components/LayingEggLoader.tsx create mode 100644 ui/desktop/src/components/RecipeEditor.tsx create mode 100644 ui/desktop/src/components/icons/Bars.tsx create mode 100644 ui/desktop/src/components/icons/ChevronRight.tsx create mode 100644 ui/desktop/src/components/icons/Geese.tsx create mode 100644 ui/desktop/src/recipe/index.ts diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index c7c68866..031cc6db 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -4,6 +4,7 @@ pub mod config_management; pub mod configs; pub mod extension; pub mod health; +pub mod recipe; pub mod reply; pub mod session; pub mod utils; @@ -18,5 +19,6 @@ pub fn configure(state: crate::state::AppState) -> Router { .merge(extension::routes(state.clone())) .merge(configs::routes(state.clone())) .merge(config_management::routes(state.clone())) + .merge(recipe::routes(state.clone())) .merge(session::routes(state)) } diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs new file mode 100644 index 00000000..6e9598d6 --- /dev/null +++ b/crates/goose-server/src/routes/recipe.rs @@ -0,0 +1,89 @@ +use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; +use goose::message::Message; +use goose::recipe::Recipe; +use serde::{Deserialize, Serialize}; + +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct CreateRecipeRequest { + messages: Vec, + // Required metadata + title: String, + description: String, + // Optional fields + #[serde(default)] + activities: Option>, + #[serde(default)] + author: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AuthorRequest { + #[serde(default)] + contact: Option, + #[serde(default)] + metadata: Option, +} + +#[derive(Debug, Serialize)] +pub struct CreateRecipeResponse { + recipe: Option, + error: Option, +} + +/// Create a Recipe configuration from the current state of an agent +async fn create_recipe( + State(state): State, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + let agent = state.agent.read().await; + let agent = agent.as_ref().ok_or_else(|| { + let error_response = CreateRecipeResponse { + recipe: None, + error: Some("Agent not initialized".to_string()), + }; + (StatusCode::PRECONDITION_REQUIRED, Json(error_response)) + })?; + + // Create base recipe from agent state and messages + let recipe_result = agent.create_recipe(request.messages).await; + + match recipe_result { + Ok(mut recipe) => { + // Update with user-provided metadata + recipe.title = request.title; + recipe.description = request.description; + if request.activities.is_some() { + recipe.activities = request.activities + }; + + // Add author if provided + if let Some(author_req) = request.author { + recipe.author = Some(goose::recipe::Author { + contact: author_req.contact, + metadata: author_req.metadata, + }); + } + + Ok(Json(CreateRecipeResponse { + recipe: Some(recipe), + error: None, + })) + } + Err(e) => { + // Return 400 Bad Request with error message + let error_response = CreateRecipeResponse { + recipe: None, + error: Some(e.to_string()), + }; + Err((StatusCode::BAD_REQUEST, Json(error_response))) + } + } +} + +pub fn routes(state: AppState) -> Router { + Router::new() + .route("/recipe/create", post(create_recipe)) + .with_state(state) +} diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 2c7b1e1d..4aa1cdb4 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import { IpcRendererEvent } from 'electron'; import { addExtensionFromDeepLink } from './extensions'; import { openSharedSessionFromDeepLink } from './sessionLinks'; @@ -27,7 +27,9 @@ import ConfigureProvidersView from './components/settings/providers/ConfigurePro import SessionsView from './components/sessions/SessionsView'; import SharedSessionView from './components/sessions/SharedSessionView'; import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage'; +import RecipeEditor from './components/RecipeEditor'; import { useChat } from './hooks/useChat'; +import { addExtension as addExtensionDirect, FullExtensionConfig } from './extensions'; import 'react-toastify/dist/ReactToastify.css'; import { useConfig, MalformedConfigError } from './components/ConfigContext'; @@ -46,7 +48,8 @@ export type View = | 'settingsV2' | 'sessions' | 'sharedSession' - | 'loading'; + | 'loading' + | 'recipeEditor'; export type ViewOptions = | SettingsViewOptions @@ -59,16 +62,47 @@ export type ViewConfig = { viewOptions?: ViewOptions; }; +const getInitialView = (): ViewConfig => { + const urlParams = new URLSearchParams(window.location.search); + const viewFromUrl = urlParams.get('view'); + const windowConfig = window.electron.getConfig(); + + if (viewFromUrl === 'recipeEditor' && windowConfig?.recipeConfig) { + return { + view: 'recipeEditor', + viewOptions: { + config: windowConfig.recipeConfig, + }, + }; + } + + // Any other URL-specified view + if (viewFromUrl) { + return { + view: viewFromUrl as View, + viewOptions: {}, + }; + } + + // Default case + return { + view: 'welcome', + viewOptions: {}, + }; +}; + export default function App() { const [fatalError, setFatalError] = useState(null); const [modalVisible, setModalVisible] = useState(false); const [pendingLink, setPendingLink] = useState(null); const [modalMessage, setModalMessage] = useState(''); - const [{ view, viewOptions }, setInternalView] = useState({ - view: 'loading', - viewOptions: {}, - }); - const { getExtensions, addExtension, read } = useConfig(); + const [{ view, viewOptions }, setInternalView] = useState(getInitialView()); + const { + getExtensions, + addExtension: addExtensionToConfig, + disableAllExtensions, + read, + } = useConfig(); const initAttemptedRef = useRef(false); // Utility function to extract the command from the link @@ -90,7 +124,153 @@ export default function App() { setInternalView({ view, viewOptions }); }; - // Single initialization effect that handles both v1 and v2 settings + const disableAllStoredExtensions = () => { + const userSettingsStr = localStorage.getItem('user_settings'); + if (!userSettingsStr) return; + + try { + const userSettings = JSON.parse(userSettingsStr); + // Store original state before modifying + localStorage.setItem('user_settings_backup', userSettingsStr); + console.log('Backing up user_settings'); + + // Disable all extensions + userSettings.extensions = userSettings.extensions.map((ext) => ({ + ...ext, + enabled: false, + })); + + localStorage.setItem('user_settings', JSON.stringify(userSettings)); + console.log('Disabled all stored extensions'); + window.electron.emit('settings-updated'); + } catch (error) { + console.error('Error disabling stored extensions:', error); + } + }; + + // Function to restore original extension states for new non-recipe windows + const restoreOriginalExtensionStates = () => { + const backupStr = localStorage.getItem('user_settings_backup'); + if (backupStr) { + localStorage.setItem('user_settings', backupStr); + console.log('Restored original extension states'); + } + }; + + const updateUserSettingsWithConfig = (extensions: FullExtensionConfig[]) => { + try { + const userSettingsStr = localStorage.getItem('user_settings'); + const userSettings = userSettingsStr ? JSON.parse(userSettingsStr) : { extensions: [] }; + + // For each extension in the passed in config + extensions.forEach((newExtension) => { + // Find if this extension already exists + const existingIndex = userSettings.extensions.findIndex( + (ext) => ext.id === newExtension.id + ); + + if (existingIndex !== -1) { + // Extension exists - just set its enabled to true + userSettings.extensions[existingIndex].enabled = true; + } else { + // Extension is new - add it to the array + userSettings.extensions.push({ + ...newExtension, + enabled: true, + }); + } + }); + + localStorage.setItem('user_settings', JSON.stringify(userSettings)); + console.log('Updated user settings with new/enabled extensions:', userSettings.extensions); + + // Notify any listeners (like the settings page) that settings have changed + window.electron.emit('settings-updated'); + } catch (error) { + console.error('Error updating user settings:', error); + } + }; + + const enableRecipeConfigExtensions = async (extensions: FullExtensionConfig[]) => { + if (!extensions?.length) { + console.log('No extensions to enable from bot config'); + return; + } + + console.log(`Enabling ${extensions.length} extensions from bot config:`, extensions); + + disableAllStoredExtensions(); + + // Wait for initial server readiness + await new Promise((resolve) => setTimeout(resolve, 2000)); + + for (const extension of extensions) { + try { + console.log(`Enabling extension: ${extension.name}`); + const extensionConfig = { + ...extension, + enabled: true, + }; + + // Try to add the extension + const response = await addExtensionDirect(extensionConfig, false); + + if (!response.ok) { + console.error( + `Failed to enable extension ${extension.name}: Server returned ${response.status}` + ); + // If it's a 428, retry once + if (response.status === 428) { + console.log('Server not ready, waiting and will retry...'); + await new Promise((resolve) => setTimeout(resolve, 2000)); + try { + await addExtensionDirect(extensionConfig, true); + console.log(`Successfully enabled extension ${extension.name} on retry`); + } catch (retryError) { + console.error(`Failed to enable extension ${extension.name} on retry:`, retryError); + } + } + continue; + } + updateUserSettingsWithConfig(extensions); + + console.log(`Successfully enabled extension: ${extension.name}`); + } catch (error) { + console.error(`Failed to enable extension ${extension.name}:`, error); + } + } + + console.log('Finished enabling bot config extensions'); + }; + + const enableRecipeConfigExtensionsV2 = useCallback( + async (extensions: FullExtensionConfig[]) => { + if (!extensions?.length) { + console.log('No extensions to enable from bot config'); + return; + } + + try { + await disableAllExtensions(); + console.log('Disabled all existing extensions'); + + for (const extension of extensions) { + try { + console.log('Enabling extension: ${extension.name}'); + await addExtensionToConfig(extension.name, extension, true); + } catch (error) { + console.error(`Failed to enable extension ${extension.name}:`, error); + } + } + } catch (error) { + console.error('Failed to enable bot extensions'); + } + console.log('Finished enabling bot config extensions'); + }, + [disableAllExtensions, addExtensionToConfig] + ); + + // settings v2 initialization useEffect(() => { if (!settingsV2Enabled) { return; @@ -105,6 +285,27 @@ export default function App() { console.log(`Initializing app with settings v2`); + const urlParams = new URLSearchParams(window.location.search); + const viewType = urlParams.get('view'); + const recipeConfig = window.appConfig.get('recipeConfig'); + + // Handle bot config extensions first + if (recipeConfig?.extensions?.length > 0 && viewType != 'recipeEditor') { + console.log('Found extensions in bot config:', recipeConfig.extensions); + enableRecipeConfigExtensionsV2(recipeConfig.extensions); + } + + // If we have a specific view type in the URL, use that and skip provider detection + if (viewType) { + if (viewType === 'recipeEditor' && recipeConfig) { + console.log('Setting view to recipeEditor with config:', recipeConfig); + setView('recipeEditor', { config: recipeConfig }); + } else { + setView(viewType as View); + } + return; + } + const initializeApp = async () => { try { // Initialize config first @@ -121,7 +322,7 @@ export default function App() { try { await initializeSystem(provider, model, { getExtensions, - addExtension, + addExtensionToConfig, }); } catch (error) { console.error('Error in initialization:', error); @@ -153,7 +354,7 @@ export default function App() { console.error('Unhandled error in initialization:', error); setFatalError(`${error instanceof Error ? error.message : 'Unknown error'}`); }); - }, [read, getExtensions, addExtension]); + }, [read, getExtensions, addExtensionToConfig, enableRecipeConfigExtensionsV2]); const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); const [isLoadingSession, setIsLoadingSession] = useState(false); @@ -243,6 +444,24 @@ export default function App() { setView(newView); }; + // Get initial view and config + const urlParams = new URLSearchParams(window.location.search); + const viewFromUrl = urlParams.get('view'); + if (viewFromUrl) { + // Get the config from the electron window config + const windowConfig = window.electron.getConfig(); + + if (viewFromUrl === 'recipeEditor') { + const initialViewOptions = { + recipeConfig: windowConfig?.recipeConfig, + view: viewFromUrl, + }; + setView(viewFromUrl, initialViewOptions); + } else { + setView(viewFromUrl); + } + } + window.electron.on('set-view', handleSetView); return () => window.electron.off('set-view', handleSetView); }, []); @@ -250,7 +469,7 @@ export default function App() { // Add cleanup for session states when view changes useEffect(() => { console.log(`View changed to: ${view}`); - if (view !== 'chat') { + if (view !== 'chat' && view !== 'recipeEditor') { console.log('Not in chat view, clearing loading session state'); setIsLoadingSession(false); } @@ -291,7 +510,7 @@ export default function App() { setModalVisible(false); // Dismiss modal immediately try { if (settingsV2Enabled) { - await addExtensionFromDeepLinkV2(pendingLink, addExtension, setView); + await addExtensionFromDeepLinkV2(pendingLink, addExtensionToConfig, setView); } else { await addExtensionFromDeepLink(pendingLink, setView); } @@ -318,12 +537,40 @@ export default function App() { const { addRecentModel } = useRecentModels(); // TODO: remove useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const viewType = urlParams.get('view'); + const recipeConfig = window.appConfig.get('recipeConfig'); + if (settingsV2Enabled) { return; } console.log(`Initializing app with settings v1`); + // Handle bot config extensions first + if (recipeConfig?.extensions?.length > 0 && viewType != 'recipeEditor') { + console.log('Found extensions in bot config:', recipeConfig.extensions); + enableRecipeConfigExtensions(recipeConfig.extensions); + } + + // If we have a specific view type in the URL, use that and skip provider detection + if (viewType) { + if (viewType === 'recipeEditor' && recipeConfig) { + console.log('Setting view to recipeEditor with config:', recipeConfig); + setView('recipeEditor', { config: recipeConfig }); + } else { + setView(viewType as View); + } + return; + } + + // if not in any of the states above (in a regular chat) + if (!recipeConfig) { + restoreOriginalExtensionStates(); + } + + console.log(`Initializing app with settings v1`); + // Attempt to detect config for a stored provider const detectStoredProvider = () => { try { @@ -510,6 +757,27 @@ export default function App() { }} /> )} + {view === 'recipeEditor' && ( + setView('chat')} + setView={setView} + onSave={(config) => { + console.log('Saving recipe config:', config); + window.electron.createChatWindow( + undefined, + undefined, + undefined, + undefined, + config, + 'recipeEditor', + { config } + ); + setView('chat'); + }} + /> + )} {isGoosehintsModalOpen && ( diff --git a/ui/desktop/src/botConfig.ts b/ui/desktop/src/botConfig.ts deleted file mode 100644 index cb64c9fb..00000000 --- a/ui/desktop/src/botConfig.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Bot configuration interface - */ -export interface BotConfig { - instructions: string; - activities: string[] | null; -} diff --git a/ui/desktop/src/components/AgentHeader.tsx b/ui/desktop/src/components/AgentHeader.tsx new file mode 100644 index 00000000..35f33a6b --- /dev/null +++ b/ui/desktop/src/components/AgentHeader.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +interface AgentHeaderProps { + title: string; + profileInfo?: string; + onChangeProfile?: () => void; +} + +export function AgentHeader({ title, profileInfo, onChangeProfile }: AgentHeaderProps) { + return ( +
+
+ + + Agent{' '} + {title} + +
+ {profileInfo && ( +
+ {profileInfo} + {onChangeProfile && ( + + )} +
+ )} +
+ ); +} diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index 3c9deee0..3ec4e01d 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -12,10 +12,13 @@ import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; import UserMessage from './UserMessage'; import Splash from './Splash'; import { SearchView } from './conversation/SearchView'; -import { DeepLinkModal } from './ui/DeepLinkModal'; +import { createRecipe } from '../recipe'; +import { AgentHeader } from './AgentHeader'; +import LayingEggLoader from './LayingEggLoader'; +// import { configureRecipeExtensions } from '../utils/recipeExtensions'; import 'react-toastify/dist/ReactToastify.css'; import { useMessageStream } from '../hooks/useMessageStream'; -import { BotConfig } from '../botConfig'; +import { Recipe } from '../recipe'; import { Message, createUserMessage, @@ -24,7 +27,6 @@ import { ToolRequestMessageContent, ToolResponseMessageContent, ToolConfirmationRequestMessageContent, - getTextContent, EnableExtensionRequestMessageContent, } from '../types/message'; @@ -37,14 +39,6 @@ export interface ChatType { messages: Message[]; } -interface GeneratedBotConfig { - id: string; - name: string; - description: string; - instructions: string; - activities: string[]; -} - // Helper function to determine if a message is a user message const isUserMessage = (message: Message): boolean => { if (message.role === 'assistant') { @@ -55,7 +49,7 @@ const isUserMessage = (message: Message): boolean => { } if (message.content.every((c) => c.type === 'enableExtensionRequest')) { return false; - } + } return true; }; @@ -75,13 +69,11 @@ export default function ChatView({ const [hasMessages, setHasMessages] = useState(false); const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); const [showGame, setShowGame] = useState(false); - const [waitingForAgentResponse, setWaitingForAgentResponse] = useState(false); - const [showShareableBotModal, setshowShareableBotModal] = useState(false); - const [generatedBotConfig, setGeneratedBotConfig] = useState(null); + const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false); const scrollRef = useRef(null); // Get botConfig directly from appConfig - const botConfig = window.appConfig.get('botConfig') as BotConfig | null; + const botConfig = window.appConfig.get('botConfig') as Recipe | null; const { messages, @@ -126,33 +118,50 @@ export default function ChatView({ // Listen for make-agent-from-chat event useEffect(() => { const handleMakeAgent = async () => { - window.electron.logInfo('Making agent from chat...'); + window.electron.logInfo('Making recipe from chat...'); + setIsGeneratingRecipe(true); - // Log all messages for now - window.electron.logInfo('Current messages:'); - chat.messages.forEach((message, index) => { - const role = isUserMessage(message) ? 'user' : 'assistant'; - const content = isUserMessage(message) ? message.text : getTextContent(message); - window.electron.logInfo(`Message ${index} (${role}): ${content}`); - }); + try { + // Create recipe directly from chat messages + const createRecipeRequest = { + messages: messages, + title: 'Custom Recipe', + description: 'Created from chat session', + }; - // Inject a question into the chat to generate instructions - const instructionsPrompt = - 'Based on our conversation so far, could you create:\n' + - "1. A concise set of instructions (1-2 paragraphs) that describe what you've been helping with. Pay special attention if any output styles or formats are requested (and make it clear), and note any non standard tools used or required.\n" + - '2. A list of 3-5 example activities (as a few words each at most) that would be relevant to this topic\n\n' + - "Format your response with clear headings for 'Instructions:' and 'Activities:' sections." + - 'For example, perhaps we have been discussing fruit and you might write:\n\n' + - 'Instructions:\nUsing web searches we find pictures of fruit, and always check what language to reply in.' + - 'Activities:\nShow pics of apples, say a random fruit, share a fruit fact'; + const response = await createRecipe(createRecipeRequest); - // Set waiting state to true before adding the prompt - setWaitingForAgentResponse(true); + if (response.error) { + throw new Error(`Failed to create recipe: ${response.error}`); + } - // Add the prompt as a user message - append(createUserMessage(instructionsPrompt)); + window.electron.logInfo('Created recipe:'); + window.electron.logInfo(JSON.stringify(response.recipe, null, 2)); - window.electron.logInfo('Injected instructions prompt into chat'); + // Create a new window for the recipe editor + console.log('Opening recipe editor with config:', response.recipe); + + // First, verify the recipe data + if (!response.recipe || !response.recipe.title) { + throw new Error('Invalid recipe data received'); + } + + window.electron.createChatWindow( + undefined, // query + undefined, // dir + undefined, // version + undefined, // resumeSessionId + response.recipe, // recipe config + 'recipeEditor' // view type + ); + + window.electron.logInfo('Opening recipe editor window'); + } catch (error) { + window.electron.logInfo('Failed to create recipe:'); + window.electron.logInfo(error.message); + } finally { + setIsGeneratingRecipe(false); + } }; window.addEventListener('make-agent-from-chat', handleMakeAgent); @@ -160,74 +169,9 @@ export default function ChatView({ return () => { window.removeEventListener('make-agent-from-chat', handleMakeAgent); }; - }, [append, chat.messages, setWaitingForAgentResponse]); - - // Listen for new messages and process agent response - useEffect(() => { - // Only process if we're waiting for an agent response - if (!waitingForAgentResponse || messages.length === 0) { - return; - } - - // Get the last message - const lastMessage = messages[messages.length - 1]; - - // Check if it's an assistant message (response to our prompt) - if (lastMessage.role === 'assistant') { - // Extract the content - const content = getTextContent(lastMessage); - - // Process the agent's response - if (content) { - window.electron.logInfo('Received agent response:'); - window.electron.logInfo(content); - - // Parse the response to extract instructions and activities - const instructionsMatch = content.match(/Instructions:(.*?)(?=Activities:|$)/s); - const activitiesMatch = content.match(/Activities:(.*?)$/s); - - const instructions = instructionsMatch ? instructionsMatch[1].trim() : ''; - const activitiesText = activitiesMatch ? activitiesMatch[1].trim() : ''; - - // Parse activities into an array - const activities = activitiesText - .split(/\n+/) - .map((line) => line.replace(/^[•\-*\d]+\.?\s*/, '').trim()) - .filter((activity) => activity.length > 0); - - // Create a bot config object - const generatedConfig = { - id: `bot-${Date.now()}`, - name: 'Custom Bot', - description: 'Bot created from chat', - instructions: instructions, - activities: activities, - }; - - window.electron.logInfo('Extracted bot config:'); - window.electron.logInfo(JSON.stringify(generatedConfig, null, 2)); - - // Store the generated bot config - setGeneratedBotConfig(generatedConfig); - - // Show the modal with the generated bot config - setshowShareableBotModal(true); - - window.electron.logInfo('Generated bot config for agent creation'); - - // Reset waiting state - setWaitingForAgentResponse(false); - } - } - }, [messages, waitingForAgentResponse, setshowShareableBotModal, setGeneratedBotConfig]); - - // Leaving these in for easy debugging of different message states - - // One message with a tool call and no text content - // const messages = [{"role":"assistant","created":1742484893,"content":[{"type":"toolRequest","id":"call_udVcu3crnFdx2k5FzlAjk5dI","toolCall":{"status":"success","value":{"name":"developer__text_editor","arguments":{"command":"write","file_text":"Hello, this is a test file.\nLet's see if this works properly.","path":"/Users/alexhancock/Development/testfile.txt"}}}}]}]; - - // One message with text content and tool calls - // const messages = [{"role":"assistant","created":1742484388,"content":[{"type":"text","text":"Sure, let's break this down into two steps:\n\n1. **Write content to a `.txt` file.**\n2. **Read the content from the `.txt` file.**\n\nLet's start by writing some example content to a `.txt` file. I'll create a file named `example.txt` and write a sample sentence into it. Then I'll read the content back. \n\n### Sample Content\nWe'll write the following content into the `example.txt` file:\n\n```\nHello World! This is an example text file.\n```\n\nLet's proceed with this task."},{"type":"toolRequest","id":"call_CmvAsxMxiWVKZvONZvnz4QCE","toolCall":{"status":"success","value":{"name":"developer__text_editor","arguments":{"command":"write","file_text":"Hello World! This is an example text file.","path":"/Users/alexhancock/Development/example.txt"}}}}]}]; + }, [messages]); + // do we need append here? + // }, [append, chat.messages]); // Update chat messages when they change and save to sessionStorage useEffect(() => { @@ -417,15 +361,30 @@ export default function ChatView({ return (
+ {/* Loader when generating recipe */} + {isGeneratingRecipe && }
+ {botConfig?.title && messages.length > 0 && ( + { + // Handle profile change + console.log('Change profile clicked'); + }} + /> + )} {messages.length === 0 ? ( append(createUserMessage(text))} - activities={botConfig?.activities || null} + activities={Array.isArray(botConfig?.activities) ? botConfig.activities : null} + title={botConfig?.title} /> ) : ( @@ -490,21 +449,6 @@ export default function ChatView({ {showGame && setShowGame(false)} />} - - {/* Deep Link Modal */} - {showShareableBotModal && generatedBotConfig && ( - { - setshowShareableBotModal(false); - setGeneratedBotConfig(null); - }} - onOpen={() => { - setshowShareableBotModal(false); - setGeneratedBotConfig(null); - }} - /> - )}
); } diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index 9359de80..76631ab6 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -49,6 +49,8 @@ interface ConfigContextType { removeExtension: (name: string) => Promise; getProviders: (b: boolean) => Promise; getExtensions: (b: boolean) => Promise; + disableAllExtensions: () => Promise; + enableBotExtensions: (extensions: ExtensionConfig[]) => Promise; } interface ConfigProviderProps { @@ -204,8 +206,25 @@ export const ConfigProvider: React.FC = ({ children }) => { })(); }, []); - const contextValue = useMemo( - () => ({ + const contextValue = useMemo(() => { + const disableAllExtensions = async () => { + const currentExtensions = await getExtensions(true); + for (const ext of currentExtensions) { + if (ext.enabled) { + await addExtension(ext.name, ext, false); + } + } + await reloadConfig(); + }; + + const enableBotExtensions = async (extensions: ExtensionConfig[]) => { + for (const ext of extensions) { + await addExtension(ext.name, ext, true); + } + await reloadConfig(); + }; + + return { config, providersList, extensionsList, @@ -217,21 +236,23 @@ export const ConfigProvider: React.FC = ({ children }) => { toggleExtension, getProviders, getExtensions, - }), - [ - config, - providersList, - extensionsList, - upsert, - read, - remove, - addExtension, - removeExtension, - toggleExtension, - getProviders, - getExtensions, - ] - ); + disableAllExtensions, + enableBotExtensions, + }; + }, [ + config, + providersList, + extensionsList, + upsert, + read, + remove, + addExtension, + removeExtension, + toggleExtension, + getProviders, + getExtensions, + reloadConfig, + ]); return {children}; }; diff --git a/ui/desktop/src/components/LayingEggLoader.tsx b/ui/desktop/src/components/LayingEggLoader.tsx new file mode 100644 index 00000000..3d96947f --- /dev/null +++ b/ui/desktop/src/components/LayingEggLoader.tsx @@ -0,0 +1,30 @@ +import React, { useEffect, useState } from 'react'; +import { Geese } from './icons/Geese'; + +export default function LayingEggLoader() { + const [dots, setDots] = useState(''); + + useEffect(() => { + const interval = setInterval(() => { + setDots((prev) => (prev.length >= 3 ? '' : prev + '.')); + }, 500); + + return () => clearInterval(interval); + }, []); + + return ( +
+
+
+ +
+

+ Laying an egg{dots} +

+

+ Please wait while we process your request +

+
+
+ ); +} diff --git a/ui/desktop/src/components/RecipeEditor.tsx b/ui/desktop/src/components/RecipeEditor.tsx new file mode 100644 index 00000000..29c618a7 --- /dev/null +++ b/ui/desktop/src/components/RecipeEditor.tsx @@ -0,0 +1,377 @@ +import React, { useState, useEffect } from 'react'; +import { Recipe } from '../recipe'; +import { Buffer } from 'buffer'; +import { FullExtensionConfig } from '../extensions'; +import { ChevronRight } from './icons/ChevronRight'; +import Back from './icons/Back'; +import { Bars } from './icons/Bars'; +import { Geese } from './icons/Geese'; +import Copy from './icons/Copy'; +import { useConfig } from '../components/ConfigContext'; +import { settingsV2Enabled } from '../flags'; + +interface RecipeEditorProps { + config?: Recipe; +} + +// Function to generate a deep link from a recipe +function generateDeepLink(recipe: Recipe): string { + const configBase64 = Buffer.from(JSON.stringify(recipe)).toString('base64'); + return `goose://recipe?config=${configBase64}`; +} + +export default function RecipeEditor({ config }: RecipeEditorProps) { + const { getExtensions } = useConfig(); + const [recipeConfig] = useState(config); + const [title, setTitle] = useState(config?.title || ''); + const [description, setDescription] = useState(config?.description || ''); + const [instructions, setInstructions] = useState(config?.instructions || ''); + const [activities, setActivities] = useState(config?.activities || []); + const [availableExtensions, setAvailableExtensions] = useState([]); + const [selectedExtensions, setSelectedExtensions] = useState( + config?.extensions?.map((e) => e.id) || [] + ); + const [newActivity, setNewActivity] = useState(''); + + // Section visibility state + const [activeSection, setActiveSection] = useState< + 'none' | 'activities' | 'instructions' | 'extensions' + >('none'); + + // Load extensions + useEffect(() => { + const loadExtensions = async () => { + if (settingsV2Enabled) { + try { + const extensions = await getExtensions(false); // force refresh to get latest + console.log('extensions {}', extensions); + setAvailableExtensions(extensions || []); + } catch (error) { + console.error('Failed to load extensions:', error); + } + } else { + const userSettingsStr = localStorage.getItem('user_settings'); + if (userSettingsStr) { + const userSettings = JSON.parse(userSettingsStr); + setAvailableExtensions(userSettings.extensions || []); + } + } + }; + loadExtensions(); + // Intentionally omitting getExtensions from deps to avoid refresh loops + // eslint-disable-next-line + }, []); + + const handleExtensionToggle = (id: string) => { + console.log('Toggling extension:', id); + setSelectedExtensions((prev) => { + const isSelected = prev.includes(id); + const newState = isSelected ? prev.filter((extId) => extId !== id) : [...prev, id]; + return newState; + }); + }; + + const handleAddActivity = () => { + if (newActivity.trim()) { + setActivities((prev) => [...prev, newActivity.trim()]); + setNewActivity(''); + } + }; + + const handleRemoveActivity = (activity: string) => { + setActivities((prev) => prev.filter((a) => a !== activity)); + }; + + const getCurrentConfig = (): Recipe => { + console.log('Creating config with:', { + selectedExtensions, + availableExtensions, + recipeConfig, + }); + + const config = { + ...recipeConfig, + title, + description, + instructions, + activities, + extensions: selectedExtensions + .map((name) => { + const extension = availableExtensions.find((e) => e.name === name); + console.log('Looking for extension:', name, 'Found:', extension); + if (!extension) return null; + + // Create a clean copy of the extension configuration + const cleanExtension = { ...extension }; + delete cleanExtension.enabled; + + // If the extension has env_keys, preserve keys but clear values + if (cleanExtension.env_keys) { + cleanExtension.env_keys = Object.fromEntries( + Object.keys(cleanExtension.env_keys).map((key) => [key, '']) + ); + } + + return cleanExtension; + }) + .filter(Boolean) as FullExtensionConfig[], + }; + console.log('Final config extensions:', config.extensions); + return config; + }; + + const deeplink = generateDeepLink(getCurrentConfig()); + + // Render expanded section content + const renderSectionContent = () => { + switch (activeSection) { + case 'activities': + return ( +
+ +
+ +
+
+

Activities

+

+ The top-line prompts and activities that will display within your goose home page. +

+
+
+
+ {activities.map((activity, index) => ( +
100 ? activity : undefined} + > + {activity.length > 100 ? activity.slice(0, 100) + '...' : activity} + +
+ ))} +
+
+ setNewActivity(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleAddActivity()} + className="flex-1 px-4 py-3 bg-bgSubtle text-textStandard rounded-xl placeholder-textPlaceholder focus:outline-none focus:ring-2 focus:ring-borderProminent" + placeholder="Add new activity..." + /> + +
+
+
+ ); + + case 'instructions': + return ( +
+ +
+ +
+
+

Instructions

+

+ Hidden instructions that will be passed to the provider to help direct and add + context to your responses. +

+
+