Merge pull request #802 from LunaSpica/feat/ai-provider-model-settings

feat(ai): 重构供应商与模型设置,完善连接测试和推理配置
This commit is contained in:
Kang authored and GitHub committed 2026-09-27 21:19:47 +08:00
commit de44b58f02
23 files changed
+2708 -425

No files matched your search

+56 -5
View File
@@ -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<String> {
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<Vec<String>> {
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<String> {
let settings = storage::load_settings_doc::<config::AppSettings>(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<ai::AiModelDiscovery>,
@@ -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,
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ pub fn import_connection_icon(
Ok(icon)
}
fn import_connection_icon_data_url(path: &str) -> AppResult<String> {
pub(crate) fn import_connection_icon_data_url(path: &str) -> AppResult<String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err(AppError::Config(
+10 -9
View File
@@ -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::{
+96 -11
View File
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supported_reasoning_efforts: Option<Vec<AiModelReasoningEffort>>,
}
#[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<String>,
#[serde(default)]
pub api_protocol: Option<AiProviderApiProtocol>,
#[serde(default)]
pub api_format: AiApiFormat,
#[serde(default)]
@@ -426,7 +457,7 @@ fn default_provider_profiles() -> Vec<AiProviderProfile> {
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<AiProviderProfile> {
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<AiProviderProfile> {
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<AiProviderProfile> {
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<AiModelConfigItem>
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,
+5 -5
View File
@@ -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};
+4 -1
View File
@@ -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;
+432 -47
View File
@@ -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<ReasoningEffort>
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<ResolvedAiModel> {
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<AiProviderKind> {
let (prefix, _) = model_id.split_once(':')?;
match prefix {
@@ -156,7 +212,14 @@ fn resolve_model_credential(
model: &AiModelConfigItem,
provider_kind: Option<&AiProviderKind>,
) -> AppResult<Option<AiProviderCredential>> {
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<Vec<AiModelDi
pub async fn list_model_names_for_settings(
settings: &AiSettings,
) -> AppResult<Vec<AiModelDiscovery>> {
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<Vec<String>> {
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<Vec<String>> {
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<Vec<String>> {
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<Option<(&'static str, String)>> {
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<String> = 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<Vec<String>> {
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<String> {
@@ -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::<String>::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());
+94 -5
View File
@@ -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<String> {
"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()),
+4
View File
@@ -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,
+52
View File
@@ -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<Record<AIProviderKind, IconType>> = {
openai: RiOpenaiFill,
anthropic: SiAnthropic,
gemini: SiGooglegemini,
deepseek: SiDeepseek,
xai: RiGrokAiFill,
zai: RiZhipuAiFill,
ollama: SiOllama,
mimo: SiXiaomi,
};
const PROVIDER_ICON_INITIALS: Partial<Record<AIProviderKind, string>> = {
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 (
<span
aria-hidden="true"
className={`grid shrink-0 place-items-center rounded-full bg-primary/10 text-primary ${isSmall ? "size-5" : "size-6"}`}
>
{iconDataUrl?.startsWith("data:image/png;base64,") ? (
<img src={iconDataUrl} alt="" className="size-full rounded-full object-cover" />
) : Icon ? (
<Icon className={isSmall ? "size-3.5 text-primary" : "size-4 text-primary"} />
) : initials ? (
<span className={`font-bold leading-none ${isSmall ? "text-[0.5rem]" : "text-[0.625rem]"}`}>
{initials}
</span>
) : (
<MdCloud className={isSmall ? "size-3.5 text-primary" : "size-4 text-primary"} />
)}
</span>
);
}
+44 -14
View File
@@ -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
/>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex flex-1 min-w-0 items-center gap-2">
<div className="w-1/3 min-w-0">
<div className="min-w-0 max-w-[45%] shrink-0">
<Select
value={runMode}
onValueChange={(value) => selectRunMode(value as AIRunMode)}
>
<SelectTrigger size="sm" className="w-full text-xs">
<SelectTrigger size="sm" className="w-fit max-w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
@@ -2008,13 +2034,13 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
</Select>
</div>
<div className="w-2/3 min-w-0">
<div className="min-w-0 flex-1">
{externalModelLabel ? (
<Button
type="button"
size="sm"
variant="outline"
className="h-8 w-full min-w-0 justify-start px-2 text-xs"
className="h-8 w-fit max-w-full min-w-0 justify-start px-2 text-xs"
disabled
>
<span className="truncate">{externalModelLabel}</span>
@@ -2024,20 +2050,24 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
models={selectableModels}
credentials={aiSettings.provider_credentials}
selectedModel={selectedModel}
selectedReasoningEffort={aiSettings.default_reasoning_effort ?? "auto"}
selectedReasoningEffort={selectedReasoningEffort}
open={modelPopoverOpen}
onOpenChange={setModelPopoverOpen}
onSelect={(model) =>
onSelect={(model) => {
const default_reasoning_effort = getModelReasoningOptions(model).includes(
configuredReasoningEffort,
)
? configuredReasoningEffort
: "auto";
updateAppSettings({
ai: { ...aiSettings, default_model_id: model.id },
})
}
ai: { ...aiSettings, default_model_id: model.id, default_reasoning_effort },
});
}}
onSelectReasoningEffort={(default_reasoning_effort) =>
updateAppSettings({
ai: { ...aiSettings, default_reasoning_effort },
})
}
className="w-full truncate"
/>
)}
</div>
+27 -4
View File
@@ -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 (
<ProviderBadge
kind={model.backend === "codex" ? "openai" : (credential?.provider_kind ?? model.provider_kind)}
iconDataUrl={credential?.icon_data_url}
size="sm"
/>
);
}
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 (
<Popover open={open} onOpenChange={onOpenChange}>
@@ -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}
>
<span className="flex min-w-0 items-center gap-1">
{selectedModel ? (
<ModelProviderBadge model={selectedModel} credentials={credentials} />
) : null}
<span className="truncate">{selectedModel?.name ?? t("ai.modelSelect")}</span>
{selectedModel ? (
<span className="shrink-0 text-muted-foreground">· {reasoningLabel}</span>
@@ -64,7 +86,7 @@ export function ModelCombobox({
<CommandList className="max-h-64 terminal-scroll">
<CommandEmpty>{t("ai.noModelMatches")}</CommandEmpty>
<CommandGroup heading={t("ai.reasoning")}>
{REASONING_OPTIONS.map((effort) => (
{reasoningOptions.map((effort) => (
<CommandItem
key={effort}
value={`${t(`ai.reasoningEffort.${effort}`)} ${effort}`}
@@ -94,6 +116,7 @@ export function ModelCombobox({
<MdCheck
className={`text-sm ${selectedModel?.id === model.id ? "opacity-100" : "opacity-0"}`}
/>
<ModelProviderBadge model={model} credentials={credentials} />
<span className="min-w-0 flex-1 truncate">{model.name}</span>
{providerLabel ? (
<span className="shrink-0 text-[0.625rem] text-muted-foreground">
+224
View File
@@ -0,0 +1,224 @@
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<string, unknown>;
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<typeof import("react-i18next")>()),
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 <AiModelsTab />;
}
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;
}
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();
});
it("keeps an empty account collapsed until the user adds a provider", () => {
const initial = settingsWithProviders();
initial.provider_credentials = [];
initial.models = [];
render(<Harness initial={initial} />);
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(<Harness initial={settingsWithProviders()} />);
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("preserves default reasoning efforts when customizing an unconfigured model", () => {
const initial = settingsWithProviders();
delete initial.models[0].supported_reasoning_efforts;
render(<Harness initial={initial} />);
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"],
] as const)("allows %s without an API key to test and refresh models", async (_label, kind) => {
invokeMock.mockResolvedValueOnce(["first-model"]).mockResolvedValueOnce(["refreshed-model"]);
render(<Harness initial={settingsWithNoKeyProvider(kind)} />);
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(<Harness initial={settingsWithProviders()} />);
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(<Harness initial={initial} />);
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(<Harness initial={settingsWithProviders()} />);
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<string[]>((done) => {
resolve = done;
}),
);
render(<Harness initial={settingsWithProviders()} />);
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();
});
});
File diff suppressed because it is too large. Load diff
+9 -2
View File
@@ -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) && (
<div className="flex flex-col gap-3 border-b border-border/60 px-4 py-4 sm:px-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div
className={cn(
"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between",
headerRowClassName,
)}
>
{(title || desc) && (
<div className="min-w-0">
{title && <h3 className="text-sm font-semibold leading-5">{title}</h3>}
+36
View File
@@ -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;
+47 -2
View File
@@ -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",
+47 -2
View File
@@ -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 키 자격 증명 추가",
+47 -2
View File
@@ -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 等)",
+47 -2
View File
@@ -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 等)",
+23
View File
@@ -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;
+50 -14
View File
@@ -2,12 +2,38 @@ 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 const DEFAULT_MODEL_REASONING_EFFORTS: AIModelReasoningEffort[] = [
"none",
"low",
"medium",
"high",
"xhigh",
];
export function getModelReasoningOptions(model: AIModelConfigItem | null): AIReasoningEffort[] {
const supported = model?.supported_reasoning_efforts ?? DEFAULT_MODEL_REASONING_EFFORTS;
return ["auto", ...MODEL_REASONING_EFFORTS.filter((effort) => supported.includes(effort))];
}
export interface BuiltinProviderInfo {
label: string;
defaultBaseUrl: string | null;
@@ -20,7 +46,7 @@ export const DEFAULT_AI_REQUEST_USER_AGENT =
export const BUILTIN_PROVIDERS: Partial<Record<AIProviderKind, BuiltinProviderInfo>> = {
openai: {
label: "OpenAI",
defaultBaseUrl: null,
defaultBaseUrl: "https://api.openai.com/v1/",
models: [
"gpt-3.5-turbo",
"gpt-3.5-turbo-0613",
@@ -179,7 +205,7 @@ export const BUILTIN_PROVIDERS: Partial<Record<AIProviderKind, BuiltinProviderIn
},
anthropic: {
label: "Anthropic",
defaultBaseUrl: null,
defaultBaseUrl: "https://api.anthropic.com/v1/",
models: [
"claude-3-sonnet-20240229",
"claude-3-opus-20240229",
@@ -217,7 +243,7 @@ export const BUILTIN_PROVIDERS: Partial<Record<AIProviderKind, BuiltinProviderIn
},
gemini: {
label: "Google Gemini",
defaultBaseUrl: null,
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta/",
models: [
"gemini-2.5-flash",
"gemini-2.5-pro",
@@ -268,7 +294,7 @@ export const BUILTIN_PROVIDERS: Partial<Record<AIProviderKind, BuiltinProviderIn
},
deepseek: {
label: "DeepSeek",
defaultBaseUrl: null,
defaultBaseUrl: "https://api.deepseek.com/v1/",
models: [
"deepseek-chat",
"deepseek-reasoner",
@@ -427,24 +453,33 @@ export function getCustomProviderBaseUrlPlaceholder(providerKind: AIProviderKind
}
export function supportsCustomModelDiscovery(
credential: Pick<AIProviderCredential, "id" | "enabled" | "provider_kind">,
credential: Pick<AIProviderCredential, "id" | "enabled" | "provider_kind"> &
Partial<Pick<AIProviderCredential, "api_protocol">>,
): 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<AIProviderCredential, "id" | "provider_kind">,
credential: Pick<AIProviderCredential, "id" | "provider_kind"> &
Partial<Pick<AIProviderCredential, "api_protocol">>,
): 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<AIProviderCredential, "provider_kind">,
credential: Pick<AIProviderCredential, "provider_kind"> &
Partial<Pick<AIProviderCredential, "api_protocol">>,
): boolean {
if (credential.api_protocol && credential.api_protocol !== "openai_compatible") return false;
return credential.provider_kind === "openai" || credential.provider_kind === "openai_compatible";
}
@@ -454,7 +489,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 +498,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 +507,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 +516,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 +624,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 +633,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,
+17 -1
View File
@@ -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;