diff --git a/BitFun-Installer/index.html b/BitFun-Installer/index.html index e18048d3..7c3f33ad 100644 --- a/BitFun-Installer/index.html +++ b/BitFun-Installer/index.html @@ -4,9 +4,18 @@ Install BitFun + diff --git a/BitFun-Installer/scripts/sync-model-i18n.cjs b/BitFun-Installer/scripts/sync-model-i18n.cjs index b25583f9..8002b896 100644 --- a/BitFun-Installer/scripts/sync-model-i18n.cjs +++ b/BitFun-Installer/scripts/sync-model-i18n.cjs @@ -54,39 +54,93 @@ function buildProviderPatch(settingsAiModel) { return providerPatch; } -function buildModelPatch(onboarding, settingsAiModel, languageTag) { +function buildFormPatch(form) { + if (!form || typeof form !== 'object') return {}; + return { + baseUrl: form.baseUrl ?? '', + apiKey: form.apiKey ?? '', + apiKeyPlaceholder: form.apiKeyPlaceholder ?? '', + provider: form.provider ?? '', + providerPlaceholder: form.providerPlaceholder ?? '', + modelSelection: form.modelSelection ?? '', + modelName: form.modelName ?? '', + resolvedUrlLabel: form.resolvedUrlLabel ?? '', + }; +} + +function buildFormatsPatch(formats) { + if (!formats || typeof formats !== 'object') return {}; + return { + openaiCompatible: formats.openaiCompatible ?? '', + responsesApi: formats.responsesApi ?? '', + claudeApi: formats.claudeApi ?? '', + geminiApi: formats.geminiApi ?? '', + }; +} + +function buildModelPatch(settingsAiModel, languageTag, components) { const isZh = languageTag === 'zh'; + const form = get(settingsAiModel, 'form', {}); + const formats = get(settingsAiModel, 'formats', {}); + const input = get(components, 'input', {}); return { description: get( - onboarding, - 'model.description', + settingsAiModel, + 'subtitle', 'Configure AI model provider, API key, and advanced parameters.' ), - providerLabel: get(onboarding, 'model.provider.label', 'Model Provider'), - selectProvider: get(onboarding, 'model.provider.placeholder', 'Select a provider...'), - customProvider: get(onboarding, 'model.provider.options.custom', 'Custom'), - getApiKey: get(onboarding, 'model.apiKey.help', 'How to get an API Key?'), - modelNamePlaceholder: get( - onboarding, - 'model.modelName.inputPlaceholder', - get(onboarding, 'model.modelName.placeholder', 'Enter model name...') + providerLabel: get(settingsAiModel, 'providerSelection.title', 'Model Provider'), + selectProvider: get(settingsAiModel, 'providerSelection.orSelectProvider', 'Select a provider...'), + customProvider: get(settingsAiModel, 'providerSelection.customTitle', 'Custom'), + getApiKey: get(settingsAiModel, 'providerSelection.getApiKey', 'How to get an API Key?'), + fillApiKeyBeforeFetch: get( + settingsAiModel, + 'providerSelection.fillApiKeyBeforeFetch', + 'Enter the API key before fetching models' + ), + fetchingModels: get(settingsAiModel, 'providerSelection.fetchingModels', 'Fetching model list...'), + fetchFailedFallback: get( + settingsAiModel, + 'providerSelection.fetchFailedFallback', + 'Failed to fetch model list, fell back to common preset models' + ), + fetchEmptyFallback: get( + settingsAiModel, + 'providerSelection.fetchEmptyFallback', + 'Provider returned no models, fell back to common preset models' ), - modelNameSelectPlaceholder: get(onboarding, 'model.modelName.selectPlaceholder', 'Select a model...'), - modelSearchPlaceholder: get( - onboarding, - 'model.modelName.searchPlaceholder', - 'Search or enter a custom model name...' + usingPresetModels: get( + settingsAiModel, + 'providerSelection.usingPresetModels', + 'Currently showing common preset models' + ), + modelNamePlaceholder: get( + settingsAiModel, + 'providerSelection.inputModelName', + get(settingsAiModel, 'form.modelName', 'Enter model name...') ), + modelNameSelectPlaceholder: get(settingsAiModel, 'providerSelection.selectModel', 'Select a model...'), modelNoResults: isZh ? '没有匹配的模型' : 'No matching models', - customModel: get(onboarding, 'model.modelName.customHint', 'Use custom model name'), - baseUrlPlaceholder: get(onboarding, 'model.baseUrl.placeholder', 'Enter API URL'), + /** Installer: use addCustomModel (not useCustomModel / "Press Enter") for the extra dropdown option */ + addCustomModel: get(settingsAiModel, 'providerSelection.addCustomModel', 'Add Custom Model'), + form: buildFormPatch(form), + formats: buildFormatsPatch(formats), + showSecret: get(input, 'show', 'Show'), + hideSecret: get(input, 'hide', 'Hide'), + baseUrlPlaceholder: isZh + ? '示例:https://open.bigmodel.cn/api/paas/v4/chat/completions' + : 'e.g., https://open.bigmodel.cn/api/paas/v4/chat/completions', customRequestBodyPlaceholder: get( - onboarding, - 'model.advanced.customRequestBodyPlaceholder', + settingsAiModel, + 'advancedSettings.customRequestBody.placeholder', '{\n "temperature": 0.8,\n "top_p": 0.9\n}' ), - jsonValid: get(onboarding, 'model.advanced.jsonValid', 'Valid JSON format'), - jsonInvalid: get(onboarding, 'model.advanced.jsonInvalid', 'Invalid JSON format'), + jsonValid: get(settingsAiModel, 'advancedSettings.customRequestBody.validJson', 'Valid JSON format'), + jsonInvalid: get( + settingsAiModel, + 'advancedSettings.customRequestBody.invalidJson', + 'Invalid JSON format' + ), skipSslVerify: get( settingsAiModel, 'advancedSettings.skipSslVerify.label', @@ -105,10 +159,10 @@ function buildModelPatch(onboarding, settingsAiModel, languageTag) { addHeader: get(settingsAiModel, 'advancedSettings.customHeaders.addHeader', 'Add Field'), headerKey: get(settingsAiModel, 'advancedSettings.customHeaders.keyPlaceholder', 'key'), headerValue: get(settingsAiModel, 'advancedSettings.customHeaders.valuePlaceholder', 'value'), - testConnection: get(onboarding, 'model.testConnection', 'Test Connection'), - testing: get(onboarding, 'model.testing', 'Testing...'), - testSuccess: get(onboarding, 'model.testSuccess', 'Connection successful'), - testFailed: get(onboarding, 'model.testFailed', 'Connection failed'), + testConnection: get(settingsAiModel, 'actions.test', 'Test Connection'), + testing: isZh ? '测试中...' : 'Testing...', + testSuccess: get(settingsAiModel, 'messages.testSuccess', 'Connection successful'), + testFailed: get(settingsAiModel, 'messages.testFailed', 'Connection failed'), advancedShow: 'Show advanced settings', advancedHide: 'Hide advanced settings', providers: buildProviderPatch(settingsAiModel), @@ -119,34 +173,38 @@ function syncOne(languageTag) { const localeDir = languageTag === 'zh' ? 'zh-CN' : 'en-US'; const installerLocale = languageTag === 'zh' ? 'zh.json' : 'en.json'; - const sourceOnboardingPath = path.join( + const sourceAiModelPath = path.join( PROJECT_ROOT, 'src', 'web-ui', 'src', 'locales', localeDir, - 'onboarding.json' + 'settings', + 'ai-model.json' ); - - const sourceAiModelPath = path.join( + const sourceComponentsPath = path.join( PROJECT_ROOT, 'src', 'web-ui', 'src', 'locales', localeDir, - 'settings', - 'ai-model.json' + 'components.json' ); const targetPath = path.join(INSTALLER_ROOT, 'src', 'i18n', 'locales', installerLocale); - const onboarding = readJson(sourceOnboardingPath); const settingsAiModel = readJson(sourceAiModelPath); + let components = {}; + try { + components = readJson(sourceComponentsPath); + } catch { + // optional + } const target = readJson(targetPath); - const patch = buildModelPatch(onboarding, settingsAiModel, languageTag); + const patch = buildModelPatch(settingsAiModel, languageTag, components); target.model = mergeDeep(target.model || {}, patch); writeJson(targetPath, target); diff --git a/BitFun-Installer/scripts/sync-theme-i18n.cjs b/BitFun-Installer/scripts/sync-theme-i18n.cjs index f3648e0b..d66735e3 100644 --- a/BitFun-Installer/scripts/sync-theme-i18n.cjs +++ b/BitFun-Installer/scripts/sync-theme-i18n.cjs @@ -24,9 +24,10 @@ function writeJson(filePath, data) { } function extractThemeNames(source, sourceLabel) { - const presets = source?.theme?.presets; + // Theme preset names live under settings/basics.json → appearance.presets (formerly theme.json → theme.presets). + const presets = source?.appearance?.presets; if (!presets || typeof presets !== "object") { - throw new Error(`Invalid theme presets in ${sourceLabel}`); + throw new Error(`Invalid appearance.presets in ${sourceLabel}`); } const result = {}; @@ -61,7 +62,7 @@ function main() { "locales", "en-US", "settings", - "theme.json" + "basics.json" ); const sourceZhPath = path.join( PROJECT_ROOT, @@ -71,7 +72,7 @@ function main() { "locales", "zh-CN", "settings", - "theme.json" + "basics.json" ); const targetEnPath = path.join( diff --git a/BitFun-Installer/src-tauri/Cargo.toml b/BitFun-Installer/src-tauri/Cargo.toml index 982c9332..f0ad5c22 100644 --- a/BitFun-Installer/src-tauri/Cargo.toml +++ b/BitFun-Installer/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ flate2 = "1.0" tar = "0.4" chrono = "0.4" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +urlencoding = "2" [target.'cfg(windows)'.dependencies] winreg = "0.52" diff --git a/BitFun-Installer/src-tauri/src/installer/commands.rs b/BitFun-Installer/src-tauri/src/installer/commands.rs index 71caf166..f88edede 100644 --- a/BitFun-Installer/src-tauri/src/installer/commands.rs +++ b/BitFun-Installer/src-tauri/src/installer/commands.rs @@ -1,7 +1,10 @@ //! Tauri commands exposed to the frontend installer UI. use super::extract::{self, ESTIMATED_INSTALL_SIZE}; -use super::types::{ConnectionTestResult, DiskSpaceInfo, InstallOptions, InstallProgress, ModelConfig}; +use super::model_list; +use super::types::{ + ConnectionTestResult, DiskSpaceInfo, InstallOptions, InstallProgress, ModelConfig, RemoteModelInfo, +}; use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -587,6 +590,7 @@ pub fn close_installer(window: Window) { #[tauri::command] pub fn set_theme_preference(theme_preference: String) -> Result<(), String> { let allowed = [ + "system", "bitfun-dark", "bitfun-light", "bitfun-midnight", @@ -662,17 +666,81 @@ pub async fn test_model_config_connection(model_config: ModelConfig) -> Result Result, String> { + if model_config.api_key.trim().is_empty() { + return Err("API key is required".to_string()); + } + if model_config.base_url.trim().is_empty() { + return Err("Base URL is required".to_string()); + } + model_list::list_remote_models(&model_config).await +} + // ── Helpers ──────────────────────────────────────────────────────────────── +/// API format for HTTP test (connection + headers). fn normalize_api_format(model: &ModelConfig) -> String { let normalized = model.format.trim().to_ascii_lowercase(); - if normalized == "anthropic" { - "anthropic".to_string() - } else { - "openai".to_string() + match normalized.as_str() { + "anthropic" => "anthropic".to_string(), + "gemini" | "google" => "gemini".to_string(), + "responses" | "response" => "responses".to_string(), + _ => "openai".to_string(), } } +fn storage_format(model: &ModelConfig) -> String { + model.format.trim().to_ascii_lowercase() +} + +/// Stored `request_url` aligned with settings `resolveRequestUrl` (no bitfun_core). +fn resolve_stored_request_url(base_url: &str, format: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/'); + if trimmed.ends_with('#') { + return trimmed[..trimmed.len().saturating_sub(1)] + .trim_end_matches('/') + .to_string(); + } + match format { + "openai" => { + if trimmed.ends_with("chat/completions") { + trimmed.to_string() + } else { + format!("{}/chat/completions", trimmed) + } + } + "responses" | "response" => { + if trimmed.ends_with("responses") { + trimmed.to_string() + } else { + format!("{}/responses", trimmed) + } + } + "anthropic" => { + if trimmed.ends_with("v1/messages") { + trimmed.to_string() + } else { + format!("{}/v1/messages", trimmed) + } + } + "gemini" | "google" => gemini_installer_base_url(trimmed).to_string(), + _ => trimmed.to_string(), + } +} + +fn gemini_installer_base_url(url: &str) -> &str { + let mut u = url; + if let Some(pos) = u.find("/v1beta") { + u = &u[..pos]; + } + if let Some(pos) = u.find("/models/") { + u = &u[..pos]; + } + u.trim_end_matches('/') +} + fn append_endpoint(base_url: &str, endpoint: &str) -> String { let base = base_url.trim(); if base.is_empty() { @@ -684,7 +752,7 @@ fn append_endpoint(base_url: &str, endpoint: &str) -> String { format!("{}/{}", base.trim_end_matches('/'), endpoint) } -fn resolve_request_url(base_url: &str, format: &str) -> String { +fn resolve_request_url(base_url: &str, format: &str, model_name: &str) -> String { let trimmed = base_url.trim().trim_end_matches('/').to_string(); if trimmed.is_empty() { return String::new(); @@ -696,7 +764,12 @@ fn resolve_request_url(base_url: &str, format: &str) -> String { match format { "anthropic" => append_endpoint(&trimmed, "v1/messages"), - "openai" => append_endpoint(&trimmed, "chat/completions"), + "openai" | "responses" => append_endpoint(&trimmed, "chat/completions"), + "gemini" => { + let base = gemini_installer_base_url(&trimmed); + let encoded = urlencoding::encode(model_name.trim()); + format!("{}/v1beta/models/{}:generateContent", base, encoded) + } _ => trimmed, } } @@ -748,6 +821,14 @@ fn build_request_headers(model: &ModelConfig, format: &str) -> Result String { async fn run_model_connection_test(model: &ModelConfig) -> Result, String> { let format = normalize_api_format(model); - let endpoint = resolve_request_url(&model.base_url, &format); + let endpoint = resolve_request_url(&model.base_url, &format, model.model_name.trim()); let headers = build_request_headers(model, &format)?; let custom_request_body = parse_custom_request_body(&model.custom_request_body)?; let mut payload = Map::new(); - payload.insert("model".to_string(), Value::String(model.model_name.trim().to_string())); if format == "anthropic" { + payload.insert("model".to_string(), Value::String(model.model_name.trim().to_string())); payload.insert("max_tokens".to_string(), Value::Number(16_u64.into())); payload.insert( "messages".to_string(), serde_json::json!([{ "role": "user", "content": "hello" }]), ); + } else if format == "gemini" { + payload.insert( + "contents".to_string(), + serde_json::json!([{ "parts": [{ "text": "hello" }] }]), + ); + let mut gen = Map::new(); + gen.insert("maxOutputTokens".to_string(), Value::Number(32_u64.into())); + payload.insert("generationConfig".to_string(), Value::Object(gen)); } else { + payload.insert("model".to_string(), Value::String(model.model_name.trim().to_string())); payload.insert("max_tokens".to_string(), Value::Number(16_u64.into())); payload.insert("temperature".to_string(), serde_json::json!(0.1)); payload.insert( @@ -843,6 +933,18 @@ async fn run_model_connection_test(model: &ModelConfig) -> Result .and_then(|item| item.get("text")) .and_then(|v| v.as_str()) .map(|s| s.to_string()) + } else if format == "gemini" { + parsed_json + .get("candidates") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|c| c.get("content")) + .and_then(|c| c.get("parts")) + .and_then(|p| p.as_array()) + .and_then(|arr| arr.first()) + .and_then(|part| part.get("text")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) } else { parsed_json .get("choices") @@ -958,6 +1060,21 @@ fn find_existing_ancestor(path: &Path) -> PathBuf { current } +/// Actual install root is always under a `BitFun` directory: `{user choice}/BitFun`. +/// If the user already chose a path whose last segment is `BitFun`, do not append again. +fn with_bitfun_install_subdir(path: PathBuf) -> PathBuf { + let already_bitfun = path + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.eq_ignore_ascii_case("BitFun")) + .unwrap_or(false); + if already_bitfun { + path + } else { + path.join("BitFun") + } +} + fn prepare_install_target(requested_path: &Path) -> Result { if !requested_path.is_absolute() { return Err("Installation path must be absolute".into()); @@ -967,10 +1084,11 @@ fn prepare_install_target(requested_path: &Path) -> Result { return Err("Refusing to install into a filesystem root directory".into()); } - #[cfg(target_os = "windows")] - let install_path = resolve_windows_install_target(requested_path)?; - #[cfg(not(target_os = "windows"))] - let install_path = requested_path.to_path_buf(); + if requested_path.exists() && !requested_path.is_dir() { + return Err("Path exists but is not a directory".into()); + } + + let install_path = with_bitfun_install_subdir(requested_path.to_path_buf()); if install_path.exists() { if !install_path.is_dir() { @@ -1009,43 +1127,6 @@ fn directory_has_entries(path: &Path) -> Result { Ok(entries.next().transpose().map_err(|e| e.to_string())?.is_some()) } -#[cfg(target_os = "windows")] -fn resolve_windows_install_target(requested_path: &Path) -> Result { - if requested_path.exists() && !requested_path.is_dir() { - return Err("Path exists but is not a directory".into()); - } - - let sensitive_dirs = [ - dirs::home_dir(), - dirs::desktop_dir(), - dirs::document_dir(), - dirs::download_dir(), - dirs::picture_dir(), - dirs::audio_dir(), - dirs::video_dir(), - dirs::data_local_dir(), - dirs::config_dir(), - ]; - - if sensitive_dirs - .into_iter() - .flatten() - .any(|sensitive_dir| windows_path_eq_case_insensitive(requested_path, &sensitive_dir)) - { - return Ok(requested_path.join("BitFun")); - } - - if requested_path.exists() - && directory_has_entries(requested_path)? - && !requested_path.join(INSTALL_MANIFEST_FILE).exists() - && !requested_path.join("BitFun.exe").exists() - { - return Ok(requested_path.join("BitFun")); - } - - Ok(requested_path.to_path_buf()) -} - fn ensure_app_config_path() -> Result { let config_root = dirs::config_dir() .ok_or_else(|| "Failed to get user config directory".to_string())? @@ -1152,15 +1233,15 @@ fn apply_first_launch_model(model: &ModelConfig) -> Result<(), String> { .map(|v| v.to_string()) .unwrap_or_else(|| format!("{} - {}", model.provider, model.model_name)); - let custom_request_body = parse_custom_request_body(&model.custom_request_body)?; - let api_format = normalize_api_format(model); - let request_url = resolve_request_url(model.base_url.trim(), &api_format); + let _ = parse_custom_request_body(&model.custom_request_body)?; + let stored_fmt = storage_format(model); + let request_url = resolve_stored_request_url(model.base_url.trim(), &stored_fmt); let mut model_map = Map::new(); model_map.insert("id".to_string(), Value::String(model_id.clone())); model_map.insert("name".to_string(), Value::String(display_name)); model_map.insert( "provider".to_string(), - Value::String(api_format), + Value::String(stored_fmt), ); model_map.insert( "model_name".to_string(), @@ -1191,6 +1272,7 @@ fn apply_first_launch_model(model: &ModelConfig) -> Result<(), String> { model_map.insert("metadata".to_string(), Value::Null); model_map.insert("enable_thinking_process".to_string(), Value::Bool(false)); model_map.insert("support_preserved_thinking".to_string(), Value::Bool(false)); + model_map.insert("inline_think_in_text".to_string(), Value::Bool(false)); if let Some(skip_ssl_verify) = model.skip_ssl_verify { model_map.insert("skip_ssl_verify".to_string(), Value::Bool(skip_ssl_verify)); @@ -1220,8 +1302,11 @@ fn apply_first_launch_model(model: &ModelConfig) -> Result<(), String> { } } } - if let Some(extra_body) = custom_request_body { - model_map.insert("custom_request_body".to_string(), Value::Object(extra_body)); + if let Some(raw) = &model.custom_request_body { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + model_map.insert("custom_request_body".to_string(), Value::String(trimmed.to_string())); + } } let model_json = Value::Object(model_map); diff --git a/BitFun-Installer/src-tauri/src/installer/mod.rs b/BitFun-Installer/src-tauri/src/installer/mod.rs index 39838904..137ecebc 100644 --- a/BitFun-Installer/src-tauri/src/installer/mod.rs +++ b/BitFun-Installer/src-tauri/src/installer/mod.rs @@ -1,5 +1,6 @@ pub mod commands; pub mod extract; +pub mod model_list; pub mod types; #[cfg(target_os = "windows")] diff --git a/BitFun-Installer/src-tauri/src/installer/model_list.rs b/BitFun-Installer/src-tauri/src/installer/model_list.rs new file mode 100644 index 00000000..fef9e119 --- /dev/null +++ b/BitFun-Installer/src-tauri/src/installer/model_list.rs @@ -0,0 +1,296 @@ +//! Remote model listing for installer (copied behavior from main app AI client; no bitfun_core dependency). +use crate::installer::types::{ModelConfig, RemoteModelInfo}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION}; +use serde::Deserialize; +use std::time::Duration; + +#[derive(Debug, Deserialize)] +struct OpenAIModelsResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct OpenAIModelEntry { + id: String, +} + +#[derive(Debug, Deserialize)] +struct AnthropicModelsResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct AnthropicModelEntry { + id: String, + #[serde(default)] + display_name: Option, +} + +#[derive(Debug, Deserialize)] +struct GeminiModelsResponse { + #[serde(default)] + models: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GeminiModelEntry { + name: String, + #[serde(default)] + display_name: Option, + #[serde(default)] + supported_generation_methods: Vec, +} + +fn normalize_base_url_for_discovery(base_url: &str) -> String { + base_url + .trim() + .trim_end_matches('#') + .trim_end_matches('/') + .to_string() +} + +fn resolve_openai_models_url(base_url: &str) -> String { + let mut base = normalize_base_url_for_discovery(base_url); + for suffix in ["/chat/completions", "/responses", "/models"] { + if base.ends_with(suffix) { + base.truncate(base.len() - suffix.len()); + break; + } + } + if base.is_empty() { + return "models".to_string(); + } + format!("{}/models", base) +} + +fn resolve_anthropic_models_url(base_url: &str) -> String { + let mut base = normalize_base_url_for_discovery(base_url); + if base.ends_with("/v1/messages") { + base.truncate(base.len() - "/v1/messages".len()); + return format!("{}/v1/models", base); + } + if base.ends_with("/v1/models") { + return base; + } + if base.ends_with("/v1") { + return format!("{}/models", base); + } + if base.is_empty() { + return "v1/models".to_string(); + } + format!("{}/v1/models", base) +} + +fn gemini_base_url(url: &str) -> &str { + let mut u = url; + if let Some(pos) = u.find("/v1beta") { + u = &u[..pos]; + } + if let Some(pos) = u.find("/models/") { + u = &u[..pos]; + } + u.trim_end_matches('/') +} + +fn resolve_gemini_models_url(base_url: &str) -> String { + let base = normalize_base_url_for_discovery(base_url); + let base = gemini_base_url(&base); + format!("{}/v1beta/models", base) +} + +fn dedupe_remote_models(models: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for m in models { + if seen.insert(m.id.clone()) { + out.push(m); + } + } + out +} + +fn list_format_for_dispatch(model: &ModelConfig) -> String { + let f = model.format.trim().to_ascii_lowercase(); + match f.as_str() { + "anthropic" => "anthropic".to_string(), + "gemini" | "google" => "gemini".to_string(), + "openai" | "response" | "responses" => "openai".to_string(), + _ => "openai".to_string(), + } +} + +fn build_list_headers(model: &ModelConfig, format: &str) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + match format { + "anthropic" => { + let api_key = HeaderValue::from_str(model.api_key.trim()) + .map_err(|_| "apiKey contains unsupported header characters".to_string())?; + headers.insert(HeaderName::from_static("x-api-key"), api_key); + headers.insert( + HeaderName::from_static("anthropic-version"), + HeaderValue::from_static("2023-06-01"), + ); + } + "gemini" => { + let api_key = HeaderValue::from_str(model.api_key.trim()) + .map_err(|_| "apiKey contains unsupported header characters".to_string())?; + headers.insert(HeaderName::from_static("x-goog-api-key"), api_key.clone()); + let bearer = format!("Bearer {}", model.api_key.trim()); + let auth = HeaderValue::from_str(&bearer) + .map_err(|_| "apiKey contains unsupported header characters".to_string())?; + headers.insert(AUTHORIZATION, auth); + } + _ => { + let bearer = format!("Bearer {}", model.api_key.trim()); + let auth = HeaderValue::from_str(&bearer) + .map_err(|_| "apiKey contains unsupported header characters".to_string())?; + headers.insert(AUTHORIZATION, auth); + } + } + Ok(headers) +} + +pub async fn list_remote_models(model: &ModelConfig) -> Result, String> { + let dispatch = list_format_for_dispatch(model); + match dispatch.as_str() { + "openai" => list_openai_models(model).await, + "anthropic" => list_anthropic_models(model).await, + "gemini" => list_gemini_models(model).await, + _ => Err(format!("Unsupported format for model listing: {}", dispatch)), + } +} + +async fn list_openai_models(model: &ModelConfig) -> Result, String> { + let url = resolve_openai_models_url(&model.base_url); + let headers = build_list_headers(model, "openai")?; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .danger_accept_invalid_certs(model.skip_ssl_verify.unwrap_or(false)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let response = client + .get(&url) + .headers(headers) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("Failed to read response body: {}", e))?; + if !status.is_success() { + return Err(format!("HTTP {}: {}", status.as_u16(), body.chars().take(400).collect::())); + } + + let payload: OpenAIModelsResponse = serde_json::from_str(&body) + .map_err(|e| format!("Invalid OpenAI models response: {}", e))?; + + Ok(dedupe_remote_models( + payload + .data + .into_iter() + .map(|m| RemoteModelInfo { + id: m.id, + display_name: None, + }) + .collect(), + )) +} + +async fn list_anthropic_models(model: &ModelConfig) -> Result, String> { + let url = resolve_anthropic_models_url(&model.base_url); + let headers = build_list_headers(model, "anthropic")?; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .danger_accept_invalid_certs(model.skip_ssl_verify.unwrap_or(false)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let response = client + .get(&url) + .headers(headers) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("Failed to read response body: {}", e))?; + if !status.is_success() { + return Err(format!("HTTP {}: {}", status.as_u16(), body.chars().take(400).collect::())); + } + + let payload: AnthropicModelsResponse = serde_json::from_str(&body) + .map_err(|e| format!("Invalid Anthropic models response: {}", e))?; + + Ok(dedupe_remote_models( + payload + .data + .into_iter() + .map(|m| RemoteModelInfo { + id: m.id, + display_name: m.display_name, + }) + .collect(), + )) +} + +async fn list_gemini_models(model: &ModelConfig) -> Result, String> { + let url = resolve_gemini_models_url(&model.base_url); + let headers = build_list_headers(model, "gemini")?; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .danger_accept_invalid_certs(model.skip_ssl_verify.unwrap_or(false)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let response = client + .get(&url) + .headers(headers) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("Failed to read response body: {}", e))?; + if !status.is_success() { + return Err(format!("HTTP {}: {}", status.as_u16(), body.chars().take(400).collect::())); + } + + let payload: GeminiModelsResponse = serde_json::from_str(&body) + .map_err(|e| format!("Invalid Gemini models response: {}", e))?; + + Ok(dedupe_remote_models( + payload + .models + .into_iter() + .filter(|m| { + m.supported_generation_methods.is_empty() + || m.supported_generation_methods + .iter() + .any(|method| method == "generateContent") + }) + .map(|m| { + let id = m + .name + .strip_prefix("models/") + .unwrap_or(&m.name) + .to_string(); + RemoteModelInfo { + id, + display_name: m.display_name, + } + }) + .collect(), + )) +} diff --git a/BitFun-Installer/src-tauri/src/installer/types.rs b/BitFun-Installer/src-tauri/src/installer/types.rs index 5e8080f1..ba9c58a6 100644 --- a/BitFun-Installer/src-tauri/src/installer/types.rs +++ b/BitFun-Installer/src-tauri/src/installer/types.rs @@ -19,7 +19,7 @@ pub struct InstallOptions { pub launch_after_install: bool, /// First-launch app language (zh-CN / en-US) pub app_language: String, - /// First-launch theme preference (BitFun built-in theme id) + /// First-launch theme preference (`system` or BitFun built-in theme id) pub theme_preference: String, /// Optional first-launch model configuration. pub model_config: Option, @@ -46,6 +46,15 @@ pub struct ModelConfig { pub custom_headers_mode: Option, } +/// One entry from provider model discovery (installer-local; mirrors main app shape). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteModelInfo { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConnectionTestResult { @@ -93,7 +102,7 @@ impl Default for InstallOptions { add_to_path: true, launch_after_install: true, app_language: "zh-CN".to_string(), - theme_preference: "bitfun-light".to_string(), + theme_preference: "system".to_string(), model_config: None, } } diff --git a/BitFun-Installer/src-tauri/src/lib.rs b/BitFun-Installer/src-tauri/src/lib.rs index e6c66b3d..d7334697 100644 --- a/BitFun-Installer/src-tauri/src/lib.rs +++ b/BitFun-Installer/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ pub fn run() { commands::start_installation, commands::set_model_config, commands::test_model_config_connection, + commands::list_model_config_models, commands::set_theme_preference, commands::uninstall, commands::launch_application, diff --git a/BitFun-Installer/src/App.tsx b/BitFun-Installer/src/App.tsx index ab7fea64..0d92b985 100644 --- a/BitFun-Installer/src/App.tsx +++ b/BitFun-Installer/src/App.tsx @@ -7,6 +7,7 @@ import { ProgressPage } from './pages/Progress'; import { ThemeSetup } from './pages/ThemeSetup'; import { UninstallPage } from './pages/Uninstall'; import { useInstaller } from './hooks/useInstaller'; +import { useSyncInstallerRootTheme } from './theme/installerThemeRuntime'; import './styles/global.css'; const STEP_NUMBERS: Record = { @@ -18,6 +19,7 @@ const STEP_NUMBERS: Record = { function App() { const installer = useInstaller(); + useSyncInstallerRootTheme(installer.options.themePreference); const { t, i18n } = useTranslation(); const handleLanguageSelect = (lang: string) => { diff --git a/BitFun-Installer/src/data/modelProviders.ts b/BitFun-Installer/src/data/modelProviders.ts index 86cff759..bb93fcdc 100644 --- a/BitFun-Installer/src/data/modelProviders.ts +++ b/BitFun-Installer/src/data/modelProviders.ts @@ -1,6 +1,7 @@ import type { ModelConfig } from '../types/installer'; -export type ApiFormat = 'openai' | 'anthropic'; +/** Matches main app `src/web-ui/.../modelConfigs.ts` ApiFormat for presets. */ +export type ApiFormat = 'openai' | 'anthropic' | 'gemini' | 'responses'; export interface ProviderUrlOption { url: string; @@ -19,20 +20,40 @@ export interface ProviderTemplate { baseUrlOptions?: ProviderUrlOption[]; } +/** Same order as `AIModelConfig.tsx` `providerOrder`. */ export const PROVIDER_DISPLAY_ORDER: string[] = [ + 'openbitfun', 'zhipu', 'qwen', 'deepseek', 'volcengine', - 'siliconflow', - 'nvidia', - 'openrouter', 'minimax', 'moonshot', + 'gemini', 'anthropic', + 'siliconflow', + 'nvidia', + 'openrouter', ]; export const PROVIDER_TEMPLATES: Record = { + openbitfun: { + id: 'openbitfun', + nameKey: 'model.providers.openbitfun.name', + descriptionKey: 'model.providers.openbitfun.description', + baseUrl: 'https://api.openbitfun.com', + format: 'anthropic', + models: [], + }, + gemini: { + id: 'gemini', + nameKey: 'model.providers.gemini.name', + descriptionKey: 'model.providers.gemini.description', + baseUrl: 'https://generativelanguage.googleapis.com', + format: 'gemini', + models: ['gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview'], + helpUrl: 'https://aistudio.google.com/app/apikey', + }, anthropic: { id: 'anthropic', nameKey: 'model.providers.anthropic.name', @@ -48,7 +69,7 @@ export const PROVIDER_TEMPLATES: Record = { descriptionKey: 'model.providers.minimax.description', baseUrl: 'https://api.minimaxi.com/anthropic', format: 'anthropic', - models: ['MiniMax-M2.5', 'MiniMax-M2.1'], + models: ['MiniMax-M2.7-highspeed', 'MiniMax-M2.5-highspeed'], helpUrl: 'https://platform.minimax.io/', baseUrlOptions: [ { diff --git a/BitFun-Installer/src/data/modelRequestFormats.ts b/BitFun-Installer/src/data/modelRequestFormats.ts new file mode 100644 index 00000000..3b031f8c --- /dev/null +++ b/BitFun-Installer/src/data/modelRequestFormats.ts @@ -0,0 +1,2 @@ +/** Values match `ModelConfig.format` / settings request format. Labels come from i18n `model.formats.*`. */ +export type RequestFormatValue = 'openai' | 'responses' | 'anthropic' | 'gemini'; diff --git a/BitFun-Installer/src/i18n/locales/en.json b/BitFun-Installer/src/i18n/locales/en.json index 4d1427e7..f242015d 100644 --- a/BitFun-Installer/src/i18n/locales/en.json +++ b/BitFun-Installer/src/i18n/locales/en.json @@ -40,15 +40,15 @@ "back": "Back", "skip": "Skip for now", "nextTheme": "Next: Theme", - "description": "Configuring an AI model is required to use BitFun. Select a provider and enter your API information", - "providerLabel": "Model Provider", - "selectProvider": "Select a model provider...", - "customProvider": "Custom", - "getApiKey": "How to get an API Key?", + "description": "Configure and manage AI model providers", + "providerLabel": "Select Model Provider", + "selectProvider": "or select a preset provider", + "customProvider": "Custom Configuration", + "getApiKey": "Get API Key", "modelNamePlaceholder": "Enter model name...", "baseUrlPlaceholder": "e.g., https://open.bigmodel.cn/api/paas/v4/chat/completions", "customRequestBodyPlaceholder": "{\n \"temperature\": 0.8,\n \"top_p\": 0.9\n}", - "jsonValid": "Valid JSON format", + "jsonValid": "JSON format is valid", "jsonInvalid": "Invalid JSON format, please check syntax", "skipSslVerify": "Skip SSL Certificate Verification", "customHeadersModeMerge": "Merge Override", @@ -126,14 +126,38 @@ "description": "OpenRouter Model Platform" } }, - "modelNameSelectPlaceholder": "Select a model...", - "customModel": "Use custom model name", + "modelNameSelectPlaceholder": "Select model", + "customModel": "Press Enter to use", "testConnection": "Test Connection", "testing": "Testing...", - "testSuccess": "Connection successful", - "testFailed": "Connection failed", - "modelSearchPlaceholder": "Search or enter a custom model name...", - "modelNoResults": "No matching models" + "testSuccess": "Test successful", + "testFailed": "Test failed", + "modelSearchPlaceholder": "Search or enter model name...", + "modelNoResults": "No matching models", + "fillApiKeyBeforeFetch": "Enter the API key before fetching models", + "fetchingModels": "Fetching model list...", + "fetchFailedFallback": "Failed to fetch model list, fell back to common preset models", + "fetchEmptyFallback": "Provider returned no models, fell back to common preset models", + "usingPresetModels": "Currently showing common preset models", + "addCustomModel": "Add Custom Model", + "form": { + "baseUrl": "API URL", + "apiKey": "API Key", + "apiKeyPlaceholder": "Enter your API Key", + "provider": "Request Format", + "providerPlaceholder": "Select request format", + "modelSelection": "Select Models", + "modelName": "Model Name", + "resolvedUrlLabel": "Request URL: " + }, + "formats": { + "openaiCompatible": "OpenAI Compatible", + "responsesApi": "OpenAI Responses API", + "claudeApi": "Claude API", + "geminiApi": "Gemini GenerateContent API" + }, + "showSecret": "Show", + "hideSecret": "Hide" }, "progress": { "title": "Installing", @@ -153,6 +177,7 @@ "themeSetup": { "title": "Theme & Launch", "subtitle": "Choose your startup theme, then launch", + "followSystem": "Match system", "skip": "Skip theme and launch", "themeNames": { "bitfun-dark": "Dark", diff --git a/BitFun-Installer/src/i18n/locales/zh.json b/BitFun-Installer/src/i18n/locales/zh.json index 6b54c7ca..01c85e2b 100644 --- a/BitFun-Installer/src/i18n/locales/zh.json +++ b/BitFun-Installer/src/i18n/locales/zh.json @@ -40,16 +40,16 @@ "back": "返回", "skip": "稍后配置", "nextTheme": "下一步:主题", - "description": "配置 AI 模型是使用 BitFun 的前提,请选择模型服务商并填写 API 信息", - "providerLabel": "模型服务商", - "selectProvider": "选择模型服务商...", - "customProvider": "自定义", - "getApiKey": "如何获取 API Key?", + "description": "配置和管理 AI 模型提供商", + "providerLabel": "选择模型提供商", + "selectProvider": "或选择预设提供商", + "customProvider": "自定义配置", + "getApiKey": "获取 API Key", "modelNamePlaceholder": "输入自定义模型名称...", "baseUrlPlaceholder": "示例:https://open.bigmodel.cn/api/paas/v4/chat/completions", "customRequestBodyPlaceholder": "{\n \"temperature\": 0.8,\n \"top_p\": 0.9\n}", - "jsonValid": "JSON 格式有效", - "jsonInvalid": "JSON 格式错误,请检查语法", + "jsonValid": "JSON格式有效", + "jsonInvalid": "JSON格式错误,请检查语法", "skipSslVerify": "跳过SSL证书验证", "customHeadersModeMerge": "合并覆盖", "customHeadersModeReplace": "完全替换", @@ -126,14 +126,38 @@ "description": "OpenRouter 大模型平台" } }, - "modelNameSelectPlaceholder": "选择模型...", - "customModel": "使用自定义模型名称", + "modelNameSelectPlaceholder": "选择模型", + "customModel": "按 Enter 使用", "testConnection": "测试连接", "testing": "测试中...", - "testSuccess": "连接成功", - "testFailed": "连接失败", - "modelSearchPlaceholder": "搜索或输入自定义模型名称...", - "modelNoResults": "没有匹配的模型" + "testSuccess": "测试成功", + "testFailed": "测试失败", + "modelSearchPlaceholder": "搜索或输入模型名称...", + "modelNoResults": "没有匹配的模型", + "fillApiKeyBeforeFetch": "请先填写 API Key 再获取模型列表", + "fetchingModels": "正在拉取模型列表...", + "fetchFailedFallback": "拉取模型列表失败,已回退到常用预设模型", + "fetchEmptyFallback": "供应商未返回可用模型,已回退到常用预设模型", + "usingPresetModels": "当前显示的是常用预设模型", + "addCustomModel": "添加自定义模型", + "form": { + "baseUrl": "API地址", + "apiKey": "API密钥", + "apiKeyPlaceholder": "输入您的 API Key", + "provider": "请求格式", + "providerPlaceholder": "选择请求格式", + "modelSelection": "选择模型", + "modelName": "模型名称", + "resolvedUrlLabel": "实际请求地址:" + }, + "formats": { + "openaiCompatible": "OpenAI 兼容", + "responsesApi": "OpenAI Responses API", + "claudeApi": "Claude API", + "geminiApi": "Gemini GenerateContent API" + }, + "showSecret": "显示", + "hideSecret": "隐藏" }, "progress": { "title": "安装中", @@ -153,6 +177,7 @@ "themeSetup": { "title": "主题与启动", "subtitle": "选择首次启动主题,然后开始使用", + "followSystem": "跟随系统", "skip": "跳过主题并启动", "themeNames": { "bitfun-dark": "暗色", diff --git a/BitFun-Installer/src/pages/ModelSetup.tsx b/BitFun-Installer/src/pages/ModelSetup.tsx index 509f39c4..07cf3217 100644 --- a/BitFun-Installer/src/pages/ModelSetup.tsx +++ b/BitFun-Installer/src/pages/ModelSetup.tsx @@ -1,3 +1,4 @@ +import { invoke } from '@tauri-apps/api/core'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -8,7 +9,9 @@ import { type ApiFormat, type ProviderTemplate, } from '../data/modelProviders'; -import type { ConnectionTestResult, InstallOptions, ModelConfig } from '../types/installer'; +import type { RequestFormatValue } from '../data/modelRequestFormats'; +import type { ConnectionTestResult, InstallOptions, ModelConfig, RemoteModelInfo } from '../types/installer'; +import { previewRequestUrl, resolveRequestUrl } from '../utils/modelRequestUrl'; type TestStatus = 'idle' | 'testing' | 'success' | 'error'; const CUSTOM_MODEL_OPTION = '__custom_model__'; @@ -32,10 +35,8 @@ interface SimpleSelectProps { options: SelectOption[]; placeholder: string; onChange: (value: string) => void; - searchable?: boolean; - searchPlaceholder?: string; - emptyText?: string; - alwaysVisibleValues?: string[]; + onOpenChange?: (open: boolean) => void; + disabled?: boolean; } function SimpleSelect({ @@ -43,25 +44,12 @@ function SimpleSelect({ options, placeholder, onChange, - searchable = false, - searchPlaceholder, - emptyText, - alwaysVisibleValues = [], + onOpenChange, + disabled = false, }: SimpleSelectProps) { const [open, setOpen] = useState(false); - const [search, setSearch] = useState(''); const rootRef = useRef(null); const selected = useMemo(() => options.find((item) => item.value === value) || null, [options, value]); - const filteredOptions = useMemo(() => { - if (!searchable || !search.trim()) return options; - const keyword = search.trim().toLowerCase(); - return options.filter((item) => { - if (alwaysVisibleValues.includes(item.value)) return true; - const label = item.label.toLowerCase(); - const desc = item.description?.toLowerCase() || ''; - return label.includes(keyword) || desc.includes(keyword); - }); - }, [options, search, searchable, alwaysVisibleValues]); useEffect(() => { if (!open) return; @@ -69,21 +57,26 @@ function SimpleSelect({ if (!rootRef.current) return; if (!rootRef.current.contains(event.target as Node)) { setOpen(false); + onOpenChange?.(false); } }; document.addEventListener('pointerdown', onPointerDown); return () => document.removeEventListener('pointerdown', onPointerDown); - }, [open]); + }, [open, onOpenChange]); return (
)) ) : ( -
{emptyText || 'No results'}
+
)}
)} @@ -132,6 +115,15 @@ function SimpleSelect({ ); } +function FieldRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnection }: ModelSetupProps) { const { t } = useTranslation(); const providers = useMemo(() => getOrderedProviders(), []); @@ -139,11 +131,16 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti const [selectedProviderId, setSelectedProviderId] = useState(current?.provider || ''); const [apiKey, setApiKey] = useState(current?.apiKey || ''); + const [showApiKey, setShowApiKey] = useState(false); const [baseUrl, setBaseUrl] = useState(current?.baseUrl || ''); const [modelName, setModelName] = useState(current?.modelName || ''); + const [apiFormat, setApiFormat] = useState((current?.format as ApiFormat) || 'openai'); const [customFormat, setCustomFormat] = useState((current?.format as ApiFormat) || 'openai'); const [forceCustomModelInput, setForceCustomModelInput] = useState(false); + const [remoteModels, setRemoteModels] = useState([]); + const [isFetchingRemoteModels, setIsFetchingRemoteModels] = useState(false); + const [remoteModelsError, setRemoteModelsError] = useState(null); const [testStatus, setTestStatus] = useState('idle'); const [testMessage, setTestMessage] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); @@ -154,6 +151,11 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti return PROVIDER_TEMPLATES[selectedProviderId] || null; }, [selectedProviderId]); + const defaultProviderLabel = useMemo(() => { + if (!template) return t('model.customProvider'); + return t(template.nameKey, { defaultValue: template.id }); + }, [template, t]); + const effectiveBaseUrl = useMemo(() => { if (isCustomProvider) return baseUrl.trim(); if (baseUrl.trim()) return baseUrl.trim(); @@ -165,38 +167,31 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti return template?.models[0] || ''; }, [modelName, template]); - const effectiveFormat = useMemo(() => { + const resolvedApiFormat = useMemo(() => { if (isCustomProvider || !template) return customFormat; - return resolveProviderFormat(template, effectiveBaseUrl); - }, [isCustomProvider, template, customFormat, effectiveBaseUrl]); + return apiFormat; + }, [isCustomProvider, template, customFormat, apiFormat]); + + const previewResolvedUrl = useMemo( + () => previewRequestUrl(effectiveBaseUrl, resolvedApiFormat), + [effectiveBaseUrl, resolvedApiFormat], + ); const draftModelConfig = useMemo(() => { if (!selectedProviderId) return null; - - const providerDisplayName = template - ? t(template.nameKey, { defaultValue: template.id }) - : t('model.customProvider', { defaultValue: 'Custom' }); - const configName = `${providerDisplayName} - ${effectiveModelName}`.trim(); - return { provider: selectedProviderId, apiKey, baseUrl: effectiveBaseUrl, modelName: effectiveModelName, - format: effectiveFormat, - configName, + format: resolvedApiFormat, + configName: defaultProviderLabel, }; - }, [ - selectedProviderId, - template, - apiKey, - effectiveBaseUrl, - effectiveModelName, - effectiveFormat, - t, - ]); - - const canContinue = Boolean(selectedProviderId && apiKey.trim() && effectiveBaseUrl && effectiveModelName); + }, [selectedProviderId, apiKey, effectiveBaseUrl, effectiveModelName, resolvedApiFormat, defaultProviderLabel]); + + const canContinue = Boolean( + selectedProviderId && apiKey.trim() && effectiveBaseUrl && effectiveModelName && draftModelConfig, + ); const canTestConnection = canContinue && testStatus !== 'testing'; @@ -212,41 +207,88 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti setTestMessage(''); }, []); - const handleProviderSelect = useCallback((providerId: string) => { - resetTestState(); - setSelectedProviderId(providerId); - setForceCustomModelInput(false); - if (providerId === 'custom') { - setBaseUrl(''); - setModelName(''); - setCustomFormat('openai'); + const resetRemoteDiscovery = useCallback(() => { + setRemoteModels([]); + setRemoteModelsError(null); + }, []); + + const fetchRemoteModels = useCallback(async () => { + if (!draftModelConfig || !apiKey.trim()) { + setRemoteModelsError(t('model.fillApiKeyBeforeFetch')); return; } - const nextTemplate = PROVIDER_TEMPLATES[providerId]; - if (!nextTemplate) return; - const next = createModelConfigFromTemplate(nextTemplate, null); - setBaseUrl(next.baseUrl); - setModelName(next.modelName); - setCustomFormat(next.format); - }, [resetTestState]); + setIsFetchingRemoteModels(true); + setRemoteModelsError(null); + try { + const list = await invoke('list_model_config_models', { + modelConfig: draftModelConfig, + }); + setRemoteModels(list); + if (list.length === 0) { + setRemoteModelsError(t('model.fetchEmptyFallback')); + } + } catch { + setRemoteModels([]); + setRemoteModelsError(t('model.fetchFailedFallback')); + } finally { + setIsFetchingRemoteModels(false); + } + }, [draftModelConfig, apiKey, t]); + + const handleProviderSelect = useCallback( + (providerId: string) => { + resetTestState(); + resetRemoteDiscovery(); + setSelectedProviderId(providerId); + setForceCustomModelInput(false); + if (providerId === 'custom') { + setBaseUrl(''); + setModelName(''); + setCustomFormat('openai'); + setApiFormat('openai'); + return; + } + const nextTemplate = PROVIDER_TEMPLATES[providerId]; + if (!nextTemplate) return; + const next = createModelConfigFromTemplate(nextTemplate, null); + setBaseUrl(next.baseUrl); + setModelName(next.modelName); + setApiFormat(resolveProviderFormat(nextTemplate, next.baseUrl)); + setCustomFormat(next.format); + }, + [resetTestState, resetRemoteDiscovery], + ); + + const handleBaseUrlOptionSelect = useCallback( + (url: string) => { + setBaseUrl(url); + resetTestState(); + resetRemoteDiscovery(); + if (template?.baseUrlOptions) { + const opt = template.baseUrlOptions.find((o) => o.url === url.trim()); + if (opt) setApiFormat(opt.format); + } + }, + [template, resetTestState, resetRemoteDiscovery], + ); const handleTestConnection = useCallback(async () => { if (!draftModelConfig || !canTestConnection) return; setTestStatus('testing'); - setTestMessage(t('model.testing', { defaultValue: 'Testing...' })); + setTestMessage(t('model.testing')); try { const result = await onTestConnection(draftModelConfig); if (result.success) { setTestStatus('success'); - setTestMessage(t('model.testSuccess', { defaultValue: 'Connection successful' })); + setTestMessage(t('model.testSuccess')); } else { setTestStatus('error'); - setTestMessage(result.errorDetails || t('model.testFailed', { defaultValue: 'Connection failed' })); + setTestMessage(result.errorDetails || t('model.testFailed')); } } catch (error) { const message = error instanceof Error ? error.message : String(error); setTestStatus('error'); - setTestMessage(message || t('model.testFailed', { defaultValue: 'Connection failed' })); + setTestMessage(message || t('model.testFailed')); } }, [draftModelConfig, canTestConnection, onTestConnection, t]); @@ -265,7 +307,7 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti const providerOptions = useMemo(() => { return [ - { value: 'custom', label: t('model.customProvider', { defaultValue: 'Custom' }) }, + { value: 'custom', label: t('model.customProvider') }, ...providers.map((provider) => ({ value: provider.id, label: t(provider.nameKey, { defaultValue: provider.id }), @@ -278,168 +320,216 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti return template.baseUrlOptions.map((opt) => ({ value: opt.url, label: opt.url, - description: `${opt.format.toUpperCase()} / ${opt.noteKey ? t(opt.noteKey, { defaultValue: 'default' }) : 'default'}`, + description: `${opt.format.toUpperCase()} · ${opt.noteKey ? t(opt.noteKey) : ''}`, })); }, [template, t]); + const formatSelectOptions = useMemo( + () => [ + { value: 'openai', label: t('model.formats.openaiCompatible') }, + { value: 'responses', label: t('model.formats.responsesApi') }, + { value: 'anthropic', label: t('model.formats.claudeApi') }, + { value: 'gemini', label: t('model.formats.geminiApi') }, + ], + [t], + ); + + const mergedModelIds = useMemo(() => { + const preset = template?.models ?? []; + const remoteIds = remoteModels.map((m) => m.id); + return [...new Set([...preset, ...remoteIds])]; + }, [template, remoteModels]); + const modelOptions = useMemo(() => { - if (!template) return []; + if (!template && !isCustomProvider) return []; + if (isCustomProvider) { + return []; + } return [ - ...template.models.map((item) => ({ value: item, label: item })), + ...mergedModelIds.map((id) => { + const dn = remoteModels.find((m) => m.id === id)?.displayName; + return { + value: id, + label: dn ? `${id} (${dn})` : id, + }; + }), { value: CUSTOM_MODEL_OPTION, - label: t('model.customModel', { defaultValue: 'Use custom model name' }), + label: t('model.addCustomModel'), }, ]; - }, [template, t]); + }, [template, isCustomProvider, mergedModelIds, remoteModels, t]); const modelSelectionValue = useMemo(() => { if (!template) return ''; if (forceCustomModelInput) return CUSTOM_MODEL_OPTION; const trimmed = modelName.trim(); - if (!trimmed) return template.models[0] || ''; - if (template.models.includes(trimmed)) return trimmed; + if (!trimmed) return mergedModelIds[0] || CUSTOM_MODEL_OPTION; + if (mergedModelIds.includes(trimmed)) return trimmed; return CUSTOM_MODEL_OPTION; - }, [template, modelName, forceCustomModelInput]); - - const customFormatOptions: SelectOption[] = [ - { value: 'openai', label: 'OpenAI Compatible' }, - { value: 'anthropic', label: 'Anthropic' }, - ]; + }, [template, modelName, forceCustomModelInput, mergedModelIds]); + + const modelFetchHint = useMemo(() => { + if (isFetchingRemoteModels) return t('model.fetchingModels'); + if (remoteModelsError) return remoteModelsError; + if (remoteModels.length > 0) return null; + if (template?.models?.length) return t('model.usingPresetModels'); + return null; + }, [isFetchingRemoteModels, remoteModelsError, remoteModels.length, template, t]); + + const storedRequestUrlReadonly = useMemo( + () => resolveRequestUrl(effectiveBaseUrl, resolvedApiFormat, effectiveModelName), + [effectiveBaseUrl, resolvedApiFormat, effectiveModelName], + ); return (
-
- {t('model.subtitle')} -
-
- {t('model.description', { defaultValue: 'Configure AI model provider and API key.' })} -
- -
{t('model.providerLabel', { defaultValue: 'Model Provider' })}
- - - {template && ( -
- {t(template.descriptionKey, { defaultValue: '' })} -
- )} +
{t('model.subtitle')}
+
{t('model.description')}
+ + + + + + {template &&
{t(template.descriptionKey)}
} {!!selectedProviderId && (
- {template ? ( - <> - { - if (next === CUSTOM_MODEL_OPTION) { - setForceCustomModelInput(true); - if (template.models.includes(modelName.trim())) { - setModelName(''); - } - resetTestState(); - return; - } - setForceCustomModelInput(false); - setModelName(next); + +
+ { + setApiKey(e.target.value); resetTestState(); + resetRemoteDiscovery(); }} /> - {(forceCustomModelInput || (modelName.trim() && !template.models.includes(modelName.trim()))) && ( - { - setModelName(e.target.value); - resetTestState(); - }} + +
+
+ + +
+ {baseUrlOptions.length > 0 ? ( + o.url === effectiveBaseUrl) ? effectiveBaseUrl : ''} + options={baseUrlOptions} + placeholder={t('model.baseUrlPlaceholder')} + onChange={(next) => handleBaseUrlOptionSelect(next)} /> - )} - - ) : ( - { - setModelName(e.target.value); - resetTestState(); - }} - /> - )} + ) : null} + { + setBaseUrl(e.target.value); + resetTestState(); + resetRemoteDiscovery(); + if (template && !isCustomProvider) { + setApiFormat(resolveProviderFormat(template, e.target.value)); + } + }} + /> +
+
- {baseUrlOptions.length > 0 ? ( - { - setBaseUrl(next); - resetTestState(); - }} - /> - ) : ( - { - setBaseUrl(e.target.value); - resetTestState(); - }} - /> + {!!effectiveBaseUrl && ( + + + )} - { - setApiKey(e.target.value); - resetTestState(); - }} - /> - - {isCustomProvider && ( + { - setCustomFormat((next as ApiFormat) || 'openai'); + const v = next as RequestFormatValue; + if (isCustomProvider) setCustomFormat(v); + else setApiFormat(v); resetTestState(); + resetRemoteDiscovery(); }} /> - )} + + + + {template ? ( +
+ { + if (open) void fetchRemoteModels(); + }} + onChange={(next) => { + if (next === CUSTOM_MODEL_OPTION) { + setForceCustomModelInput(true); + if (mergedModelIds.includes(modelName.trim())) { + setModelName(''); + } + resetTestState(); + return; + } + setForceCustomModelInput(false); + setModelName(next); + resetTestState(); + }} + /> + {(forceCustomModelInput || (modelName.trim() && !mergedModelIds.includes(modelName.trim()))) && ( + { + setModelName(e.target.value); + resetTestState(); + }} + /> + )} +
+ ) : ( + { + setModelName(e.target.value); + resetTestState(); + }} + /> + )} +
+ + {modelFetchHint &&
{modelFetchHint}
}
)} {!!selectedProviderId && (
- {testStatus === 'success' && ( - {testMessage} - )} - {testStatus === 'error' && ( - {testMessage} - )} + {testStatus === 'success' && {testMessage}} + {testStatus === 'error' && {testMessage}}
)}
diff --git a/BitFun-Installer/src/pages/Options.tsx b/BitFun-Installer/src/pages/Options.tsx index a40dca52..8b28fcc0 100644 --- a/BitFun-Installer/src/pages/Options.tsx +++ b/BitFun-Installer/src/pages/Options.tsx @@ -1,8 +1,9 @@ import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { invoke } from '@tauri-apps/api/core'; import { open } from '@tauri-apps/plugin-dialog'; import { Checkbox } from '../components/Checkbox'; -import type { InstallOptions, DiskSpaceInfo } from '../types/installer'; +import type { InstallOptions, DiskSpaceInfo, InstallPathValidation } from '../types/installer'; interface OptionsProps { options: InstallOptions; @@ -36,7 +37,14 @@ export function Options({ title: t('options.pathLabel'), }); if (selected && typeof selected === 'string') { - setOptions((prev) => ({ ...prev, installPath: selected })); + try { + const validated = await invoke('validate_install_path', { + path: selected, + }); + setOptions((prev) => ({ ...prev, installPath: validated.installPath })); + } catch { + setOptions((prev) => ({ ...prev, installPath: selected })); + } } }; diff --git a/BitFun-Installer/src/pages/ThemeSetup.tsx b/BitFun-Installer/src/pages/ThemeSetup.tsx index fa293cad..19e56cb2 100644 --- a/BitFun-Installer/src/pages/ThemeSetup.tsx +++ b/BitFun-Installer/src/pages/ThemeSetup.tsx @@ -1,168 +1,10 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { invoke } from '@tauri-apps/api/core'; import { Checkbox } from '../components/Checkbox'; -import type { InstallOptions, ThemeId } from '../types/installer'; - -type InstallerTheme = { - id: ThemeId; - name: string; - type: 'dark' | 'light'; - colors: { - background: { - primary: string; - secondary: string; - tertiary: string; - quaternary: string; - elevated: string; - workbench: string; - flowchat: string; - tooltip: string; - }; - text: { - primary: string; - secondary: string; - muted: string; - disabled: string; - }; - accent: Record<'50' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800', string>; - purple: Record<'50' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800', string>; - semantic: { - success: string; - warning: string; - error: string; - info: string; - highlight: string; - highlightBg: string; - }; - border: { - subtle: string; - base: string; - medium: string; - strong: string; - prominent: string; - }; - element: { - subtle: string; - soft: string; - base: string; - medium: string; - strong: string; - elevated: string; - }; - }; -}; - -const THEMES: InstallerTheme[] = [ - { - id: 'bitfun-dark', - name: 'Dark', - type: 'dark', - colors: { - background: { primary: '#121214', secondary: '#18181a', tertiary: '#121214', quaternary: '#202024', elevated: '#18181a', workbench: '#121214', flowchat: '#121214', tooltip: 'rgba(30, 30, 32, 0.92)' }, - text: { primary: '#e8e8e8', secondary: '#b0b0b0', muted: '#858585', disabled: '#555555' }, - accent: { '50': 'rgba(96, 165, 250, 0.04)', '100': 'rgba(96, 165, 250, 0.08)', '200': 'rgba(96, 165, 250, 0.15)', '300': 'rgba(96, 165, 250, 0.25)', '400': 'rgba(96, 165, 250, 0.4)', '500': '#60a5fa', '600': '#3b82f6', '700': 'rgba(59, 130, 246, 0.8)', '800': 'rgba(59, 130, 246, 0.9)' }, - purple: { '50': 'rgba(139, 92, 246, 0.04)', '100': 'rgba(139, 92, 246, 0.08)', '200': 'rgba(139, 92, 246, 0.15)', '300': 'rgba(139, 92, 246, 0.25)', '400': 'rgba(139, 92, 246, 0.4)', '500': '#8b5cf6', '600': '#7c3aed', '700': 'rgba(124, 58, 237, 0.8)', '800': 'rgba(124, 58, 237, 0.9)' }, - semantic: { success: '#34d399', warning: '#f59e0b', error: '#ef4444', info: '#E1AB80', highlight: '#d4a574', highlightBg: 'rgba(212, 165, 116, 0.15)' }, - border: { subtle: 'rgba(255, 255, 255, 0.12)', base: 'rgba(255, 255, 255, 0.18)', medium: 'rgba(255, 255, 255, 0.24)', strong: 'rgba(255, 255, 255, 0.32)', prominent: 'rgba(225, 171, 128, 0.50)' }, - element: { subtle: 'rgba(255, 255, 255, 0.06)', soft: 'rgba(255, 255, 255, 0.10)', base: 'rgba(255, 255, 255, 0.13)', medium: 'rgba(255, 255, 255, 0.17)', strong: 'rgba(255, 255, 255, 0.21)', elevated: 'rgba(255, 255, 255, 0.25)' }, - }, - }, - { - id: 'bitfun-light', - name: 'Light', - type: 'light', - colors: { - background: { primary: '#f7f8fa', secondary: '#ffffff', tertiary: '#f3f5f8', quaternary: '#ebeef3', elevated: '#ffffff', workbench: '#f7f8fa', flowchat: '#f7f8fa', tooltip: 'rgba(255, 255, 255, 0.98)' }, - text: { primary: '#1e293b', secondary: '#3d4f66', muted: '#64748b', disabled: '#94a3b8' }, - accent: { '50': 'rgba(71, 102, 143, 0.04)', '100': 'rgba(71, 102, 143, 0.08)', '200': 'rgba(71, 102, 143, 0.14)', '300': 'rgba(71, 102, 143, 0.22)', '400': 'rgba(71, 102, 143, 0.36)', '500': '#5a7bb2', '600': '#4a6694', '700': 'rgba(74, 102, 148, 0.8)', '800': 'rgba(74, 102, 148, 0.9)' }, - purple: { '50': 'rgba(107, 90, 137, 0.04)', '100': 'rgba(107, 90, 137, 0.08)', '200': 'rgba(107, 90, 137, 0.14)', '300': 'rgba(107, 90, 137, 0.22)', '400': 'rgba(107, 90, 137, 0.36)', '500': '#7c6b99', '600': '#655680', '700': 'rgba(101, 86, 128, 0.8)', '800': 'rgba(101, 86, 128, 0.9)' }, - semantic: { success: '#5b9a6f', warning: '#c08c42', error: '#c26565', info: '#5a7bb2', highlight: '#b8863a', highlightBg: 'rgba(184, 134, 58, 0.12)' }, - border: { subtle: 'rgba(100, 116, 139, 0.15)', base: 'rgba(100, 116, 139, 0.22)', medium: 'rgba(100, 116, 139, 0.32)', strong: 'rgba(100, 116, 139, 0.42)', prominent: 'rgba(100, 116, 139, 0.52)' }, - element: { subtle: 'rgba(71, 102, 143, 0.05)', soft: 'rgba(71, 102, 143, 0.08)', base: 'rgba(71, 102, 143, 0.11)', medium: 'rgba(71, 102, 143, 0.15)', strong: 'rgba(71, 102, 143, 0.20)', elevated: 'rgba(255, 255, 255, 0.92)' }, - }, - }, - { - id: 'bitfun-midnight', - name: 'Midnight', - type: 'dark', - colors: { - background: { primary: '#2b2d30', secondary: '#1e1f22', tertiary: '#313335', quaternary: '#3c3f41', elevated: '#2b2d30', workbench: '#212121', flowchat: '#2b2d30', tooltip: 'rgba(43, 45, 48, 0.94)' }, - text: { primary: '#bcbec4', secondary: '#9da0a8', muted: '#6f737a', disabled: '#4e5157' }, - accent: { '50': 'rgba(88, 166, 255, 0.04)', '100': 'rgba(88, 166, 255, 0.08)', '200': 'rgba(88, 166, 255, 0.15)', '300': 'rgba(88, 166, 255, 0.25)', '400': 'rgba(88, 166, 255, 0.4)', '500': '#58a6ff', '600': '#3b82f6', '700': 'rgba(59, 130, 246, 0.8)', '800': 'rgba(59, 130, 246, 0.9)' }, - purple: { '50': 'rgba(156, 120, 255, 0.04)', '100': 'rgba(156, 120, 255, 0.08)', '200': 'rgba(156, 120, 255, 0.15)', '300': 'rgba(156, 120, 255, 0.25)', '400': 'rgba(156, 120, 255, 0.4)', '500': '#9c78ff', '600': '#8b5cf6', '700': 'rgba(139, 92, 246, 0.8)', '800': 'rgba(139, 92, 246, 0.9)' }, - semantic: { success: '#6aab73', warning: '#e0a055', error: '#cc7f7a', info: '#58a6ff', highlight: '#d4a574', highlightBg: 'rgba(212, 165, 116, 0.15)' }, - border: { subtle: 'rgba(255, 255, 255, 0.08)', base: 'rgba(255, 255, 255, 0.14)', medium: 'rgba(255, 255, 255, 0.20)', strong: 'rgba(255, 255, 255, 0.26)', prominent: 'rgba(255, 255, 255, 0.35)' }, - element: { subtle: 'rgba(255, 255, 255, 0.04)', soft: 'rgba(255, 255, 255, 0.06)', base: 'rgba(255, 255, 255, 0.09)', medium: 'rgba(255, 255, 255, 0.12)', strong: 'rgba(255, 255, 255, 0.15)', elevated: 'rgba(255, 255, 255, 0.18)' }, - }, - }, - { - id: 'bitfun-china-style', - name: 'Ink Charm', - type: 'light', - colors: { - background: { primary: '#faf8f0', secondary: '#f5f3e8', tertiary: '#f0ede0', quaternary: '#ebe8d8', elevated: '#ebe9e3', workbench: '#faf8f0', flowchat: '#faf8f0', tooltip: 'rgba(250, 248, 240, 0.96)' }, - text: { primary: '#1a1a1a', secondary: '#3d3d3d', muted: '#6a6a6a', disabled: '#9a9a9a' }, - accent: { '50': 'rgba(46, 94, 138, 0.04)', '100': 'rgba(46, 94, 138, 0.08)', '200': 'rgba(46, 94, 138, 0.15)', '300': 'rgba(46, 94, 138, 0.25)', '400': 'rgba(46, 94, 138, 0.4)', '500': '#2e5e8a', '600': '#234a6d', '700': 'rgba(35, 74, 109, 0.8)', '800': 'rgba(35, 74, 109, 0.9)' }, - purple: { '50': 'rgba(126, 176, 155, 0.04)', '100': 'rgba(126, 176, 155, 0.08)', '200': 'rgba(126, 176, 155, 0.15)', '300': 'rgba(126, 176, 155, 0.25)', '400': 'rgba(126, 176, 155, 0.4)', '500': '#7eb09b', '600': '#5a9078', '700': 'rgba(90, 144, 120, 0.8)', '800': 'rgba(90, 144, 120, 0.9)' }, - semantic: { success: '#52ad5a', warning: '#f0a020', error: '#c8102e', info: '#2e5e8a', highlight: '#f0a020', highlightBg: 'rgba(240, 160, 32, 0.12)' }, - border: { subtle: 'rgba(106, 92, 70, 0.12)', base: 'rgba(106, 92, 70, 0.20)', medium: 'rgba(106, 92, 70, 0.28)', strong: 'rgba(106, 92, 70, 0.36)', prominent: 'rgba(106, 92, 70, 0.48)' }, - element: { subtle: 'rgba(46, 94, 138, 0.03)', soft: 'rgba(46, 94, 138, 0.06)', base: 'rgba(46, 94, 138, 0.10)', medium: 'rgba(46, 94, 138, 0.14)', strong: 'rgba(46, 94, 138, 0.18)', elevated: 'rgba(255, 255, 255, 0.85)' }, - }, - }, - { - id: 'bitfun-china-night', - name: 'Ink Night', - type: 'dark', - colors: { - background: { primary: '#1a1814', secondary: '#212019', tertiary: '#262420', quaternary: '#2d2926', elevated: '#2d2926', workbench: '#1a1814', flowchat: '#1a1814', tooltip: 'rgba(26, 24, 20, 0.95)' }, - text: { primary: '#e8e6e1', secondary: '#c5c3be', muted: '#928f89', disabled: '#5f5d59' }, - accent: { '50': 'rgba(115, 165, 204, 0.04)', '100': 'rgba(115, 165, 204, 0.08)', '200': 'rgba(115, 165, 204, 0.15)', '300': 'rgba(115, 165, 204, 0.25)', '400': 'rgba(115, 165, 204, 0.4)', '500': '#73a5cc', '600': '#5a8bb3', '700': 'rgba(90, 139, 179, 0.8)', '800': 'rgba(90, 139, 179, 0.9)' }, - purple: { '50': 'rgba(150, 198, 180, 0.04)', '100': 'rgba(150, 198, 180, 0.08)', '200': 'rgba(150, 198, 180, 0.15)', '300': 'rgba(150, 198, 180, 0.25)', '400': 'rgba(150, 198, 180, 0.4)', '500': '#96c6b4', '600': '#7aab98', '700': 'rgba(122, 171, 152, 0.8)', '800': 'rgba(122, 171, 152, 0.9)' }, - semantic: { success: '#6bc072', warning: '#f5b555', error: '#e85555', info: '#73a5cc', highlight: '#e6a84a', highlightBg: 'rgba(230, 168, 74, 0.15)' }, - border: { subtle: 'rgba(232, 230, 225, 0.10)', base: 'rgba(232, 230, 225, 0.16)', medium: 'rgba(232, 230, 225, 0.22)', strong: 'rgba(232, 230, 225, 0.28)', prominent: 'rgba(232, 230, 225, 0.38)' }, - element: { subtle: 'rgba(115, 165, 204, 0.06)', soft: 'rgba(115, 165, 204, 0.09)', base: 'rgba(115, 165, 204, 0.12)', medium: 'rgba(115, 165, 204, 0.16)', strong: 'rgba(115, 165, 204, 0.20)', elevated: 'rgba(45, 41, 38, 0.95)' }, - }, - }, - { - id: 'bitfun-cyber', - name: 'Cyber', - type: 'dark', - colors: { - background: { primary: '#101010', secondary: '#151515', tertiary: '#1a1a1a', quaternary: '#1f1f1f', elevated: '#0d0d0d', workbench: '#101010', flowchat: '#101010', tooltip: 'rgba(16, 16, 16, 0.95)' }, - text: { primary: '#e0f2ff', secondary: '#c7e7ff', muted: '#7fadcc', disabled: '#4a5a66' }, - accent: { '50': 'rgba(0, 230, 255, 0.05)', '100': 'rgba(0, 230, 255, 0.1)', '200': 'rgba(0, 230, 255, 0.18)', '300': 'rgba(0, 230, 255, 0.3)', '400': 'rgba(0, 230, 255, 0.45)', '500': '#00e6ff', '600': '#00ccff', '700': 'rgba(0, 204, 255, 0.85)', '800': 'rgba(0, 204, 255, 0.95)' }, - purple: { '50': 'rgba(138, 43, 226, 0.05)', '100': 'rgba(138, 43, 226, 0.1)', '200': 'rgba(138, 43, 226, 0.18)', '300': 'rgba(138, 43, 226, 0.3)', '400': 'rgba(138, 43, 226, 0.45)', '500': '#8a2be2', '600': '#7928ca', '700': 'rgba(121, 40, 202, 0.85)', '800': 'rgba(121, 40, 202, 0.95)' }, - semantic: { success: '#00ff9f', warning: '#ffcc00', error: '#ff0055', info: '#00e6ff', highlight: '#ffdd44', highlightBg: 'rgba(255, 221, 68, 0.15)' }, - border: { subtle: 'rgba(0, 230, 255, 0.14)', base: 'rgba(0, 230, 255, 0.20)', medium: 'rgba(0, 230, 255, 0.28)', strong: 'rgba(0, 230, 255, 0.36)', prominent: 'rgba(0, 230, 255, 0.50)' }, - element: { subtle: 'rgba(0, 230, 255, 0.06)', soft: 'rgba(0, 230, 255, 0.09)', base: 'rgba(0, 230, 255, 0.13)', medium: 'rgba(0, 230, 255, 0.17)', strong: 'rgba(0, 230, 255, 0.22)', elevated: 'rgba(0, 230, 255, 0.27)' }, - }, - }, - { - id: 'bitfun-slate', - name: 'Slate', - type: 'dark', - colors: { - background: { primary: '#1a1c1e', secondary: '#1a1c1e', tertiary: '#1a1c1e', quaternary: '#32363a', elevated: '#1a1c1e', workbench: '#1a1c1e', flowchat: '#1a1c1e', tooltip: 'rgba(42, 45, 48, 0.96)' }, - text: { primary: '#e4e6e8', secondary: '#b8bbc0', muted: '#8a8d92', disabled: '#5a5d62' }, - accent: { '50': 'rgba(107, 155, 213, 0.04)', '100': 'rgba(107, 155, 213, 0.08)', '200': 'rgba(107, 155, 213, 0.15)', '300': 'rgba(107, 155, 213, 0.25)', '400': 'rgba(107, 155, 213, 0.4)', '500': '#6b9bd5', '600': '#5a8bc4', '700': 'rgba(90, 139, 196, 0.8)', '800': 'rgba(90, 139, 196, 0.9)' }, - purple: { '50': 'rgba(165, 180, 252, 0.04)', '100': 'rgba(165, 180, 252, 0.08)', '200': 'rgba(165, 180, 252, 0.15)', '300': 'rgba(165, 180, 252, 0.25)', '400': 'rgba(165, 180, 252, 0.4)', '500': '#a5b4fc', '600': '#8b9adb', '700': 'rgba(139, 154, 219, 0.8)', '800': 'rgba(139, 154, 219, 0.9)' }, - semantic: { success: '#7fb899', warning: '#d4a574', error: '#c9878d', info: '#6b9bd5', highlight: '#d4d6d8', highlightBg: 'rgba(212, 214, 216, 0.12)' }, - border: { subtle: 'rgba(255, 255, 255, 0.12)', base: 'rgba(255, 255, 255, 0.18)', medium: 'rgba(255, 255, 255, 0.24)', strong: 'rgba(255, 255, 255, 0.32)', prominent: 'rgba(255, 255, 255, 0.45)' }, - element: { subtle: 'rgba(255, 255, 255, 0.06)', soft: 'rgba(255, 255, 255, 0.10)', base: 'rgba(255, 255, 255, 0.13)', medium: 'rgba(255, 255, 255, 0.17)', strong: 'rgba(255, 255, 255, 0.21)', elevated: 'rgba(255, 255, 255, 0.25)' }, - }, - }, -]; - -const THEME_DISPLAY_ORDER: ThemeId[] = [ - 'bitfun-light', - 'bitfun-slate', - 'bitfun-dark', - 'bitfun-midnight', - 'bitfun-china-style', - 'bitfun-china-night', - 'bitfun-cyber', -]; +import type { InstallOptions, ThemeId, ThemePreferenceId } from '../types/installer'; +import { SYSTEM_THEME_ID } from '../types/installer'; +import { THEMES, THEME_DISPLAY_ORDER, findInstallerThemeById } from '../theme/installerThemesData'; interface ThemeSetupProps { options: InstallOptions; @@ -176,8 +18,10 @@ export function ThemeSetup({ options, setOptions, onLaunch, onClose }: ThemeSetu const [isFinishing, setIsFinishing] = useState(false); const [finishError, setFinishError] = useState(null); const orderedThemes = [...THEMES].sort((a, b) => THEME_DISPLAY_ORDER.indexOf(a.id) - THEME_DISPLAY_ORDER.indexOf(b.id)); + const lightPreview = findInstallerThemeById('bitfun-light'); + const darkPreview = findInstallerThemeById('bitfun-dark'); - const selectTheme = (theme: ThemeId) => { + const selectTheme = (theme: ThemePreferenceId) => { setOptions((prev) => ({ ...prev, themePreference: theme })); }; @@ -215,65 +59,13 @@ export function ThemeSetup({ options, setOptions, onLaunch, onClose }: ThemeSetu await onLaunch(); } onClose(); - } catch (err: any) { - setFinishError(typeof err === 'string' ? err : err?.message || 'Failed to launch BitFun'); + } catch (err: unknown) { + setFinishError(typeof err === 'string' ? err : (err as Error)?.message || 'Failed to launch BitFun'); } finally { setIsFinishing(false); } }; - useEffect(() => { - const selectedTheme = - THEMES.find((theme) => theme.id === options.themePreference) ?? - THEMES.find((theme) => theme.id === 'bitfun-light') ?? - THEMES[0]; - const root = document.documentElement; - const { colors } = selectedTheme; - - root.style.setProperty('--color-bg-primary', colors.background.primary); - root.style.setProperty('--color-bg-secondary', colors.background.secondary); - root.style.setProperty('--color-bg-tertiary', colors.background.tertiary); - root.style.setProperty('--color-bg-quaternary', colors.background.quaternary); - root.style.setProperty('--color-bg-elevated', colors.background.elevated); - root.style.setProperty('--color-bg-workbench', colors.background.workbench); - root.style.setProperty('--color-bg-flowchat', colors.background.flowchat); - root.style.setProperty('--color-bg-tooltip', colors.background.tooltip ?? colors.background.elevated); - root.style.setProperty('--color-text-primary', colors.text.primary); - root.style.setProperty('--color-text-secondary', colors.text.secondary); - root.style.setProperty('--color-text-muted', colors.text.muted); - root.style.setProperty('--color-text-disabled', colors.text.disabled); - root.style.setProperty('--element-bg-subtle', colors.element.subtle); - root.style.setProperty('--element-bg-soft', colors.element.soft); - root.style.setProperty('--element-bg-base', colors.element.base); - root.style.setProperty('--element-bg-medium', colors.element.medium); - root.style.setProperty('--element-bg-strong', colors.element.strong); - root.style.setProperty('--element-bg-elevated', colors.element.elevated); - root.style.setProperty('--border-subtle', colors.border.subtle); - root.style.setProperty('--border-base', colors.border.base); - root.style.setProperty('--border-medium', colors.border.medium); - root.style.setProperty('--border-strong', colors.border.strong); - root.style.setProperty('--border-prominent', colors.border.prominent); - root.style.setProperty('--color-success', colors.semantic.success); - root.style.setProperty('--color-warning', colors.semantic.warning); - root.style.setProperty('--color-error', colors.semantic.error); - root.style.setProperty('--color-info', colors.semantic.info); - root.style.setProperty('--color-highlight', colors.semantic.highlight); - root.style.setProperty('--color-highlight-bg', colors.semantic.highlightBg); - - Object.entries(colors.accent).forEach(([key, value]) => { - root.style.setProperty(`--color-accent-${key}`, value); - }); - - if (colors.purple) { - Object.entries(colors.purple).forEach(([key, value]) => { - root.style.setProperty(`--color-purple-${key}`, value); - }); - } - - root.setAttribute('data-theme', selectedTheme.id); - root.setAttribute('data-theme-type', selectedTheme.type); - }, [options.themePreference]); - return (
+ + {orderedThemes.map((theme) => ( -
-
- ); - } - - return ( -
-
- {t('navigation.stepOf', { - current: currentStepIndex + 1, - total: totalSteps - })} -
-
- - - -
-
- ); - }; - - return ( -
- {/* Background decoration */} -
- - {/* Window controls (macOS uses native traffic lights) */} - {!isMacOS && ( -
- -
- )} - - {/* Progress indicator */} - {renderProgress()} - - {/* Main content */} -
-
- {renderStepContent()} -
-
- - {/* Navigation */} - {renderNavigation()} - - {/* Inline confirm dialog for incomplete model config */} - {showIncompleteWarning && ( -
-
-
- -
-

- {t('model.incompleteConfig.title')} -

-

- {t('model.incompleteConfig.message')} -

-
- - -
-
-
- )} -
- ); -}; - -export default OnboardingWizard; diff --git a/src/web-ui/src/features/onboarding/components/index.ts b/src/web-ui/src/features/onboarding/components/index.ts deleted file mode 100644 index 2d54c682..00000000 --- a/src/web-ui/src/features/onboarding/components/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Onboarding component exports - */ - -export { OnboardingWizard } from './OnboardingWizard'; -export * from './steps'; diff --git a/src/web-ui/src/features/onboarding/components/steps/CompletionStep.tsx b/src/web-ui/src/features/onboarding/components/steps/CompletionStep.tsx deleted file mode 100644 index 7893d5e0..00000000 --- a/src/web-ui/src/features/onboarding/components/steps/CompletionStep.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Completion step - * CompletionStep - shows model status and get started button - */ - -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { CheckCircle, ArrowRight } from 'lucide-react'; -import { useOnboardingStore, isModelConfigComplete } from '../../store/onboardingStore'; - -interface CompletionStepProps { - onComplete: () => void; -} - -export const CompletionStep: React.FC = ({ - onComplete -}) => { - const { t } = useTranslation('onboarding'); - const { modelConfig } = useOnboardingStore(); - - const hasModel = isModelConfigComplete(modelConfig); - - return ( -
- {/* Success icon */} -
- -
- - {/* Title */} -
-

- {t('completion.title')} -

-

- {t('completion.subtitle')} -

-
- - {/* Model status hint - only show warning when not configured */} - {!hasModel && ( -
- {t('completion.modelStatus.notConfigured')} -
- )} - - {/* Get started button */} - -
- ); -}; - -export default CompletionStep; diff --git a/src/web-ui/src/features/onboarding/components/steps/LanguageStep.tsx b/src/web-ui/src/features/onboarding/components/steps/LanguageStep.tsx deleted file mode 100644 index 85bc90fa..00000000 --- a/src/web-ui/src/features/onboarding/components/steps/LanguageStep.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Language selection step - * LanguageStep - choose UI language - */ - -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -interface LanguageStepProps { - selectedLanguage: string; - onLanguageChange: (language: string) => void; -} - -const LANGUAGE_OPTIONS = [ - { id: 'zh-CN', shortLabelKey: 'language.optionsShort.zh-CN', labelKey: 'language.options.zh-CN' }, - { id: 'en-US', shortLabelKey: 'language.optionsShort.en-US', labelKey: 'language.options.en-US' } -]; - -export const LanguageStep: React.FC = ({ - selectedLanguage, - onLanguageChange -}) => { - const { t } = useTranslation('onboarding'); - - return ( -
- {/* Logo */} -
- BitFun Logo -
- - {/* Welcome text */} -

- BitFun -

- - {/* Subtitle - both languages on one line with / separator */} -
- 选择界面语言 / Choose Your Language -
- - {/* Language options */} -
- {LANGUAGE_OPTIONS.map((option) => ( -
onLanguageChange(option.id)} - > -
- {t(option.shortLabelKey)} -
-
- {t(option.labelKey)} -
-
- ))} -
-
- ); -}; - -export default LanguageStep; diff --git a/src/web-ui/src/features/onboarding/components/steps/ModeIntroStep.tsx b/src/web-ui/src/features/onboarding/components/steps/ModeIntroStep.tsx deleted file mode 100644 index 217eb042..00000000 --- a/src/web-ui/src/features/onboarding/components/steps/ModeIntroStep.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Mode intro step - * ModeIntroStep - minimal, premium mode overview - */ - -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Zap, GitBranch, Search } from 'lucide-react'; - -interface ModeIntroStepProps {} - -const MODE_LIST = [ - { - id: 'agentic', - icon: Zap, - nameKey: 'modes.modeList.agentic.name', - descKey: 'modes.modeList.agentic.description', - featuresKey: 'modes.modeList.agentic.features' - }, - { - id: 'plan', - icon: GitBranch, - nameKey: 'modes.modeList.plan.name', - descKey: 'modes.modeList.plan.description', - featuresKey: 'modes.modeList.plan.features' - }, - { - id: 'debug', - icon: Search, - nameKey: 'modes.modeList.debug.name', - descKey: 'modes.modeList.debug.description', - featuresKey: 'modes.modeList.debug.features' - } -]; - -export const ModeIntroStep: React.FC = () => { - const { t } = useTranslation('onboarding'); - - return ( -
- {/* Title */} -
-

- {t('modes.title')} -

-

- {t('modes.description')} -

-
- - {/* Mode list */} -
- {MODE_LIST.map((mode) => { - const IconComponent = mode.icon; - const features = t(mode.featuresKey, { returnObjects: true }) as string[]; - - return ( -
-
- -
-
- {t(mode.nameKey)} -
-
- {t(mode.descKey)} -
-
- {Array.isArray(features) && features.slice(0, 2).map((feature, idx) => ( - - {feature} - - ))} -
-
- ); - })} -
- - {/* Tip */} -

- {t('modes.tip')} -

-
- ); -}; - -export default ModeIntroStep; diff --git a/src/web-ui/src/features/onboarding/components/steps/ModelConfigStep.tsx b/src/web-ui/src/features/onboarding/components/steps/ModelConfigStep.tsx deleted file mode 100644 index b420affa..00000000 --- a/src/web-ui/src/features/onboarding/components/steps/ModelConfigStep.tsx +++ /dev/null @@ -1,875 +0,0 @@ -/** - * ModelConfigStep - */ - -import React, { useState, useCallback, useMemo, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Settings, Loader, Check, X, ExternalLink, ChevronDown, ChevronUp, AlertTriangle, Plus } from 'lucide-react'; -import { useOnboardingStore } from '../../store/onboardingStore'; -import { aiApi } from '@/infrastructure/api'; -import { systemAPI } from '@/infrastructure/api'; -import { Select, Checkbox, Button, IconButton } from '@/component-library'; -import { PROVIDER_TEMPLATES } from '@/infrastructure/config/services/modelConfigs'; -import { createLogger } from '@/shared/utils/logger'; -import { translateConnectionTestMessage } from '@/shared/utils/aiConnectionTestMessages'; - -const log = createLogger('ModelConfigStep'); - -interface ModelConfigStepProps { - onSkipForNow: () => void; -} - -/** Provider display order */ -const PROVIDER_ORDER = ['openbitfun', 'zhipu', 'qwen', 'deepseek', 'volcengine', 'minimax', 'moonshot', 'gemini', 'anthropic']; - -type TestStatus = 'idle' | 'testing' | 'success' | 'error'; -type RemoteModelOption = { id: string; display_name?: string }; - -export const ModelConfigStep: React.FC = ({ onSkipForNow }) => { - const { t } = useTranslation('onboarding'); - const { t: tAiModel } = useTranslation('settings/ai-model'); - const { modelConfig, setModelConfig } = useOnboardingStore(); - - // Basic fields - const [selectedProviderId, setSelectedProviderId] = useState(modelConfig?.provider || ''); - const [apiKey, setApiKey] = useState(modelConfig?.apiKey || ''); - const [baseUrl, setBaseUrl] = useState(modelConfig?.baseUrl || ''); - const [modelName, setModelName] = useState(modelConfig?.modelName || ''); - const [customFormat, setCustomFormat] = useState<'openai' | 'responses' | 'anthropic' | 'gemini'>( - (modelConfig?.format as 'openai' | 'responses' | 'anthropic' | 'gemini') || 'openai' - ); - const [testStatus, setTestStatus] = useState('idle'); - const [testError, setTestError] = useState(''); - const [testNotice, setTestNotice] = useState(''); - const [remoteModelOptions, setRemoteModelOptions] = useState([]); - const [isFetchingRemoteModels, setIsFetchingRemoteModels] = useState(false); - const [remoteModelsError, setRemoteModelsError] = useState(''); - const [hasAttemptedRemoteFetch, setHasAttemptedRemoteFetch] = useState(false); - - // Advanced settings - restore from store so state survives unmount/remount - const [showAdvancedSettings, setShowAdvancedSettings] = useState( - Boolean(modelConfig?.customRequestBody || modelConfig?.skipSslVerify || modelConfig?.customHeaders) - ); - const [customRequestBody, setCustomRequestBody] = useState(modelConfig?.customRequestBody || ''); - const [skipSslVerify, setSkipSslVerify] = useState(modelConfig?.skipSslVerify || false); - const [customHeaders, setCustomHeaders] = useState>(modelConfig?.customHeaders || {}); - const [customHeadersMode, setCustomHeadersMode] = useState<'merge' | 'replace'>( - modelConfig?.customHeadersMode || 'merge' - ); - - // Build sorted provider options from PROVIDER_TEMPLATES - const providerOptions = useMemo(() => { - const sorted = PROVIDER_ORDER - .filter(id => PROVIDER_TEMPLATES[id]) - .map(id => PROVIDER_TEMPLATES[id]); - - // Add any templates not in the explicit order - Object.values(PROVIDER_TEMPLATES).forEach(template => { - if (!PROVIDER_ORDER.includes(template.id)) { - sorted.push(template); - } - }); - - // Dynamically get translated name and description - return sorted.map(provider => ({ - ...provider, - name: tAiModel(`providers.${provider.id}.name`), - description: tAiModel(`providers.${provider.id}.description`) - })); - }, [tAiModel]); - - // Build select options: custom first, then preset providers - const selectOptions = useMemo(() => { - const options: Array<{ label: string; value: string; description: string }> = [{ - label: t('model.provider.options.custom'), - value: 'custom', - description: t('model.provider.customDescription') - }]; - providerOptions.forEach(p => { - options.push({ - label: p.name, - value: p.id, - description: p.description - }); - }); - return options; - }, [providerOptions, t]); - - // Current template (null if custom or not selected) - const currentTemplate = useMemo(() => { - if (!selectedProviderId || selectedProviderId === 'custom') return null; - const template = PROVIDER_TEMPLATES[selectedProviderId]; - if (!template) return null; - // Dynamically get translated name, description, and baseUrlOptions notes - return { - ...template, - name: tAiModel(`providers.${template.id}.name`), - description: tAiModel(`providers.${template.id}.description`), - baseUrlOptions: template.baseUrlOptions?.map(opt => ({ - ...opt, - note: tAiModel(`providers.${template.id}.urlOptions.${opt.note}`, { defaultValue: opt.note }) - })) - }; - }, [selectedProviderId, tAiModel]); - - const resetRemoteModelDiscovery = useCallback(() => { - setRemoteModelOptions([]); - setIsFetchingRemoteModels(false); - setRemoteModelsError(''); - setHasAttemptedRemoteFetch(false); - }, []); - - const buildModelDiscoveryConfig = useCallback(() => { - const template = selectedProviderId !== 'custom' ? PROVIDER_TEMPLATES[selectedProviderId] : null; - const resolvedBaseUrl = (baseUrl || template?.baseUrl || '').trim(); - const resolvedModelName = (modelName || template?.models[0] || 'model-discovery').trim(); - let resolvedFormat: 'openai' | 'responses' | 'anthropic' | 'gemini' = customFormat; - if (template) { - if (template.baseUrlOptions?.length) { - const effectiveUrl = baseUrl || template.baseUrl; - const matchedOption = template.baseUrlOptions.find(opt => opt.url === effectiveUrl); - resolvedFormat = matchedOption ? matchedOption.format : template.format; - } else { - resolvedFormat = template.format; - } - } - const resolvedApiKey = apiKey.trim(); - - if (!resolvedBaseUrl || !resolvedApiKey) { - return null; - } - - return { - id: 'onboarding_model_discovery', - name: 'Onboarding Model Discovery', - provider: resolvedFormat, - api_key: resolvedApiKey, - base_url: resolvedBaseUrl, - request_url: resolvedBaseUrl, - model_name: resolvedModelName, - enabled: true, - category: 'general_chat', - capabilities: ['text_chat'], - recommended_for: [], - metadata: {}, - context_window: 128000, - max_tokens: 8192, - enable_thinking_process: false, - support_preserved_thinking: false, - inline_think_in_text: false, - skip_ssl_verify: skipSslVerify, - custom_headers: Object.keys(customHeaders).length > 0 ? customHeaders : undefined, - custom_headers_mode: Object.keys(customHeaders).length > 0 ? customHeadersMode : undefined, - custom_request_body: customRequestBody.trim() || undefined, - }; - }, [apiKey, baseUrl, modelName, selectedProviderId, customFormat, skipSslVerify, customHeaders, customHeadersMode, customRequestBody]); - - const fetchRemoteModels = useCallback(async () => { - const discoveryConfig = buildModelDiscoveryConfig(); - if (!discoveryConfig) { - setRemoteModelOptions([]); - setRemoteModelsError(tAiModel('providerSelection.fillApiKeyBeforeFetch')); - setHasAttemptedRemoteFetch(true); - return; - } - - setIsFetchingRemoteModels(true); - setRemoteModelsError(''); - setHasAttemptedRemoteFetch(true); - - try { - const remoteModels = await aiApi.listModelsByConfig(discoveryConfig); - const dedupedModels = remoteModels.filter((model, index, arr) => ( - !!model.id && arr.findIndex(item => item.id === model.id) === index - )); - - if (dedupedModels.length === 0) { - setRemoteModelOptions([]); - setRemoteModelsError(tAiModel('providerSelection.fetchEmptyFallback')); - return; - } - - setRemoteModelOptions(dedupedModels); - setRemoteModelsError(''); - } catch (error) { - log.warn('Failed to fetch remote models during onboarding, falling back', { error }); - setRemoteModelOptions([]); - setRemoteModelsError(tAiModel('providerSelection.fetchFailedFallback')); - } finally { - setIsFetchingRemoteModels(false); - } - }, [buildModelDiscoveryConfig, tAiModel]); - - // Stable JSON representation of customHeaders for useEffect dependency - const customHeadersJson = useMemo(() => JSON.stringify(customHeaders), [customHeaders]); - - // Sync form state to onboarding store whenever fields change - useEffect(() => { - if (!selectedProviderId) { - setModelConfig(null); - return; - } - - const template = selectedProviderId !== 'custom' ? PROVIDER_TEMPLATES[selectedProviderId] : null; - const effectiveBaseUrl = baseUrl || (template?.baseUrl || ''); - const effectiveModelName = modelName || (template?.models[0] || ''); - - // Derive format - let format: 'openai' | 'responses' | 'anthropic' | 'gemini' = customFormat; - if (template) { - if (template.baseUrlOptions?.length) { - const effectiveUrl = baseUrl || template.baseUrl; - const matched = template.baseUrlOptions.find(opt => opt.url === effectiveUrl); - format = matched ? matched.format : template.format; - } else { - format = template.format; - } - } - - const translatedName = template ? tAiModel(`providers.${template.id}.name`) : null; - const customLabel = t('model.provider.options.custom'); - const configName = translatedName || customLabel; - - const parsedHeaders = JSON.parse(customHeadersJson) as Record; - - setModelConfig({ - provider: selectedProviderId, - apiKey, - baseUrl: effectiveBaseUrl, - modelName: effectiveModelName, - format, - configName, - customRequestBody: customRequestBody.trim() || undefined, - skipSslVerify: skipSslVerify || undefined, - customHeaders: Object.keys(parsedHeaders).length > 0 ? parsedHeaders : undefined, - customHeadersMode: Object.keys(parsedHeaders).length > 0 ? customHeadersMode : undefined, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedProviderId, apiKey, baseUrl, modelName, customFormat, customRequestBody, skipSslVerify, customHeadersJson, customHeadersMode]); - - // Handle provider change - const handleProviderChange = useCallback((newProviderId: string) => { - resetRemoteModelDiscovery(); - setSelectedProviderId(newProviderId); - setTestStatus('idle'); - setTestError(''); - setTestNotice(''); - - if (newProviderId === 'custom') { - setBaseUrl(''); - setModelName(''); - } else { - const template = PROVIDER_TEMPLATES[newProviderId]; - if (template) { - setBaseUrl(template.baseUrl); - setModelName(template.models[0] || ''); - } - } - }, [resetRemoteModelDiscovery]); - - // Handle "skip for now": clear config and proceed - const handleSkipForNow = useCallback(() => { - setModelConfig(null); - onSkipForNow(); - }, [setModelConfig, onSkipForNow]); - - // Handle model name change from template select - const handleModelNameChange = useCallback((value: string) => { - setModelName(value); - setTestStatus('idle'); - setTestError(''); - setTestNotice(''); - }, []); - - // Open help URL - const handleOpenHelpUrl = useCallback(async () => { - if (currentTemplate?.helpUrl) { - try { - await systemAPI.openExternal(currentTemplate.helpUrl); - } catch { - window.open(currentTemplate.helpUrl, '_blank'); - } - } - }, [currentTemplate]); - - // Get effective format - const getEffectiveFormat = useCallback(() => { - if (currentTemplate) { - // If the template has baseUrlOptions, derive format from the selected URL - if (currentTemplate.baseUrlOptions && currentTemplate.baseUrlOptions.length > 0) { - const effectiveUrl = baseUrl || currentTemplate.baseUrl; - const matchedOption = currentTemplate.baseUrlOptions.find(opt => opt.url === effectiveUrl); - if (matchedOption) { - return matchedOption.format; - } - } - return currentTemplate.format; - } - return customFormat; - }, [currentTemplate, customFormat, baseUrl]); - - // Test connection (purely for connectivity validation, does not affect saving) - const handleTestConnection = useCallback(async () => { - if (!apiKey || !selectedProviderId) return; - - setTestStatus('testing'); - setTestError(''); - setTestNotice(''); - - try { - const effectiveBaseUrl = baseUrl || (currentTemplate?.baseUrl || ''); - const effectiveModelName = modelName || (currentTemplate?.models[0] || ''); - const format = getEffectiveFormat(); - - const result = await aiApi.testConfigConnection({ - base_url: effectiveBaseUrl, - api_key: apiKey, - model_name: effectiveModelName, - provider: format - }); - const localizedMessage = translateConnectionTestMessage(result.message_code, tAiModel); - - if (result.success) { - setTestStatus('success'); - setTestNotice(localizedMessage || result.error_details || ''); - log.info('Connection test passed', { - provider: selectedProviderId, - modelName: effectiveModelName - }); - } else { - setTestStatus('error'); - setTestNotice(''); - const detailLines = [ - localizedMessage, - result.error_details ? `${tAiModel('messages.errorDetails')}: ${result.error_details}` : undefined - ].filter((line): line is string => Boolean(line)); - const errorMsg = detailLines.length > 0 - ? `${t('model.testFailed')}\n${detailLines.join('\n')}` - : t('model.testFailed'); - setTestError(errorMsg); - } - } catch (error) { - log.error('Connection test failed', error); - setTestStatus('error'); - setTestNotice(''); - const rawMsg = error instanceof Error ? error.message : String(error); - // Tauri command errors often have "Connection test failed: " prefix, extract the actual cause - const cleanMsg = rawMsg.replace(/^Connection test failed:\s*/i, ''); - setTestError(cleanMsg ? `${t('model.testFailed')}\n${cleanMsg}` : t('model.testFailed')); - } - }, [apiKey, selectedProviderId, baseUrl, modelName, currentTemplate, getEffectiveFormat, t]); - - // Render test button - const renderTestButton = () => { - const isDisabled = !apiKey || !selectedProviderId || testStatus === 'testing'; - - let buttonClass = 'bitfun-onboarding-model__test-btn'; - let content: React.ReactNode = t('model.testConnection'); - - switch (testStatus) { - case 'testing': - content = ( - <> - - {t('model.testing')} - - ); - break; - case 'success': - buttonClass += ' bitfun-onboarding-model__test-btn--success'; - content = ( - <> - - {t('model.testSuccess')} - - ); - break; - case 'error': - buttonClass += ' bitfun-onboarding-model__test-btn--error'; - content = ( - <> - - {t('model.testFailed')} - - ); - break; - } - - return ( - - ); - }; - - // Validate custom request body JSON - const customRequestBodyValidation = useMemo(() => { - if (!customRequestBody || !customRequestBody.trim()) return null; - try { - JSON.parse(customRequestBody); - return 'valid'; - } catch { - return 'invalid'; - } - }, [customRequestBody]); - - // Whether a provider is selected (to show the form) - const isProviderSelected = !!selectedProviderId; - const availableModelOptions = ( - remoteModelOptions.length > 0 - ? remoteModelOptions.map(model => ({ - label: `${currentTemplate?.name || t('model.provider.options.custom')}/${model.display_name || model.id}`, - value: model.id, - description: model.display_name && model.display_name !== model.id ? model.id : undefined - })) - : (currentTemplate?.models || []).map(model => ({ - label: `${currentTemplate?.name || t('model.provider.options.custom')}/${model}`, - value: model - })) - ); - const modelFetchHint = isFetchingRemoteModels - ? tAiModel('providerSelection.fetchingModels') - : remoteModelsError - ? remoteModelsError - : remoteModelOptions.length > 0 - ? null - : currentTemplate?.models?.length - ? tAiModel('providerSelection.usingPresetModels') - : hasAttemptedRemoteFetch - ? tAiModel('providerSelection.noPresetModels') - : null; - - return ( -
- {/* Icon */} -
- -
- - {/* Header */} -
-

- {t('model.title')} -

-

- {t('model.description')} -

-
- - {/* Config Form */} -
- {/* Provider Select */} -
- handleModelNameChange(value as string)} - placeholder={t('model.modelName.selectPlaceholder')} - options={availableModelOptions} - searchable - allowCustomValue - loading={isFetchingRemoteModels} - emptyText={tAiModel('providerSelection.noPresetModels')} - searchPlaceholder={t('model.modelName.inputPlaceholder')} - customValueHint={t('model.modelName.customHint')} - /> -
- -
- {modelFetchHint && ( - - {modelFetchHint} - - )} -
- - {/* API Key */} -
- - { - resetRemoteModelDiscovery(); - setApiKey(e.target.value); - setTestStatus('idle'); - setTestError(''); - setTestNotice(''); - }} - /> - {currentTemplate.helpUrl && ( - - )} -
- - {/* Base URL (pre-filled, editable) */} -
- - {currentTemplate.baseUrlOptions && currentTemplate.baseUrlOptions.length > 0 ? ( - { - resetRemoteModelDiscovery(); - setBaseUrl(e.target.value); - setTestStatus('idle'); - setTestError(''); - setTestNotice(''); - }} - onFocus={(e) => e.target.select()} - /> - )} -
- - )} - - {/* Custom provider form */} - {isProviderSelected && selectedProviderId === 'custom' && ( - <> - {/* Base URL */} -
- - { - resetRemoteModelDiscovery(); - setBaseUrl(e.target.value); - setTestStatus('idle'); - setTestError(''); - setTestNotice(''); - }} - /> -
- - {/* Model Name (text input) */} -
- - { - resetRemoteModelDiscovery(); - setApiKey(e.target.value); - setTestStatus('idle'); - setTestError(''); - setTestNotice(''); - }} - /> -
- - {/* API Format */} -
- setCustomHeadersMode('merge')} - /> - {tAiModel('advancedSettings.customHeaders.modeMerge')} - - -
- - {Object.entries(customHeaders).map(([key, value], index) => ( -
- { - const newHeaders = { ...customHeaders }; - const oldValue = newHeaders[key]; - delete newHeaders[key]; - if (e.target.value) { - newHeaders[e.target.value] = oldValue; - } - setCustomHeaders(newHeaders); - }} - placeholder={tAiModel('advancedSettings.customHeaders.keyPlaceholder')} - style={{ flex: 1 }} - /> - { - setCustomHeaders(prev => ({ ...prev, [key]: e.target.value })); - }} - placeholder={tAiModel('advancedSettings.customHeaders.valuePlaceholder')} - style={{ flex: 1 }} - /> - { - const newHeaders = { ...customHeaders }; - delete newHeaders[key]; - setCustomHeaders(newHeaders); - }} - tooltip={tAiModel('actions.delete')} - > - - -
- ))} - -
- )} - - {/* Custom Request Body */} -
- - - {t('model.advanced.customRequestBodyHint')} - -