From e3d82df0305941adc0881d9d49643f135a03dd21 Mon Sep 17 00:00:00 2001 From: lily-de <119957291+lily-de@users.noreply.github.com> Date: Fri, 17 Jan 2025 15:18:03 -0800 Subject: [PATCH] feat: provider settings alpha version (#625) --- crates/goose-server/Cargo.toml | 1 + crates/goose-server/src/routes/agent.rs | 47 +++ .../src/routes/providers_and_keys.json | 38 +++ crates/goose-server/src/routes/secrets.rs | 153 ++++++++- crates/goose/src/key_manager.rs | 26 ++ ui/desktop/src/ChatWindow.tsx | 145 ++------ ui/desktop/src/components/MoreMenu.tsx | 12 + .../src/components/chat_window/ChatLayout.tsx | 10 + .../src/components/chat_window/ChatRoutes.tsx | 35 ++ ui/desktop/src/components/settings/Keys.tsx | 282 ++++++++++++++++ .../src/components/settings/SecretsList.tsx | 146 ++++++++ .../src/components/settings/Settings.tsx | 313 +++++++++--------- .../settings/modals/ConfirmDeletionModal.tsx | 33 ++ .../components/settings/providers/Header.tsx | 20 ++ .../settings/providers/ProviderCard.tsx | 114 +++++++ .../settings/providers/ProvidersList.tsx | 20 ++ .../components/settings/providers/types.ts | 38 +++ .../components/settings/providers/utils.ts | 68 ++++ ui/desktop/src/components/ui/toast.tsx | 4 +- .../ProviderSetupModal.tsx | 6 +- .../welcome_screen/WelcomeModal.tsx | 65 ++++ ui/desktop/src/types/electron.d.ts | 3 +- ui/desktop/src/utils/providerUtils.ts | 95 ++++-- 23 files changed, 1379 insertions(+), 295 deletions(-) create mode 100644 crates/goose-server/src/routes/providers_and_keys.json create mode 100644 ui/desktop/src/components/chat_window/ChatLayout.tsx create mode 100644 ui/desktop/src/components/chat_window/ChatRoutes.tsx create mode 100644 ui/desktop/src/components/settings/Keys.tsx create mode 100644 ui/desktop/src/components/settings/SecretsList.tsx create mode 100644 ui/desktop/src/components/settings/modals/ConfirmDeletionModal.tsx create mode 100644 ui/desktop/src/components/settings/providers/Header.tsx create mode 100644 ui/desktop/src/components/settings/providers/ProviderCard.tsx create mode 100644 ui/desktop/src/components/settings/providers/ProvidersList.tsx create mode 100644 ui/desktop/src/components/settings/providers/types.ts create mode 100644 ui/desktop/src/components/settings/providers/utils.ts rename ui/desktop/src/components/{ => welcome_screen}/ProviderSetupModal.tsx (96%) create mode 100644 ui/desktop/src/components/welcome_screen/WelcomeModal.tsx diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index f0020ea5b..ac4541b41 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -30,6 +30,7 @@ http = "1.0" config = { version = "0.14.1", features = ["toml"] } thiserror = "1.0" clap = { version = "4.4", features = ["derive"] } +once_cell = "1.18" [[bin]] name = "goosed" diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 2a1d23d17..5d7827913 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -7,6 +7,7 @@ use axum::{ }; use goose::{agents::AgentFactory, providers::factory}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; #[derive(Serialize)] struct VersionsResponse { @@ -25,6 +26,28 @@ struct CreateAgentResponse { version: String, } +#[derive(Deserialize)] +struct ProviderFile { + name: String, + description: String, + models: Vec, + required_keys: Vec, +} + +#[derive(Serialize)] +struct ProviderDetails { + name: String, + description: String, + models: Vec, + required_keys: Vec, +} + +#[derive(Serialize)] +struct ProviderList { + id: String, + details: ProviderDetails, +} + async fn get_versions() -> Json { let versions = AgentFactory::available_versions(); let default_version = AgentFactory::default_version().to_string(); @@ -64,9 +87,33 @@ async fn create_agent( Ok(Json(CreateAgentResponse { version })) } +async fn list_providers() -> Json> { + let contents = include_str!("providers_and_keys.json"); + + let providers: HashMap = + serde_json::from_str(contents).expect("Failed to parse providers_and_keys.json"); + + let response: Vec = providers + .into_iter() + .map(|(id, provider)| ProviderList { + id, + details: ProviderDetails { + name: provider.name, + description: provider.description, + models: provider.models, + required_keys: provider.required_keys, + }, + }) + .collect(); + + // Return the response as JSON. + Json(response) +} + pub fn routes(state: AppState) -> Router { Router::new() .route("/agent/versions", get(get_versions)) + .route("/agent/providers", get(list_providers)) .route("/agent", post(create_agent)) .with_state(state) } diff --git a/crates/goose-server/src/routes/providers_and_keys.json b/crates/goose-server/src/routes/providers_and_keys.json new file mode 100644 index 000000000..2371c2367 --- /dev/null +++ b/crates/goose-server/src/routes/providers_and_keys.json @@ -0,0 +1,38 @@ +{ + "openai": { + "name": "OpenAI", + "description": "Use GPT-4 and other OpenAI models", + "models": ["gpt-4o", "gpt-4-turbo","o1"], + "required_keys": ["OPENAI_API_KEY"] + }, + "anthropic": { + "name": "Anthropic", + "description": "Use Claude and other Anthropic models", + "models": ["claude-3.5-sonnet-2"], + "required_keys": ["ANTHROPIC_API_KEY"] + }, + "databricks": { + "name": "Databricks", + "description": "Connect to LLMs via Databricks", + "models": ["claude-3-5-sonnet-2"], + "required_keys": ["DATABRICKS_HOST"] + }, + "google": { + "name": "Google", + "description": "Lorem ipsum", + "models": ["gemini-1.5-flash"], + "required_keys": ["GOOGLE_API_KEY"] + }, + "grok": { + "name": "Grok", + "description": "Lorem ipsum", + "models": ["llama-3.3-70b-versatile"], + "required_keys": ["GROK_API_KEY"] + }, + "ollama": { + "name": "Ollama", + "description": "Lorem ipsum", + "models": ["qwen2.5"], + "required_keys": [] + } +} \ No newline at end of file diff --git a/crates/goose-server/src/routes/secrets.rs b/crates/goose-server/src/routes/secrets.rs index abe7820be..3900db165 100644 --- a/crates/goose-server/src/routes/secrets.rs +++ b/crates/goose-server/src/routes/secrets.rs @@ -1,8 +1,12 @@ use crate::state::AppState; -use axum::{extract::State, routing::post, Json, Router}; -use goose::key_manager::save_to_keyring; +use axum::{extract::State, routing::delete, routing::post, Json, Router}; +use goose::key_manager::{ + delete_from_keyring, get_keyring_secret, save_to_keyring, KeyRetrievalStrategy, +}; use http::{HeaderMap, StatusCode}; +use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; #[derive(Serialize)] struct SecretResponse { @@ -36,8 +40,153 @@ async fn store_secret( } } +#[derive(Debug, Serialize, Deserialize)] +pub struct ProviderSecretRequest { + pub providers: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SecretStatus { + pub is_set: bool, + pub location: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProviderResponse { + pub supported: bool, + pub name: Option, + pub description: Option, + pub models: Option>, + pub secret_status: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ProviderConfig { + name: String, + description: String, + models: Vec, + required_keys: Vec, +} + +static PROVIDER_ENV_REQUIREMENTS: Lazy> = Lazy::new(|| { + let contents = include_str!("providers_and_keys.json"); + serde_json::from_str(contents).expect("Failed to parse providers_and_keys.json") +}); + +fn check_key_status(key: &str) -> (bool, Option) { + if let Ok(_value) = std::env::var(key) { + (true, Some("env".to_string())) + } else if let Ok(_) = get_keyring_secret(key, KeyRetrievalStrategy::KeyringOnly) { + (true, Some("keyring".to_string())) + } else { + (false, None) + } +} + +async fn check_provider_secrets( + Json(request): Json, +) -> Result>, StatusCode> { + let mut response = HashMap::new(); + + for provider_name in request.providers { + if let Some(provider_config) = PROVIDER_ENV_REQUIREMENTS.get(&provider_name) { + let mut secret_status = HashMap::new(); + + for key in &provider_config.required_keys { + let (key_set, key_location) = check_key_status(key); + secret_status.insert( + key.to_string(), + SecretStatus { + is_set: key_set, + location: key_location, + }, + ); + } + + response.insert( + provider_name, + ProviderResponse { + supported: true, + name: Some(provider_config.name.clone()), + description: Some(provider_config.description.clone()), + models: Some(provider_config.models.clone()), + secret_status, + }, + ); + } else { + response.insert( + provider_name, + ProviderResponse { + supported: false, + name: None, + description: None, + models: None, + secret_status: HashMap::new(), + }, + ); + } + } + + Ok(Json(response)) +} + +#[derive(Deserialize)] +struct DeleteSecretRequest { + key: String, +} + +async fn delete_secret( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + // Verify secret key + let secret_key = headers + .get("X-Secret-Key") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if secret_key != state.secret_key { + return Err(StatusCode::UNAUTHORIZED); + } + + // Attempt to delete the key + match delete_from_keyring(&request.key) { + Ok(_) => Ok(StatusCode::NO_CONTENT), + Err(_) => Err(StatusCode::NOT_FOUND), + } +} + pub fn routes(state: AppState) -> Router { Router::new() + .route("/secrets/providers", post(check_provider_secrets)) .route("/secrets/store", post(store_secret)) + .route("/secrets/delete", delete(delete_secret)) .with_state(state) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_unsupported_provider() { + // Setup + let request = ProviderSecretRequest { + providers: vec!["unsupported_provider".to_string()], + }; + + // Execute + let result = check_provider_secrets(Json(request)).await; + + // Assert + assert!(result.is_ok()); + let Json(response) = result.unwrap(); + + let provider_status = response + .get("unsupported_provider") + .expect("Provider should exist"); + assert!(!provider_status.supported); + assert!(provider_status.secret_status.is_empty()); + } +} diff --git a/crates/goose/src/key_manager.rs b/crates/goose/src/key_manager.rs index 0a882f7f7..9d6355b1a 100644 --- a/crates/goose/src/key_manager.rs +++ b/crates/goose/src/key_manager.rs @@ -85,6 +85,11 @@ pub fn save_to_keyring(key_name: &str, key_val: &str) -> std::result::Result<(), kr.set_password(key_val).map_err(KeyManagerError::from) } +pub fn delete_from_keyring(key_name: &str) -> std::result::Result<(), KeyManagerError> { + let kr = Entry::new("goose", key_name)?; + kr.delete_credential().map_err(KeyManagerError::from) +} + #[cfg(test)] mod tests { use super::*; @@ -100,6 +105,27 @@ mod tests { kr.delete_credential().map_err(KeyManagerError::from) } + #[test] + fn test_delete_from_keyring() { + let key_name = format!("{}{}", TEST_ENV_PREFIX, "DELETE_KEY"); + + // Save a value to the keyring + save_to_keyring(&key_name, "test_value").unwrap(); + + // Verify it exists + let kr = Entry::new("goose", &key_name).unwrap(); + assert_eq!(kr.get_password().unwrap(), "test_value"); + + // Delete the keyring entry + let result = delete_from_keyring(&key_name); + assert!(result.is_ok()); + + // Verify deletion + let kr = Entry::new("goose", &key_name).unwrap(); + let password_result = kr.get_password(); + assert!(password_result.is_err()); + } + #[test] fn test_get_key_environment_only() { let key_name = format!("{}{}", TEST_ENV_PREFIX, "ENV_KEY"); diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index d5846ce4e..5a7650f55 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -8,15 +8,19 @@ import GooseMessage from "./components/GooseMessage"; import Input from "./components/Input"; import LoadingGoose from "./components/LoadingGoose"; import MoreMenu from "./components/MoreMenu"; -import Settings from "./components/settings/Settings"; import Splash from "./components/Splash"; import { Card } from "./components/ui/card"; import { ScrollArea } from "./components/ui/scroll-area"; import UserMessage from "./components/UserMessage"; import WingToWing, { Working } from "./components/WingToWing"; import { askAi } from "./utils/askAI"; -import { ProviderSetupModal } from "./components/ProviderSetupModal"; -import { providers, ProviderOption } from "./utils/providerUtils"; +import { + Provider, +} from "./utils/providerUtils"; +import { ChatLayout } from "./components/chat_window/ChatLayout" +import { ChatRoutes } from "./components/chat_window/ChatRoutes" +import { WelcomeModal } from "./components/welcome_screen/WelcomeModal" +import { getStoredProvider, initializeSystem } from './utils/providerUtils' declare global { interface Window { @@ -49,7 +53,7 @@ export interface Chat { type ScrollBehavior = "auto" | "smooth" | "instant"; -function ChatContent({ +export function ChatContent({ chats, setChats, selectedChatId, @@ -380,7 +384,7 @@ export default function ChatWindow() { const [working, setWorking] = useState(Working.Idle); const [progressMessage, setProgressMessage] = useState(""); const [selectedProvider, setSelectedProvider] = - useState(null); + useState(null) const [showWelcomeModal, setShowWelcomeModal] = useState(true); // Add this useEffect to track changes and update welcome state @@ -395,8 +399,7 @@ export default function ChatWindow() { useEffect(() => { // Check if we already have a provider set const config = window.electron.getConfig(); - const storedProvider = - config.GOOSE_PROVIDER || localStorage.getItem("GOOSE_PROVIDER"); + const storedProvider = getStoredProvider(config) if (storedProvider) { setShowWelcomeModal(false); @@ -422,41 +425,6 @@ export default function ChatWindow() { return response; }; - const addAgent = async (provider: string) => { - const response = await fetch(getApiUrl("/agent"), { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Secret-Key": getSecretKey(), - }, - body: JSON.stringify({ provider: provider }), - }); - - if (!response.ok) { - throw new Error(`Failed to add agent: ${response.statusText}`); - } - - return response; - }; - - const addSystemConfig = async (system: string) => { - await addMCP("goosed", ["mcp", system]); - }; - - const initializeSystem = async (provider: string) => { - try { - await addAgent(provider); - await addSystemConfig("developer2"); - // add system from deep link up front - if (window.appConfig.get("DEEP_LINK")) { - await addMCPSystem(window.appConfig.get("DEEP_LINK")); - } - } catch (error) { - console.error("Failed to initialize system:", error); - throw error; - } - }; - const handleModalSubmit = async (apiKey: string) => { try { const trimmedKey = apiKey.trim(); @@ -473,7 +441,7 @@ export default function ChatWindow() { await initializeSystem(selectedProvider.id); // Save provider selection and close modal - localStorage.setItem("GOOSE_PROVIDER", selectedProvider.name); + localStorage.setItem("GOOSE_PROVIDER", selectedProvider.id); setShowWelcomeModal(false); } catch (error) { console.error("Failed to setup provider:", error); @@ -485,8 +453,7 @@ export default function ChatWindow() { useEffect(() => { const setupStoredProvider = async () => { const config = window.electron.getConfig(); - const storedProvider = - config.GOOSE_PROVIDER || localStorage.getItem("GOOSE_PROVIDER"); + const storedProvider = getStoredProvider(config); if (storedProvider) { try { await initializeSystem(storedProvider); @@ -500,71 +467,27 @@ export default function ChatWindow() { }, []); return ( -
-
-
- - - } - /> - } /> - } /> - -
- - - - {showWelcomeModal && ( -
- {selectedProvider ? ( - { - setSelectedProvider(null); - }} + + + + {showWelcomeModal && ( + - ) : ( - -

- Select a Provider -

-
- {providers.map((provider) => ( - - ))} -
-
- )} -
- )} -
+ )} + ); -} +} \ No newline at end of file diff --git a/ui/desktop/src/components/MoreMenu.tsx b/ui/desktop/src/components/MoreMenu.tsx index cf28d90be..9097be313 100644 --- a/ui/desktop/src/components/MoreMenu.tsx +++ b/ui/desktop/src/components/MoreMenu.tsx @@ -246,6 +246,18 @@ export default function MoreMenu() { > Reset Provider + {/* Provider keys settings */} + {process.env.NODE_ENV === "development" && ( + + )}
diff --git a/ui/desktop/src/components/chat_window/ChatLayout.tsx b/ui/desktop/src/components/chat_window/ChatLayout.tsx new file mode 100644 index 000000000..04bd15ee6 --- /dev/null +++ b/ui/desktop/src/components/chat_window/ChatLayout.tsx @@ -0,0 +1,10 @@ +import React from "react"; + +export const ChatLayout = ({ children, mode }) => ( +
+
+
+ {children} +
+
+); diff --git a/ui/desktop/src/components/chat_window/ChatRoutes.tsx b/ui/desktop/src/components/chat_window/ChatRoutes.tsx new file mode 100644 index 000000000..4db182b98 --- /dev/null +++ b/ui/desktop/src/components/chat_window/ChatRoutes.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { Routes, Route, Navigate } from "react-router-dom"; +import { ChatContent } from "../../ChatWindow" +import Settings from "../settings/Settings" +import Keys from "../settings/Keys" + +export const ChatRoutes = ({ + chats, + setChats, + selectedChatId, + setSelectedChatId, + setProgressMessage, + setWorking, + }) => ( + + + } + /> + } /> + } /> + } /> + +); diff --git a/ui/desktop/src/components/settings/Keys.tsx b/ui/desktop/src/components/settings/Keys.tsx new file mode 100644 index 000000000..32ef19159 --- /dev/null +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -0,0 +1,282 @@ +import React, { useEffect, useState } from 'react'; +import { getApiUrl, getSecretKey } from "../../config"; +import { FaArrowLeft } from 'react-icons/fa'; +import { showToast } from '../ui/toast'; +import { useNavigate } from 'react-router-dom'; +import { Modal, ModalContent, ModalHeader, ModalTitle } from '../ui/modal'; +import { initializeSystem, getStoredProvider } from '../../utils/providerUtils'; +import { + getSecretsSettings, + transformProviderSecretsResponse, + transformSecrets, +} from './providers/utils'; +import { ProviderSetupModal } from "../welcome_screen/ProviderSetupModal"; +import { Provider } from './providers/types' +import { ProviderCard } from './providers/ProviderCard' +import { ConfirmDeletionModal } from './modals/ConfirmDeletionModal' + + +// Main Component: Keys +export default function Keys() { + const navigate = useNavigate(); + const [secrets, setSecrets] = useState([]); + const [expandedProviders, setExpandedProviders] = useState(new Set()); + const [providers, setProviders] = useState([]); + const [showSetProviderKeyModal, setShowSetProviderKeyModal] = useState(false); + const [currentKey, setCurrentKey] = useState(null); + const [selectedProvider, setSelectedProvider] = useState(null); + const [isChangingProvider, setIsChangingProvider] = useState(false); + const [keyToDelete, setKeyToDelete] = useState<{providerId: string, key: string} | null>(null); + + + useEffect(() => { + const fetchSecrets = async () => { + try { + // Fetch secrets state (set/unset) + let data = await getSecretsSettings() + let transformedProviders: Provider[] = transformProviderSecretsResponse(data) + setProviders(transformedProviders); + + // Transform secrets data into an array -- [ + // { key: "OPENAI_API_KEY", location: "keyring", is_set: true }, + // { key: "OPENAI_OTHER_KEY", location: "none", is_set: false } + // ] + const transformedSecrets = transformSecrets(data) + console.log("transformedSecrets", transformedSecrets) + + setSecrets(transformedSecrets); + + // Check and expand active provider + // TODO: fix the below lint error + const config = window.electron.getConfig(); + const gooseProvider = getStoredProvider(config); + if (gooseProvider) { + const matchedProvider = transformedProviders.find(provider => + provider.id.toLowerCase() === gooseProvider + ); + if (matchedProvider) { + setExpandedProviders(new Set([matchedProvider.id])); + } else { + console.warn(`Provider ${gooseProvider} not found in settings.`); + } + } + } catch (error) { + console.error('Error fetching secrets:', error); + } + }; + + fetchSecrets(); + }, []); + + const toggleProvider = (providerId) => { + setExpandedProviders(prev => { + const newSet = new Set(prev); + if (newSet.has(providerId)) { + newSet.delete(providerId); + } else { + newSet.add(providerId); + } + return newSet; + }); + }; + + const getProviderStatus = (provider: Provider) => { + const providerSecrets = provider.keys.map(key => secrets.find(s => s.key === key)); + return providerSecrets.some(s => !s?.is_set); + }; + + const handleAddOrEditKey = (key: string, providerName: string) => { + const secret = secrets.find((s) => s.key === key); + + if (secret?.location === 'env') { + showToast("Cannot edit key set in environment. Please modify your ~/.zshrc or equivalent file.", "error"); + return; + } + console.log("Key passed to handleAddOrEditKey:", key); // Debug log + setCurrentKey(key); + setSelectedProvider(providerName); // Set the selected provider name + setShowSetProviderKeyModal(true); // Show the modal + }; + + const handleSubmit = async (apiKey: string) => { + setShowSetProviderKeyModal(false); // Hide the modal + + const secret = secrets.find((s) => s.key === currentKey); + const isAdding = !secret?.is_set; + + try { + if (!isAdding) { + // Delete old key logic + const deleteResponse = await fetch(getApiUrl("/secrets/delete"), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify({ key: currentKey }), + }); + + if (!deleteResponse.ok) { + throw new Error('Failed to delete old key'); + } + } + + // Store new key logic + const storeResponse = await fetch(getApiUrl("/secrets/store"), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify({ + key: currentKey, + value: apiKey.trim(), + }), + }); + + if (!storeResponse.ok) { + throw new Error(isAdding ? 'Failed to add key' : 'Failed to store new key'); + } + + // Update local state + setSecrets( + secrets.map((s) => + s.key === currentKey + ? { ...s, location: 'keyring', is_set: true } + : s + ) + ); + + showToast(isAdding ? "Key added successfully" : "Key updated successfully", "success"); + } catch (error) { + console.error('Error updating key:', error); + showToast(isAdding ? "Failed to add key" : "Failed to update key", "error"); + } finally { + setCurrentKey(null); + } + }; + + const handleCancel = () => { + setShowSetProviderKeyModal(false); // Close the modal without making changes + setCurrentKey(null); + }; + + const handleDeleteKey = async (providerId: string, key: string) => { + // Find the secret to check its source + const secret = secrets.find(s => s.key === key); + + if (secret?.location === 'env') { + showToast("This key is set in your environment. Please remove it from your ~/.zshrc or equivalent file.", "error"); + return; + } + + // Show confirmation modal + setKeyToDelete({ providerId, key }); + }; + + const confirmDelete = async () => { + if (!keyToDelete) return; + + try { + const response = await fetch(getApiUrl("/secrets/delete"), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify({ key: keyToDelete.key }) + }); + + if (!response.ok) { + throw new Error('Failed to delete key'); + } + + // Update local state to reflect deletion + setSecrets(secrets.map((s) => + s.key === keyToDelete.key + ? { ...s, location: 'none', is_set: false } // Mark as not set + : s + )); + showToast(`Key ${keyToDelete.key} deleted from keychain`, "success"); + } catch (error) { + console.error('Error deleting key:', error); + showToast("Failed to delete key", "error"); + } finally { + setKeyToDelete(null); + } + }; + + const isProviderSupported = (providerId: string) => { + const provider = providers.find(p => p.id === providerId); + return provider?.supported ?? false; + }; + + const handleSelectProvider = async (providerId) => { + setIsChangingProvider(true); + try { + // Update localStorage + // TODO: do we need to consider cases where GOOSE_PROVIDER is set in the zshrc file? + const provider = providers.find(p => p.id === providerId); + if (provider) { + localStorage.setItem("GOOSE_PROVIDER", provider.name); + initializeSystem(provider.id); + showToast(`Switched to ${provider.name}`, "success"); + } + } catch (error) { + showToast("Failed to change provider", "error"); + } finally { + setIsChangingProvider(false); + } + }; + + return ( +
+
+ +

Providers

+
+ +
+ {providers.map((provider) => ( + + ))} +
+ + {showSetProviderKeyModal && currentKey && selectedProvider && ( + handleSubmit(apiKey)} // Call handleSubmit when submitting + onCancel={() => setShowSetProviderKeyModal(false)} // Close modal on cancel + /> + )} + + {keyToDelete && ( + setKeyToDelete(null)} + onConfirm={confirmDelete} + /> + )} +
+ ); +} diff --git a/ui/desktop/src/components/settings/SecretsList.tsx b/ui/desktop/src/components/settings/SecretsList.tsx new file mode 100644 index 000000000..da8d08754 --- /dev/null +++ b/ui/desktop/src/components/settings/SecretsList.tsx @@ -0,0 +1,146 @@ +import React, { useEffect, useState } from 'react'; +import { getApiUrl, getSecretKey } from '../../config'; + +interface SecretSource { + key: string; + source: string; + is_set: boolean; +} + +interface SecretsListResponse { + secrets: SecretSource[]; +} + +type SVGComponentProps = { + className?: string; + width?: number | string; + height?: number | string; + fill?: string; + stroke?: string; + strokeWidth?: number | string; + viewBox?: string; + xmlns?: string; + // Add any other specific props you need +}; + +export const SecretsList = () => { + const [secrets, setSecrets] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchSecrets(); + }, []); + + const fetchSecrets = async () => { + try { + const response = await fetch(getApiUrl('/secrets/list'), { + headers: { + 'X-Secret-Key': getSecretKey(), + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch secrets'); + } + + const data: SecretsListResponse = await response.json(); + setSecrets(data.secrets); + } catch (error) { + console.error('Error fetching secrets:', error); + } finally { + setLoading(false); + } + }; + + const handleAddKey = async (key: string, value: string) => { + try { + const response = await fetch(getApiUrl('/secrets/store'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify({ key, value }), + }); + + if (!response.ok) { + throw new Error('Failed to store secret'); + } + + // Refresh the secrets list + fetchSecrets(); + } catch (error) { + console.error('Error storing secret:', error); + } + }; + + return ( +
+
+

Environment Variables

+ +
+ +
+ {secrets.map((secret) => ( +
+
+

{secret.key}

+

+ {secret.is_set ? ( + + ✓ Set from {secret.source} + + ) : ( + Not set + )} +

+
+
+ {secret.is_set && ( + <> + + + + )} + +
+
+ ))} +
+
+ ); +}; + +const EyeIcon: React.FC = (props) => ( + + + + +); + +const ClipboardIcon: React.FC = (props) => ( + + + +); + +const PencilIcon: React.FC = (props) => ( + + + +); \ No newline at end of file diff --git a/ui/desktop/src/components/settings/Settings.tsx b/ui/desktop/src/components/settings/Settings.tsx index 47f4b62b4..db721b553 100644 --- a/ui/desktop/src/components/settings/Settings.tsx +++ b/ui/desktop/src/components/settings/Settings.tsx @@ -1,6 +1,5 @@ import React, { useState } from "react"; import { ScrollArea } from "../ui/scroll-area"; -import { Card } from "../ui/card"; import { useNavigate } from "react-router-dom"; import { Settings as SettingsType, Model, Extension, Key } from "./types"; import { ToggleableItem } from "./ToggleableItem"; @@ -14,7 +13,7 @@ import { showToast } from "../ui/toast"; import { Back } from "../icons"; const EXTENSIONS_DESCRIPTION = - "The Model Context Protocol (MCP) is a system that allows AI models to securely connect with local or remote resources using standard server setups. It works like a client-server setup and expands AI capabilities using three main components: Prompts, Resources, and Tools."; + "The Model Context Protocol (MCP) is a system that allows AI models to securely connect with local or remote resources using standard server setups. It works like a client-server setup and expands AI capabilities using three main components: Prompts, Resources, and Tools."; const DEFAULT_SETTINGS: SettingsType = { models: [ @@ -96,7 +95,7 @@ export default function Settings() { setSettings((prev) => ({ ...prev, extensions: prev.extensions.map((ext) => - ext.id === extensionId ? { ...ext, enabled: !ext.enabled } : ext + ext.id === extensionId ? { ...ext, enabled: !ext.enabled } : ext ), })); }; @@ -104,7 +103,7 @@ export default function Settings() { const handleNavClick = (section: string, e: React.MouseEvent) => { e.preventDefault(); const scrollArea = document.querySelector( - "[data-radix-scroll-area-viewport]" + "[data-radix-scroll-area-viewport]" ); const element = document.getElementById(section.toLowerCase()); @@ -147,7 +146,7 @@ export default function Settings() { setSettings((prev) => ({ ...prev, keys: prev.keys.map((key) => - key.id === updatedKey.id ? updatedKey : key + key.id === updatedKey.id ? updatedKey : key ), })); setEditingKey(null); @@ -177,174 +176,174 @@ export default function Settings() { }; return ( -
-
- -
- {/* Left Navigation */} -
-
- -
- {["Models", "Extensions", "Keys"].map((section) => ( - +
+ {["Models", "Extensions", "Keys"].map((section) => ( + - ))} + > + {section} + + ))} +
-
- {/* Content Area */} -
-
- {/* Models Section */} -
-
-

Models

- -
- {settings.models.map((model) => ( - - ))} -
+ {/* Content Area */} +
+
+ {/* Models Section */} +
+
+

Models

+ +
+ {settings.models.map((model) => ( + + ))} +
- {/* Extensions Section */} -
-
-

Extensions

-
-

- {EXTENSIONS_DESCRIPTION} -

- {settings.extensions.map((ext) => ( - - ))} -
+ {/* Extensions Section */} +
+
+

Extensions

+
+

+ {EXTENSIONS_DESCRIPTION} +

+ {settings.extensions.map((ext) => ( + + ))} +
- {/* Keys Section */} -
-
-

Keys

- -
-

- {EXTENSIONS_DESCRIPTION} -

- {settings.keys.map((keyItem) => ( - - ))} + {/* Keys Section */} +
+
+

Keys

+ +
+

+ {EXTENSIONS_DESCRIPTION} +

+ {settings.keys.map((keyItem) => ( + + ))} + +
+ +
+
-
+ {/* Reset Button */} +
-
- - {/* Reset Button */} -
-
-
- -
+ +
- {/* Reset Confirmation Dialog */} - - - - Reset Settings - -
-

- Are you sure you want to reset all settings to their default - values? This cannot be undone. -

-
-
- - -
-
-
+ {/* Reset Confirmation Dialog */} + + + + Reset Settings + +
+

+ Are you sure you want to reset all settings to their default + values? This cannot be undone. +

+
+
+ + +
+
+
- {/* Add the modals */} - setAddModelOpen(false)} - onAdd={handleAddModel} - /> - { - setAddKeyOpen(false); - setEditingKey(null); - }} - onSubmit={editingKey ? handleUpdateKey : handleAddKey} - onDelete={handleDeleteKey} - initialKey={editingKey || undefined} - /> + {/* Add the modals */} + setAddModelOpen(false)} + onAdd={handleAddModel} + /> + { + setAddKeyOpen(false); + setEditingKey(null); + }} + onSubmit={editingKey ? handleUpdateKey : handleAddKey} + onDelete={handleDeleteKey} + initialKey={editingKey || undefined} + /> - setShowAllKeys(false)} - keys={settings.keys} - /> -
+ setShowAllKeys(false)} + keys={settings.keys} + /> +
); -} +} \ No newline at end of file diff --git a/ui/desktop/src/components/settings/modals/ConfirmDeletionModal.tsx b/ui/desktop/src/components/settings/modals/ConfirmDeletionModal.tsx new file mode 100644 index 000000000..9eeea7533 --- /dev/null +++ b/ui/desktop/src/components/settings/modals/ConfirmDeletionModal.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Modal, ModalContent, ModalHeader, ModalTitle } from '../../ui/modal'; + +export const ConfirmDeletionModal = ({ keyToDelete, onCancel, onConfirm }) => { + return ( + + + + Confirm Deletion + +
+

+ Are you sure you want to delete this API key from the keychain? +

+
+ + +
+
+
+
+ ); +}; diff --git a/ui/desktop/src/components/settings/providers/Header.tsx b/ui/desktop/src/components/settings/providers/Header.tsx new file mode 100644 index 000000000..4c65923f6 --- /dev/null +++ b/ui/desktop/src/components/settings/providers/Header.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { FaArrowLeft } from "react-icons/fa"; + +export default function Header() { + const navigate = useNavigate(); + + return ( +
+ +

Providers

+
+ ); +} diff --git a/ui/desktop/src/components/settings/providers/ProviderCard.tsx b/ui/desktop/src/components/settings/providers/ProviderCard.tsx new file mode 100644 index 000000000..503425924 --- /dev/null +++ b/ui/desktop/src/components/settings/providers/ProviderCard.tsx @@ -0,0 +1,114 @@ +import { FaKey, FaExclamationCircle, FaPencilAlt, FaTrash, FaPlus } from 'react-icons/fa'; +import React from "react"; + +export const ProviderCard = ({ + provider, + secrets, + isExpanded, + isSupported, + isChangingProvider, + toggleProvider, + handleAddOrEditKey, + handleDeleteKey, + handleSelectProvider, + getProviderStatus, + }) => { + const hasUnsetKeys = getProviderStatus(provider); + + return ( +
+
+ +
+ + {isSupported && isExpanded && ( +
+ {provider.keys.map(key => { + const secret = secrets.find(s => s.key === key); + return ( +
+
+

{key}

+

Source: {secret?.location || 'none'}

+
+
+ + {secret?.is_set ? 'Key set' : 'Missing'} + + + +
+
+ ); + })} + + {provider.id.toLowerCase() !== localStorage.getItem("GOOSE_PROVIDER")?.toLowerCase() && ( + + )} +
+ )} +
+ ); +}; diff --git a/ui/desktop/src/components/settings/providers/ProvidersList.tsx b/ui/desktop/src/components/settings/providers/ProvidersList.tsx new file mode 100644 index 000000000..63e2f99ed --- /dev/null +++ b/ui/desktop/src/components/settings/providers/ProvidersList.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +export const ProviderList = ({ providers, onProviderSelect }) => ( +
+ {providers.map((provider) => ( + + ))} +
+); diff --git a/ui/desktop/src/components/settings/providers/types.ts b/ui/desktop/src/components/settings/providers/types.ts new file mode 100644 index 000000000..31ec1f571 --- /dev/null +++ b/ui/desktop/src/components/settings/providers/types.ts @@ -0,0 +1,38 @@ +// transformation of the response provided by secrets/provider endpoint +export interface Provider { + id: string; + name: string; + keys: string[]; + description: string; + canDelete?: boolean; + supported: boolean; + order: number; +} + +export interface SecretDetails { + key: string; + is_set: boolean; + location?: string; +} + +// returned by the secrets/providers endpoint +export interface ProviderResponse { + supported: boolean; + name?: string; + description?: string; + models?: string[]; + secret_status: Record; +} + +// Represents the backend's secret structure for a single secret +export interface RawSecretStatus { + location: string; // Where the secret is stored (e.g., "keyring") + is_set: boolean; // Whether the secret is configured +} + +// Represents the transformed structure of a secret in the frontend +export interface TransformedSecret { + key: string; // The secret's key (e.g., "OPENAI_API_KEY") + location: string; // Where the secret is stored (e.g., "keyring") + is_set: boolean; // Whether the secret is set +} \ No newline at end of file diff --git a/ui/desktop/src/components/settings/providers/utils.ts b/ui/desktop/src/components/settings/providers/utils.ts new file mode 100644 index 000000000..3261ca60e --- /dev/null +++ b/ui/desktop/src/components/settings/providers/utils.ts @@ -0,0 +1,68 @@ +import { ProviderResponse, Provider, TransformedSecret, RawSecretStatus } from './types' +import { getProvidersList } from '../../../utils/providerUtils' +import { getApiUrl, getSecretKey } from "../../../config"; + +export async function getSecretsSettings(): Promise> { + const providerList = await getProvidersList(); + // Extract the list of IDs + const providerIds = providerList.map((provider) => provider.id); + + // Fetch secrets state (set/unset) using the provider IDs + const response = await fetch(getApiUrl("/secrets/providers"), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify({ + providers: providerIds + }) + }); + + if (!response.ok) { + throw new Error('Failed to fetch secrets'); + } + + const data = await response.json() as Record; + console.log("raw response", data) + return data +} + +export function transformProviderSecretsResponse(data: Record) : Provider[] { + // Transform the response into a list of ProviderWithSecrets objects + const providerOrder = ['openai', 'anthropic', 'databricks']; // maintains these three at top of resulting list + const transformedProviders: Provider[] = Object.entries(data) + .map(([id, status]: [string, any]) => ({ + id: id.toLowerCase(), + name: status.name ? status.name : id, + keys: status.secret_status ? Object.keys(status.secret_status) : [], + description: status.description ? status.description : "Unsupported provider", + supported: status.supported, + canDelete: id.toLowerCase() !== 'openai' && id.toLowerCase() !== 'anthropic', + order: providerOrder.indexOf(id.toLowerCase()) + })) + .sort((a, b) => { + if (a.order !== -1 && b.order !== -1) { + return a.order - b.order; + } + if (a.order === -1 && b.order === -1) { + return a.name.localeCompare(b.name); + } + return a.order === -1 ? 1 : -1; + }); + + console.log("transformed providers", transformedProviders) + return transformedProviders +} + +export function transformSecrets(data: Record): TransformedSecret[] { + return Object.entries(data) + .filter(([_, provider]) => provider.supported && provider.secret_status) + .flatMap(([_, provider]) => + Object.entries(provider.secret_status!).map(([key, rawStatus]) => ({ + key, // Secret key (e.g., "OPENAI_API_KEY") + location: rawStatus.location || "none", // Default location if missing + is_set: rawStatus.is_set, // Renamed from `is_set` to `isSet` + })) + ); +} diff --git a/ui/desktop/src/components/ui/toast.tsx b/ui/desktop/src/components/ui/toast.tsx index 4d36fced4..b5671a388 100644 --- a/ui/desktop/src/components/ui/toast.tsx +++ b/ui/desktop/src/components/ui/toast.tsx @@ -13,10 +13,10 @@ export function showToast(message: string, type: 'success' | 'error') { toast.textContent = message; document.body.appendChild(toast); - // Animate out + // Animate out after 5 seconds instead of 2 setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateY(1rem)'; setTimeout(() => toast.remove(), 300); - }, 2000); + }, 5000); } diff --git a/ui/desktop/src/components/ProviderSetupModal.tsx b/ui/desktop/src/components/welcome_screen/ProviderSetupModal.tsx similarity index 96% rename from ui/desktop/src/components/ProviderSetupModal.tsx rename to ui/desktop/src/components/welcome_screen/ProviderSetupModal.tsx index e5f519ab9..97426ee52 100644 --- a/ui/desktop/src/components/ProviderSetupModal.tsx +++ b/ui/desktop/src/components/welcome_screen/ProviderSetupModal.tsx @@ -1,8 +1,8 @@ import React from "react"; -import { Card } from "./ui/card"; +import { Card } from "../ui/card"; import { Lock } from "lucide-react"; -import { Input } from "./ui/input"; -import { Button } from "./ui/button"; +import { Input } from "../ui/input"; +import { Button } from "../ui/button"; // import UnionIcon from "../images/Union@2x.svg"; interface ProviderSetupModalProps { diff --git a/ui/desktop/src/components/welcome_screen/WelcomeModal.tsx b/ui/desktop/src/components/welcome_screen/WelcomeModal.tsx new file mode 100644 index 000000000..6d3c06449 --- /dev/null +++ b/ui/desktop/src/components/welcome_screen/WelcomeModal.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from "react"; +import { ProviderSetupModal } from "./ProviderSetupModal"; +import { Card } from "../ui/card"; +import { ProviderList } from "../settings/providers/ProvidersList"; +import { getProvidersList, Provider } from "../../utils/providerUtils"; + +export const WelcomeModal = ({ + selectedProvider, + setSelectedProvider, + onSubmit, + }: { + selectedProvider: Provider | string | null; + setSelectedProvider: React.Dispatch>; + onSubmit: (apiKey: string) => void; +}) => { + const [providers, setProviders] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchProviders = async () => { + try { + const providerList = await getProvidersList(); + // Filter for only "anthropic" and "openai" + const filteredProviders = providerList.filter((provider) => + ["anthropic", "openai"].includes(provider.id) + ); + setProviders(filteredProviders); + } catch (err) { + console.error("Failed to fetch providers:", err); + setError("Unable to load providers. Please try again."); + } + }; + + fetchProviders(); + }, []); + + return ( +
+ {selectedProvider ? ( + setSelectedProvider(null)} + model={""} // placeholder + endpoint={""} // placeholder + /> + ) : ( + +

+ Select a Provider +

+ {error ? ( +

{error}

+ ) : ( + + )} +
+ )} +
+ ); +}; + diff --git a/ui/desktop/src/types/electron.d.ts b/ui/desktop/src/types/electron.d.ts index 602f9ccfa..b95e397f9 100644 --- a/ui/desktop/src/types/electron.d.ts +++ b/ui/desktop/src/types/electron.d.ts @@ -1,12 +1,13 @@ interface IElectronAPI { hideWindow: () => void; - createChatWindow: (query: string) => void; + createChatWindow: (query?: string, dir?: string, version?: string) => void; getConfig: () => { GOOSE_SERVER__PORT: number; GOOSE_API_HOST: string; apiCredsMissing: boolean; secretKey: string; }; + directoryChooser: () => void; } declare global { diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index dd79273aa..80cf39fc1 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -1,10 +1,12 @@ +import {addMCP, addMCPSystem, getApiUrl, getSecretKey } from "../config"; + export const SELECTED_PROVIDER_KEY = "GOOSE_PROVIDER__API_KEY" export interface ProviderOption { id: string; name: string; description: string; - modelExample: string; + models: string; } export const OPENAI_ENDPOINT_PLACEHOLDER = "https://api.openai.com"; @@ -12,25 +14,80 @@ export const ANTHROPIC_ENDPOINT_PLACEHOLDER = "https://api.anthropic.com"; export const OPENAI_DEFAULT_MODEL = "gpt-4" export const ANTHROPIC_DEFAULT_MODEL = "claude-3-sonnet" -// TODO we will provide these from a rust endpoint -export const providers: ProviderOption[] = [ - { - id: 'openai', - name: 'OpenAI', - description: 'Use GPT-4 and other OpenAI models', - modelExample: 'gpt-4-turbo' - }, - { - id: 'anthropic', - name: 'Anthropic', - description: 'Use Claude and other Anthropic models', - modelExample: 'claude-3-sonnet' +export function getStoredProvider(config: any): string | null { + console.log("config goose provider", config.GOOSE_PROVIDER) + console.log("local storage goose provider", localStorage.getItem("GOOSE_PROVIDER")) + return config.GOOSE_PROVIDER || localStorage.getItem("GOOSE_PROVIDER"); +} + +export interface Provider { + id: string; // Lowercase key (e.g., "openai") + name: string; // Provider name (e.g., "OpenAI") + description: string; // Description of the provider + models: string[]; // List of supported models + requiredKeys: string[]; // List of required keys +} + +export async function getProvidersList(): Promise { + const response = await fetch(getApiUrl("/agent/providers"), { + method: "GET", + }); + + if (!response.ok) { + throw new Error(`Failed to fetch providers: ${response.statusText}`); } -]; -export const getCurrentProvider = (): string => { - const provider = localStorage.getItem(SELECTED_PROVIDER_KEY); - console.log('Getting current provider:', provider || 'none'); - return provider || 'openai'; // default to OpenAI if none selected + const data = await response.json(); + console.log("Raw API Response:", data); // Log the raw response + + + // Format the response into an array of providers + return data.map((item: any) => ({ + id: item.id, // Root-level ID + name: item.details?.name || "Unknown Provider", // Nested name in details + description: item.details?.description || "No description available.", // Nested description + models: item.details?.models || [], // Nested models array + requiredKeys: item.details?.required_keys || [], // Nested required keys array + })); +} + +const addAgent = async (provider: string) => { + const response = await fetch(getApiUrl("/agent"), { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Secret-Key": getSecretKey(), + }, + body: JSON.stringify({ provider: provider }), + }); + + if (!response.ok) { + throw new Error(`Failed to add agent: ${response.statusText}`); + } + + return response; +}; + +const addSystemConfig = async (system: string) => { + await addMCP("goosed", ["mcp", system]); +}; + +export const initializeSystem = async (provider: string) => { + try { + console.log("initializing with provider", provider) + await addAgent(provider); + await addSystemConfig("developer2"); + + // Handle deep link if present + const deepLink = window.appConfig.get('DEEP_LINK'); + if (deepLink) { + await addMCPSystem(deepLink); + } + } catch (error) { + console.error("Failed to initialize system:", error); + throw error; + } }; + +