mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
feat: new onboarding flow (#7266)
This commit is contained in:
@@ -469,6 +469,7 @@ derive_utoipa!(Icon as IconSchema);
|
||||
super::routes::recipe::recipe_to_yaml,
|
||||
super::routes::setup::start_openrouter_setup,
|
||||
super::routes::setup::start_tetrate_setup,
|
||||
super::routes::setup::start_nanogpt_setup,
|
||||
super::routes::tunnel::start_tunnel,
|
||||
super::routes::tunnel::stop_tunnel,
|
||||
super::routes::tunnel::get_tunnel_status,
|
||||
@@ -510,6 +511,7 @@ derive_utoipa!(Icon as IconSchema);
|
||||
goose::providers::catalog::ProviderTemplate,
|
||||
goose::providers::catalog::ModelTemplate,
|
||||
goose::providers::catalog::ModelCapabilities,
|
||||
super::routes::config_management::CreateCustomProviderResponse,
|
||||
super::routes::config_management::CheckProviderRequest,
|
||||
super::routes::config_management::SetProviderRequest,
|
||||
super::routes::config_management::ModelInfoQuery,
|
||||
|
||||
@@ -633,19 +633,24 @@ pub async fn validate_config() -> Result<Json<String>, ErrorResponse> {
|
||||
|
||||
Ok(Json("Config file is valid".to_string()))
|
||||
}
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct CreateCustomProviderResponse {
|
||||
pub provider_name: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/config/custom-providers",
|
||||
request_body = UpdateCustomProviderRequest,
|
||||
responses(
|
||||
(status = 200, description = "Custom provider created successfully", body = String),
|
||||
(status = 200, description = "Custom provider created successfully", body = CreateCustomProviderResponse),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn create_custom_provider(
|
||||
Json(request): Json<UpdateCustomProviderRequest>,
|
||||
) -> Result<Json<String>, ErrorResponse> {
|
||||
) -> Result<Json<CreateCustomProviderResponse>, ErrorResponse> {
|
||||
let config = goose::config::declarative_providers::create_custom_provider(
|
||||
goose::config::declarative_providers::CreateCustomProviderParams {
|
||||
engine: request.engine,
|
||||
@@ -663,7 +668,9 @@ pub async fn create_custom_provider(
|
||||
|
||||
goose::providers::refresh_custom_providers().await?;
|
||||
|
||||
Ok(Json(format!("Custom provider added - ID: {}", config.id())))
|
||||
Ok(Json(CreateCustomProviderResponse {
|
||||
provider_name: config.id().to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::routes::errors::ErrorResponse;
|
||||
use crate::state::AppState;
|
||||
use axum::{routing::post, Json, Router};
|
||||
use goose::config::signup_nanogpt::{complete_nanogpt_auth, configure_nanogpt};
|
||||
use goose::config::signup_openrouter::OpenRouterAuth;
|
||||
use goose::config::signup_tetrate::{configure_tetrate, TetrateAuth};
|
||||
use goose::config::{configure_openrouter, Config};
|
||||
@@ -18,6 +19,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/handle_openrouter", post(start_openrouter_setup))
|
||||
.route("/handle_tetrate", post(start_tetrate_setup))
|
||||
.route("/handle_nanogpt", post(start_nanogpt_setup))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -50,7 +52,7 @@ async fn start_openrouter_setup() -> Result<Json<SetupResponse>, ErrorResponse>
|
||||
}
|
||||
Err(e) => Ok(Json(SetupResponse {
|
||||
success: false,
|
||||
message: format!("Setup failed: {}", e),
|
||||
message: e.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -84,7 +86,38 @@ async fn start_tetrate_setup() -> Result<Json<SetupResponse>, ErrorResponse> {
|
||||
}
|
||||
Err(e) => Ok(Json(SetupResponse {
|
||||
success: false,
|
||||
message: format!("Setup failed: {}", e),
|
||||
message: e.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/handle_nanogpt",
|
||||
responses(
|
||||
(status = 200, body=SetupResponse)
|
||||
),
|
||||
)]
|
||||
async fn start_nanogpt_setup() -> Result<Json<SetupResponse>, ErrorResponse> {
|
||||
match complete_nanogpt_auth().await {
|
||||
Ok(api_key) => {
|
||||
let config = Config::global();
|
||||
|
||||
if let Err(e) = configure_nanogpt(config, api_key) {
|
||||
return Ok(Json(SetupResponse {
|
||||
success: false,
|
||||
message: format!("Failed to configure NanoGPT: {}", e),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Json(SetupResponse {
|
||||
success: true,
|
||||
message: "NanoGPT setup completed successfully".to_string(),
|
||||
}))
|
||||
}
|
||||
Err(e) => Ok(Json(SetupResponse {
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ mod migrations;
|
||||
pub mod paths;
|
||||
pub mod permission;
|
||||
pub mod search_path;
|
||||
pub mod signup_nanogpt;
|
||||
pub mod signup_openrouter;
|
||||
pub mod signup_tetrate;
|
||||
|
||||
@@ -21,6 +22,7 @@ pub use extensions::{
|
||||
};
|
||||
pub use goose_mode::GooseMode;
|
||||
pub use permission::PermissionManager;
|
||||
pub use signup_nanogpt::configure_nanogpt;
|
||||
pub use signup_openrouter::configure_openrouter;
|
||||
pub use signup_tetrate::configure_tetrate;
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tokio::time::{sleep, timeout};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// Default model for NanoGPT configuration
|
||||
pub const NANOGPT_DEFAULT_MODEL: &str = "openai/gpt-4.1-nano";
|
||||
|
||||
const NANOGPT_START_URL: &str = "https://nano-gpt.com/api/cli-login/start";
|
||||
const NANOGPT_POLL_URL: &str = "https://nano-gpt.com/api/cli-login/poll";
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(180); // 3 minutes
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct StartRequest {
|
||||
client_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StartResponse {
|
||||
device_code: String,
|
||||
verification_uri_complete: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PollRequest {
|
||||
device_code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PollResponse {
|
||||
key: String,
|
||||
}
|
||||
|
||||
async fn poll_for_token(device_code: &str) -> Result<String> {
|
||||
let client = Client::new();
|
||||
|
||||
loop {
|
||||
sleep(POLL_INTERVAL).await;
|
||||
|
||||
let body = PollRequest {
|
||||
device_code: device_code.to_string(),
|
||||
};
|
||||
|
||||
let response = client.post(NANOGPT_POLL_URL).json(&body).send().await?;
|
||||
// https://docs.nano-gpt.com/integrations/cli-login#response-codes
|
||||
match response.status().as_u16() {
|
||||
200 => {
|
||||
let poll_resp: PollResponse = response.json().await?;
|
||||
return Ok(poll_resp.key);
|
||||
}
|
||||
202 => {
|
||||
continue;
|
||||
}
|
||||
410 => {
|
||||
return Err(anyhow!("Device code has expired - please try again"));
|
||||
}
|
||||
409 => {
|
||||
return Err(anyhow!("Device code has already been consumed"));
|
||||
}
|
||||
404 => {
|
||||
return Err(anyhow!("Invalid device code"));
|
||||
}
|
||||
429 => {
|
||||
return Err(anyhow!(
|
||||
"Too many requests to NanoGPT. Please wait a moment and try again."
|
||||
));
|
||||
}
|
||||
other => {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"Unexpected poll response: {} - {}",
|
||||
other,
|
||||
error_text
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn complete_nanogpt_auth() -> Result<String> {
|
||||
let client = Client::new();
|
||||
let body = StartRequest {
|
||||
client_name: "goose".to_string(),
|
||||
};
|
||||
|
||||
let response = client.post(NANOGPT_START_URL).json(&body).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"Failed to start NanoGPT device flow: {} - {}",
|
||||
status,
|
||||
error_text
|
||||
));
|
||||
}
|
||||
|
||||
let start_resp: StartResponse = response.json().await?;
|
||||
|
||||
println!("Opening browser for NanoGPT authentication...");
|
||||
|
||||
if let Err(e) = webbrowser::open(&start_resp.verification_uri_complete) {
|
||||
eprintln!("Failed to open browser automatically: {}", e);
|
||||
println!(
|
||||
"Please open this URL manually: {}",
|
||||
start_resp.verification_uri_complete
|
||||
);
|
||||
}
|
||||
|
||||
println!("Waiting for NanoGPT authorization...");
|
||||
|
||||
match timeout(AUTH_TIMEOUT, poll_for_token(&start_resp.device_code)).await {
|
||||
Ok(Ok(api_key)) => Ok(api_key),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => Err(anyhow!("Authentication timed out - please try again")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configure_nanogpt(config: &Config, api_key: String) -> Result<()> {
|
||||
config.set_secret("NANOGPT_API_KEY", &api_key)?;
|
||||
config.set_goose_provider("nano-gpt")?;
|
||||
config.set_goose_model(NANOGPT_DEFAULT_MODEL)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -581,15 +581,14 @@ pub async fn emit_event(
|
||||
event_name: &str,
|
||||
mut properties: HashMap<String, serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
if !is_telemetry_enabled() {
|
||||
// Only onboarding events are enabled for now. These bypass the telemetry
|
||||
// check so we can track the funnel before the user makes their choice.
|
||||
let is_onboarding_event =
|
||||
event_name.starts_with("onboarding_") || event_name == "telemetry_preference_set";
|
||||
if !is_onboarding_event {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Temporarily disabled - only session_started events are sent
|
||||
let _ = (event_name, &mut properties);
|
||||
return Ok(());
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
let installation = load_or_create_installation();
|
||||
|
||||
insert(&mut properties, "os", std::env::consts::OS);
|
||||
|
||||
@@ -168,6 +168,11 @@ impl ProviderDef for AnthropicProvider {
|
||||
),
|
||||
],
|
||||
)
|
||||
.with_setup_steps(vec![
|
||||
"Go to https://platform.claude.com/settings/keys",
|
||||
"Click 'Create Key'",
|
||||
"Copy the key and paste it above",
|
||||
])
|
||||
}
|
||||
|
||||
fn from_env(
|
||||
|
||||
@@ -176,6 +176,9 @@ pub struct ProviderMetadata {
|
||||
pub model_doc_link: String,
|
||||
/// Required configuration keys
|
||||
pub config_keys: Vec<ConfigKey>,
|
||||
/// step-by-step instructions for set up providers eg: api key
|
||||
#[serde(default)]
|
||||
pub setup_steps: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProviderMetadata {
|
||||
@@ -208,6 +211,7 @@ impl ProviderMetadata {
|
||||
.collect(),
|
||||
model_doc_link: model_doc_link.to_string(),
|
||||
config_keys,
|
||||
setup_steps: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +232,7 @@ impl ProviderMetadata {
|
||||
known_models: models,
|
||||
model_doc_link: model_doc_link.to_string(),
|
||||
config_keys,
|
||||
setup_steps: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,8 +245,14 @@ impl ProviderMetadata {
|
||||
known_models: vec![],
|
||||
model_doc_link: "".to_string(),
|
||||
config_keys: vec![],
|
||||
setup_steps: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_setup_steps(mut self, steps: Vec<&str>) -> Self {
|
||||
self.setup_steps = steps.into_iter().map(|s| s.to_string()).collect();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration key metadata for provider setup
|
||||
|
||||
@@ -121,6 +121,12 @@ impl ProviderDef for GoogleProvider {
|
||||
ConfigKey::new("GOOGLE_HOST", false, false, Some(GOOGLE_API_HOST), false),
|
||||
],
|
||||
)
|
||||
.with_setup_steps(vec![
|
||||
"Go to https://aistudio.google.com and sign in with your Google account",
|
||||
"Click 'Get API key' on the left sidebar",
|
||||
"Create a new API key or select an existing one",
|
||||
"Copy the key and paste it above",
|
||||
])
|
||||
}
|
||||
|
||||
fn from_env(
|
||||
|
||||
@@ -20,6 +20,7 @@ use super::{
|
||||
lead_worker::LeadWorkerProvider,
|
||||
litellm::LiteLLMProvider,
|
||||
local_inference::LocalInferenceProvider,
|
||||
nanogpt::NanoGptProvider,
|
||||
ollama::OllamaProvider,
|
||||
openai::OpenAiProvider,
|
||||
openrouter::OpenRouterProvider,
|
||||
@@ -65,6 +66,7 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
|
||||
registry.register::<GithubCopilotProvider>(false);
|
||||
registry.register::<GoogleProvider>(true);
|
||||
registry.register::<LiteLLMProvider>(false);
|
||||
registry.register::<NanoGptProvider>(true);
|
||||
registry.register::<OllamaProvider>(true);
|
||||
registry.register::<OpenAiProvider>(true);
|
||||
registry.register::<OpenRouterProvider>(true);
|
||||
|
||||
@@ -28,6 +28,7 @@ mod init;
|
||||
pub mod lead_worker;
|
||||
pub mod litellm;
|
||||
pub mod local_inference;
|
||||
pub mod nanogpt;
|
||||
pub mod oauth;
|
||||
pub mod ollama;
|
||||
pub mod openai;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
use super::api_client::{ApiClient, AuthMethod};
|
||||
use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata};
|
||||
use super::errors::ProviderError;
|
||||
use super::openai_compatible::{handle_status_openai_compat, stream_openai_compat};
|
||||
use super::retry::ProviderRetry;
|
||||
use super::utils::{ImageFormat, RequestLog};
|
||||
use crate::conversation::message::Message;
|
||||
use crate::model::ModelConfig;
|
||||
use crate::providers::formats::openai::create_request;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use futures::future::BoxFuture;
|
||||
use rmcp::model::Tool;
|
||||
|
||||
const NANOGPT_PROVIDER_NAME: &str = "nano-gpt";
|
||||
pub const NANOGPT_API_HOST: &str = "https://nano-gpt.com/api/v1";
|
||||
pub const NANOGPT_SUBSCRIPTION_HOST: &str = "https://nano-gpt.com/api/subscription/v1";
|
||||
pub const NANOGPT_DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4.6";
|
||||
pub const NANOGPT_DOC_URL: &str = "https://docs.nano-gpt.com/";
|
||||
const NANOGPT_API_KEY: &str = "NANOGPT_API_KEY";
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct NanoGptProvider {
|
||||
#[serde(skip)]
|
||||
api_client: ApiClient,
|
||||
model: ModelConfig,
|
||||
#[serde(skip)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl NanoGptProvider {
|
||||
async fn check_subscription(api_key: &str) -> bool {
|
||||
let client = match ApiClient::new(
|
||||
NANOGPT_SUBSCRIPTION_HOST.to_string(),
|
||||
AuthMethod::BearerToken(api_key.to_string()),
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
match client.response_get(None, "usage").await {
|
||||
Ok(resp) => resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|json| json.get("active")?.as_bool())
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn from_env(model: ModelConfig) -> Result<Self> {
|
||||
let config = crate::config::Config::global();
|
||||
let api_key: String = config.get_secret(NANOGPT_API_KEY)?;
|
||||
|
||||
let is_subscription = Self::check_subscription(&api_key).await;
|
||||
let host = if is_subscription {
|
||||
tracing::debug!("NanoGPT subscription active, using subscription endpoint");
|
||||
NANOGPT_SUBSCRIPTION_HOST.to_string()
|
||||
} else {
|
||||
tracing::debug!("NanoGPT using pay-as-you-go endpoint");
|
||||
NANOGPT_API_HOST.to_string()
|
||||
};
|
||||
|
||||
let api_client = ApiClient::new(host, AuthMethod::BearerToken(api_key))?;
|
||||
|
||||
Ok(Self {
|
||||
api_client,
|
||||
model,
|
||||
name: NANOGPT_PROVIDER_NAME.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderDef for NanoGptProvider {
|
||||
type Provider = Self;
|
||||
|
||||
fn metadata() -> ProviderMetadata {
|
||||
ProviderMetadata::new(
|
||||
NANOGPT_PROVIDER_NAME,
|
||||
"NanoGPT",
|
||||
"Access multiple AI models through NanoGPT's unified API",
|
||||
NANOGPT_DEFAULT_MODEL,
|
||||
vec![NANOGPT_DEFAULT_MODEL],
|
||||
NANOGPT_DOC_URL,
|
||||
vec![ConfigKey::new(NANOGPT_API_KEY, true, true, None, true)],
|
||||
)
|
||||
}
|
||||
|
||||
fn from_env(
|
||||
model: ModelConfig,
|
||||
_extensions: Vec<crate::config::ExtensionConfig>,
|
||||
) -> BoxFuture<'static, Result<Self::Provider>> {
|
||||
Box::pin(Self::from_env(model))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for NanoGptProvider {
|
||||
fn get_name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn get_model_config(&self) -> ModelConfig {
|
||||
self.model.clone()
|
||||
}
|
||||
|
||||
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
|
||||
let response = self
|
||||
.api_client
|
||||
.request(None, "models?detailed=true")
|
||||
.response_get()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ProviderError::RequestFailed(format!(
|
||||
"Failed to fetch models from NanoGPT API: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
let json: serde_json::Value = response.json().await.map_err(|e| {
|
||||
ProviderError::RequestFailed(format!(
|
||||
"Failed to parse NanoGPT models API response as JSON: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Some(err_obj) = json.get("error") {
|
||||
let msg = err_obj
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
return Err(ProviderError::RequestFailed(format!(
|
||||
"NanoGPT API returned an error: {}",
|
||||
msg
|
||||
)));
|
||||
}
|
||||
|
||||
let data = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| {
|
||||
ProviderError::RequestFailed("Missing 'data' field in JSON response".into())
|
||||
})?;
|
||||
|
||||
let mut models: Vec<String> = data
|
||||
.iter()
|
||||
.filter_map(|model| {
|
||||
let id = model.get("id").and_then(|v| v.as_str())?;
|
||||
let supports_tool_calling = model
|
||||
.get("capabilities")
|
||||
.and_then(|c| c.get("tool_calling"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if supports_tool_calling {
|
||||
Some(id.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
models.sort();
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
model_config: &ModelConfig,
|
||||
session_id: &str,
|
||||
system: &str,
|
||||
messages: &[Message],
|
||||
tools: &[Tool],
|
||||
) -> Result<MessageStream, ProviderError> {
|
||||
let payload = create_request(
|
||||
model_config,
|
||||
system,
|
||||
messages,
|
||||
tools,
|
||||
&ImageFormat::OpenAi,
|
||||
true,
|
||||
)?;
|
||||
|
||||
let mut log = RequestLog::start(model_config, &payload)?;
|
||||
|
||||
let response = self
|
||||
.with_retry(|| async {
|
||||
let resp = self
|
||||
.api_client
|
||||
.response_post(Some(session_id), "chat/completions", &payload)
|
||||
.await?;
|
||||
handle_status_openai_compat(resp).await
|
||||
})
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
let _ = log.error(e);
|
||||
})?;
|
||||
|
||||
stream_openai_compat(response, log)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_metadata() {
|
||||
let metadata = NanoGptProvider::metadata();
|
||||
assert_eq!(metadata.name, "nano-gpt");
|
||||
assert_eq!(metadata.default_model, "anthropic/claude-sonnet-4.6");
|
||||
assert_eq!(metadata.config_keys[0].name, NANOGPT_API_KEY);
|
||||
assert!(metadata.config_keys[0].required);
|
||||
assert!(metadata.config_keys[0].secret);
|
||||
}
|
||||
}
|
||||
@@ -338,6 +338,12 @@ impl ProviderDef for OpenAiProvider {
|
||||
ConfigKey::new("OPENAI_TIMEOUT", false, false, Some("600"), false),
|
||||
],
|
||||
)
|
||||
.with_setup_steps(vec![
|
||||
"Go to https://platform.openai.com and sign up or log in",
|
||||
"Navigate to API Keys in the left sidebar",
|
||||
"Click 'Create new secret key'",
|
||||
"Copy the key and paste it above",
|
||||
])
|
||||
}
|
||||
|
||||
fn from_env(
|
||||
|
||||
@@ -168,6 +168,11 @@ impl ProviderDef for OpenRouterProvider {
|
||||
),
|
||||
],
|
||||
)
|
||||
.with_setup_steps(vec![
|
||||
"Go to https://openrouter.ai/settings/keys",
|
||||
"Click 'Create' or use an existing API key",
|
||||
"Copy the key and paste it above",
|
||||
])
|
||||
}
|
||||
|
||||
fn from_env(
|
||||
|
||||
@@ -139,6 +139,7 @@ impl ProviderRegistry {
|
||||
known_models,
|
||||
model_doc_link: base_metadata.model_doc_link,
|
||||
config_keys,
|
||||
setup_steps: vec![],
|
||||
};
|
||||
|
||||
self.entries.insert(
|
||||
|
||||
@@ -373,6 +373,7 @@ mod tests {
|
||||
known_models: vec![],
|
||||
model_doc_link: "".to_string(),
|
||||
config_keys: vec![],
|
||||
setup_steps: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ impl ProviderDef for MockCompactionProvider {
|
||||
known_models: vec![],
|
||||
model_doc_link: "".to_string(),
|
||||
config_keys: vec![],
|
||||
setup_steps: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
-2
@@ -839,9 +839,9 @@
|
||||
"200": {
|
||||
"description": "Custom provider created successfully",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/CreateCustomProviderResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1888,6 +1888,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/handle_nanogpt": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"super::routes::setup"
|
||||
],
|
||||
"operationId": "start_nanogpt_setup",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SetupResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/handle_openrouter": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -4279,6 +4299,17 @@
|
||||
"$ref": "#/components/schemas/Message"
|
||||
}
|
||||
},
|
||||
"CreateCustomProviderResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"provider_name"
|
||||
],
|
||||
"properties": {
|
||||
"provider_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateRecipeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -6612,6 +6643,13 @@
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The unique identifier for this provider"
|
||||
},
|
||||
"setup_steps": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "step-by-step instructions for set up providers eg: api key"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+15
-5
@@ -16,6 +16,8 @@ import { ToastContainer } from 'react-toastify';
|
||||
import AnnouncementModal from './components/AnnouncementModal';
|
||||
import TelemetryOptOutModal from './components/TelemetryOptOutModal';
|
||||
import ProviderGuard from './components/ProviderGuard';
|
||||
import OnboardingGuard from './components/onboarding/OnboardingGuard';
|
||||
import { USE_NEW_ONBOARDING } from './featureFlags';
|
||||
import { createSession } from './sessions';
|
||||
|
||||
import { ChatType } from './types/chat';
|
||||
@@ -656,11 +658,19 @@ export function AppInner() {
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProviderGuard didSelectProvider={didSelectProvider}>
|
||||
<ChatProvider chat={chat} setChat={setChat} contextKey="hub">
|
||||
<AppLayout activeSessions={activeSessions} />
|
||||
</ChatProvider>
|
||||
</ProviderGuard>
|
||||
USE_NEW_ONBOARDING ? (
|
||||
<OnboardingGuard>
|
||||
<ChatProvider chat={chat} setChat={setChat} contextKey="hub">
|
||||
<AppLayout activeSessions={activeSessions} />
|
||||
</ChatProvider>
|
||||
</OnboardingGuard>
|
||||
) : (
|
||||
<ProviderGuard didSelectProvider={didSelectProvider}>
|
||||
<ChatProvider chat={chat} setChat={setChat} contextKey="hub">
|
||||
<AppLayout activeSessions={activeSessions} />
|
||||
</ChatProvider>
|
||||
</ProviderGuard>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Route index element={<HubRouteWrapper />} />
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -154,6 +154,10 @@ export type ContentBlock = ({
|
||||
|
||||
export type Conversation = Array<Message>;
|
||||
|
||||
export type CreateCustomProviderResponse = {
|
||||
provider_name: string;
|
||||
};
|
||||
|
||||
export type CreateRecipeRequest = {
|
||||
author?: AuthorRequest | null;
|
||||
session_id: string;
|
||||
@@ -927,6 +931,10 @@ export type ProviderMetadata = {
|
||||
* The unique identifier for this provider
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* step-by-step instructions for set up providers eg: api key
|
||||
*/
|
||||
setup_steps?: Array<string>;
|
||||
};
|
||||
|
||||
export type ProviderTemplate = {
|
||||
@@ -2229,10 +2237,10 @@ export type CreateCustomProviderResponses = {
|
||||
/**
|
||||
* Custom provider created successfully
|
||||
*/
|
||||
200: string;
|
||||
200: CreateCustomProviderResponse;
|
||||
};
|
||||
|
||||
export type CreateCustomProviderResponse = CreateCustomProviderResponses[keyof CreateCustomProviderResponses];
|
||||
export type CreateCustomProviderResponse2 = CreateCustomProviderResponses[keyof CreateCustomProviderResponses];
|
||||
|
||||
export type RemoveCustomProviderData = {
|
||||
body?: never;
|
||||
@@ -3071,6 +3079,19 @@ export type TranscribeDictationResponses = {
|
||||
|
||||
export type TranscribeDictationResponse = TranscribeDictationResponses[keyof TranscribeDictationResponses];
|
||||
|
||||
export type StartNanogptSetupData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/handle_nanogpt';
|
||||
};
|
||||
|
||||
export type StartNanogptSetupResponses = {
|
||||
200: SetupResponse;
|
||||
};
|
||||
|
||||
export type StartNanogptSetupResponse = StartNanogptSetupResponses[keyof StartNanogptSetupResponses];
|
||||
|
||||
export type StartOpenrouterSetupData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -1,82 +1,17 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import {
|
||||
listLocalModels,
|
||||
downloadHfModel,
|
||||
getLocalModelDownloadProgress,
|
||||
cancelLocalModelDownload,
|
||||
type DownloadProgress,
|
||||
type LocalModelResponse,
|
||||
} from '../api';
|
||||
import { toastService } from '../toasts';
|
||||
import { trackOnboardingSetupFailed } from '../utils/analytics';
|
||||
import { Goose } from './icons';
|
||||
import LocalModelPicker from './onboarding/LocalModelPicker';
|
||||
|
||||
interface LocalModelSetupProps {
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return mb >= 1024 ? `${(mb / 1024).toFixed(1)}GB` : `${mb.toFixed(0)}MB`;
|
||||
};
|
||||
|
||||
type SetupPhase = 'loading' | 'select' | 'downloading' | 'error';
|
||||
|
||||
export function LocalModelSetup({ onSuccess, onCancel }: LocalModelSetupProps) {
|
||||
const { upsert } = useConfig();
|
||||
const [phase, setPhase] = useState<SetupPhase>('loading');
|
||||
const [models, setModels] = useState<LocalModelResponse[]>([]);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showAllModels, setShowAllModels] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => cleanup, [cleanup]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await listLocalModels();
|
||||
if (response.data) {
|
||||
setModels(response.data);
|
||||
|
||||
const alreadyDownloaded = response.data.find((m) => m.status.state === 'Downloaded');
|
||||
if (alreadyDownloaded) {
|
||||
setSelectedModelId(alreadyDownloaded.id);
|
||||
} else {
|
||||
const recommended = response.data.find((m: LocalModelResponse) => m.recommended);
|
||||
if (recommended) setSelectedModelId(recommended.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load local models:', error);
|
||||
setErrorMessage('Failed to load available models. Please try again.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
setPhase('select');
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const finishSetup = async (modelId: string) => {
|
||||
const handleConfigured = async (_providerName: string, modelId: string) => {
|
||||
await upsert('GOOSE_PROVIDER', 'local', false);
|
||||
await upsert('GOOSE_MODEL', modelId, false);
|
||||
toastService.success({
|
||||
@@ -86,323 +21,20 @@ export function LocalModelSetup({ onSuccess, onCancel }: LocalModelSetupProps) {
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
const startDownload = async (modelId: string) => {
|
||||
setPhase('downloading');
|
||||
setDownloadProgress(null);
|
||||
setErrorMessage(null);
|
||||
|
||||
const model = models.find((m) => m.id === modelId);
|
||||
if (!model) {
|
||||
setErrorMessage('Model not found');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadHfModel({ body: { spec: model.id } });
|
||||
} catch (error) {
|
||||
console.error('Failed to start download:', error);
|
||||
setErrorMessage('Failed to start download. Please try again.');
|
||||
trackOnboardingSetupFailed('local', 'download_start_failed');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const response = await getLocalModelDownloadProgress({ path: { model_id: modelId } });
|
||||
if (response.data) {
|
||||
setDownloadProgress(response.data);
|
||||
if (response.data.status === 'completed') {
|
||||
cleanup();
|
||||
await finishSetup(modelId);
|
||||
} else if (response.data.status === 'failed') {
|
||||
cleanup();
|
||||
setErrorMessage(response.data.error || 'Download failed.');
|
||||
trackOnboardingSetupFailed('local', response.data.error || 'download_failed');
|
||||
setPhase('error');
|
||||
} else if (response.data.status === 'cancelled') {
|
||||
cleanup();
|
||||
setPhase('select');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
cleanup();
|
||||
setErrorMessage('Lost connection to download. Please try again.');
|
||||
trackOnboardingSetupFailed('local', 'progress_poll_failed');
|
||||
setPhase('error');
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (phase === 'downloading' && selectedModelId) {
|
||||
cleanup();
|
||||
try {
|
||||
await cancelLocalModelDownload({ path: { model_id: selectedModelId } });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
setDownloadProgress(null);
|
||||
setPhase('select');
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrimaryAction = async () => {
|
||||
if (!selectedModelId) return;
|
||||
const model = models.find((m) => m.id === selectedModelId);
|
||||
if (!model) return;
|
||||
if (model.status.state === 'Downloaded') {
|
||||
await finishSetup(model.id);
|
||||
} else {
|
||||
await startDownload(model.id);
|
||||
}
|
||||
};
|
||||
|
||||
const recommended = models.find((m) => m.recommended);
|
||||
const otherModels = models.filter((m) => m.id !== recommended?.id);
|
||||
const selectedModel = models.find((m) => m.id === selectedModelId);
|
||||
|
||||
if (phase === 'loading') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-text-muted mb-4"></div>
|
||||
<p className="text-text-muted text-sm">Checking available models...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-left space-y-3">
|
||||
<div className="origin-bottom-left goose-icon-animation">
|
||||
<Goose className="size-6 sm:size-8" />
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-4xl font-light">Run Locally</h1>
|
||||
<p className="text-text-muted text-base sm:text-lg">
|
||||
Download a model to run Goose entirely on your machine — no API keys, no accounts,
|
||||
Download a model to run goose entirely on your machine — no API keys, no accounts,
|
||||
completely free and private.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{phase === 'error' && (
|
||||
<div className="space-y-4">
|
||||
<div className="border border-red-500/30 rounded-xl p-4 bg-red-500/5">
|
||||
<p className="text-sm text-red-400">{errorMessage}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setErrorMessage(null);
|
||||
setPhase('select');
|
||||
}}
|
||||
className="w-full px-6 py-3 bg-background-muted text-text-default rounded-lg transition-colors font-medium hover:bg-background-muted/80"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="w-full px-6 py-3 bg-transparent text-text-muted rounded-lg hover:bg-background-muted transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model selection */}
|
||||
{phase === 'select' && (
|
||||
<div className="space-y-5">
|
||||
{/* Recommended model card */}
|
||||
{recommended && (
|
||||
<div
|
||||
onClick={() => setSelectedModelId(recommended.id)}
|
||||
className={`relative w-full p-4 sm:p-6 border rounded-xl cursor-pointer transition-all duration-200 group ${
|
||||
selectedModelId === recommended.id
|
||||
? 'border-blue-500 bg-blue-500/5'
|
||||
: 'border-border-subtle hover:border-border-default'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute -top-2 -right-2 sm:-top-3 sm:-right-3 z-10">
|
||||
<span className="inline-block px-2 py-1 text-xs font-medium bg-blue-600 text-white rounded-full">
|
||||
Best for your machine
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
checked={selectedModelId === recommended.id}
|
||||
onChange={() => setSelectedModelId(recommended.id)}
|
||||
className="cursor-pointer flex-shrink-0 mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-text-default text-sm sm:text-base">
|
||||
{recommended.id}
|
||||
</span>
|
||||
{recommended.status.state === 'Downloaded' && (
|
||||
<span className="text-xs bg-green-600 text-white px-2 py-0.5 rounded-full">
|
||||
Ready
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-muted text-xs mt-1">
|
||||
{formatSize(recommended.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable other models */}
|
||||
{otherModels.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAllModels(!showAllModels)}
|
||||
className="text-sm text-blue-500 hover:text-blue-400 transition-colors flex items-center gap-1"
|
||||
>
|
||||
{showAllModels ? 'Hide other sizes' : `Show ${otherModels.length} other sizes`}
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 transition-transform ${showAllModels ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showAllModels && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{otherModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
onClick={() => setSelectedModelId(model.id)}
|
||||
className={`w-full p-4 border rounded-xl cursor-pointer transition-all duration-200 ${
|
||||
selectedModelId === model.id
|
||||
? 'border-blue-500 bg-blue-500/5'
|
||||
: 'border-border-subtle hover:border-border-default'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
checked={selectedModelId === model.id}
|
||||
onChange={() => setSelectedModelId(model.id)}
|
||||
className="cursor-pointer flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-text-default text-sm">
|
||||
{model.id}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatSize(model.size_bytes)}
|
||||
</span>
|
||||
{model.status.state === 'Downloaded' && (
|
||||
<span className="text-xs bg-green-600 text-white px-2 py-0.5 rounded-full">
|
||||
Ready
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary action */}
|
||||
<button
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={!selectedModelId}
|
||||
className="w-full px-6 py-3 bg-background-muted text-text-default rounded-lg transition-colors font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-background-muted/80"
|
||||
>
|
||||
{selectedModel?.status.state === 'Downloaded'
|
||||
? `Use ${selectedModel.id}`
|
||||
: selectedModel
|
||||
? `Download ${selectedModel.id} (${formatSize(selectedModel.size_bytes)})`
|
||||
: 'Select a model'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="w-full px-6 py-3 bg-transparent text-text-muted rounded-lg hover:bg-background-muted transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Downloading state */}
|
||||
{phase === 'downloading' && selectedModel && (
|
||||
<div className="space-y-6">
|
||||
<div className="border border-border-subtle rounded-xl p-5 sm:p-6 bg-background-default">
|
||||
<p className="font-medium text-text-default text-sm sm:text-base mb-4">
|
||||
Downloading {selectedModel.id}
|
||||
</p>
|
||||
|
||||
{downloadProgress ? (
|
||||
<div className="space-y-3">
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-background-subtle rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${downloadProgress.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
<span>
|
||||
{formatBytes(downloadProgress.bytes_downloaded)} of{' '}
|
||||
{formatBytes(downloadProgress.total_bytes)}
|
||||
</span>
|
||||
<span>{downloadProgress.progress_percent.toFixed(0)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
{downloadProgress.speed_bps ? (
|
||||
<span>{formatBytes(downloadProgress.speed_bps)}/s</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{downloadProgress.eta_seconds != null && downloadProgress.eta_seconds > 0 && (
|
||||
<span>
|
||||
~
|
||||
{downloadProgress.eta_seconds < 60
|
||||
? `${Math.round(downloadProgress.eta_seconds)}s`
|
||||
: `${Math.round(downloadProgress.eta_seconds / 60)}m`}{' '}
|
||||
remaining
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-text-muted"></div>
|
||||
<span className="text-sm text-text-muted">Starting download...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="w-full px-6 py-3 bg-transparent text-text-muted rounded-lg hover:bg-background-muted transition-colors border border-border-subtle"
|
||||
>
|
||||
Cancel Download
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<LocalModelPicker onConfigured={handleConfigured} onBack={onCancel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
|
||||
} | null>(null);
|
||||
|
||||
const handleTetrateSetup = async () => {
|
||||
trackOnboardingProviderSelected('tetrate');
|
||||
trackOnboardingProviderSelected({ method: 'tetrate' });
|
||||
try {
|
||||
const result = await startTetrateSetup();
|
||||
if (result.success) {
|
||||
@@ -107,7 +107,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
|
||||
};
|
||||
|
||||
const handleChatGptCodexSetup = async () => {
|
||||
trackOnboardingProviderSelected('chatgpt_codex');
|
||||
trackOnboardingProviderSelected({ method: 'chatgpt_codex' });
|
||||
try {
|
||||
const result = await startChatGptCodexSetup();
|
||||
if (result.success) {
|
||||
@@ -136,7 +136,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
|
||||
};
|
||||
|
||||
const handleApiKeySuccess = async (provider: string, _model: string, apiKey: string) => {
|
||||
trackOnboardingProviderSelected('api_key');
|
||||
trackOnboardingProviderSelected({ method: 'api_key' });
|
||||
const keyName = `${provider.toUpperCase()}_API_KEY`;
|
||||
await upsert(keyName, apiKey, true);
|
||||
await upsert('GOOSE_PROVIDER', provider, false);
|
||||
@@ -161,7 +161,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
|
||||
};
|
||||
|
||||
const handleOpenRouterSetup = async () => {
|
||||
trackOnboardingProviderSelected('openrouter');
|
||||
trackOnboardingProviderSelected({ method: 'openrouter' });
|
||||
try {
|
||||
const result = await startOpenRouterSetup();
|
||||
if (result.success) {
|
||||
@@ -339,7 +339,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
trackOnboardingProviderSelected('local');
|
||||
trackOnboardingProviderSelected({ method: 'local' });
|
||||
setShowLocalModelSetup(true);
|
||||
}}
|
||||
className="w-full p-4 sm:p-6 bg-transparent border rounded-xl transition-all duration-200 cursor-pointer group"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react';
|
||||
import { startNanogptSetup } from '../../utils/nanogptSetup';
|
||||
import { startTetrateSetup } from '../../utils/tetrateSetup';
|
||||
import { Tetrate } from '../icons';
|
||||
import LocalModelPicker from './LocalModelPicker';
|
||||
import { HardDrive } from 'lucide-react';
|
||||
|
||||
const TETRATE = 'tetrate' as const;
|
||||
const NANOGPT = 'nano-gpt' as const;
|
||||
const LOCAL_PROVIDER = 'local' as const;
|
||||
type FreeOption = typeof TETRATE | typeof NANOGPT | typeof LOCAL_PROVIDER;
|
||||
|
||||
interface FreeOptionCardsProps {
|
||||
onConfigured: (providerName: string, modelId?: string) => void;
|
||||
}
|
||||
|
||||
const ChevronRight = () => (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const cardClass = (isSelected: boolean) =>
|
||||
`w-full p-4 bg-transparent border rounded-lg transition-all duration-200 cursor-pointer group ${
|
||||
isSelected ? 'border-blue-400' : 'hover:border-blue-400'
|
||||
}`;
|
||||
|
||||
export default function FreeOptionCards({ onConfigured }: FreeOptionCardsProps) {
|
||||
const [error, setError] = useState<{
|
||||
message: string;
|
||||
type: typeof TETRATE | typeof NANOGPT;
|
||||
} | null>(null);
|
||||
const [selectedProvider, setSelectedProvider] = useState<FreeOption | null>(null);
|
||||
|
||||
const handleSetup = async (type: typeof TETRATE | typeof NANOGPT) => {
|
||||
setError(null);
|
||||
setSelectedProvider(type);
|
||||
try {
|
||||
const result = type === TETRATE ? await startTetrateSetup() : await startNanogptSetup();
|
||||
if (result.success) {
|
||||
onConfigured(type);
|
||||
} else {
|
||||
setError({ message: result.message, type });
|
||||
}
|
||||
} catch {
|
||||
setError({ message: 'An unexpected error occurred during setup.', type });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTetrateSetup = () => handleSetup(TETRATE);
|
||||
const handleNanogptSetup = () => handleSetup(NANOGPT);
|
||||
|
||||
const handleRunLocallyClick = () => {
|
||||
setSelectedProvider(LOCAL_PROVIDER);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
if (!error) return;
|
||||
if (error.type === TETRATE) {
|
||||
handleTetrateSetup();
|
||||
} else {
|
||||
handleNanogptSetup();
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedProvider === LOCAL_PROVIDER) {
|
||||
return (
|
||||
<LocalModelPicker onConfigured={onConfigured} onBack={() => setSelectedProvider(null)} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 border rounded-xl bg-background-muted">
|
||||
<p className="text-sm text-text-muted mb-4">Choose an option to get started.</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div onClick={handleTetrateSetup} className={cardClass(selectedProvider === TETRATE)}>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tetrate className="w-5 h-5 text-text-default" />
|
||||
<span className="font-medium text-text-default text-base">
|
||||
Agent Router by Tetrate
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-text-muted group-hover:text-text-default transition-colors">
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-text-muted text-sm">
|
||||
Access multiple AI models with automatic setup. Sign up to receive $10 credit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div onClick={handleNanogptSetup} className={cardClass(selectedProvider === NANOGPT)}>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 flex items-center justify-center text-text-default text-xs font-bold">
|
||||
N
|
||||
</span>
|
||||
<span className="font-medium text-text-default text-base">NanoGPT</span>
|
||||
</div>
|
||||
<div className="text-text-muted group-hover:text-text-default transition-colors">
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-text-muted text-sm">
|
||||
Sign up to receive 60M free tokens for 7 days.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div onClick={handleRunLocallyClick} className={cardClass(false)}>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="w-5 h-5 text-text-default" />
|
||||
<span className="font-medium text-text-default text-base">Use a Local Model</span>
|
||||
<span className="inline-block px-1.5 py-0.5 text-[10px] font-medium bg-green-600 text-white rounded-full">
|
||||
Free & Private
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-text-muted group-hover:text-text-default transition-colors">
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-text-muted text-sm">
|
||||
Download a model and run entirely on your machine. No API keys, no accounts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 p-3 border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20 rounded-lg flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error.message}</p>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="px-3 py-1 text-sm font-medium text-red-700 dark:text-red-400 bg-white dark:bg-gray-800 border border-red-300 dark:border-red-700 rounded-md hover:bg-red-50 dark:hover:bg-red-900/30 shrink-0"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
listLocalModels,
|
||||
downloadHfModel,
|
||||
getLocalModelDownloadProgress,
|
||||
cancelLocalModelDownload,
|
||||
type DownloadProgress,
|
||||
type LocalModelResponse,
|
||||
} from '../../api';
|
||||
import { trackOnboardingSetupFailed } from '../../utils/analytics';
|
||||
|
||||
interface LocalModelPickerProps {
|
||||
onConfigured: (providerName: string, modelId: string) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return mb >= 1024 ? `${(mb / 1024).toFixed(1)}GB` : `${mb.toFixed(0)}MB`;
|
||||
};
|
||||
|
||||
const LOCAL_PROVIDER = 'local';
|
||||
|
||||
type Phase = 'loading' | 'select' | 'downloading' | 'error';
|
||||
|
||||
export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPickerProps) {
|
||||
const [phase, setPhase] = useState<Phase>('loading');
|
||||
const [models, setModels] = useState<LocalModelResponse[]>([]);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showAllModels, setShowAllModels] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => cleanup, [cleanup]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await listLocalModels({ throwOnError: true });
|
||||
if (response.data) {
|
||||
setModels(response.data);
|
||||
|
||||
const alreadyDownloaded = response.data.find((m) => m.status.state === 'Downloaded');
|
||||
if (alreadyDownloaded) {
|
||||
setSelectedModelId(alreadyDownloaded.id);
|
||||
} else {
|
||||
const recommended = response.data.find((m: LocalModelResponse) => m.recommended);
|
||||
if (recommended) setSelectedModelId(recommended.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load local models:', error);
|
||||
setErrorMessage('Failed to load available models. Please try again.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
setPhase('select');
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const finishSetup = (modelId: string) => {
|
||||
onConfigured(LOCAL_PROVIDER, modelId);
|
||||
};
|
||||
|
||||
const startDownload = async (modelId: string) => {
|
||||
setPhase('downloading');
|
||||
setDownloadProgress(null);
|
||||
setErrorMessage(null);
|
||||
|
||||
const model = models.find((m) => m.id === modelId);
|
||||
if (!model) {
|
||||
setErrorMessage('Model not found');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadHfModel({ body: { spec: model.id }, throwOnError: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to start download:', error);
|
||||
setErrorMessage('Failed to start download. Please try again.');
|
||||
trackOnboardingSetupFailed(LOCAL_PROVIDER, 'download_start_failed');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const response = await getLocalModelDownloadProgress({
|
||||
path: { model_id: modelId },
|
||||
throwOnError: true,
|
||||
});
|
||||
if (response.data) {
|
||||
setDownloadProgress(response.data);
|
||||
if (response.data.status === 'completed') {
|
||||
cleanup();
|
||||
finishSetup(modelId);
|
||||
} else if (response.data.status === 'failed') {
|
||||
cleanup();
|
||||
setErrorMessage(response.data.error || 'Download failed.');
|
||||
trackOnboardingSetupFailed(LOCAL_PROVIDER, response.data.error || 'download_failed');
|
||||
setPhase('error');
|
||||
} else if (response.data.status === 'cancelled') {
|
||||
cleanup();
|
||||
setPhase('select');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
cleanup();
|
||||
setErrorMessage('Lost connection to download. Please try again.');
|
||||
trackOnboardingSetupFailed(LOCAL_PROVIDER, 'progress_poll_failed');
|
||||
setPhase('error');
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleCancelDownload = async () => {
|
||||
if (phase === 'downloading' && selectedModelId) {
|
||||
cleanup();
|
||||
try {
|
||||
await cancelLocalModelDownload({ path: { model_id: selectedModelId } });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
setDownloadProgress(null);
|
||||
setPhase('select');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrimaryAction = async () => {
|
||||
if (!selectedModelId) return;
|
||||
const model = models.find((m) => m.id === selectedModelId);
|
||||
if (!model) return;
|
||||
if (model.status.state === 'Downloaded') {
|
||||
finishSetup(model.id);
|
||||
} else {
|
||||
await startDownload(model.id);
|
||||
}
|
||||
};
|
||||
|
||||
const recommended = models.find((m) => m.recommended);
|
||||
const otherModels = models.filter((m) => m.id !== recommended?.id);
|
||||
const selectedModel = models.find((m) => m.id === selectedModelId);
|
||||
|
||||
if (phase === 'loading') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-text-muted mb-4"></div>
|
||||
<p className="text-text-muted text-sm">Checking available models...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 border rounded-xl bg-background-muted">
|
||||
{phase === 'error' && (
|
||||
<div className="space-y-3">
|
||||
<div className="border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setErrorMessage(null);
|
||||
setPhase('select');
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-transparent border rounded-lg text-text-default text-sm font-medium hover:bg-background-muted/80 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'select' && (
|
||||
<div className="space-y-3">
|
||||
{recommended && (
|
||||
<div
|
||||
onClick={() => setSelectedModelId(recommended.id)}
|
||||
className={`relative w-full p-4 border rounded-lg cursor-pointer transition-all duration-200 ${
|
||||
selectedModelId === recommended.id
|
||||
? 'border-blue-500 bg-blue-500/5'
|
||||
: 'border-border-subtle hover:border-border-default'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute -top-2 -right-2 z-10">
|
||||
<span className="inline-block px-2 py-0.5 text-xs font-medium bg-blue-600 text-white rounded-full">
|
||||
Best for your machine
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
checked={selectedModelId === recommended.id}
|
||||
onChange={() => setSelectedModelId(recommended.id)}
|
||||
className="cursor-pointer flex-shrink-0 mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-text-default text-sm">
|
||||
{recommended.id}
|
||||
</span>
|
||||
{recommended.status.state === 'Downloaded' && (
|
||||
<span className="text-xs bg-green-600 text-white px-2 py-0.5 rounded-full">
|
||||
Ready
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-muted text-xs mt-1">
|
||||
{formatSize(recommended.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otherModels.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAllModels(!showAllModels)}
|
||||
className="text-sm text-blue-500 hover:text-blue-400 transition-colors flex items-center gap-1"
|
||||
>
|
||||
{showAllModels ? 'Hide other sizes' : `Show ${otherModels.length} other sizes`}
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 transition-transform ${showAllModels ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showAllModels && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{otherModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
onClick={() => setSelectedModelId(model.id)}
|
||||
className={`w-full p-4 border rounded-lg cursor-pointer transition-all duration-200 ${
|
||||
selectedModelId === model.id
|
||||
? 'border-blue-500 bg-blue-500/5'
|
||||
: 'border-border-subtle hover:border-border-default'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
checked={selectedModelId === model.id}
|
||||
onChange={() => setSelectedModelId(model.id)}
|
||||
className="cursor-pointer flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-text-default text-sm">
|
||||
{model.id}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatSize(model.size_bytes)}
|
||||
</span>
|
||||
{model.status.state === 'Downloaded' && (
|
||||
<span className="text-xs bg-green-600 text-white px-2 py-0.5 rounded-full">
|
||||
Ready
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={!selectedModelId}
|
||||
className="w-full px-4 py-2.5 bg-blue-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed hover:bg-blue-700 cursor-pointer"
|
||||
>
|
||||
{selectedModel?.status.state === 'Downloaded'
|
||||
? `Use ${selectedModel.id}`
|
||||
: selectedModel
|
||||
? `Download ${selectedModel.id} (${formatSize(selectedModel.size_bytes)})`
|
||||
: 'Select a model'}
|
||||
</button>
|
||||
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="w-full px-4 py-2.5 text-blue-600 dark:text-blue-400 text-sm font-medium border border-blue-300 dark:border-blue-700 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors cursor-pointer"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'downloading' && selectedModel && (
|
||||
<div className="space-y-3">
|
||||
<div className="border border-border-subtle rounded-lg p-4 bg-background-default">
|
||||
<p className="font-medium text-text-default text-sm mb-3">
|
||||
Downloading {selectedModel.id}
|
||||
</p>
|
||||
|
||||
{downloadProgress ? (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full bg-background-subtle rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${downloadProgress.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
<span>
|
||||
{formatBytes(downloadProgress.bytes_downloaded)} of{' '}
|
||||
{formatBytes(downloadProgress.total_bytes)}
|
||||
</span>
|
||||
<span>{downloadProgress.progress_percent.toFixed(0)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
{downloadProgress.speed_bps ? (
|
||||
<span>{formatBytes(downloadProgress.speed_bps)}/s</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{downloadProgress.eta_seconds != null && downloadProgress.eta_seconds > 0 && (
|
||||
<span>
|
||||
~
|
||||
{downloadProgress.eta_seconds < 60
|
||||
? `${Math.round(downloadProgress.eta_seconds)}s`
|
||||
: `${Math.round(downloadProgress.eta_seconds / 60)}m`}{' '}
|
||||
remaining
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-text-muted"></div>
|
||||
<span className="text-sm text-text-muted">Starting download...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCancelDownload}
|
||||
className="w-full px-4 py-2.5 bg-transparent text-text-muted border rounded-lg text-sm hover:bg-background-default/80 transition-colors"
|
||||
>
|
||||
Cancel Download
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-yellow-50/50 dark:bg-yellow-900/10 p-3 mt-3">
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300 leading-relaxed">
|
||||
Local models keep everything on your machine for full privacy. Performance and context
|
||||
window size may vary compared to cloud providers depending on your hardware and model
|
||||
size.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useConfig } from '../ConfigContext';
|
||||
import { useModelAndProvider } from '../ModelAndProviderContext';
|
||||
import { Goose } from '../icons';
|
||||
import ProviderSelector from './ProviderSelector';
|
||||
import OnboardingSuccess from './OnboardingSuccess';
|
||||
import {
|
||||
trackOnboardingStarted,
|
||||
trackOnboardingCompleted,
|
||||
trackOnboardingProviderSelected,
|
||||
trackTelemetryPreference,
|
||||
setTelemetryEnabled as setAnalyticsTelemetryEnabled,
|
||||
} from '../../utils/analytics';
|
||||
|
||||
const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED';
|
||||
|
||||
interface OnboardingGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function OnboardingGuard({ children }: OnboardingGuardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { read, upsert, getProviders } = useConfig();
|
||||
const { refreshCurrentModelAndProvider } = useModelAndProvider();
|
||||
|
||||
const [isCheckingProvider, setIsCheckingProvider] = useState(true);
|
||||
const [hasProvider, setHasProvider] = useState(false);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const [configuredProvider, setConfiguredProvider] = useState<string | null>(null);
|
||||
const [configuredProviderDisplayName, setConfiguredProviderDisplayName] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [configuredModel, setConfiguredModel] = useState<string | null>(null);
|
||||
const hasTrackedOnboardingStart = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkProvider = async () => {
|
||||
try {
|
||||
const provider = ((await read('GOOSE_PROVIDER', false)) as string) || '';
|
||||
setHasProvider(provider.trim() !== '');
|
||||
} catch (error) {
|
||||
console.error('Error checking provider:', error);
|
||||
setHasProvider(false);
|
||||
} finally {
|
||||
setIsCheckingProvider(false);
|
||||
}
|
||||
};
|
||||
checkProvider();
|
||||
}, [read]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCheckingProvider && !hasProvider && !hasTrackedOnboardingStart.current) {
|
||||
trackOnboardingStarted();
|
||||
hasTrackedOnboardingStart.current = true;
|
||||
}
|
||||
}, [isCheckingProvider, hasProvider]);
|
||||
|
||||
const handleConfigured = async (providerName: string, modelId?: string) => {
|
||||
trackOnboardingProviderSelected({ provider: providerName });
|
||||
await upsert('GOOSE_PROVIDER', providerName, false);
|
||||
const providers = await getProviders(true);
|
||||
const matchedProvider = providers.find((p) => p.name === providerName);
|
||||
if (modelId) {
|
||||
await upsert('GOOSE_MODEL', modelId, false);
|
||||
setConfiguredModel(modelId);
|
||||
} else if (matchedProvider) {
|
||||
await upsert('GOOSE_MODEL', matchedProvider.metadata.default_model, false);
|
||||
setConfiguredModel(matchedProvider.metadata.default_model);
|
||||
}
|
||||
await refreshCurrentModelAndProvider();
|
||||
setConfiguredProvider(providerName);
|
||||
setConfiguredProviderDisplayName(matchedProvider?.metadata.display_name || providerName);
|
||||
};
|
||||
|
||||
const finishOnboarding = async (telemetryEnabled: boolean) => {
|
||||
try {
|
||||
await upsert(TELEMETRY_CONFIG_KEY, telemetryEnabled, false);
|
||||
} catch (error) {
|
||||
console.error('Failed to save telemetry preference:', error);
|
||||
}
|
||||
trackTelemetryPreference(telemetryEnabled, 'onboarding');
|
||||
if (configuredProvider) {
|
||||
trackOnboardingCompleted(configuredProvider, configuredModel ?? undefined);
|
||||
}
|
||||
if (!telemetryEnabled) {
|
||||
setAnalyticsTelemetryEnabled(false);
|
||||
}
|
||||
navigate('/', { replace: true });
|
||||
setHasProvider(true);
|
||||
};
|
||||
|
||||
if (isCheckingProvider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasProvider) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (configuredProviderDisplayName) {
|
||||
return (
|
||||
<OnboardingSuccess providerName={configuredProviderDisplayName} onFinish={finishOnboarding} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full bg-background-default overflow-hidden">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div
|
||||
className={`flex flex-col items-center p-4 pb-8 transition-all duration-500 ease-in-out ${hasSelection ? 'pt-8' : 'pt-[15vh]'}`}
|
||||
>
|
||||
<div className="max-w-2xl w-full mx-auto">
|
||||
<div
|
||||
className={`text-left transition-all duration-500 ease-in-out overflow-hidden ${hasSelection ? 'max-h-0 opacity-0 mb-0' : 'max-h-60 opacity-100 mb-8'}`}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<Goose className="size-8" />
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-4xl font-light mb-3">Welcome to goose</h1>
|
||||
<p className="text-text-muted text-base sm:text-lg">
|
||||
Your local AI agent. Connect an AI model provider to get started.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProviderSelector
|
||||
onConfigured={handleConfigured}
|
||||
onFirstSelection={() => setHasSelection(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import PrivacyInfoModal from './PrivacyInfoModal';
|
||||
|
||||
const LOCAL_PROVIDER = 'local';
|
||||
|
||||
interface OnboardingSuccessProps {
|
||||
providerName: string;
|
||||
onFinish: (telemetryEnabled: boolean) => void;
|
||||
}
|
||||
|
||||
export default function OnboardingSuccess({ providerName, onFinish }: OnboardingSuccessProps) {
|
||||
const [showPrivacyInfo, setShowPrivacyInfo] = useState(false);
|
||||
const [telemetryOptIn, setTelemetryOptIn] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full bg-background-default overflow-hidden">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="flex flex-col items-center justify-center h-full p-4">
|
||||
<div className="max-w-md w-full mx-auto text-center">
|
||||
<div className="mb-6">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-500/10 mb-4">
|
||||
<svg
|
||||
className="w-6 h-6 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-light text-text-default mb-1">
|
||||
{providerName === LOCAL_PROVIDER
|
||||
? 'Local model ready'
|
||||
: `Connected to ${providerName}`}
|
||||
</h2>
|
||||
<p className="text-text-muted text-sm">You're all set to start using goose.</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full p-4 bg-transparent border rounded-xl text-left mb-6">
|
||||
<h3 className="font-medium text-text-default text-sm mb-1">Privacy</h3>
|
||||
<p className="text-text-muted text-sm">
|
||||
Anonymous usage data helps improve goose. We never collect your conversations, code,
|
||||
or personal data.{' '}
|
||||
<button
|
||||
onClick={() => setShowPrivacyInfo(true)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Learn more
|
||||
</button>
|
||||
</p>
|
||||
<label className="mt-3 flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={telemetryOptIn}
|
||||
onChange={(e) => setTelemetryOptIn(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-text-muted text-sm">Share anonymous usage data</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => onFinish(telemetryOptIn)} className="w-full">
|
||||
Get Started
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PrivacyInfoModal isOpen={showPrivacyInfo} onClose={() => setShowPrivacyInfo(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
|
||||
interface PrivacyInfoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PrivacyInfoModal({ isOpen, onClose }: PrivacyInfoModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">Privacy details</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<p className="text-text-muted text-sm mb-3">
|
||||
Anonymous usage data helps us understand how goose is used and identify areas for
|
||||
improvement.
|
||||
</p>
|
||||
<p className="font-medium text-text-default text-sm mb-1.5">What we collect:</p>
|
||||
<ul className="text-text-muted text-sm list-disc list-outside space-y-0.5 ml-5 mb-3">
|
||||
<li>Operating system, version, and architecture</li>
|
||||
<li>goose version and install method</li>
|
||||
<li>Provider and model used</li>
|
||||
<li>Extensions and tool usage counts (names only)</li>
|
||||
<li>Session metrics (duration, interaction count, token usage)</li>
|
||||
<li>Error types (e.g., "rate_limit", "auth" - no details)</li>
|
||||
</ul>
|
||||
<p className="text-text-muted text-sm">
|
||||
We never collect your conversations, code, tool arguments, error messages, or any
|
||||
personal data. You can change this setting anytime in Settings.
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState } from 'react';
|
||||
import { configureProviderOauth, ProviderDetails } from '../../api';
|
||||
import { useConfig } from '../ConfigContext';
|
||||
import DefaultProviderSetupForm, {
|
||||
ConfigInput,
|
||||
} from '../settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm';
|
||||
import { providerConfigSubmitHandler } from '../settings/providers/modal/subcomponents/handlers/DefaultSubmitHandler';
|
||||
import ProviderLogo from '../settings/providers/modal/subcomponents/ProviderLogo';
|
||||
import { SecureStorageNotice } from '../settings/providers/modal/subcomponents/SecureStorageNotice';
|
||||
import { Button } from '../ui/button';
|
||||
import { LogIn, ChevronRight } from 'lucide-react';
|
||||
|
||||
function parseLinks(text: string) {
|
||||
return text.split(/(https?:\/\/[^\s]+)/g).map((part, i) =>
|
||||
/^https?:\/\//.test(part) ? (
|
||||
<a
|
||||
key={i}
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.electron.openExternal(part);
|
||||
}}
|
||||
className="underline hover:text-text-default cursor-pointer"
|
||||
>
|
||||
{part}
|
||||
</a>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function OAuthForm({
|
||||
provider,
|
||||
onConfigured,
|
||||
onError,
|
||||
}: {
|
||||
provider: ProviderDetails;
|
||||
onConfigured: (name: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await configureProviderOauth({
|
||||
path: { name: provider.name },
|
||||
throwOnError: true,
|
||||
});
|
||||
onConfigured(provider.name);
|
||||
} catch (err) {
|
||||
onError(`Sign-in failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-6 py-3"
|
||||
size="lg"
|
||||
>
|
||||
<LogIn size={20} />
|
||||
{isLoading ? 'Signing in...' : `Sign in with ${provider.metadata.display_name}`}
|
||||
</Button>
|
||||
<p className="text-xs text-text-muted text-center">
|
||||
A browser window will open for you to complete the login.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyForm({
|
||||
provider,
|
||||
onConfigured,
|
||||
onError,
|
||||
}: {
|
||||
provider: ProviderDetails;
|
||||
onConfigured: (name: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
}) {
|
||||
const { upsert } = useConfig();
|
||||
const [configValues, setConfigValues] = useState<Record<string, ConfigInput>>({});
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showSetupHelp, setShowSetupHelp] = useState(false);
|
||||
const setupSteps = provider.metadata.setup_steps;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setValidationErrors({});
|
||||
|
||||
const parameters = provider.metadata.config_keys || [];
|
||||
const errors: Record<string, string> = {};
|
||||
parameters.forEach((param) => {
|
||||
if (
|
||||
param.required &&
|
||||
!configValues[param.name]?.value &&
|
||||
!configValues[param.name]?.serverValue
|
||||
) {
|
||||
errors[param.name] = `${param.name} is required`;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setValidationErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
const toSubmit = Object.fromEntries(
|
||||
Object.entries(configValues)
|
||||
.filter(([, entry]) => !!entry.value)
|
||||
.map(([k, entry]) => [k, entry.value || ''])
|
||||
);
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await providerConfigSubmitHandler(upsert, provider, toSubmit);
|
||||
onConfigured(provider.name);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: typeof err === 'object' && err !== null && 'message' in err
|
||||
? String((err as Record<string, unknown>).message)
|
||||
: JSON.stringify(err);
|
||||
onError(msg);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DefaultProviderSetupForm
|
||||
configValues={configValues}
|
||||
setConfigValues={setConfigValues}
|
||||
provider={provider}
|
||||
validationErrors={validationErrors}
|
||||
showOptions={false}
|
||||
/>
|
||||
{provider.metadata.config_keys.some((k) => k.required && k.secret) && <SecureStorageNotice />}
|
||||
{setupSteps && setupSteps.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSetupHelp(!showSetupHelp)}
|
||||
className="flex items-center gap-1 text-sm text-text-muted hover:text-text-default transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${showSetupHelp ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
Don't have an API key?
|
||||
</button>
|
||||
{showSetupHelp && (
|
||||
<ol className="mt-2 ml-5 list-decimal text-sm text-text-muted space-y-1">
|
||||
{setupSteps.map((step, i) => (
|
||||
<li key={i}>{parseLinks(step)}</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||
{isSubmitting ? 'Configuring...' : 'Continue'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderConfigFormProps {
|
||||
provider: ProviderDetails;
|
||||
onConfigured: (providerName: string) => void;
|
||||
}
|
||||
|
||||
export default function ProviderConfigForm({ provider, onConfigured }: ProviderConfigFormProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isOAuthProvider = provider.metadata.config_keys.some((key) => key.oauth_flow);
|
||||
|
||||
const renderForm = () => {
|
||||
if (isOAuthProvider) {
|
||||
return <OAuthForm provider={provider} onConfigured={onConfigured} onError={setError} />;
|
||||
}
|
||||
return <ApiKeyForm provider={provider} onConfigured={onConfigured} onError={setError} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 border rounded-xl bg-background-muted">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<ProviderLogo providerName={provider.name} />
|
||||
<div>
|
||||
<h3 className="font-medium text-text-default">{provider.metadata.display_name}</h3>
|
||||
<p className="text-xs text-text-muted">{provider.metadata.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderForm()}
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-red-50 text-red-800 border border-red-200 dark:bg-red-900/20 dark:text-red-200 dark:border-red-800 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
providers as fetchProviders,
|
||||
createCustomProvider,
|
||||
ProviderDetails,
|
||||
UpdateCustomProviderRequest,
|
||||
} from '../../api';
|
||||
import { Select } from '../ui/Select';
|
||||
import ProviderConfigForm from './ProviderConfigForm';
|
||||
import FreeOptionCards from './FreeOptionCards';
|
||||
import CustomProviderForm from '../settings/providers/modal/subcomponents/forms/CustomProviderForm';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Gift, Key, Plus } from 'lucide-react';
|
||||
|
||||
const FREE_OPTIONS = 'free-options' as const;
|
||||
const OWN_PROVIDER = 'own-provider' as const;
|
||||
|
||||
type SelectedPath = typeof FREE_OPTIONS | typeof OWN_PROVIDER | null;
|
||||
|
||||
interface ProviderOption {
|
||||
value: string;
|
||||
label: string;
|
||||
provider: ProviderDetails;
|
||||
}
|
||||
|
||||
interface ProviderSelectorProps {
|
||||
onConfigured: (providerName: string, modelId?: string) => void;
|
||||
onFirstSelection?: () => void;
|
||||
}
|
||||
|
||||
export default function ProviderSelector({
|
||||
onConfigured,
|
||||
onFirstSelection,
|
||||
}: ProviderSelectorProps) {
|
||||
const [providerList, setProviderList] = useState<ProviderDetails[]>([]);
|
||||
const [selectedOption, setSelectedOption] = useState<ProviderOption | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<SelectedPath>(null);
|
||||
const [showCustomModal, setShowCustomModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await fetchProviders({ throwOnError: true });
|
||||
if (response.data) {
|
||||
const list = Array.isArray(response.data)
|
||||
? response.data
|
||||
: (response.data as { providers: ProviderDetails[] }).providers || [];
|
||||
setProviderList(list);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch providers:', err);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const options: ProviderOption[] = useMemo(() => {
|
||||
return [...providerList]
|
||||
.sort((a, b) => {
|
||||
const aPreferred = a.provider_type === 'Preferred' ? 0 : 1;
|
||||
const bPreferred = b.provider_type === 'Preferred' ? 0 : 1;
|
||||
if (aPreferred !== bPreferred) return aPreferred - bPreferred;
|
||||
return a.metadata.display_name.localeCompare(b.metadata.display_name);
|
||||
})
|
||||
.map((provider) => ({
|
||||
value: provider.name,
|
||||
label: provider.metadata.display_name,
|
||||
provider,
|
||||
}));
|
||||
}, [providerList]);
|
||||
|
||||
const fuzzyFilterOption = (option: { label: string; value: string }, inputValue: string) => {
|
||||
const normalize = (s: string) => s.toLowerCase().replace(/[\s_-]/g, '');
|
||||
return (
|
||||
normalize(option.label).includes(normalize(inputValue)) ||
|
||||
normalize(option.value).includes(normalize(inputValue))
|
||||
);
|
||||
};
|
||||
|
||||
const handleFreeCreditClick = () => {
|
||||
setSelectedPath(FREE_OPTIONS);
|
||||
setSelectedOption(null);
|
||||
onFirstSelection?.();
|
||||
};
|
||||
|
||||
const handleOwnProviderClick = () => {
|
||||
setSelectedPath(OWN_PROVIDER);
|
||||
onFirstSelection?.();
|
||||
};
|
||||
|
||||
const handleProviderSelect = (option: ProviderOption | null) => {
|
||||
setSelectedOption(option);
|
||||
if (option) onFirstSelection?.();
|
||||
};
|
||||
|
||||
const handleCreateCustomProvider = async (data: UpdateCustomProviderRequest) => {
|
||||
const result = await createCustomProvider({ body: data, throwOnError: true });
|
||||
setShowCustomModal(false);
|
||||
if (result.data?.provider_name) {
|
||||
onConfigured(result.data.provider_name);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedProvider = selectedOption?.provider ?? null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||
<div
|
||||
onClick={handleFreeCreditClick}
|
||||
className={`p-4 border rounded-xl transition-all duration-200 cursor-pointer group ${
|
||||
selectedPath === FREE_OPTIONS
|
||||
? 'border-blue-400 bg-background-muted'
|
||||
: 'border-border-default bg-background-muted hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
<Gift size={20} className="text-text-muted mb-2" />
|
||||
<span className="font-medium text-text-default text-base block">
|
||||
Use Free/Local Providers
|
||||
</span>
|
||||
<p className="text-text-muted text-sm mt-1">
|
||||
Use a local model or a provider with free credits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={handleOwnProviderClick}
|
||||
className={`p-4 border rounded-xl transition-all duration-200 cursor-pointer group ${
|
||||
selectedPath === OWN_PROVIDER
|
||||
? 'border-blue-400 bg-background-muted'
|
||||
: 'border-border-default bg-background-muted hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
<Key size={20} className="text-text-muted mb-2" />
|
||||
<span className="font-medium text-text-default text-base block">
|
||||
Connect to a Provider
|
||||
</span>
|
||||
<p className="text-text-muted text-sm mt-1">Connect OpenAI, Anthropic, Google, etc</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedPath === FREE_OPTIONS && (
|
||||
<div className="animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<FreeOptionCards onConfigured={onConfigured} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPath === OWN_PROVIDER && (
|
||||
<div className="animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div className="mb-4">
|
||||
<Select
|
||||
options={options}
|
||||
value={selectedOption}
|
||||
onChange={(option) => handleProviderSelect(option as ProviderOption | null)}
|
||||
placeholder="Select a provider"
|
||||
isClearable
|
||||
isSearchable
|
||||
autoFocus
|
||||
filterOption={fuzzyFilterOption}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCustomModal(true)}
|
||||
className="flex items-center gap-1 text-sm text-text-muted hover:text-text-default transition-colors mb-6"
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>Add a custom provider</span>
|
||||
</button>
|
||||
|
||||
{selectedProvider && (
|
||||
<ProviderConfigForm
|
||||
key={selectedProvider.name}
|
||||
provider={selectedProvider}
|
||||
onConfigured={onConfigured}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={showCustomModal} onOpenChange={setShowCustomModal}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Custom Provider</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CustomProviderForm
|
||||
initialData={null}
|
||||
isEditable={true}
|
||||
onSubmit={handleCreateCustomProvider}
|
||||
onCancel={() => setShowCustomModal(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -180,7 +180,8 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
|
||||
setDownloading((prev) => new Set(prev).add(key));
|
||||
try {
|
||||
const response = await downloadHfModel({
|
||||
body: { spec }, throwOnError: true
|
||||
body: { spec },
|
||||
throwOnError: true,
|
||||
});
|
||||
if (response.data) {
|
||||
onDownloadStarted(response.data);
|
||||
@@ -191,7 +192,6 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
|
||||
title: 'Direct download failed',
|
||||
msg: 'Failed to start the download. Check the spec: ' + errorMessage(e),
|
||||
});
|
||||
|
||||
} finally {
|
||||
setDownloading((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
||||
@@ -197,7 +197,7 @@ function ProviderCards({
|
||||
async (data: UpdateCustomProviderRequest) => {
|
||||
const { createCustomProvider } = await import('../../../api');
|
||||
const result = await createCustomProvider({ body: data, throwOnError: true });
|
||||
const providerId = result.data?.replace('Custom provider added - ID: ', '') || null;
|
||||
const providerId = result.data?.provider_name;
|
||||
setShowCustomProviderModal(false);
|
||||
if (refreshProviders) {
|
||||
await refreshProviders();
|
||||
|
||||
+24
-14
@@ -11,7 +11,7 @@ import ProviderCatalogPicker from '../ProviderCatalogPicker';
|
||||
type Step = 'choice' | 'catalog' | 'form';
|
||||
|
||||
interface CustomProviderFormProps {
|
||||
onSubmit: (data: UpdateCustomProviderRequest) => void;
|
||||
onSubmit: (data: UpdateCustomProviderRequest) => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
onDelete?: () => Promise<void>;
|
||||
isActiveProvider?: boolean;
|
||||
@@ -44,6 +44,7 @@ export default function CustomProviderForm({
|
||||
value: false,
|
||||
});
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
|
||||
// Template + step state
|
||||
@@ -185,8 +186,10 @@ export default function CustomProviderForm({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitError(null);
|
||||
setValidationErrors({});
|
||||
|
||||
const errors: Record<string, string> = {};
|
||||
if (!displayName) errors.displayName = 'Display name is required';
|
||||
@@ -227,18 +230,23 @@ export default function CustomProviderForm({
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
onSubmit({
|
||||
engine,
|
||||
display_name: displayName,
|
||||
api_url: apiUrl,
|
||||
api_key: apiKey,
|
||||
models: modelList,
|
||||
supports_streaming: supportsStreaming,
|
||||
requires_auth: requiresAuth,
|
||||
headers: headersObject,
|
||||
catalog_provider_id: selectedTemplate?.id ?? initialData?.catalog_provider_id ?? undefined,
|
||||
base_path: basePath || undefined,
|
||||
});
|
||||
try {
|
||||
await onSubmit({
|
||||
engine,
|
||||
display_name: displayName,
|
||||
api_url: apiUrl,
|
||||
api_key: apiKey,
|
||||
models: modelList,
|
||||
supports_streaming: supportsStreaming,
|
||||
requires_auth: requiresAuth,
|
||||
headers: headersObject,
|
||||
catalog_provider_id: selectedTemplate?.id ?? initialData?.catalog_provider_id ?? undefined,
|
||||
base_path: basePath || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save custom provider:', error);
|
||||
setSubmitError('Failed to save provider. Please check your configuration and try again.');
|
||||
}
|
||||
};
|
||||
|
||||
// Aggregate capability badges for template models
|
||||
@@ -669,6 +677,8 @@ export default function CustomProviderForm({
|
||||
|
||||
<SecureStorageNotice />
|
||||
|
||||
{submitError && <p className="text-red-500 text-sm">{submitError}</p>}
|
||||
|
||||
{showDeleteConfirmation ? (
|
||||
<div className="pt-4 space-y-3">
|
||||
{isActiveProvider ? (
|
||||
|
||||
+9
-3
@@ -17,6 +17,7 @@ interface DefaultProviderSetupFormProps {
|
||||
setConfigValues: React.Dispatch<React.SetStateAction<Record<string, ConfigInput>>>;
|
||||
provider: ProviderDetails;
|
||||
validationErrors: ValidationErrors;
|
||||
showOptions?: boolean;
|
||||
}
|
||||
|
||||
const envToPrettyName = (envVar: string) => {
|
||||
@@ -40,6 +41,7 @@ export default function DefaultProviderSetupForm({
|
||||
setConfigValues,
|
||||
provider,
|
||||
validationErrors = {},
|
||||
showOptions = true,
|
||||
}: DefaultProviderSetupFormProps) {
|
||||
const parameters = useMemo(
|
||||
() => provider.metadata.config_keys || [],
|
||||
@@ -168,8 +170,12 @@ export default function DefaultProviderSetupForm({
|
||||
));
|
||||
};
|
||||
|
||||
let aboveFoldParameters = parameters.filter((p) => p.primary);
|
||||
let belowFoldParameters = parameters.filter((p) => !p.primary);
|
||||
let aboveFoldParameters = parameters.filter(
|
||||
(p) => p.primary || (p.required && (p.default === undefined || p.default === null))
|
||||
);
|
||||
let belowFoldParameters = parameters.filter(
|
||||
(p) => !p.primary && !(p.required && (p.default === undefined || p.default === null))
|
||||
);
|
||||
|
||||
if (aboveFoldParameters.length === 0 && parameters.length > 0) {
|
||||
aboveFoldParameters = parameters;
|
||||
@@ -187,7 +193,7 @@ export default function DefaultProviderSetupForm({
|
||||
) : (
|
||||
<div>
|
||||
<div>{renderParametersList(aboveFoldParameters)}</div>
|
||||
{belowFoldParameters.length > 0 && (
|
||||
{showOptions && belowFoldParameters.length > 0 && (
|
||||
<Collapsible
|
||||
open={optionalExpanded}
|
||||
onOpenChange={setOptionalExpanded}
|
||||
|
||||
@@ -55,7 +55,7 @@ function DialogContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background p-2 hover:bg-background-secondary rounded-full focus:ring-ring data-[state=open]:bg-background-secondary transition-all duration-200 data-[state=open]:text-text-secondary absolute top-5 right-4 opacity-70 hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<DialogPrimitive.Close className="ring-offset-background p-1 hover:bg-background-secondary rounded-full focus:ring-ring data-[state=open]:bg-background-secondary transition-all duration-200 data-[state=open]:text-text-secondary absolute top-4 right-4 opacity-70 hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const USE_NEW_ONBOARDING = true;
|
||||
@@ -612,7 +612,14 @@ export function useChatStream({
|
||||
sseMaxRetryAttempts: 0,
|
||||
});
|
||||
|
||||
await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId, abortControllerRef.current.signal);
|
||||
await streamFromResponse(
|
||||
stream,
|
||||
currentMessages,
|
||||
dispatch,
|
||||
onFinish,
|
||||
sessionId,
|
||||
abortControllerRef.current.signal
|
||||
);
|
||||
} catch (error) {
|
||||
// AbortError is expected when user stops streaming
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
@@ -654,7 +661,14 @@ export function useChatStream({
|
||||
sseMaxRetryAttempts: 0,
|
||||
});
|
||||
|
||||
await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId, abortControllerRef.current.signal);
|
||||
await streamFromResponse(
|
||||
stream,
|
||||
currentMessages,
|
||||
dispatch,
|
||||
onFinish,
|
||||
sessionId,
|
||||
abortControllerRef.current.signal
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
// Silently handle abort
|
||||
@@ -794,7 +808,14 @@ export function useChatStream({
|
||||
sseMaxRetryAttempts: 0,
|
||||
});
|
||||
|
||||
await streamFromResponse(stream, messagesForUI, dispatch, onFinish, targetSessionId, abortControllerRef.current.signal);
|
||||
await streamFromResponse(
|
||||
stream,
|
||||
messagesForUI,
|
||||
dispatch,
|
||||
onFinish,
|
||||
targetSessionId,
|
||||
abortControllerRef.current.signal
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle });
|
||||
|
||||
@@ -67,19 +67,7 @@ async function sendEvent(
|
||||
export type AnalyticsEvent =
|
||||
| { name: 'page_view'; properties: { page: string; referrer?: string } }
|
||||
| { name: 'onboarding_started'; properties: Record<string, never> }
|
||||
| {
|
||||
name: 'onboarding_provider_selected';
|
||||
properties: {
|
||||
method:
|
||||
| 'api_key'
|
||||
| 'openrouter'
|
||||
| 'tetrate'
|
||||
| 'chatgpt_codex'
|
||||
| 'ollama'
|
||||
| 'local'
|
||||
| 'other';
|
||||
};
|
||||
}
|
||||
| { name: 'onboarding_provider_selected'; properties: { provider?: string; method?: string } }
|
||||
| {
|
||||
name: 'onboarding_completed';
|
||||
properties: { provider: string; model?: string; duration_seconds?: number };
|
||||
@@ -293,12 +281,13 @@ export function trackOnboardingStarted(): void {
|
||||
trackEvent({ name: 'onboarding_started', properties: {} });
|
||||
}
|
||||
|
||||
export function trackOnboardingProviderSelected(
|
||||
method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'local' | 'other'
|
||||
): void {
|
||||
export function trackOnboardingProviderSelected(options: {
|
||||
provider?: string;
|
||||
method?: string;
|
||||
}): void {
|
||||
trackEvent({
|
||||
name: 'onboarding_provider_selected',
|
||||
properties: { method },
|
||||
properties: options,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { startNanogptSetup as startNanogptSetupApi } from '../api';
|
||||
|
||||
export async function startNanogptSetup(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
return (await startNanogptSetupApi({ throwOnError: true })).data;
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to start NanoGPT setup: ${e instanceof Error ? e.message : String(e)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user