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 ErrorScreen from './components/ErrorScreen';
|
||||||
import { ConfirmationModal } from './components/ui/ConfirmationModal';
|
import { ConfirmationModal } from './components/ui/ConfirmationModal';
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
import { toastService } from './toasts';
|
||||||
import { extractExtensionName } from './components/settings/extensions/utils';
|
import { extractExtensionName } from './components/settings/extensions/utils';
|
||||||
import { GoosehintsModal } from './components/GoosehintsModal';
|
import { GoosehintsModal } from './components/GoosehintsModal';
|
||||||
import { SessionDetails, fetchSessionDetails } from './sessions';
|
import { SessionDetails, fetchSessionDetails } from './sessions';
|
||||||
@@ -98,29 +99,36 @@ export default function App() {
|
|||||||
console.log(`Found provider: ${provider}, model: ${model}, setting chat view`);
|
console.log(`Found provider: ${provider}, model: ${model}, setting chat view`);
|
||||||
setView('chat');
|
setView('chat');
|
||||||
|
|
||||||
// Initialize the system in background
|
// Initialize the system and wait for it to complete before setting up extensions
|
||||||
initializeSystem(provider, model)
|
try {
|
||||||
.then(() => console.log('System initialization successful'))
|
console.log('Initializing system before setting up extensions...');
|
||||||
.catch((error) => {
|
await initializeSystem(provider, model);
|
||||||
console.error('Error initializing system:', error);
|
console.log('System initialization successful');
|
||||||
setFatalError(`System initialization error: ${error.message || 'Unknown error'}`);
|
// Now that the agent is initialized, we can safely set up extensions
|
||||||
setView('welcome');
|
return true;
|
||||||
});
|
} catch (error) {
|
||||||
|
console.error('Error initializing system:', error);
|
||||||
|
setFatalError(`System initialization error: ${error.message || 'Unknown error'}`);
|
||||||
|
setView('welcome');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Missing configuration, show onboarding
|
// Missing configuration, show onboarding
|
||||||
console.log('Missing configuration, showing onboarding');
|
console.log('Missing configuration, showing onboarding');
|
||||||
if (!provider) console.log('Missing provider');
|
if (!provider) console.log('Missing provider');
|
||||||
if (!model) console.log('Missing model');
|
if (!model) console.log('Missing model');
|
||||||
setView('welcome');
|
setView('welcome');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking configuration:', error);
|
console.error('Error checking configuration:', error);
|
||||||
setFatalError(`Configuration check error: ${error.message || 'Unknown error'}`);
|
setFatalError(`Configuration check error: ${error.message || 'Unknown error'}`);
|
||||||
setView('welcome');
|
setView('welcome');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Setup extensions in parallel
|
// Setup extensions after agent is initialized
|
||||||
const setupExtensions = async () => {
|
const setupExtensions = async () => {
|
||||||
// Set the ref immediately to prevent duplicate runs
|
// Set the ref immediately to prevent duplicate runs
|
||||||
initAttemptedRef.current = true;
|
initAttemptedRef.current = true;
|
||||||
@@ -175,18 +183,25 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Extensions setup complete');
|
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
|
// Execute the flows sequentially to ensure agent is initialized before adding extensions
|
||||||
checkRequiredConfig().catch((error) => {
|
checkRequiredConfig()
|
||||||
console.error('Unhandled error in checkRequiredConfig:', error);
|
.then((agentInitialized) => {
|
||||||
setFatalError(`Config check error: ${error.message || 'Unknown error'}`);
|
// Only proceed with extension setup if agent was successfully initialized
|
||||||
});
|
if (agentInitialized) {
|
||||||
|
return setupExtensions();
|
||||||
setupExtensions().catch((error) => {
|
}
|
||||||
console.error('Unhandled error in setupExtensions:', error);
|
console.log('Skipping extension setup because agent was not initialized');
|
||||||
// Not setting fatal error here since extensions are optional
|
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
|
}, []); // Empty dependency array since we're using initAttemptedRef
|
||||||
const setView = (view: View, viewOptions: Record<any, any> = {}) => {
|
const setView = (view: View, viewOptions: Record<any, any> = {}) => {
|
||||||
console.log(`Setting view to: ${view}`, viewOptions);
|
console.log(`Setting view to: ${view}`, viewOptions);
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import { ExtensionConfig } from '../api';
|
|||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { initializeAgent as startAgent, replaceWithShims } from './utils';
|
import { initializeAgent as startAgent, replaceWithShims } from './utils';
|
||||||
import {
|
import { toastError, toastInfo, toastLoading, toastSuccess } from '../toasts';
|
||||||
ToastError,
|
|
||||||
ToastInfo,
|
|
||||||
ToastLoading,
|
|
||||||
ToastSuccess,
|
|
||||||
} from '../components/settings/models/toasts';
|
|
||||||
|
|
||||||
// extensionUpdate = an extension was newly added or updated so we should attempt to add it
|
// extensionUpdate = an extension was newly added or updated so we should attempt to add it
|
||||||
|
|
||||||
@@ -31,7 +26,7 @@ export const useAgent = () => {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize agent:', error);
|
console.error('Failed to initialize agent:', error);
|
||||||
ToastError({
|
toastError({
|
||||||
title: 'Failed to initialize agent',
|
title: 'Failed to initialize agent',
|
||||||
traceback: error instanceof Error ? error.message : 'Unknown error',
|
traceback: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
@@ -91,12 +86,12 @@ export const useAgent = () => {
|
|||||||
try {
|
try {
|
||||||
let toastId;
|
let toastId;
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
toastId = ToastLoading({
|
toastId = toastLoading({
|
||||||
title: extension.name,
|
title: extension.name,
|
||||||
msg: 'Adding extension...',
|
msg: 'Adding extension...',
|
||||||
toastOptions: { position: 'top-center' },
|
toastOptions: { position: 'top-center' },
|
||||||
});
|
});
|
||||||
ToastInfo({
|
toastInfo({
|
||||||
msg: 'Press the escape key to continue using goose while extension loads',
|
msg: 'Press the escape key to continue using goose while extension loads',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -119,7 +114,7 @@ export const useAgent = () => {
|
|||||||
if (response.status === 428) {
|
if (response.status === 428) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
if (toastId) toast.dismiss(toastId);
|
if (toastId) toast.dismiss(toastId);
|
||||||
ToastError({
|
toastError({
|
||||||
msg: 'Agent is not initialized. Please initialize the agent first.',
|
msg: 'Agent is not initialized. Please initialize the agent first.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -128,7 +123,7 @@ export const useAgent = () => {
|
|||||||
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
if (toastId) toast.dismiss(toastId);
|
if (toastId) toast.dismiss(toastId);
|
||||||
ToastError({
|
toastError({
|
||||||
title: extension.name,
|
title: extension.name,
|
||||||
msg: 'Failed to add extension',
|
msg: 'Failed to add extension',
|
||||||
traceback: errorMsg,
|
traceback: errorMsg,
|
||||||
@@ -152,7 +147,7 @@ export const useAgent = () => {
|
|||||||
if (!data.error) {
|
if (!data.error) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
if (toastId) toast.dismiss(toastId);
|
if (toastId) toast.dismiss(toastId);
|
||||||
ToastSuccess({
|
toastSuccess({
|
||||||
title: extension.name,
|
title: extension.name,
|
||||||
msg: 'Successfully added extension',
|
msg: 'Successfully added extension',
|
||||||
});
|
});
|
||||||
@@ -164,7 +159,7 @@ export const useAgent = () => {
|
|||||||
const errorMessage = `Error adding ${extension.name} extension${data.message ? `. ${data.message}` : ''}`;
|
const errorMessage = `Error adding ${extension.name} extension${data.message ? `. ${data.message}` : ''}`;
|
||||||
console.error(errorMessage);
|
console.error(errorMessage);
|
||||||
if (toastId) toast.dismiss(toastId);
|
if (toastId) toast.dismiss(toastId);
|
||||||
ToastError({
|
toastError({
|
||||||
title: extension.name,
|
title: extension.name,
|
||||||
msg: 'Failed to add extension',
|
msg: 'Failed to add extension',
|
||||||
traceback: data.message,
|
traceback: data.message,
|
||||||
@@ -175,7 +170,7 @@ export const useAgent = () => {
|
|||||||
console.log('Got some other error');
|
console.log('Got some other error');
|
||||||
const errorMessage = `Failed to add ${extension.name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
const errorMessage = `Failed to add ${extension.name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
console.error(errorMessage);
|
console.error(errorMessage);
|
||||||
ToastError({
|
toastError({
|
||||||
title: extension.name,
|
title: extension.name,
|
||||||
msg: 'Failed to add extension',
|
msg: 'Failed to add extension',
|
||||||
traceback: error.message,
|
traceback: error.message,
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
getTextContent,
|
getTextContent,
|
||||||
createAssistantMessage,
|
createAssistantMessage,
|
||||||
} from '../types/message';
|
} from '../types/message';
|
||||||
import { ToastSuccess } from './settings/models/toasts';
|
|
||||||
|
|
||||||
export interface ChatType {
|
export interface ChatType {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -178,8 +178,20 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
|||||||
getProviders,
|
getProviders,
|
||||||
getExtensions,
|
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>;
|
return <ConfigContext.Provider value={contextValue}>{children}</ConfigContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { initializeSystem } from '../utils/providerUtils';
|
|||||||
import { getApiUrl, getSecretKey } from '../config';
|
import { getApiUrl, getSecretKey } from '../config';
|
||||||
import { getActiveProviders, isSecretKey } from './settings/api_keys/utils';
|
import { getActiveProviders, isSecretKey } from './settings/api_keys/utils';
|
||||||
import { BaseProviderGrid, getProviderDescription } from './settings/providers/BaseProviderGrid';
|
import { BaseProviderGrid, getProviderDescription } from './settings/providers/BaseProviderGrid';
|
||||||
import { ToastError, ToastSuccess } from './settings/models/toasts';
|
import { toastError, toastSuccess } from '../toasts';
|
||||||
|
|
||||||
interface ProviderGridProps {
|
interface ProviderGridProps {
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
@@ -55,7 +55,7 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) {
|
|||||||
addRecentModel(model);
|
addRecentModel(model);
|
||||||
localStorage.setItem('GOOSE_PROVIDER', providerId);
|
localStorage.setItem('GOOSE_PROVIDER', providerId);
|
||||||
|
|
||||||
ToastSuccess({
|
toastSuccess({
|
||||||
title: provider.name,
|
title: provider.name,
|
||||||
msg: `Starting Goose with default model: ${getDefaultModel(provider.name.toLowerCase().replace(/ /g, '_'))}.`,
|
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,
|
title: provider,
|
||||||
msg: isUpdate ? `Successfully updated configuration` : `Successfully added configuration`,
|
msg: isUpdate ? `Successfully updated configuration` : `Successfully added configuration`,
|
||||||
});
|
});
|
||||||
@@ -147,7 +147,7 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) {
|
|||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error handling modal submit:', error);
|
console.error('Error handling modal submit:', error);
|
||||||
ToastError({
|
toastError({
|
||||||
title: provider,
|
title: provider,
|
||||||
msg: `Failed to ${providers.find((p) => p.id === selectedId)?.isConfigured ? 'update' : 'add'} configuration`,
|
msg: `Failed to ${providers.find((p) => p.id === selectedId)?.isConfigured ? 'update' : 'add'} configuration`,
|
||||||
traceback: error.message,
|
traceback: error.message,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { RecentModelsRadio } from './models/RecentModels';
|
|||||||
import { ExtensionItem } from './extensions/ExtensionItem';
|
import { ExtensionItem } from './extensions/ExtensionItem';
|
||||||
import type { View } from '../../App';
|
import type { View } from '../../App';
|
||||||
import { ModeSelection } from './basic/ModeSelection';
|
import { ModeSelection } from './basic/ModeSelection';
|
||||||
import { ToastSuccess } from './models/toasts';
|
import { toastSuccess } from '../../toasts';
|
||||||
|
|
||||||
const EXTENSIONS_DESCRIPTION =
|
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.';
|
'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);
|
const response = await removeExtension(extensionBeingConfigured.name, true);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
ToastSuccess({
|
toastSuccess({
|
||||||
title: extensionBeingConfigured.name,
|
title: extensionBeingConfigured.name,
|
||||||
msg: `Successfully removed extension`,
|
msg: `Successfully removed extension`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { Input } from '../../ui/input';
|
|||||||
import { FullExtensionConfig } from '../../../extensions';
|
import { FullExtensionConfig } from '../../../extensions';
|
||||||
import { getApiUrl, getSecretKey } from '../../../config';
|
import { getApiUrl, getSecretKey } from '../../../config';
|
||||||
import { addExtension } from '../../../extensions';
|
import { addExtension } from '../../../extensions';
|
||||||
import { toast } from 'react-toastify';
|
import { toastError, toastSuccess } from '../../../toasts';
|
||||||
import { ToastError, ToastSuccess } from '../models/toasts';
|
|
||||||
|
|
||||||
interface ConfigureExtensionModalProps {
|
interface ConfigureExtensionModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -69,7 +68,7 @@ export function ConfigureBuiltInExtensionModal({
|
|||||||
throw new Error('Failed to add system configuration');
|
throw new Error('Failed to add system configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
ToastSuccess({
|
toastSuccess({
|
||||||
title: extension.name,
|
title: extension.name,
|
||||||
msg: `Successfully configured extension`,
|
msg: `Successfully configured extension`,
|
||||||
});
|
});
|
||||||
@@ -77,7 +76,7 @@ export function ConfigureBuiltInExtensionModal({
|
|||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error configuring extension:', error);
|
console.error('Error configuring extension:', error);
|
||||||
ToastError({
|
toastError({
|
||||||
title: extension.name,
|
title: extension.name,
|
||||||
msg: `Failed to configure the extension`,
|
msg: `Failed to configure the extension`,
|
||||||
traceback: error.message,
|
traceback: error.message,
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { Input } from '../../ui/input';
|
|||||||
import { FullExtensionConfig } from '../../../extensions';
|
import { FullExtensionConfig } from '../../../extensions';
|
||||||
import { getApiUrl, getSecretKey } from '../../../config';
|
import { getApiUrl, getSecretKey } from '../../../config';
|
||||||
import { addExtension } from '../../../extensions';
|
import { addExtension } from '../../../extensions';
|
||||||
import { toast } from 'react-toastify';
|
import { toastError, toastSuccess } from '../../../toasts';
|
||||||
import { ToastError, ToastSuccess } from '../models/toasts';
|
|
||||||
|
|
||||||
interface ConfigureExtensionModalProps {
|
interface ConfigureExtensionModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -71,7 +70,7 @@ export function ConfigureExtensionModal({
|
|||||||
throw new Error('Failed to add system configuration');
|
throw new Error('Failed to add system configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
ToastSuccess({
|
toastSuccess({
|
||||||
title: extension.name,
|
title: extension.name,
|
||||||
msg: `Successfully configured extension`,
|
msg: `Successfully configured extension`,
|
||||||
});
|
});
|
||||||
@@ -79,7 +78,7 @@ export function ConfigureExtensionModal({
|
|||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error configuring extension:', error);
|
console.error('Error configuring extension:', error);
|
||||||
ToastError({
|
toastError({
|
||||||
title: extension.name,
|
title: extension.name,
|
||||||
msg: `Failed to configure extension`,
|
msg: `Failed to configure extension`,
|
||||||
traceback: error.message,
|
traceback: error.message,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { toast } from 'react-toastify';
|
|||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
import { createDarkSelectStyles, darkSelectTheme } from '../../ui/select-styles';
|
import { createDarkSelectStyles, darkSelectTheme } from '../../ui/select-styles';
|
||||||
import { getApiUrl, getSecretKey } from '../../../config';
|
import { getApiUrl, getSecretKey } from '../../../config';
|
||||||
import { ToastError } from '../models/toasts';
|
import { toastError } from '../../../toasts';
|
||||||
|
|
||||||
interface ManualExtensionModalProps {
|
interface ManualExtensionModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -39,22 +39,22 @@ export function ManualExtensionModal({ isOpen, onClose, onSubmit }: ManualExtens
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!formData.id || !formData.name || !formData.description) {
|
if (!formData.id || !formData.name || !formData.description) {
|
||||||
ToastError({ title: 'Please fill in all required fields' });
|
toastError({ title: 'Please fill in all required fields' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.type === 'stdio' && !formData.commandInput) {
|
if (formData.type === 'stdio' && !formData.commandInput) {
|
||||||
ToastError({ title: 'Command is required for stdio type' });
|
toastError({ title: 'Command is required for stdio type' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.type === 'sse' && !formData.uri) {
|
if (formData.type === 'sse' && !formData.uri) {
|
||||||
ToastError({ title: 'URI is required for SSE type' });
|
toastError({ title: 'URI is required for SSE type' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.type === 'builtin' && !formData.name) {
|
if (formData.type === 'builtin' && !formData.name) {
|
||||||
ToastError({ title: 'Name is required for builtin type' });
|
toastError({ title: 'Name is required for builtin type' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ export function ManualExtensionModal({ isOpen, onClose, onSubmit }: ManualExtens
|
|||||||
resetForm();
|
resetForm();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error configuring extension:', 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 { Model } from './ModelContext';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { gooseModels } from './GooseModels';
|
import { gooseModels } from './GooseModels';
|
||||||
import { ToastError, ToastSuccess } from './toasts';
|
import { toastError, toastSuccess } from '../../../toasts';
|
||||||
import { initializeSystem } from '../../../utils/providerUtils';
|
import { initializeSystem } from '../../../utils/providerUtils';
|
||||||
import { useRecentModels } from './RecentModels';
|
import { useRecentModels } from './RecentModels';
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export function useHandleModelSelection() {
|
|||||||
console.log(`[${componentName}] Switched to model: ${model.name} (${model.provider})`);
|
console.log(`[${componentName}] Switched to model: ${model.name} (${model.provider})`);
|
||||||
|
|
||||||
// Display a success toast notification
|
// Display a success toast notification
|
||||||
ToastSuccess({
|
toastSuccess({
|
||||||
title: 'Model changed',
|
title: 'Model changed',
|
||||||
msg: `Switched to ${model.alias ?? model.name}`,
|
msg: `Switched to ${model.alias ?? model.name}`,
|
||||||
});
|
});
|
||||||
@@ -40,7 +40,7 @@ export function useHandleModelSelection() {
|
|||||||
// Handle errors gracefully
|
// Handle errors gracefully
|
||||||
console.error(`[${componentName}] Failed to switch model:`, error);
|
console.error(`[${componentName}] Failed to switch model:`, error);
|
||||||
// Display an error toast notification
|
// Display an error toast notification
|
||||||
ToastError({
|
toastError({
|
||||||
title: model.name,
|
title: model.name,
|
||||||
msg: `Failed to switch to model`,
|
msg: `Failed to switch to model`,
|
||||||
traceback: error.message,
|
traceback: error.message,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { toast } from 'react-toastify';
|
|||||||
import { getActiveProviders, isSecretKey } from '../api_keys/utils';
|
import { getActiveProviders, isSecretKey } from '../api_keys/utils';
|
||||||
import { useModel } from '../models/ModelContext';
|
import { useModel } from '../models/ModelContext';
|
||||||
import { Button } from '../../ui/button';
|
import { Button } from '../../ui/button';
|
||||||
import { ToastError, ToastSuccess } from '../models/toasts';
|
import { toastError, toastSuccess } from '../../../toasts';
|
||||||
|
|
||||||
function ConfirmationModal({ message, onConfirm, onCancel }) {
|
function ConfirmationModal({ message, onConfirm, onCancel }) {
|
||||||
return (
|
return (
|
||||||
@@ -142,7 +142,7 @@ export function ConfigureProvidersGrid() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ToastSuccess({
|
toastSuccess({
|
||||||
title: provider,
|
title: provider,
|
||||||
msg: isUpdate ? `Successfully updated configuration` : `Successfully added configuration`,
|
msg: isUpdate ? `Successfully updated configuration` : `Successfully added configuration`,
|
||||||
});
|
});
|
||||||
@@ -155,7 +155,7 @@ export function ConfigureProvidersGrid() {
|
|||||||
setModalMode('setup');
|
setModalMode('setup');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error handling modal submit:', error);
|
console.error('Error handling modal submit:', error);
|
||||||
ToastError({
|
toastError({
|
||||||
title: provider,
|
title: provider,
|
||||||
msg: `Failed to ${providers.find((p) => p.id === selectedForSetup)?.isConfigured ? 'update' : 'add'} configuration`,
|
msg: `Failed to ${providers.find((p) => p.id === selectedForSetup)?.isConfigured ? 'update' : 'add'} configuration`,
|
||||||
traceback: error.message,
|
traceback: error.message,
|
||||||
@@ -181,7 +181,7 @@ export function ConfigureProvidersGrid() {
|
|||||||
// Check if the selected provider is currently active
|
// Check if the selected provider is currently active
|
||||||
if (currentModel?.provider === providerToDelete.name) {
|
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.`;
|
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);
|
setIsConfirmationOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -209,7 +209,7 @@ export function ConfigureProvidersGrid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Configuration deleted successfully.');
|
console.log('Configuration deleted successfully.');
|
||||||
ToastSuccess({
|
toastSuccess({
|
||||||
title: providerToDelete.name,
|
title: providerToDelete.name,
|
||||||
msg: 'Successfully deleted configuration',
|
msg: 'Successfully deleted configuration',
|
||||||
});
|
});
|
||||||
@@ -218,7 +218,7 @@ export function ConfigureProvidersGrid() {
|
|||||||
setActiveKeys(updatedKeys);
|
setActiveKeys(updatedKeys);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting configuration:', error);
|
console.error('Error deleting configuration:', error);
|
||||||
ToastError({
|
toastError({
|
||||||
title: providerToDelete.name,
|
title: providerToDelete.name,
|
||||||
msg: 'Failed to delete configuration',
|
msg: 'Failed to delete configuration',
|
||||||
traceback: error.message,
|
traceback: error.message,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React from 'react';
|
|||||||
import { ScrollArea } from '../ui/scroll-area';
|
import { ScrollArea } from '../ui/scroll-area';
|
||||||
import BackButton from '../ui/BackButton';
|
import BackButton from '../ui/BackButton';
|
||||||
import type { View } from '../../App';
|
import type { View } from '../../App';
|
||||||
import { useConfig } from '../ConfigContext';
|
|
||||||
import ExtensionsSection from './extensions/ExtensionsSection';
|
import ExtensionsSection from './extensions/ExtensionsSection';
|
||||||
import ModelsSection from './models/ModelsSection';
|
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
|
// If extension is enabled, we are trying to toggle if off, otherwise on
|
||||||
const toggleDirection = extension.enabled ? 'toggleOff' : 'toggleOn';
|
const toggleDirection = extension.enabled ? 'toggleOff' : 'toggleOn';
|
||||||
const extensionConfig = extractExtensionConfig(extension);
|
const extensionConfig = extractExtensionConfig(extension);
|
||||||
|
|
||||||
await toggleExtension({
|
await toggleExtension({
|
||||||
toggle: toggleDirection,
|
toggle: toggleDirection,
|
||||||
extensionConfig: extensionConfig,
|
extensionConfig: extensionConfig,
|
||||||
addToConfig: addExtension,
|
addToConfig: addExtension,
|
||||||
removeFromConfig: removeExtension,
|
toastOptions: { silent: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await fetchExtensions(); // Refresh the list after toggling
|
await fetchExtensions(); // Refresh the list after toggling
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,8 +66,6 @@ export default function ExtensionsSection() {
|
|||||||
|
|
||||||
const handleAddExtension = async (formData: ExtensionFormData) => {
|
const handleAddExtension = async (formData: ExtensionFormData) => {
|
||||||
const extensionConfig = createExtensionConfig(formData);
|
const extensionConfig = createExtensionConfig(formData);
|
||||||
// TODO: replace activateExtension in index
|
|
||||||
// TODO: make sure error handling works
|
|
||||||
await activateExtension({ addToConfig: addExtension, extensionConfig: extensionConfig });
|
await activateExtension({ addToConfig: addExtension, extensionConfig: extensionConfig });
|
||||||
handleModalClose();
|
handleModalClose();
|
||||||
await fetchExtensions();
|
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';
|
// Export public API
|
||||||
import builtInExtensionsData from './built-in-extensions.json';
|
export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils';
|
||||||
import { FixedExtensionEntry } from '../../ConfigContext';
|
|
||||||
import { getApiUrl, getSecretKey } from '../../../config';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
import { ToastError, ToastLoading, ToastSuccess } from '../../settings/models/toasts';
|
|
||||||
|
|
||||||
// Default extension timeout in seconds
|
// Export extension management functions
|
||||||
// TODO: keep in sync with rust better
|
export {
|
||||||
export const DEFAULT_EXTENSION_TIMEOUT = 300;
|
activateExtension,
|
||||||
|
addToAgentOnStartup,
|
||||||
|
updateExtension,
|
||||||
|
toggleExtension,
|
||||||
|
deleteExtension,
|
||||||
|
} from './extension-manager';
|
||||||
|
|
||||||
// Type definition for built-in extensions from JSON
|
// Export built-in extension functions
|
||||||
type BuiltinExtension = {
|
export { syncBuiltInExtensions, initializeBuiltInExtensions } from './built-in';
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
display_name: string;
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
type: 'builtin';
|
|
||||||
envs?: { [key: string]: string };
|
|
||||||
timeout?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: need to keep this in sync better with `name_to_key` on the rust side
|
// Export deeplink handling
|
||||||
function nameToKey(name: string): string {
|
export { addExtensionFromDeepLink } from './deeplink';
|
||||||
return name
|
|
||||||
.split('')
|
|
||||||
.filter((char) => !char.match(/\s/))
|
|
||||||
.join('')
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleError(message: string, shouldThrow = false): void {
|
// Export agent API functions
|
||||||
ToastError({
|
export { addToAgent as AddToAgent, removeFromAgent as RemoveFromAgent } from './agent-api';
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 { FixedExtensionEntry } from '../../ConfigContext';
|
||||||
import { ExtensionConfig } from '../../../api/types.gen';
|
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 type { View } from '../../../App';
|
||||||
import ModelSettingsButtons from './subcomponents/ModelSettingsButtons';
|
import ModelSettingsButtons from './subcomponents/ModelSettingsButtons';
|
||||||
import { useConfig } from '../../ConfigContext';
|
import { useConfig } from '../../ConfigContext';
|
||||||
import { ToastError } from '../../settings/models/toasts';
|
import { toastError } from '../../../toasts';
|
||||||
|
|
||||||
interface ModelsSectionProps {
|
interface ModelsSectionProps {
|
||||||
setView: (view: View) => void;
|
setView: (view: View) => void;
|
||||||
@@ -11,38 +11,65 @@ interface ModelsSectionProps {
|
|||||||
const UNKNOWN_PROVIDER_TITLE = 'Provider name error';
|
const UNKNOWN_PROVIDER_TITLE = 'Provider name error';
|
||||||
const UNKNOWN_PROVIDER_MSG = 'Unknown provider in config -- please inspect your config.yaml';
|
const UNKNOWN_PROVIDER_MSG = 'Unknown provider in config -- please inspect your config.yaml';
|
||||||
|
|
||||||
// todo: use for block settings
|
|
||||||
export default function ModelsSection({ setView }: ModelsSectionProps) {
|
export default function ModelsSection({ setView }: ModelsSectionProps) {
|
||||||
const [provider, setProvider] = useState<string | null>(null);
|
const [provider, setProvider] = useState<string | null>(null);
|
||||||
const [model, setModel] = useState<string>('');
|
const [model, setModel] = useState<string>('');
|
||||||
const { read, getProviders } = useConfig();
|
const { read, getProviders } = useConfig();
|
||||||
|
|
||||||
|
// Use a ref to prevent multiple loads
|
||||||
|
const isLoadingRef = useRef(false);
|
||||||
|
const isLoadedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentModel = async () => {
|
// Prevent the effect from running again if it's already loading or loaded
|
||||||
const gooseModel = (await read('GOOSE_MODEL', false)) as string;
|
if (isLoadingRef.current || isLoadedRef.current) return;
|
||||||
const gooseProvider = (await read('GOOSE_PROVIDER', false)) as string;
|
|
||||||
const providers = await getProviders(true);
|
|
||||||
|
|
||||||
// lookup display name
|
// Mark as loading
|
||||||
const providerDetailsList = providers.filter((provider) => provider.name === gooseProvider);
|
isLoadingRef.current = true;
|
||||||
|
|
||||||
if (providerDetailsList.length != 1) {
|
const loadModelData = async () => {
|
||||||
ToastError({
|
try {
|
||||||
title: UNKNOWN_PROVIDER_TITLE,
|
const gooseModel = (await read('GOOSE_MODEL', false)) as string;
|
||||||
msg: UNKNOWN_PROVIDER_MSG,
|
const gooseProvider = (await read('GOOSE_PROVIDER', false)) as string;
|
||||||
});
|
const providers = await getProviders(true);
|
||||||
setModel(gooseModel);
|
|
||||||
setProvider(gooseProvider);
|
// lookup display name
|
||||||
return;
|
const providerDetailsList = providers.filter((provider) => provider.name === gooseProvider);
|
||||||
|
|
||||||
|
if (providerDetailsList.length != 1) {
|
||||||
|
toastError({
|
||||||
|
title: UNKNOWN_PROVIDER_TITLE,
|
||||||
|
msg: UNKNOWN_PROVIDER_MSG,
|
||||||
|
});
|
||||||
|
setModel(gooseModel);
|
||||||
|
setProvider(gooseProvider);
|
||||||
|
} else {
|
||||||
|
const providerDisplayName = providerDetailsList[0].metadata.display_name;
|
||||||
|
setModel(gooseModel);
|
||||||
|
setProvider(providerDisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as loaded and not loading
|
||||||
|
isLoadedRef.current = true;
|
||||||
|
isLoadingRef.current = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading model data:', error);
|
||||||
|
isLoadingRef.current = false;
|
||||||
}
|
}
|
||||||
const providerDisplayName = providerDetailsList[0].metadata.display_name;
|
|
||||||
setModel(gooseModel);
|
|
||||||
setProvider(providerDisplayName);
|
|
||||||
};
|
};
|
||||||
(async () => {
|
|
||||||
await currentModel();
|
loadModelData();
|
||||||
})();
|
|
||||||
}, [getProviders, read]);
|
// 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 (
|
return (
|
||||||
<section id="models">
|
<section id="models">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { initializeAgent } from '../../../agent/index';
|
import { initializeAgent } from '../../../agent/index';
|
||||||
import { ToastError, ToastSuccess } from '../../settings/models/toasts';
|
import { toastError, toastSuccess } from '../../../toasts';
|
||||||
import { ProviderDetails } from '@/src/api';
|
import { ProviderDetails } from '@/src/api';
|
||||||
|
|
||||||
// titles
|
// titles
|
||||||
@@ -29,8 +29,7 @@ export async function changeModel({ model, provider, writeToConfig }: changeMode
|
|||||||
await initializeAgent({ model: model, provider: provider });
|
await initializeAgent({ model: model, provider: provider });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to change model at agent step -- ${model} ${provider}`);
|
console.error(`Failed to change model at agent step -- ${model} ${provider}`);
|
||||||
// show toast with error
|
toastError({
|
||||||
ToastError({
|
|
||||||
title: CHANGE_MODEL_TOAST_TITLE,
|
title: CHANGE_MODEL_TOAST_TITLE,
|
||||||
msg: SWITCH_MODEL_AGENT_ERROR_MSG,
|
msg: SWITCH_MODEL_AGENT_ERROR_MSG,
|
||||||
traceback: error,
|
traceback: error,
|
||||||
@@ -44,8 +43,7 @@ export async function changeModel({ model, provider, writeToConfig }: changeMode
|
|||||||
await writeToConfig('GOOSE_MODEL', model, false);
|
await writeToConfig('GOOSE_MODEL', model, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to change model at config step -- ${model} ${provider}`);
|
console.error(`Failed to change model at config step -- ${model} ${provider}`);
|
||||||
// show toast with error
|
toastError({
|
||||||
ToastError({
|
|
||||||
title: CHANGE_MODEL_TOAST_TITLE,
|
title: CHANGE_MODEL_TOAST_TITLE,
|
||||||
msg: CONFIG_UPDATE_ERROR_MSG,
|
msg: CONFIG_UPDATE_ERROR_MSG,
|
||||||
traceback: error,
|
traceback: error,
|
||||||
@@ -54,7 +52,7 @@ export async function changeModel({ model, provider, writeToConfig }: changeMode
|
|||||||
// TODO: reset agent to use current config settings
|
// TODO: reset agent to use current config settings
|
||||||
} finally {
|
} finally {
|
||||||
// show toast
|
// show toast
|
||||||
ToastSuccess({
|
toastSuccess({
|
||||||
title: CHANGE_MODEL_TOAST_TITLE,
|
title: CHANGE_MODEL_TOAST_TITLE,
|
||||||
msg: `${SWITCH_MODEL_SUCCESS_MSG} -- using ${model} from ${provider}`,
|
msg: `${SWITCH_MODEL_SUCCESS_MSG} -- using ${model} from ${provider}`,
|
||||||
});
|
});
|
||||||
@@ -73,8 +71,7 @@ export async function startAgentFromConfig({ readFromConfig }: startAgentFromCon
|
|||||||
try {
|
try {
|
||||||
modelProvider = await getCurrentModelAndProvider({ readFromConfig: readFromConfig });
|
modelProvider = await getCurrentModelAndProvider({ readFromConfig: readFromConfig });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// show toast with error
|
toastError({
|
||||||
ToastError({
|
|
||||||
title: START_AGENT_TITLE,
|
title: START_AGENT_TITLE,
|
||||||
msg: CONFIG_READ_MODEL_ERROR_MSG,
|
msg: CONFIG_READ_MODEL_ERROR_MSG,
|
||||||
traceback: error,
|
traceback: error,
|
||||||
@@ -91,16 +88,14 @@ export async function startAgentFromConfig({ readFromConfig }: startAgentFromCon
|
|||||||
await initializeAgent({ model: model, provider: provider });
|
await initializeAgent({ model: model, provider: provider });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to change model at agent step -- ${model} ${provider}`);
|
console.error(`Failed to change model at agent step -- ${model} ${provider}`);
|
||||||
// show toast with error
|
toastError({
|
||||||
ToastError({
|
|
||||||
title: CHANGE_MODEL_TOAST_TITLE,
|
title: CHANGE_MODEL_TOAST_TITLE,
|
||||||
msg: SWITCH_MODEL_AGENT_ERROR_MSG,
|
msg: SWITCH_MODEL_AGENT_ERROR_MSG,
|
||||||
traceback: error,
|
traceback: error,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
// success toast
|
toastSuccess({
|
||||||
ToastSuccess({
|
|
||||||
title: CHANGE_MODEL_TOAST_TITLE,
|
title: CHANGE_MODEL_TOAST_TITLE,
|
||||||
msg: `${INITIALIZE_SYSTEM_WITH_MODEL_SUCCESS_MSG} with ${model} from ${provider}`,
|
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);
|
const providerDetailsList = providers.filter((provider) => provider.name === gooseProvider);
|
||||||
|
|
||||||
if (providerDetailsList.length != 1) {
|
if (providerDetailsList.length != 1) {
|
||||||
ToastError({
|
toastError({
|
||||||
title: UNKNOWN_PROVIDER_TITLE,
|
title: UNKNOWN_PROVIDER_TITLE,
|
||||||
msg: UNKNOWN_PROVIDER_MSG,
|
msg: UNKNOWN_PROVIDER_MSG,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { type SettingsViewOptions } from './components/settings/SettingsView';
|
|||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import builtInExtensionsData from './built-in-extensions.json';
|
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';
|
import { Toast } from 'react-toastify/dist/components';
|
||||||
|
|
||||||
// Hardcoded default extension timeout in seconds
|
// Hardcoded default extension timeout in seconds
|
||||||
@@ -84,7 +84,7 @@ export async function addExtension(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let toastId;
|
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'), {
|
const response = await fetch(getApiUrl('/extensions/add'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -100,7 +100,7 @@ export async function addExtension(
|
|||||||
if (!data.error) {
|
if (!data.error) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
if (toastId) toast.dismiss(toastId);
|
if (toastId) toast.dismiss(toastId);
|
||||||
ToastSuccess({ title: extension.name, msg: `Successfully enabled extension` });
|
toastSuccess({ title: extension.name, msg: `Successfully enabled extension` });
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ export async function addExtension(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (toastId) toast.dismiss(toastId);
|
if (toastId) toast.dismiss(toastId);
|
||||||
ToastError({
|
toastError({
|
||||||
title: extension.name,
|
title: extension.name,
|
||||||
msg: errorMessage,
|
msg: errorMessage,
|
||||||
traceback: data.message,
|
traceback: data.message,
|
||||||
@@ -129,7 +129,7 @@ export async function addExtension(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = `Failed to add ${extension.name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
const errorMessage = `Failed to add ${extension.name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
console.error(errorMessage);
|
console.error(errorMessage);
|
||||||
ToastError({
|
toastError({
|
||||||
title: extension.name,
|
title: extension.name,
|
||||||
msg: 'Failed to add extension',
|
msg: 'Failed to add extension',
|
||||||
traceback: error.message,
|
traceback: error.message,
|
||||||
@@ -154,14 +154,14 @@ export async function removeExtension(name: string, silent: boolean = false): Pr
|
|||||||
|
|
||||||
if (!data.error) {
|
if (!data.error) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
ToastSuccess({ title: name, msg: 'Successfully disabled extension' });
|
toastSuccess({ title: name, msg: 'Successfully disabled extension' });
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage = `Error removing ${name} extension${data.message ? `. ${data.message}` : ''}`;
|
const errorMessage = `Error removing ${name} extension${data.message ? `. ${data.message}` : ''}`;
|
||||||
console.error(errorMessage);
|
console.error(errorMessage);
|
||||||
ToastError({
|
toastError({
|
||||||
title: name,
|
title: name,
|
||||||
msg: 'Error removing extension',
|
msg: 'Error removing extension',
|
||||||
traceback: data.message,
|
traceback: data.message,
|
||||||
@@ -171,7 +171,7 @@ export async function removeExtension(name: string, silent: boolean = false): Pr
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = `Failed to remove ${name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
const errorMessage = `Failed to remove ${name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
console.error(errorMessage);
|
console.error(errorMessage);
|
||||||
ToastError({
|
toastError({
|
||||||
title: name,
|
title: name,
|
||||||
msg: 'Error removing extension',
|
msg: 'Error removing extension',
|
||||||
traceback: error.message,
|
traceback: error.message,
|
||||||
@@ -257,7 +257,7 @@ function envVarsRequired(config: ExtensionConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleError(message: string, shouldThrow = false): void {
|
function handleError(message: string, shouldThrow = false): void {
|
||||||
ToastError({
|
toastError({
|
||||||
title: 'Failed to install extension',
|
title: 'Failed to install extension',
|
||||||
msg: message,
|
msg: message,
|
||||||
traceback: 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