From 49dc396c83666f6bcc9b9c436fe21377ceea6306 Mon Sep 17 00:00:00 2001 From: Lily Delalande <119957291+lily-de@users.noreply.github.com> Date: Thu, 13 Mar 2025 19:40:39 -0700 Subject: [PATCH] ui: load builtins (#1679) --- crates/goose-cli/src/commands/configure.rs | 12 +-- crates/goose-server/src/openapi.rs | 1 - crates/goose/src/config/extensions.rs | 5 +- ui/desktop/openapi.json | 2 +- ui/desktop/src/App.tsx | 42 ++++++++- ui/desktop/src/built-in-extensions.json | 4 +- ui/desktop/src/components/ConfigContext.tsx | 17 ++-- .../extensions/ExtensionsSection.tsx | 26 +++--- .../settings_v2/extensions/LoadBuiltins.tsx | 90 +++++++++++++++++++ 9 files changed, 164 insertions(+), 35 deletions(-) create mode 100644 ui/desktop/src/components/settings_v2/extensions/LoadBuiltins.tsx diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 887a7bc6..f0f3e425 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -422,27 +422,27 @@ pub fn configure_extensions_dialog() -> Result<(), Box> { "built-in" => { let extension = cliclack::select("Which built-in extension would you like to enable?") .item( - "developer", + "Developer Tools", "Developer Tools", "Code editing and shell access", ) .item( - "computercontroller", + "Computer Controller", "Computer Controller", "controls for webscraping, file caching, and automations", ) .item( - "google_drive", + "Google Drive", "Google Drive", "Search and read content from google drive - additional config required", ) .item( - "memory", + "Memory", "Memory", "Tools to save and retrieve durable memories", ) .item( - "tutorial", + "Tutorial", "Tutorial", "Access interactive tutorials and guides", ) @@ -454,7 +454,7 @@ pub fn configure_extensions_dialog() -> Result<(), Box> { .placeholder(&goose::config::DEFAULT_EXTENSION_TIMEOUT.to_string()) .validate(|input: &String| match input.parse::() { Ok(_) => Ok(()), - Err(_) => Err("Please enter a valide timeout"), + Err(_) => Err("Please enter a valid timeout"), }) .interact()?; diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 3f53e81a..d97d58a1 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -26,7 +26,6 @@ use goose::providers::base::ProviderMetadata; super::routes::config_management::ConfigKeyQuery, super::routes::config_management::ConfigResponse, super::routes::config_management::ProvidersResponse, - super::routes::config_management::ProvidersResponse, super::routes::config_management::ProviderDetails, super::routes::config_management::ExtensionResponse, super::routes::config_management::ExtensionQuery, diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index d044f12c..d34f10c9 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -16,7 +16,10 @@ pub struct ExtensionEntry { } pub fn name_to_key(name: &str) -> String { - name.to_string() + name.chars() + .filter(|c| !c.is_whitespace()) + .collect::() + .to_lowercase() } /// Extension configuration management diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 491f9c99..0b929249 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "1.0.12" + "version": "1.0.13" }, "paths": { "/config": { diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 3d6c37ea..4c874616 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { addExtensionFromDeepLink } from './extensions'; import { getStoredModel } from './utils/providerUtils'; import { getStoredProvider, initializeSystem } from './utils/providerUtils'; @@ -24,6 +24,11 @@ import ProviderSettings from './components/settings_v2/providers/ProviderSetting import { useChat } from './hooks/useChat'; import 'react-toastify/dist/ReactToastify.css'; +import { useConfig } from './components/ConfigContext'; +import { + initializeBuiltInExtensions, + syncBuiltInExtensions, +} from './components/settings_v2/extensions/LoadBuiltins'; // Views and their options export type View = @@ -57,6 +62,41 @@ export default function App() { view: 'welcome', viewOptions: {}, }); + const { getExtensions, addExtension } = useConfig(); + const initAttemptedRef = useRef(false); + + useEffect(() => { + // Skip if feature flag is not enabled + if (!process.env.ALPHA) { + return; + } + + const setupExtensions = async () => { + try { + // Set the ref immediately to prevent duplicate runs + initAttemptedRef.current = true; + + // Force refresh extensions from the backend to ensure we have the latest + const refreshedExtensions = await getExtensions(true); + + if (refreshedExtensions.length === 0) { + // If we still have no extensions, this is truly a first-time setup + console.log('First-time setup: Adding all built-in extensions...'); + await initializeBuiltInExtensions(addExtension); + } else { + // Extensions exist, check for any missing built-ins + console.log('Checking for missing built-in extensions...'); + console.log(refreshedExtensions); + await syncBuiltInExtensions(refreshedExtensions, addExtension); + } + } catch (error) { + console.error('Error setting up extensions:', error); + } + }; + + setupExtensions(); + }, []); // Empty dependency array since we're using initAttemptedRef + const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); const [isLoadingSession, setIsLoadingSession] = useState(false); diff --git a/ui/desktop/src/built-in-extensions.json b/ui/desktop/src/built-in-extensions.json index ee620bf2..27066768 100644 --- a/ui/desktop/src/built-in-extensions.json +++ b/ui/desktop/src/built-in-extensions.json @@ -1,7 +1,7 @@ [ { - "id": "developer", - "name": "Developer", + "id": "developertools", + "name": "Developer Tools", "description": "General development tools useful for software engineering.", "enabled": true, "type": "builtin", diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index 80379643..cb425e66 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -43,7 +43,7 @@ interface ConfigContextType { toggleExtension: (name: string) => Promise; removeExtension: (name: string) => Promise; getProviders: (b: boolean) => Promise; - getExtensions: (b: boolean) => Promise + getExtensions: (b: boolean) => Promise; } interface ConfigProviderProps { @@ -74,12 +74,11 @@ export const ConfigProvider: React.FC = ({ children }) => { // Load extensions try { - const extensionsResponse = await apiGetExtensions() - setExtensionsList(extensionsResponse.data.extensions) + const extensionsResponse = await apiGetExtensions(); + setExtensionsList(extensionsResponse.data.extensions); } catch (error) { - console.error('Failed to load extensions:', error) + console.error('Failed to load extensions:', error); } - })(); }, []); @@ -125,7 +124,7 @@ export const ConfigProvider: React.FC = ({ children }) => { }; const removeExtension = async (name: string) => { - await apiRemoveExtension({path: {name: name}}); + await apiRemoveExtension({ path: { name: name } }); await reloadConfig(); }; @@ -133,13 +132,13 @@ export const ConfigProvider: React.FC = ({ children }) => { const query: ExtensionQuery = { name, config, enabled }; await apiUpdateExtension({ body: query, - path: {name: name} + path: { name: name }, }); await reloadConfig(); }; const toggleExtension = async (name: string) => { - await apiToggleExtension({path: {name: name}}); + await apiToggleExtension({ path: { name: name } }); await reloadConfig(); }; @@ -158,7 +157,7 @@ export const ConfigProvider: React.FC = ({ children }) => { if (forceRefresh || extensionsList.length === 0) { // If a refresh is forced, or we don't have providers yet const response = await apiGetExtensions(); - const extensionResponse: ExtensionResponse = response.data + const extensionResponse: ExtensionResponse = response.data; setExtensionsList(extensionResponse.extensions); return extensionResponse.extensions; } diff --git a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx index 344fcd93..ece4fafd 100644 --- a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx @@ -74,21 +74,19 @@ export default function ExtensionsSection() { }); useEffect(() => { - const extensions = read('extensions', false) + const extensions = read('extensions', false); if (extensions) { - const extensionItems: ExtensionItem[] = Object.entries(extensions).map( - ([name, ext]) => { - const extensionConfig = ext as ExtensionConfig; - return { - id: name, - title: getFriendlyTitle(name), - subtitle: getSubtitle(extensionConfig), - enabled: extensionConfig.enabled, - canConfigure: extensionConfig.type === 'stdio' && !!extensionConfig.envs, - config: extensionConfig, - }; - } - ); + const extensionItems: ExtensionItem[] = Object.entries(extensions).map(([name, ext]) => { + const extensionConfig = ext as ExtensionConfig; + return { + id: name, + title: getFriendlyTitle(name), + subtitle: getSubtitle(extensionConfig), + enabled: extensionConfig.enabled, + canConfigure: extensionConfig.type === 'stdio' && !!extensionConfig.envs, + config: extensionConfig, + }; + }); setExtensions(extensionItems); } }, [read]); diff --git a/ui/desktop/src/components/settings_v2/extensions/LoadBuiltins.tsx b/ui/desktop/src/components/settings_v2/extensions/LoadBuiltins.tsx new file mode 100644 index 00000000..1df11913 --- /dev/null +++ b/ui/desktop/src/components/settings_v2/extensions/LoadBuiltins.tsx @@ -0,0 +1,90 @@ +import type { ExtensionConfig } from '../../../api/types.gen'; +import builtInExtensionsData from '../../../built-in-extensions.json'; +import { FixedExtensionEntry } from '@/src/components/ConfigContext'; + +// Type definition for built-in extensions from JSON +type BuiltinExtension = { + id: string; + name: string; + description: string; + enabled: boolean; + type: 'builtin'; + env_keys: string[]; + timeout?: number; +}; + +// TODO: need to keep this in sync better with `name_to_key` on the rust side +function nameToKey(name: string): string { + return name + .split('') + .filter((char) => !char.match(/\s/)) + .join('') + .toLowerCase(); +} + +/** + * Synchronizes built-in extensions with the config system. + * This function ensures all built-in extensions are added, which is especially + * important for first-time users with an empty config.yaml. + * + * @param existingExtensions Current list of extensions from the config (could be empty) + * @param addExtensionFn Function to add a new extension to the config + * @returns Promise that resolves when sync is complete + */ +export async function syncBuiltInExtensions( + existingExtensions: FixedExtensionEntry[], + addExtensionFn: (name: string, config: ExtensionConfig, enabled: boolean) => Promise +): Promise { + try { + console.log('Setting up built-in extensions... in syncBuiltinExtensions'); + + // Create a set of existing extension IDs for quick lookup + const existingExtensionKeys = new Set(existingExtensions.map((ext) => nameToKey(ext.name))); + console.log('existing extension ids', existingExtensionKeys); + + // Cast the imported JSON data to the expected type + const builtinExtensions = builtInExtensionsData as BuiltinExtension[]; + + // Track how many extensions were added + let addedCount = 0; + + // Check each built-in extension + for (const builtinExt of builtinExtensions) { + // Only add if the extension doesn't already exist + if (!existingExtensionKeys.has(builtinExt.id)) { + console.log(`Adding built-in extension: ${builtinExt.id}`); + + // Convert to the ExtensionConfig format + const extConfig: ExtensionConfig = { + name: builtinExt.name, + type: 'builtin', + timeout: builtinExt.timeout ?? 300, + }; + + // Add the extension with its default enabled state + await addExtensionFn(nameToKey(builtinExt.name), extConfig, builtinExt.enabled); + addedCount++; + } + } + + if (addedCount > 0) { + console.log(`Added ${addedCount} built-in extensions.`); + } else { + console.log('All built-in extensions already present.'); + } + } catch (error) { + console.error('Failed to add built-in extensions:', error); + throw error; + } +} + +/** + * Function to initialize all built-in extensions for a first-time user. + * This can be called when the application is first installed. + */ +export async function initializeBuiltInExtensions( + addExtensionFn: (name: string, config: ExtensionConfig, enabled: boolean) => Promise +): Promise { + // Call with an empty list to ensure all built-ins are added + await syncBuiltInExtensions([], addExtensionFn); +}