mirror of
https://github.com/aljazceru/goose.git
synced 2026-02-02 05:04:23 +01:00
ldelalande+alexhancock/shared-config-client (#1219)
Co-authored-by: Lily Delalande <ldelalande@squareup.com>
This commit is contained in:
2
Justfile
2
Justfile
@@ -9,6 +9,8 @@ release-binary:
|
||||
@echo "Building release version..."
|
||||
cargo build --release
|
||||
@just copy-binary
|
||||
@echo "Generating OpenAPI schema..."
|
||||
cargo run -p goose-server --bin generate_schema
|
||||
|
||||
# Build Windows executable
|
||||
release-windows:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
src/api/generated
|
||||
*.lock
|
||||
@@ -37,6 +37,7 @@ const noWindowLocationHref = {
|
||||
module.exports = [
|
||||
js.configs.recommended,
|
||||
{
|
||||
ignores: ['src/api/generated/**'],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
|
||||
7
ui/desktop/openapi-ts.config.ts
Normal file
7
ui/desktop/openapi-ts.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from '@hey-api/openapi-ts';
|
||||
|
||||
export default defineConfig({
|
||||
input: './openapi.json',
|
||||
output: './src/api/generated',
|
||||
plugins: ['@hey-api/client-fetch'],
|
||||
});
|
||||
264
ui/desktop/openapi.json
Normal file
264
ui/desktop/openapi.json
Normal file
@@ -0,0 +1,264 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "goose-server",
|
||||
"description": "An AI agent",
|
||||
"contact": {
|
||||
"name": "Block",
|
||||
"email": "ai-oss-tools@block.xyz"
|
||||
},
|
||||
"license": {
|
||||
"name": "Apache-2.0"
|
||||
},
|
||||
"version": "1.0.5"
|
||||
},
|
||||
"paths": {
|
||||
"/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"super::routes::config_management"
|
||||
],
|
||||
"operationId": "read_all_config",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "All configuration values retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConfigResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/extension": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"super::routes::config_management"
|
||||
],
|
||||
"operationId": "add_extension",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExtensionQuery"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Extension added successfully",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"super::routes::config_management"
|
||||
],
|
||||
"operationId": "remove_extension",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConfigKeyQuery"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Extension removed successfully",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Extension not found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/read": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"super::routes::config_management"
|
||||
],
|
||||
"operationId": "read_config",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConfigKeyQuery"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Configuration value retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Configuration key not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/remove": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"super::routes::config_management"
|
||||
],
|
||||
"operationId": "remove_config",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConfigKeyQuery"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Configuration value removed successfully",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Configuration key not found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/upsert": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"super::routes::config_management"
|
||||
],
|
||||
"operationId": "upsert_config",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpsertConfigQuery"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Configuration value upserted successfully",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ConfigKeyQuery": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConfigResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"config"
|
||||
],
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExtensionQuery": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"config"
|
||||
],
|
||||
"properties": {
|
||||
"config": {},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpsertConfigQuery": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"is_secret": {
|
||||
"type": "boolean",
|
||||
"nullable": true
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3826
ui/desktop/package-lock.json
generated
3826
ui/desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@
|
||||
"description": "Goose App",
|
||||
"main": ".vite/build/main.js",
|
||||
"scripts": {
|
||||
"start-gui": "electron-forge start",
|
||||
"generate-api": "openapi-ts",
|
||||
"start-gui": "npm run generate-api && electron-forge start",
|
||||
"start": "cd ../.. && just run-ui",
|
||||
"start:test-error": "GOOSE_TEST_ERROR=true electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
@@ -31,6 +32,7 @@
|
||||
"@electron-forge/plugin-vite": "^7.5.0",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@eslint/js": "^8.56.0",
|
||||
"@hey-api/openapi-ts": "^0.64.4",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/electron-squirrel-startup": "^1.0.2",
|
||||
@@ -64,6 +66,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^0.0.72",
|
||||
"@ai-sdk/ui-utils": "^1.0.2",
|
||||
"@hey-api/client-fetch": "^0.8.1",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
|
||||
@@ -24,6 +24,7 @@ import Splash from './components/Splash';
|
||||
import Settings from './components/settings/Settings';
|
||||
import MoreModelsSettings from './components/settings/models/MoreModels';
|
||||
import ConfigureProviders from './components/settings/providers/ConfigureProviders';
|
||||
import { ConfigPage } from './components/pages/ConfigPage';
|
||||
|
||||
export interface Chat {
|
||||
id: number;
|
||||
@@ -35,7 +36,13 @@ export interface Chat {
|
||||
}>;
|
||||
}
|
||||
|
||||
export type View = 'welcome' | 'chat' | 'settings' | 'moreModels' | 'configureProviders';
|
||||
export type View =
|
||||
| 'welcome'
|
||||
| 'chat'
|
||||
| 'settings'
|
||||
| 'moreModels'
|
||||
| 'configureProviders'
|
||||
| 'configPage';
|
||||
|
||||
// This component is our main chat content.
|
||||
// We'll move the majority of chat logic here, minus the 'view' state.
|
||||
@@ -388,6 +395,14 @@ export default function ChatWindow() {
|
||||
setView={setView}
|
||||
/>
|
||||
)}
|
||||
{view === 'configPage' && (
|
||||
<ConfigPage
|
||||
onClose={() => {
|
||||
setView('chat');
|
||||
}}
|
||||
setView={setView}
|
||||
/>
|
||||
)}
|
||||
{view === 'configureProviders' && (
|
||||
<ConfigureProviders
|
||||
onClose={() => {
|
||||
|
||||
150
ui/desktop/src/ConfigTestingWindow.tsx
Normal file
150
ui/desktop/src/ConfigTestingWindow.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Input } from './components/ui/input';
|
||||
import { Label } from './components/ui/label';
|
||||
import { Card } from './components/ui/card';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import BackButton from './components/ui/BackButton';
|
||||
import { useConfig } from './hooks/useConfig';
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [configs, setConfigs] = useState<Record<string, any>>({});
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { loading, error, loadConfigs, addConfig, removeConfig } = useConfig();
|
||||
|
||||
// Fetch all configs on component mount
|
||||
useEffect(() => {
|
||||
const fetchConfigs = async () => {
|
||||
const result = await loadConfigs();
|
||||
setConfigs(result);
|
||||
};
|
||||
fetchConfigs();
|
||||
}, [loadConfigs]);
|
||||
|
||||
const handleAddConfig = async () => {
|
||||
if (!newKey || !newValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedValue = newValue;
|
||||
// Try to parse as JSON if it looks like JSON
|
||||
if (newValue.trim().startsWith('{') || newValue.trim().startsWith('[')) {
|
||||
try {
|
||||
parsedValue = JSON.parse(newValue);
|
||||
} catch (e) {
|
||||
// If parsing fails, use the original string value
|
||||
console.log('Value is not valid JSON, using as string');
|
||||
}
|
||||
}
|
||||
|
||||
const success = await addConfig(newKey, parsedValue);
|
||||
if (success) {
|
||||
setNewKey('');
|
||||
setNewValue('');
|
||||
const updatedConfigs = await loadConfigs();
|
||||
setConfigs(updatedConfigs);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveConfig = async (key: string) => {
|
||||
const success = await removeConfig(key);
|
||||
if (success) {
|
||||
const updatedConfigs = await loadConfigs();
|
||||
setConfigs(updatedConfigs);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full">
|
||||
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
|
||||
|
||||
<div className="flex flex-col pb-24">
|
||||
<div className="px-8 pt-6 pb-4">
|
||||
<BackButton onClick={() => navigate('/settings')} />
|
||||
<h1 className="text-3xl font-medium text-textStandard mt-1">Configuration</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 py-8 pt-[20px] px-8">
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
{/* Add new config form */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Add New Configuration</h2>
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<Label htmlFor="configKey">Key</Label>
|
||||
<Input
|
||||
id="configKey"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
placeholder="Enter config key"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="configValue">Value</Label>
|
||||
<Input
|
||||
id="configValue"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
placeholder="Enter config value (string or JSON)"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleAddConfig} disabled={loading}>
|
||||
{loading ? 'Adding...' : 'Add Configuration'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config list */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Current Configurations</h2>
|
||||
<div className="grid gap-4">
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500">Loading configurations...</div>
|
||||
) : Object.keys(configs).length === 0 ? (
|
||||
<div className="text-center text-gray-500">No configurations found</div>
|
||||
) : (
|
||||
Object.entries(configs).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded"
|
||||
>
|
||||
<div className="break-all">
|
||||
<span className="font-medium">{key}:</span>{' '}
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
{typeof value === 'object'
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleRemoveConfig(key)}
|
||||
size="sm"
|
||||
className="ml-4 shrink-0"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '...' : 'Remove'}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
ui/desktop/src/api/config.ts
Normal file
59
ui/desktop/src/api/config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
16
ui/desktop/src/api/generated/client.gen.ts
Normal file
16
ui/desktop/src/api/generated/client.gen.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { ClientOptions } from './types.gen';
|
||||
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from '@hey-api/client-fetch';
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
* and the returned object will become the client's initial configuration.
|
||||
*
|
||||
* You may want to initialize your client this way instead of calling
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = (override?: Config<DefaultClientOptions & T>) => Config<Required<DefaultClientOptions> & T>;
|
||||
|
||||
export const client = createClient(createConfig<ClientOptions>());
|
||||
3
ui/desktop/src/api/generated/index.ts
Normal file
3
ui/desktop/src/api/generated/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
export * from './types.gen';
|
||||
export * from './sdk.gen';
|
||||
81
ui/desktop/src/api/generated/sdk.gen.ts
Normal file
81
ui/desktop/src/api/generated/sdk.gen.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
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 { client as _heyApiClient } from './client.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
|
||||
/**
|
||||
* You can provide a client instance returned by `createClient()` instead of
|
||||
* individual options. This might be also useful if you want to implement a
|
||||
* custom client.
|
||||
*/
|
||||
client?: Client;
|
||||
/**
|
||||
* You can pass arbitrary values through the `meta` object. This can be
|
||||
* used to access values that aren't defined as part of the SDK function.
|
||||
*/
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const readAllConfig = <ThrowOnError extends boolean = false>(options?: Options<ReadAllConfigData, ThrowOnError>) => {
|
||||
return (options?.client ?? _heyApiClient).get<ReadAllConfigResponse, unknown, ThrowOnError>({
|
||||
url: '/config',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const removeExtension = <ThrowOnError extends boolean = false>(options: Options<RemoveExtensionData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).delete<RemoveExtensionResponse, unknown, ThrowOnError>({
|
||||
url: '/config/extension',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const addExtension = <ThrowOnError extends boolean = false>(options: Options<AddExtensionData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<AddExtensionResponse, unknown, ThrowOnError>({
|
||||
url: '/config/extension',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const readConfig = <ThrowOnError extends boolean = false>(options: Options<ReadConfigData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).get<unknown, unknown, ThrowOnError>({
|
||||
url: '/config/read',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const removeConfig = <ThrowOnError extends boolean = false>(options: Options<RemoveConfigData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<RemoveConfigResponse, unknown, ThrowOnError>({
|
||||
url: '/config/remove',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const upsertConfig = <ThrowOnError extends boolean = false>(options: Options<UpsertConfigData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<UpsertConfigResponse, unknown, ThrowOnError>({
|
||||
url: '/config/upsert',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
165
ui/desktop/src/api/generated/types.gen.ts
Normal file
165
ui/desktop/src/api/generated/types.gen.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type ConfigKeyQuery = {
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type ConfigResponse = {
|
||||
config: {};
|
||||
};
|
||||
|
||||
export type ExtensionQuery = {
|
||||
config: unknown;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type UpsertConfigQuery = {
|
||||
is_secret?: boolean | null;
|
||||
key: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type ReadAllConfigData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/config';
|
||||
};
|
||||
|
||||
export type ReadAllConfigResponses = {
|
||||
/**
|
||||
* All configuration values retrieved successfully
|
||||
*/
|
||||
200: ConfigResponse;
|
||||
};
|
||||
|
||||
export type ReadAllConfigResponse = ReadAllConfigResponses[keyof ReadAllConfigResponses];
|
||||
|
||||
export type RemoveExtensionData = {
|
||||
body: ConfigKeyQuery;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/config/extension';
|
||||
};
|
||||
|
||||
export type RemoveExtensionErrors = {
|
||||
/**
|
||||
* Extension not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type RemoveExtensionResponses = {
|
||||
/**
|
||||
* Extension removed successfully
|
||||
*/
|
||||
200: string;
|
||||
};
|
||||
|
||||
export type RemoveExtensionResponse = RemoveExtensionResponses[keyof RemoveExtensionResponses];
|
||||
|
||||
export type AddExtensionData = {
|
||||
body: ExtensionQuery;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/config/extension';
|
||||
};
|
||||
|
||||
export type AddExtensionErrors = {
|
||||
/**
|
||||
* Invalid request
|
||||
*/
|
||||
400: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type AddExtensionResponses = {
|
||||
/**
|
||||
* Extension added successfully
|
||||
*/
|
||||
200: string;
|
||||
};
|
||||
|
||||
export type AddExtensionResponse = AddExtensionResponses[keyof AddExtensionResponses];
|
||||
|
||||
export type ReadConfigData = {
|
||||
body: ConfigKeyQuery;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/config/read';
|
||||
};
|
||||
|
||||
export type ReadConfigErrors = {
|
||||
/**
|
||||
* Configuration key not found
|
||||
*/
|
||||
404: unknown;
|
||||
};
|
||||
|
||||
export type ReadConfigResponses = {
|
||||
/**
|
||||
* Configuration value retrieved successfully
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type RemoveConfigData = {
|
||||
body: ConfigKeyQuery;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/config/remove';
|
||||
};
|
||||
|
||||
export type RemoveConfigErrors = {
|
||||
/**
|
||||
* Configuration key not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type RemoveConfigResponses = {
|
||||
/**
|
||||
* Configuration value removed successfully
|
||||
*/
|
||||
200: string;
|
||||
};
|
||||
|
||||
export type RemoveConfigResponse = RemoveConfigResponses[keyof RemoveConfigResponses];
|
||||
|
||||
export type UpsertConfigData = {
|
||||
body: UpsertConfigQuery;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/config/upsert';
|
||||
};
|
||||
|
||||
export type UpsertConfigErrors = {
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type UpsertConfigResponses = {
|
||||
/**
|
||||
* Configuration value upserted successfully
|
||||
*/
|
||||
200: string;
|
||||
};
|
||||
|
||||
export type UpsertConfigResponse = UpsertConfigResponses[keyof UpsertConfigResponses];
|
||||
|
||||
export type ClientOptions = {
|
||||
baseUrl: `${string}://${string}` | (string & {});
|
||||
};
|
||||
162
ui/desktop/src/components/pages/ConfigPage.tsx
Normal file
162
ui/desktop/src/components/pages/ConfigPage.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Card } from '../ui/card';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import BackButton from '../ui/BackButton';
|
||||
import { useConfig } from '../../hooks/useConfig';
|
||||
import type { View } from '@/src/ChatWindow';
|
||||
|
||||
interface ConfigItem {
|
||||
key: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export function ConfigPage({
|
||||
onClose,
|
||||
setView,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
setView?: (view: View) => void;
|
||||
}) {
|
||||
const [configs, setConfigs] = useState<Record<string, any>>({});
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { loading, error, loadConfigs, addConfig, removeConfig } = useConfig();
|
||||
|
||||
// Fetch all configs on component mount
|
||||
useEffect(() => {
|
||||
const fetchConfigs = async () => {
|
||||
const result = await loadConfigs();
|
||||
setConfigs(result);
|
||||
};
|
||||
fetchConfigs();
|
||||
}, [loadConfigs]);
|
||||
|
||||
const handleAddConfig = async () => {
|
||||
if (!newKey || !newValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedValue = newValue;
|
||||
// Try to parse as JSON if it looks like JSON
|
||||
if (newValue.trim().startsWith('{') || newValue.trim().startsWith('[')) {
|
||||
try {
|
||||
parsedValue = JSON.parse(newValue);
|
||||
} catch (e) {
|
||||
// If parsing fails, use the original string value
|
||||
console.log('Value is not valid JSON, using as string');
|
||||
}
|
||||
}
|
||||
|
||||
const success = await addConfig(newKey, parsedValue);
|
||||
if (success) {
|
||||
setNewKey('');
|
||||
setNewValue('');
|
||||
const updatedConfigs = await loadConfigs();
|
||||
setConfigs(updatedConfigs);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveConfig = async (key: string) => {
|
||||
const success = await removeConfig(key);
|
||||
if (success) {
|
||||
const updatedConfigs = await loadConfigs();
|
||||
setConfigs(updatedConfigs);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full">
|
||||
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
|
||||
|
||||
<div className="flex flex-col pb-24">
|
||||
<div className="px-8 pt-6 pb-4">
|
||||
<BackButton onClick={() => navigate('/settings')} />
|
||||
<h1 className="text-3xl font-medium text-textStandard mt-1">Configuration</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 py-8 pt-[20px] px-8">
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
{/* Add new config form */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Add New Configuration</h2>
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<Label htmlFor="configKey">Key</Label>
|
||||
<Input
|
||||
id="configKey"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
placeholder="Enter config key"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="configValue">Value</Label>
|
||||
<Input
|
||||
id="configValue"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
placeholder="Enter config value (string or JSON)"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleAddConfig} disabled={loading}>
|
||||
{loading ? 'Adding...' : 'Add Configuration'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config list */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Current Configurations</h2>
|
||||
<div className="grid gap-4">
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500">Loading configurations...</div>
|
||||
) : Object.keys(configs).length === 0 ? (
|
||||
<div className="text-center text-gray-500">No configurations found</div>
|
||||
) : (
|
||||
Object.entries(configs).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded"
|
||||
>
|
||||
<div className="break-all">
|
||||
<span className="font-medium">{key}:</span>{' '}
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
{typeof value === 'object'
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleRemoveConfig(key)}
|
||||
size="sm"
|
||||
className="ml-4 shrink-0"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '...' : 'Remove'}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -181,6 +181,8 @@ export default function Settings({
|
||||
return BUILT_IN_EXTENSIONS.some((builtIn) => builtIn.id === extensionId);
|
||||
};
|
||||
|
||||
function navigate(s: string) {}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full">
|
||||
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
|
||||
@@ -259,6 +261,27 @@ export default function Settings({
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="configuration">
|
||||
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
|
||||
<h2 className="text-xl font-semibold text-textStandard">Configuration</h2>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/settings/config')}
|
||||
className="text-indigo-500 hover:text-indigo-600 text-sm"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8">
|
||||
<p className="text-sm text-textStandard mb-4">
|
||||
Manage application configuration and settings. You can view, add, edit, and
|
||||
remove configuration values.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
107
ui/desktop/src/config/ConfigManager.tsx
Normal file
107
ui/desktop/src/config/ConfigManager.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
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
ui/desktop/src/config/api.ts
Normal file
98
ui/desktop/src/config/api.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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
ui/desktop/src/hooks/useConfig.ts
Normal file
111
ui/desktop/src/hooks/useConfig.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user