feat: new onboarding flow (#7266)

This commit is contained in:
Lifei Zhou
2026-03-17 12:33:41 +11:00
committed by GitHub
parent a518c42cbc
commit 4604354bc2
40 changed files with 1800 additions and 441 deletions
+2
View File
@@ -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(
+35 -2
View File
@@ -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(),
})),
}
}
+2
View File
@@ -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(())
}
+5 -6
View File
@@ -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);
+5
View File
@@ -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(
+11
View File
@@ -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
+6
View File
@@ -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(
+2
View File
@@ -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);
+1
View File
@@ -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;
+213
View File
@@ -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);
}
}
+6
View File
@@ -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(
+5
View File
@@ -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(
+1
View File
@@ -373,6 +373,7 @@ mod tests {
known_models: vec![],
model_doc_link: "".to_string(),
config_keys: vec![],
setup_steps: vec![],
}
}
+1
View File
@@ -191,6 +191,7 @@ impl ProviderDef for MockCompactionProvider {
known_models: vec![],
model_doc_link: "".to_string(),
config_keys: vec![],
setup_steps: vec![],
}
}
+40 -2
View File
@@ -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
View File
@@ -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
+23 -2
View File
@@ -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;
+4 -372
View File
@@ -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>
);
}
+5 -5
View File
@@ -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 &amp; 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();
@@ -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 ? (
@@ -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}
+1 -1
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export const USE_NEW_ONBOARDING = true;
+24 -3
View File
@@ -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 });
+6 -17
View File
@@ -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,
});
}
+12
View File
@@ -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)}`,
};
}
}