Merge pull request #802 from LunaSpica/feat/ai-provider-model-settings
feat(ai): 重构供应商与模型设置,完善连接测试和推理配置
This commit is contained in:
23 files changed
+2708
-425
No files matched your search
+56
-5
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+1336
-298
File diff suppressed because it is too large.
Load diff
@@ -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>}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 키 자격 증명 추가",
|
||||
|
||||
@@ -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 等)",
|
||||
|
||||
@@ -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 等)",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Vendored
+17
-1
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user