From 80ccc17d413df3c84888da2739cd896e71d5bbc6 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Wed, 15 Jan 2025 21:16:44 -0800 Subject: [PATCH 01/32] list provider secret status endpoint --- .../src/routes/providers_and_keys.json | 6 + crates/goose-server/src/routes/secrets.rs | 116 +++++++++++++++++- 2 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 crates/goose-server/src/routes/providers_and_keys.json 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..3dbcdb70a --- /dev/null +++ b/crates/goose-server/src/routes/providers_and_keys.json @@ -0,0 +1,6 @@ +{ + "OpenAI": ["OPENAI_API_KEY"], + "Anthropic": ["ANTHROPIC_API_KEY"], + "Databricks": ["DATABRICKS_API_KEY"], + "OtherProvider": ["OTHER_PROVIDER_KEY_1", "OTHER_PROVIDER_KEY_2"] +} \ 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..f20c54d70 100644 --- a/crates/goose-server/src/routes/secrets.rs +++ b/crates/goose-server/src/routes/secrets.rs @@ -1,8 +1,10 @@ use crate::state::AppState; -use axum::{extract::State, routing::post, Json, Router}; +use axum::{extract::State, routing::{post, get}, Json, Router}; use goose::key_manager::save_to_keyring; use http::{HeaderMap, StatusCode}; use serde::{Deserialize, Serialize}; +use std::{env, collections::HashMap}; +use once_cell::sync::Lazy; #[derive(Serialize)] struct SecretResponse { @@ -15,6 +17,71 @@ struct SecretRequest { value: String, } +#[derive(Serialize)] +struct ProviderStatus { + supported: bool, + secret_status: HashMap, // Map of API key names to their statuses +} + +#[derive(Serialize)] +struct SecretStatus { + is_set: bool, // True if the key is set + location: Option, // "env", "keychain", or None +} + +#[derive(Serialize)] +struct SecretSource { + key: String, + source: String, // "env", "keyring", or "none" + is_set: bool, // true if the secret exists, false otherwise +} + +#[derive(Serialize)] +struct SecretsListResponse { + secrets: Vec, +} + +#[derive(Debug, Serialize)] +struct KeyStatus { + set: bool, + location: Option, // "env", "keyring", or null + supported: bool, +} + +#[derive(Debug, Deserialize)] +struct ProviderRequest { + providers: 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 get_supported_secrets() -> Vec<&'static str> { + PROVIDER_KEYS.values() + .flat_map(|keys| keys.iter()) + .map(|s| s.as_str()) + .collect() +} + + + +/// Check the status of a key, including whether it's set and its location. +pub fn check_key_status(key_name: &str) -> (bool, Option) { + // Current hierarchy: prioritize environment variables over keyring + if let Ok(_) = env::var(key_name) { + return (true, Some("env".to_string())); // Found in environment + } + + if let Ok(_) = get_keyring_secret(key_name, KeyRetrievalStrategy::KeyringOnly) { + return (true, Some("keychain".to_string())); // Found in keyring + } + + (false, None) // Not found in either source +} + + async fn store_secret( State(state): State, headers: HeaderMap, @@ -30,14 +97,57 @@ async fn store_secret( return Err(StatusCode::UNAUTHORIZED); } + // Verify this is a supported secret key + let supported_secrets = get_supported_secrets(); + if !supported_secrets.contains(&request.key.as_str()) { + return Err(StatusCode::BAD_REQUEST); + } + match save_to_keyring(&request.key, &request.value) { Ok(_) => Ok(Json(SecretResponse { error: false })), Err(_) => Ok(Json(SecretResponse { error: true })), } } +async fn check_provider_secrets( + Json(request): Json, +) -> Json> { + let mut response = HashMap::new(); + + for provider_name in request.providers { + if let Some(keys) = PROVIDER_ENV_REQUIREMENTS.get(&provider_name) { + let mut secret_status = HashMap::new(); + + for key in keys { + // Assume check_key_status returns (bool, Option) + 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, ProviderStatus { + supported: true, + secret_status, + }); + } else { + // Provider not supported + response.insert(provider_name, ProviderStatus { + supported: false, + secret_status: HashMap::new(), + }); + } + } + + Json(response) +} + pub fn routes(state: AppState) -> Router { Router::new() .route("/secrets/store", post(store_secret)) - .with_state(state) -} + .route("/secrets/provider", get(list_provider_secrets)) \ No newline at end of file From 6c60d8071b10adbfb7116e51f73433c9a80ed9f1 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Wed, 15 Jan 2025 21:21:51 -0800 Subject: [PATCH 02/32] add tests --- crates/goose-server/src/routes/secrets.rs | 120 +++++++++++++++++++--- 1 file changed, 107 insertions(+), 13 deletions(-) diff --git a/crates/goose-server/src/routes/secrets.rs b/crates/goose-server/src/routes/secrets.rs index f20c54d70..988f33cf6 100644 --- a/crates/goose-server/src/routes/secrets.rs +++ b/crates/goose-server/src/routes/secrets.rs @@ -17,18 +17,6 @@ struct SecretRequest { value: String, } -#[derive(Serialize)] -struct ProviderStatus { - supported: bool, - secret_status: HashMap, // Map of API key names to their statuses -} - -#[derive(Serialize)] -struct SecretStatus { - is_set: bool, // True if the key is set - location: Option, // "env", "keychain", or None -} - #[derive(Serialize)] struct SecretSource { key: String, @@ -147,7 +135,113 @@ async fn check_provider_secrets( Json(response) } +#[cfg(test)] +mod tests { + use super::*; + use axum::Json; + use std::collections::HashMap; + use lazy_static::lazy_static; + + // Mock PROVIDER_ENV_REQUIREMENTS for testing + lazy_static! { + static ref TEST_PROVIDER_REQUIREMENTS: HashMap> = { + let mut m = HashMap::new(); + m.insert( + "test_provider".to_string(), + vec!["TEST_API_KEY".to_string(), "TEST_SECRET".to_string()] + ); + m + }; + } + + #[tokio::test] + async fn test_supported_provider_with_set_keys() { + // Setup + let request = ProviderRequest { + providers: vec!["test_provider".to_string()] + }; + + // Set environment variables for testing + std::env::set_var("TEST_API_KEY", "dummy_value"); + std::env::set_var("TEST_SECRET", "dummy_secret"); + + // Execute + let Json(response) = check_provider_secrets(Json(request)).await; + + // Assert + let provider_status = response.get("test_provider").expect("Provider should exist"); + assert!(provider_status.supported); + + let secret_status = &provider_status.secret_status; + assert!(secret_status.get("TEST_API_KEY").unwrap().is_set); + assert!(secret_status.get("TEST_SECRET").unwrap().is_set); + } + + #[tokio::test] + async fn test_unsupported_provider() { + // Setup + let request = ProviderRequest { + providers: vec!["unsupported_provider".to_string()] + }; + + // Execute + let Json(response) = check_provider_secrets(Json(request)).await; + + // Assert + let provider_status = response.get("unsupported_provider").expect("Provider should exist"); + assert!(!provider_status.supported); + assert!(provider_status.secret_status.is_empty()); + } + + #[tokio::test] + async fn test_supported_provider_with_missing_keys() { + // Setup + let request = ProviderRequest { + providers: vec!["test_provider".to_string()] + }; + + // Remove environment variables if they exist + std::env::remove_var("TEST_API_KEY"); + std::env::remove_var("TEST_SECRET"); + + // Execute + let Json(response) = check_provider_secrets(Json(request)).await; + + // Assert + let provider_status = response.get("test_provider").expect("Provider should exist"); + assert!(provider_status.supported); + + let secret_status = &provider_status.secret_status; + assert!(!secret_status.get("TEST_API_KEY").unwrap().is_set); + assert!(!secret_status.get("TEST_SECRET").unwrap().is_set); + } + + #[tokio::test] + async fn test_multiple_providers() { + // Setup + let request = ProviderRequest { + providers: vec![ + "test_provider".to_string(), + "unsupported_provider".to_string() + ] + }; + + // Execute + let Json(response) = check_provider_secrets(Json(request)).await; + + // Assert + assert_eq!(response.len(), 2); + + let supported_status = response.get("test_provider").expect("Supported provider should exist"); + assert!(supported_status.supported); + + let unsupported_status = response.get("unsupported_provider").expect("Unsupported provider should exist"); + assert!(!unsupported_status.supported); + } +} + pub fn routes(state: AppState) -> Router { Router::new() .route("/secrets/store", post(store_secret)) - .route("/secrets/provider", get(list_provider_secrets)) \ No newline at end of file + .route("/secrets/provider", get(list_provider_secrets)) +} \ No newline at end of file From f8fc4a85f68f56cb553ee4c9a475a0fa6640a8d5 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Wed, 15 Jan 2025 21:26:23 -0800 Subject: [PATCH 03/32] passing tests --- crates/goose-server/src/routes/secrets.rs | 177 ++++++---------------- 1 file changed, 45 insertions(+), 132 deletions(-) diff --git a/crates/goose-server/src/routes/secrets.rs b/crates/goose-server/src/routes/secrets.rs index 988f33cf6..e65e84e07 100644 --- a/crates/goose-server/src/routes/secrets.rs +++ b/crates/goose-server/src/routes/secrets.rs @@ -1,99 +1,49 @@ -use crate::state::AppState; -use axum::{extract::State, routing::{post, get}, Json, Router}; -use goose::key_manager::save_to_keyring; -use http::{HeaderMap, StatusCode}; -use serde::{Deserialize, Serialize}; -use std::{env, collections::HashMap}; +use axum::{ + routing::{get, post}, + Json, Router, +}; use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use crate::state::AppState; +use goose::key_manager::{get_keyring_secret, KeyRetrievalStrategy}; -#[derive(Serialize)] -struct SecretResponse { - error: bool, -} - -#[derive(Deserialize)] -struct SecretRequest { - key: String, - value: String, -} - -#[derive(Serialize)] -struct SecretSource { - key: String, - source: String, // "env", "keyring", or "none" - is_set: bool, // true if the secret exists, false otherwise -} - -#[derive(Serialize)] -struct SecretsListResponse { - secrets: Vec, +// Define the types needed for the API +#[derive(Debug, Serialize, Deserialize)] +pub struct ProviderRequest { + pub providers: Vec, } -#[derive(Debug, Serialize)] -struct KeyStatus { - set: bool, - location: Option, // "env", "keyring", or null - supported: bool, +#[derive(Debug, Serialize, Deserialize)] +pub struct SecretStatus { + pub is_set: bool, + pub location: Option, } -#[derive(Debug, Deserialize)] -struct ProviderRequest { - providers: Vec, +#[derive(Debug, Serialize, Deserialize)] +pub struct ProviderStatus { + pub supported: bool, + pub secret_status: HashMap, } +// Define the provider requirements 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") + let mut m = HashMap::new(); + m.insert( + "test_provider".to_string(), + vec!["TEST_API_KEY".to_string(), "TEST_SECRET".to_string()] + ); + m }); -fn get_supported_secrets() -> Vec<&'static str> { - PROVIDER_KEYS.values() - .flat_map(|keys| keys.iter()) - .map(|s| s.as_str()) - .collect() -} - - - -/// Check the status of a key, including whether it's set and its location. -pub fn check_key_status(key_name: &str) -> (bool, Option) { - // Current hierarchy: prioritize environment variables over keyring - if let Ok(_) = env::var(key_name) { - return (true, Some("env".to_string())); // Found in environment - } - - if let Ok(_) = get_keyring_secret(key_name, KeyRetrievalStrategy::KeyringOnly) { - return (true, Some("keychain".to_string())); // Found in keyring - } - - (false, None) // Not found in either source -} - - -async fn store_secret( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Result, StatusCode> { - // 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); - } - - // Verify this is a supported secret key - let supported_secrets = get_supported_secrets(); - if !supported_secrets.contains(&request.key.as_str()) { - return Err(StatusCode::BAD_REQUEST); - } - - match save_to_keyring(&request.key, &request.value) { - Ok(_) => Ok(Json(SecretResponse { error: false })), - Err(_) => Ok(Json(SecretResponse { error: true })), +// Helper function to check key status +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) } } @@ -107,9 +57,7 @@ async fn check_provider_secrets( let mut secret_status = HashMap::new(); for key in keys { - // Assume check_key_status returns (bool, Option) let (key_set, key_location) = check_key_status(key); - secret_status.insert( key.to_string(), SecretStatus { @@ -124,7 +72,6 @@ async fn check_provider_secrets( secret_status, }); } else { - // Provider not supported response.insert(provider_name, ProviderStatus { supported: false, secret_status: HashMap::new(), @@ -135,24 +82,15 @@ async fn check_provider_secrets( Json(response) } +pub fn routes(state: AppState) -> Router { + Router::new() + .route("/provider", post(check_provider_secrets)) + .with_state(state) +} + #[cfg(test)] mod tests { use super::*; - use axum::Json; - use std::collections::HashMap; - use lazy_static::lazy_static; - - // Mock PROVIDER_ENV_REQUIREMENTS for testing - lazy_static! { - static ref TEST_PROVIDER_REQUIREMENTS: HashMap> = { - let mut m = HashMap::new(); - m.insert( - "test_provider".to_string(), - vec!["TEST_API_KEY".to_string(), "TEST_SECRET".to_string()] - ); - m - }; - } #[tokio::test] async fn test_supported_provider_with_set_keys() { @@ -175,6 +113,10 @@ mod tests { let secret_status = &provider_status.secret_status; assert!(secret_status.get("TEST_API_KEY").unwrap().is_set); assert!(secret_status.get("TEST_SECRET").unwrap().is_set); + + // Cleanup + std::env::remove_var("TEST_API_KEY"); + std::env::remove_var("TEST_SECRET"); } #[tokio::test] @@ -193,29 +135,6 @@ mod tests { assert!(provider_status.secret_status.is_empty()); } - #[tokio::test] - async fn test_supported_provider_with_missing_keys() { - // Setup - let request = ProviderRequest { - providers: vec!["test_provider".to_string()] - }; - - // Remove environment variables if they exist - std::env::remove_var("TEST_API_KEY"); - std::env::remove_var("TEST_SECRET"); - - // Execute - let Json(response) = check_provider_secrets(Json(request)).await; - - // Assert - let provider_status = response.get("test_provider").expect("Provider should exist"); - assert!(provider_status.supported); - - let secret_status = &provider_status.secret_status; - assert!(!secret_status.get("TEST_API_KEY").unwrap().is_set); - assert!(!secret_status.get("TEST_SECRET").unwrap().is_set); - } - #[tokio::test] async fn test_multiple_providers() { // Setup @@ -238,10 +157,4 @@ mod tests { let unsupported_status = response.get("unsupported_provider").expect("Unsupported provider should exist"); assert!(!unsupported_status.supported); } -} - -pub fn routes(state: AppState) -> Router { - Router::new() - .route("/secrets/store", post(store_secret)) - .route("/secrets/provider", get(list_provider_secrets)) } \ No newline at end of file From 3875e49169bce9a4ae396e5cc77ef01cbbce57cd Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Wed, 15 Jan 2025 22:13:24 -0800 Subject: [PATCH 04/32] add back in store route --- crates/goose-server/Cargo.toml | 1 + crates/goose-server/src/routes/secrets.rs | 123 ++++++++++------------ 2 files changed, 55 insertions(+), 69 deletions(-) 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/secrets.rs b/crates/goose-server/src/routes/secrets.rs index e65e84e07..b37e25745 100644 --- a/crates/goose-server/src/routes/secrets.rs +++ b/crates/goose-server/src/routes/secrets.rs @@ -1,16 +1,50 @@ use axum::{ + extract::State, routing::{get, post}, Json, Router, }; -use once_cell::sync::Lazy; +use once_cell::sync::Lazy; // TODO: investigate if we need use serde::{Deserialize, Serialize}; use std::collections::HashMap; use crate::state::AppState; -use goose::key_manager::{get_keyring_secret, KeyRetrievalStrategy}; +use http::{HeaderMap, StatusCode}; +use goose::key_manager::{save_to_keyring, get_keyring_secret, KeyRetrievalStrategy}; + +#[derive(Serialize)] +struct SecretResponse { + error: bool, +} + +#[derive(Deserialize)] +struct SecretRequest { + key: String, + value: String, +} + +async fn store_secret( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, StatusCode> { + // 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); + } + + match save_to_keyring(&request.key, &request.value) { + Ok(_) => Ok(Json(SecretResponse { error: false })), + Err(_) => Ok(Json(SecretResponse { error: true })), + } +} + -// Define the types needed for the API #[derive(Debug, Serialize, Deserialize)] -pub struct ProviderRequest { +pub struct ProviderSecretRequest { pub providers: Vec, } @@ -26,19 +60,15 @@ pub struct ProviderStatus { pub secret_status: HashMap, } -// Define the provider requirements static PROVIDER_ENV_REQUIREMENTS: Lazy>> = Lazy::new(|| { - let mut m = HashMap::new(); - m.insert( - "test_provider".to_string(), - vec!["TEST_API_KEY".to_string(), "TEST_SECRET".to_string()] - ); - m + let contents = include_str!("providers_and_keys.json"); + serde_json::from_str(contents).expect("Failed to parse providers_and_keys.json") }); -// Helper function to check key status + +// Helper function to check if a key is set somewhere fn check_key_status(key: &str) -> (bool, Option) { - if let Ok(value) = std::env::var(key) { + 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())) @@ -48,8 +78,8 @@ fn check_key_status(key: &str) -> (bool, Option) { } async fn check_provider_secrets( - Json(request): Json, -) -> Json> { + Json(request): Json, +) -> Result>, StatusCode> { let mut response = HashMap::new(); for provider_name in request.providers { @@ -79,12 +109,13 @@ async fn check_provider_secrets( } } - Json(response) + Ok(Json(response)) } pub fn routes(state: AppState) -> Router { Router::new() - .route("/provider", post(check_provider_secrets)) + .route("/secrets/providers", get(check_provider_secrets)) + .route("/secrets/store", post(store_secret)) .with_state(state) } @@ -92,69 +123,23 @@ pub fn routes(state: AppState) -> Router { mod tests { use super::*; - #[tokio::test] - async fn test_supported_provider_with_set_keys() { - // Setup - let request = ProviderRequest { - providers: vec!["test_provider".to_string()] - }; - - // Set environment variables for testing - std::env::set_var("TEST_API_KEY", "dummy_value"); - std::env::set_var("TEST_SECRET", "dummy_secret"); - - // Execute - let Json(response) = check_provider_secrets(Json(request)).await; - - // Assert - let provider_status = response.get("test_provider").expect("Provider should exist"); - assert!(provider_status.supported); - - let secret_status = &provider_status.secret_status; - assert!(secret_status.get("TEST_API_KEY").unwrap().is_set); - assert!(secret_status.get("TEST_SECRET").unwrap().is_set); - - // Cleanup - std::env::remove_var("TEST_API_KEY"); - std::env::remove_var("TEST_SECRET"); - } - #[tokio::test] async fn test_unsupported_provider() { // Setup - let request = ProviderRequest { - providers: vec!["unsupported_provider".to_string()] + let request = ProviderSecretRequest { + providers: vec!["unsupported_provider".to_string()], }; // Execute - let Json(response) = check_provider_secrets(Json(request)).await; + 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()); } - #[tokio::test] - async fn test_multiple_providers() { - // Setup - let request = ProviderRequest { - providers: vec![ - "test_provider".to_string(), - "unsupported_provider".to_string() - ] - }; - - // Execute - let Json(response) = check_provider_secrets(Json(request)).await; - - // Assert - assert_eq!(response.len(), 2); - - let supported_status = response.get("test_provider").expect("Supported provider should exist"); - assert!(supported_status.supported); - - let unsupported_status = response.get("unsupported_provider").expect("Unsupported provider should exist"); - assert!(!unsupported_status.supported); - } } \ No newline at end of file From c8e8162e57ce1e49b168e7687c3460b7d46dc3b5 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 13:45:21 -0800 Subject: [PATCH 05/32] resolve conflicts --- ui/desktop/src/ChatWindow.tsx | 7 +- ui/desktop/src/components/MoreMenu.tsx | 9 + ui/desktop/src/components/settings/Keys.tsx | 255 ++++++++++++++ .../src/components/settings/SecretsList.tsx | 135 ++++++++ .../src/components/settings/Settings.tsx | 310 +++++++++--------- ui/desktop/src/components/ui/toast.tsx | 4 +- ui/desktop/src/types/electron.d.ts | 3 +- 7 files changed, 564 insertions(+), 159 deletions(-) create mode 100644 ui/desktop/src/components/settings/Keys.tsx create mode 100644 ui/desktop/src/components/settings/SecretsList.tsx diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index d5846ce4e..85687e73a 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -16,7 +16,11 @@ 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, + ProviderOption, +} from "./utils/providerUtils"; +import Keys from "./components/settings/Keys"; declare global { interface Window { @@ -520,6 +524,7 @@ export default function ChatWindow() { } /> } /> + } /> } /> diff --git a/ui/desktop/src/components/MoreMenu.tsx b/ui/desktop/src/components/MoreMenu.tsx index cf28d90be..a05d5085c 100644 --- a/ui/desktop/src/components/MoreMenu.tsx +++ b/ui/desktop/src/components/MoreMenu.tsx @@ -246,6 +246,15 @@ export default function MoreMenu() { > Reset Provider + diff --git a/ui/desktop/src/components/settings/Keys.tsx b/ui/desktop/src/components/settings/Keys.tsx new file mode 100644 index 000000000..47ea155bb --- /dev/null +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -0,0 +1,255 @@ +import React, { useEffect, useState } from 'react'; +import { getApiUrl, getSecretKey } from "../../config"; +import { FaKey, FaExclamationCircle, FaPencilAlt, FaTrash } from 'react-icons/fa'; +import { showToast } from '../ui/toast'; + +interface SecretSource { + key: string; + source: string; + is_set: boolean; +} + +interface Provider { + id: string; + name: string; + keys: string[]; + description: string; + canDelete?: boolean; +} + +interface ProviderSecretStatus { + is_set: boolean; + location: string | null; +} + +interface ProviderResponse { + supported: boolean; + secret_status?: Record; +} + +const PROVIDERS: Provider[] = [ + { + id: 'openai', + name: 'OpenAI', + keys: ['OPENAI_API_KEY'], + description: 'OpenAI API access for GPT models' + }, + { + id: 'anthropic', + name: 'Anthropic', + keys: ['ANTHROPIC_API_KEY'], + description: 'Anthropic API access for Claude models' + }, + { + id: 'databricks', + name: 'Databricks', + keys: ['DATABRICKS_API_KEY'], + description: 'Databricks API access', + canDelete: true + }, + { + id: 'otherProvider', + name: 'OtherProvider', + keys: ['MY_API_KEY'], + description: 'OtherProvider API access', + canDelete: true + }, + + +]; + +// Mock data that matches the Rust endpoint response format +const MOCK_SECRETS_RESPONSE = { + 'openai': { + supported: true, + secret_status: { + 'OPENAI_API_KEY': { + is_set: true, + location: 'keyring' + } + } + }, + 'anthropic': { + supported: true, + secret_status: { + 'ANTHROPIC_API_KEY': { + is_set: false, + location: null + } + } + }, + 'databricks': { + supported: true, + secret_status: { + 'DATABRICKS_API_KEY': { + is_set: true, + location: 'env' + } + } + }, + 'otherProvider': { + supported: false, + } +}; + +export default function Keys() { + const [secrets, setSecrets] = useState([]); + const [expandedProviders, setExpandedProviders] = useState>(new Set()); + const [providers, setProviders] = useState(PROVIDERS); + + useEffect(() => { + const fetchSecrets = async () => { + try { + await new Promise(resolve => setTimeout(resolve, 500)); + + // Transform only supported providers with secret_status + const transformedSecrets = Object.entries(MOCK_SECRETS_RESPONSE) + .filter(([_, status]) => status.supported && status.secret_status) + .flatMap(([_, status]) => + Object.entries(status.secret_status!).map(([key, secretStatus]) => ({ + key, + source: secretStatus.location || 'none', + is_set: secretStatus.is_set + })) + ); + + setSecrets(transformedSecrets); + } catch (error) { + console.error('Error fetching secrets:', error); + } + }; + + fetchSecrets(); + }, []); + + const getProviderStatus = (provider: Provider) => { + const providerSecrets = provider.keys.map(key => + secrets.find(s => s.key === key) + ); + return providerSecrets.some(s => !s?.is_set); + }; + + const handleEdit = (key: string) => { + showToast("Key edited and updated in the keychain", "success"); + }; + + const handleDeleteKey = (providerId: string, key: string) => { + showToast(`Key ${key} deleted`, "success"); + }; + + const handleDeleteProvider = (providerId: string) => { + setProviders(providers.filter(p => p.id !== providerId)); + showToast(`Provider ${providerId} removed`, "success"); + }; + + const toggleProvider = (providerId: string) => { + setExpandedProviders(prev => { + const newSet = new Set(prev); + if (newSet.has(providerId)) { + newSet.delete(providerId); + } else { + newSet.add(providerId); + } + return newSet; + }); + }; + + const isProviderSupported = (providerId: string) => { + return MOCK_SECRETS_RESPONSE[providerId]?.supported ?? false; + }; + + return ( +
+
+

Providers

+ +
+ +
+ {providers.map((provider) => { + const hasUnsetKeys = getProviderStatus(provider); + const isExpanded = expandedProviders.has(provider.id); + const isSupported = isProviderSupported(provider.id); + + return ( +
+
+ + {provider.canDelete && ( + + )} +
+ + {isSupported && isExpanded && ( +
+ {provider.keys.map(key => { + const secret = secrets.find(s => s.key === key); + return ( +
+
+

{key}

+

+ Source: {secret?.source || 'none'} +

+
+
+ + {secret?.is_set ? 'Set' : 'Not Set'} + + + +
+
+ ); + })} +
+ )} +
+ ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/ui/desktop/src/components/settings/SecretsList.tsx b/ui/desktop/src/components/settings/SecretsList.tsx new file mode 100644 index 000000000..fe3fe4fc1 --- /dev/null +++ b/ui/desktop/src/components/settings/SecretsList.tsx @@ -0,0 +1,135 @@ +import React, { useEffect, useState } from 'react'; +import { getApiUrl, getSecretKey } from '../../config'; + +interface SecretSource { + key: string; + source: string; + is_set: boolean; +} + +interface SecretsListResponse { + secrets: SecretSource[]; +} + +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 && ( + <> + + + + )} + +
+
+ ))} +
+
+ ); +}; + +// Simple icon components +const EyeIcon = (props: React.SVGProps) => ( + + + + +); + +const ClipboardIcon = (props: React.SVGProps) => ( + + + +); + +const PencilIcon = (props: React.SVGProps) => ( + + + +); \ 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..c3ca6d255 100644 --- a/ui/desktop/src/components/settings/Settings.tsx +++ b/ui/desktop/src/components/settings/Settings.tsx @@ -14,7 +14,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 +96,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 +104,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 +147,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 +177,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} + /> +
); } 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/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 { From 265bde29e16e31646d4f918fa85066c9998dbadd Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Wed, 15 Jan 2025 23:30:16 -0800 Subject: [PATCH 06/32] ability to delete keys --- crates/goose-server/src/routes/secrets.rs | 35 +++++- crates/goose/src/key_manager.rs | 26 +++++ ui/desktop/src/components/settings/Keys.tsx | 113 ++++++++++++++++++-- 3 files changed, 164 insertions(+), 10 deletions(-) diff --git a/crates/goose-server/src/routes/secrets.rs b/crates/goose-server/src/routes/secrets.rs index b37e25745..cd40c63b5 100644 --- a/crates/goose-server/src/routes/secrets.rs +++ b/crates/goose-server/src/routes/secrets.rs @@ -1,6 +1,7 @@ use axum::{ extract::State, - routing::{get, post}, + routing::post, + routing::delete, Json, Router, }; use once_cell::sync::Lazy; // TODO: investigate if we need @@ -8,7 +9,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use crate::state::AppState; use http::{HeaderMap, StatusCode}; -use goose::key_manager::{save_to_keyring, get_keyring_secret, KeyRetrievalStrategy}; +use goose::key_manager::{save_to_keyring, get_keyring_secret, delete_from_keyring, KeyRetrievalStrategy}; #[derive(Serialize)] struct SecretResponse { @@ -112,10 +113,38 @@ async fn check_provider_secrets( 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", get(check_provider_secrets)) + .route("/secrets/providers", post(check_provider_secrets)) .route("/secrets/store", post(store_secret)) + .route("/secrets/delete", delete(delete_secret)) .with_state(state) } 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/components/settings/Keys.tsx b/ui/desktop/src/components/settings/Keys.tsx index 47ea155bb..f275fecf2 100644 --- a/ui/desktop/src/components/settings/Keys.tsx +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -1,7 +1,14 @@ import React, { useEffect, useState } from 'react'; import { getApiUrl, getSecretKey } from "../../config"; -import { FaKey, FaExclamationCircle, FaPencilAlt, FaTrash } from 'react-icons/fa'; +import { FaKey, FaExclamationCircle, FaPencilAlt, FaTrash, FaArrowLeft } from 'react-icons/fa'; import { showToast } from '../ui/toast'; +import { useNavigate } from 'react-router-dom'; +import { + Modal, + ModalContent, + ModalHeader, + ModalTitle +} from '../ui/modal'; interface SecretSource { key: string; @@ -92,10 +99,21 @@ const MOCK_SECRETS_RESPONSE = { } }; +interface ProviderStatusResponse { + [provider: string]: { + set: boolean; + location: string | null; + supported: boolean; + }; +} + export default function Keys() { + const navigate = useNavigate(); const [secrets, setSecrets] = useState([]); const [expandedProviders, setExpandedProviders] = useState>(new Set()); const [providers, setProviders] = useState(PROVIDERS); + const [showTestModal, setShowTestModal] = useState(false); + const [testResponse, setTestResponse] = useState(null); useEffect(() => { const fetchSecrets = async () => { @@ -133,8 +151,36 @@ export default function Keys() { showToast("Key edited and updated in the keychain", "success"); }; - const handleDeleteKey = (providerId: string, key: string) => { - showToast(`Key ${key} deleted`, "success"); + const handleDeleteKey = async (providerId: string, key: string) => { + // Find the secret to check its source + const secret = secrets.find(s => s.key === key); + + if (secret?.source === 'env') { + showToast("This key is set in your environment. Please remove it from your ~/.zshrc or equivalent file.", "error"); + return; + } + + try { + const response = await fetch(getApiUrl("/secrets/delete"), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify({ key }) + }); + + if (!response.ok) { + throw new Error('Failed to delete key'); + } + + // Update local state to reflect deletion + setSecrets(secrets.filter(s => s.key !== key)); + showToast(`Key ${key} deleted from keychain`, "success"); + } catch (error) { + console.error('Error deleting key:', error); + showToast("Failed to delete key", "error"); + } }; const handleDeleteProvider = (providerId: string) => { @@ -158,13 +204,53 @@ export default function Keys() { return MOCK_SECRETS_RESPONSE[providerId]?.supported ?? false; }; + const handleTestProviders = async () => { + try { + const response = await fetch(getApiUrl("/secrets/providers"), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + providers: ["OpenAI", "Anthropic"] + }) + }); + + if (!response.ok) { + throw new Error('Failed to fetch provider status'); + } + + const data = await response.json(); + setTestResponse(data); + setShowTestModal(true); + } catch (error) { + console.error('Error testing providers:', error); + showToast("Failed to test providers", "error"); + } + }; + return (
-
-

Providers

- +

Providers

+
+ + +
@@ -250,6 +336,19 @@ export default function Keys() { ); })}
+ + + + + Provider Status Test + +
+
+              {testResponse && JSON.stringify(testResponse, null, 2)}
+            
+
+
+
); } \ No newline at end of file From 4620c94ebbcc6b0685dcae14ad5292364ad62d3d Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Wed, 15 Jan 2025 23:43:21 -0800 Subject: [PATCH 07/32] show selected provider --- ui/desktop/src/components/settings/Keys.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ui/desktop/src/components/settings/Keys.tsx b/ui/desktop/src/components/settings/Keys.tsx index f275fecf2..7c50d2c48 100644 --- a/ui/desktop/src/components/settings/Keys.tsx +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -132,6 +132,16 @@ export default function Keys() { ); setSecrets(transformedSecrets); + // Check the GOOSE_PROVIDER from localStorage + const gooseProvider = localStorage.getItem("GOOSE_PROVIDER").toLowerCase() || null; + if (gooseProvider) { + const matchedProvider = PROVIDERS.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); } From 074bb2006ec69c7e9613219627b060dbba4b506e Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Thu, 16 Jan 2025 00:17:25 -0800 Subject: [PATCH 08/32] selected provider displayed --- ui/desktop/src/components/settings/Keys.tsx | 64 +++++++++++++++++---- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/ui/desktop/src/components/settings/Keys.tsx b/ui/desktop/src/components/settings/Keys.tsx index 7c50d2c48..c7aee29a6 100644 --- a/ui/desktop/src/components/settings/Keys.tsx +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -118,10 +118,40 @@ export default function Keys() { useEffect(() => { const fetchSecrets = async () => { try { - await new Promise(resolve => setTimeout(resolve, 500)); + const response = await fetch(getApiUrl("/secrets/providers"), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify({ + providers: ["OpenAI", "Anthropic", "MyProvider"] + }) + }); + + if (!response.ok) { + throw new Error('Failed to fetch secrets'); + } + + let data = await response.json(); + console.log(data) + // Append test data for additional providers + data = { + ...data, + 'databricks': { + supported: true, + secret_status: { + 'DATABRICKS_API_KEY': { + is_set: false, + location: null + } + } + } + }; + // Transform only supported providers with secret_status - const transformedSecrets = Object.entries(MOCK_SECRETS_RESPONSE) + const transformedSecrets = Object.entries(data) .filter(([_, status]) => status.supported && status.secret_status) .flatMap(([_, status]) => Object.entries(status.secret_status!).map(([key, secretStatus]) => ({ @@ -132,15 +162,16 @@ export default function Keys() { ); setSecrets(transformedSecrets); + // Check the GOOSE_PROVIDER from localStorage - const gooseProvider = localStorage.getItem("GOOSE_PROVIDER").toLowerCase() || null; + const gooseProvider = localStorage.getItem("GOOSE_PROVIDER")?.toLowerCase() || null; if (gooseProvider) { - const matchedProvider = PROVIDERS.find(provider => provider.id.toLowerCase() === gooseProvider); - if (matchedProvider) { + const matchedProvider = PROVIDERS.find(provider => provider.id.toLowerCase() === gooseProvider); + if (matchedProvider) { setExpandedProviders(new Set([matchedProvider.id])); - } else { + } else { console.warn(`Provider ${gooseProvider} not found in settings.`); - } + } } } catch (error) { console.error('Error fetching secrets:', error); @@ -220,9 +251,10 @@ export default function Keys() { method: 'POST', headers: { 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), }, body: JSON.stringify({ - providers: ["OpenAI", "Anthropic"] + providers: ["OpenAI", "Anthropic", "MyProvider"] }) }); @@ -281,7 +313,19 @@ export default function Keys() {
-

{provider.name}

+
+

{provider.name}

+ {provider.id.toLowerCase() === (localStorage.getItem("GOOSE_PROVIDER")?.toLowerCase() || '') && ( + + Selected Provider + + )} + {!isSupported && ( + + Not Supported + + )} +

{isSupported ? provider.description : 'Provider not supported'}

@@ -320,7 +364,7 @@ export default function Keys() { ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' }`}> - {secret?.is_set ? 'Set' : 'Not Set'} + {secret?.is_set ? 'Key set' : 'Missing'} @@ -403,6 +421,33 @@ export default function Keys() {
+ + setKeyToDelete(null)}> + + + Confirm Deletion + +
+

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

+
+ + +
+
+
+
); } \ No newline at end of file From 226604feab9699d74b87e6492cd97e8fdad069f7 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Thu, 16 Jan 2025 01:26:34 -0800 Subject: [PATCH 10/32] broken attempt to change providers --- ui/desktop/src/components/settings/Keys.tsx | 129 ++++++++++++++++---- 1 file changed, 105 insertions(+), 24 deletions(-) diff --git a/ui/desktop/src/components/settings/Keys.tsx b/ui/desktop/src/components/settings/Keys.tsx index 8755ba330..0e9f8dd50 100644 --- a/ui/desktop/src/components/settings/Keys.tsx +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { getApiUrl, getSecretKey } from "../../config"; -import { FaKey, FaExclamationCircle, FaPencilAlt, FaTrash, FaArrowLeft } from 'react-icons/fa'; +import { FaKey, FaExclamationCircle, FaPencilAlt, FaTrash, FaArrowLeft, FaPlus } from 'react-icons/fa'; import { showToast } from '../ui/toast'; import { useNavigate } from 'react-router-dom'; import { @@ -38,6 +38,14 @@ interface ProviderResponse { secret_status?: Record; } +interface ProviderStatusResponse { + [provider: string]: { + set: boolean; + location: string | null; + supported: boolean; + }; +} + export default function Keys() { const navigate = useNavigate(); const [secrets, setSecrets] = useState([]); @@ -46,6 +54,7 @@ export default function Keys() { const [showTestModal, setShowTestModal] = useState(false); const [testResponse, setTestResponse] = useState(null); const [keyToDelete, setKeyToDelete] = useState<{providerId: string, key: string} | null>(null); + const [isChangingProvider, setIsChangingProvider] = useState(false); useEffect(() => { const fetchSecrets = async () => { @@ -65,7 +74,7 @@ export default function Keys() { throw new Error('Failed to fetch secrets'); } - const data = await response.json(); + const data = await response.json() as Record; console.log(data); // Transform the backend response into Provider objects @@ -131,8 +140,8 @@ export default function Keys() { return providerSecrets.some(s => !s?.is_set); }; - const handleEdit = async (key: string) => { - // Find the secret to check its source + const handleAddOrEdit = async (key: string) => { + // Find the secret to check its source and status const secret = secrets.find(s => s.key === key); if (secret?.source === 'env') { @@ -140,23 +149,25 @@ export default function Keys() { return; } - // Get new value from user (you might want to use a modal instead) - const newValue = prompt("Enter new API key:"); + const isAdding = !secret?.is_set; + const newValue = prompt(isAdding ? "Enter API key:" : "Enter new API key:"); if (!newValue) return; // User cancelled try { - // Delete old key first - const deleteResponse = await fetch(getApiUrl("/secrets/delete"), { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ key }) - }); + if (!isAdding) { + // Delete old key first if editing + const deleteResponse = await fetch(getApiUrl("/secrets/delete"), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify({ key }) + }); - if (!deleteResponse.ok) { - throw new Error('Failed to delete old key'); + if (!deleteResponse.ok) { + throw new Error('Failed to delete old key'); + } } // Store new key @@ -173,7 +184,7 @@ export default function Keys() { }); if (!storeResponse.ok) { - throw new Error('Failed to store new key'); + throw new Error(isAdding ? 'Failed to add key' : 'Failed to store new key'); } // Update local state @@ -183,10 +194,10 @@ export default function Keys() { : s )); - showToast("Key updated successfully", "success"); + showToast(isAdding ? "Key added successfully" : "Key updated successfully", "success"); } catch (error) { console.error('Error updating key:', error); - showToast("Failed to update key", "error"); + showToast(isAdding ? "Failed to add key" : "Failed to update key", "error"); } }; @@ -270,7 +281,7 @@ export default function Keys() { throw new Error('Failed to fetch provider status'); } - const data = await response.json(); + const data = await response.json() as Record; setTestResponse(data); setShowTestModal(true); } catch (error) { @@ -279,6 +290,65 @@ export default function Keys() { } }; + const handleSelectProvider = async (providerId: string) => { + setIsChangingProvider(true); + try { + const url = getApiUrl("/agent"); + console.log("Attempting to fetch:", url); + + // Initialize agent with new provider + const agentResponse = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Secret-Key": getSecretKey(), + }, + body: JSON.stringify({ + provider: providerId, + }), + }).catch(error => { + console.error("Fetch failed:", error); + throw error; + }); + + if (agentResponse.status === 401) { + throw new Error("Unauthorized - invalid secret key"); + } + if (!agentResponse.ok) { + throw new Error(`Failed to set agent: ${agentResponse.statusText}`); + } + + // Initialize system config + const systemResponse = await fetch(getApiUrl("/system"), { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Secret-Key": getSecretKey(), + }, + body: JSON.stringify({ system: "developer2" }), + }); + + if (!systemResponse.ok) { + throw new Error("Failed to set system config"); + } + + // Update localStorage + const provider = providers.find(p => p.id === providerId); + if (provider) { + localStorage.setItem("GOOSE_PROVIDER", provider.name); + showToast(`Switched to ${provider.name}`, "success"); + + // Spawn new chat window with the new provider + window.electron.createChatWindow(); + } + } catch (error) { + console.error("Failed to change provider:", error); + showToast(error instanceof Error ? error.message : "Failed to change provider", "error"); + } finally { + setIsChangingProvider(false); + } + }; + return (
@@ -375,11 +445,11 @@ export default function Keys() { {secret?.is_set ? 'Key set' : 'Missing'}
)} + +
); })} From 0c2ca40a3dc92896ce305962791c8917472e9500 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Thu, 16 Jan 2025 10:36:14 -0800 Subject: [PATCH 11/32] stable keys page --- ui/desktop/src/components/settings/Keys.tsx | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/ui/desktop/src/components/settings/Keys.tsx b/ui/desktop/src/components/settings/Keys.tsx index 0e9f8dd50..46e975eb3 100644 --- a/ui/desktop/src/components/settings/Keys.tsx +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -472,19 +472,18 @@ export default function Keys() { ); })} + + {provider.id.toLowerCase() !== localStorage.getItem("GOOSE_PROVIDER")?.toLowerCase() && ( + + )} )} - - ); })} From 36660bb00d63f55136ebc2b2724310c92d2747d1 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 13:46:49 -0800 Subject: [PATCH 12/32] resolve conflicts --- ui/desktop/src/ChatWindow.tsx | 14 +++--- ui/desktop/src/components/settings/Keys.tsx | 42 +---------------- ui/desktop/src/utils/systemInitializer.ts | 52 +++++++++++++++++++++ 3 files changed, 62 insertions(+), 46 deletions(-) create mode 100644 ui/desktop/src/utils/systemInitializer.ts diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index 85687e73a..f7d1620ad 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -21,6 +21,7 @@ import { ProviderOption, } from "./utils/providerUtils"; import Keys from "./components/settings/Keys"; +import { initializeSystem } from './utils/systemInitializer'; declare global { interface Window { @@ -426,14 +427,14 @@ export default function ChatWindow() { return response; }; - const addAgent = async (provider: string) => { + const addAgent = async (provider: ProviderOption) => { const response = await fetch(getApiUrl("/agent"), { method: "POST", headers: { "Content-Type": "application/json", "X-Secret-Key": getSecretKey(), }, - body: JSON.stringify({ provider: provider }), + body: JSON.stringify({ provider: provider.id }), }); if (!response.ok) { @@ -447,13 +448,14 @@ export default function ChatWindow() { await addMCP("goosed", ["mcp", system]); }; - const initializeSystem = async (provider: string) => { + const initializeSystem = async (provider: ProviderOption) => { 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")); + // 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); diff --git a/ui/desktop/src/components/settings/Keys.tsx b/ui/desktop/src/components/settings/Keys.tsx index 46e975eb3..5be1c4b61 100644 --- a/ui/desktop/src/components/settings/Keys.tsx +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -9,6 +9,7 @@ import { ModalHeader, ModalTitle } from '../ui/modal'; +import { initializeSystem } from '../../utils/systemInitializer'; const PROVIDER_ORDER = ['openai', 'anthropic', 'databricks']; @@ -293,46 +294,7 @@ export default function Keys() { const handleSelectProvider = async (providerId: string) => { setIsChangingProvider(true); try { - const url = getApiUrl("/agent"); - console.log("Attempting to fetch:", url); - - // Initialize agent with new provider - const agentResponse = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Secret-Key": getSecretKey(), - }, - body: JSON.stringify({ - provider: providerId, - }), - }).catch(error => { - console.error("Fetch failed:", error); - throw error; - }); - - if (agentResponse.status === 401) { - throw new Error("Unauthorized - invalid secret key"); - } - if (!agentResponse.ok) { - throw new Error(`Failed to set agent: ${agentResponse.statusText}`); - } - - // Initialize system config - const systemResponse = await fetch(getApiUrl("/system"), { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Secret-Key": getSecretKey(), - }, - body: JSON.stringify({ system: "developer2" }), - }); - - if (!systemResponse.ok) { - throw new Error("Failed to set system config"); - } - - // Update localStorage + // Update localStorage const provider = providers.find(p => p.id === providerId); if (provider) { localStorage.setItem("GOOSE_PROVIDER", provider.name); diff --git a/ui/desktop/src/utils/systemInitializer.ts b/ui/desktop/src/utils/systemInitializer.ts new file mode 100644 index 000000000..eddd7a9fb --- /dev/null +++ b/ui/desktop/src/utils/systemInitializer.ts @@ -0,0 +1,52 @@ +import { ProviderOption } from './providerUtils'; +import { getApiUrl, getSecretKey } from '../config'; + +export const addAgent = async (provider: ProviderOption) => { + const response = await fetch(getApiUrl("/agent"), { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Secret-Key": getSecretKey(), + }, + body: JSON.stringify({ provider: provider.id }), + }); + + if (!response.ok) { + throw new Error(`Failed to add agent: ${response.statusText}`); + } + + return response; +}; + +export const addSystemConfig = async (system: string) => { + const systemConfig = { + type: "Stdio", + cmd: await window.electron.getBinaryPath("goosed"), + args: ["mcp", system], + }; + + const response = await fetch(getApiUrl("/systems/add"), { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Secret-Key": getSecretKey(), + }, + body: JSON.stringify(systemConfig), + }); + + if (!response.ok) { + throw new Error(`Failed to add system config for ${system}: ${response.statusText}`); + } + + return response; +}; + +export const initializeSystem = async (provider: ProviderOption) => { + try { + await addAgent(provider); + await addSystemConfig("developer2"); + } catch (error) { + console.error("Failed to initialize system:", error); + throw error; + } +}; \ No newline at end of file From 4d24342ba6657bb52e1bc6f6d858c9b7cf50807b Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Thu, 16 Jan 2025 16:40:17 -0800 Subject: [PATCH 13/32] refresh current window provider state --- ui/desktop/src/components/settings/Keys.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/desktop/src/components/settings/Keys.tsx b/ui/desktop/src/components/settings/Keys.tsx index 5be1c4b61..1deaf31dd 100644 --- a/ui/desktop/src/components/settings/Keys.tsx +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -298,6 +298,7 @@ export default function Keys() { const provider = providers.find(p => p.id === providerId); if (provider) { localStorage.setItem("GOOSE_PROVIDER", provider.name); + initializeSystem(provider) showToast(`Switched to ${provider.name}`, "success"); // Spawn new chat window with the new provider From da317cfe1d198cf543c270c2f0d88c0b20dc0318 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Thu, 16 Jan 2025 17:41:49 -0800 Subject: [PATCH 14/32] update list of supported providers --- .../src/routes/providers_and_keys.json | 40 +++++++++++++++++-- crates/goose-server/src/routes/secrets.rs | 14 +++++-- ui/desktop/src/components/settings/Keys.tsx | 2 +- ui/desktop/src/utils/providerUtils.ts | 9 ++--- ui/desktop/src/utils/systemInitializer.ts | 8 +++- 5 files changed, 59 insertions(+), 14 deletions(-) diff --git a/crates/goose-server/src/routes/providers_and_keys.json b/crates/goose-server/src/routes/providers_and_keys.json index 3dbcdb70a..2371c2367 100644 --- a/crates/goose-server/src/routes/providers_and_keys.json +++ b/crates/goose-server/src/routes/providers_and_keys.json @@ -1,6 +1,38 @@ { - "OpenAI": ["OPENAI_API_KEY"], - "Anthropic": ["ANTHROPIC_API_KEY"], - "Databricks": ["DATABRICKS_API_KEY"], - "OtherProvider": ["OTHER_PROVIDER_KEY_1", "OTHER_PROVIDER_KEY_2"] + "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 cd40c63b5..8e4375dfd 100644 --- a/crates/goose-server/src/routes/secrets.rs +++ b/crates/goose-server/src/routes/secrets.rs @@ -61,7 +61,15 @@ pub struct ProviderStatus { pub secret_status: HashMap, } -static PROVIDER_ENV_REQUIREMENTS: Lazy>> = Lazy::new(|| { +#[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") }); @@ -84,10 +92,10 @@ async fn check_provider_secrets( let mut response = HashMap::new(); for provider_name in request.providers { - if let Some(keys) = PROVIDER_ENV_REQUIREMENTS.get(&provider_name) { + if let Some(provider_config) = PROVIDER_ENV_REQUIREMENTS.get(&provider_name) { let mut secret_status = HashMap::new(); - for key in keys { + for key in &provider_config.required_keys { let (key_set, key_location) = check_key_status(key); secret_status.insert( key.to_string(), diff --git a/ui/desktop/src/components/settings/Keys.tsx b/ui/desktop/src/components/settings/Keys.tsx index 1deaf31dd..06921e04e 100644 --- a/ui/desktop/src/components/settings/Keys.tsx +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -67,7 +67,7 @@ export default function Keys() { 'X-Secret-Key': getSecretKey(), }, body: JSON.stringify({ - providers: ["OpenAI", "Anthropic", "MyProvider", "Databricks"] + providers: ["openai", "anthropic", "myprovider", "databricks"] }) }); diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index dd79273aa..fb2e4914e 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -28,9 +28,8 @@ export const providers: ProviderOption[] = [ } ]; -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 -}; +export function getStoredProvider(config: any): string | null { + return config.GOOSE_PROVIDER || localStorage.getItem("GOOSE_PROVIDER"); +} + diff --git a/ui/desktop/src/utils/systemInitializer.ts b/ui/desktop/src/utils/systemInitializer.ts index eddd7a9fb..4eeedd94c 100644 --- a/ui/desktop/src/utils/systemInitializer.ts +++ b/ui/desktop/src/utils/systemInitializer.ts @@ -1,5 +1,5 @@ import { ProviderOption } from './providerUtils'; -import { getApiUrl, getSecretKey } from '../config'; +import { getApiUrl, getSecretKey, addMCP, addMCPSystem } from '../config'; export const addAgent = async (provider: ProviderOption) => { const response = await fetch(getApiUrl("/agent"), { @@ -45,6 +45,12 @@ export const initializeSystem = async (provider: ProviderOption) => { try { 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; From 111ef592c8afdad000bd19bbbe23d3177b468e0a Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 13:47:55 -0800 Subject: [PATCH 15/32] resolve conflicts --- crates/goose-server/src/routes/agent.rs | 48 ++++++++ crates/goose-server/src/routes/secrets.rs | 17 ++- ui/desktop/src/ChatWindow.tsx | 112 ++++++------------ .../src/components/chat_window/ChatLayout.tsx | 10 ++ .../src/components/chat_window/ChatRoutes.tsx | 35 ++++++ ui/desktop/src/components/settings/Keys.tsx | 16 ++- .../settings/providers/ProvidersList.tsx | 20 ++++ .../components/settings/providers/utils.ts | 0 .../ProviderSetupModal.tsx | 6 +- .../welcome_screen/WelcomeModal.tsx | 65 ++++++++++ ui/desktop/src/utils/providerUtils.ts | 36 +++++- 11 files changed, 274 insertions(+), 91 deletions(-) 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/providers/ProvidersList.tsx 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/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 2a1d23d17..e936651c6 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,29 @@ 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 +88,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/secrets.rs b/crates/goose-server/src/routes/secrets.rs index 8e4375dfd..ddbb676c1 100644 --- a/crates/goose-server/src/routes/secrets.rs +++ b/crates/goose-server/src/routes/secrets.rs @@ -56,8 +56,11 @@ pub struct SecretStatus { } #[derive(Debug, Serialize, Deserialize)] -pub struct ProviderStatus { +pub struct ProviderResponse { pub supported: bool, + pub name: Option, + pub description: Option, + pub models: Option>, pub secret_status: HashMap, } @@ -88,7 +91,7 @@ fn check_key_status(key: &str) -> (bool, Option) { async fn check_provider_secrets( Json(request): Json, -) -> Result>, StatusCode> { +) -> Result>, StatusCode> { let mut response = HashMap::new(); for provider_name in request.providers { @@ -106,13 +109,19 @@ async fn check_provider_secrets( ); } - response.insert(provider_name, ProviderStatus { + 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, ProviderStatus { + response.insert(provider_name, ProviderResponse { supported: false, + name: None, + description: None, + models: None, secret_status: HashMap::new(), }); } diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index f7d1620ad..9273dba93 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -8,20 +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 { Provider, - ProviderOption, } from "./utils/providerUtils"; -import Keys from "./components/settings/Keys"; -import { initializeSystem } from './utils/systemInitializer'; +import { ChatLayout } from "./components/chat_window/ChatLayout" +import { ChatRoutes } from "./components/chat_window/ChatRoutes" +import { WelcomeModal } from "./components/welcome_screen/WelcomeModal" +import { getStoredProvider } from './utils/providerUtils' declare global { interface Window { @@ -54,7 +53,7 @@ export interface Chat { type ScrollBehavior = "auto" | "smooth" | "instant"; -function ChatContent({ +export function ChatContent({ chats, setChats, selectedChatId, @@ -385,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 @@ -427,14 +426,14 @@ export default function ChatWindow() { return response; }; - const addAgent = async (provider: ProviderOption) => { + 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.id }), + body: JSON.stringify({ provider: provider }), }); if (!response.ok) { @@ -448,8 +447,9 @@ export default function ChatWindow() { await addMCP("goosed", ["mcp", system]); }; - const initializeSystem = async (provider: ProviderOption) => { + const initializeSystem = async (provider: String) => { try { + console.log("initializing with provider", provider) await addAgent(provider); await addSystemConfig("developer2"); // Handle deep link if present @@ -479,7 +479,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); @@ -491,8 +491,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); @@ -506,72 +505,27 @@ export default function ChatWindow() { }, []); return ( -
-
-
- - - } - /> - } /> - } /> - } /> - -
- - - - {showWelcomeModal && ( -
- {selectedProvider ? ( - { - setSelectedProvider(null); - }} + + + + {showWelcomeModal && ( + - ) : ( - -

- Select a Provider -

-
- {providers.map((provider) => ( - - ))} -
-
- )} -
- )} -
+ )} + ); } 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 index 06921e04e..7f5e0d727 100644 --- a/ui/desktop/src/components/settings/Keys.tsx +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -34,9 +34,17 @@ interface ProviderSecretStatus { location: string | null; } +interface SecretStatus { + is_set: boolean; + location?: string; +} + interface ProviderResponse { - supported: boolean; - secret_status?: Record; + supported: boolean; + name?: string; + description?: string; + models?: string[]; + secret_status: Record; } interface ProviderStatusResponse { @@ -82,9 +90,9 @@ export default function Keys() { const transformedProviders: Provider[] = Object.entries(data) .map(([id, status]: [string, any]) => ({ id: id.toLowerCase(), - name: id, + name: status.name ? status.name : id, keys: status.secret_status ? Object.keys(status.secret_status) : [], - description: `${id} API access`, + description: status.description ? status.description : "Unsupported provider", supported: status.supported, canDelete: id.toLowerCase() !== 'openai' && id.toLowerCase() !== 'anthropic', order: PROVIDER_ORDER.indexOf(id.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/utils.ts b/ui/desktop/src/components/settings/providers/utils.ts new file mode 100644 index 000000000..e69de29bb 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/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index fb2e4914e..f5180ed11 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -1,10 +1,12 @@ +import { getApiUrl } 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"; @@ -32,4 +34,36 @@ export function getStoredProvider(config: any): string | null { 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}`); + } + + 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 + })); +} + + From 7774f59e085759c9083535c8257d260daf1d45b1 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Thu, 16 Jan 2025 19:37:10 -0800 Subject: [PATCH 16/32] fetch all supported providers to show in settings --- ui/desktop/src/components/settings/Keys.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/components/settings/Keys.tsx b/ui/desktop/src/components/settings/Keys.tsx index 7f5e0d727..ecf410ecf 100644 --- a/ui/desktop/src/components/settings/Keys.tsx +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -10,6 +10,7 @@ import { ModalTitle } from '../ui/modal'; import { initializeSystem } from '../../utils/systemInitializer'; +import { getProvidersList } from '../../utils/providerUtils' const PROVIDER_ORDER = ['openai', 'anthropic', 'databricks']; @@ -68,6 +69,13 @@ export default function Keys() { useEffect(() => { const fetchSecrets = async () => { try { + // Fetch providers dynamically from getProvidersList + const providerList = await getProvidersList(); + // Extract the list of IDs + const providerIds = providerList.map((provider) => provider.id); + console.log("Provider IDs:", providerIds); + + // Fetch secrets state (set/unset) using the provider IDs const response = await fetch(getApiUrl("/secrets/providers"), { method: 'POST', headers: { @@ -75,7 +83,7 @@ export default function Keys() { 'X-Secret-Key': getSecretKey(), }, body: JSON.stringify({ - providers: ["openai", "anthropic", "myprovider", "databricks"] + providers: providerIds }) }); From 0cafc5c0643ffbbd5eacbcef54648e8313b95b30 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 13:48:57 -0800 Subject: [PATCH 17/32] resolve conflicts --- ui/desktop/src/ChatWindow.tsx | 43 +--- ui/desktop/src/components/settings/Keys.tsx | 233 ++++++------------ .../components/settings/providers/Header.tsx | 20 ++ .../settings/providers/ProviderCard.tsx | 122 +++++++++ .../components/settings/providers/types.ts | 38 +++ .../components/settings/providers/utils.ts | 68 +++++ ui/desktop/src/utils/providerUtils.ts | 56 +++-- ui/desktop/src/utils/systemInitializer.ts | 58 ----- 8 files changed, 368 insertions(+), 270 deletions(-) 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/types.ts delete mode 100644 ui/desktop/src/utils/systemInitializer.ts diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index 9273dba93..1bdef5f57 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -20,7 +20,7 @@ import { import { ChatLayout } from "./components/chat_window/ChatLayout" import { ChatRoutes } from "./components/chat_window/ChatRoutes" import { WelcomeModal } from "./components/welcome_screen/WelcomeModal" -import { getStoredProvider } from './utils/providerUtils' +import { getStoredProvider, initializeSystem } from './utils/providerUtils' declare global { interface Window { @@ -425,44 +425,7 @@ 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 { - 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; - } - }; - + const handleModalSubmit = async (apiKey: string) => { try { const trimmedKey = apiKey.trim(); @@ -528,4 +491,4 @@ export default function ChatWindow() { )} ); -} +}; \ No newline at end of file diff --git a/ui/desktop/src/components/settings/Keys.tsx b/ui/desktop/src/components/settings/Keys.tsx index ecf410ecf..a10f132e7 100644 --- a/ui/desktop/src/components/settings/Keys.tsx +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -9,129 +9,48 @@ import { ModalHeader, ModalTitle } from '../ui/modal'; -import { initializeSystem } from '../../utils/systemInitializer'; -import { getProvidersList } from '../../utils/providerUtils' - -const PROVIDER_ORDER = ['openai', 'anthropic', 'databricks']; - -interface SecretSource { - key: string; - source: string; - is_set: boolean; -} - -interface Provider { - id: string; - name: string; - keys: string[]; - description: string; - canDelete?: boolean; - supported: boolean; - order: number; -} - -interface ProviderSecretStatus { - is_set: boolean; - location: string | null; -} - -interface SecretStatus { - is_set: boolean; - location?: string; -} - -interface ProviderResponse { - supported: boolean; - name?: string; - description?: string; - models?: string[]; - secret_status: Record; -} - -interface ProviderStatusResponse { - [provider: string]: { - set: boolean; - location: string | null; - supported: boolean; - }; -} +import { initializeSystem } from '../../utils/providerUtils'; +import {getSecretsSettings, transformProviderSecretsResponse, transformSecrets} from './providers/utils' +import { SecretDetails, Provider, ProviderResponse } from './providers/types' +import { getStoredProvider } from "../../utils/providerUtils" +import { ProviderSetupModal } from "../welcome_screen/ProviderSetupModal" export default function Keys() { const navigate = useNavigate(); - const [secrets, setSecrets] = useState([]); + const [secrets, setSecrets] = useState([]); const [expandedProviders, setExpandedProviders] = useState>(new Set()); - const [providers, setProviders] = useState([]); + const [providers, setProviders] = useState([]); const [showTestModal, setShowTestModal] = useState(false); - const [testResponse, setTestResponse] = useState(null); + const [currentKey, setCurrentKey] = useState(null); // Tracks key being edited/added + const [selectedProvider, setSelectedProvider] = useState(null); + const [showSetProviderKeyModal, setShowSetProviderKeyModal] = useState(false) const [keyToDelete, setKeyToDelete] = useState<{providerId: string, key: string} | null>(null); const [isChangingProvider, setIsChangingProvider] = useState(false); + useEffect(() => { + console.log("Modal visibility:", showSetProviderKeyModal); + }, [showSetProviderKeyModal]); + useEffect(() => { const fetchSecrets = async () => { try { - // Fetch providers dynamically from getProvidersList - const providerList = await getProvidersList(); - // Extract the list of IDs - const providerIds = providerList.map((provider) => provider.id); - console.log("Provider IDs:", providerIds); - - // 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(data); - - // Transform the backend response into Provider objects - 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: PROVIDER_ORDER.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; - }); - + // Fetch secrets state (set/unset) + let data = await getSecretsSettings() + let transformedProviders: Provider[] = transformProviderSecretsResponse(data) setProviders(transformedProviders); - // Transform secrets data - const transformedSecrets = Object.entries(data) - .filter(([_, status]: [string, any]) => status.supported && status.secret_status) - .flatMap(([_, status]) => - Object.entries(status.secret_status!).map(([key, secretStatus]: [string, any]) => ({ - key, - source: secretStatus.location || 'none', - is_set: secretStatus.is_set - })) - ); + // 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 - const gooseProvider = localStorage.getItem("GOOSE_PROVIDER")?.toLowerCase() || null; + const config = window.electron.getConfig(); + const gooseProvider = getStoredProvider(config); if (gooseProvider) { const matchedProvider = transformedProviders.find(provider => provider.id.toLowerCase() === gooseProvider @@ -157,29 +76,35 @@ export default function Keys() { return providerSecrets.some(s => !s?.is_set); }; - const handleAddOrEdit = async (key: string) => { - // Find the secret to check its source and status - const secret = secrets.find(s => s.key === key); - - if (secret?.source === 'env') { + 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; - const newValue = prompt(isAdding ? "Enter API key:" : "Enter new API key:"); - if (!newValue) return; // User cancelled try { if (!isAdding) { - // Delete old key first if editing + // 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 }) + body: JSON.stringify({ key: currentKey }), }); if (!deleteResponse.ok) { @@ -187,17 +112,17 @@ export default function Keys() { } } - // Store new 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, - value: newValue.trim() - }) + body: JSON.stringify({ + key: currentKey, + value: apiKey.trim(), + }), }); if (!storeResponse.ok) { @@ -205,24 +130,33 @@ export default function Keys() { } // Update local state - setSecrets(secrets.map(s => - s.key === key - ? { ...s, source: 'keyring', is_set: true } - : s - )); + 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?.source === 'env') { + if (secret?.location === 'env') { showToast("This key is set in your environment. Please remove it from your ~/.zshrc or equivalent file.", "error"); return; } @@ -249,7 +183,11 @@ export default function Keys() { } // Update local state to reflect deletion - setSecrets(secrets.filter(s => s.key !== keyToDelete.key)); + 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); @@ -299,7 +237,6 @@ export default function Keys() { } const data = await response.json() as Record; - setTestResponse(data); setShowTestModal(true); } catch (error) { console.error('Error testing providers:', error); @@ -314,11 +251,8 @@ export default function Keys() { const provider = providers.find(p => p.id === providerId); if (provider) { localStorage.setItem("GOOSE_PROVIDER", provider.name); - initializeSystem(provider) + initializeSystem(provider.id) showToast(`Switched to ${provider.name}`, "success"); - - // Spawn new chat window with the new provider - window.electron.createChatWindow(); } } catch (error) { console.error("Failed to change provider:", error); @@ -392,15 +326,6 @@ export default function Keys() { )} - {provider.canDelete && ( - - )}
{isSupported && isExpanded && ( @@ -412,7 +337,7 @@ export default function Keys() {

{key}

- Source: {secret?.source || 'none'} + Source: {secret?.location || 'none'}

@@ -424,7 +349,7 @@ export default function Keys() { {secret?.is_set ? 'Key set' : 'Missing'}
- - - - Provider Status Test - -
-
-              {testResponse && JSON.stringify(testResponse, null, 2)}
-            
-
-
-
+ {showSetProviderKeyModal && currentKey && selectedProvider && ( + handleSubmit(apiKey)} // Call handleSubmit when submitting + onCancel={() => setShowSetProviderKeyModal(false)} // Close modal on cancel + /> + )} + setKeyToDelete(null)}> 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..138d90b9d --- /dev/null +++ b/ui/desktop/src/components/settings/providers/ProviderCard.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useState } from 'react'; +import { getApiUrl, getSecretKey } from "../../../config"; +import { FaKey, FaExclamationCircle, FaPencilAlt, FaTrash, FaArrowLeft, FaPlus } from 'react-icons/fa'; + +import { + Modal, + ModalContent, + ModalHeader, + ModalTitle +} from '../../ui/modal'; +import { initializeSystem } from '../../../utils/providerUtils'; +import {getSecretsSettings, transformProviderSecretsResponse, transformSecrets} from './utils' +import { SecretDetails, Provider, ProviderResponse } from './types' + +function ProviderCard({ + provider, + secrets, + expandedProviders, + toggleProvider, + handleAddOrEditKey, + handleDeleteKey, + handleSelectProvider, + isChangingProvider, + getProviderStatus, + isProviderSupported, + }: { + provider: Provider; + secrets: SecretDetails[]; + expandedProviders: Set; + toggleProvider: (id: string) => void; + handleAddOrEditKey: (key: string, providerName: string) => void; + handleDeleteKey: (providerId: string, key: string) => void; + handleSelectProvider: (providerId: string) => void; + isChangingProvider: boolean; + getProviderStatus: (provider: Provider) => boolean; + isProviderSupported: (providerId: string) => boolean; +}) { + const hasUnsetKeys = getProviderStatus(provider); + const isExpanded = expandedProviders.has(provider.id); + const isSupported = isProviderSupported(provider.id); + + return ( +
+ {/* Provider Header */} +
+ +
+ + {/* Provider Keys */} + {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/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 index e69de29bb..f6af58f6a 100644 --- a/ui/desktop/src/components/settings/providers/utils.ts +++ 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/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index f5180ed11..801c3863d 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -1,4 +1,4 @@ -import { getApiUrl } from "../config"; +import {addMCP, addMCPSystem, getApiUrl, getSecretKey } from "../config"; export const SELECTED_PROVIDER_KEY = "GOOSE_PROVIDER__API_KEY" @@ -14,22 +14,6 @@ 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 { return config.GOOSE_PROVIDER || localStorage.getItem("GOOSE_PROVIDER"); } @@ -65,5 +49,43 @@ export async function getProvidersList(): Promise { })); } +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; + } +}; + diff --git a/ui/desktop/src/utils/systemInitializer.ts b/ui/desktop/src/utils/systemInitializer.ts deleted file mode 100644 index 4eeedd94c..000000000 --- a/ui/desktop/src/utils/systemInitializer.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ProviderOption } from './providerUtils'; -import { getApiUrl, getSecretKey, addMCP, addMCPSystem } from '../config'; - -export const addAgent = async (provider: ProviderOption) => { - const response = await fetch(getApiUrl("/agent"), { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Secret-Key": getSecretKey(), - }, - body: JSON.stringify({ provider: provider.id }), - }); - - if (!response.ok) { - throw new Error(`Failed to add agent: ${response.statusText}`); - } - - return response; -}; - -export const addSystemConfig = async (system: string) => { - const systemConfig = { - type: "Stdio", - cmd: await window.electron.getBinaryPath("goosed"), - args: ["mcp", system], - }; - - const response = await fetch(getApiUrl("/systems/add"), { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Secret-Key": getSecretKey(), - }, - body: JSON.stringify(systemConfig), - }); - - if (!response.ok) { - throw new Error(`Failed to add system config for ${system}: ${response.statusText}`); - } - - return response; -}; - -export const initializeSystem = async (provider: ProviderOption) => { - try { - 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; - } -}; \ No newline at end of file From a0f36d1d027cbc77262cb7c4df2c9f3117816da7 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 13:38:36 -0800 Subject: [PATCH 18/32] modularize the provider keys code --- ui/desktop/src/components/settings/Keys.tsx | 333 +++++------------- .../settings/modals/ConfirmDeletionModal.tsx | 33 ++ .../settings/providers/ProviderCard.tsx | 92 +++-- 3 files changed, 165 insertions(+), 293 deletions(-) create mode 100644 ui/desktop/src/components/settings/modals/ConfirmDeletionModal.tsx diff --git a/ui/desktop/src/components/settings/Keys.tsx b/ui/desktop/src/components/settings/Keys.tsx index a10f132e7..32ef19159 100644 --- a/ui/desktop/src/components/settings/Keys.tsx +++ b/ui/desktop/src/components/settings/Keys.tsx @@ -1,35 +1,33 @@ import React, { useEffect, useState } from 'react'; import { getApiUrl, getSecretKey } from "../../config"; -import { FaKey, FaExclamationCircle, FaPencilAlt, FaTrash, FaArrowLeft, FaPlus } from 'react-icons/fa'; +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 } from '../../utils/providerUtils'; -import {getSecretsSettings, transformProviderSecretsResponse, transformSecrets} from './providers/utils' -import { SecretDetails, Provider, ProviderResponse } from './providers/types' -import { getStoredProvider } from "../../utils/providerUtils" -import { ProviderSetupModal } from "../welcome_screen/ProviderSetupModal" - +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 [showTestModal, setShowTestModal] = useState(false); - const [currentKey, setCurrentKey] = useState(null); // Tracks key being edited/added - const [selectedProvider, setSelectedProvider] = useState(null); - const [showSetProviderKeyModal, setShowSetProviderKeyModal] = useState(false) - const [keyToDelete, setKeyToDelete] = useState<{providerId: string, key: string} | null>(null); + 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(() => { - console.log("Modal visibility:", showSetProviderKeyModal); - }, [showSetProviderKeyModal]); useEffect(() => { const fetchSecrets = async () => { @@ -45,15 +43,16 @@ export default function Keys() { // ] 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 + const matchedProvider = transformedProviders.find(provider => + provider.id.toLowerCase() === gooseProvider ); if (matchedProvider) { setExpandedProviders(new Set([matchedProvider.id])); @@ -69,10 +68,20 @@ export default function Keys() { 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) - ); + const providerSecrets = provider.keys.map(key => secrets.find(s => s.key === key)); return providerSecrets.some(s => !s?.is_set); }; @@ -155,7 +164,7 @@ export default function Keys() { 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; @@ -197,239 +206,77 @@ export default function Keys() { } }; - const handleDeleteProvider = (providerId: string) => { - setProviders(providers.filter(p => p.id !== providerId)); - showToast(`Provider ${providerId} removed`, "success"); - }; - - const toggleProvider = (providerId: string) => { - setExpandedProviders(prev => { - const newSet = new Set(prev); - if (newSet.has(providerId)) { - newSet.delete(providerId); - } else { - newSet.add(providerId); - } - return newSet; - }); - }; - const isProviderSupported = (providerId: string) => { const provider = providers.find(p => p.id === providerId); return provider?.supported ?? false; }; - const handleTestProviders = async () => { - try { - const response = await fetch(getApiUrl("/secrets/providers"), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - providers: ["OpenAI", "Anthropic", "MyProvider"] - }) - }); - - if (!response.ok) { - throw new Error('Failed to fetch provider status'); - } - - const data = await response.json() as Record; - setShowTestModal(true); - } catch (error) { - console.error('Error testing providers:', error); - showToast("Failed to test providers", "error"); - } - }; - - const handleSelectProvider = async (providerId: string) => { + const handleSelectProvider = async (providerId) => { setIsChangingProvider(true); try { - // Update localStorage + // 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) + initializeSystem(provider.id); showToast(`Switched to ${provider.name}`, "success"); } } catch (error) { - console.error("Failed to change provider:", error); - showToast(error instanceof Error ? error.message : "Failed to change provider", "error"); + showToast("Failed to change provider", "error"); } finally { setIsChangingProvider(false); } }; return ( -
-
- -

Providers

-
- - +

Providers

-
-
- {providers.map((provider) => { - const hasUnsetKeys = getProviderStatus(provider); - const isExpanded = expandedProviders.has(provider.id); - const isSupported = isProviderSupported(provider.id); - - 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() && ( - - )} -
- )} -
- ); - })} -
+
+ {providers.map((provider) => ( + + ))} +
- {showSetProviderKeyModal && currentKey && selectedProvider && ( - handleSubmit(apiKey)} // Call handleSubmit when submitting - onCancel={() => setShowSetProviderKeyModal(false)} // Close modal on cancel - /> - )} - - - setKeyToDelete(null)}> - - - Confirm Deletion - -
-

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

-
- - -
-
-
-
-
+ {showSetProviderKeyModal && currentKey && selectedProvider && ( + handleSubmit(apiKey)} // Call handleSubmit when submitting + onCancel={() => setShowSetProviderKeyModal(false)} // Close modal on cancel + /> + )} + + {keyToDelete && ( + setKeyToDelete(null)} + onConfirm={confirmDelete} + /> + )} +
); -} \ 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/ProviderCard.tsx b/ui/desktop/src/components/settings/providers/ProviderCard.tsx index 138d90b9d..503425924 100644 --- a/ui/desktop/src/components/settings/providers/ProviderCard.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderCard.tsx @@ -1,47 +1,22 @@ -import React, { useEffect, useState } from 'react'; -import { getApiUrl, getSecretKey } from "../../../config"; -import { FaKey, FaExclamationCircle, FaPencilAlt, FaTrash, FaArrowLeft, FaPlus } from 'react-icons/fa'; +import { FaKey, FaExclamationCircle, FaPencilAlt, FaTrash, FaPlus } from 'react-icons/fa'; +import React from "react"; -import { - Modal, - ModalContent, - ModalHeader, - ModalTitle -} from '../../ui/modal'; -import { initializeSystem } from '../../../utils/providerUtils'; -import {getSecretsSettings, transformProviderSecretsResponse, transformSecrets} from './utils' -import { SecretDetails, Provider, ProviderResponse } from './types' - -function ProviderCard({ +export const ProviderCard = ({ provider, secrets, - expandedProviders, + isExpanded, + isSupported, + isChangingProvider, toggleProvider, handleAddOrEditKey, handleDeleteKey, handleSelectProvider, - isChangingProvider, getProviderStatus, - isProviderSupported, - }: { - provider: Provider; - secrets: SecretDetails[]; - expandedProviders: Set; - toggleProvider: (id: string) => void; - handleAddOrEditKey: (key: string, providerName: string) => void; - handleDeleteKey: (providerId: string, key: string) => void; - handleSelectProvider: (providerId: string) => void; - isChangingProvider: boolean; - getProviderStatus: (provider: Provider) => boolean; - isProviderSupported: (providerId: string) => boolean; -}) { + }) => { const hasUnsetKeys = getProviderStatus(provider); - const isExpanded = expandedProviders.has(provider.id); - const isSupported = isProviderSupported(provider.id); return (
- {/* Provider Header */}
-

{provider.name}

- {!isSupported && ( - - Not Supported - - )} +
+

{provider.name}

+ {provider.id.toLowerCase() === localStorage.getItem("GOOSE_PROVIDER")?.toLowerCase() && ( + + Selected Provider + + )} + {!isSupported && ( + + Not Supported + + )} +
+

+ {isSupported ? provider.description : 'Provider not supported'} +

- {isSupported && hasUnsetKeys && } + {isSupported && hasUnsetKeys && ( + + )} - {/* Provider Keys */} {isSupported && isExpanded && (
- {provider.keys.map((key) => { - const secret = secrets.find((s) => s.key === key); + {provider.keys.map(key => { + const secret = secrets.find(s => s.key === key); return (
@@ -76,18 +62,17 @@ function ProviderCard({

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

- + {secret?.is_set ? 'Key set' : 'Missing'} @@ -98,7 +83,13 @@ function ProviderCard({ ? 'text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 hover:bg-red-100 dark:hover:bg-red-900' : 'text-gray-300 dark:text-gray-600 cursor-not-allowed' }`} + title={ + secret?.is_set + ? "Delete key from keychain" + : "No key to delete - Add a key first before deleting" + } disabled={!secret?.is_set} + aria-disabled={!secret?.is_set} > @@ -106,6 +97,7 @@ function ProviderCard({
); })} + {provider.id.toLowerCase() !== localStorage.getItem("GOOSE_PROVIDER")?.toLowerCase() && (
); -} +}; From 9ff3f9cd711b7972d722f4d527ecc30caded0932 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 13:50:25 -0800 Subject: [PATCH 19/32] test --- ui/desktop/src/ChatWindow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index 1bdef5f57..609c634ad 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -425,7 +425,7 @@ export default function ChatWindow() { return response; }; - + const handleModalSubmit = async (apiKey: string) => { try { const trimmedKey = apiKey.trim(); From 929fee8293477818bf30c0a187b749aa182866c7 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 13:51:25 -0800 Subject: [PATCH 20/32] remove unused import --- ui/desktop/src/components/settings/Settings.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/desktop/src/components/settings/Settings.tsx b/ui/desktop/src/components/settings/Settings.tsx index c3ca6d255..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"; @@ -347,4 +346,4 @@ export default function Settings() { />
); -} +} \ No newline at end of file From 5b4afb0b3425d4f33755813d7ec42c964156a6a2 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 13:54:59 -0800 Subject: [PATCH 21/32] hide provider settings --- ui/desktop/src/components/MoreMenu.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/ui/desktop/src/components/MoreMenu.tsx b/ui/desktop/src/components/MoreMenu.tsx index a05d5085c..9097be313 100644 --- a/ui/desktop/src/components/MoreMenu.tsx +++ b/ui/desktop/src/components/MoreMenu.tsx @@ -246,15 +246,18 @@ export default function MoreMenu() { > Reset Provider - + {/* Provider keys settings */} + {process.env.NODE_ENV === "development" && ( + + )} From 585094d323e73a2397eec3126334de4be0b8ca88 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 14:03:39 -0800 Subject: [PATCH 22/32] lint --- crates/goose-server/src/routes/agent.rs | 4 +- crates/goose-server/src/routes/secrets.rs | 51 +++++++++++++---------- ui/desktop/src/ChatWindow.tsx | 3 +- ui/desktop/src/utils/providerUtils.ts | 2 + 4 files changed, 33 insertions(+), 27 deletions(-) diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index e936651c6..ae88739bb 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -40,13 +40,13 @@ struct ProviderDetails { name: String, description: String, models: Vec, - required_keys: Vec + required_keys: Vec, } #[derive(Serialize)] struct ProviderList { id: String, - details: ProviderDetails + details: ProviderDetails, } async fn get_versions() -> Json { diff --git a/crates/goose-server/src/routes/secrets.rs b/crates/goose-server/src/routes/secrets.rs index ddbb676c1..742e8cf52 100644 --- a/crates/goose-server/src/routes/secrets.rs +++ b/crates/goose-server/src/routes/secrets.rs @@ -1,15 +1,12 @@ -use axum::{ - extract::State, - routing::post, - routing::delete, - Json, Router, +use crate::state::AppState; +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; // TODO: investigate if we need use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use crate::state::AppState; -use http::{HeaderMap, StatusCode}; -use goose::key_manager::{save_to_keyring, get_keyring_secret, delete_from_keyring, KeyRetrievalStrategy}; #[derive(Serialize)] struct SecretResponse { @@ -109,21 +106,27 @@ async fn check_provider_secrets( ); } - 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, - }); + 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(), - }); + response.insert( + provider_name, + ProviderResponse { + supported: false, + name: None, + description: None, + models: None, + secret_status: HashMap::new(), + }, + ); } } @@ -183,7 +186,9 @@ mod tests { assert!(result.is_ok()); let Json(response) = result.unwrap(); - let provider_status = response.get("unsupported_provider").expect("Provider should exist"); + 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/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index 609c634ad..1e50dc215 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -399,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); diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index 801c3863d..d83c6588a 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -15,6 +15,8 @@ export const OPENAI_DEFAULT_MODEL = "gpt-4" export const ANTHROPIC_DEFAULT_MODEL = "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"); } From e886cb6352286425948529295e514bcf425f89f9 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 14:07:32 -0800 Subject: [PATCH 23/32] lint desktop --- ui/desktop/src/ChatWindow.tsx | 2 +- ui/desktop/src/components/settings/SecretsList.tsx | 2 +- ui/desktop/src/components/settings/providers/utils.ts | 2 +- ui/desktop/src/utils/providerUtils.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index 1e50dc215..5a7650f55 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -490,4 +490,4 @@ export default function ChatWindow() { )} ); -}; \ No newline at end of file +} \ No newline at end of file diff --git a/ui/desktop/src/components/settings/SecretsList.tsx b/ui/desktop/src/components/settings/SecretsList.tsx index fe3fe4fc1..8ea511350 100644 --- a/ui/desktop/src/components/settings/SecretsList.tsx +++ b/ui/desktop/src/components/settings/SecretsList.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, SVGProps } from 'react'; import { getApiUrl, getSecretKey } from '../../config'; interface SecretSource { diff --git a/ui/desktop/src/components/settings/providers/utils.ts b/ui/desktop/src/components/settings/providers/utils.ts index f6af58f6a..3261ca60e 100644 --- a/ui/desktop/src/components/settings/providers/utils.ts +++ b/ui/desktop/src/components/settings/providers/utils.ts @@ -65,4 +65,4 @@ export function transformSecrets(data: Record): Transf is_set: rawStatus.is_set, // Renamed from `is_set` to `isSet` })) ); -}; +} diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index d83c6588a..80cf39fc1 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -51,7 +51,7 @@ export async function getProvidersList(): Promise { })); } -const addAgent = async (provider: String) => { +const addAgent = async (provider: string) => { const response = await fetch(getApiUrl("/agent"), { method: "POST", headers: { @@ -72,7 +72,7 @@ const addSystemConfig = async (system: string) => { await addMCP("goosed", ["mcp", system]); }; -export const initializeSystem = async (provider: String) => { +export const initializeSystem = async (provider: string) => { try { console.log("initializing with provider", provider) await addAgent(provider); From dd7fd36cdd0378a65c0ae917d2175b39fb854bd9 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 14:10:01 -0800 Subject: [PATCH 24/32] lint rust --- crates/goose-server/src/routes/agent.rs | 1 - crates/goose-server/src/routes/secrets.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index ae88739bb..5d7827913 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -34,7 +34,6 @@ struct ProviderFile { required_keys: Vec, } - #[derive(Serialize)] struct ProviderDetails { name: String, diff --git a/crates/goose-server/src/routes/secrets.rs b/crates/goose-server/src/routes/secrets.rs index 742e8cf52..cb3f20bca 100644 --- a/crates/goose-server/src/routes/secrets.rs +++ b/crates/goose-server/src/routes/secrets.rs @@ -4,7 +4,7 @@ use goose::key_manager::{ delete_from_keyring, get_keyring_secret, save_to_keyring, KeyRetrievalStrategy, }; use http::{HeaderMap, StatusCode}; -use once_cell::sync::Lazy; // TODO: investigate if we need +use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use std::collections::HashMap; From 60f6588f3c17164096ace6991108fbd515993070 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 14:11:59 -0800 Subject: [PATCH 25/32] lint desktop --- ui/desktop/src/components/settings/SecretsList.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ui/desktop/src/components/settings/SecretsList.tsx b/ui/desktop/src/components/settings/SecretsList.tsx index 8ea511350..05145df88 100644 --- a/ui/desktop/src/components/settings/SecretsList.tsx +++ b/ui/desktop/src/components/settings/SecretsList.tsx @@ -11,6 +11,10 @@ interface SecretsListResponse { secrets: SecretSource[]; } +type SVGComponentProps = React.SVGProps & { + className?: string; +}; + export const SecretsList = () => { const [secrets, setSecrets] = useState([]); const [loading, setLoading] = useState(true); @@ -114,21 +118,20 @@ export const SecretsList = () => { ); }; -// Simple icon components -const EyeIcon = (props: React.SVGProps) => ( +const EyeIcon: React.FC = (props) => ( ); -const ClipboardIcon = (props: React.SVGProps) => ( +const ClipboardIcon: React.FC = (props) => ( ); -const PencilIcon = (props: React.SVGProps) => ( +const PencilIcon: React.FC = (props) => ( From 7ef8530bc413500395094b2a096b97290bd6e802 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 14:14:14 -0800 Subject: [PATCH 26/32] lint --- crates/goose-server/src/routes/secrets.rs | 4 ---- ui/desktop/src/components/settings/SecretsList.tsx | 12 +++++++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/goose-server/src/routes/secrets.rs b/crates/goose-server/src/routes/secrets.rs index cb3f20bca..2f3cadf1d 100644 --- a/crates/goose-server/src/routes/secrets.rs +++ b/crates/goose-server/src/routes/secrets.rs @@ -40,7 +40,6 @@ async fn store_secret( } } - #[derive(Debug, Serialize, Deserialize)] pub struct ProviderSecretRequest { pub providers: Vec, @@ -74,8 +73,6 @@ static PROVIDER_ENV_REQUIREMENTS: Lazy> = Lazy:: serde_json::from_str(contents).expect("Failed to parse providers_and_keys.json") }); - -// Helper function to check if a key is set somewhere fn check_key_status(key: &str) -> (bool, Option) { if let Ok(_value) = std::env::var(key) { (true, Some("env".to_string())) @@ -192,5 +189,4 @@ mod tests { assert!(!provider_status.supported); assert!(provider_status.secret_status.is_empty()); } - } \ No newline at end of file diff --git a/ui/desktop/src/components/settings/SecretsList.tsx b/ui/desktop/src/components/settings/SecretsList.tsx index 05145df88..0743822a0 100644 --- a/ui/desktop/src/components/settings/SecretsList.tsx +++ b/ui/desktop/src/components/settings/SecretsList.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, SVGProps } from 'react'; +import React, { useEffect, useState } from 'react'; import { getApiUrl, getSecretKey } from '../../config'; interface SecretSource { @@ -11,9 +11,15 @@ interface SecretsListResponse { secrets: SecretSource[]; } -type SVGComponentProps = React.SVGProps & { +type SVGComponentProps = { className?: string; -}; + width?: number | string; + height?: number | string; + fill?: string; + stroke?: string; + strokeWidth?: number | string; + // Add any other specific SVG props you need +} & React.HTMLAttributes; export const SecretsList = () => { const [secrets, setSecrets] = useState([]); From b64852a5ac54b44d9bc17898ca3dba7adb5a8010 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 14:16:33 -0800 Subject: [PATCH 27/32] lint --- crates/goose-server/src/routes/secrets.rs | 2 +- ui/desktop/src/components/settings/SecretsList.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/goose-server/src/routes/secrets.rs b/crates/goose-server/src/routes/secrets.rs index 2f3cadf1d..3900db165 100644 --- a/crates/goose-server/src/routes/secrets.rs +++ b/crates/goose-server/src/routes/secrets.rs @@ -189,4 +189,4 @@ mod tests { assert!(!provider_status.supported); assert!(provider_status.secret_status.is_empty()); } -} \ No newline at end of file +} diff --git a/ui/desktop/src/components/settings/SecretsList.tsx b/ui/desktop/src/components/settings/SecretsList.tsx index 0743822a0..da8d08754 100644 --- a/ui/desktop/src/components/settings/SecretsList.tsx +++ b/ui/desktop/src/components/settings/SecretsList.tsx @@ -18,8 +18,10 @@ type SVGComponentProps = { fill?: string; stroke?: string; strokeWidth?: number | string; - // Add any other specific SVG props you need -} & React.HTMLAttributes; + viewBox?: string; + xmlns?: string; + // Add any other specific props you need +}; export const SecretsList = () => { const [secrets, setSecrets] = useState([]); From 107ee2e1ac488da63114326c42bc7cdb854069f4 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 14:29:00 -0800 Subject: [PATCH 28/32] try to fix tests --- crates/goose/src/providers/anthropic.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index 67b4ae5e9..62e6d3d12 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -367,7 +367,7 @@ mod tests { client: Client::builder().build().unwrap(), host: mock_server.uri(), api_key: "test_api_key".to_string(), - model: ModelConfig::new("claude-3-sonnet-20241022".to_string()) + model: ModelConfig::new("claude-3-5-sonnet-latest") .with_temperature(Some(0.7)) .with_context_limit(Some(200_000)), }; @@ -390,20 +390,18 @@ mod tests { "stop_sequence": null, "usage": { "input_tokens": 12, - "output_tokens": 15, - "cache_creation_input_tokens": 12, - "cache_read_input_tokens": 0 + "output_tokens": 15 } }); let (_, provider) = setup_mock_server(response_body).await; - let messages = vec![Message::user().with_text("Hello?")]; - + let (message, usage) = provider .complete_internal("You are a helpful assistant.", &messages, &[]) .await?; + assert!(matches!(message.content[0], MessageContent::Text(_))); if let MessageContent::Text(text) = &message.content[0] { assert_eq!(text.text, "Hello! How can I assist you today?"); } else { @@ -433,14 +431,12 @@ mod tests { "expression": "2 + 2" } }], - "model": "claude-3-sonnet-20240229", + "model": "claude-3-5-sonnet-latest", "stop_reason": "end_turn", "stop_sequence": null, "usage": { "input_tokens": 15, - "output_tokens": 20, - "cache_creation_input_tokens": 15, - "cache_read_input_tokens": 0, + "output_tokens": 20 } }); @@ -490,7 +486,7 @@ mod tests { "type": "text", "text": "Hello!" }], - "model": "claude-3-sonnet-20240229", + "model": "claude-3-5-sonnet-latest", "stop_reason": "end_turn", "stop_sequence": null, "usage": { From 46ccb4dc7318b74e248e05cffca67f65395d09df Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 14:31:29 -0800 Subject: [PATCH 29/32] fix tests --- crates/goose/src/providers/anthropic.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index 62e6d3d12..627feb4e2 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -367,7 +367,7 @@ mod tests { client: Client::builder().build().unwrap(), host: mock_server.uri(), api_key: "test_api_key".to_string(), - model: ModelConfig::new("claude-3-5-sonnet-latest") + model: ModelConfig::new("claude-3-5-sonnet-latest".to_string()) .with_temperature(Some(0.7)) .with_context_limit(Some(200_000)), }; From 40d6e6be53674046a6bfe9c1bc35b88e2e177602 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 14:33:51 -0800 Subject: [PATCH 30/32] fix whitespace --- crates/goose/src/providers/anthropic.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index 627feb4e2..bcabd992d 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -396,7 +396,6 @@ mod tests { let (_, provider) = setup_mock_server(response_body).await; let messages = vec![Message::user().with_text("Hello?")]; - let (message, usage) = provider .complete_internal("You are a helpful assistant.", &messages, &[]) .await?; From a1cc92add6cc7ffac79cedde6d2e497a5ebd0e2e Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 14:44:15 -0800 Subject: [PATCH 31/32] change anthropic to what is on v1.0 --- crates/goose/src/providers/anthropic.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index bcabd992d..cc38e42aa 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -367,7 +367,7 @@ mod tests { client: Client::builder().build().unwrap(), host: mock_server.uri(), api_key: "test_api_key".to_string(), - model: ModelConfig::new("claude-3-5-sonnet-latest".to_string()) + model: ModelConfig::new("claude-3-sonnet-20241022".to_string()) .with_temperature(Some(0.7)) .with_context_limit(Some(200_000)), }; @@ -390,17 +390,20 @@ mod tests { "stop_sequence": null, "usage": { "input_tokens": 12, - "output_tokens": 15 + "output_tokens": 15, + "cache_creation_input_tokens": 12, + "cache_read_input_tokens": 0 } }); let (_, provider) = setup_mock_server(response_body).await; + let messages = vec![Message::user().with_text("Hello?")]; + let (message, usage) = provider .complete_internal("You are a helpful assistant.", &messages, &[]) .await?; - assert!(matches!(message.content[0], MessageContent::Text(_))); if let MessageContent::Text(text) = &message.content[0] { assert_eq!(text.text, "Hello! How can I assist you today?"); } else { @@ -430,12 +433,14 @@ mod tests { "expression": "2 + 2" } }], - "model": "claude-3-5-sonnet-latest", + "model": "claude-3-sonnet-20240229", "stop_reason": "end_turn", "stop_sequence": null, "usage": { "input_tokens": 15, - "output_tokens": 20 + "output_tokens": 20, + "cache_creation_input_tokens": 15, + "cache_read_input_tokens": 0, } }); @@ -485,7 +490,7 @@ mod tests { "type": "text", "text": "Hello!" }], - "model": "claude-3-5-sonnet-latest", + "model": "claude-3-sonnet-20240229", "stop_reason": "end_turn", "stop_sequence": null, "usage": { @@ -514,4 +519,4 @@ mod tests { Ok(()) } -} +} \ No newline at end of file From 6e6127012bb9bcfa4fc78079842d4280887e1cd6 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Fri, 17 Jan 2025 14:47:35 -0800 Subject: [PATCH 32/32] remove whitespace --- crates/goose/src/providers/anthropic.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index cc38e42aa..67b4ae5e9 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -519,4 +519,4 @@ mod tests { Ok(()) } -} \ No newline at end of file +}