mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-19 15:14:21 +01:00
ui: clean up toasts and errors (#1872)
Co-authored-by: Alex Hancock <alexhancock@block.xyz>
This commit is contained in:
@@ -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> = {}) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
getTextContent,
|
||||
createAssistantMessage,
|
||||
} from '../types/message';
|
||||
import { ToastSuccess } from './settings/models/toasts';
|
||||
|
||||
export interface ChatType {
|
||||
id: string;
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
184
ui/desktop/src/components/settings_v2/extensions/agent-api.ts
Normal file
184
ui/desktop/src/components/settings_v2/extensions/agent-api.ts
Normal 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;
|
||||
}
|
||||
89
ui/desktop/src/components/settings_v2/extensions/built-in.ts
Normal file
89
ui/desktop/src/components/settings_v2/extensions/built-in.ts
Normal 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);
|
||||
}
|
||||
107
ui/desktop/src/components/settings_v2/extensions/deeplink.ts
Normal file
107
ui/desktop/src/components/settings_v2/extensions/deeplink.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
183
ui/desktop/src/toasts.tsx
Normal 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 }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user