diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index f5a03186..35cad707 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -9,6 +9,7 @@ import { getDefaultModel } from './components/settings/models/hardcoded_stuff'; import ErrorScreen from './components/ErrorScreen'; import { ConfirmationModal } from './components/ui/ConfirmationModal'; import { ToastContainer } from 'react-toastify'; +import { toastService } from './toasts'; import { extractExtensionName } from './components/settings/extensions/utils'; import { GoosehintsModal } from './components/GoosehintsModal'; import { SessionDetails, fetchSessionDetails } from './sessions'; @@ -98,29 +99,36 @@ export default function App() { console.log(`Found provider: ${provider}, model: ${model}, setting chat view`); setView('chat'); - // Initialize the system in background - initializeSystem(provider, model) - .then(() => console.log('System initialization successful')) - .catch((error) => { - console.error('Error initializing system:', error); - setFatalError(`System initialization error: ${error.message || 'Unknown error'}`); - setView('welcome'); - }); + // Initialize the system and wait for it to complete before setting up extensions + try { + console.log('Initializing system before setting up extensions...'); + await initializeSystem(provider, model); + console.log('System initialization successful'); + // Now that the agent is initialized, we can safely set up extensions + return true; + } catch (error) { + console.error('Error initializing system:', error); + setFatalError(`System initialization error: ${error.message || 'Unknown error'}`); + setView('welcome'); + return false; + } } else { // Missing configuration, show onboarding console.log('Missing configuration, showing onboarding'); if (!provider) console.log('Missing provider'); if (!model) console.log('Missing model'); setView('welcome'); + return false; } } catch (error) { console.error('Error checking configuration:', error); setFatalError(`Configuration check error: ${error.message || 'Unknown error'}`); setView('welcome'); + return false; } }; - // Setup extensions in parallel + // Setup extensions after agent is initialized const setupExtensions = async () => { // Set the ref immediately to prevent duplicate runs initAttemptedRef.current = true; @@ -175,18 +183,25 @@ export default function App() { } console.log('Extensions setup complete'); + + // Reset the toast service silent flag to ensure toasts work after startup + toastService.configure({ silent: false }); }; - // Execute the two flows in parallel for speed - checkRequiredConfig().catch((error) => { - console.error('Unhandled error in checkRequiredConfig:', error); - setFatalError(`Config check error: ${error.message || 'Unknown error'}`); - }); - - setupExtensions().catch((error) => { - console.error('Unhandled error in setupExtensions:', error); - // Not setting fatal error here since extensions are optional - }); + // Execute the flows sequentially to ensure agent is initialized before adding extensions + checkRequiredConfig() + .then((agentInitialized) => { + // Only proceed with extension setup if agent was successfully initialized + if (agentInitialized) { + return setupExtensions(); + } + console.log('Skipping extension setup because agent was not initialized'); + return Promise.resolve(); + }) + .catch((error) => { + console.error('Unhandled error in startup sequence:', error); + setFatalError(`Startup error: ${error.message || 'Unknown error'}`); + }); }, []); // Empty dependency array since we're using initAttemptedRef const setView = (view: View, viewOptions: Record = {}) => { console.log(`Setting view to: ${view}`, viewOptions); diff --git a/ui/desktop/src/agent/UpdateAgent.tsx b/ui/desktop/src/agent/UpdateAgent.tsx index d44d7413..a049ff83 100644 --- a/ui/desktop/src/agent/UpdateAgent.tsx +++ b/ui/desktop/src/agent/UpdateAgent.tsx @@ -4,12 +4,7 @@ import { ExtensionConfig } from '../api'; import { toast } from 'react-toastify'; import React, { useState } from 'react'; import { initializeAgent as startAgent, replaceWithShims } from './utils'; -import { - ToastError, - ToastInfo, - ToastLoading, - ToastSuccess, -} from '../components/settings/models/toasts'; +import { toastError, toastInfo, toastLoading, toastSuccess } from '../toasts'; // extensionUpdate = an extension was newly added or updated so we should attempt to add it @@ -31,7 +26,7 @@ export const useAgent = () => { return true; } catch (error) { console.error('Failed to initialize agent:', error); - ToastError({ + toastError({ title: 'Failed to initialize agent', traceback: error instanceof Error ? error.message : 'Unknown error', }); @@ -91,12 +86,12 @@ export const useAgent = () => { try { let toastId; if (!silent) { - toastId = ToastLoading({ + toastId = toastLoading({ title: extension.name, msg: 'Adding extension...', toastOptions: { position: 'top-center' }, }); - ToastInfo({ + toastInfo({ msg: 'Press the escape key to continue using goose while extension loads', }); } @@ -119,7 +114,7 @@ export const useAgent = () => { if (response.status === 428) { if (!silent) { if (toastId) toast.dismiss(toastId); - ToastError({ + toastError({ msg: 'Agent is not initialized. Please initialize the agent first.', }); } @@ -128,7 +123,7 @@ export const useAgent = () => { if (!silent) { if (toastId) toast.dismiss(toastId); - ToastError({ + toastError({ title: extension.name, msg: 'Failed to add extension', traceback: errorMsg, @@ -152,7 +147,7 @@ export const useAgent = () => { if (!data.error) { if (!silent) { if (toastId) toast.dismiss(toastId); - ToastSuccess({ + toastSuccess({ title: extension.name, msg: 'Successfully added extension', }); @@ -164,7 +159,7 @@ export const useAgent = () => { const errorMessage = `Error adding ${extension.name} extension${data.message ? `. ${data.message}` : ''}`; console.error(errorMessage); if (toastId) toast.dismiss(toastId); - ToastError({ + toastError({ title: extension.name, msg: 'Failed to add extension', traceback: data.message, @@ -175,7 +170,7 @@ export const useAgent = () => { console.log('Got some other error'); const errorMessage = `Failed to add ${extension.name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`; console.error(errorMessage); - ToastError({ + toastError({ title: extension.name, msg: 'Failed to add extension', traceback: error.message, diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index 78bacd5d..ef2e59a9 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -27,7 +27,6 @@ import { getTextContent, createAssistantMessage, } from '../types/message'; -import { ToastSuccess } from './settings/models/toasts'; export interface ChatType { id: string; diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index dda12696..2d1c0581 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -178,8 +178,20 @@ export const ConfigProvider: React.FC = ({ children }) => { getProviders, getExtensions, }), - [config, providersList, extensionsList] - ); // Functions don't need to be dependencies as they don't change + [ + config, + providersList, + extensionsList, + upsert, + read, + remove, + addExtension, + removeExtension, + toggleExtension, + getProviders, + getExtensions, + ] + ); return {children}; }; diff --git a/ui/desktop/src/components/ProviderGrid.tsx b/ui/desktop/src/components/ProviderGrid.tsx index 1b58662d..9ad66b9d 100644 --- a/ui/desktop/src/components/ProviderGrid.tsx +++ b/ui/desktop/src/components/ProviderGrid.tsx @@ -14,7 +14,7 @@ import { initializeSystem } from '../utils/providerUtils'; import { getApiUrl, getSecretKey } from '../config'; import { getActiveProviders, isSecretKey } from './settings/api_keys/utils'; import { BaseProviderGrid, getProviderDescription } from './settings/providers/BaseProviderGrid'; -import { ToastError, ToastSuccess } from './settings/models/toasts'; +import { toastError, toastSuccess } from '../toasts'; interface ProviderGridProps { onSubmit?: () => void; @@ -55,7 +55,7 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) { addRecentModel(model); localStorage.setItem('GOOSE_PROVIDER', providerId); - ToastSuccess({ + toastSuccess({ title: provider.name, msg: `Starting Goose with default model: ${getDefaultModel(provider.name.toLowerCase().replace(/ /g, '_'))}.`, }); @@ -135,7 +135,7 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) { } } - ToastSuccess({ + toastSuccess({ title: provider, msg: isUpdate ? `Successfully updated configuration` : `Successfully added configuration`, }); @@ -147,7 +147,7 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) { setSelectedId(null); } catch (error) { console.error('Error handling modal submit:', error); - ToastError({ + toastError({ title: provider, msg: `Failed to ${providers.find((p) => p.id === selectedId)?.isConfigured ? 'update' : 'add'} configuration`, traceback: error.message, diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index 9e05e837..9b98e745 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -15,7 +15,7 @@ import { RecentModelsRadio } from './models/RecentModels'; import { ExtensionItem } from './extensions/ExtensionItem'; import type { View } from '../../App'; import { ModeSelection } from './basic/ModeSelection'; -import { ToastSuccess } from './models/toasts'; +import { toastSuccess } from '../../toasts'; const EXTENSIONS_DESCRIPTION = 'The Model Context Protocol (MCP) is a system that allows AI models to securely connect with local or remote resources using standard server setups. It works like a client-server setup and expands AI capabilities using three main components: Prompts, Resources, and Tools.'; @@ -164,7 +164,7 @@ export default function SettingsView({ const response = await removeExtension(extensionBeingConfigured.name, true); if (response.ok) { - ToastSuccess({ + toastSuccess({ title: extensionBeingConfigured.name, msg: `Successfully removed extension`, }); diff --git a/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx index 44d9706c..d41b0640 100644 --- a/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx +++ b/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx @@ -5,8 +5,7 @@ import { Input } from '../../ui/input'; import { FullExtensionConfig } from '../../../extensions'; import { getApiUrl, getSecretKey } from '../../../config'; import { addExtension } from '../../../extensions'; -import { toast } from 'react-toastify'; -import { ToastError, ToastSuccess } from '../models/toasts'; +import { toastError, toastSuccess } from '../../../toasts'; interface ConfigureExtensionModalProps { isOpen: boolean; @@ -69,7 +68,7 @@ export function ConfigureBuiltInExtensionModal({ throw new Error('Failed to add system configuration'); } - ToastSuccess({ + toastSuccess({ title: extension.name, msg: `Successfully configured extension`, }); @@ -77,7 +76,7 @@ export function ConfigureBuiltInExtensionModal({ onClose(); } catch (error) { console.error('Error configuring extension:', error); - ToastError({ + toastError({ title: extension.name, msg: `Failed to configure the extension`, traceback: error.message, diff --git a/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx index 71d3fa87..82dcf8ba 100644 --- a/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx +++ b/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx @@ -5,8 +5,7 @@ import { Input } from '../../ui/input'; import { FullExtensionConfig } from '../../../extensions'; import { getApiUrl, getSecretKey } from '../../../config'; import { addExtension } from '../../../extensions'; -import { toast } from 'react-toastify'; -import { ToastError, ToastSuccess } from '../models/toasts'; +import { toastError, toastSuccess } from '../../../toasts'; interface ConfigureExtensionModalProps { isOpen: boolean; @@ -71,7 +70,7 @@ export function ConfigureExtensionModal({ throw new Error('Failed to add system configuration'); } - ToastSuccess({ + toastSuccess({ title: extension.name, msg: `Successfully configured extension`, }); @@ -79,7 +78,7 @@ export function ConfigureExtensionModal({ onClose(); } catch (error) { console.error('Error configuring extension:', error); - ToastError({ + toastError({ title: extension.name, msg: `Failed to configure extension`, traceback: error.message, diff --git a/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx index 0df7cdd5..3c665651 100644 --- a/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx +++ b/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx @@ -7,7 +7,7 @@ import { toast } from 'react-toastify'; import Select from 'react-select'; import { createDarkSelectStyles, darkSelectTheme } from '../../ui/select-styles'; import { getApiUrl, getSecretKey } from '../../../config'; -import { ToastError } from '../models/toasts'; +import { toastError } from '../../../toasts'; interface ManualExtensionModalProps { isOpen: boolean; @@ -39,22 +39,22 @@ export function ManualExtensionModal({ isOpen, onClose, onSubmit }: ManualExtens e.preventDefault(); if (!formData.id || !formData.name || !formData.description) { - ToastError({ title: 'Please fill in all required fields' }); + toastError({ title: 'Please fill in all required fields' }); return; } if (formData.type === 'stdio' && !formData.commandInput) { - ToastError({ title: 'Command is required for stdio type' }); + toastError({ title: 'Command is required for stdio type' }); return; } if (formData.type === 'sse' && !formData.uri) { - ToastError({ title: 'URI is required for SSE type' }); + toastError({ title: 'URI is required for SSE type' }); return; } if (formData.type === 'builtin' && !formData.name) { - ToastError({ title: 'Name is required for builtin type' }); + toastError({ title: 'Name is required for builtin type' }); return; } @@ -99,7 +99,7 @@ export function ManualExtensionModal({ isOpen, onClose, onSubmit }: ManualExtens resetForm(); } catch (error) { console.error('Error configuring extension:', error); - ToastError({ title: 'Failed to configure extension', traceback: error.message }); + toastError({ title: 'Failed to configure extension', traceback: error.message }); } }; diff --git a/ui/desktop/src/components/settings/models/toasts.tsx b/ui/desktop/src/components/settings/models/toasts.tsx deleted file mode 100644 index 307c6c82..00000000 --- a/ui/desktop/src/components/settings/models/toasts.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { toast, ToastOptions } from 'react-toastify'; -import React from 'react'; - -const commonToastOptions: ToastOptions = { - position: 'top-right', - closeButton: false, - hideProgressBar: true, - closeOnClick: true, - pauseOnHover: true, - draggable: true, -}; - -type ToastSuccessProps = { title?: string; msg?: string; toastOptions?: ToastOptions }; -export function ToastSuccess({ title, msg, toastOptions = {} }: ToastSuccessProps) { - return toast.success( -
- {title ? {title} : null} - {title ?
{msg}
: null} -
, - { ...commonToastOptions, autoClose: 3000, ...toastOptions } - ); -} - -type ToastErrorProps = { - title?: string; - msg?: string; - traceback?: string; - toastOptions?: ToastOptions; -}; -export function ToastError({ title, msg, traceback, toastOptions }: ToastErrorProps) { - return toast.error( -
-
- {title ? {title} : null} - {msg ?
{msg}
: null} -
-
- {traceback ? ( - - ) : null} -
-
, - { ...commonToastOptions, autoClose: traceback ? false : 5000, ...toastOptions } - ); -} - -type ToastLoadingProps = { title?: string; msg?: string; toastOptions?: ToastOptions }; -export function ToastLoading({ title, msg, toastOptions }: ToastLoadingProps) { - return toast.loading( -
- {title ? {title} : null} - {title ?
{msg}
: null} -
, - { ...commonToastOptions, autoClose: false, ...toastOptions } - ); -} - -type ToastInfoProps = { title?: string; msg?: string; toastOptions?: ToastOptions }; -export function ToastInfo({ title, msg, toastOptions }: ToastInfoProps) { - return toast.info( -
- {title ? {title} : null} - {msg ?
{msg}
: null} -
, - { ...commonToastOptions, ...toastOptions } - ); -} diff --git a/ui/desktop/src/components/settings/models/utils.tsx b/ui/desktop/src/components/settings/models/utils.tsx index 5af28772..561f5fff 100644 --- a/ui/desktop/src/components/settings/models/utils.tsx +++ b/ui/desktop/src/components/settings/models/utils.tsx @@ -2,7 +2,7 @@ import { useModel } from './ModelContext'; // Import the useModel hook import { Model } from './ModelContext'; import { useMemo } from 'react'; import { gooseModels } from './GooseModels'; -import { ToastError, ToastSuccess } from './toasts'; +import { toastError, toastSuccess } from '../../../toasts'; import { initializeSystem } from '../../../utils/providerUtils'; import { useRecentModels } from './RecentModels'; @@ -32,7 +32,7 @@ export function useHandleModelSelection() { console.log(`[${componentName}] Switched to model: ${model.name} (${model.provider})`); // Display a success toast notification - ToastSuccess({ + toastSuccess({ title: 'Model changed', msg: `Switched to ${model.alias ?? model.name}`, }); @@ -40,7 +40,7 @@ export function useHandleModelSelection() { // Handle errors gracefully console.error(`[${componentName}] Failed to switch model:`, error); // Display an error toast notification - ToastError({ + toastError({ title: model.name, msg: `Failed to switch to model`, traceback: error.message, diff --git a/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx b/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx index 9e190a89..ab3c86b6 100644 --- a/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx @@ -8,7 +8,7 @@ import { toast } from 'react-toastify'; import { getActiveProviders, isSecretKey } from '../api_keys/utils'; import { useModel } from '../models/ModelContext'; import { Button } from '../../ui/button'; -import { ToastError, ToastSuccess } from '../models/toasts'; +import { toastError, toastSuccess } from '../../../toasts'; function ConfirmationModal({ message, onConfirm, onCancel }) { return ( @@ -142,7 +142,7 @@ export function ConfigureProvidersGrid() { } } - ToastSuccess({ + toastSuccess({ title: provider, msg: isUpdate ? `Successfully updated configuration` : `Successfully added configuration`, }); @@ -155,7 +155,7 @@ export function ConfigureProvidersGrid() { setModalMode('setup'); } catch (error) { console.error('Error handling modal submit:', error); - ToastError({ + toastError({ title: provider, msg: `Failed to ${providers.find((p) => p.id === selectedForSetup)?.isConfigured ? 'update' : 'add'} configuration`, traceback: error.message, @@ -181,7 +181,7 @@ export function ConfigureProvidersGrid() { // Check if the selected provider is currently active if (currentModel?.provider === providerToDelete.name) { const msg = `Cannot delete the configuration because it's the provider of the current model (${currentModel.name}). Please switch to a different model first.`; - ToastError({ title: providerToDelete.name, msg, traceback: msg }); + toastError({ title: providerToDelete.name, msg, traceback: msg }); setIsConfirmationOpen(false); return; } @@ -209,7 +209,7 @@ export function ConfigureProvidersGrid() { } console.log('Configuration deleted successfully.'); - ToastSuccess({ + toastSuccess({ title: providerToDelete.name, msg: 'Successfully deleted configuration', }); @@ -218,7 +218,7 @@ export function ConfigureProvidersGrid() { setActiveKeys(updatedKeys); } catch (error) { console.error('Error deleting configuration:', error); - ToastError({ + toastError({ title: providerToDelete.name, msg: 'Failed to delete configuration', traceback: error.message, diff --git a/ui/desktop/src/components/settings_v2/SettingsView.tsx b/ui/desktop/src/components/settings_v2/SettingsView.tsx index 32f33b90..3850e4e2 100644 --- a/ui/desktop/src/components/settings_v2/SettingsView.tsx +++ b/ui/desktop/src/components/settings_v2/SettingsView.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { ScrollArea } from '../ui/scroll-area'; import BackButton from '../ui/BackButton'; import type { View } from '../../App'; -import { useConfig } from '../ConfigContext'; import ExtensionsSection from './extensions/ExtensionsSection'; import ModelsSection from './models/ModelsSection'; diff --git a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx index 34c007da..82fd5934 100644 --- a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx @@ -48,12 +48,14 @@ export default function ExtensionsSection() { // If extension is enabled, we are trying to toggle if off, otherwise on const toggleDirection = extension.enabled ? 'toggleOff' : 'toggleOn'; const extensionConfig = extractExtensionConfig(extension); + await toggleExtension({ toggle: toggleDirection, extensionConfig: extensionConfig, addToConfig: addExtension, - removeFromConfig: removeExtension, + toastOptions: { silent: false }, }); + await fetchExtensions(); // Refresh the list after toggling }; @@ -64,8 +66,6 @@ export default function ExtensionsSection() { const handleAddExtension = async (formData: ExtensionFormData) => { const extensionConfig = createExtensionConfig(formData); - // TODO: replace activateExtension in index - // TODO: make sure error handling works await activateExtension({ addToConfig: addExtension, extensionConfig: extensionConfig }); handleModalClose(); await fetchExtensions(); diff --git a/ui/desktop/src/components/settings_v2/extensions/agent-api.ts b/ui/desktop/src/components/settings_v2/extensions/agent-api.ts new file mode 100644 index 00000000..4f272220 --- /dev/null +++ b/ui/desktop/src/components/settings_v2/extensions/agent-api.ts @@ -0,0 +1,184 @@ +import { ExtensionConfig } from '../../../api/types.gen'; +import { getApiUrl, getSecretKey } from '../../../config'; +import { toastService, ToastServiceOptions } from '../../../toasts'; + +/** + * Makes an API call to the extension endpoints + */ +export async function extensionApiCall( + endpoint: string, + payload: any, + options: ToastServiceOptions = {} +): Promise { + // Configure toast notifications + toastService.configure(options); + + // Determine if we're activating or removing an extension + const isActivating = endpoint == '/extensions/add'; + const action = { + type: isActivating ? 'activating' : 'removing', + verb: isActivating ? 'Activating' : 'Removing', + pastTense: isActivating ? 'activated' : 'removed', + }; + + // for adding the payload is an extensionConfig, for removing payload is just the name + const extensionName = isActivating ? payload.name : payload; + let toastId; + + // Step 1: Show loading toast (only for activation) + if (isActivating) { + toastId = toastService.loading({ + title: extensionName, + msg: `${action.verb} ${extensionName} extension...`, + }); + } + + try { + // Step 2: Make the API call + const response = await fetch(getApiUrl(endpoint), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify(payload), + }); + + // Step 3: Handle non-successful responses + if (!response.ok) { + return handleErrorResponse(response, extensionName, action, toastId); + } + + // Step 4: Parse response data + const data = await parseResponseData(response); + + // Step 5: Check for errors in the response data + if (data.error) { + const errorMessage = `Error ${action.type} extension: ${data.message || 'Unknown error'}`; + toastService.dismiss(toastId); + toastService.error({ + title: extensionName, + msg: errorMessage, + traceback: data.message || 'Unknown error', + }); + throw new Error(errorMessage); + } + + // Step 6: Success - dismiss loading toast and return + toastService.dismiss(toastId); + toastService.success({ + title: extensionName, + msg: `Successfully ${action.pastTense} extension!`, + }); + return response; + } catch (error) { + // Final catch-all error handler + toastService.dismiss(toastId); + console.error(`Error in extensionApiCall for ${extensionName}:`, error); + throw error; + } +} + +// Helper functions to separate concerns + +// Handles HTTP error responses +function handleErrorResponse( + response: Response, + extensionName: string, + action: { type: string; verb: string }, + toastId: string +): never { + const errorMsg = `Server returned ${response.status}: ${response.statusText}`; + console.error(errorMsg); + + // Special case: Agent not initialized (status 428) + if (response.status === 428 && action.type === 'activating') { + toastService.dismiss(toastId); + toastService.error({ + title: extensionName, + msg: 'Failed to add extension. Goose Agent was still starting up. Please try again.', + traceback: errorMsg, + }); + throw new Error('Agent is not initialized. Please initialize the agent first.'); + } + + // General error case + const msg = `Failed to ${action.type === 'activating' ? 'add' : 'remove'} ${extensionName} extension: ${errorMsg}`; + toastService.dismiss(toastId); + toastService.error({ + title: extensionName, + msg: msg, + traceback: errorMsg, + }); + throw new Error(msg); +} + +// Safely parses JSON response +async function parseResponseData(response: Response): Promise { + try { + const text = await response.text(); + return text ? JSON.parse(text) : { error: false }; + } catch (parseError) { + console.warn('Could not parse response as JSON, assuming success', parseError); + return { error: false }; + } +} + +/** + * Add an extension to the agent + */ +export async function addToAgent( + extension: ExtensionConfig, + options: ToastServiceOptions = {} +): Promise { + try { + if (extension.type === 'stdio') { + extension.cmd = await replaceWithShims(extension.cmd); + } + + return await extensionApiCall('/extensions/add', extension, options); + } catch (error) { + // Check if this is a 428 error and make the message more descriptive + if (error.message && error.message.includes('428')) { + const enhancedError = new Error( + 'Agent is not initialized. Please initialize the agent first. (428 Precondition Required)' + ); + console.error(`Failed to add extension ${extension.name} to agent: ${enhancedError.message}`); + throw enhancedError; + } + + console.error(`Failed to add extension ${extension.name} to agent:`, error); + throw error; + } +} + +/** + * Remove an extension from the agent + */ +export async function removeFromAgent( + name: string, + options: ToastServiceOptions = {} +): Promise { + try { + return await extensionApiCall('/extensions/remove', name, options); + } catch (error) { + console.error(`Failed to remove extension ${name} from agent:`, error); + throw error; + } +} + +// Update the path to the binary based on the command +async function replaceWithShims(cmd: string): Promise { + const binaryPathMap: Record = { + goosed: await window.electron.getBinaryPath('goosed'), + npx: await window.electron.getBinaryPath('npx'), + uvx: await window.electron.getBinaryPath('uvx'), + }; + + if (binaryPathMap[cmd]) { + console.log('--------> Replacing command with shim ------>', cmd, binaryPathMap[cmd]); + return binaryPathMap[cmd]; + } + + return cmd; +} diff --git a/ui/desktop/src/components/settings_v2/extensions/built-in.ts b/ui/desktop/src/components/settings_v2/extensions/built-in.ts new file mode 100644 index 00000000..59431252 --- /dev/null +++ b/ui/desktop/src/components/settings_v2/extensions/built-in.ts @@ -0,0 +1,89 @@ +import type { ExtensionConfig } from '../../../api/types.gen'; +import { FixedExtensionEntry } from '../../ConfigContext'; +import builtInExtensionsData from './built-in-extensions.json'; +import { nameToKey } from './utils'; + +// Type definition for built-in extensions from JSON +type BuiltinExtension = { + id: string; + name: string; + display_name: string; + description: string; + enabled: boolean; + type: 'builtin'; + envs?: { [key: string]: string }; + timeout?: number; +}; + +/** + * 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 -- use the id + if (!existingExtensionKeys.has(builtinExt.id)) { + console.log(`Adding built-in extension: ${builtinExt.id}`); + + // Convert to the ExtensionConfig format + const extConfig: ExtensionConfig = { + name: builtinExt.name, + display_name: builtinExt.display_name, + type: 'builtin', + timeout: builtinExt.timeout ?? 300, + }; + + // Add the extension with its default enabled state + try { + await addExtensionFn(nameToKey(builtinExt.name), extConfig, builtinExt.enabled); + addedCount++; + } catch (error) { + console.error(`Failed to add built-in extension ${builtinExt.name}:`, error); + // Continue with other extensions even if one fails + } + } + } + + 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); +} diff --git a/ui/desktop/src/components/settings_v2/extensions/deeplink.ts b/ui/desktop/src/components/settings_v2/extensions/deeplink.ts new file mode 100644 index 00000000..726e50de --- /dev/null +++ b/ui/desktop/src/components/settings_v2/extensions/deeplink.ts @@ -0,0 +1,107 @@ +import type { ExtensionConfig } from '../../../api/types.gen'; +import { toastService } from '../../../toasts'; +import { activateExtension } from './extension-manager'; +import { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils'; + +/** + * Handles adding an extension from a deeplink URL + */ +export async function addExtensionFromDeepLink( + url: string, + addExtensionFn: ( + name: string, + extensionConfig: ExtensionConfig, + enabled: boolean + ) => Promise, + setView: (view: string, options: { extensionId: string; showEnvVars: boolean }) => void +) { + const parsedUrl = new URL(url); + + if (parsedUrl.protocol !== 'goose:') { + toastService.handleError( + 'Invalid Protocol', + 'Failed to install extension: Invalid protocol: URL must use the goose:// scheme', + { shouldThrow: true } + ); + } + + // Check that all required fields are present and not empty + const requiredFields = ['name']; + + for (const field of requiredFields) { + const value = parsedUrl.searchParams.get(field); + if (!value || value.trim() === '') { + toastService.handleError( + 'Missing Field', + `Failed to install extension: The link is missing required field '${field}'`, + { shouldThrow: true } + ); + } + } + + const cmd = parsedUrl.searchParams.get('cmd'); + if (!cmd) { + toastService.handleError( + 'Missing Command', + "Failed to install extension: Missing required 'cmd' parameter in the URL", + { shouldThrow: true } + ); + } + + // Validate that the command is one of the allowed commands + const allowedCommands = ['jbang', 'npx', 'uvx', 'goosed']; + if (!allowedCommands.includes(cmd)) { + toastService.handleError( + 'Invalid Command', + `Failed to install extension: Invalid command: ${cmd}. Only ${allowedCommands.join(', ')} are allowed.`, + { shouldThrow: true } + ); + } + + // Check for security risk with npx -c command + const args = parsedUrl.searchParams.getAll('arg'); + if (cmd === 'npx' && args.includes('-c')) { + toastService.handleError( + 'Security Risk', + 'Failed to install extension: npx with -c argument can lead to code injection', + { shouldThrow: true } + ); + } + + const envList = parsedUrl.searchParams.getAll('env'); + const name = parsedUrl.searchParams.get('name')!; + const timeout = parsedUrl.searchParams.get('timeout'); + + // Create the extension config + const config: ExtensionConfig = { + name: name, + type: 'stdio', + cmd: cmd, + args: args, + envs: + envList.length > 0 + ? Object.fromEntries( + envList.map((env) => { + const [key] = env.split('='); + return [key, '']; // Initialize with empty string as value + }) + ) + : undefined, + timeout: timeout ? parseInt(timeout, 10) : DEFAULT_EXTENSION_TIMEOUT, + }; + + // Check if extension requires env vars and go to settings if so + if (config.envs && Object.keys(config.envs).length > 0) { + console.log('Environment variables required, redirecting to settings'); + setView('settings', { extensionId: nameToKey(name), showEnvVars: true }); + return; + } + + // If no env vars are required, proceed with adding the extension + try { + await activateExtension({ extensionConfig: config, addToConfig: addExtensionFn }); + } catch (error) { + console.error('Failed to activate extension from deeplink:', error); + throw error; + } +} diff --git a/ui/desktop/src/components/settings_v2/extensions/extension-manager.ts b/ui/desktop/src/components/settings_v2/extensions/extension-manager.ts new file mode 100644 index 00000000..29ba7e56 --- /dev/null +++ b/ui/desktop/src/components/settings_v2/extensions/extension-manager.ts @@ -0,0 +1,265 @@ +import type { ExtensionConfig } from '../../../api/types.gen'; +import { ToastServiceOptions } from '../../../toasts'; +import { addToAgent, removeFromAgent } from './agent-api'; + +interface ActivateExtensionProps { + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + extensionConfig: ExtensionConfig; +} + +/** + * Activates an extension by adding it to both the config system and the API. + * @param props The extension activation properties + * @returns Promise that resolves when activation is complete + */ +export async function activateExtension({ + addToConfig, + extensionConfig, +}: ActivateExtensionProps): Promise { + try { + // AddToAgent + await addToAgent(extensionConfig); + } catch (error) { + console.error('Failed to add extension to agent:', error); + // add to config with enabled = false + await addToConfig(extensionConfig.name, extensionConfig, false); + // Rethrow the error to inform the caller + throw error; + } + + // Then add to config + try { + await addToConfig(extensionConfig.name, extensionConfig, true); + } catch (error) { + console.error('Failed to add extension to config:', error); + // remove from Agent + try { + await removeFromAgent(extensionConfig.name); + } catch (removeError) { + console.error('Failed to remove extension from agent after config failure:', removeError); + } + // Rethrow the error to inform the caller + throw error; + } +} + +interface AddToAgentOnStartupProps { + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + extensionConfig: ExtensionConfig; +} + +/** + * Adds an extension to the agent during application startup with retry logic + */ +export async function addToAgentOnStartup({ + addToConfig, + extensionConfig, +}: AddToAgentOnStartupProps): Promise { + const MAX_RETRIES = 3; + const RETRY_DELAY = 1000; // 1 second delay between retries + + let retries = 0; + + while (retries <= MAX_RETRIES) { + try { + // Use silent mode for startup + await addToAgent(extensionConfig, { silent: true, showEscMessage: false }); + // If successful, break out of the retry loop + break; + } catch (error) { + console.log(`Attempt ${retries + 1} failed when adding extension to agent:`, error); + + // Check if this is a 428 error (agent not initialized) + const is428Error = + error.message && + (error.message.includes('428') || + error.message.includes('Precondition Required') || + error.message.includes('Agent is not initialized')); + + // retry adding a few times if agent is spinning up + if (is428Error && retries < MAX_RETRIES) { + // This is a 428 error and we have retries left + retries++; + console.log( + `Agent not initialized yet. Retrying in ${RETRY_DELAY}ms... (${retries}/${MAX_RETRIES})` + ); + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); + continue; + } + + // Either not a 428 error or we've exhausted retries + console.error('Failed to add to agent after retries or due to other error:', error); + + // update config with enabled = false because we weren't able to install the extension + try { + await toggleExtension({ + toggle: 'toggleOff', + extensionConfig, + addToConfig, + toastOptions: { silent: true }, // on startup, let extensions fail silently + }); + } catch (toggleError) { + console.error('Failed to toggle extension off after agent error:', toggleError); + } + + // Rethrow the error to inform the caller + throw error; + } + } +} + +interface UpdateExtensionProps { + enabled: boolean; + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + extensionConfig: ExtensionConfig; +} + +/** + * Updates an extension configuration without changing its enabled state + */ +export async function updateExtension({ + enabled, + addToConfig, + extensionConfig, +}: UpdateExtensionProps) { + if (enabled) { + try { + // AddToAgent + await addToAgent(extensionConfig); + } catch (error) { + console.error('[updateExtension]: Failed to add extension to agent during update:', error); + // Failed to add to agent -- show that error to user and do not update the config file + throw error; + } + + // Then add to config + try { + await addToConfig(extensionConfig.name, extensionConfig, enabled); + } catch (error) { + console.error('[updateExtension]: Failed to update extension in config:', error); + throw error; + } + } else { + try { + await addToConfig(extensionConfig.name, extensionConfig, enabled); + } catch (error) { + console.error('[updateExtension]: Failed to update disabled extension in config:', error); + throw error; + } + } +} + +interface ToggleExtensionProps { + toggle: 'toggleOn' | 'toggleOff'; + extensionConfig: ExtensionConfig; + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + toastOptions?: ToastServiceOptions; +} + +/** + * Toggles an extension between enabled and disabled states + */ +export async function toggleExtension({ + toggle, + extensionConfig, + addToConfig, + toastOptions = {}, +}: ToggleExtensionProps) { + // disabled to enabled + if (toggle == 'toggleOn') { + try { + // add to agent with toast options + await addToAgent(extensionConfig, { + ...toastOptions, + // For toggle operations, we want to show toast but no ESC message + showEscMessage: false, + }); + } catch (error) { + console.error('Error adding extension to agent. Will try to toggle back off. Error:', error); + try { + await toggleExtension({ + toggle: 'toggleOff', + extensionConfig, + addToConfig, + toastOptions: { silent: true }, // otherwise we will see a toast for removing something that was never added + }); + } catch (toggleError) { + console.error('Failed to toggle extension off after agent error:', toggleError); + } + throw error; + } + + // update the config + try { + await addToConfig(extensionConfig.name, extensionConfig, true); + } catch (error) { + console.error('Failed to update config after enabling extension:', error); + // remove from agent + try { + await removeFromAgent(extensionConfig.name, toastOptions); + } catch (removeError) { + console.error('Failed to remove extension from agent after config failure:', removeError); + } + throw error; + } + } else if (toggle == 'toggleOff') { + // enabled to disabled + let agentRemoveError = null; + try { + await removeFromAgent(extensionConfig.name, toastOptions); + } catch (error) { + // note there was an error, but attempt to remove from config anyway + console.error('Error removing extension from agent', extensionConfig.name, error); + agentRemoveError = error; + } + + // update the config + try { + await addToConfig(extensionConfig.name, extensionConfig, false); + } catch (error) { + console.error('Error removing extension from config', extensionConfig.name, 'Error:', error); + throw error; + } + + // If we had an error removing from agent but succeeded updating config, still throw the original error + if (agentRemoveError) { + throw agentRemoveError; + } + } +} + +interface DeleteExtensionProps { + name: string; + removeFromConfig: (name: string) => Promise; +} + +/** + * Deletes an extension completely from both agent and config + */ +export async function deleteExtension({ name, removeFromConfig }: DeleteExtensionProps) { + // remove from agent + let agentRemoveError = null; + try { + await removeFromAgent(name); + } catch (error) { + console.error('Failed to remove extension from agent during deletion:', error); + agentRemoveError = error; + } + + try { + await removeFromConfig(name); + } catch (error) { + console.error( + 'Failed to remove extension from config after removing from agent. Error:', + error + ); + // If we also had an agent remove error, log it but throw the config error as it's more critical + throw error; + } + + // If we had an error removing from agent but succeeded removing from config, still throw the original error + if (agentRemoveError) { + throw agentRemoveError; + } +} diff --git a/ui/desktop/src/components/settings_v2/extensions/index.ts b/ui/desktop/src/components/settings_v2/extensions/index.ts index 7da160f2..baff99e4 100644 --- a/ui/desktop/src/components/settings_v2/extensions/index.ts +++ b/ui/desktop/src/components/settings_v2/extensions/index.ts @@ -1,556 +1,20 @@ -import type { ExtensionConfig } from '../../../api/types.gen'; -import builtInExtensionsData from './built-in-extensions.json'; -import { FixedExtensionEntry } from '../../ConfigContext'; -import { getApiUrl, getSecretKey } from '../../../config'; -import { toast } from 'react-toastify'; -import { ToastError, ToastLoading, ToastSuccess } from '../../settings/models/toasts'; +// Export public API +export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils'; -// Default extension timeout in seconds -// TODO: keep in sync with rust better -export const DEFAULT_EXTENSION_TIMEOUT = 300; +// Export extension management functions +export { + activateExtension, + addToAgentOnStartup, + updateExtension, + toggleExtension, + deleteExtension, +} from './extension-manager'; -// Type definition for built-in extensions from JSON -type BuiltinExtension = { - id: string; - name: string; - display_name: string; - description: string; - enabled: boolean; - type: 'builtin'; - envs?: { [key: string]: string }; - timeout?: number; -}; +// Export built-in extension functions +export { syncBuiltInExtensions, initializeBuiltInExtensions } from './built-in'; -// 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(); -} +// Export deeplink handling +export { addExtensionFromDeepLink } from './deeplink'; -function handleError(message: string, shouldThrow = false): void { - ToastError({ - title: 'Error', - msg: message, - traceback: message, - }); - console.error(message); - if (shouldThrow) { - throw new Error(message); - } -} - -// Update the path to the binary based on the command -async function replaceWithShims(cmd: string) { - const binaryPathMap: Record = { - goosed: await window.electron.getBinaryPath('goosed'), - npx: await window.electron.getBinaryPath('npx'), - uvx: await window.electron.getBinaryPath('uvx'), - }; - - if (binaryPathMap[cmd]) { - console.log('--------> Replacing command with shim ------>', cmd, binaryPathMap[cmd]); - cmd = binaryPathMap[cmd]; - } - - return cmd; -} - -interface activateExtensionProps { - addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; - extensionConfig: ExtensionConfig; -} - -/** - * Activates an extension by adding it to both the config system and the API. - * @param name The extension name - * @param config The extension configuration - * @param addExtensionFn Function to add extension to config - * @returns Promise that resolves when activation is complete - */ -export async function activateExtension({ - addToConfig, - extensionConfig, -}: activateExtensionProps): Promise { - try { - // AddToAgent - await AddToAgent(extensionConfig); - } catch (error) { - console.error('Failed to add extension to agent:', error); - // add to config with enabled = false - await addToConfig(extensionConfig.name, extensionConfig, false); - // Rethrow the error to inform the caller - throw error; - } - - // Then add to config - try { - await addToConfig(extensionConfig.name, extensionConfig, true); - } catch (error) { - console.error('Failed to add extension to config:', error); - // remove from Agent - try { - await RemoveFromAgent(extensionConfig.name); - } catch (removeError) { - console.error('Failed to remove extension from agent after config failure:', removeError); - } - // Rethrow the error to inform the caller - throw error; - } -} - -interface addToAgentOnStartupProps { - addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; - extensionConfig: ExtensionConfig; -} - -export async function addToAgentOnStartup({ - addToConfig, - extensionConfig, -}: addToAgentOnStartupProps): Promise { - try { - // AddToAgent - await AddToAgent(extensionConfig); - } catch (error) { - console.log('got error trying to add to agent in addAgentOnStartUp', error); - // update config with enabled = false - try { - await toggleExtension({ toggle: 'toggleOff', extensionConfig, addToConfig }); - } catch (toggleError) { - console.error('Failed to toggle extension off after agent error:', toggleError); - } - // Rethrow the error to inform the caller - throw error; - } -} - -interface updateExtensionProps { - enabled: boolean; - addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; - extensionConfig: ExtensionConfig; -} - -// updating -- no change to enabled state -export async function updateExtension({ - enabled, - addToConfig, - extensionConfig, -}: updateExtensionProps) { - if (enabled) { - try { - // AddToAgent - await AddToAgent(extensionConfig); - } catch (error) { - console.error('Failed to add extension to agent during update:', error); - // Failed to add to agent -- show that error to user and do not update the config file - throw error; - } - - // Then add to config - try { - await addToConfig(extensionConfig.name, extensionConfig, enabled); - } catch (error) { - console.error('Failed to update extension in config:', error); - throw error; - } - } else { - try { - await addToConfig(extensionConfig.name, extensionConfig, enabled); - } catch (error) { - console.error('Failed to update disabled extension in config:', error); - throw error; - } - } -} - -interface toggleExtensionProps { - toggle: 'toggleOn' | 'toggleOff'; - extensionConfig: ExtensionConfig; - addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; -} - -export async function toggleExtension({ - toggle, - extensionConfig, - addToConfig, -}: toggleExtensionProps) { - // disabled to enabled - if (toggle == 'toggleOn') { - try { - // add to agent - await AddToAgent(extensionConfig); - } catch (error) { - console.error('Error adding extension to agent. Will try to toggle back off. Error:', error); - try { - await toggleExtension({ toggle: 'toggleOff', extensionConfig, addToConfig }); - } catch (toggleError) { - console.error('Failed to toggle extension off after agent error:', toggleError); - } - throw error; - } - - // update the config - try { - await addToConfig(extensionConfig.name, extensionConfig, true); - } catch (error) { - console.error('Failed to update config after enabling extension:', error); - // remove from agent - try { - await RemoveFromAgent(extensionConfig.name); - } catch (removeError) { - console.error('Failed to remove extension from agent after config failure:', removeError); - } - throw error; - } - } else if (toggle == 'toggleOff') { - // enabled to disabled - let agentRemoveError = null; - try { - await RemoveFromAgent(extensionConfig.name); - } catch (error) { - // note there was an error, but attempt to remove from config anyway - console.error('Error removing extension from agent', extensionConfig.name, error); - agentRemoveError = error; - } - - // update the config - try { - await addToConfig(extensionConfig.name, extensionConfig, false); - } catch (error) { - console.error('Error removing extension from config', extensionConfig.name, 'Error:', error); - throw error; - } - - // If we had an error removing from agent but succeeded updating config, still throw the original error - if (agentRemoveError) { - throw agentRemoveError; - } - } -} - -interface deleteExtensionProps { - name: string; - removeFromConfig: (name: string) => Promise; -} - -export async function deleteExtension({ name, removeFromConfig }: deleteExtensionProps) { - // remove from agent - let agentRemoveError = null; - try { - await RemoveFromAgent(name); - } catch (error) { - console.error('Failed to remove extension from agent during deletion:', error); - agentRemoveError = error; - } - - try { - await removeFromConfig(name); - } catch (error) { - console.error( - 'Failed to remove extension from config after removing from agent. Error:', - error - ); - // If we also had an agent remove error, log it but throw the config error as it's more critical - throw error; - } - - // If we had an error removing from agent but succeeded removing from config, still throw the original error - if (agentRemoveError) { - throw agentRemoveError; - } -} - -{ - /*Deeplinks*/ -} - -export async function addExtensionFromDeepLink( - url: string, - addExtensionFn: ( - name: string, - extensionConfig: ExtensionConfig, - enabled: boolean - ) => Promise, - setView: (view: string, options: { extensionId: string; showEnvVars: boolean }) => void -) { - const parsedUrl = new URL(url); - - if (parsedUrl.protocol !== 'goose:') { - handleError( - 'Failed to install extension: Invalid protocol: URL must use the goose:// scheme', - true - ); - } - - // Check that all required fields are present and not empty - const requiredFields = ['name']; - - for (const field of requiredFields) { - const value = parsedUrl.searchParams.get(field); - if (!value || value.trim() === '') { - handleError( - `Failed to install extension: The link is missing required field '${field}'`, - true - ); - } - } - - const cmd = parsedUrl.searchParams.get('cmd'); - if (!cmd) { - handleError("Failed to install extension: Missing required 'cmd' parameter in the URL", true); - } - - // Validate that the command is one of the allowed commands - const allowedCommands = ['jbang', 'npx', 'uvx', 'goosed']; - if (!allowedCommands.includes(cmd)) { - handleError( - `Failed to install extension: Invalid command: ${cmd}. Only ${allowedCommands.join(', ')} are allowed.`, - true - ); - } - - // Check for security risk with npx -c command - const args = parsedUrl.searchParams.getAll('arg'); - if (cmd === 'npx' && args.includes('-c')) { - handleError( - 'Failed to install extension: npx with -c argument can lead to code injection', - true - ); - } - - const envList = parsedUrl.searchParams.getAll('env'); - const name = parsedUrl.searchParams.get('name')!; - const timeout = parsedUrl.searchParams.get('timeout'); - - // Create the extension config - const config: ExtensionConfig = { - name: name, - type: 'stdio', - cmd: cmd, - args: args, - envs: - envList.length > 0 - ? Object.fromEntries( - envList.map((env) => { - const [key] = env.split('='); - return [key, '']; // Initialize with empty string as value - }) - ) - : undefined, - timeout: timeout ? parseInt(timeout, 10) : DEFAULT_EXTENSION_TIMEOUT, - }; - - // Check if extension requires env vars and go to settings if so - if (config.envs && Object.keys(config.envs).length > 0) { - console.log('Environment variables required, redirecting to settings'); - setView('settings', { extensionId: nameToKey(name), showEnvVars: true }); - return; - } - - // If no env vars are required, proceed with adding the extension - try { - await activateExtension({ extensionConfig: config, addToConfig: addExtensionFn }); - } catch (error) { - console.error('Failed to activate extension from deeplink:', error); - throw error; - } -} - -{ - /*Built ins*/ -} - -/** - * 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 -- use the id - if (!existingExtensionKeys.has(builtinExt.id)) { - console.log(`Adding built-in extension: ${builtinExt.id}`); - - // Convert to the ExtensionConfig format - const extConfig: ExtensionConfig = { - name: builtinExt.name, - display_name: builtinExt.display_name, - type: 'builtin', - timeout: builtinExt.timeout ?? 300, - }; - - // Add the extension with its default enabled state - try { - await addExtensionFn(nameToKey(builtinExt.name), extConfig, builtinExt.enabled); - addedCount++; - } catch (error) { - console.error(`Failed to add built-in extension ${builtinExt.name}:`, error); - // Continue with other extensions even if one fails - } - } - } - - 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); -} - -{ - /* Agent-related helper functions */ -} -async function extensionApiCall( - endpoint: string, - payload: any, - actionType: 'adding' | 'removing', - extensionName: string -): Promise { - let toastId; - const actionVerb = actionType === 'adding' ? 'Adding' : 'Removing'; - const pastVerb = actionType === 'adding' ? 'added' : 'removed'; - - try { - if (actionType === 'adding') { - // Show loading toast - toastId = ToastLoading({ - title: extensionName, - msg: `${actionVerb} ${extensionName} extension...`, - }); - // FIXME: this also shows when toggling -- should only show when you have modal up (fix: diff message for toggling) - toast.info( - 'Press the ESC key on your keyboard to continue using goose while extension loads' - ); - } - - const response = await fetch(getApiUrl(endpoint), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify(payload), - }); - - // Handle non-OK responses - if (!response.ok) { - const errorMsg = `Server returned ${response.status}: ${response.statusText}`; - console.error(errorMsg); - - // Special handling for 428 Precondition Required (agent not initialized) - if (response.status === 428 && actionType === 'adding') { - if (toastId) toast.dismiss(toastId); - ToastError({ - title: extensionName, - msg: 'Agent is not initialized. Please initialize the agent first.', - traceback: errorMsg, - }); - throw new Error('Agent is not initialized. Please initialize the agent first.'); - } - - const msg = `Failed to ${actionType === 'adding' ? 'add' : 'remove'} ${extensionName} extension: ${errorMsg}`; - console.error(msg); - - if (toastId) toast.dismiss(toastId); - ToastError({ - title: extensionName, - msg: msg, - traceback: errorMsg, - }); - throw new Error(msg); - } - - // Parse response JSON safely - let data; - try { - const text = await response.text(); - data = text ? JSON.parse(text) : { error: false }; - } catch (parseError) { - console.warn('Could not parse response as JSON, assuming success', parseError); - data = { error: false }; - } - - if (!data.error) { - if (toastId) toast.dismiss(toastId); - ToastSuccess({ title: extensionName, msg: `Successfully ${pastVerb} extension` }); - return response; - } else { - const errorMessage = `Error ${actionType} extension -- parsing data: ${data.message || 'Unknown error'}`; - console.error(errorMessage); - if (toastId) toast.dismiss(toastId); - ToastError({ - title: extensionName, - msg: errorMessage, - traceback: data.message || 'Unknown error', // why data.message not data.error? - }); - throw new Error(errorMessage); - } - } catch (error) { - if (toastId) toast.dismiss(toastId); - console.error(`Error in extensionApiCall for ${extensionName}:`, error); - throw error; - } -} - -// Public functions -export async function AddToAgent(extension: ExtensionConfig): Promise { - try { - if (extension.type === 'stdio') { - console.log('extension command', extension.cmd); - extension.cmd = await replaceWithShims(extension.cmd); - console.log('next ext command', extension.cmd); - } - - return await extensionApiCall('/extensions/add', extension, 'adding', extension.name); - } catch (error) { - console.error(`Failed to add extension ${extension.name} to agent:`, error); - throw error; - } -} - -export async function RemoveFromAgent(name: string): Promise { - try { - return await extensionApiCall('/extensions/remove', name, 'removing', name); - } catch (error) { - console.error(`Failed to remove extension ${name} from agent:`, error); - throw error; - } -} +// Export agent API functions +export { addToAgent as AddToAgent, removeFromAgent as RemoveFromAgent } from './agent-api'; diff --git a/ui/desktop/src/components/settings_v2/extensions/utils.ts b/ui/desktop/src/components/settings_v2/extensions/utils.ts index ecc5ba4e..45cd7ad8 100644 --- a/ui/desktop/src/components/settings_v2/extensions/utils.ts +++ b/ui/desktop/src/components/settings_v2/extensions/utils.ts @@ -1,3 +1,19 @@ +// Default extension timeout in seconds +// TODO: keep in sync with rust better +export const DEFAULT_EXTENSION_TIMEOUT = 300; + +/** + * Converts an extension name to a key format + * TODO: need to keep this in sync better with `name_to_key` on the rust side + */ +export function nameToKey(name: string): string { + return name + .split('') + .filter((char) => !char.match(/\s/)) + .join('') + .toLowerCase(); +} + import { FixedExtensionEntry } from '../../ConfigContext'; import { ExtensionConfig } from '../../../api/types.gen'; diff --git a/ui/desktop/src/components/settings_v2/models/ModelsSection.tsx b/ui/desktop/src/components/settings_v2/models/ModelsSection.tsx index 0e988e76..005d7738 100644 --- a/ui/desktop/src/components/settings_v2/models/ModelsSection.tsx +++ b/ui/desktop/src/components/settings_v2/models/ModelsSection.tsx @@ -1,8 +1,8 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import type { View } from '../../../App'; import ModelSettingsButtons from './subcomponents/ModelSettingsButtons'; import { useConfig } from '../../ConfigContext'; -import { ToastError } from '../../settings/models/toasts'; +import { toastError } from '../../../toasts'; interface ModelsSectionProps { setView: (view: View) => void; @@ -11,38 +11,65 @@ interface ModelsSectionProps { const UNKNOWN_PROVIDER_TITLE = 'Provider name error'; const UNKNOWN_PROVIDER_MSG = 'Unknown provider in config -- please inspect your config.yaml'; -// todo: use for block settings export default function ModelsSection({ setView }: ModelsSectionProps) { const [provider, setProvider] = useState(null); const [model, setModel] = useState(''); const { read, getProviders } = useConfig(); + // Use a ref to prevent multiple loads + const isLoadingRef = useRef(false); + const isLoadedRef = useRef(false); + useEffect(() => { - const currentModel = async () => { - const gooseModel = (await read('GOOSE_MODEL', false)) as string; - const gooseProvider = (await read('GOOSE_PROVIDER', false)) as string; - const providers = await getProviders(true); + // Prevent the effect from running again if it's already loading or loaded + if (isLoadingRef.current || isLoadedRef.current) return; - // lookup display name - const providerDetailsList = providers.filter((provider) => provider.name === gooseProvider); + // Mark as loading + isLoadingRef.current = true; - if (providerDetailsList.length != 1) { - ToastError({ - title: UNKNOWN_PROVIDER_TITLE, - msg: UNKNOWN_PROVIDER_MSG, - }); - setModel(gooseModel); - setProvider(gooseProvider); - return; + const loadModelData = async () => { + try { + const gooseModel = (await read('GOOSE_MODEL', false)) as string; + const gooseProvider = (await read('GOOSE_PROVIDER', false)) as string; + const providers = await getProviders(true); + + // lookup display name + const providerDetailsList = providers.filter((provider) => provider.name === gooseProvider); + + if (providerDetailsList.length != 1) { + toastError({ + title: UNKNOWN_PROVIDER_TITLE, + msg: UNKNOWN_PROVIDER_MSG, + }); + setModel(gooseModel); + setProvider(gooseProvider); + } else { + const providerDisplayName = providerDetailsList[0].metadata.display_name; + setModel(gooseModel); + setProvider(providerDisplayName); + } + + // Mark as loaded and not loading + isLoadedRef.current = true; + isLoadingRef.current = false; + } catch (error) { + console.error('Error loading model data:', error); + isLoadingRef.current = false; } - const providerDisplayName = providerDetailsList[0].metadata.display_name; - setModel(gooseModel); - setProvider(providerDisplayName); }; - (async () => { - await currentModel(); - })(); - }, [getProviders, read]); + + loadModelData(); + + // Clean up function + return () => { + isLoadingRef.current = false; + isLoadedRef.current = false; + }; + + // Run this effect only once when the component mounts + // We're using refs to control the actual execution, so we don't need dependencies + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return (
diff --git a/ui/desktop/src/components/settings_v2/models/index.ts b/ui/desktop/src/components/settings_v2/models/index.ts index 950a6205..95a7ac39 100644 --- a/ui/desktop/src/components/settings_v2/models/index.ts +++ b/ui/desktop/src/components/settings_v2/models/index.ts @@ -1,5 +1,5 @@ import { initializeAgent } from '../../../agent/index'; -import { ToastError, ToastSuccess } from '../../settings/models/toasts'; +import { toastError, toastSuccess } from '../../../toasts'; import { ProviderDetails } from '@/src/api'; // titles @@ -29,8 +29,7 @@ export async function changeModel({ model, provider, writeToConfig }: changeMode await initializeAgent({ model: model, provider: provider }); } catch (error) { console.error(`Failed to change model at agent step -- ${model} ${provider}`); - // show toast with error - ToastError({ + toastError({ title: CHANGE_MODEL_TOAST_TITLE, msg: SWITCH_MODEL_AGENT_ERROR_MSG, traceback: error, @@ -44,8 +43,7 @@ export async function changeModel({ model, provider, writeToConfig }: changeMode await writeToConfig('GOOSE_MODEL', model, false); } catch (error) { console.error(`Failed to change model at config step -- ${model} ${provider}`); - // show toast with error - ToastError({ + toastError({ title: CHANGE_MODEL_TOAST_TITLE, msg: CONFIG_UPDATE_ERROR_MSG, traceback: error, @@ -54,7 +52,7 @@ export async function changeModel({ model, provider, writeToConfig }: changeMode // TODO: reset agent to use current config settings } finally { // show toast - ToastSuccess({ + toastSuccess({ title: CHANGE_MODEL_TOAST_TITLE, msg: `${SWITCH_MODEL_SUCCESS_MSG} -- using ${model} from ${provider}`, }); @@ -73,8 +71,7 @@ export async function startAgentFromConfig({ readFromConfig }: startAgentFromCon try { modelProvider = await getCurrentModelAndProvider({ readFromConfig: readFromConfig }); } catch (error) { - // show toast with error - ToastError({ + toastError({ title: START_AGENT_TITLE, msg: CONFIG_READ_MODEL_ERROR_MSG, traceback: error, @@ -91,16 +88,14 @@ export async function startAgentFromConfig({ readFromConfig }: startAgentFromCon await initializeAgent({ model: model, provider: provider }); } catch (error) { console.error(`Failed to change model at agent step -- ${model} ${provider}`); - // show toast with error - ToastError({ + toastError({ title: CHANGE_MODEL_TOAST_TITLE, msg: SWITCH_MODEL_AGENT_ERROR_MSG, traceback: error, }); return; } finally { - // success toast - ToastSuccess({ + toastSuccess({ title: CHANGE_MODEL_TOAST_TITLE, msg: `${INITIALIZE_SYSTEM_WITH_MODEL_SUCCESS_MSG} with ${model} from ${provider}`, }); @@ -148,7 +143,7 @@ export async function getCurrentModelAndProviderForDisplay({ const providerDetailsList = providers.filter((provider) => provider.name === gooseProvider); if (providerDetailsList.length != 1) { - ToastError({ + toastError({ title: UNKNOWN_PROVIDER_TITLE, msg: UNKNOWN_PROVIDER_MSG, }); diff --git a/ui/desktop/src/extensions.tsx b/ui/desktop/src/extensions.tsx index 611b1abe..b9ebc871 100644 --- a/ui/desktop/src/extensions.tsx +++ b/ui/desktop/src/extensions.tsx @@ -5,7 +5,7 @@ import { type SettingsViewOptions } from './components/settings/SettingsView'; import { toast } from 'react-toastify'; import builtInExtensionsData from './built-in-extensions.json'; -import { ToastError, ToastLoading, ToastSuccess } from './components/settings/models/toasts'; +import { toastError, toastLoading, toastSuccess } from './toasts'; import { Toast } from 'react-toastify/dist/components'; // Hardcoded default extension timeout in seconds @@ -84,7 +84,7 @@ export async function addExtension( }; let toastId; - if (!silent) toastId = ToastLoading({ title: extension.name, msg: 'Adding extension...' }); + if (!silent) toastId = toastLoading({ title: extension.name, msg: 'Adding extension...' }); const response = await fetch(getApiUrl('/extensions/add'), { method: 'POST', @@ -100,7 +100,7 @@ export async function addExtension( if (!data.error) { if (!silent) { if (toastId) toast.dismiss(toastId); - ToastSuccess({ title: extension.name, msg: `Successfully enabled extension` }); + toastSuccess({ title: extension.name, msg: `Successfully enabled extension` }); } return response; } @@ -118,7 +118,7 @@ export async function addExtension( } if (toastId) toast.dismiss(toastId); - ToastError({ + toastError({ title: extension.name, msg: errorMessage, traceback: data.message, @@ -129,7 +129,7 @@ export async function addExtension( } catch (error) { const errorMessage = `Failed to add ${extension.name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`; console.error(errorMessage); - ToastError({ + toastError({ title: extension.name, msg: 'Failed to add extension', traceback: error.message, @@ -154,14 +154,14 @@ export async function removeExtension(name: string, silent: boolean = false): Pr if (!data.error) { if (!silent) { - ToastSuccess({ title: name, msg: 'Successfully disabled extension' }); + toastSuccess({ title: name, msg: 'Successfully disabled extension' }); } return response; } const errorMessage = `Error removing ${name} extension${data.message ? `. ${data.message}` : ''}`; console.error(errorMessage); - ToastError({ + toastError({ title: name, msg: 'Error removing extension', traceback: data.message, @@ -171,7 +171,7 @@ export async function removeExtension(name: string, silent: boolean = false): Pr } catch (error) { const errorMessage = `Failed to remove ${name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`; console.error(errorMessage); - ToastError({ + toastError({ title: name, msg: 'Error removing extension', traceback: error.message, @@ -257,7 +257,7 @@ function envVarsRequired(config: ExtensionConfig) { } function handleError(message: string, shouldThrow = false): void { - ToastError({ + toastError({ title: 'Failed to install extension', msg: message, traceback: message, diff --git a/ui/desktop/src/toasts.tsx b/ui/desktop/src/toasts.tsx new file mode 100644 index 00000000..1075a736 --- /dev/null +++ b/ui/desktop/src/toasts.tsx @@ -0,0 +1,183 @@ +import { toast, ToastOptions } from 'react-toastify'; +import React from 'react'; + +export interface ToastServiceOptions { + silent?: boolean; + showEscMessage?: boolean; + shouldThrow?: boolean; +} + +export default class ToastService { + private silent: boolean = false; + private showEscMessage: boolean = true; + private shouldThrow: boolean = false; + + // Create a singleton instance + private static instance: ToastService; + + public static getInstance(): ToastService { + if (!ToastService.instance) { + ToastService.instance = new ToastService(); + } + return ToastService.instance; + } + + configure(options: ToastServiceOptions = {}): void { + console.log('ToastService.configure called with options:', options); + if (options.silent !== undefined) { + console.log(`Setting silent from ${this.silent} to ${options.silent}`); + this.silent = options.silent; + } + if (options.showEscMessage !== undefined) { + console.log( + `Setting showEscMessage from ${this.showEscMessage} to ${options.showEscMessage}` + ); + this.showEscMessage = options.showEscMessage; + } + if (options.shouldThrow !== undefined) { + console.log(`Setting shouldThrow from ${this.shouldThrow} to ${options.shouldThrow}`); + this.shouldThrow = options.shouldThrow; + } + } + + error({ title, msg, traceback }: { title: string; msg: string; traceback: string }): void { + console.log(`ToastService.error called - silent=${this.silent}`, { title, msg }); + if (!this.silent) { + toastError({ title, msg, traceback }); + } else { + console.log('Toast suppressed because silent=true'); + } + console.error(msg, traceback); + + if (this.shouldThrow) { + throw new Error(msg); + } + } + + loading({ title, msg }: { title: string; msg: string }): string | number | undefined { + console.log(`ToastService.loading called - silent=${this.silent}`, { title, msg }); + if (this.silent) { + console.log('Toast suppressed because silent=true'); + return undefined; + } + + const toastId = toastLoading({ title, msg }); + + if (this.showEscMessage) { + toast.info( + 'Press the ESC key on your keyboard to continue using goose while extension loads' + ); + } + return toastId; + } + + success({ title, msg }: { title: string; msg: string }): void { + console.log(`ToastService.success called - silent=${this.silent}`, { title, msg }); + if (this.silent) { + console.log('Toast suppressed because silent=true'); + return; + } + toastSuccess({ title, msg }); + } + + dismiss(toastId?: string | number): void { + if (toastId) toast.dismiss(toastId); + } + + /** + * Handle errors with consistent logging and toast notifications + * Consolidates the functionality of the original handleError function + */ + handleError(title: string, message: string, options: ToastServiceOptions = {}): void { + this.configure(options); + this.error({ + title: title || 'Error', + msg: message, + traceback: message, + }); + } +} + +// Export a singleton instance for use throughout the app +export const toastService = ToastService.getInstance(); + +const commonToastOptions: ToastOptions = { + position: 'top-right', + closeButton: false, + hideProgressBar: true, + closeOnClick: true, + pauseOnHover: true, + draggable: true, +}; + +type ToastSuccessProps = { title?: string; msg?: string; toastOptions?: ToastOptions }; +export function toastSuccess({ title, msg, toastOptions = {} }: ToastSuccessProps) { + return toast.success( +
+ {title ? {title} : null} + {title ?
{msg}
: null} +
, + { ...commonToastOptions, autoClose: 3000, ...toastOptions } + ); +} + +type ToastErrorProps = { + title?: string; + msg?: string; + traceback?: string; + toastOptions?: ToastOptions; +}; + +export function toastError({ title, msg, traceback, toastOptions }: ToastErrorProps) { + return toast.error( +
+
+ {title ? {title} : null} + {msg ?
{msg}
: null} +
+
+ {traceback ? ( + + ) : null} +
+
, + { ...commonToastOptions, autoClose: traceback ? false : 5000, ...toastOptions } + ); +} + +type ToastLoadingProps = { + title?: string; + msg?: string; + toastOptions?: ToastOptions; +}; + +export function toastLoading({ title, msg, toastOptions }: ToastLoadingProps) { + return toast.loading( +
+ {title ? {title} : null} + {title ?
{msg}
: null} +
, + { ...commonToastOptions, autoClose: false, ...toastOptions } + ); +} + +type ToastInfoProps = { + title?: string; + msg?: string; + toastOptions?: ToastOptions; +}; + +export function toastInfo({ title, msg, toastOptions }: ToastInfoProps) { + return toast.info( +
+ {title ? {title} : null} + {msg ?
{msg}
: null} +
, + { ...commonToastOptions, ...toastOptions } + ); +}