ui: clean up toasts and errors (#1872)

Co-authored-by: Alex Hancock <alexhancock@block.xyz>
This commit is contained in:
Lily Delalande
2025-03-27 11:07:27 -04:00
committed by GitHub
parent 369acf8fe6
commit afef3b1af7
24 changed files with 1015 additions and 739 deletions

View File

@@ -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) => {
// 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,17 +183,24 @@ 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<any, any> = {}) => {

View File

@@ -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,

View File

@@ -27,7 +27,6 @@ import {
getTextContent,
createAssistantMessage,
} from '../types/message';
import { ToastSuccess } from './settings/models/toasts';
export interface ChatType {
id: string;

View File

@@ -178,8 +178,20 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ 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 <ConfigContext.Provider value={contextValue}>{children}</ConfigContext.Provider>;
};

View File

@@ -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,

View File

@@ -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`,
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 });
}
};

View File

@@ -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(
<div>
{title ? <strong className="font-medium">{title}</strong> : null}
{title ? <div>{msg}</div> : null}
</div>,
{ ...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(
<div className="flex gap-4">
<div className="flex-grow">
{title ? <strong className="font-medium">{title}</strong> : null}
{msg ? <div>{msg}</div> : null}
</div>
<div className="flex-none flex items-center">
{traceback ? (
<button
className="text-textProminentInverse font-medium"
onClick={() => navigator.clipboard.writeText(traceback)}
>
Copy error
</button>
) : null}
</div>
</div>,
{ ...commonToastOptions, autoClose: traceback ? false : 5000, ...toastOptions }
);
}
type ToastLoadingProps = { title?: string; msg?: string; toastOptions?: ToastOptions };
export function ToastLoading({ title, msg, toastOptions }: ToastLoadingProps) {
return toast.loading(
<div>
{title ? <strong className="font-medium">{title}</strong> : null}
{title ? <div>{msg}</div> : null}
</div>,
{ ...commonToastOptions, autoClose: false, ...toastOptions }
);
}
type ToastInfoProps = { title?: string; msg?: string; toastOptions?: ToastOptions };
export function ToastInfo({ title, msg, toastOptions }: ToastInfoProps) {
return toast.info(
<div>
{title ? <strong className="font-medium">{title}</strong> : null}
{msg ? <div>{msg}</div> : null}
</div>,
{ ...commonToastOptions, ...toastOptions }
);
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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';

View File

@@ -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();

View File

@@ -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<Response> {
// 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<any> {
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<Response> {
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<Response> {
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<string> {
const binaryPathMap: Record<string, string> = {
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;
}

View File

@@ -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<void>
): Promise<void> {
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<void>
): Promise<void> {
// Call with an empty list to ensure all built-ins are added
await syncBuiltInExtensions([], addExtensionFn);
}

View File

@@ -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<void>,
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;
}
}

View File

@@ -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<void>;
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<void> {
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<void>;
extensionConfig: ExtensionConfig;
}
/**
* Adds an extension to the agent during application startup with retry logic
*/
export async function addToAgentOnStartup({
addToConfig,
extensionConfig,
}: AddToAgentOnStartupProps): Promise<void> {
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<void>;
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<void>;
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<void>;
}
/**
* 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;
}
}

View File

@@ -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<string, string> = {
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<void>;
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<void> {
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<void>;
extensionConfig: ExtensionConfig;
}
export async function addToAgentOnStartup({
addToConfig,
extensionConfig,
}: addToAgentOnStartupProps): Promise<void> {
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<void>;
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<void>;
}
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<void>;
}
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<void>,
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<void>
): Promise<void> {
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<void>
): Promise<void> {
// Call with an empty list to ensure all built-ins are added
await syncBuiltInExtensions([], addExtensionFn);
}
{
/* Agent-related helper functions */
}
async function extensionApiCall<T>(
endpoint: string,
payload: any,
actionType: 'adding' | 'removing',
extensionName: string
): Promise<Response> {
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<Response> {
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<Response> {
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';

View File

@@ -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';

View File

@@ -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,14 +11,24 @@ 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<string | null>(null);
const [model, setModel] = useState<string>('');
const { read, getProviders } = useConfig();
// Use a ref to prevent multiple loads
const isLoadingRef = useRef(false);
const isLoadedRef = useRef(false);
useEffect(() => {
const currentModel = async () => {
// Prevent the effect from running again if it's already loading or loaded
if (isLoadingRef.current || isLoadedRef.current) return;
// Mark as loading
isLoadingRef.current = true;
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);
@@ -27,22 +37,39 @@ export default function ModelsSection({ setView }: ModelsSectionProps) {
const providerDetailsList = providers.filter((provider) => provider.name === gooseProvider);
if (providerDetailsList.length != 1) {
ToastError({
toastError({
title: UNKNOWN_PROVIDER_TITLE,
msg: UNKNOWN_PROVIDER_MSG,
});
setModel(gooseModel);
setProvider(gooseProvider);
return;
}
} 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;
}
};
(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 (
<section id="models">

View File

@@ -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,
});

View File

@@ -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,

183
ui/desktop/src/toasts.tsx Normal file
View File

@@ -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(
<div>
{title ? <strong className="font-medium">{title}</strong> : null}
{title ? <div>{msg}</div> : null}
</div>,
{ ...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(
<div className="flex gap-4">
<div className="flex-grow">
{title ? <strong className="font-medium">{title}</strong> : null}
{msg ? <div>{msg}</div> : null}
</div>
<div className="flex-none flex items-center">
{traceback ? (
<button
className="text-textProminentInverse font-medium"
onClick={() => navigator.clipboard.writeText(traceback)}
>
Copy error
</button>
) : null}
</div>
</div>,
{ ...commonToastOptions, autoClose: traceback ? false : 5000, ...toastOptions }
);
}
type ToastLoadingProps = {
title?: string;
msg?: string;
toastOptions?: ToastOptions;
};
export function toastLoading({ title, msg, toastOptions }: ToastLoadingProps) {
return toast.loading(
<div>
{title ? <strong className="font-medium">{title}</strong> : null}
{title ? <div>{msg}</div> : null}
</div>,
{ ...commonToastOptions, autoClose: false, ...toastOptions }
);
}
type ToastInfoProps = {
title?: string;
msg?: string;
toastOptions?: ToastOptions;
};
export function toastInfo({ title, msg, toastOptions }: ToastInfoProps) {
return toast.info(
<div>
{title ? <strong className="font-medium">{title}</strong> : null}
{msg ? <div>{msg}</div> : null}
</div>,
{ ...commonToastOptions, ...toastOptions }
);
}