feat: hook extensions up in settings-v2 (#1447)
This commit is contained in:
@@ -9,6 +9,7 @@ use utoipa::OpenApi;
|
|||||||
super::routes::config_management::read_config,
|
super::routes::config_management::read_config,
|
||||||
super::routes::config_management::add_extension,
|
super::routes::config_management::add_extension,
|
||||||
super::routes::config_management::remove_extension,
|
super::routes::config_management::remove_extension,
|
||||||
|
super::routes::config_management::update_extension,
|
||||||
super::routes::config_management::read_all_config
|
super::routes::config_management::read_all_config
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use axum::routing::put;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post},
|
||||||
@@ -194,6 +195,46 @@ pub async fn read_all_config(
|
|||||||
Ok(Json(ConfigResponse { config: values }))
|
Ok(Json(ConfigResponse { config: values }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/config/extension",
|
||||||
|
request_body = ExtensionQuery,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Extension configuration updated successfully", body = String),
|
||||||
|
(status = 404, description = "Extension not found"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn update_extension(
|
||||||
|
State(_state): State<Arc<Mutex<HashMap<String, Value>>>>,
|
||||||
|
Json(extension): Json<ExtensionQuery>,
|
||||||
|
) -> Result<Json<String>, StatusCode> {
|
||||||
|
let config = Config::global();
|
||||||
|
|
||||||
|
// Get current extensions
|
||||||
|
let mut extensions: HashMap<String, Value> = match config.get("extensions") {
|
||||||
|
Ok(exts) => exts,
|
||||||
|
Err(_) => return Err(StatusCode::NOT_FOUND),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if extension exists
|
||||||
|
if !extensions.contains_key(&extension.name) {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update extension configuration
|
||||||
|
extensions.insert(extension.name.clone(), extension.config);
|
||||||
|
|
||||||
|
// Save updated extensions
|
||||||
|
match config.set(
|
||||||
|
"extensions",
|
||||||
|
Value::Object(extensions.into_iter().collect()),
|
||||||
|
) {
|
||||||
|
Ok(_) => Ok(Json(format!("Updated extension {}", extension.name))),
|
||||||
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes(state: AppState) -> Router {
|
pub fn routes(state: AppState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/config", get(read_all_config))
|
.route("/config", get(read_all_config))
|
||||||
@@ -201,6 +242,7 @@ pub fn routes(state: AppState) -> Router {
|
|||||||
.route("/config/remove", post(remove_config))
|
.route("/config/remove", post(remove_config))
|
||||||
.route("/config/read", post(read_config))
|
.route("/config/read", post(read_config))
|
||||||
.route("/config/extension", post(add_extension))
|
.route("/config/extension", post(add_extension))
|
||||||
|
.route("/config/extension", put(update_extension))
|
||||||
.route("/config/extension", delete(remove_extension))
|
.route("/config/extension", delete(remove_extension))
|
||||||
.with_state(state.config)
|
.with_state(state.config)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
out
|
out
|
||||||
src/api/generated
|
src/api
|
||||||
*.lock
|
*.lock
|
||||||
@@ -37,7 +37,7 @@ const noWindowLocationHref = {
|
|||||||
module.exports = [
|
module.exports = [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
{
|
{
|
||||||
ignores: ['src/api/generated/**'],
|
ignores: ['src/api/**'],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: tsParser,
|
parser: tsParser,
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import { defineConfig } from '@hey-api/openapi-ts';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
input: './openapi.json',
|
input: './openapi.json',
|
||||||
output: './src/api/generated',
|
output: './src/api',
|
||||||
plugins: ['@hey-api/client-fetch'],
|
plugins: ['@hey-api/client-fetch'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,6 +68,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"super::routes::config_management"
|
||||||
|
],
|
||||||
|
"operationId": "update_extension",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ExtensionQuery"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Extension configuration updated successfully",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Extension not found"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"super::routes::config_management"
|
"super::routes::config_management"
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import {
|
|
||||||
readAllConfig,
|
|
||||||
readConfig,
|
|
||||||
removeConfig,
|
|
||||||
upsertConfig,
|
|
||||||
addExtension,
|
|
||||||
removeExtension,
|
|
||||||
} from './generated';
|
|
||||||
import { client } from './generated/client.gen';
|
|
||||||
|
|
||||||
// Initialize client configuration
|
|
||||||
client.setConfig({
|
|
||||||
baseUrl: window.appConfig.get('GOOSE_API_HOST') + ':' + window.appConfig.get('GOOSE_PORT'),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Secret-Key': window.appConfig.get('secretKey'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export class Config {
|
|
||||||
static async upsert(key: string, value: any, isSecret?: boolean) {
|
|
||||||
return await upsertConfig({
|
|
||||||
body: {
|
|
||||||
key,
|
|
||||||
value,
|
|
||||||
is_secret: isSecret,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static async read(key: string) {
|
|
||||||
return await readConfig({
|
|
||||||
body: { key },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static async remove(key: string) {
|
|
||||||
return await removeConfig({
|
|
||||||
body: { key },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static async readAll() {
|
|
||||||
const response = await readAllConfig();
|
|
||||||
return response.data.config;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async addExtension(name: string, config: any) {
|
|
||||||
return await addExtension({
|
|
||||||
body: { name, config },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static async removeExtension(name: string) {
|
|
||||||
return await removeExtension({
|
|
||||||
body: { key: name },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
|
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
|
||||||
import type { ReadAllConfigData, ReadAllConfigResponse, RemoveExtensionData, RemoveExtensionResponse, AddExtensionData, AddExtensionResponse, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen';
|
import type { ReadAllConfigData, ReadAllConfigResponse, RemoveExtensionData, RemoveExtensionResponse, AddExtensionData, AddExtensionResponse, UpdateExtensionData, UpdateExtensionResponse, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen';
|
||||||
import { client as _heyApiClient } from './client.gen';
|
import { client as _heyApiClient } from './client.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
|
||||||
@@ -47,6 +47,17 @@ export const addExtension = <ThrowOnError extends boolean = false>(options: Opti
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateExtension = <ThrowOnError extends boolean = false>(options: Options<UpdateExtensionData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? _heyApiClient).put<UpdateExtensionResponse, unknown, ThrowOnError>({
|
||||||
|
url: '/config/extension',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const readConfig = <ThrowOnError extends boolean = false>(options: Options<ReadConfigData, ThrowOnError>) => {
|
export const readConfig = <ThrowOnError extends boolean = false>(options: Options<ReadConfigData, ThrowOnError>) => {
|
||||||
return (options.client ?? _heyApiClient).get<unknown, unknown, ThrowOnError>({
|
return (options.client ?? _heyApiClient).get<unknown, unknown, ThrowOnError>({
|
||||||
url: '/config/read',
|
url: '/config/read',
|
||||||
@@ -89,6 +89,33 @@ export type AddExtensionResponses = {
|
|||||||
|
|
||||||
export type AddExtensionResponse = AddExtensionResponses[keyof AddExtensionResponses];
|
export type AddExtensionResponse = AddExtensionResponses[keyof AddExtensionResponses];
|
||||||
|
|
||||||
|
export type UpdateExtensionData = {
|
||||||
|
body: ExtensionQuery;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/config/extension';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateExtensionErrors = {
|
||||||
|
/**
|
||||||
|
* Extension not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
/**
|
||||||
|
* Internal server error
|
||||||
|
*/
|
||||||
|
500: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateExtensionResponses = {
|
||||||
|
/**
|
||||||
|
* Extension configuration updated successfully
|
||||||
|
*/
|
||||||
|
200: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateExtensionResponse = UpdateExtensionResponses[keyof UpdateExtensionResponses];
|
||||||
|
|
||||||
export type ReadConfigData = {
|
export type ReadConfigData = {
|
||||||
body: ConfigKeyQuery;
|
body: ConfigKeyQuery;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -1,5 +1,23 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
import { Config } from '../api/config';
|
import {
|
||||||
|
readAllConfig,
|
||||||
|
readConfig,
|
||||||
|
removeConfig,
|
||||||
|
upsertConfig,
|
||||||
|
addExtension as apiAddExtension,
|
||||||
|
removeExtension as apiRemoveExtension,
|
||||||
|
updateExtension as apiUpdateExtension,
|
||||||
|
} from '../api';
|
||||||
|
import { client } from '../api/client.gen';
|
||||||
|
|
||||||
|
// Initialize client configuration
|
||||||
|
client.setConfig({
|
||||||
|
baseUrl: window.appConfig.get('GOOSE_API_HOST') + ':' + window.appConfig.get('GOOSE_PORT'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Secret-Key': window.appConfig.get('secretKey'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
interface ConfigContextType {
|
interface ConfigContextType {
|
||||||
config: Record<string, any>;
|
config: Record<string, any>;
|
||||||
@@ -7,6 +25,7 @@ interface ConfigContextType {
|
|||||||
read: (key: string) => Promise<any>;
|
read: (key: string) => Promise<any>;
|
||||||
remove: (key: string) => Promise<void>;
|
remove: (key: string) => Promise<void>;
|
||||||
addExtension: (name: string, config: any) => Promise<void>;
|
addExtension: (name: string, config: any) => Promise<void>;
|
||||||
|
updateExtension: (name: string, config: any) => Promise<void>;
|
||||||
removeExtension: (name: string) => Promise<void>;
|
removeExtension: (name: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,42 +41,65 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load all configuration data on mount
|
// Load all configuration data on mount
|
||||||
(async () => {
|
(async () => {
|
||||||
const initialConfig = await Config.readAll();
|
const response = await readAllConfig();
|
||||||
setConfig(initialConfig || {});
|
setConfig(response.data.config || {});
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reloadConfig = async () => {
|
const reloadConfig = async () => {
|
||||||
const newConfig = await Config.readAll();
|
const response = await readAllConfig();
|
||||||
setConfig(newConfig || {});
|
setConfig(response.data.config || {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const upsert = async (key: string, value: any, isSecret?: boolean) => {
|
const upsert = async (key: string, value: any, isSecret?: boolean) => {
|
||||||
await Config.upsert(key, value, isSecret);
|
await upsertConfig({
|
||||||
|
body: {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
is_secret: isSecret,
|
||||||
|
},
|
||||||
|
});
|
||||||
await reloadConfig();
|
await reloadConfig();
|
||||||
};
|
};
|
||||||
|
|
||||||
const read = async (key: string) => {
|
const read = async (key: string) => {
|
||||||
return Config.read(key);
|
return await readConfig({
|
||||||
|
body: { key },
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (key: string) => {
|
const remove = async (key: string) => {
|
||||||
await Config.remove(key);
|
await removeConfig({
|
||||||
|
body: { key },
|
||||||
|
});
|
||||||
await reloadConfig();
|
await reloadConfig();
|
||||||
};
|
};
|
||||||
|
|
||||||
const addExtension = async (name: string, config: any) => {
|
const addExtension = async (name: string, config: any) => {
|
||||||
await Config.addExtension(name, config);
|
await apiAddExtension({
|
||||||
|
body: { name, config },
|
||||||
|
});
|
||||||
await reloadConfig();
|
await reloadConfig();
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeExtension = async (name: string) => {
|
const removeExtension = async (name: string) => {
|
||||||
await Config.removeExtension(name);
|
await apiRemoveExtension({
|
||||||
|
body: { key: name },
|
||||||
|
});
|
||||||
|
await reloadConfig();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateExtension = async (name: string, config: any) => {
|
||||||
|
await apiUpdateExtension({
|
||||||
|
body: { name, config },
|
||||||
|
});
|
||||||
await reloadConfig();
|
await reloadConfig();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigContext.Provider value={{ config, upsert, read, remove, addExtension, removeExtension }}>
|
<ConfigContext.Provider
|
||||||
|
value={{ config, upsert, read, remove, addExtension, updateExtension, removeExtension }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ConfigContext.Provider>
|
</ConfigContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +1,83 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Switch } from '../ui/switch';
|
import { Switch } from '../ui/switch';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { Gear } from '../icons/Gear';
|
import { Gear } from '../icons/Gear';
|
||||||
import { GPSIcon } from '../ui/icons';
|
import { GPSIcon } from '../ui/icons';
|
||||||
|
import { useConfig } from '../ConfigContext';
|
||||||
|
|
||||||
|
interface ExtensionConfig {
|
||||||
|
args?: string[];
|
||||||
|
cmd?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
envs?: Record<string, string>;
|
||||||
|
name: string;
|
||||||
|
type: 'stdio' | 'builtin';
|
||||||
|
}
|
||||||
|
|
||||||
interface ExtensionItem {
|
interface ExtensionItem {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
canConfigure?: boolean;
|
canConfigure: boolean;
|
||||||
|
config: ExtensionConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extensionItems: ExtensionItem[] = [
|
// Helper function to get a friendly title from extension name
|
||||||
{
|
const getFriendlyTitle = (name: string): string => {
|
||||||
id: 'dev',
|
return name
|
||||||
title: 'Developer Tools',
|
.split('-')
|
||||||
subtitle: 'Code editing and shell access',
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
enabled: true,
|
.join(' ');
|
||||||
canConfigure: true,
|
};
|
||||||
},
|
|
||||||
{
|
// Helper function to get a subtitle based on extension type and configuration
|
||||||
id: 'browser',
|
const getSubtitle = (config: ExtensionConfig): string => {
|
||||||
title: 'Web Browser',
|
if (config.type === 'builtin') {
|
||||||
subtitle: 'Internet access and web automation',
|
return 'Built-in extension';
|
||||||
enabled: false,
|
}
|
||||||
canConfigure: true,
|
return `${config.type.toUpperCase()} extension${config.cmd ? ` (${config.cmd})` : ''}`;
|
||||||
},
|
};
|
||||||
];
|
|
||||||
|
|
||||||
export default function ExtensionsSection() {
|
export default function ExtensionsSection() {
|
||||||
const [extensions, setExtensions] = useState<ExtensionItem[]>(extensionItems);
|
const { config, updateExtension } = useConfig();
|
||||||
|
const [extensions, setExtensions] = useState<ExtensionItem[]>([]);
|
||||||
|
|
||||||
const handleExtensionToggle = (id: string) => {
|
useEffect(() => {
|
||||||
setExtensions(
|
if (config.extensions) {
|
||||||
extensions.map((extension) => ({
|
const extensionItems: ExtensionItem[] = Object.entries(config.extensions).map(
|
||||||
...extension,
|
([name, ext]) => {
|
||||||
enabled: extension.id === id ? !extension.enabled : extension.enabled,
|
const extensionConfig = ext as ExtensionConfig;
|
||||||
}))
|
return {
|
||||||
);
|
id: name,
|
||||||
|
title: getFriendlyTitle(name),
|
||||||
|
subtitle: getSubtitle(extensionConfig),
|
||||||
|
enabled: extensionConfig.enabled,
|
||||||
|
canConfigure: extensionConfig.type === 'stdio' && !!extensionConfig.envs,
|
||||||
|
config: extensionConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setExtensions(extensionItems);
|
||||||
|
}
|
||||||
|
}, [config.extensions]);
|
||||||
|
|
||||||
|
const handleExtensionToggle = async (id: string) => {
|
||||||
|
const extension = extensions.find((ext) => ext.id === id);
|
||||||
|
if (extension) {
|
||||||
|
const updatedConfig = {
|
||||||
|
...extension.config,
|
||||||
|
enabled: !extension.config.enabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateExtension(id, updatedConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update extension:', error);
|
||||||
|
// Here you might want to add a toast notification for error feedback
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user