ui: remove and update extensions (#1847)

This commit is contained in:
Lily Delalande
2025-03-25 13:14:59 -04:00
committed by GitHub
parent a28a0d149f
commit 6abf5b40ce
13 changed files with 312 additions and 107 deletions

View File

@@ -150,7 +150,7 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) {
ToastError({
title: provider,
msg: `Failed to ${providers.find((p) => p.id === selectedId)?.isConfigured ? 'update' : 'add'} configuration`,
errorMessage: error.message,
traceback: error.message,
});
}
};

View File

@@ -80,7 +80,7 @@ export function ConfigureBuiltInExtensionModal({
ToastError({
title: extension.name,
msg: `Failed to configure the extension`,
errorMessage: error.message,
traceback: error.message,
});
} finally {
setIsSubmitting(false);

View File

@@ -82,7 +82,7 @@ export function ConfigureExtensionModal({
ToastError({
title: extension.name,
msg: `Failed to configure extension`,
errorMessage: error.message,
traceback: error.message,
});
} finally {
setIsSubmitting(false);

View File

@@ -99,7 +99,7 @@ export function ManualExtensionModal({ isOpen, onClose, onSubmit }: ManualExtens
resetForm();
} catch (error) {
console.error('Error configuring extension:', error);
ToastError({ title: 'Failed to configure extension', errorMessage: error.message });
ToastError({ title: 'Failed to configure extension', traceback: error.message });
}
};

View File

@@ -24,10 +24,10 @@ export function ToastSuccess({ title, msg, toastOptions = {} }: ToastSuccessProp
type ToastErrorProps = {
title?: string;
msg?: string;
errorMessage?: string;
traceback?: string;
toastOptions?: ToastOptions;
};
export function ToastError({ title, msg, errorMessage, toastOptions }: ToastErrorProps) {
export function ToastError({ title, msg, traceback, toastOptions }: ToastErrorProps) {
return toast.error(
<div className="flex gap-4">
<div className="flex-grow">
@@ -35,17 +35,17 @@ export function ToastError({ title, msg, errorMessage, toastOptions }: ToastErro
{msg ? <div>{msg}</div> : null}
</div>
<div className="flex-none flex items-center">
{errorMessage ? (
{traceback ? (
<button
className="text-textProminentInverse font-medium"
onClick={() => navigator.clipboard.writeText(errorMessage)}
onClick={() => navigator.clipboard.writeText(traceback)}
>
Copy error
</button>
) : null}
</div>
</div>,
{ ...commonToastOptions, autoClose: errorMessage ? false : 5000, ...toastOptions }
{ ...commonToastOptions, autoClose: traceback ? false : 5000, ...toastOptions }
);
}

View File

@@ -43,7 +43,7 @@ export function useHandleModelSelection() {
ToastError({
title: model.name,
msg: `Failed to switch to model`,
errorMessage: error.message,
traceback: error.message,
});
}
};

View File

@@ -158,7 +158,7 @@ export function ConfigureProvidersGrid() {
ToastError({
title: provider,
msg: `Failed to ${providers.find((p) => p.id === selectedForSetup)?.isConfigured ? 'update' : 'add'} configuration`,
errorMessage: error.message,
traceback: error.message,
});
}
};
@@ -181,7 +181,7 @@ export function ConfigureProvidersGrid() {
// Check if the selected provider is currently active
if (currentModel?.provider === providerToDelete.name) {
const msg = `Cannot delete the configuration because it's the provider of the current model (${currentModel.name}). Please switch to a different model first.`;
ToastError({ title: providerToDelete.name, msg, errorMessage: msg });
ToastError({ title: providerToDelete.name, msg, traceback: msg });
setIsConfirmationOpen(false);
return;
}
@@ -221,7 +221,7 @@ export function ConfigureProvidersGrid() {
ToastError({
title: providerToDelete.name,
msg: 'Failed to delete configuration',
errorMessage: error.message,
traceback: error.message,
});
}
setIsConfirmationOpen(false);

View File

@@ -9,20 +9,20 @@ import {
createExtensionConfig,
ExtensionFormData,
extensionToFormData,
extractExtensionConfig,
getDefaultFormData,
} from './utils';
import { useAgent } from '../../../agent/UpdateAgent';
import { activateExtension } from '.';
import { activateExtension, deleteExtension, toggleExtension, updateExtension } from './index';
export default function ExtensionsSection() {
const { toggleExtension, getExtensions, addExtension, removeExtension } = useConfig();
const { getExtensions, addExtension, removeExtension } = useConfig();
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [extensions, setExtensions] = useState<FixedExtensionEntry[]>([]);
const [selectedExtension, setSelectedExtension] = useState<FixedExtensionEntry | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const { updateAgent, addExtensionToAgent } = useAgent();
const fetchExtensions = async () => {
setLoading(true);
@@ -44,13 +44,17 @@ export default function ExtensionsSection() {
fetchExtensions();
}, []);
const handleExtensionToggle = async (name: string) => {
try {
await toggleExtension(name);
fetchExtensions(); // Refresh the list after toggling
} catch (error) {
console.error('Failed to toggle extension:', error);
}
const handleExtensionToggle = async (extension: FixedExtensionEntry) => {
// If extension is enabled, we are trying to toggle if off, otherwise on
const toggleDirection = extension.enabled ? 'toggleOff' : 'toggleOn';
const extensionConfig = extractExtensionConfig(extension);
await toggleExtension({
toggle: toggleDirection,
extensionConfig: extensionConfig,
addToConfig: addExtension,
removeFromConfig: removeExtension,
});
await fetchExtensions(); // Refresh the list after toggling
};
const handleConfigureClick = (extension: FixedExtensionEntry) => {
@@ -60,38 +64,29 @@ export default function ExtensionsSection() {
const handleAddExtension = async (formData: ExtensionFormData) => {
const extensionConfig = createExtensionConfig(formData);
try {
await activateExtension(formData.name, extensionConfig, addExtension);
console.log('attempting to add extension');
await updateAgent(extensionConfig);
handleModalClose();
await fetchExtensions(); // Refresh the list after adding
} catch (error) {
console.error('Failed to add extension:', error);
}
// TODO: replace activateExtension in index
// TODO: make sure error handling works
await activateExtension({ addToConfig: addExtension, extensionConfig: extensionConfig });
handleModalClose();
await fetchExtensions();
};
const handleUpdateExtension = async (formData: ExtensionFormData) => {
const extensionConfig = createExtensionConfig(formData);
try {
await activateExtension(formData.name, extensionConfig, addExtension);
handleModalClose();
fetchExtensions(); // Refresh the list after updating
} catch (error) {
console.error('Failed to update extension configuration:', error);
}
await updateExtension({
enabled: formData.enabled,
extensionConfig: extensionConfig,
addToConfig: addExtension,
});
handleModalClose();
await fetchExtensions();
};
const handleDeleteExtension = async (name: string) => {
try {
await removeExtension(name);
handleModalClose();
fetchExtensions(); // Refresh the list after deleting
} catch (error) {
console.error('Failed to delete extension:', error);
}
await deleteExtension({ name, removeFromConfig: removeExtension });
handleModalClose();
await fetchExtensions();
};
const handleModalClose = () => {

View File

@@ -6,6 +6,7 @@ import { toast } from 'react-toastify';
import { ToastError, ToastLoading, ToastSuccess } from '../../settings/models/toasts';
// Default extension timeout in seconds
// TODO: keep in sync with rust better
export const DEFAULT_EXTENSION_TIMEOUT = 300;
// Type definition for built-in extensions from JSON
@@ -33,7 +34,7 @@ function handleError(message: string, shouldThrow = false): void {
ToastError({
title: 'Error',
msg: message,
errorMessage: message,
traceback: message,
});
console.error(message);
if (shouldThrow) {
@@ -57,6 +58,11 @@ async function replaceWithShims(cmd: string) {
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
@@ -64,67 +70,151 @@ async function replaceWithShims(cmd: string) {
* @param addExtensionFn Function to add extension to config
* @returns Promise that resolves when activation is complete
*/
export async function activateExtension(
name: string,
config: ExtensionConfig,
addExtensionFn: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>
): Promise<void> {
let toastId;
export async function activateExtension({
addToConfig,
extensionConfig,
}: activateExtensionProps): Promise<void> {
try {
// Show loading toast
toastId = ToastLoading({ title: name, msg: 'Adding extension...' });
// First add to the config system
await addExtensionFn(nameToKey(name), config, true);
// Then call the API endpoint
const response = await fetch(getApiUrl('/extensions/add'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': getSecretKey(),
},
body: JSON.stringify({
type: config.type,
name: nameToKey(name),
cmd: await replaceWithShims(config.cmd),
args: config.args || [],
env_keys: config.envs ? Object.keys(config.envs) : undefined,
timeout: config.timeout,
}),
});
const data = await response.json();
if (!data.error) {
if (toastId) toast.dismiss(toastId);
ToastSuccess({ title: name, msg: 'Successfully enabled extension' });
} else {
const errorMessage = `Error adding extension`;
console.error(errorMessage);
if (toastId) toast.dismiss(toastId);
ToastError({
title: name,
msg: errorMessage,
errorMessage: data.message,
});
}
// AddToAgent
await AddToAgent(extensionConfig);
} catch (error) {
const errorMessage = `Failed to add ${name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`;
console.error(errorMessage);
if (toastId) toast.dismiss(toastId);
ToastError({
title: name,
msg: 'Failed to add extension',
errorMessage: error.message,
});
// add to config with enabled = false
await addToConfig(extensionConfig.name, extensionConfig, false);
// show user the error, return
console.log('error', error);
return;
}
// Then add to config
try {
await addToConfig(extensionConfig.name, extensionConfig, true);
} catch (error) {
// remove from Agent
await RemoveFromAgent(extensionConfig.name);
// config error workflow
console.log('error', 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) {
// i think only error that gets thrown here is when it's not from the response... rest are handled by agent
console.log('error', error);
// failed to add to agent -- show that error to user and do not update the config file
return;
}
// Then add to config
try {
await addToConfig(extensionConfig.name, extensionConfig, enabled);
} catch (error) {
// config error workflow
console.log('error', error);
}
} else {
try {
await addToConfig(extensionConfig.name, extensionConfig, enabled);
} catch (error) {
// TODO: Add to agent with previous configuration and raise error
// for now just log error
console.log('error', error);
}
}
}
interface toggleExtensionProps {
toggle: 'toggleOn' | 'toggleOff';
extensionConfig: ExtensionConfig;
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
removeFromConfig: (name: string) => 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) {
// do nothing raise error
// show user error
console.log('Error adding extension to agent. Error:', error);
return;
}
// update the config
try {
await addToConfig(extensionConfig.name, extensionConfig, true);
} catch (error) {
// remove from agent?
await RemoveFromAgent(extensionConfig.name);
}
} else if (toggle == 'toggleOff') {
// enabled to disabled
try {
await RemoveFromAgent(extensionConfig.name);
} catch (error) {
// note there was an error, but remove from config anyway
console.error('Error removing extension from agent', extensionConfig.name, error);
}
// update the config
try {
await addToConfig(extensionConfig.name, extensionConfig, false);
} catch (error) {
// TODO: Add to agent with previous configuration
console.log('Error removing extension from config', extensionConfig.name, 'Error:', error);
}
}
}
interface deleteExtensionProps {
name: string;
removeFromConfig: (name: string) => Promise<void>;
}
export async function deleteExtension({ name, removeFromConfig }: deleteExtensionProps) {
// remove from agent
await RemoveFromAgent(name);
try {
await removeFromConfig(name);
} catch (error) {
console.log('Failed to remove extension from config after removing from agent. Error:', error);
// TODO: tell user to restart goose and try again to remove (will still be present in settings but not on agent until restart)
throw error;
}
}
{
/*Deeplinks*/
}
export async function addExtensionFromDeepLink(
url: string,
addExtensionFn: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>,
addExtensionFn: (
name: string,
extensionConfig: ExtensionConfig,
enabled: boolean
) => Promise<void>,
setView: (view: string, options: { extensionId: string; showEnvVars: boolean }) => void
) {
const parsedUrl = new URL(url);
@@ -202,7 +292,11 @@ export async function addExtensionFromDeepLink(
}
// If no env vars are required, proceed with adding the extension
await activateExtension(name, config, addExtensionFn);
await activateExtension({ extensionConfig: config, addToConfig: addExtensionFn });
}
{
/*Built ins*/
}
/**
@@ -272,3 +366,109 @@ export async function initializeBuiltInExtensions(
// 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,
});
return response;
}
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,
});
return response;
}
// Parse response JSON safely
let data;
try {
const text = await response.text();
data = text ? JSON.parse(text) : { error: false };
} catch (error) {
console.warn('Could not parse response as JSON, assuming success', error);
data = { error: false };
}
if (!data.error) {
if (toastId) toast.dismiss(toastId);
ToastSuccess({ title: extensionName, msg: 'Successfully enabled extension' });
} else {
const errorMessage = `Error adding extension -- parsing data`;
console.error(errorMessage);
if (toastId) toast.dismiss(toastId);
ToastError({
title: extensionName,
msg: errorMessage,
traceback: data.message, // why data.message not data.error?
});
}
} catch (error) {
//
}
}
// Public functions
export async function AddToAgent(extension: ExtensionConfig): Promise<Response> {
if (extension.type === 'stdio') {
console.log('extension command', extension.cmd);
extension.cmd = await replaceWithShims(extension.cmd);
console.log('next ext command', extension.cmd);
}
return extensionApiCall('/extensions/add', extension, 'adding', extension.name);
}
export async function RemoveFromAgent(name: string): Promise<Response> {
return extensionApiCall('/extensions/remove', name, 'removing', name);
}

View File

@@ -7,7 +7,7 @@ import { getSubtitle, getFriendlyTitle } from './ExtensionList';
interface ExtensionItemProps {
extension: FixedExtensionEntry;
onToggle: (name: string) => void;
onToggle: (extension: FixedExtensionEntry) => void;
onConfigure: (extension: FixedExtensionEntry) => void;
}
@@ -37,7 +37,7 @@ export default function ExtensionItem({ extension, onToggle, onConfigure }: Exte
)}
<Switch
checked={extension.enabled}
onCheckedChange={() => onToggle(extension.name)}
onCheckedChange={() => onToggle(extension)}
variant="mono"
/>
</div>

View File

@@ -7,7 +7,7 @@ import { combineCmdAndArgs } from '../utils';
interface ExtensionListProps {
extensions: FixedExtensionEntry[];
onToggle: (name: string) => void;
onToggle: (extension: FixedExtensionEntry) => void;
onConfigure: (extension: FixedExtensionEntry) => void;
}

View File

@@ -95,3 +95,13 @@ export function splitCmdAndArgs(str: string): { cmd: string; args: string[] } {
export function combineCmdAndArgs(cmd: string, args: string[]): string {
return [cmd, ...args].join(' ');
}
/**
* Extracts the ExtensionConfig from a FixedExtensionEntry object
* @param fixedEntry - The FixedExtensionEntry object
* @returns The ExtensionConfig portion of the object
*/
export function extractExtensionConfig(fixedEntry: FixedExtensionEntry): ExtensionConfig {
const { enabled, ...extensionConfig } = fixedEntry;
return extensionConfig;
}

View File

@@ -51,7 +51,7 @@ export const AddModelModal = ({ onClose }: AddModelModalProps) => {
} catch (e) {
ToastError({
title: 'Failed to add model',
errorMessage: e.message,
traceback: e.message,
});
}
};