feat: wip on ConfigProvider and integration in settings_v2 (#1395)

This commit is contained in:
Alex Hancock
2025-02-26 17:28:24 -05:00
committed by GitHub
parent 26348a695d
commit 1919cbf743
7 changed files with 185 additions and 411 deletions
@@ -0,0 +1,72 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { Config } from '../api/config';
interface ConfigContextType {
config: Record<string, any>;
upsert: (key: string, value: any, isSecret?: boolean) => Promise<void>;
read: (key: string) => Promise<any>;
remove: (key: string) => Promise<void>;
addExtension: (name: string, config: any) => Promise<void>;
removeExtension: (name: string) => Promise<void>;
}
interface ConfigProviderProps {
children: React.ReactNode;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
const [config, setConfig] = useState<Record<string, any>>({});
useEffect(() => {
// Load all configuration data on mount
(async () => {
const initialConfig = await Config.readAll();
setConfig(initialConfig || {});
})();
}, []);
const reloadConfig = async () => {
const newConfig = await Config.readAll();
setConfig(newConfig || {});
};
const upsert = async (key: string, value: any, isSecret?: boolean) => {
await Config.upsert(key, value, isSecret);
await reloadConfig();
};
const read = async (key: string) => {
return Config.read(key);
};
const remove = async (key: string) => {
await Config.remove(key);
await reloadConfig();
};
const addExtension = async (name: string, config: any) => {
await Config.addExtension(name, config);
await reloadConfig();
};
const removeExtension = async (name: string) => {
await Config.removeExtension(name);
await reloadConfig();
};
return (
<ConfigContext.Provider value={{ config, upsert, read, remove, addExtension, removeExtension }}>
{children}
</ConfigContext.Provider>
);
};
export const useConfig = () => {
const context = useContext(ConfigContext);
if (context === undefined) {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
};
@@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { Switch } from '../ui/switch';
import { Plus } from 'lucide-react';
import { Gear } from '../icons/Gear';
import { GPSIcon } from '../ui/icons';
interface ExtensionItem {
id: string;
title: string;
subtitle: string;
enabled: boolean;
canConfigure?: boolean;
}
const extensionItems: ExtensionItem[] = [
{
id: 'dev',
title: 'Developer Tools',
subtitle: 'Code editing and shell access',
enabled: true,
canConfigure: true,
},
{
id: 'browser',
title: 'Web Browser',
subtitle: 'Internet access and web automation',
enabled: false,
canConfigure: true,
},
];
export default function ExtensionsSection() {
const [extensions, setExtensions] = useState<ExtensionItem[]>(extensionItems);
const handleExtensionToggle = (id: string) => {
setExtensions(
extensions.map((extension) => ({
...extension,
enabled: extension.id === id ? !extension.enabled : extension.enabled,
}))
);
};
return (
<section id="extensions">
<div className="flex justify-between items-center mb-6 px-8">
<h1 className="text-3xl font-medium text-textStandard">Extensions</h1>
</div>
<div className="px-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>
<div className="space-y-2">
{extensions.map((extension, index) => (
<React.Fragment key={extension.id}>
<div className="flex items-center justify-between py-3">
<div className="space-y-1">
<h3 className="font-medium text-textStandard">{extension.title}</h3>
<p className="text-sm text-textSubtle">{extension.subtitle}</p>
</div>
<div className="flex items-center gap-4">
{extension.canConfigure && (
<button className="text-textSubtle hover:text-textStandard">
<Gear className="h-5 w-5" />
</button>
)}
<Switch
checked={extension.enabled}
onCheckedChange={() => handleExtensionToggle(extension.id)}
className="bg-[#393838] [&_span[data-state]]:bg-white"
/>
</div>
</div>
{index < extensions.length - 1 && <div className="h-px bg-borderSubtle" />}
</React.Fragment>
))}
</div>
<div className="flex gap-4 pt-4 w-full">
<Button className="flex items-center gap-2 flex-1 justify-center bg-[#393838] hover:bg-subtle">
<Plus className="h-4 w-4" />
Manually Add
</Button>
<Button
className="flex items-center gap-2 flex-1 justify-center text-textSubtle border-standard bg-grey-60 hover:bg-subtle"
onClick={() => window.open('https://block.github.io/goose/v1/extensions/', '_blank')}
>
<GPSIcon size={18} />
Visit Extensions
</Button>
</div>
</div>
</section>
);
}
@@ -2,11 +2,11 @@ import React from 'react';
import { ScrollArea } from '../ui/scroll-area';
import BackButton from '../ui/BackButton';
import type { View } from '../../App';
import { useConfig } from '../ConfigContext';
import { Button } from '../ui/button';
import { Switch } from '../ui/switch';
import { Plus } from 'lucide-react';
import { Gear } from '../icons/Gear';
import { GPSIcon } from '../ui/icons';
import ExtensionsSection from './ExtensionsSection';
interface ModelOption {
id: string;
@@ -15,14 +15,6 @@ interface ModelOption {
selected: boolean;
}
interface ExtensionItem {
id: string;
title: string;
subtitle: string;
enabled: boolean;
canConfigure?: boolean;
}
// Mock data - replace with actual data source
const defaultModelOptions: ModelOption[] = [
{
@@ -39,23 +31,6 @@ const defaultModelOptions: ModelOption[] = [
},
];
const extensionItems: ExtensionItem[] = [
{
id: 'dev',
title: 'Developer Tools',
subtitle: 'Code editing and shell access',
enabled: true,
canConfigure: true,
},
{
id: 'browser',
title: 'Web Browser',
subtitle: 'Internet access and web automation',
enabled: false,
canConfigure: true,
},
];
export type SettingsViewOptions = {
extensionId?: string;
showEnvVars?: boolean;
@@ -71,7 +46,10 @@ export default function SettingsView({
viewOptions: SettingsViewOptions;
}) {
const [modelOptions, setModelOptions] = React.useState<ModelOption[]>(defaultModelOptions);
const [extensions, setExtensions] = React.useState<ExtensionItem[]>(extensionItems);
const { config } = useConfig();
console.log(config);
const handleModelSelect = (selectedId: string) => {
setModelOptions(
@@ -82,15 +60,6 @@ export default function SettingsView({
);
};
const handleExtensionToggle = (id: string) => {
setExtensions(
extensions.map((extension) => ({
...extension,
enabled: extension.id === id ? !extension.enabled : extension.enabled,
}))
);
};
return (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
@@ -146,57 +115,7 @@ export default function SettingsView({
</section>
{/* Extensions Section */}
<section id="extensions">
<div className="flex justify-between items-center mb-6 px-8">
<h1 className="text-3xl font-medium text-textStandard">Extensions</h1>
</div>
<div className="px-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>
<div className="space-y-2">
{extensions.map((extension, index) => (
<React.Fragment key={extension.id}>
<div className="flex items-center justify-between py-3">
<div className="space-y-1">
<h3 className="font-medium text-textStandard">{extension.title}</h3>
<p className="text-sm text-textSubtle">{extension.subtitle}</p>
</div>
<div className="flex items-center gap-4">
{extension.canConfigure && (
<button className="text-textSubtle hover:text-textStandard">
<Gear className="h-5 w-5" />
</button>
)}
<Switch
checked={extension.enabled}
onCheckedChange={() => handleExtensionToggle(extension.id)}
className="bg-[#393838] [&_span[data-state]]:bg-white"
/>
</div>
</div>
{index < extensions.length - 1 && <div className="h-px bg-borderSubtle" />}
</React.Fragment>
))}
</div>
<div className="flex gap-4 pt-4 w-full">
<Button className="flex items-center gap-2 flex-1 justify-center bg-[#393838] hover:bg-subtle">
<Plus className="h-4 w-4" />
Manually Add
</Button>
<Button
className="flex items-center gap-2 flex-1 justify-center text-textSubtle border-standard bg-grey-60 hover:bg-subtle"
onClick={() =>
window.open('https://block.github.io/goose/v1/extensions/', '_blank')
}
>
<GPSIcon size={18} />
Visit Extensions
</Button>
</div>
</div>
</section>
<ExtensionsSection />
</div>
</div>
</div>
-107
View File
@@ -1,107 +0,0 @@
import React, { useEffect, useState } from 'react';
import { ConfigAPI, ConfigResponse } from './api';
export const ConfigManager: React.FC = () => {
const [config, setConfig] = useState<ConfigResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
const data = await ConfigAPI.readAllConfig();
setConfig(data);
setError(null);
} catch (err) {
setError('Failed to load configuration');
console.error(err);
}
};
const handleUpsert = async () => {
try {
await ConfigAPI.upsertConfig({
key: newKey,
value: newValue as any, // You might want to add proper parsing here
});
await loadConfig();
setNewKey('');
setNewValue('');
setError(null);
} catch (err) {
setError('Failed to update configuration');
console.error(err);
}
};
const handleRemove = async (key: string) => {
try {
await ConfigAPI.removeConfig(key);
await loadConfig();
setError(null);
} catch (err) {
setError('Failed to remove configuration');
console.error(err);
}
};
return (
<div className="p-4">
<h2 className="text-2xl font-bold mb-4">Configuration Manager</h2>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
<div className="mb-4">
<h3 className="text-lg font-semibold mb-2">Add/Update Configuration</h3>
<div className="flex gap-2">
<input
type="text"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="Key"
className="border p-2 rounded"
/>
<input
type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder="Value"
className="border p-2 rounded"
/>
<button onClick={handleUpsert} className="bg-blue-500 text-white px-4 py-2 rounded">
Save
</button>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-2">Current Configuration</h3>
{config && (
<div className="border rounded">
{Object.entries(config.config).map(([key, value]) => (
<div key={key} className="p-2 border-b flex justify-between items-center">
<div>
<span className="font-medium">{key}:</span> <span>{JSON.stringify(value)}</span>
</div>
<button
onClick={() => handleRemove(key)}
className="text-red-500 hover:text-red-700"
>
Remove
</button>
</div>
))}
</div>
)}
</div>
</div>
);
};
-98
View File
@@ -1,98 +0,0 @@
import { Value } from 'yaml';
export interface UpsertConfigQuery {
key: string;
value: Value;
isSecret?: boolean;
}
export interface ConfigKeyQuery {
key: string;
}
export interface ExtensionQuery {
name: string;
config: Value;
}
export interface ConfigResponse {
config: Record<string, Value>;
}
const API_BASE = 'http://localhost:3000'; // Update this with your actual API base URL
export class ConfigAPI {
static async readAllConfig(): Promise<ConfigResponse> {
const response = await fetch(`${API_BASE}/config`);
if (!response.ok) {
throw new Error('Failed to fetch config');
}
return response.json();
}
static async upsertConfig(query: UpsertConfigQuery): Promise<string> {
const params = new URLSearchParams({
key: query.key,
value: JSON.stringify(query.value),
...(query.isSecret && { is_secret: String(query.isSecret) }),
});
const response = await fetch(`${API_BASE}/config/upsert?${params}`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to upsert config');
}
return response.text();
}
static async removeConfig(key: string): Promise<string> {
const params = new URLSearchParams({ key });
const response = await fetch(`${API_BASE}/config/remove?${params}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to remove config');
}
return response.text();
}
static async readConfig(key: string): Promise<Value> {
const params = new URLSearchParams({ key });
const response = await fetch(`${API_BASE}/config/read?${params}`);
if (!response.ok) {
throw new Error('Failed to read config');
}
return response.json();
}
static async addExtension(extension: ExtensionQuery): Promise<string> {
const response = await fetch(`${API_BASE}/config/extension`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(extension),
});
if (!response.ok) {
throw new Error('Failed to add extension');
}
return response.text();
}
static async removeExtension(name: string): Promise<string> {
const params = new URLSearchParams({ key: name });
const response = await fetch(`${API_BASE}/config/extension?${params}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to remove extension');
}
return response.text();
}
}
-111
View File
@@ -1,111 +0,0 @@
import { useState, useCallback } from 'react';
import { Config } from '../api/config';
import { toast } from 'react-toastify';
export interface UseConfigOptions {
onError?: (error: Error) => void;
showToasts?: boolean;
}
export function useConfig(options: UseConfigOptions = {}) {
const { onError, showToasts = true } = options;
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const handleError = useCallback(
(error: Error, message: string) => {
setError(error);
if (showToasts) {
toast.error(message);
}
if (onError) {
onError(error);
}
},
[onError, showToasts]
);
const loadConfigs = useCallback(async () => {
try {
setLoading(true);
setError(null);
const configs = await Config.readAll();
return configs;
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to load configurations');
handleError(error, 'Failed to load configurations');
return {};
} finally {
setLoading(false);
}
}, [handleError]);
const addConfig = useCallback(
async (key: string, value: any) => {
try {
setLoading(true);
setError(null);
await Config.upsert(key, value);
if (showToasts) {
toast.success(`Successfully added configuration: ${key}`);
}
return true;
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to add configuration');
handleError(error, `Failed to add configuration: ${key}`);
return false;
} finally {
setLoading(false);
}
},
[handleError, showToasts]
);
const removeConfig = useCallback(
async (key: string) => {
try {
setLoading(true);
setError(null);
await Config.remove(key);
if (showToasts) {
toast.success(`Successfully removed configuration: ${key}`);
}
return true;
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to remove configuration');
handleError(error, `Failed to remove configuration: ${key}`);
return false;
} finally {
setLoading(false);
}
},
[handleError, showToasts]
);
const readConfig = useCallback(
async (key: string) => {
try {
setLoading(true);
setError(null);
const value = await Config.read(key);
return value;
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to read configuration');
handleError(error, `Failed to read configuration: ${key}`);
return null;
} finally {
setLoading(false);
}
},
[handleError]
);
return {
loading,
error,
loadConfigs,
addConfig,
removeConfig,
readConfig,
};
}
+10 -7
View File
@@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { ModelProvider } from './components/settings/models/ModelContext';
import { ConfigProvider } from './components/ConfigContext';
import { ErrorBoundary } from './components/ErrorBoundary';
import { ActiveKeysProvider } from './components/settings/api_keys/ActiveKeysContext';
import { patchConsoleLogging } from './utils';
@@ -10,12 +11,14 @@ patchConsoleLogging();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ModelProvider>
<ActiveKeysProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
</ActiveKeysProvider>
</ModelProvider>
<ConfigProvider>
<ModelProvider>
<ActiveKeysProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
</ActiveKeysProvider>
</ModelProvider>
</ConfigProvider>
</React.StrictMode>
);