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({ 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`,
errorMessage: error.message, traceback: error.message,
}); });
} }
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -158,7 +158,7 @@ export function ConfigureProvidersGrid() {
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`,
errorMessage: 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, errorMessage: msg }); ToastError({ title: providerToDelete.name, msg, traceback: msg });
setIsConfirmationOpen(false); setIsConfirmationOpen(false);
return; return;
} }
@@ -221,7 +221,7 @@ export function ConfigureProvidersGrid() {
ToastError({ ToastError({
title: providerToDelete.name, title: providerToDelete.name,
msg: 'Failed to delete configuration', msg: 'Failed to delete configuration',
errorMessage: error.message, traceback: error.message,
}); });
} }
setIsConfirmationOpen(false); setIsConfirmationOpen(false);

View File

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

View File

@@ -6,6 +6,7 @@ import { toast } from 'react-toastify';
import { ToastError, ToastLoading, ToastSuccess } from '../../settings/models/toasts'; import { ToastError, ToastLoading, ToastSuccess } from '../../settings/models/toasts';
// Default extension timeout in seconds // Default extension timeout in seconds
// TODO: keep in sync with rust better
export const DEFAULT_EXTENSION_TIMEOUT = 300; export const DEFAULT_EXTENSION_TIMEOUT = 300;
// Type definition for built-in extensions from JSON // Type definition for built-in extensions from JSON
@@ -33,7 +34,7 @@ function handleError(message: string, shouldThrow = false): void {
ToastError({ ToastError({
title: 'Error', title: 'Error',
msg: message, msg: message,
errorMessage: message, traceback: message,
}); });
console.error(message); console.error(message);
if (shouldThrow) { if (shouldThrow) {
@@ -57,6 +58,11 @@ async function replaceWithShims(cmd: string) {
return 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. * Activates an extension by adding it to both the config system and the API.
* @param name The extension name * @param name The extension name
@@ -64,67 +70,151 @@ async function replaceWithShims(cmd: string) {
* @param addExtensionFn Function to add extension to config * @param addExtensionFn Function to add extension to config
* @returns Promise that resolves when activation is complete * @returns Promise that resolves when activation is complete
*/ */
export async function activateExtension( export async function activateExtension({
name: string, addToConfig,
config: ExtensionConfig, extensionConfig,
addExtensionFn: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void> }: activateExtensionProps): Promise<void> {
): Promise<void> {
let toastId;
try { try {
// Show loading toast // AddToAgent
toastId = ToastLoading({ title: name, msg: 'Adding extension...' }); await AddToAgent(extensionConfig);
// 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,
});
}
} catch (error) { } catch (error) {
const errorMessage = `Failed to add ${name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`; // add to config with enabled = false
console.error(errorMessage); await addToConfig(extensionConfig.name, extensionConfig, false);
if (toastId) toast.dismiss(toastId); // show user the error, return
ToastError({ console.log('error', error);
title: name, return;
msg: 'Failed to add extension', }
errorMessage: error.message,
}); // 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; throw error;
} }
} }
{
/*Deeplinks*/
}
export async function addExtensionFromDeepLink( export async function addExtensionFromDeepLink(
url: string, 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 setView: (view: string, options: { extensionId: string; showEnvVars: boolean }) => void
) { ) {
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
@@ -202,7 +292,11 @@ export async function addExtensionFromDeepLink(
} }
// If no env vars are required, proceed with adding the extension // 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 // Call with an empty list to ensure all built-ins are added
await syncBuiltInExtensions([], addExtensionFn); 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 { interface ExtensionItemProps {
extension: FixedExtensionEntry; extension: FixedExtensionEntry;
onToggle: (name: string) => void; onToggle: (extension: FixedExtensionEntry) => void;
onConfigure: (extension: FixedExtensionEntry) => void; onConfigure: (extension: FixedExtensionEntry) => void;
} }
@@ -37,7 +37,7 @@ export default function ExtensionItem({ extension, onToggle, onConfigure }: Exte
)} )}
<Switch <Switch
checked={extension.enabled} checked={extension.enabled}
onCheckedChange={() => onToggle(extension.name)} onCheckedChange={() => onToggle(extension)}
variant="mono" variant="mono"
/> />
</div> </div>

View File

@@ -7,7 +7,7 @@ import { combineCmdAndArgs } from '../utils';
interface ExtensionListProps { interface ExtensionListProps {
extensions: FixedExtensionEntry[]; extensions: FixedExtensionEntry[];
onToggle: (name: string) => void; onToggle: (extension: FixedExtensionEntry) => void;
onConfigure: (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 { export function combineCmdAndArgs(cmd: string, args: string[]): string {
return [cmd, ...args].join(' '); 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) { } catch (e) {
ToastError({ ToastError({
title: 'Failed to add model', title: 'Failed to add model',
errorMessage: e.message, traceback: e.message,
}); });
} }
}; };