Mini agent extension config (#2209)

Co-authored-by: Zaki Ali <zaki@squareup.com>
This commit is contained in:
Lily Delalande
2025-04-15 18:43:23 -04:00
committed by GitHub
parent 6549b6d429
commit 1dfbd470c2
6 changed files with 210 additions and 129 deletions

View File

@@ -7,9 +7,10 @@ import Back from './icons/Back';
import { Bars } from './icons/Bars';
import { Geese } from './icons/Geese';
import Copy from './icons/Copy';
import Check from './icons/Check';
import { useConfig } from '../components/ConfigContext';
import { settingsV2Enabled } from '../flags';
import { useConfig } from './ConfigContext';
import { FixedExtensionEntry } from './ConfigContext';
import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList';
import { Check } from 'lucide-react';
interface RecipeEditorProps {
config?: Recipe;
@@ -28,47 +29,83 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
const [description, setDescription] = useState(config?.description || '');
const [instructions, setInstructions] = useState(config?.instructions || '');
const [activities, setActivities] = useState<string[]>(config?.activities || []);
const [availableExtensions, setAvailableExtensions] = useState<FullExtensionConfig[]>([]);
const [selectedExtensions, setSelectedExtensions] = useState<string[]>(
config?.extensions?.map((e) => e.id) || []
);
const [newActivity, setNewActivity] = useState('');
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
const [copied, setCopied] = useState(false);
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
// Initialize selected extensions for the recipe from config or localStorage
const [recipeExtensions, setRecipeExtensions] = useState<string[]>(() => {
// First try to get from localStorage
const stored = localStorage.getItem('recipe_editor_extensions');
if (stored) {
try {
const parsed = JSON.parse(stored);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
console.error('Failed to parse localStorage recipe extensions:', e);
return [];
}
}
// Fall back to config if available, using extension names
const exts = [];
return exts;
});
const [newActivity, setNewActivity] = useState('');
// Section visibility state
const [activeSection, setActiveSection] = useState<
'none' | 'activities' | 'instructions' | 'extensions'
>('none');
// Load extensions
// Load extensions when component mounts and when switching to extensions section
useEffect(() => {
const loadExtensions = async () => {
if (settingsV2Enabled) {
if (activeSection === 'extensions' && !extensionsLoaded) {
const loadExtensions = async () => {
try {
const extensions = await getExtensions(false); // force refresh to get latest
console.log('extensions {}', extensions);
setAvailableExtensions(extensions || []);
console.log('Loading extensions for recipe editor');
if (extensions && extensions.length > 0) {
// Map the extensions with the current selection state from recipeExtensions
const initializedExtensions = extensions.map((ext) => ({
...ext,
enabled: recipeExtensions.includes(ext.name),
}));
setExtensionOptions(initializedExtensions);
setExtensionsLoaded(true);
}
} catch (error) {
console.error('Failed to load extensions:', error);
}
} else {
const userSettingsStr = localStorage.getItem('user_settings');
if (userSettingsStr) {
const userSettings = JSON.parse(userSettingsStr);
setAvailableExtensions(userSettings.extensions || []);
}
}
};
loadExtensions();
// Intentionally omitting getExtensions from deps to avoid refresh loops
// eslint-disable-next-line
}, []);
};
loadExtensions();
}
}, [activeSection, getExtensions, recipeExtensions, extensionsLoaded]);
// Effect for updating extension options when recipeExtensions change
useEffect(() => {
if (extensionsLoaded && extensionOptions.length > 0) {
const updatedOptions = extensionOptions.map((ext) => ({
...ext,
enabled: recipeExtensions.includes(ext.name),
}));
setExtensionOptions(updatedOptions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recipeExtensions, extensionsLoaded]);
const handleExtensionToggle = (extension: FixedExtensionEntry) => {
console.log('Toggling extension:', extension.name);
setRecipeExtensions((prev) => {
const isSelected = prev.includes(extension.name);
const newState = isSelected
? prev.filter((extName) => extName !== extension.name)
: [...prev, extension.name];
// Persist to localStorage
localStorage.setItem('recipe_editor_extensions', JSON.stringify(newState));
const handleExtensionToggle = (id: string) => {
console.log('Toggling extension:', id);
setSelectedExtensions((prev) => {
const isSelected = prev.includes(id);
const newState = isSelected ? prev.filter((extId) => extId !== id) : [...prev, id];
return newState;
});
};
@@ -86,8 +123,8 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
const getCurrentConfig = (): Recipe => {
console.log('Creating config with:', {
selectedExtensions,
availableExtensions,
selectedExtensions: recipeExtensions,
availableExtensions: extensionOptions,
recipeConfig,
});
@@ -97,9 +134,9 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
description,
instructions,
activities,
extensions: selectedExtensions
extensions: recipeExtensions
.map((name) => {
const extension = availableExtensions.find((e) => e.name === name);
const extension = extensionOptions.find((e) => e.name === name);
console.log('Looking for extension:', name, 'Found:', extension);
if (!extension) return null;
@@ -139,6 +176,8 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
const handleOpenAgent = () => {
if (validateForm()) {
const updatedConfig = getCurrentConfig();
// Clear stored extensions when submitting
localStorage.removeItem('recipe_editor_extensions');
window.electron.createChatWindow(
undefined,
undefined,
@@ -166,6 +205,13 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
});
};
// Reset extensionsLoaded when section changes away from extensions
useEffect(() => {
if (activeSection !== 'extensions') {
setExtensionsLoaded(false);
}
}, [activeSection]);
// Render expanded section content
const renderSectionContent = () => {
switch (activeSection) {
@@ -258,49 +304,23 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
</div>
<div className="mb-8 mt-6">
<h2 className="text-2xl font-medium mb-2 text-textProminent">Extensions</h2>
<p className="text-textSubtle">
Choose which extensions will be available to your agent.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
{availableExtensions.map((extension) => (
<button
key={extension.name}
className="p-4 border border-borderSubtle rounded-lg flex justify-between items-center w-full text-left hover:bg-bgSubtle bg-bgApp"
onClick={() => handleExtensionToggle(extension.name)}
>
<div>
<h3 className="font-medium text-textProminent">{extension.name}</h3>
<p className="text-sm text-textSubtle">
{extension.description || 'No description available'}
</p>
</div>
<div className="relative inline-block w-10 align-middle select-none">
<div
className={`w-10 h-6 rounded-full transition-colors duration-200 ease-in-out ${
selectedExtensions.includes(extension.name)
? 'bg-bgAppInverse'
: 'bg-borderSubtle'
}`}
>
<div
className={`w-6 h-6 rounded-full bg-bgApp border-2 transform transition-transform duration-200 ease-in-out ${
selectedExtensions.includes(extension.name)
? 'translate-x-4 border-bgAppInverse'
: 'translate-x-0 border-borderSubtle'
}`}
/>
</div>
</div>
</button>
))}
<p className="text-textSubtle">Select extensions to bundle in the recipe</p>
</div>
{extensionsLoaded ? (
<ExtensionList
extensions={extensionOptions}
onToggle={handleExtensionToggle}
isStatic={true}
/>
) : (
<div className="text-center py-8 text-textSubtle">Loading extensions...</div>
)}
</div>
);
default:
return (
<div className="space-y-4 py-4">
<div className="space-y-2 py-2">
<div>
<h2 className="text-lg font-medium mb-2 text-textProminent">Agent</h2>
<input
@@ -315,7 +335,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
className={`w-full p-3 border rounded-lg bg-bgApp text-textStandard ${
errors.title ? 'border-red-500' : 'border-borderSubtle'
}`}
placeholder="Agent Name (required)"
placeholder="Agent Recipe Name (required)"
/>
{errors.title && <div className="text-red-500 text-sm mt-1">{errors.title}</div>}
</div>
@@ -348,7 +368,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
<div className="text-left">
<h3 className="font-medium text-textProminent">Activities</h3>
<p className="text-textSubtle text-sm">
Starting activities present in the home panel on a fresh goose session
Starting activities present in the home panel on a fresh session
</p>
</div>
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
@@ -360,9 +380,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
>
<div className="text-left">
<h3 className="font-medium text-textProminent">Instructions</h3>
<p className="text-textSubtle text-sm">
Starting activities present in the home panel on a fresh goose session
</p>
<p className="text-textSubtle text-sm">Recipe instructions sent to the model</p>
</div>
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
</button>
@@ -374,7 +392,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
<div className="text-left">
<h3 className="font-medium text-textProminent">Extensions</h3>
<p className="text-textSubtle text-sm">
Starting activities present in the home panel on a fresh goose session
Extensions to be enabled by default with this recipe
</p>
</div>
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
@@ -397,7 +415,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
</div>
{/* Action Buttons */}
<div className="flex flex-col space-y-2 pt-4">
<div className="flex flex-col space-y-2 pt-1">
<button
onClick={handleOpenAgent}
className="w-full p-3 bg-bgAppInverse text-textProminentInverse rounded-lg hover:bg-bgStandardInverse disabled:opacity-50 disabled:cursor-not-allowed"
@@ -406,7 +424,10 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
Open agent
</button>
<button
onClick={() => window.close()}
onClick={() => {
localStorage.removeItem('recipe_editor_extensions');
window.close();
}}
className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle"
>
Cancel
@@ -425,10 +446,11 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
<Geese className="w-12 h-12 text-iconProminent" />
</div>
<h1 className="text-2xl font-medium text-center text-textProminent">
Create custom agent
Create an agent recipe
</h1>
<p className="text-textSubtle text-center mt-2 text-sm">
Your custom agent can be shared with others
Your custom agent recipe can be shared with others. Fill in the sections below to
create!
</p>
</div>
)}

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { ScrollArea } from '../ui/scroll-area';
import BackButton from '../ui/BackButton';
import type { View } from '../../App';

View File

@@ -19,9 +19,22 @@ import { ExtensionConfig } from '../../../api/types.gen';
interface ExtensionSectionProps {
deepLinkConfig?: ExtensionConfig;
showEnvVars?: boolean;
hideButtons?: boolean;
hideHeader?: boolean;
disableConfiguration?: boolean;
customToggle?: (extension: FixedExtensionEntry) => Promise<boolean | void>;
selectedExtensions?: string[]; // Add controlled state
}
export default function ExtensionsSection({ deepLinkConfig, showEnvVars }: ExtensionSectionProps) {
export default function ExtensionsSection({
deepLinkConfig,
showEnvVars,
hideButtons,
hideHeader,
disableConfiguration,
customToggle,
selectedExtensions = [],
}: ExtensionSectionProps) {
const { getExtensions, addExtension, removeExtension } = useConfig();
const [extensions, setExtensions] = useState<FixedExtensionEntry[]>([]);
const [selectedExtension, setSelectedExtension] = useState<FixedExtensionEntry | null>(null);
@@ -37,22 +50,35 @@ export default function ExtensionsSection({ deepLinkConfig, showEnvVars }: Exten
const fetchExtensions = useCallback(async () => {
const extensionsList = await getExtensions(true); // Force refresh
// Sort extensions by name to maintain consistent order
const sortedExtensions = [...extensionsList].sort((a, b) => {
// First sort by builtin
if (a.type === 'builtin' && b.type !== 'builtin') return -1;
if (a.type !== 'builtin' && b.type === 'builtin') return 1;
const sortedExtensions = [...extensionsList]
.sort((a, b) => {
// First sort by builtin
if (a.type === 'builtin' && b.type !== 'builtin') return -1;
if (a.type !== 'builtin' && b.type === 'builtin') return 1;
// Then sort by bundled (handle null/undefined cases)
const aBundled = a.bundled === true;
const bBundled = b.bundled === true;
if (aBundled && !bBundled) return -1;
if (!aBundled && bBundled) return 1;
// Then sort by bundled (handle null/undefined cases)
const aBundled = a.bundled === true;
const bBundled = b.bundled === true;
if (aBundled && !bBundled) return -1;
if (!aBundled && bBundled) return 1;
// Finally sort alphabetically within each group
return a.name.localeCompare(b.name);
});
// Finally sort alphabetically within each group
return a.name.localeCompare(b.name);
})
.map((ext) => ({
...ext,
// Use selectedExtensions to determine enabled state in recipe editor
enabled: disableConfiguration ? selectedExtensions.includes(ext.name) : ext.enabled,
}));
console.log(
'Setting extensions with selectedExtensions:',
selectedExtensions,
'Extensions:',
sortedExtensions
);
setExtensions(sortedExtensions);
}, [getExtensions]);
}, [getExtensions, disableConfiguration, selectedExtensions]);
useEffect(() => {
fetchExtensions();
@@ -60,6 +86,17 @@ export default function ExtensionsSection({ deepLinkConfig, showEnvVars }: Exten
}, []);
const handleExtensionToggle = async (extension: FixedExtensionEntry) => {
if (customToggle) {
await customToggle(extension);
// After custom toggle, update the local state to reflect the change
setExtensions((prevExtensions) =>
prevExtensions.map((ext) =>
ext.name === extension.name ? { ...ext, enabled: !ext.enabled } : ext
)
);
return true;
}
// If extension is enabled, we are trying to toggle if off, otherwise on
const toggleDirection = extension.enabled ? 'toggleOff' : 'toggleOn';
const extensionConfig = extractExtensionConfig(extension);
@@ -149,37 +186,46 @@ export default function ExtensionsSection({ deepLinkConfig, showEnvVars }: Exten
return (
<section id="extensions" className="px-8">
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-medium text-textStandard">Extensions</h2>
</div>
<div className="border-b border-borderSubtle pb-8">
<p className="text-sm text-textStandard mb-6">
These extensions use the Model Context Protocol (MCP). They can expand Goose's
capabilities using three main components: Prompts, Resources, and Tools.
</p>
{!hideHeader && (
<>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-medium text-textStandard">Extensions</h2>
</div>
<div className="border-b border-borderSubtle">
<p className="text-sm text-textStandard mb-6">
These extensions use the Model Context Protocol (MCP). They can expand Goose's
capabilities using three main components: Prompts, Resources, and Tools.
</p>
</div>
</>
)}
<div className={`${!hideHeader ? '' : 'border-b border-borderSubtle'} pb-8`}>
<ExtensionList
extensions={extensions}
onToggle={handleExtensionToggle}
onConfigure={handleConfigureClick}
disableConfiguration={disableConfiguration}
/>
<div className="flex gap-4 pt-4 w-full">
<Button
className="flex items-center gap-2 justify-center text-white dark:text-black bg-bgAppInverse hover:bg-bgStandardInverse [&>svg]:!size-4"
onClick={() => setIsAddModalOpen(true)}
>
<Plus className="h-4 w-4" />
Add custom extension
</Button>
<Button
className="flex items-center gap-2 justify-center text-textStandard bg-bgApp border border-borderSubtle hover:border-borderProminent hover:bg-bgApp [&>svg]:!size-4"
onClick={() => window.open('https://block.github.io/goose/v1/extensions/', '_blank')}
>
<GPSIcon size={12} />
Browse extensions
</Button>
</div>
{!hideButtons && (
<div className="flex gap-4 pt-4 w-full">
<Button
className="flex items-center gap-2 justify-center text-white dark:text-black bg-bgAppInverse hover:bg-bgStandardInverse [&>svg]:!size-4"
onClick={() => setIsAddModalOpen(true)}
>
<Plus className="h-4 w-4" />
Add custom extension
</Button>
<Button
className="flex items-center gap-2 justify-center text-textStandard bg-bgApp border border-borderSubtle hover:border-borderProminent hover:bg-bgApp [&>svg]:!size-4"
onClick={() => window.open('https://block.github.io/goose/v1/extensions/', '_blank')}
>
<GPSIcon size={12} />
Browse extensions
</Button>
</div>
)}
{/* Modal for updating an existing extension */}
{isModalOpen && selectedExtension && (

View File

@@ -6,11 +6,17 @@ import { getSubtitle, getFriendlyTitle } from './ExtensionList';
interface ExtensionItemProps {
extension: FixedExtensionEntry;
onToggle: (extension: FixedExtensionEntry) => Promise<boolean | void>;
onConfigure: (extension: FixedExtensionEntry) => void;
onToggle: (extension: FixedExtensionEntry) => Promise<boolean | void> | void;
onConfigure?: (extension: FixedExtensionEntry) => void;
isStatic?: boolean; // to not allow users to edit configuration
}
export default function ExtensionItem({ extension, onToggle, onConfigure }: ExtensionItemProps) {
export default function ExtensionItem({
extension,
onToggle,
onConfigure,
isStatic,
}: ExtensionItemProps) {
// Add local state to track the visual toggle state
const [visuallyEnabled, setVisuallyEnabled] = useState(extension.enabled);
// Track if we're in the process of toggling
@@ -59,7 +65,9 @@ export default function ExtensionItem({ extension, onToggle, onConfigure }: Exte
// Bundled extensions and builtins are not editable
// Over time we can take the first part of the conditional away as people have bundled: true in their config.yaml entries
const editable = !(extension.type === 'builtin' || extension.bundled);
// allow configuration editing if extension is not a builtin/bundled extension AND isStatic = false
const editable = !(extension.type === 'builtin' || extension.bundled) && !isStatic;
return (
<div
@@ -78,7 +86,7 @@ export default function ExtensionItem({ extension, onToggle, onConfigure }: Exte
{editable && (
<button
className="text-textSubtle hover:text-textStandard"
onClick={() => onConfigure(extension)}
onClick={() => (onConfigure ? onConfigure(extension) : () => {})}
>
<Gear className="h-4 w-4" />
</button>

View File

@@ -7,11 +7,17 @@ import { combineCmdAndArgs, removeShims } from '../utils';
interface ExtensionListProps {
extensions: FixedExtensionEntry[];
onToggle: (extension: FixedExtensionEntry) => Promise<boolean | void>;
onConfigure: (extension: FixedExtensionEntry) => void;
onToggle: (extension: FixedExtensionEntry) => Promise<boolean | void> | void;
onConfigure?: (extension: FixedExtensionEntry) => void;
isStatic?: boolean;
}
export default function ExtensionList({ extensions, onToggle, onConfigure }: ExtensionListProps) {
export default function ExtensionList({
extensions,
onToggle,
onConfigure,
isStatic,
}: ExtensionListProps) {
return (
<div className="grid grid-cols-2 gap-2 mb-2">
{extensions.map((extension) => (
@@ -20,6 +26,7 @@ export default function ExtensionList({ extensions, onToggle, onConfigure }: Ext
extension={extension}
onToggle={onToggle}
onConfigure={onConfigure}
isStatic={isStatic}
/>
))}
</div>

View File

@@ -150,7 +150,6 @@ export default function ProviderConfigurationModal() {
// go through the keys are remove them
for (const param of params) {
console.log('param', param.name, 'secret', param.secret);
await remove(param.name, param.secret);
}