From 2d06062600a0982c4ed4a7a58b55709bf8ad33f1 Mon Sep 17 00:00:00 2001 From: Yue-Sui Date: Sun, 27 Sep 2026 16:08:00 +0800 Subject: [PATCH 1/3] feat(ai): refactor provider and model settings --- src-tauri/src/cmd/ai.rs | 61 +- src-tauri/src/cmd/connection.rs | 2 +- src-tauri/src/config/mod.rs | 19 +- src-tauri/src/config/settings/ai.rs | 107 +- src-tauri/src/config/settings/mod.rs | 10 +- src-tauri/src/core/ai/mod.rs | 5 +- src-tauri/src/core/ai/model.rs | 479 +++++- src-tauri/src/core/ai/responses.rs | 99 +- src-tauri/src/lib.rs | 4 + src/components/ai/ProviderBadge.tsx | 52 + src/components/panel/ai/AIAssistantPanel.tsx | 58 +- src/components/panel/ai/ModelCombobox.tsx | 31 +- src/components/settings/AiTab.test.tsx | 150 ++ src/components/settings/AiTab.tsx | 1623 ++++++++++++++---- src/components/settings/SettingFormItems.tsx | 11 +- src/hooks/useModalChildWindows.ts | 36 + src/i18n/locales/en.json | 49 +- src/i18n/locales/ko.json | 49 +- src/i18n/locales/zh-CN.json | 49 +- src/i18n/locales/zh-TW.json | 49 +- src/index.css | 23 + src/lib/aiSettings.ts | 57 +- src/types/global.d.ts | 18 +- 23 files changed, 2616 insertions(+), 425 deletions(-) create mode 100644 src/components/ai/ProviderBadge.tsx create mode 100644 src/components/settings/AiTab.test.tsx diff --git a/src-tauri/src/cmd/ai.rs b/src-tauri/src/cmd/ai.rs index a5c9a3d1..0919ef41 100644 --- a/src-tauri/src/cmd/ai.rs +++ b/src-tauri/src/cmd/ai.rs @@ -5,10 +5,17 @@ use crate::core::ai::{ AiStreamStart, AppendAiAuditRequest, ClaudeCodeAccountStatus, ClaudeCodeCliStatus, ClaudeCodeRuntime, CodexAccountStatus, CodexCliStatus, CodexLoginFlow, CodexLoginStart, }; -use crate::error::AppResult; +use crate::error::{AppError, AppResult}; +use crate::storage::{self, SettingsDocKey}; +use crate::utils::crypto; use std::sync::Arc; use tauri::Emitter; +#[tauri::command] +pub fn import_ai_provider_icon(path: String) -> AppResult { + super::connection::import_connection_icon_data_url(&path) +} + #[tauri::command] pub fn start_ai_chat_stream( app: tauri::AppHandle, @@ -58,6 +65,50 @@ pub async fn refresh_ai_model_settings( Ok(config::mask_ai_settings(merged_ai)) } +#[tauri::command] +pub async fn test_ai_provider_connection( + app: tauri::AppHandle, + ai_settings: config::AiSettings, + credential_id: String, +) -> AppResult> { + let existing = config::load_app_settings(&app)?; + let merged_ai = config::merge_masked_ai_settings(&existing.ai, ai_settings); + ai::test_provider_connection(&merged_ai, &credential_id).await +} + +#[tauri::command] +pub async fn test_ai_model_connection( + app: tauri::AppHandle, + ai_settings: config::AiSettings, + model_id: String, +) -> AppResult<()> { + let existing = config::load_app_settings(&app)?; + let merged_ai = config::merge_masked_ai_settings(&existing.ai, ai_settings); + ai::test_model_connection(&merged_ai, &model_id).await +} + +#[tauri::command] +pub fn reveal_ai_provider_api_key(credential_id: String) -> AppResult { + let settings = storage::load_settings_doc::(SettingsDocKey::AppSettings)?; + let encrypted_key = settings + .ai + .provider_credentials + .iter() + .find(|credential| credential.id == credential_id) + .and_then(|credential| credential.api_key.as_ref()) + .or_else(|| { + settings + .ai + .provider_profiles + .iter() + .find(|profile| profile.id == credential_id) + .and_then(|profile| profile.api_key.as_ref()) + }) + .filter(|key| !key.is_empty()) + .ok_or_else(|| AppError::Config("AI provider API key is not configured".to_string()))?; + crypto::decrypt(encrypted_key) +} + fn merge_model_discoveries( settings: &config::AiSettings, discoveries: Vec, @@ -91,15 +142,13 @@ fn merge_model_discoveries( enabled: false, source: item.source, last_seen_at: Some(now.clone()), + supported_reasoning_efforts: None, }); } } for old in &settings.models { - if !seen.contains(&old.id) - && (old.source == config::AiModelSource::Manual - || old.backend == config::AiBackendKind::Codex) - { + if !seen.contains(&old.id) { merged.push(old.clone()); } } @@ -256,6 +305,7 @@ mod tests { enabled, source: config::AiModelSource::RustGenai, last_seen_at: None, + supported_reasoning_efforts: None, } } @@ -269,6 +319,7 @@ mod tests { enabled: true, source: config::AiModelSource::Manual, last_seen_at: None, + supported_reasoning_efforts: None, } } diff --git a/src-tauri/src/cmd/connection.rs b/src-tauri/src/cmd/connection.rs index 485021f1..4504b837 100644 --- a/src-tauri/src/cmd/connection.rs +++ b/src-tauri/src/cmd/connection.rs @@ -90,7 +90,7 @@ pub fn import_connection_icon( Ok(icon) } -fn import_connection_icon_data_url(path: &str) -> AppResult { +pub(crate) fn import_connection_icon_data_url(path: &str) -> AppResult { let trimmed = path.trim(); if trimmed.is_empty() { return Err(AppError::Config( diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index dd6436dd..aeae6a3a 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -83,15 +83,16 @@ pub use quick_command::{ pub use settings::{ AI_REQUEST_USER_AGENT_DEFAULT, ActionLinksMatcherSettings, AgentCommandExecutionMode, AiAgentKind, AiApiFormat, AiBackendKind, AiCustomActionConfig, AiMode, AiModelConfigItem, - AiModelSource, AiPermissionMode, AiProviderCredential, AiProviderKind, AiProviderProfile, - AiReasoningEffort, AiSettings, AppSettings, AppearanceSettings, ClaudeCodeIntegrationSettings, - CodexIntegrationSettings, CodexThreadMode, DiagnosticsLogLevel, DiagnosticsSettings, - ExternalMcpSessionScope, ExternalMcpSettings, GeneralSettings, InteractionSettings, - KeywordHighlightRule, ProxySettings, RecordingSettings, RiskLevel, SearchEngine, - SearchSettings, SecuritySettings, TerminalColorsConfig, TerminalSettings, ThemeColorsConfig, - ThemeConfig, TransferSettings, TranslationSettings, ai_model_id_for_credential, - ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, load_app_settings, - mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings, save_app_settings, + AiModelSource, AiPermissionMode, AiProviderApiProtocol, AiProviderCredential, AiProviderKind, + AiProviderProfile, AiReasoningEffort, AiSettings, AppSettings, AppearanceSettings, + ClaudeCodeIntegrationSettings, CodexIntegrationSettings, CodexThreadMode, DiagnosticsLogLevel, + DiagnosticsSettings, ExternalMcpSessionScope, ExternalMcpSettings, GeneralSettings, + InteractionSettings, KeywordHighlightRule, ProxySettings, RecordingSettings, RiskLevel, + SearchEngine, SearchSettings, SecuritySettings, TerminalColorsConfig, TerminalSettings, + ThemeColorsConfig, ThemeConfig, TransferSettings, TranslationSettings, + ai_model_id_for_credential, ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, + load_app_settings, mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings, + save_app_settings, }; #[allow(unused_imports)] pub use tunnel::{ diff --git a/src-tauri/src/config/settings/ai.rs b/src-tauri/src/config/settings/ai.rs index 449fcf57..46ad4f13 100644 --- a/src-tauri/src/config/settings/ai.rs +++ b/src-tauri/src/config/settings/ai.rs @@ -33,6 +33,15 @@ pub enum AiApiFormat { Responses, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AiProviderApiProtocol { + OpenaiCompatible, + Anthropic, + Gemini, + Ollama, +} + impl Default for AiApiFormat { fn default() -> Self { Self::ChatCompletions @@ -126,10 +135,26 @@ pub enum AiMode { pub enum AiReasoningEffort { Auto, None, + Minimal, Low, Medium, High, XHigh, + Max, + Ultra, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum AiModelReasoningEffort { + None, + Minimal, + Low, + Medium, + High, + XHigh, + Max, + Ultra, } impl Default for AiReasoningEffort { @@ -204,6 +229,8 @@ pub struct AiModelConfigItem { pub source: AiModelSource, #[serde(default)] pub last_seen_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supported_reasoning_efforts: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -294,6 +321,10 @@ pub struct AiProviderCredential { pub id: String, pub name: String, pub provider_kind: AiProviderKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon_data_url: Option, + #[serde(default)] + pub api_protocol: Option, #[serde(default)] pub api_format: AiApiFormat, #[serde(default)] @@ -426,7 +457,7 @@ fn default_provider_profiles() -> Vec { name: "OpenAI".to_string(), provider_kind: AiProviderKind::Openai, model: "gpt-4o-mini".to_string(), - base_url: None, + base_url: Some("https://api.openai.com/v1/".to_string()), api_key: None, enabled: false, }, @@ -435,7 +466,7 @@ fn default_provider_profiles() -> Vec { name: "Anthropic".to_string(), provider_kind: AiProviderKind::Anthropic, model: "claude-3-haiku-20240307".to_string(), - base_url: None, + base_url: Some("https://api.anthropic.com/v1/".to_string()), api_key: None, enabled: false, }, @@ -444,7 +475,7 @@ fn default_provider_profiles() -> Vec { name: "Google Gemini".to_string(), provider_kind: AiProviderKind::Gemini, model: "gemini-2.0-flash".to_string(), - base_url: None, + base_url: Some("https://generativelanguage.googleapis.com/v1beta/".to_string()), api_key: None, enabled: false, }, @@ -453,7 +484,7 @@ fn default_provider_profiles() -> Vec { name: "DeepSeek".to_string(), provider_kind: AiProviderKind::Deepseek, model: "deepseek-chat".to_string(), - base_url: None, + base_url: Some("https://api.deepseek.com/v1/".to_string()), api_key: None, enabled: false, }, @@ -534,6 +565,8 @@ fn credential_from_profile(profile: &AiProviderProfile) -> AiProviderCredential id: profile.id.clone(), name: profile.name.clone(), provider_kind: profile.provider_kind.clone(), + icon_data_url: None, + api_protocol: None, api_format: AiApiFormat::default(), base_url: profile.base_url.clone(), api_key: profile.api_key.clone(), @@ -571,6 +604,7 @@ fn model_from_profile(profile: &AiProviderProfile) -> Option AiModelSource::RustGenai }, last_seen_at: None, + supported_reasoning_efforts: None, }) } @@ -628,7 +662,7 @@ impl Default for AiSettings { .map(|item| item.id.clone()); Self { - schema_version: 6, + schema_version: 7, enabled: true, context_line_limit: default_context_line_limit(), redaction_enabled: true, @@ -715,7 +749,8 @@ pub fn merge_masked_ai_settings(current: &AiSettings, mut next: AiSettings) -> A pub fn normalize_ai_settings(settings: &mut AiSettings) -> bool { let original = serde_json::to_string(settings).unwrap_or_default(); - settings.schema_version = 6; + let migrating_provider_defaults = settings.schema_version < 7; + settings.schema_version = 7; if settings.request_user_agent.trim().is_empty() { settings.request_user_agent = default_request_user_agent(); } @@ -726,7 +761,9 @@ pub fn normalize_ai_settings(settings: &mut AiSettings) -> bool { } } - if settings.provider_credentials.is_empty() { + let migrating_legacy_credentials = + migrating_provider_defaults && settings.provider_credentials.is_empty(); + if migrating_legacy_credentials { settings.provider_credentials = settings .provider_profiles .iter() @@ -734,13 +771,27 @@ pub fn normalize_ai_settings(settings: &mut AiSettings) -> bool { .collect(); } + let defaults = default_provider_profiles(); for credential in &mut settings.provider_credentials { + // Older built-in credentials relied on SDK defaults. The settings UI and + // direct model discovery now need an explicit address. + if migrating_provider_defaults + && credential + .base_url + .as_deref() + .is_none_or(|url| url.trim().is_empty()) + && let Some(default) = defaults.iter().find(|profile| { + profile.id == credential.id && profile.provider_kind == credential.provider_kind + }) + { + credential.base_url = default.base_url.clone(); + } if is_builtin_ollama_provider(&credential.id, &credential.provider_kind) { migrate_legacy_ollama_base_url(&mut credential.base_url); } } - if settings.models.is_empty() { + if settings.models.is_empty() && migrating_legacy_credentials { let mut seen = HashSet::new(); settings.models = settings .provider_profiles @@ -977,7 +1028,7 @@ mod tests { settings.active_profile_id = "deepseek".to_string(); assert!(normalize_ai_settings(&mut settings)); - assert_eq!(settings.schema_version, 6); + assert_eq!(settings.schema_version, 7); assert!(!settings.provider_credentials.is_empty()); assert!( settings @@ -1030,7 +1081,7 @@ mod tests { ); assert!(normalize_ai_settings(&mut settings)); - assert_eq!(settings.schema_version, 6); + assert_eq!(settings.schema_version, 7); assert_eq!( settings.provider_credentials[0].api_format, AiApiFormat::ChatCompletions @@ -1056,7 +1107,7 @@ mod tests { assert!(normalize_ai_settings(&mut settings)); - assert_eq!(settings.schema_version, 6); + assert_eq!(settings.schema_version, 7); assert_eq!(settings.models[0].backend, AiBackendKind::Genai); assert!(!settings.codex.enabled); } @@ -1073,6 +1124,7 @@ mod tests { enabled: true, source: AiModelSource::RustGenai, last_seen_at: None, + supported_reasoning_efforts: None, }], ..AiSettings::default() }; @@ -1095,6 +1147,37 @@ mod tests { ); } + #[test] + fn normalize_backfills_legacy_addresses_once_and_keeps_custom_endpoints() { + let mut settings = AiSettings::default(); + settings.schema_version = 6; + settings.provider_credentials[0].base_url = None; + settings.provider_credentials[1].base_url = Some("https://proxy.example/v1/".into()); + assert!(normalize_ai_settings(&mut settings)); + assert_eq!( + settings.provider_credentials[0].base_url.as_deref(), + Some("https://api.openai.com/v1/") + ); + assert_eq!( + settings.provider_credentials[1].base_url.as_deref(), + Some("https://proxy.example/v1/") + ); + + settings.provider_credentials[0].base_url = None; + normalize_ai_settings(&mut settings); + assert!(settings.provider_credentials[0].base_url.is_none()); + } + + #[test] + fn normalize_does_not_recreate_deleted_providers_or_models() { + let mut settings = AiSettings::default(); + settings.provider_credentials.clear(); + settings.models.clear(); + normalize_ai_settings(&mut settings); + assert!(settings.provider_credentials.is_empty()); + assert!(settings.models.is_empty()); + } + #[test] fn default_ai_settings_use_ollama_native_base_url() { let settings = AiSettings::default(); @@ -1163,6 +1246,8 @@ mod tests { id: "credential-openai-compatible".to_string(), name: "OpenAI Compatible".to_string(), provider_kind: AiProviderKind::OpenaiCompatible, + icon_data_url: None, + api_protocol: None, api_format: AiApiFormat::default(), base_url: Some(OLLAMA_LEGACY_DEFAULT_BASE_URL.to_string()), api_key: None, diff --git a/src-tauri/src/config/settings/mod.rs b/src-tauri/src/config/settings/mod.rs index e9f5461e..06e55867 100644 --- a/src-tauri/src/config/settings/mod.rs +++ b/src-tauri/src/config/settings/mod.rs @@ -14,11 +14,11 @@ mod translation; pub use ai::{ AI_REQUEST_USER_AGENT_DEFAULT, AgentCommandExecutionMode, AiAgentKind, AiApiFormat, AiBackendKind, AiCustomActionConfig, AiMode, AiModelConfigItem, AiModelSource, - AiPermissionMode, AiProviderCredential, AiProviderKind, AiProviderProfile, AiReasoningEffort, - AiSettings, ClaudeCodeIntegrationSettings, CodexIntegrationSettings, CodexThreadMode, - ExternalMcpSessionScope, ExternalMcpSettings, RiskLevel, ai_model_id_for_credential, - ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, mask_ai_settings, - merge_masked_ai_settings, normalize_ai_settings, + AiPermissionMode, AiProviderApiProtocol, AiProviderCredential, AiProviderKind, + AiProviderProfile, AiReasoningEffort, AiSettings, ClaudeCodeIntegrationSettings, + CodexIntegrationSettings, CodexThreadMode, ExternalMcpSessionScope, ExternalMcpSettings, + RiskLevel, ai_model_id_for_credential, ai_model_id_for_provider, decrypt_ai_settings, + encrypt_ai_settings, mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings, }; pub use appearance::{AppearanceSettings, TerminalColorsConfig, ThemeColorsConfig, ThemeConfig}; pub use diagnostics::{DiagnosticsLogLevel, DiagnosticsSettings}; diff --git a/src-tauri/src/core/ai/mod.rs b/src-tauri/src/core/ai/mod.rs index 4edd6b1f..7eac9fe1 100644 --- a/src-tauri/src/core/ai/mod.rs +++ b/src-tauri/src/core/ai/mod.rs @@ -22,7 +22,10 @@ pub use history::{ append_ai_audit, clear_ai_history, delete_ai_session, get_ai_audit_logs, get_ai_messages, get_ai_sessions, rebind_ai_session, }; -pub use model::{list_model_names, list_model_names_for_settings}; +pub use model::{ + list_model_names, list_model_names_for_settings, test_model_connection, + test_provider_connection, +}; pub(crate) use redaction::redact_sensitive_text; pub use stream::{cancel_chat_stream, start_chat_stream}; pub(crate) use types::AiCaptureEvent; diff --git a/src-tauri/src/core/ai/model.rs b/src-tauri/src/core/ai/model.rs index 4a5effb0..1ca3ed89 100644 --- a/src-tauri/src/core/ai/model.rs +++ b/src-tauri/src/core/ai/model.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::time::Duration; use genai::adapter::AdapterKind; -use genai::chat::{ChatOptions, ReasoningEffort}; +use genai::chat::{ChatMessage, ChatOptions, ChatRequest, ReasoningEffort}; use genai::resolver::{AuthData, Endpoint, ServiceTargetResolver}; use genai::{Client, ModelIden, WebConfig}; use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; @@ -10,14 +10,18 @@ use serde_json::Value; use crate::config::{ self, AI_REQUEST_USER_AGENT_DEFAULT, AiApiFormat, AiBackendKind, AiModelConfigItem, - AiModelSource, AiProviderCredential, AiProviderKind, AiReasoningEffort, AiSettings, - ai_model_id_for_credential, + AiModelSource, AiProviderApiProtocol, AiProviderCredential, AiProviderKind, AiReasoningEffort, + AiSettings, ai_model_id_for_credential, ai_model_id_for_provider, }; use crate::error::{AppError, AppResult}; use crate::utils::url::{join_api_base_url, normalize_api_base_url}; use super::types::{AiChatRequest, AiModelDiscovery}; +const MODEL_TEST_SYSTEM_PROMPT: &str = "You are NyaTerm's terminal AI connectivity check. Reply with OK only; do not suggest or run commands."; +const MODEL_TEST_USER_PROMPT: &str = "Confirm that this model can respond."; +const MODEL_TEST_MAX_OUTPUT_TOKENS: u32 = 64; + #[derive(Debug, Clone)] pub(super) struct ResolvedAiModel { pub model_name: String, @@ -59,6 +63,8 @@ pub(super) fn build_chat_options(settings: &AiSettings) -> ChatOptions { if let Some(reasoning_effort) = genai_reasoning_effort(&settings.default_reasoning_effort) { options = options.with_reasoning_effort(reasoning_effort); + } else if matches!(settings.default_reasoning_effort, AiReasoningEffort::Ultra) { + options = options.with_extra_body(serde_json::json!({ "reasoning_effort": "ultra" })); } options @@ -68,10 +74,13 @@ fn genai_reasoning_effort(value: &AiReasoningEffort) -> Option match value { AiReasoningEffort::Auto => None, AiReasoningEffort::None => Some(ReasoningEffort::None), + AiReasoningEffort::Minimal => Some(ReasoningEffort::Minimal), AiReasoningEffort::Low => Some(ReasoningEffort::Low), AiReasoningEffort::Medium => Some(ReasoningEffort::Medium), AiReasoningEffort::High => Some(ReasoningEffort::High), AiReasoningEffort::XHigh => Some(ReasoningEffort::XHigh), + AiReasoningEffort::Max => Some(ReasoningEffort::Max), + AiReasoningEffort::Ultra => None, } } @@ -88,6 +97,13 @@ pub(super) fn resolve_request_model( let selected_model = resolve_request_model_config(settings, request)?; + resolve_model_config(settings, &selected_model) +} + +fn resolve_model_config( + settings: &AiSettings, + selected_model: &AiModelConfigItem, +) -> AppResult { if selected_model.backend == AiBackendKind::Codex { return Err(AppError::Config( "Codex models must be routed through codex app-server".to_string(), @@ -100,7 +116,7 @@ pub(super) fn resolve_request_model( .or_else(|| infer_provider_kind_from_model_id(&selected_model.id)); let credential = - resolve_model_credential(settings, &selected_model, model_provider_kind.as_ref())?; + resolve_model_credential(settings, selected_model, model_provider_kind.as_ref())?; let provider_kind = credential .as_ref() .map(|credential| credential.provider_kind.clone()) @@ -114,7 +130,6 @@ pub(super) fn resolve_request_model( validate_model_credential(&provider_kind, credential.as_ref())?; tracing::info!( - requested_model_id = ?request.model_id, resolved_model_id = %selected_model.id, resolved_model_name = %selected_model.name, provider_kind = ?provider_kind, @@ -133,6 +148,47 @@ pub(super) fn resolve_request_model( }) } +pub async fn test_model_connection(settings: &AiSettings, model_id: &str) -> AppResult<()> { + // The chat panel's selected effort may belong to a different model. + // Connectivity checks use the target model's own default reasoning behavior. + let mut settings = settings.clone(); + settings.default_reasoning_effort = AiReasoningEffort::Auto; + let selected_model = settings + .models + .iter() + .find(|model| model.id == model_id) + .ok_or_else(|| AppError::Config("AI model is no longer configured".to_string()))?; + let resolved_model = resolve_model_config(&settings, selected_model)?; + let timeout = Duration::from_millis(settings.timeout_ms.clamp(1_000, 60_000)); + + tokio::time::timeout(timeout, async { + if super::responses::uses_responses_api(&resolved_model) { + return super::responses::test_responses_model( + &settings, + &resolved_model, + MODEL_TEST_SYSTEM_PROMPT, + MODEL_TEST_USER_PROMPT, + MODEL_TEST_MAX_OUTPUT_TOKENS, + ) + .await; + } + + let client = build_client(&resolved_model, &settings)?; + let request = ChatRequest::new(vec![ + ChatMessage::system(MODEL_TEST_SYSTEM_PROMPT), + ChatMessage::user(MODEL_TEST_USER_PROMPT), + ]); + let options = build_chat_options(&settings).with_max_tokens(MODEL_TEST_MAX_OUTPUT_TOKENS); + client + .exec_chat(&resolved_model.model_name, request, Some(&options)) + .await + .map_err(|error| AppError::Config(format!("AI model test failed: {error}")))?; + Ok(()) + }) + .await + .map_err(|_| AppError::Config("AI model test timed out".to_string()))? +} + fn infer_provider_kind_from_model_id(model_id: &str) -> Option { let (prefix, _) = model_id.split_once(':')?; match prefix { @@ -156,7 +212,14 @@ fn resolve_model_credential( model: &AiModelConfigItem, provider_kind: Option<&AiProviderKind>, ) -> AppResult> { - if let Some(credential_id) = model.credential_id.as_deref() { + // Legacy built-in models use the provider ID as their implicit credential ID. + // Never let a newly added account of the same kind capture those models. + let implicit_credential_id = model + .id + .split_once(':') + .map(|(prefix, _)| prefix) + .filter(|prefix| is_builtin_ai_provider_credential_id(prefix)); + if let Some(credential_id) = model.credential_id.as_deref().or(implicit_credential_id) { let credential = settings .provider_credentials .iter() @@ -229,7 +292,7 @@ pub(super) fn build_client(model: &ResolvedAiModel, settings: &AiSettings) -> Ap "Building AI client" ); - let adapter_kind = adapter_kind(&model.provider_kind); + let adapter_kind = adapter_kind_for_model(model); let mapped_model = genai_model_name(&model.provider_kind, &model.model_name); let api_key = model .credential @@ -243,7 +306,13 @@ pub(super) fn build_client(model: &ResolvedAiModel, settings: &AiSettings) -> Ap .map(normalize_api_base_url) .transpose()? .filter(|value| !value.trim().is_empty()); - let allows_empty_auth = model.provider_kind == AiProviderKind::OpenaiCompatible; + let allows_empty_auth = model.provider_kind == AiProviderKind::OpenaiCompatible + || (model.provider_kind == AiProviderKind::Ollama + && model + .credential + .as_ref() + .and_then(|credential| credential.api_protocol.as_ref()) + == Some(&AiProviderApiProtocol::OpenaiCompatible)); let resolver = ServiceTargetResolver::from_resolver_fn(move |service_target: genai::ServiceTarget| { @@ -315,6 +384,24 @@ fn adapter_kind(kind: &AiProviderKind) -> AdapterKind { } } +fn adapter_kind_for_model(model: &ResolvedAiModel) -> AdapterKind { + match model + .credential + .as_ref() + .and_then(|credential| credential.api_protocol.as_ref()) + { + None => adapter_kind(&model.provider_kind), + Some(AiProviderApiProtocol::OpenaiCompatible) => match model.provider_kind { + AiProviderKind::Deepseek => AdapterKind::DeepSeek, + AiProviderKind::Groq => AdapterKind::Groq, + _ => AdapterKind::OpenAI, + }, + Some(AiProviderApiProtocol::Anthropic) => AdapterKind::Anthropic, + Some(AiProviderApiProtocol::Gemini) => AdapterKind::Gemini, + Some(AiProviderApiProtocol::Ollama) => AdapterKind::Ollama, + } +} + fn genai_model_name(provider_kind: &AiProviderKind, model_name: &str) -> String { if matches!(provider_kind, AiProviderKind::Deepseek) && let Some(base_model_name) = model_name.strip_suffix("-none") @@ -333,54 +420,52 @@ pub async fn list_model_names(app: &tauri::AppHandle) -> AppResult AppResult> { - let custom_credentials = openai_compatible_model_discovery_credentials(settings); + let credentials = model_discovery_credentials(settings); let mut models = BTreeMap::new(); let mut errors = Vec::new(); - for credential in &custom_credentials { - let base_url = credential - .base_url - .as_deref() - .unwrap_or("") - .trim() - .to_string(); - if base_url.is_empty() { - continue; - } - let api_key = credential.api_key.clone().filter(|v| !v.trim().is_empty()); + for credential in credentials { let label = credential.name.as_str(); tracing::info!( credential = label, - url = base_url, - "Fetching model list from custom provider" + provider_kind = ?credential.provider_kind, + "Fetching model list from provider" ); - match fetch_openai_compatible_models(&base_url, api_key.as_deref(), settings).await { + match fetch_credential_model_names(credential, settings).await { Ok(names) => { tracing::info!( credential = label, count = names.len(), - models = ?names, - "Fetched models from custom provider" + "Fetched models from provider" ); for name in names { let trimmed = name.trim(); if trimmed.is_empty() { continue; } - let id = ai_model_id_for_credential(&credential.id, trimmed); + let builtin = is_builtin_ai_provider_credential_id(&credential.id); + let id = if builtin { + ai_model_id_for_provider(&credential.provider_kind, trimmed) + } else { + ai_model_id_for_credential(&credential.id, trimmed) + }; models.entry(id.clone()).or_insert(AiModelDiscovery { id, name: trimmed.to_string(), backend: AiBackendKind::Genai, - provider_kind: Some(AiProviderKind::OpenaiCompatible), - credential_id: Some(credential.id.clone()), + provider_kind: Some(credential.provider_kind.clone()), + credential_id: if builtin { + None + } else { + Some(credential.id.clone()) + }, source: AiModelSource::RustGenai, }); } } Err(error) => { - tracing::warn!(credential = label, %error, "Failed to fetch models from custom provider"); + tracing::warn!(credential = label, %error, "Failed to fetch models from provider"); errors.push(format!("{label}: {error}")); } } @@ -396,17 +481,210 @@ pub async fn list_model_names_for_settings( Ok(models.into_values().collect()) } -fn openai_compatible_model_discovery_credentials( +pub async fn test_provider_connection( settings: &AiSettings, -) -> Vec<&AiProviderCredential> { + credential_id: &str, +) -> AppResult> { + let credential = settings + .provider_credentials + .iter() + .find(|credential| credential.id == credential_id && credential.enabled) + .ok_or_else(|| AppError::Config("No enabled AI provider selected".to_string()))?; + + fetch_credential_model_names(credential, settings).await +} + +async fn fetch_credential_model_names( + credential: &AiProviderCredential, + settings: &AiSettings, +) -> AppResult> { + let base_url = credential.base_url.as_deref().unwrap_or_default().trim(); + if base_url.is_empty() { + return Err(AppError::Config( + "AI provider API base URL is required".to_string(), + )); + } + + let api_key = credential + .api_key + .as_deref() + .filter(|value| !value.trim().is_empty()); + let protocol = + credential + .api_protocol + .clone() + .unwrap_or_else(|| match &credential.provider_kind { + AiProviderKind::Anthropic => AiProviderApiProtocol::Anthropic, + AiProviderKind::Gemini => AiProviderApiProtocol::Gemini, + AiProviderKind::Ollama => AiProviderApiProtocol::Ollama, + _ => AiProviderApiProtocol::OpenaiCompatible, + }); + match protocol { + AiProviderApiProtocol::OpenaiCompatible => { + fetch_openai_compatible_models(base_url, api_key, settings).await + } + AiProviderApiProtocol::Anthropic => { + fetch_provider_model_names( + base_url, + api_key, + settings, + "models", + Some(("limit", "1000")), + "data", + "id", + AiProviderApiProtocol::Anthropic, + ) + .await + } + AiProviderApiProtocol::Gemini => { + fetch_provider_model_names( + base_url, + api_key, + settings, + "models", + Some(("pageSize", "1000")), + "models", + "name", + AiProviderApiProtocol::Gemini, + ) + .await + } + AiProviderApiProtocol::Ollama => { + fetch_provider_model_names( + base_url, + api_key, + settings, + "api/tags", + None, + "models", + "name", + AiProviderApiProtocol::Ollama, + ) + .await + } + } +} + +async fn fetch_provider_model_names( + base_url: &str, + api_key: Option<&str>, + settings: &AiSettings, + path: &str, + query: Option<(&str, &str)>, + response_field: &str, + model_name_field: &str, + protocol: AiProviderApiProtocol, +) -> AppResult> { + let url = join_api_base_url(base_url, path)?; + let mut url = reqwest::Url::parse(&url) + .map_err(|error| AppError::Config(format!("Invalid model list URL: {error}")))?; + if let Some((name, value)) = query { + url.query_pairs_mut().append_pair(name, value); + } + let client = reqwest::Client::builder() + .default_headers(ai_request_headers(settings)?) + .build() + .map_err(|error| AppError::Config(format!("Failed to build AI HTTP client: {error}")))?; + let mut names = Vec::new(); + let mut cursor: Option<(&str, String)> = None; + let mut seen_cursors = std::collections::HashSet::new(); + for _ in 0..100 { + let mut page_url = url.clone(); + if let Some((key, value)) = &cursor { + page_url.query_pairs_mut().append_pair(key, value); + } + let mut request = client + .get(page_url.as_str()) + .timeout(Duration::from_secs(15)); + if let Some(key) = api_key { + request = match protocol { + AiProviderApiProtocol::Anthropic => request + .header("x-api-key", key) + .header("anthropic-version", "2023-06-01"), + AiProviderApiProtocol::Gemini => request.header("x-goog-api-key", key), + AiProviderApiProtocol::Ollama => request.bearer_auth(key), + AiProviderApiProtocol::OpenaiCompatible => request.bearer_auth(key), + }; + } else if protocol == AiProviderApiProtocol::Anthropic { + request = request.header("anthropic-version", "2023-06-01"); + } + let response = request.send().await.map_err(|error| { + AppError::Config(format!("Failed to fetch models from {url}: {error}")) + })?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AppError::Config(format!( + "Failed to fetch models from {url}: {status} {body}" + ))); + } + let body: Value = response + .json() + .await + .map_err(|error| AppError::Config(format!("Invalid JSON from {url}: {error}")))?; + let entries = body[response_field] + .as_array() + .ok_or_else(|| AppError::Config(format!("Invalid model list from {url}")))?; + for entry in entries { + let name = entry[model_name_field] + .as_str() + .filter(|name| !name.trim().is_empty()) + .ok_or_else(|| AppError::Config("Invalid model list: missing model ID".into()))?; + names.push(if protocol == AiProviderApiProtocol::Gemini { + name.strip_prefix("models/").unwrap_or(name).to_string() + } else { + name.to_string() + }); + } + cursor = next_models_cursor(&body, &protocol)?; + match &cursor { + None => return Ok(names), + Some((_, value)) if !seen_cursors.insert(value.clone()) => { + return Err(AppError::Config( + "Model list pagination repeated a cursor".into(), + )); + } + _ => {} + } + } + Err(AppError::Config( + "Model list pagination exceeded 100 pages".into(), + )) +} + +fn next_models_cursor( + body: &Value, + protocol: &AiProviderApiProtocol, +) -> AppResult> { + let (key, value) = match protocol { + AiProviderApiProtocol::Anthropic if body["has_more"].as_bool() == Some(true) => { + let value = body["last_id"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::Config("Model list has more pages but no last_id".into()) + })?; + ("after_id", value) + } + AiProviderApiProtocol::Gemini => { + let Some(value) = body["nextPageToken"] + .as_str() + .filter(|value| !value.is_empty()) + else { + return Ok(None); + }; + ("pageToken", value) + } + _ => return Ok(None), + }; + Ok(Some((key, value.to_string()))) +} + +fn model_discovery_credentials(settings: &AiSettings) -> Vec<&AiProviderCredential> { settings .provider_credentials .iter() - .filter(|credential| { - credential.enabled - && credential.provider_kind == AiProviderKind::OpenaiCompatible - && !is_builtin_ai_provider_credential_id(&credential.id) - }) + .filter(|credential| credential.enabled) .collect() } @@ -459,15 +737,25 @@ async fn fetch_openai_compatible_models( .json() .await .map_err(|e| AppError::Config(format!("Invalid JSON from {url}: {e}")))?; - let names: Vec = body["data"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|item| item["id"].as_str().map(String::from)) - .collect() + parse_openai_model_names(&body) +} + +fn parse_openai_model_names(body: &Value) -> AppResult> { + let models = body["data"].as_array().ok_or_else(|| { + AppError::Config("Invalid models response: expected a data array".to_string()) + })?; + models + .iter() + .map(|item| { + item["id"] + .as_str() + .filter(|name| !name.trim().is_empty()) + .map(String::from) + .ok_or_else(|| { + AppError::Config("Invalid models response: missing model ID".to_string()) + }) }) - .unwrap_or_default(); - Ok(names) + .collect() } fn openai_compatible_models_url(base_url: &str) -> AppResult { @@ -488,6 +776,92 @@ mod tests { } } + #[test] + fn builtin_models_keep_their_original_account() { + let mut settings = AiSettings::default(); + settings.provider_credentials = vec![ + test_credential_with_id("credential-new", AiProviderKind::Openai, Some("new-key")), + test_credential_with_id("openai", AiProviderKind::Openai, Some("original-key")), + ]; + let model = settings + .models + .iter() + .find(|model| model.id.starts_with("openai:")) + .unwrap(); + let credential = resolve_model_credential(&settings, model, Some(&AiProviderKind::Openai)) + .unwrap() + .unwrap(); + assert_eq!(credential.id, "openai"); + + settings.provider_credentials[1].enabled = false; + let model = settings + .models + .iter() + .find(|model| model.id.starts_with("openai:")) + .unwrap(); + assert!(resolve_model_credential(&settings, model, Some(&AiProviderKind::Openai)).is_err()); + settings.provider_credentials.pop(); + let model = settings + .models + .iter() + .find(|model| model.id.starts_with("openai:")) + .unwrap(); + assert!(resolve_model_credential(&settings, model, Some(&AiProviderKind::Openai)).is_err()); + } + + #[test] + fn models_response_distinguishes_empty_lists_from_invalid_payloads() { + assert_eq!( + parse_openai_model_names(&serde_json::json!({"data": []})).unwrap(), + Vec::::new() + ); + assert_eq!( + parse_openai_model_names(&serde_json::json!({"data": [{"id": "model-a"}]})).unwrap(), + vec!["model-a"] + ); + assert!(parse_openai_model_names(&serde_json::json!({"error": "unauthorized"})).is_err()); + assert!(parse_openai_model_names(&serde_json::json!({"data": [{}]})).is_err()); + } + + #[test] + fn model_pagination_uses_protocol_specific_cursors() { + assert_eq!( + next_models_cursor( + &serde_json::json!({"has_more": true, "last_id": "claude-a"}), + &AiProviderApiProtocol::Anthropic + ) + .unwrap(), + Some(("after_id", "claude-a".into())) + ); + assert!( + next_models_cursor( + &serde_json::json!({"has_more": true}), + &AiProviderApiProtocol::Anthropic + ) + .is_err() + ); + assert_eq!( + next_models_cursor( + &serde_json::json!({"nextPageToken": "page-2"}), + &AiProviderApiProtocol::Gemini + ) + .unwrap(), + Some(("pageToken", "page-2".into())) + ); + assert_eq!( + next_models_cursor( + &serde_json::json!({"has_more": false}), + &AiProviderApiProtocol::Anthropic + ) + .unwrap(), + None + ); + assert_eq!( + next_models_cursor(&serde_json::json!({}), &AiProviderApiProtocol::Gemini).unwrap(), + None + ); + } + fn test_credential(kind: AiProviderKind, api_key: Option<&str>) -> AiProviderCredential { test_credential_with_id("credential-test", kind, api_key) } @@ -501,6 +875,8 @@ mod tests { id: id.to_string(), name: "Test Provider".to_string(), provider_kind: kind, + icon_data_url: None, + api_protocol: None, api_format: AiApiFormat::default(), base_url: Some("https://api.example.com/v1/".to_string()), api_key: api_key.map(str::to_string), @@ -689,7 +1065,7 @@ mod tests { } #[test] - fn model_discovery_credentials_only_include_custom_openai_compatible() { + fn model_discovery_credentials_include_enabled_providers() { let mut settings = AiSettings::default(); settings.provider_credentials = vec![ test_credential_with_id("openai", AiProviderKind::OpenaiCompatible, None), @@ -710,12 +1086,20 @@ mod tests { }, ]; - let ids: Vec<_> = openai_compatible_model_discovery_credentials(&settings) + let ids: Vec<_> = model_discovery_credentials(&settings) .into_iter() .map(|credential| credential.id.as_str()) .collect(); - assert_eq!(ids, vec!["credential-openai"]); + assert_eq!( + ids, + vec![ + "openai", + "credential-openai", + "credential-anthropic", + "credential-gemini" + ] + ); } #[test] @@ -734,6 +1118,7 @@ mod tests { enabled: true, source: AiModelSource::Manual, last_seen_at: None, + supported_reasoning_efforts: None, }]; settings.default_model_id = Some("credential-b:claude-test".to_string()); diff --git a/src-tauri/src/core/ai/responses.rs b/src-tauri/src/core/ai/responses.rs index 34540dfe..2c73b5b4 100644 --- a/src-tauri/src/core/ai/responses.rs +++ b/src-tauri/src/core/ai/responses.rs @@ -6,7 +6,9 @@ use serde_json::{Value, json}; use tauri::AppHandle; use tokio::sync::oneshot; -use crate::config::{AiApiFormat, AiProviderKind, AiReasoningEffort, AiSettings}; +use crate::config::{ + AiApiFormat, AiProviderApiProtocol, AiProviderKind, AiReasoningEffort, AiSettings, +}; use crate::error::{AppError, AppResult}; use crate::utils::url::{join_api_base_url, normalize_api_base_url}; @@ -36,11 +38,93 @@ enum ResponsesSseEvent { } pub(super) fn uses_responses_api(model: &ResolvedAiModel) -> bool { - model.api_format == AiApiFormat::Responses - && matches!( + if model.api_format != AiApiFormat::Responses { + return false; + } + + match model + .credential + .as_ref() + .and_then(|credential| credential.api_protocol.as_ref()) + { + Some(AiProviderApiProtocol::OpenaiCompatible) => true, + Some(_) => false, + None => matches!( model.provider_kind, AiProviderKind::Openai | AiProviderKind::OpenaiCompatible - ) + ), + } +} + +pub(super) async fn test_responses_model( + settings: &AiSettings, + model: &ResolvedAiModel, + system_prompt: &str, + user_prompt: &str, + max_output_tokens: u32, +) -> AppResult<()> { + let url = responses_url(model)?; + let client = reqwest::Client::builder() + .default_headers(ai_request_headers(settings)?) + .build() + .map_err(|error| AppError::Config(format!("Failed to build AI HTTP client: {error}")))?; + let mut body = json!({ + "model": model.model_name, + "input": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_prompt }, + ], + "max_output_tokens": max_output_tokens, + "stream": false, + "store": false, + }); + if let Some(effort) = responses_reasoning_effort(&settings.default_reasoning_effort) { + body["reasoning"] = json!({ "effort": effort }); + } + + let mut request = client.post(&url).json(&body); + if let Some(key) = model + .credential + .as_ref() + .and_then(|credential| credential.api_key.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + request = request.bearer_auth(key); + } + let response = request + .send() + .await + .map_err(|error| AppError::Config(format!("AI model test failed: {error}")))?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AppError::Config(format!( + "AI model test failed: {status} {}", + truncate_preview(&body, 500) + ))); + } + let body: Value = response + .json() + .await + .map_err(|error| AppError::Config(format!("Invalid AI model test response: {error}")))?; + if let Some(error) = body.get("error").filter(|error| !error.is_null()) { + let message = error + .get("message") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| error.to_string()); + return Err(AppError::Config(format!("AI model test failed: {message}"))); + } + if matches!(body["status"].as_str(), Some("failed" | "cancelled")) { + return Err(AppError::Config("AI model test failed".to_string())); + } + if body["id"].as_str().is_none() && body["output"].as_array().is_none() { + return Err(AppError::Config( + "AI model test returned an invalid response".to_string(), + )); + } + Ok(()) } pub(super) async fn run_responses_chat_messages_stream( @@ -482,10 +566,13 @@ fn responses_reasoning_effort(value: &AiReasoningEffort) -> Option<&'static str> match value { AiReasoningEffort::Auto => None, AiReasoningEffort::None => Some("none"), + AiReasoningEffort::Minimal => Some("minimal"), AiReasoningEffort::Low => Some("low"), AiReasoningEffort::Medium => Some("medium"), AiReasoningEffort::High => Some("high"), AiReasoningEffort::XHigh => Some("xhigh"), + AiReasoningEffort::Max => Some("max"), + AiReasoningEffort::Ultra => Some("ultra"), } } @@ -509,7 +596,7 @@ fn responses_base_url(model: &ResolvedAiModel) -> AppResult { "Responses API requires an API base URL for OpenAI-compatible credentials".to_string(), )), (None, provider_kind) => Err(AppError::Config(format!( - "Responses API is not supported for {provider_kind:?}" + "Responses API requires an API base URL for {provider_kind:?}" ))), } } @@ -528,6 +615,8 @@ mod tests { id: "credential-test".to_string(), name: "Test".to_string(), provider_kind: AiProviderKind::OpenaiCompatible, + icon_data_url: None, + api_protocol: None, api_format: AiApiFormat::Responses, base_url: base_url.map(str::to_string), api_key: Some("key".to_string()), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4c970a03..bed5cc74 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -159,8 +159,12 @@ pub fn run() { cmd::updater::download_portable_update, cmd::updater::apply_portable_update, cmd::ai::start_ai_chat_stream, + cmd::ai::import_ai_provider_icon, cmd::ai::list_ai_model_names, cmd::ai::refresh_ai_model_settings, + cmd::ai::test_ai_provider_connection, + cmd::ai::test_ai_model_connection, + cmd::ai::reveal_ai_provider_api_key, cmd::ai::cancel_ai_chat_stream, cmd::ai::detect_codex_cli, cmd::ai::get_codex_account_status, diff --git a/src/components/ai/ProviderBadge.tsx b/src/components/ai/ProviderBadge.tsx new file mode 100644 index 00000000..bded63ac --- /dev/null +++ b/src/components/ai/ProviderBadge.tsx @@ -0,0 +1,52 @@ +import type { IconType } from "react-icons"; +import { MdCloud } from "react-icons/md"; +import { RiGrokAiFill, RiOpenaiFill, RiZhipuAiFill } from "react-icons/ri"; +import { SiAnthropic, SiDeepseek, SiGooglegemini, SiOllama, SiXiaomi } from "react-icons/si"; +import type { AIProviderKind } from "@/types/global"; + +const PROVIDER_ICONS: Partial> = { + openai: RiOpenaiFill, + anthropic: SiAnthropic, + gemini: SiGooglegemini, + deepseek: SiDeepseek, + xai: RiGrokAiFill, + zai: RiZhipuAiFill, + ollama: SiOllama, + mimo: SiXiaomi, +}; + +const PROVIDER_ICON_INITIALS: Partial> = { + cohere: "C", +}; + +export function ProviderBadge({ + kind, + iconDataUrl, + size = "default", +}: { + kind?: AIProviderKind | null; + iconDataUrl?: string | null; + size?: "default" | "sm"; +}) { + const Icon = kind ? PROVIDER_ICONS[kind] : undefined; + const initials = kind ? PROVIDER_ICON_INITIALS[kind] : undefined; + const isSmall = size === "sm"; + return ( + + ); +} diff --git a/src/components/panel/ai/AIAssistantPanel.tsx b/src/components/panel/ai/AIAssistantPanel.tsx index d5f83ea7..c0b8734d 100644 --- a/src/components/panel/ai/AIAssistantPanel.tsx +++ b/src/components/panel/ai/AIAssistantPanel.tsx @@ -43,7 +43,11 @@ import { useApp } from "@/context/AppContext"; import { useTheme } from "@/context/ThemeContext"; import type { AIErrorDetectedDetail } from "@/lib/aiEvents"; import { AI_ERROR_DETECTED_EVENT } from "@/lib/aiEvents"; -import { resolveAILanguage, selectDefaultAIModel } from "@/lib/aiSettings"; +import { + getModelReasoningOptions, + resolveAILanguage, + selectDefaultAIModel, +} from "@/lib/aiSettings"; import { classifyAIStreamControlEvent } from "@/lib/aiStreamEvent"; import { getErrorMessage } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; @@ -198,6 +202,12 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP (isGenaiModel(storedSelectedModel) ? storedSelectedModel : null) ?? genaiModels[0] ?? null ); }, [genaiModels, runMode, storedSelectedModel]); + const configuredReasoningEffort = aiSettings.default_reasoning_effort ?? "auto"; + const selectedReasoningEffort = getModelReasoningOptions(selectedModel).includes( + configuredReasoningEffort, + ) + ? configuredReasoningEffort + : "auto"; const selectableModels = runMode === "ask" || runMode === "nyaterm_agent" ? genaiModels : []; const externalModelLabel = runMode === "codex_agent" @@ -296,11 +306,27 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP }) : (activePane?.name ?? selectedModel?.name ?? externalModelLabel ?? t("ai.notConfigured")); useEffect(() => { - if (!selectedModel || selectedModel.id === aiSettings.default_model_id) return; + if (!selectedModel) return; + if ( + selectedModel.id === aiSettings.default_model_id && + configuredReasoningEffort === selectedReasoningEffort + ) { + return; + } updateAppSettings({ - ai: { ...aiSettings, default_model_id: selectedModel.id }, + ai: { + ...aiSettings, + default_model_id: selectedModel.id, + default_reasoning_effort: selectedReasoningEffort, + }, }); - }, [aiSettings, selectedModel, updateAppSettings]); + }, [ + aiSettings, + configuredReasoningEffort, + selectedModel, + selectedReasoningEffort, + updateAppSettings, + ]); const filteredSessions = useMemo(() => { const keyword = historyQuery.trim().toLowerCase(); @@ -1987,12 +2013,12 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP />
-
+
-
+
{externalModelLabel ? (
diff --git a/src/components/panel/ai/ModelCombobox.tsx b/src/components/panel/ai/ModelCombobox.tsx index 781171f0..d135f8fb 100644 --- a/src/components/panel/ai/ModelCombobox.tsx +++ b/src/components/panel/ai/ModelCombobox.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { MdCheck, MdExpandMore } from "react-icons/md"; +import { ProviderBadge } from "@/components/ai/ProviderBadge"; import { Button } from "@/components/ui/button"; import { Command, @@ -10,10 +11,27 @@ import { CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { getModelProviderLabel } from "@/lib/aiSettings"; +import { getModelProviderLabel, getModelReasoningOptions } from "@/lib/aiSettings"; import type { AIModelConfigItem, AIProviderCredential, AIReasoningEffort } from "@/types/global"; -const REASONING_OPTIONS: AIReasoningEffort[] = ["auto", "none", "low", "medium", "high", "xhigh"]; +function ModelProviderBadge({ + model, + credentials, +}: { + model: AIModelConfigItem; + credentials: AIProviderCredential[]; +}) { + const credential = model.credential_id + ? credentials.find((item) => item.id === model.credential_id) + : undefined; + return ( + + ); +} export function ModelCombobox({ models, @@ -38,6 +56,7 @@ export function ModelCombobox({ }) { const { t } = useTranslation(); const reasoningLabel = t(`ai.reasoningEffort.${selectedReasoningEffort}`); + const reasoningOptions = getModelReasoningOptions(selectedModel); return ( @@ -46,10 +65,13 @@ export function ModelCombobox({ type="button" size="sm" variant="outline" - className={`h-8 min-w-0 max-w-[12rem] justify-between gap-2 px-2 text-xs ${className}`} + className={`h-8 min-w-0 w-fit max-w-full justify-between gap-2 px-2 text-xs ${className ?? ""}`} disabled={models.length === 0} > + {selectedModel ? ( + + ) : null} {selectedModel?.name ?? t("ai.modelSelect")} {selectedModel ? ( · {reasoningLabel} @@ -64,7 +86,7 @@ export function ModelCombobox({ {t("ai.noModelMatches")} - {REASONING_OPTIONS.map((effort) => ( + {reasoningOptions.map((effort) => ( + {model.name} {providerLabel ? ( diff --git a/src/components/settings/AiTab.test.tsx b/src/components/settings/AiTab.test.tsx new file mode 100644 index 00000000..3a6ff2eb --- /dev/null +++ b/src/components/settings/AiTab.test.tsx @@ -0,0 +1,150 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_AI_SETTINGS } from "@/lib/aiSettings"; +import type { AISettings } from "@/types/global"; +import { AiModelsTab } from "./AiTab"; + +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); +let currentSettings: AISettings; +let appState: Record; +vi.mock("@/context/AppContext", () => ({ useApp: () => appState })); +vi.mock("@/lib/invoke", () => ({ invoke: invokeMock })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => vi.fn()) })); +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key }), +})); + +function Harness({ initial }: { initial: AISettings }) { + const [ai, setAi] = useState(initial); + currentSettings = ai; + appState = { + appSettings: { ai }, + updateAppSettings: ( + patch: { ai: AISettings } | ((current: { ai: AISettings }) => { ai: AISettings }), + ) => { + setAi((current) => (typeof patch === "function" ? patch({ ai: current }).ai : patch.ai)); + }, + }; + return ; +} + +function settingsWithProviders(): AISettings { + const ai = structuredClone(DEFAULT_AI_SETTINGS); + ai.provider_credentials = ai.provider_credentials.slice(0, 2).map((credential) => ({ + ...credential, + enabled: true, + api_key: "test-key", + })); + ai.models = [ + { + id: "openai:test-model", + name: "test-model", + backend: "genai", + provider_kind: "openai", + credential_id: null, + enabled: true, + source: "rust-genai", + last_seen_at: "2026-01-01T00:00:00Z", + supported_reasoning_efforts: ["low"], + }, + ]; + return ai; +} + +describe("AI provider settings", () => { + beforeEach(() => { + invokeMock.mockReset(); + }); + + it("keeps an empty account collapsed until the user adds a provider", () => { + const initial = settingsWithProviders(); + initial.provider_credentials = []; + initial.models = []; + render(); + expect(screen.queryByText("ai.modelList")).toBeNull(); + expect(screen.queryByRole("button", { name: "OpenAI" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "ai.addProvider" })); + fireEvent.click(screen.getByRole("button", { name: "OpenAI" })); + expect(currentSettings.provider_credentials.filter((item) => item.enabled)).toHaveLength(1); + expect(screen.getByText("ai.modelList")).toBeTruthy(); + expect( + screen.getByRole("button", { name: "ai.addProvider" }).getAttribute("aria-expanded"), + ).toBe("false"); + }); + + it("clears stale upstream badges while retaining model configuration", async () => { + invokeMock.mockResolvedValue([]); + render(); + await act(async () => + fireEvent.click(screen.getByRole("button", { name: "ai.refreshModels" })), + ); + expect(currentSettings.models[0]).toMatchObject({ + name: "test-model", + enabled: true, + last_seen_at: null, + supported_reasoning_efforts: ["low"], + }); + }); + + it("gives a second account a unique name and keeps models bound to each account", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "ai.addProvider" })); + fireEvent.click(screen.getByRole("button", { name: "OpenAI" })); + const providers = currentSettings.provider_credentials.filter( + (item) => item.provider_kind === "openai", + ); + expect(providers.map((item) => item.name)).toEqual(["OpenAI-2", "OpenAI"]); + expect( + currentSettings.models.find((model) => model.id === "openai:test-model")?.credential_id, + ).toBeNull(); + expect(currentSettings.models.some((model) => model.credential_id === providers[0].id)).toBe( + true, + ); + }); + + it("hides both detail cards after deleting the last provider", () => { + const initial = settingsWithProviders(); + initial.provider_credentials = initial.provider_credentials.slice(0, 1); + render(); + fireEvent.click(screen.getByRole("button", { name: "ai.deleteProvider" })); + fireEvent.click(screen.getByRole("button", { name: "common.delete" })); + expect(currentSettings.provider_credentials.filter((item) => item.enabled)).toHaveLength(0); + expect(screen.queryByText("ai.modelList")).toBeNull(); + expect(screen.queryByRole("button", { name: "ai.connectionTest" })).toBeNull(); + expect( + screen.getByRole("button", { name: "ai.addProvider" }).getAttribute("aria-expanded"), + ).toBe("false"); + }); + + it("keeps model provenance on a failed refresh", async () => { + invokeMock.mockRejectedValue(new Error("offline")); + render(); + await act(async () => + fireEvent.click(screen.getByRole("button", { name: "ai.refreshModels" })), + ); + expect(currentSettings.models[0].last_seen_at).toBe("2026-01-01T00:00:00Z"); + }); + + it("resets a pending test when switching providers and ignores its late result", async () => { + let resolve!: (models: string[]) => void; + invokeMock.mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + render(); + fireEvent.click(screen.getByRole("button", { name: "ai.refreshModels" })); + const openai = screen.getByRole("button", { name: /^OpenAI/ }); + expect(within(openai).getByLabelText("ai.connectionStatus.testing")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /^Anthropic/ })); + expect(within(openai).getByLabelText("ai.connectionStatus.idle")).toBeTruthy(); + expect( + (screen.getByRole("button", { name: "ai.refreshModels" }) as HTMLButtonElement).disabled, + ).toBe(false); + await act(async () => resolve(["late-model"])); + expect(currentSettings.models.some((model) => model.name === "late-model")).toBe(false); + expect(within(openai).getByLabelText("ai.connectionStatus.idle")).toBeTruthy(); + }); +}); diff --git a/src/components/settings/AiTab.tsx b/src/components/settings/AiTab.tsx index 6824f7e8..8aa9f21d 100644 --- a/src/components/settings/AiTab.tsx +++ b/src/components/settings/AiTab.tsx @@ -1,46 +1,68 @@ import { listen } from "@tauri-apps/api/event"; +import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { openUrl } from "@tauri-apps/plugin-opener"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdAdd, MdDelete, - MdExpandLess, - MdExpandMore, + MdEdit, MdLogin, MdLogout, MdOpenInNew, MdRefresh, + MdVisibility, + MdVisibilityOff, } from "react-icons/md"; +import { TbPlugConnected } from "react-icons/tb"; import { toast } from "sonner"; +import { ProviderBadge } from "@/components/ai/ProviderBadge"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { SelectItem } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { useApp } from "@/context/AppContext"; -import { useSettingsDraft } from "@/context/SettingsDraftContext"; +import { isCloudSecretMasked, secretInputValue, secretPlaceholder } from "@/lib/cloudSync"; import { + AI_PROVIDERS, aiModelIdForCredential, aiModelIdForProvider, BUILTIN_PROVIDERS, - CUSTOM_AI_PROVIDER_PROTOCOLS, getCustomProviderBaseUrlPlaceholder, getProviderLabel, isBuiltinProvider, - requiresManualCustomModelEntry, + MODEL_REASONING_EFFORTS, supportsApiFormatSelection, - supportsCustomModelDiscovery, } from "@/lib/aiSettings"; import { writeClipboardText } from "@/lib/clipboard"; import { getErrorMessage } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; import { getOwnerMainWindowLabel } from "@/lib/windowManager"; import type { - AIApiFormat, AICustomActionConfig, AIModelConfigItem, + AIModelReasoningEffort, AIProviderCredential, + AIProviderApiProtocol, AIProviderKind, AISettings, ClaudeCodeIntegrationSettings, @@ -74,6 +96,7 @@ function newCredential(): AIProviderCredential { id: `credential-${crypto.randomUUID()}`, name: "", provider_kind: "openai_compatible", + api_protocol: null, api_format: "chat_completions", base_url: "", api_key: "", @@ -81,6 +104,65 @@ function newCredential(): AIProviderCredential { }; } +function providerNameTaken( + name: string, + credentials: AIProviderCredential[], + excludeId?: string, +): boolean { + const normalized = name.trim().toLocaleLowerCase(); + return ( + normalized.length > 0 && + credentials.some( + (credential) => + credential.enabled && + credential.id !== excludeId && + credential.name.trim().toLocaleLowerCase() === normalized, + ) + ); +} + +function availableProviderName( + baseName: string, + credentials: AIProviderCredential[], + excludeId?: string, +): string { + if (!providerNameTaken(baseName, credentials, excludeId)) return baseName; + let suffix = 2; + while (providerNameTaken(`${baseName}-${suffix}`, credentials, excludeId)) suffix += 1; + return `${baseName}-${suffix}`; +} + +function defaultProviderName( + credential: AIProviderCredential, + credentials: AIProviderCredential[], +): string { + const baseName = + credential.provider_kind === "openai_compatible" + ? "my-provider" + : getProviderLabel(credential.provider_kind); + return availableProviderName(baseName, credentials, credential.id); +} + +function providerApiProtocol(credential: AIProviderCredential): AIProviderApiProtocol { + if (credential.api_protocol) return credential.api_protocol; + switch (credential.provider_kind) { + case "anthropic": + return "anthropic"; + case "gemini": + return "gemini"; + case "ollama": + return "ollama"; + default: + return "openai_compatible"; + } +} + +function providerProtocolSelectValue(credential: AIProviderCredential): string { + const protocol = providerApiProtocol(credential); + if (protocol !== "openai_compatible") return protocol; + return credential.api_format === "responses" ? "responses" : "chat_completions"; +} + function newAction(prefix: string): AICustomActionConfig { return { id: `${prefix}-${crypto.randomUUID()}`, @@ -90,12 +172,23 @@ function newAction(prefix: string): AICustomActionConfig { }; } -function DeleteIconButton({ onDelete, title }: { onDelete: () => void; title: string }) { +function DeleteIconButton({ + onDelete, + title, + disabled, +}: { + onDelete: () => void; + title: string; + disabled?: boolean; +}) { return ( -
-
- {groupedModels.length === 0 ? ( -
- {visibleModels.length === 0 ? t("ai.noModels") : t("ai.noModelMatches")} -
- ) : ( - groupedModels.map((group) => { - const isCollapsed = collapsedGroups[group.groupKey] ?? false; - const enabledCount = group.models.filter((m) => m.enabled).length; - return ( -
- - {!isCollapsed ? ( - <> - {group.credential ? ( -
-
- - setManualModelNames((prev) => ({ - ...prev, - [group.groupKey]: event.target.value, - })) - } - onKeyDown={(event) => { - if (event.key === "Enter" && group.credential) { - event.preventDefault(); - addManualModel(group.credential); - } - }} - /> - -
- {requiresManualCustomModelEntry(group.credential) ? ( -
- {t("ai.modelDiscoveryUnsupported")} {t("ai.manualModelRequired")} -
- ) : null} -
- ) : null} - {group.models.map((model) => ( -
{ + if (!selectedProviderCredential) return; + if (providerDraft) { + setProviderDraft(null); + setTestedDraftModels(null); + } else { + const id = selectedProviderCredential.id; + const nextCredential = enabledCredentials.find((credential) => credential.id !== id); + if (isBuiltinProvider(id)) { + updateCredential(id, { enabled: false }); + } else { + removeCredential(id); + } + setSelectedProviderCredentialId(nextCredential?.id ?? null); + } + cancelProviderConnectionTest(); + setProviderModelCount(null); + setProviderConnectionStatus("idle"); + hideProviderApiKey(); + }; + + const testProviderConnection = async (fromModelList = false) => { + if (!selectedProviderCredential) return; + const credentialId = selectedProviderCredential.id; + const requestGeneration = ++providerConnectionTestGeneration.current; + setTestingProviderId(credentialId); + if (fromModelList) setRefreshingCurrentModels(true); + setProviderModelCount(null); + setProviderConnectionStatus("testing"); + if (!providerDraft) { + setProviderStatuses((current) => ({ ...current, [credentialId]: "testing" })); + } + try { + const aiSettings = providerDraft + ? { + ...ai, + provider_credentials: [...ai.provider_credentials, selectedProviderCredential], + } + : ai; + const modelNames = await invoke("test_ai_provider_connection", { + aiSettings, + credentialId, + }); + if (providerConnectionTestGeneration.current === requestGeneration) { + setProviderModelCount(modelNames.length); + setProviderConnectionStatus("success"); + if (!providerDraft) { + setProviderStatuses((current) => ({ ...current, [credentialId]: "success" })); + } + if (providerDraft) { + setTestedDraftModels({ credentialId, names: modelNames }); + } else { + updateAppSettings((current) => { + const models = reconcileProviderModels( + current.ai.models, + selectedProviderCredential, + modelNames, + ); + return { + ai: { + ...current.ai, + models, + default_model_id: updateDefaultModelId(current.ai, models), + }, + }; + }); + } + toast.success(t(fromModelList ? "ai.modelsRefreshed" : "ai.connectionTestSucceeded")); + } + } catch (error) { + if (providerConnectionTestGeneration.current === requestGeneration) { + setProviderConnectionStatus("error"); + if (!providerDraft) { + setProviderStatuses((current) => ({ ...current, [credentialId]: "error" })); + } + toast.error(getErrorMessage(error)); + } + } finally { + if (providerConnectionTestGeneration.current === requestGeneration) { + if (fromModelList) setRefreshingCurrentModels(false); + setTestingProviderId(null); + } + } + }; + + const protocolOptions = (credential: AIProviderCredential) => ( + <> + {t("ai.providerProtocolChatCompletions")} + {t("ai.providerProtocolResponses")} + {t("ai.providerProtocolAnthropic")} + {(credential.provider_kind === "gemini" || + credential.provider_kind === "openai_compatible") && ( + {t("ai.providerProtocolGemini")} + )} + {credential.provider_kind === "ollama" && ( + {t("ai.providerProtocolOllama")} + )} + + ); + + const renderModelListCard = () => ( + void testProviderConnection(true)} + title={t("ai.refreshModels")} + aria-label={t("ai.refreshModels")} + > + + + } + > +
+ {groupedModels.length === 0 ? ( +
+ {t("ai.noModels")} +
+ ) : ( + groupedModels.map((group) => ( +
+ {group.credential && !providerDraft ? ( +
+
+ + setManualModelNames((prev) => ({ + ...prev, + [group.groupKey]: event.target.value, + })) + } + onKeyDown={(event) => { + if (event.key === "Enter" && group.credential) { + event.preventDefault(); + addManualModel(group.credential); + } + }} + /> + +
+
+ ) : null} + {group.models.map((model) => ( +
+
+
+
{model.name}
+ {model.backend === "genai" && model.last_seen_at ? ( + -
-
{model.name}
- {model.source === "manual" ? ( - - {t("ai.manualModelBadge")} - - ) : null} -
- updateModel(model.id, { enabled })} - /> - {model.source === "manual" ? ( - removeManualModel(model.id)} - /> - ) : null} -
- ))} - + {t("ai.providerReturnedBadge")} + + ) : null} + {model.source === "manual" ? ( + + {t("ai.manualModelBadge")} + + ) : null} +
+
+ + + removeModel(model.id)} + disabled={!!providerDraft} + /> +
+ updateModel(model.id, { enabled })} + /> +
+ {editingModelId === model.id ? ( +
+
{t("ai.supportedReasoningEfforts")}
+
+ {MODEL_REASONING_EFFORTS.map((effort) => { + const selected = model.supported_reasoning_efforts?.includes(effort); + return ( + + ); + })} +
+
) : null}
- ); - }) - )} + ))} +
+ )) + )} +
+ {enabledCredentials.length === 0 ? ( +
{t("ai.manualModelNoProvider")}
+ ) : null} + {!hasEnabledModel ? ( +
{t("ai.enableOneModelHint")}
+ ) : null} + + ); + + return ( +
+ +
+
+ + + {enabledCredentials.map((credential) => ( + + ))} +
+ {showProviderChoices ? ( +
+ {AI_PROVIDERS.filter((provider) => BUILTIN_PROVIDERS[provider.value]).map( + (provider) => ( + + ), + )} + +
+ ) : null}
- {enabledCredentials.length === 0 ? ( -
{t("ai.manualModelNoProvider")}
- ) : null} - {visibleModels.every((model) => !model.enabled) ? ( -
{t("ai.enableOneModelHint")}
- ) : null}
- - - {t("common.add")} - - } - contentClassName="space-y-4" - > - {ai.provider_credentials.map((credential) => { - const builtin = isBuiltinProvider(credential.id); - return ( -
-
-
{credential.name}
-
- updateCredential(credential.id, { enabled })} - /> - {!builtin ? ( - removeCredential(credential.id)} + {enabledCredentials.length > 0 ? ( + + {selectedProviderCredential.provider_kind === "openai_compatible" ? ( + + ) : ( + + )} + {selectedProviderTitle} + + ) : undefined + } + className="min-h-24" + headerRowClassName="sm:items-center" + contentClassName="space-y-0 px-4 py-2 sm:px-5" + action={ + selectedProviderCredential ? ( +
+ + + + + + + {t("ai.deleteProvider")} + + {t( + providerDraft ? "ai.discardProviderConfirm" : "ai.deleteProviderConfirm", + { name: selectedProviderTitle }, + )} + + + + {t("common.cancel")} + + {t("common.delete")} + + + + +
+ ) : undefined + } + > + {selectedProviderCredential ? ( +
+
+ +
+ changeSelectedProviderName(event.target.value)} + onBlur={finishSelectedProviderName} + /> + {selectedProviderNameTaken ? ( +

{t("ai.providerNameDuplicate")}

) : null}
- {builtin ? ( - - + +
+ setShowProviderValidation(true)} onChange={(event) => - updateCredential(credential.id, { - api_key: event.target.value, - }) - } - /> - {supportsApiFormatSelection(credential) ? ( - - updateCredential(credential.id, { - api_format: api_format as AIApiFormat, - }) - } - > - - {t("ai.apiFormatChatCompletions")} - - {t("ai.apiFormatResponses")} - - ) : null} - - ) : ( - - - updateCredential(credential.id, { - name: event.target.value, - }) - } - /> - - updateCredential(credential.id, { - provider_kind: provider_kind as AIProviderKind, - }) - } - triggerClassName="min-w-0 [&>span]:truncate" - > - {CUSTOM_AI_PROVIDER_PROTOCOLS.map((protocol) => ( - - {t(protocol.labelKey)} - - ))} - - - updateCredential(credential.id, { + updateSelectedProviderCredential(selectedProviderCredential.id, { base_url: event.target.value, }) } /> - {supportsApiFormatSelection(credential) ? ( - - updateCredential(credential.id, { - api_format: api_format as AIApiFormat, - }) - } - > - - {t("ai.apiFormatChatCompletions")} - - {t("ai.apiFormatResponses")} - + {showProviderValidation && selectedProviderEndpointMissing ? ( +

{t("ai.providerEndpointRequired")}

) : null} - - updateCredential(credential.id, { - api_key: event.target.value, - }) - } +
+
+
+ + +
+
+ +
+
+ setShowProviderValidation(true)} + onChange={(event) => { + setRevealedProviderApiKey(null); + updateSelectedProviderCredential(selectedProviderCredential.id, { + api_key: event.target.value, + }); + }} + /> + +
+ {showProviderValidation && selectedProviderApiKeyMissing ? ( +

{t("ai.providerApiKeyRequired")}

+ ) : null} +
+
+
+ +
+ {providerModelCount !== null ? ( + + {t("ai.connectionModelCount", { count: providerModelCount })} + + ) : null} + - - )} + +
+
- ); - })} - + ) : null} + + ) : null} + + {enabledCredentials.length > 0 ? renderModelListCard() : null}
); } diff --git a/src/components/settings/SettingFormItems.tsx b/src/components/settings/SettingFormItems.tsx index 51e0b217..d215274d 100644 --- a/src/components/settings/SettingFormItems.tsx +++ b/src/components/settings/SettingFormItems.tsx @@ -17,11 +17,12 @@ type SettingFieldShellProps = SettingMetaProps & { }; type SettingSectionProps = { - title?: string; + title?: React.ReactNode; desc?: string; action?: React.ReactNode; children: React.ReactNode; className?: string; + headerRowClassName?: string; contentClassName?: string; }; @@ -55,6 +56,7 @@ export function SettingSection({ action, children, className, + headerRowClassName, contentClassName, }: SettingSectionProps) { return ( @@ -63,7 +65,12 @@ export function SettingSection({ > {(title || desc || action) && (
-
+
{(title || desc) && (
{title &&

{title}

} diff --git a/src/hooks/useModalChildWindows.ts b/src/hooks/useModalChildWindows.ts index 46b1f702..6cc0803e 100644 --- a/src/hooks/useModalChildWindows.ts +++ b/src/hooks/useModalChildWindows.ts @@ -71,6 +71,42 @@ export function useModalChildWindows() { const modalChildWindowCount = modalChildWindowLabels.size; + useEffect(() => { + if (modalChildWindowCount === 0) return; + + let disposed = false; + let checking = false; + const reconcileClosedWindows = async () => { + if (checking) return; + checking = true; + try { + const openLabels = await getOpenModalChildWindowLabels(); + if (disposed) return; + const labels = openLabels.filter((label) => !closingLabelsRef.current.has(label)); + if ( + labels.length === modalChildWindowLabels.size && + labels.every((label) => modalChildWindowLabels.has(label)) + ) { + return; + } + setModalChildWindowLabels(new Set(labels)); + await syncMainWindowModalState(); + } catch { + // The next check can recover from a transient window query failure. + } finally { + checking = false; + } + }; + + const intervalId = window.setInterval(() => { + void reconcileClosedWindows(); + }, 500); + return () => { + disposed = true; + window.clearInterval(intervalId); + }; + }, [modalChildWindowCount, modalChildWindowLabels]); + useEffect(() => { let unlistenFocusChanged: (() => void) | undefined; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 046ca4bc..b987f208 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -154,6 +154,7 @@ "dateYesterday": "Yesterday", "defaultSession": "Current session (default)", "deleteManualModel": "Delete manual model", + "deleteModel": "Delete model", "deleteSession": "Delete", "detect": "Detect", "disabled": "AI is disabled", @@ -233,6 +234,7 @@ }, "manualModelAdded": "Model added: {{model}}", "manualModelBadge": "Manual", + "providerReturnedBadge": "Provider returned", "manualModelDeleted": "Model deleted: {{model}}", "manualModelExists": "Model already exists: {{model}}", "manualModelNoProvider": "Enable an API key profile before adding a model manually.", @@ -249,8 +251,40 @@ "modeNyatermAgent": "NyaAgent", "model": "Model", "modelDiscoveryUnsupported": "The current protocol does not support automatic model discovery yet.", - "modelList": "Model list", + "modelList": "Models from this provider", + "editModelConfig": "Edit model configuration", + "supportedReasoningEfforts": "Supported reasoning effort", "modelListDesc": "Refresh pulls known models from rust-genai. New models are disabled by default.", + "providerList": "Provider list", + "addProvider": "Add provider", + "changeProviderIcon": "Change provider icon", + "deleteProvider": "Delete provider", + "deleteProviderConfirm": "Delete {{name}}? Its associated model configurations will also be removed.", + "discardProviderConfirm": "Discard {{name}}? The values you entered will be lost.", + "customProviderOption": "Custom provider", + "confirmAddProvider": "Confirm add", + "providerEndpoint": "API endpoint", + "providerEndpointRequired": "Enter an API endpoint", + "providerApiKeyRequired": "Enter an API key", + "providerNameDuplicate": "A provider with this name already exists", + "providerProtocol": "API protocol", + "providerProtocolChatCompletions": "/chat/completions (OpenAI-compatible)", + "providerProtocolResponses": "/responses (OpenAI Responses API)", + "providerProtocolAnthropic": "Anthropic Messages API", + "providerProtocolGemini": "Google Gemini API", + "providerProtocolOllama": "/api/chat (Ollama native)", + "connectionTest": "Test connection", + "connectionTestUnsupported": "Connection testing currently supports OpenAI-compatible providers only", + "connectionTestSucceeded": "Provider connection succeeded", + "connectionModelCount": "{{count}} models", + "connectionStatus": { + "idle": "Not tested", + "testing": "Testing connection…", + "success": "Connected", + "error": "Connection failed" + }, + "showApiKey": "Show API key", + "hideApiKey": "Hide API key", "modelSelect": "Model", "models": "Models", "modelsRefreshed": "Model list refreshed", @@ -279,23 +313,28 @@ "permissionObserverDesc": "Allows basic reads, asks before sensitive reads, and blocks write operations.", "placeholder": "Ask anything... Type @ for sessions", "profileName": "Name", + "providerName": "Provider name", "providerKind": "Provider", "providerProfiles": "Profiles", "quickCommandCategory": "AI Generated", "quickPrompt": "Example: find top CPU processes", "quote": "Quote", - "reasoning": "Reasoning", + "reasoning": "Reasoning effort", "reasoningEffort": { "auto": "Auto", "high": "High", "low": "Light", + "max": "Max", "medium": "Medium", + "minimal": "Minimal", "none": "Off", + "ultra": "Ultra", "xhigh": "Extreme" }, "recordHistory": "Record AI chat history", "redaction": "Redact sensitive data", "refreshModels": "Refresh model list", + "refreshProviders": "Test all provider connections and refresh models", "refreshStatus": "Refresh status", "rejectExecute": "Skip", "repairSelected": "AI generate a fix command from selection", @@ -312,6 +351,12 @@ "saveQuickCommand": "Save", "savedQuickCommand": "Saved to Quick Commands", "searchModels": "Search models", + "testModel": "Test model", + "modelTestSucceeded": "Model test succeeded: {{model}}", + "modelTestFailed": "Model test failed: {{model}}", + "modelTestSucceededShort": "Test succeeded", + "modelTestFailedShort": "Test failed", + "modelTestUnsupported": "Codex models cannot be tested here yet", "settings": "AI Settings", "setupAction": "Settings", "setupStep1": "Add an API key credential", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index daa9a823..f358c045 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -154,6 +154,7 @@ "dateYesterday": "어제", "defaultSession": "현재 세션(기본값)", "deleteManualModel": "수동 모델 삭제", + "deleteModel": "모델 삭제", "deleteSession": "삭제", "detect": "감지", "disabled": "AI가 비활성화되어 있습니다", @@ -233,6 +234,7 @@ }, "manualModelAdded": "모델 추가됨: {{model}}", "manualModelBadge": "수동", + "providerReturnedBadge": "공급자 반환", "manualModelDeleted": "모델 삭제됨: {{model}}", "manualModelExists": "이미 존재하는 모델입니다: {{model}}", "manualModelNoProvider": "모델을 수동으로 추가하려면 먼저 API 키 프로필을 활성화하세요.", @@ -249,8 +251,40 @@ "modeNyatermAgent": "NyaAgent", "model": "모델", "modelDiscoveryUnsupported": "The current protocol does not support automatic model discovery yet.", - "modelList": "모델 목록", + "modelList": "이 공급자의 모델", + "editModelConfig": "모델 설정 편집", + "supportedReasoningEfforts": "지원되는 추론 강도", "modelListDesc": "새로고침하면 rust-genai에서 알려진 모델을 가져옵니다. 새 모델은 기본적으로 비활성화됩니다.", + "providerList": "공급자 목록", + "addProvider": "공급자 추가", + "changeProviderIcon": "공급자 아이콘 변경", + "deleteProvider": "공급자 삭제", + "deleteProviderConfirm": "{{name}} 공급자를 삭제하시겠습니까? 연결된 모델 설정도 함께 제거됩니다.", + "discardProviderConfirm": "{{name}} 공급자 추가를 취소하시겠습니까? 입력한 설정이 사라집니다.", + "customProviderOption": "사용자 지정 / Custom", + "confirmAddProvider": "추가 확인", + "providerEndpoint": "API 주소", + "providerEndpointRequired": "API 주소를 입력하세요", + "providerApiKeyRequired": "API 키를 입력하세요", + "providerNameDuplicate": "같은 이름의 공급자가 이미 있습니다", + "providerProtocol": "API 프로토콜", + "providerProtocolChatCompletions": "/chat/completions (OpenAI 호환 형식)", + "providerProtocolResponses": "/responses (OpenAI Responses API)", + "providerProtocolAnthropic": "Anthropic Messages API", + "providerProtocolGemini": "Google Gemini API", + "providerProtocolOllama": "/api/chat (Ollama 기본 프로토콜)", + "connectionTest": "연결 테스트", + "connectionTestUnsupported": "현재 OpenAI 호환 공급자만 연결 테스트를 지원합니다", + "connectionTestSucceeded": "공급자 연결에 성공했습니다", + "connectionModelCount": "{{count}}개 모델", + "connectionStatus": { + "idle": "테스트되지 않음", + "testing": "연결 테스트 중…", + "success": "연결됨", + "error": "연결 실패" + }, + "showApiKey": "API 키 표시", + "hideApiKey": "API 키 숨기기", "modelSelect": "모델", "models": "모델", "modelsRefreshed": "모델 목록이 새로고침되었습니다", @@ -279,23 +313,28 @@ "permissionObserverDesc": "일반 읽기는 허용하고 민감한 읽기는 확인하며 쓰기 작업은 차단합니다.", "placeholder": "무엇이든 물어보세요... @를 입력하면 세션을 선택할 수 있습니다", "profileName": "이름", + "providerName": "공급자 이름", "providerKind": "공급자", "providerProfiles": "프로필", "quickCommandCategory": "AI 생성", "quickPrompt": "예: CPU 사용량이 가장 높은 프로세스 찾기", "quote": "인용", - "reasoning": "추론", + "reasoning": "추론 강도", "reasoningEffort": { "auto": "자동", "high": "높음", "low": "가벼움", + "max": "최대", "medium": "보통", + "minimal": "최소", "none": "끄기", + "ultra": "초고", "xhigh": "매우 높음" }, "recordHistory": "AI 채팅 기록 저장", "redaction": "민감한 데이터 마스킹", "refreshModels": "모델 목록 새로고침", + "refreshProviders": "모든 공급자 연결 테스트 및 모델 새로고침", "refreshStatus": "상태 새로고침", "rejectExecute": "건너뛰기", "repairSelected": "선택 영역으로 AI 수정 명령 생성", @@ -312,6 +351,12 @@ "saveQuickCommand": "저장", "savedQuickCommand": "빠른 명령에 저장됨", "searchModels": "모델 검색", + "testModel": "모델 테스트", + "modelTestSucceeded": "모델 테스트 성공: {{model}}", + "modelTestFailed": "모델 테스트 실패: {{model}}", + "modelTestSucceededShort": "테스트 성공", + "modelTestFailedShort": "테스트 실패", + "modelTestUnsupported": "Codex 모델은 아직 여기서 테스트할 수 없습니다", "settings": "AI 설정", "setupAction": "설정", "setupStep1": "API 키 자격 증명 추가", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e295e312..a9f1718a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -154,6 +154,7 @@ "dateYesterday": "昨天", "defaultSession": "当前会话(默认)", "deleteManualModel": "删除手动模型", + "deleteModel": "删除模型", "deleteSession": "删除", "detect": "检测", "disabled": "AI 助手未启用", @@ -233,6 +234,7 @@ }, "manualModelAdded": "已添加模型:{{model}}", "manualModelBadge": "手动", + "providerReturnedBadge": "供应商返回", "manualModelDeleted": "已删除模型:{{model}}", "manualModelExists": "模型已存在:{{model}}", "manualModelNoProvider": "请先启用一个 API Key 配置档,再手动添加模型。", @@ -249,8 +251,40 @@ "modeNyatermAgent": "NyaAgent", "model": "模型名称", "modelDiscoveryUnsupported": "当前协议暂不支持自动获取模型列表。", - "modelList": "模型列表", + "modelList": "来自此供应商的模型", + "editModelConfig": "编辑模型配置", + "supportedReasoningEfforts": "支持的推理强度", "modelListDesc": "刷新会从 rust-genai 获取已知模型;新模型默认不启用。", + "providerList": "供应商列表", + "addProvider": "添加供应商", + "changeProviderIcon": "更换供应商图标", + "deleteProvider": "删除供应商", + "deleteProviderConfirm": "确定删除“{{name}}”吗?关联的模型配置也会被移除。", + "discardProviderConfirm": "确定放弃添加“{{name}}”吗?已填写的参数将会丢失。", + "customProviderOption": "自定义 / Custom", + "confirmAddProvider": "确认添加", + "providerEndpoint": "接口地址", + "providerEndpointRequired": "请填写接口地址", + "providerApiKeyRequired": "请填写 API 密钥", + "providerNameDuplicate": "供应商名称不能重复", + "providerProtocol": "接口协议", + "providerProtocolChatCompletions": "/chat/completions(OpenAI 兼容格式)", + "providerProtocolResponses": "/responses(OpenAI Responses API)", + "providerProtocolAnthropic": "Anthropic Messages API", + "providerProtocolGemini": "Google Gemini API", + "providerProtocolOllama": "/api/chat(Ollama 原生协议)", + "connectionTest": "连接测试", + "connectionTestUnsupported": "目前仅支持测试 OpenAI 兼容供应商", + "connectionTestSucceeded": "供应商连接成功", + "connectionModelCount": "{{count}} 个模型", + "connectionStatus": { + "idle": "尚未测试", + "testing": "正在测试连接…", + "success": "连接成功", + "error": "连接失败" + }, + "showApiKey": "显示 API Key", + "hideApiKey": "隐藏 API Key", "modelSelect": "模型", "models": "模型", "modelsRefreshed": "模型列表已刷新", @@ -279,23 +313,28 @@ "permissionObserverDesc": "允许普通读取;敏感读取需要确认;禁止写入操作。", "placeholder": "描述你的需求… 输入 @ 选择目标会话", "profileName": "名称", + "providerName": "供应商名称", "providerKind": "供应商", "providerProfiles": "模型配置档", "quickCommandCategory": "AI 生成", "quickPrompt": "例如:查 CPU 占用最高的进程", "quote": "引用", - "reasoning": "推理过程", + "reasoning": "推理强度", "reasoningEffort": { "auto": "自动", "high": "高", "low": "轻度", + "max": "最高", "medium": "中", + "minimal": "最低", "none": "关闭", + "ultra": "超高", "xhigh": "极高" }, "recordHistory": "记录 AI 对话历史", "redaction": "敏感信息脱敏", "refreshModels": "刷新模型列表", + "refreshProviders": "测试所有供应商连接并刷新模型", "refreshStatus": "刷新状态", "rejectExecute": "跳过", "repairSelected": "AI 根据选中内容生成修复命令", @@ -312,6 +351,12 @@ "saveQuickCommand": "保存", "savedQuickCommand": "已保存到快捷命令", "searchModels": "搜索模型", + "testModel": "测试模型", + "modelTestSucceeded": "模型“{{model}}”测试成功", + "modelTestFailed": "模型“{{model}}”测试失败", + "modelTestSucceededShort": "测试成功", + "modelTestFailedShort": "测试失败", + "modelTestUnsupported": "Codex 模型暂不支持在此测试", "settings": "AI 设置", "setupAction": "前往设置", "setupStep1": "添加 API Key 凭证(支持 OpenAI / Anthropic / DeepSeek 等)", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 84ff429d..0e0b11d2 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -154,6 +154,7 @@ "dateYesterday": "昨天", "defaultSession": "目前工作階段(預設)", "deleteManualModel": "刪除手動模型", + "deleteModel": "刪除模型", "deleteSession": "刪除", "detect": "偵測", "disabled": "AI 助手未啟用", @@ -233,6 +234,7 @@ }, "manualModelAdded": "已新增模型:{{model}}", "manualModelBadge": "手動", + "providerReturnedBadge": "供應商回傳", "manualModelDeleted": "已刪除模型:{{model}}", "manualModelExists": "模型已存在:{{model}}", "manualModelNoProvider": "請先啟用一個 API Key 設定檔,再手動新增模型。", @@ -249,8 +251,40 @@ "modeNyatermAgent": "NyaAgent", "model": "模型名稱", "modelDiscoveryUnsupported": "目前協定暫不支援自動取得模型清單。", - "modelList": "模型清單", + "modelList": "來自此供應商的模型", + "editModelConfig": "編輯模型設定", + "supportedReasoningEfforts": "支援的推理強度", "modelListDesc": "重新整理會從 rust-genai 取得已知模型;新模型預設不啟用。", + "providerList": "供應商清單", + "addProvider": "新增供應商", + "changeProviderIcon": "更換供應商圖示", + "deleteProvider": "刪除供應商", + "deleteProviderConfirm": "確定刪除「{{name}}」嗎?關聯的模型設定也會一併移除。", + "discardProviderConfirm": "確定放棄新增「{{name}}」嗎?已填寫的參數將會遺失。", + "customProviderOption": "自訂 / Custom", + "confirmAddProvider": "確認新增", + "providerEndpoint": "介面地址", + "providerEndpointRequired": "請填寫介面地址", + "providerApiKeyRequired": "請填寫 API 金鑰", + "providerNameDuplicate": "供應商名稱不可重複", + "providerProtocol": "介面協定", + "providerProtocolChatCompletions": "/chat/completions(OpenAI 相容格式)", + "providerProtocolResponses": "/responses(OpenAI Responses API)", + "providerProtocolAnthropic": "Anthropic Messages API", + "providerProtocolGemini": "Google Gemini API", + "providerProtocolOllama": "/api/chat(Ollama 原生協定)", + "connectionTest": "連線測試", + "connectionTestUnsupported": "目前僅支援測試 OpenAI 相容供應商", + "connectionTestSucceeded": "供應商連線成功", + "connectionModelCount": "{{count}} 個模型", + "connectionStatus": { + "idle": "尚未測試", + "testing": "正在測試連線…", + "success": "連線成功", + "error": "連線失敗" + }, + "showApiKey": "顯示 API Key", + "hideApiKey": "隱藏 API Key", "modelSelect": "模型", "models": "模型", "modelsRefreshed": "模型清單已重新整理", @@ -279,23 +313,28 @@ "permissionObserverDesc": "允許一般讀取;敏感讀取需要確認;禁止寫入操作。", "placeholder": "描述你的需求… 輸入 @ 選擇目標工作階段", "profileName": "名稱", + "providerName": "供應商名稱", "providerKind": "供應商", "providerProfiles": "模型設定檔", "quickCommandCategory": "AI 產生", "quickPrompt": "例如:查 CPU 佔用最高的處理程序", "quote": "引用", - "reasoning": "推理過程", + "reasoning": "推理強度", "reasoningEffort": { "auto": "自動", "high": "高", "low": "輕度", + "max": "最高", "medium": "中", + "minimal": "最低", "none": "關閉", + "ultra": "超高", "xhigh": "極高" }, "recordHistory": "記錄 AI 對話歷史", "redaction": "敏感資訊脫敏", "refreshModels": "重新整理模型清單", + "refreshProviders": "測試所有供應商連線並重新整理模型", "refreshStatus": "重新整理狀態", "rejectExecute": "跳過", "repairSelected": "AI 根據選取內容產生修復命令", @@ -312,6 +351,12 @@ "saveQuickCommand": "儲存", "savedQuickCommand": "已儲存到快捷命令", "searchModels": "搜尋模型", + "testModel": "測試模型", + "modelTestSucceeded": "模型「{{model}}」測試成功", + "modelTestFailed": "模型「{{model}}」測試失敗", + "modelTestSucceededShort": "測試成功", + "modelTestFailedShort": "測試失敗", + "modelTestUnsupported": "Codex 模型暫不支援在此測試", "settings": "AI 設定", "setupAction": "前往設定", "setupStep1": "新增 API Key 認證資訊(支援 OpenAI / Anthropic / DeepSeek 等)", diff --git a/src/index.css b/src/index.css index a4539022..d258c054 100644 --- a/src/index.css +++ b/src/index.css @@ -228,6 +228,29 @@ background: var(--df-scroll-thumb, #30363d); } +.provider-list-scroll { + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + +.provider-list-scroll.is-scrolling { + scrollbar-color: var(--df-scroll-thumb, #30363d) transparent; +} + +.provider-list-scroll::-webkit-scrollbar { + height: 6px; +} + +.provider-list-scroll::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 999px; +} + +.provider-list-scroll.is-scrolling::-webkit-scrollbar-thumb { + background: var(--df-scroll-thumb, #30363d); +} + /* ── Security auth responsive rows ─────────────────────────────────────── */ .security-auth-action-row { container-type: inline-size; diff --git a/src/lib/aiSettings.ts b/src/lib/aiSettings.ts index c59b160a..9b245ad6 100644 --- a/src/lib/aiSettings.ts +++ b/src/lib/aiSettings.ts @@ -2,12 +2,31 @@ import type { AICustomActionConfig, AIModelConfigItem, AIModelDiscovery, + AIModelReasoningEffort, AIProviderCredential, AIProviderKind, AIProviderProfile, + AIReasoningEffort, AISettings, } from "@/types/global"; +export const MODEL_REASONING_EFFORTS: AIModelReasoningEffort[] = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", +]; + +export function getModelReasoningOptions(model: AIModelConfigItem | null): AIReasoningEffort[] { + const supported = model?.supported_reasoning_efforts; + if (!supported) return ["auto", "none", "low", "medium", "high", "xhigh"]; + return ["auto", ...MODEL_REASONING_EFFORTS.filter((effort) => supported.includes(effort))]; +} + export interface BuiltinProviderInfo { label: string; defaultBaseUrl: string | null; @@ -20,7 +39,7 @@ export const DEFAULT_AI_REQUEST_USER_AGENT = export const BUILTIN_PROVIDERS: Partial> = { openai: { label: "OpenAI", - defaultBaseUrl: null, + defaultBaseUrl: "https://api.openai.com/v1/", models: [ "gpt-3.5-turbo", "gpt-3.5-turbo-0613", @@ -179,7 +198,7 @@ export const BUILTIN_PROVIDERS: Partial, + credential: Pick & + Partial>, ): boolean { return ( !isBuiltinProvider(credential.id) && credential.enabled && - credential.provider_kind === "openai_compatible" + credential.provider_kind === "openai_compatible" && + (!credential.api_protocol || credential.api_protocol === "openai_compatible") ); } export function requiresManualCustomModelEntry( - credential: Pick, + credential: Pick & + Partial>, ): boolean { - return !isBuiltinProvider(credential.id) && credential.provider_kind !== "openai_compatible"; + return ( + !isBuiltinProvider(credential.id) && + (credential.provider_kind !== "openai_compatible" || + (credential.api_protocol != null && credential.api_protocol !== "openai_compatible")) + ); } export function supportsApiFormatSelection( - credential: Pick, + credential: Pick & + Partial>, ): boolean { + if (credential.api_protocol && credential.api_protocol !== "openai_compatible") return false; return credential.provider_kind === "openai" || credential.provider_kind === "openai_compatible"; } @@ -454,7 +482,7 @@ const DEFAULT_PROVIDER_PROFILES: AIProviderProfile[] = [ name: "OpenAI", provider_kind: "openai", model: "gpt-4o-mini", - base_url: null, + base_url: "https://api.openai.com/v1/", api_key: null, enabled: false, }, @@ -463,7 +491,7 @@ const DEFAULT_PROVIDER_PROFILES: AIProviderProfile[] = [ name: "Anthropic", provider_kind: "anthropic", model: "claude-3-haiku-20240307", - base_url: null, + base_url: "https://api.anthropic.com/v1/", api_key: null, enabled: false, }, @@ -472,7 +500,7 @@ const DEFAULT_PROVIDER_PROFILES: AIProviderProfile[] = [ name: "Google Gemini", provider_kind: "gemini", model: "gemini-2.0-flash", - base_url: null, + base_url: "https://generativelanguage.googleapis.com/v1beta/", api_key: null, enabled: false, }, @@ -481,7 +509,7 @@ const DEFAULT_PROVIDER_PROFILES: AIProviderProfile[] = [ name: "DeepSeek", provider_kind: "deepseek", model: "deepseek-chat", - base_url: null, + base_url: "https://api.deepseek.com/v1/", api_key: null, enabled: false, }, @@ -589,6 +617,7 @@ function credentialFromProfile(profile: AIProviderProfile): AIProviderCredential id: profile.id, name: profile.name, provider_kind: profile.provider_kind, + api_protocol: null, api_format: "chat_completions", base_url: profile.base_url, api_key: profile.api_key, @@ -597,7 +626,7 @@ function credentialFromProfile(profile: AIProviderProfile): AIProviderCredential } export const DEFAULT_AI_SETTINGS: AISettings = { - schema_version: 6, + schema_version: 7, enabled: false, context_line_limit: 200, redaction_enabled: true, diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 83b797b7..f4bbbfa9 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1445,11 +1445,24 @@ export interface ExternalMcpSettings { export type AIReasoningEffort = | "auto" | "none" + | "minimal" | "low" | "medium" | "high" - | "xhigh"; + | "xhigh" + | "max" + | "ultra"; +export type AIModelReasoningEffort = + | "none" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "max" + | "ultra"; export type AIApiFormat = "chat_completions" | "responses"; +export type AIProviderApiProtocol = "openai_compatible" | "anthropic" | "gemini" | "ollama"; export type AIModelSource = "rust-genai" | "manual"; export type AIBackendKind = "genai" | "codex"; export type CodexThreadMode = "persistent" | "ephemeral"; @@ -1476,6 +1489,7 @@ export interface AIModelConfigItem { enabled: boolean; source: AIModelSource; last_seen_at?: string | null; + supported_reasoning_efforts?: AIModelReasoningEffort[]; } export interface CodexIntegrationSettings { @@ -1514,6 +1528,8 @@ export interface AIProviderCredential { id: string; name: string; provider_kind: AIProviderKind; + icon_data_url?: string | null; + api_protocol?: AIProviderApiProtocol | null; api_format: AIApiFormat; base_url?: string | null; api_key?: string | null; From d77b00a90dd3bf5032281765b636ef9fb9ec3c6e Mon Sep 17 00:00:00 2001 From: Yue-Sui Date: Sun, 27 Sep 2026 19:58:49 +0800 Subject: [PATCH 2/3] fix(ai): allow keyless local provider discovery --- src/components/settings/AiTab.test.tsx | 51 ++++++++++++++++++++++++++ src/components/settings/AiTab.tsx | 16 +++++--- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/components/settings/AiTab.test.tsx b/src/components/settings/AiTab.test.tsx index 3a6ff2eb..accbc8b6 100644 --- a/src/components/settings/AiTab.test.tsx +++ b/src/components/settings/AiTab.test.tsx @@ -53,6 +53,23 @@ function settingsWithProviders(): AISettings { return ai; } +function settingsWithNoKeyProvider(kind: "ollama" | "openai_compatible"): AISettings { + const ai = settingsWithProviders(); + ai.provider_credentials = [ + { + ...ai.provider_credentials[0], + id: `no-key-${kind}`, + name: kind === "ollama" ? "Local Ollama" : "Local OpenAI-compatible", + provider_kind: kind, + api_protocol: kind, + base_url: kind === "ollama" ? "http://localhost:11434/" : "http://localhost:1234/v1/", + api_key: "", + }, + ]; + ai.models = []; + return ai; +} + describe("AI provider settings", () => { beforeEach(() => { invokeMock.mockReset(); @@ -88,6 +105,40 @@ describe("AI provider settings", () => { }); }); + it.each([ + ["Ollama", "ollama"], + ["OpenAI-compatible", "openai_compatible"], + ] as const)("allows %s without an API key to test and refresh models", async (_label, kind) => { + invokeMock.mockResolvedValueOnce(["first-model"]).mockResolvedValueOnce(["refreshed-model"]); + render(); + + expect((screen.getByPlaceholderText("settings.apiKey") as HTMLInputElement).required).toBe( + false, + ); + const connectionButton = screen.getByRole("button", { + name: "ai.connectionTest", + }) as HTMLButtonElement; + const refreshButton = screen.getByRole("button", { + name: "ai.refreshModels", + }) as HTMLButtonElement; + expect(connectionButton.disabled).toBe(false); + expect(refreshButton.disabled).toBe(false); + + await act(async () => fireEvent.click(connectionButton)); + expect(invokeMock).toHaveBeenNthCalledWith(1, "test_ai_provider_connection", { + aiSettings: expect.any(Object), + credentialId: `no-key-${kind}`, + }); + expect(currentSettings.models.some((model) => model.name === "first-model")).toBe(true); + + await act(async () => fireEvent.click(refreshButton)); + expect(invokeMock).toHaveBeenNthCalledWith(2, "test_ai_provider_connection", { + aiSettings: expect.any(Object), + credentialId: `no-key-${kind}`, + }); + expect(currentSettings.models.some((model) => model.name === "refreshed-model")).toBe(true); + }); + it("gives a second account a unique name and keeps models bound to each account", () => { render(); fireEvent.click(screen.getByRole("button", { name: "ai.addProvider" })); diff --git a/src/components/settings/AiTab.tsx b/src/components/settings/AiTab.tsx index 8aa9f21d..16c6a5b9 100644 --- a/src/components/settings/AiTab.tsx +++ b/src/components/settings/AiTab.tsx @@ -41,7 +41,6 @@ import { } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { useApp } from "@/context/AppContext"; -import { isCloudSecretMasked, secretInputValue, secretPlaceholder } from "@/lib/cloudSync"; import { AI_PROVIDERS, aiModelIdForCredential, @@ -54,6 +53,7 @@ import { supportsApiFormatSelection, } from "@/lib/aiSettings"; import { writeClipboardText } from "@/lib/clipboard"; +import { isCloudSecretMasked, secretInputValue, secretPlaceholder } from "@/lib/cloudSync"; import { getErrorMessage } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; import { getOwnerMainWindowLabel } from "@/lib/windowManager"; @@ -61,8 +61,8 @@ import type { AICustomActionConfig, AIModelConfigItem, AIModelReasoningEffort, - AIProviderCredential, AIProviderApiProtocol, + AIProviderCredential, AIProviderKind, AISettings, ClaudeCodeIntegrationSettings, @@ -1178,7 +1178,12 @@ export function AiModelsTab() { selectedProviderCredential?.id, ); const selectedProviderEndpointMissing = !selectedProviderCredential?.base_url?.trim(); - const selectedProviderApiKeyMissing = !selectedProviderCredential?.api_key?.trim(); + const selectedProviderApiKeyRequired = + !!selectedProviderCredential && + selectedProviderCredential.provider_kind !== "ollama" && + selectedProviderCredential.provider_kind !== "openai_compatible"; + const selectedProviderApiKeyMissing = + selectedProviderApiKeyRequired && !selectedProviderCredential?.api_key?.trim(); // biome-ignore lint/correctness/useExhaustiveDependencies: These transient fields belong to the selected account. useEffect(() => { setProviderNameInput(null); @@ -2348,7 +2353,8 @@ export function AiModelsTab() {
@@ -2367,7 +2373,7 @@ export function AiModelsTab() { t("settings.apiKey"), )} className="h-9 rounded-lg pr-10 text-sm" - required + required={selectedProviderApiKeyRequired} aria-invalid={showProviderValidation && selectedProviderApiKeyMissing} onBlur={() => setShowProviderValidation(true)} onChange={(event) => { From 84db9f0516505e3bc625652655ddf53e99e2db75 Mon Sep 17 00:00:00 2001 From: Kang Date: Sun, 27 Sep 2026 21:12:54 +0800 Subject: [PATCH 3/3] fix(ai): preserve default reasoning efforts --- src/components/settings/AiTab.test.tsx | 23 +++++++++++++++++++++++ src/components/settings/AiTab.tsx | 9 +++++++-- src/lib/aiSettings.ts | 11 +++++++++-- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/components/settings/AiTab.test.tsx b/src/components/settings/AiTab.test.tsx index accbc8b6..e625744e 100644 --- a/src/components/settings/AiTab.test.tsx +++ b/src/components/settings/AiTab.test.tsx @@ -105,6 +105,29 @@ describe("AI provider settings", () => { }); }); + it("preserves default reasoning efforts when customizing an unconfigured model", () => { + const initial = settingsWithProviders(); + delete initial.models[0].supported_reasoning_efforts; + render(); + + fireEvent.click(screen.getByRole("button", { name: "ai.editModelConfig" })); + expect(screen.getByRole("button", { name: "none" }).getAttribute("aria-pressed")).toBe("true"); + expect(screen.getByRole("button", { name: "minimal" }).getAttribute("aria-pressed")).toBe( + "false", + ); + expect(screen.getByRole("button", { name: "low" }).getAttribute("aria-pressed")).toBe("true"); + + fireEvent.click(screen.getByRole("button", { name: "minimal" })); + expect(currentSettings.models[0].supported_reasoning_efforts).toEqual([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + ]); + }); + it.each([ ["Ollama", "ollama"], ["OpenAI-compatible", "openai_compatible"], diff --git a/src/components/settings/AiTab.tsx b/src/components/settings/AiTab.tsx index 16c6a5b9..ad8d9cba 100644 --- a/src/components/settings/AiTab.tsx +++ b/src/components/settings/AiTab.tsx @@ -46,6 +46,7 @@ import { aiModelIdForCredential, aiModelIdForProvider, BUILTIN_PROVIDERS, + DEFAULT_MODEL_REASONING_EFFORTS, getCustomProviderBaseUrlPlaceholder, getProviderLabel, isBuiltinProvider, @@ -1409,7 +1410,9 @@ export function AiModelsTab() { }; const toggleModelReasoningEffort = (model: AIModelConfigItem, effort: AIModelReasoningEffort) => { - const selected = new Set(model.supported_reasoning_efforts ?? []); + const selected = new Set( + model.supported_reasoning_efforts ?? DEFAULT_MODEL_REASONING_EFFORTS, + ); if (selected.has(effort)) selected.delete(effort); else selected.add(effort); updateModel(model.id, { @@ -2066,7 +2069,9 @@ export function AiModelsTab() { className="mt-2 flex flex-wrap gap-2" > {MODEL_REASONING_EFFORTS.map((effort) => { - const selected = model.supported_reasoning_efforts?.includes(effort); + const selected = ( + model.supported_reasoning_efforts ?? DEFAULT_MODEL_REASONING_EFFORTS + ).includes(effort); return (