diff --git a/src-tauri/src/auto_analysis.rs b/src-tauri/src/auto_analysis.rs index e56641f..a28cab5 100644 --- a/src-tauri/src/auto_analysis.rs +++ b/src-tauri/src/auto_analysis.rs @@ -138,6 +138,7 @@ mod tests { provider: LlmProviderPreset::DeepSeek, base_url: "https://api.deepseek.com".to_string(), model: "deepseek-chat".to_string(), + insecure: false, }); } config diff --git a/src-tauri/src/command/llm_provider.rs b/src-tauri/src/command/llm_provider.rs index 36fb5f0..fbb64b4 100644 --- a/src-tauri/src/command/llm_provider.rs +++ b/src-tauri/src/command/llm_provider.rs @@ -2,7 +2,7 @@ use crate::command::base::CommandResult; use crate::config::{LlmChainLink, LlmProviderPreset, PRIMARY_LLM_ENTRY_ID}; use crate::credential::{self, CredentialStatus, EffectiveCredentialSource, ResolvedCredential}; use crate::error::AppError; -use crate::llm::service::{normalize_provider_base_url, provider_requires_key, LlmService}; +use crate::llm::service::{http_client_for, normalize_provider_base_url, provider_requires_key, LlmService}; use crate::llm::types::ConnectionReport; use rig::client::ModelListingClient; use rig::model::{Model, ModelList, ModelListingError}; @@ -177,17 +177,18 @@ pub async fn test_llm_entry_connection( } } -/// 界面上正在编辑的服务参数。两项齐备才算数:只传一半无从判断该配哪个客户端, -/// 与其猜一个,不如退回已落盘的配置。 +/// 界面上正在编辑的服务参数。三项齐备才算数:只传一半无从判断该配哪个客户端, +/// 与其猜一个,不如退回已落盘的配置。insecure 缺省时视为关闭。 fn draft_override( provider: Option, base_url: Option, -) -> Option<(LlmProviderPreset, String)> { + insecure: Option, +) -> Option<(LlmProviderPreset, String, bool)> { let base_url = base_url.map(|url| url.trim().to_string())?; if base_url.is_empty() { return None; } - Some((provider?, base_url)) + Some((provider?, base_url, insecure.unwrap_or(false))) } /// 列出降级链中某个服务可用的模型。 @@ -202,18 +203,19 @@ pub async fn list_llm_models_for( entry_id: String, provider: Option, base_url: Option, + insecure: Option, ) -> CommandResult> { - let (provider, base_url, credential) = match draft_override(provider, base_url) { - Some((provider, base_url)) => match credential::resolve_for_entry(&entry_id) { - Ok(credential) => (provider, base_url, credential), + let (provider, base_url, insecure, credential) = match draft_override(provider, base_url, insecure) { + Some((provider, base_url, insecure)) => match credential::resolve_for_entry(&entry_id) { + Ok(credential) => (provider, base_url, insecure, credential), Err(error) => return CommandResult::err(error), }, None => match resolve_chain_entry(app_handle, &entry_id) { - Ok((link, credential)) => (link.provider, link.base_url, credential), + Ok((link, credential)) => (link.provider, link.base_url, link.insecure, credential), Err(error) => return CommandResult::err(error), }, }; - to_command_result(fetch_model_list_with_credential(provider, &base_url, &credential).await) + to_command_result(fetch_model_list_with_credential(provider, &base_url, &credential, insecure).await) } fn validate_llm_base_url(base_url: &str) -> Result<(), AppError> { @@ -236,9 +238,10 @@ const MODEL_LIST_TIMEOUT_SECONDS: u64 = 30; async fn fetch_model_list( provider: LlmProviderPreset, base_url: &str, + insecure: bool, ) -> Result, AppError> { let credential = credential::resolve_for_entry(PRIMARY_LLM_ENTRY_ID)?; - fetch_model_list_with_credential(provider, base_url, &credential).await + fetch_model_list_with_credential(provider, base_url, &credential, insecure).await } /// 用指定服务自己的密钥拉取模型列表。 @@ -247,6 +250,7 @@ async fn fetch_model_list_with_credential( provider: LlmProviderPreset, base_url: &str, credential: &ResolvedCredential, + insecure: bool, ) -> Result, AppError> { validate_llm_base_url(base_url)?; if provider_requires_key(&provider) && credential.secret().is_none() { @@ -260,12 +264,14 @@ async fn fetch_model_list_with_credential( "noop" }); let base_url = normalize_provider_base_url(&provider, base_url); + let http_client = http_client_for(insecure)?; let models = match provider { LlmProviderPreset::Anthropic => { let client = rig::providers::anthropic::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|error| { AppError::configuration("无法创建大模型客户端").with_detail(error.to_string()) @@ -278,11 +284,12 @@ async fn fetch_model_list_with_credential( .map_err(|_| AppError::network("获取模型列表超时"))? .map_err(map_model_listing_error)? } - LlmProviderPreset::DeepSeek => fetch_openai_compatible_models(&base_url, api_key).await?, + LlmProviderPreset::DeepSeek => fetch_openai_compatible_models(&base_url, api_key, insecure).await?, LlmProviderPreset::OpenAi | LlmProviderPreset::OpenAiResponses => { let client = rig::providers::openai::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|error| { AppError::configuration("无法创建大模型客户端").with_detail(error.to_string()) @@ -295,7 +302,7 @@ async fn fetch_model_list_with_credential( .map_err(|_| AppError::network("获取模型列表超时"))? { Ok(models) => models, - Err(rig_error) => fetch_openai_compatible_models(&base_url, api_key) + Err(rig_error) => fetch_openai_compatible_models(&base_url, api_key, insecure) .await .map_err(|fallback_error| { let rig_error = map_model_listing_error(rig_error); @@ -320,6 +327,7 @@ async fn fetch_model_list_with_credential( let client = rig::providers::ollama::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|error| { AppError::configuration("无法创建大模型客户端").with_detail(error.to_string()) @@ -336,6 +344,7 @@ async fn fetch_model_list_with_credential( let client = rig::providers::openrouter::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|error| { AppError::configuration("无法创建大模型客户端").with_detail(error.to_string()) @@ -352,6 +361,7 @@ async fn fetch_model_list_with_credential( let client = rig::providers::xiaomimimo::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|error| { AppError::configuration("无法创建大模型客户端").with_detail(error.to_string()) @@ -383,11 +393,13 @@ fn openai_compatible_models_url(base_url: &str) -> String { async fn fetch_openai_compatible_models( base_url: &str, api_key: &str, + insecure: bool, ) -> Result { let url = openai_compatible_models_url(base_url); + let client = http_client_for(insecure)?; let response = timeout( Duration::from_secs(MODEL_LIST_TIMEOUT_SECONDS), - reqwest::Client::new() + client .get(&url) .bearer_auth(api_key) .header(reqwest::header::ACCEPT, "application/json") @@ -498,8 +510,9 @@ fn map_model_listing_error(error: ModelListingError) -> AppError { pub async fn list_llm_models( provider: LlmProviderPreset, base_url: String, + insecure: Option, ) -> CommandResult> { - match fetch_model_list(provider, &base_url).await { + match fetch_model_list(provider, &base_url, insecure.unwrap_or(false)).await { Ok(models) => CommandResult::ok(models), Err(error) => CommandResult::err(error), } @@ -534,6 +547,7 @@ mod tests { provider: LlmProviderPreset::OpenAi, base_url: "https://llm.example.test/v1".to_string(), model: model.to_string(), + insecure: false, } } @@ -592,13 +606,15 @@ mod tests { let draft = draft_override( Some(LlmProviderPreset::DeepSeek), Some(" https://api.deepseek.com ".to_string()), + Some(true), ); assert_eq!( draft, Some(( LlmProviderPreset::DeepSeek, - "https://api.deepseek.com".to_string() + "https://api.deepseek.com".to_string(), + true )) ); } @@ -606,16 +622,16 @@ mod tests { #[test] fn draft_override_falls_back_to_saved_config_when_incomplete() { // 只传一半、空串、全不传,都退回已落盘的配置,不去猜另一半 - assert_eq!(draft_override(Some(LlmProviderPreset::OpenAi), None), None); + assert_eq!(draft_override(Some(LlmProviderPreset::OpenAi), None, None), None); assert_eq!( - draft_override(None, Some("https://api.openai.com/v1".to_string())), + draft_override(None, Some("https://api.openai.com/v1".to_string()), None), None ); assert_eq!( - draft_override(Some(LlmProviderPreset::OpenAi), Some(" ".to_string())), + draft_override(Some(LlmProviderPreset::OpenAi), Some(" ".to_string()), None), None ); - assert_eq!(draft_override(None, None), None); + assert_eq!(draft_override(None, None, Some(true)), None); } #[test] diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index c1fc3a5..6fbe86c 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -340,6 +340,8 @@ struct RawLlmConfig { base_url: Option, #[serde(default)] model: Option, + #[serde(default)] + insecure: Option, } fn parse_llm_config( @@ -369,6 +371,7 @@ fn parse_llm_config( provider, base_url, model, + insecure: raw.insecure.unwrap_or(false), }; normalize_llm_config(&mut config); Ok(Some(config)) @@ -840,6 +843,10 @@ pub struct LlmConfig { pub provider: LlmProviderPreset, pub base_url: String, pub model: String, + /// 是否跳过 TLS 证书校验。仅用于自签证书的内网/自建服务,默认关闭; + /// 开启后中间人可窃取传输内容(含 API Key),切勿对公网服务开启。 + #[serde(default)] + pub insecure: bool, } /// 一个大模型服务是否填写完整、可以真正发起调用。 @@ -877,6 +884,11 @@ pub struct LlmProviderEntry { pub model: String, + /// 是否跳过 TLS 证书校验。仅用于自签证书的内网/自建服务,默认关闭; + /// 开启后中间人可窃取传输内容(含 API Key),切勿对公网服务开启。 + #[serde(default)] + pub insecure: bool, + /// 是否参与降级链。关闭后保留配置但不再被调用 #[serde(default = "default_true")] pub enabled: bool, @@ -948,6 +960,8 @@ pub struct LlmChainLink { pub provider: LlmProviderPreset, pub base_url: String, pub model: String, + /// 是否跳过 TLS 证书校验(来自主用配置或备用条目的 insecure 字段) + pub insecure: bool, } impl LlmChainLink { @@ -1018,6 +1032,7 @@ impl AppRuntimeConfig { provider: primary.provider.clone(), base_url: primary.base_url.clone(), model: primary.model.clone(), + insecure: primary.insecure, }); chain.extend( @@ -1031,6 +1046,7 @@ impl AppRuntimeConfig { provider: entry.provider.clone(), base_url: entry.base_url.clone(), model: entry.model.clone(), + insecure: entry.insecure, }), ); @@ -2055,6 +2071,7 @@ mod tests { provider: LlmProviderPreset::Ollama, base_url: " http://127.0.0.1:11434/v1/// ".to_string(), model: " qwen3 ".to_string(), + insecure: false, } } @@ -2094,6 +2111,7 @@ mod tests { provider: LlmProviderPreset::OpenAi, base_url: base_url.to_string(), model: model.to_string(), + insecure: false, }); let yaml = serde_yaml::to_string(&config).unwrap(); @@ -3010,6 +3028,7 @@ job_profiles: [] provider: LlmProviderPreset::OpenAi, base_url: "https://llm.example.test/v1".to_string(), model: model.to_string(), + insecure: false, enabled: true, } } @@ -3031,6 +3050,7 @@ job_profiles: [] provider: LlmProviderPreset::DeepSeek, base_url: "https://api.deepseek.com".to_string(), model: "deepseek-chat".to_string(), + insecure: false, }); let mut disabled = fallback_entry("backup-b", "gpt-4o-mini"); disabled.enabled = false; @@ -3060,6 +3080,7 @@ job_profiles: [] provider: LlmProviderPreset::DeepSeek, base_url: "https://api.deepseek.com".to_string(), model: "deepseek-chat".to_string(), + insecure: false, }); config.llm_enabled = Some(false); @@ -3074,6 +3095,7 @@ job_profiles: [] provider: LlmProviderPreset::OpenAi, base_url: "https://llm.example.test/v1".to_string(), model: "primary-model".to_string(), + insecure: false, }); let mut labeled = fallback_entry("backup-a", "qwen-max"); labeled.label = Some("阿里备用".to_string()); @@ -3145,6 +3167,7 @@ job_profiles: [] provider: LlmProviderPreset::OpenAi, base_url: "https://api.openai.com/v1".to_string(), model: "gpt-4o".to_string(), + insecure: false, }); let mut blank = fallback_entry("backup-blank", ""); blank.base_url = " ".to_string(); diff --git a/src-tauri/src/llm/service.rs b/src-tauri/src/llm/service.rs index 39bdd59..1e2ceff 100644 --- a/src-tauri/src/llm/service.rs +++ b/src-tauri/src/llm/service.rs @@ -79,11 +79,15 @@ impl LlmService { "noop" }); + // 自签证书场景下跳过 TLS 校验的开关:默认关闭,仅对 insecure=true 的服务生效 + let http_client = http_client_for(link.insecure)?; + let backend = match provider { LlmProviderPreset::Anthropic => LlmBackend::Anthropic( rig::providers::anthropic::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|e| { AppError::configuration("无法创建大模型客户端").with_detail(e.to_string()) @@ -93,6 +97,7 @@ impl LlmService { rig::providers::deepseek::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|e| { AppError::configuration("无法创建大模型客户端").with_detail(e.to_string()) @@ -105,6 +110,7 @@ impl LlmService { rig::providers::openai::CompletionsClient::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|e| { AppError::configuration("无法创建大模型客户端") @@ -116,6 +122,7 @@ impl LlmService { rig::providers::openai::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|e| { AppError::configuration("无法创建大模型客户端").with_detail(e.to_string()) @@ -125,6 +132,7 @@ impl LlmService { rig::providers::minimax::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|e| { AppError::configuration("无法创建大模型客户端").with_detail(e.to_string()) @@ -134,6 +142,7 @@ impl LlmService { rig::providers::moonshot::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|e| { AppError::configuration("无法创建大模型客户端").with_detail(e.to_string()) @@ -143,6 +152,7 @@ impl LlmService { rig::providers::ollama::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|e| { AppError::configuration("无法创建大模型客户端").with_detail(e.to_string()) @@ -152,6 +162,7 @@ impl LlmService { rig::providers::openrouter::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|e| { AppError::configuration("无法创建大模型客户端").with_detail(e.to_string()) @@ -161,6 +172,7 @@ impl LlmService { rig::providers::xiaomimimo::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|e| { AppError::configuration("无法创建大模型客户端").with_detail(e.to_string()) @@ -170,6 +182,7 @@ impl LlmService { rig::providers::zai::Client::builder() .api_key(api_key) .base_url(&base_url) + .http_client(http_client.clone()) .build() .map_err(|e| { AppError::configuration("无法创建大模型客户端").with_detail(e.to_string()) @@ -826,6 +839,20 @@ where } } +/// 按配置构建大模型请求用的 HTTP 客户端。 +/// +/// `insecure` 仅用于自签证书的内网/自建服务:开启后跳过 TLS 证书校验 +/// (等价于 curl --insecure),默认关闭。注意该开关同时影响证书链校验与 +/// 主机名校验,开启后中间人可窃取传输内容(含 API Key)。 +pub(crate) fn http_client_for(insecure: bool) -> Result { + reqwest::ClientBuilder::new() + .danger_accept_invalid_certs(insecure) + .build() + .map_err(|error| { + AppError::configuration("无法创建 HTTP 客户端").with_detail(error.to_string()) + }) +} + pub(crate) fn normalize_provider_base_url(provider: &LlmProviderPreset, base_url: &str) -> String { let base_url = base_url.trim().trim_end_matches('/'); if matches!(provider, LlmProviderPreset::Ollama) { @@ -1074,6 +1101,7 @@ mod tests { provider, base_url, model: "local-model".to_string(), + insecure: false, }); let credential = resolve_with_environment(&EmptyCredentialBackend, Some("secret")).unwrap(); LlmService::from_runtime(&config, &credential).unwrap() @@ -1345,6 +1373,7 @@ mod tests { provider: LlmProviderPreset::Ollama, base_url: url, model: "llama3.2".to_string(), + insecure: false, }); let credential = resolve_with_environment(&EmptyCredentialBackend, None).unwrap(); let result = tauri::async_runtime::block_on( @@ -1371,6 +1400,7 @@ mod tests { provider: LlmProviderPreset::DeepSeek, base_url: "https://api.deepseek.com".to_string(), model: "deepseek-chat".to_string(), + insecure: false, }); let credential = resolve_with_environment(&EmptyCredentialBackend, None).unwrap(); let error = LlmService::from_runtime(&config, &credential).unwrap_err(); @@ -1503,6 +1533,7 @@ mod tests { provider: LlmProviderPreset::OpenAi, base_url: "http://127.0.0.1:1/v1".to_string(), model: format!("{id}-model"), + insecure: false, } } @@ -1669,6 +1700,7 @@ mod tests { provider: LlmProviderPreset::OpenAi, base_url: "http://127.0.0.1:1/v1".to_string(), model: "backup-model".to_string(), + insecure: false, enabled: true, }]; @@ -1685,6 +1717,7 @@ mod tests { provider: LlmProviderPreset::OpenAi, base_url: "http://127.0.0.1:1/v1".to_string(), model: "primary-model".to_string(), + insecure: false, }); config.llm_enabled = Some(false); @@ -1702,6 +1735,7 @@ mod tests { provider: LlmProviderPreset::OpenAi, base_url: "http://127.0.0.1:1/v1".to_string(), model: "primary-model".to_string(), + insecure: false, }); config.llm_fallbacks = vec![LlmProviderEntry { id: "backup-1".to_string(), @@ -1709,6 +1743,7 @@ mod tests { provider: LlmProviderPreset::DeepSeek, base_url: "https://api.deepseek.com".to_string(), model: "deepseek-chat".to_string(), + insecure: false, enabled: true, }]; config.llm_retry_config = retry_config(4, 800); @@ -1730,6 +1765,7 @@ mod tests { provider: LlmProviderPreset::OpenAi, base_url: "http://127.0.0.1:1/v1".to_string(), model: "primary-model".to_string(), + insecure: false, }); config.llm_retry_config = retry_config(1, 250); let credential = resolve_with_environment(&EmptyCredentialBackend, Some("secret")).unwrap(); diff --git a/src/hooks/useAppConfig.test.tsx b/src/hooks/useAppConfig.test.tsx index 8aaaff2..5cb2466 100644 --- a/src/hooks/useAppConfig.test.tsx +++ b/src/hooks/useAppConfig.test.tsx @@ -48,7 +48,7 @@ describe("useAppConfig", () => { vi.mocked(api.loadAppConfig).mockResolvedValue({ ...config, onboarding_completed: true, - llm_config: { provider: "openai", base_url: "https://api.openai.com/v1", model: "" }, + llm_config: { provider: "openai", base_url: "https://api.openai.com/v1", model: "", insecure: false }, }); const { result } = renderHook(() => useAppConfig()); @@ -134,7 +134,7 @@ describe("useAppConfig", () => { act(() => result.current.updateConfig((current) => ({ ...current, - llm_config: { provider: "openai", base_url: "https://api.openai.com/v1", model: " " }, + llm_config: { provider: "openai", base_url: "https://api.openai.com/v1", model: " ", insecure: false }, }))); expect(result.current.dirty).toBe(true); @@ -154,7 +154,7 @@ describe("useAppConfig", () => { act(() => result.current.updateConfig((current) => ({ ...current, - llm_config: { provider: "openai", base_url: "https://api.openai.com/v1", model: "" }, + llm_config: { provider: "openai", base_url: "https://api.openai.com/v1", model: "", insecure: false }, }))); expect(result.current.status).toBe("error"); @@ -183,6 +183,7 @@ describe("useAppConfig", () => { provider: "deepseek" as const, base_url: "https://api.deepseek.com", model: "", + insecure: false, enabled: false, }], }; diff --git a/src/lib/llmConfig.ts b/src/lib/llmConfig.ts index 7966110..a77a456 100644 --- a/src/lib/llmConfig.ts +++ b/src/lib/llmConfig.ts @@ -5,7 +5,7 @@ import type { LlmCommandResult, LlmConnectionReport, LlmCredentialStatus, LlmEnt export const getLlmCredentialStatus = () => invoke>('get_llm_credential_status') export const setLlmApiKey = (apiKey: string) => invoke>('set_llm_api_key', { apiKey }) export const clearLlmApiKey = () => invoke>('clear_llm_api_key') -export const listLlmModels = (config: Pick) => invoke>('list_llm_models', { provider: config.provider, baseUrl: config.base_url }) +export const listLlmModels = (config: Pick) => invoke>('list_llm_models', { provider: config.provider, baseUrl: config.base_url, insecure: config.insecure }) export const testLlmConnection = () => invoke>('test_llm_connection') // 以下命令按降级链条目标识读写:密钥独立存储,互不覆盖。 @@ -18,11 +18,12 @@ export const listLlmCredentialStatus = (entryIds: string[]) => invoke invoke>('swap_llm_credentials', { entryA, entryB }) export const testLlmEntryConnection = (entryId: string) => invoke>('test_llm_entry_connection', { entryId }) -// 传入界面上正在编辑的 provider / base_url,未保存的备用服务也能取模型列表; +// 传入界面上正在编辑的 provider / base_url / insecure,未保存的备用服务也能取模型列表; // 省略时后端退回读已落盘的配置。密钥始终由后端按 entryId 解析。 -export const listLlmModelsFor = (entryId: string, draft?: Pick) => +export const listLlmModelsFor = (entryId: string, draft?: Pick) => invoke>('list_llm_models_for', { entryId, provider: draft?.provider ?? null, baseUrl: draft?.base_url ?? null, + insecure: draft?.insecure ?? null, }) diff --git a/src/types/app-config.test.ts b/src/types/app-config.test.ts index 8e2e8e7..69105ad 100644 --- a/src/types/app-config.test.ts +++ b/src/types/app-config.test.ts @@ -16,6 +16,7 @@ describe("app config save validation", () => { provider: "openai" as const, base_url: "https://api.openai.com/v1", model: "gpt-test", + insecure: false, }; const fallback = { id: "backup-a", @@ -23,6 +24,7 @@ describe("app config save validation", () => { provider: "deepseek" as const, base_url: "https://api.deepseek.com", model: "deepseek-chat", + insecure: false, enabled: true, }; diff --git a/src/types/app-config.ts b/src/types/app-config.ts index 2302244..f3976c9 100644 --- a/src/types/app-config.ts +++ b/src/types/app-config.ts @@ -112,6 +112,8 @@ export interface LlmConfig { provider: LlmProviderPreset; base_url: string; model: string; + /** 是否跳过 TLS 证书校验。仅用于自签证书的内网/自建服务,默认关闭 */ + insecure: boolean; } /** 主用服务在降级链中的保留标识,其 API Key 沿用旧的存储条目 */ @@ -126,6 +128,8 @@ export interface LlmProviderEntry { provider: LlmProviderPreset; base_url: string; model: string; + /** 是否跳过 TLS 证书校验。仅用于自签证书的内网/自建服务,默认关闭 */ + insecure: boolean; /** 是否参与降级链 */ enabled: boolean; } diff --git a/src/view/config/LlmConfigPanel.test.tsx b/src/view/config/LlmConfigPanel.test.tsx index a488cba..02aedb1 100644 --- a/src/view/config/LlmConfigPanel.test.tsx +++ b/src/view/config/LlmConfigPanel.test.tsx @@ -61,6 +61,7 @@ describe("LLM config validation", () => { provider: "openai" as const, base_url: "https://llm.example.test/v1", model: "custom-model", + insecure: false, }; it("accepts a service address and model without advanced parameters", () => { @@ -121,22 +122,23 @@ describe("降级链排序", () => { }); describe("设为主用", () => { - const primary: LlmConfig = { provider: "openai", base_url: "https://primary.test/v1", model: "primary-model" }; + const primary: LlmConfig = { provider: "openai", base_url: "https://primary.test/v1", model: "primary-model", insecure: false }; const fallbacks: LlmProviderEntry[] = [ - { id: "backup-a", label: "备用甲", provider: "deepseek", base_url: "https://a.test", model: "model-a", enabled: true }, - { id: "backup-b", label: null, provider: "moonshot", base_url: "https://b.test", model: "model-b", enabled: false }, + { id: "backup-a", label: "备用甲", provider: "deepseek", base_url: "https://a.test", model: "model-a", insecure: false, enabled: true }, + { id: "backup-b", label: null, provider: "moonshot", base_url: "https://b.test", model: "model-b", insecure: false, enabled: false }, ]; it("主用槽位与被提升的备用槽位对调内容,标识随槽位保持不变", () => { const result = promoteFallbackToPrimary(primary, fallbacks, 0); - expect(result?.primary).toEqual({ provider: "deepseek", base_url: "https://a.test", model: "model-a" }); + expect(result?.primary).toEqual({ provider: "deepseek", base_url: "https://a.test", model: "model-a", insecure: false }); expect(result?.fallbacks[0]).toEqual({ id: "backup-a", label: null, provider: "openai", base_url: "https://primary.test/v1", model: "primary-model", + insecure: false, enabled: true, }); // 密钥由后端按这两个标识一并对调,因此被降级的服务沿用原标识即可, @@ -185,6 +187,7 @@ const primaryConfig: LlmConfig = { provider: "openai", base_url: "https://api.openai.com/v1", model: "gpt-test", + insecure: false, }; function Harness({ @@ -238,6 +241,7 @@ const fallbackEntry = (id: string, model: string): LlmProviderEntry => ({ provider: "deepseek", base_url: "https://api.deepseek.com", model, + insecure: false, enabled: true, }); @@ -284,6 +288,7 @@ describe("LlmConfigPanel 降级链界面", () => { expect(vi.mocked(invoke)).toHaveBeenCalledWith("list_llm_models", { provider: "openai", baseUrl: "https://api.openai.com/v1", + insecure: false, }); }); @@ -335,6 +340,7 @@ describe("LlmConfigPanel 降级链界面", () => { entryId: "backup-a", provider: "deepseek", baseUrl: "https://api.deepseek.com", + insecure: false, }); }, 30_000); @@ -492,7 +498,7 @@ describe("LlmConfigPanel 降级链界面", () => { } return Promise.resolve({ success: true, data: { configured: false, source: "none" }, error: null }); }); - render(); + render(); const modelInput = await screen.findByLabelText(`${PRIMARY_LLM_ENTRY_ID} 模型`); fireEvent.click(screen.getByRole("button", { name: "刷新primary模型列表" })); diff --git a/src/view/config/LlmConfigPanel.tsx b/src/view/config/LlmConfigPanel.tsx index 432ce6e..7999b0c 100644 --- a/src/view/config/LlmConfigPanel.tsx +++ b/src/view/config/LlmConfigPanel.tsx @@ -58,7 +58,7 @@ export const LLM_PRESETS: Record; +type LlmServiceFields = Pick; const resultError = (error: CommandError | null, fallback: string) => error ? `[${error.code}] ${error.message}` : fallback; export const isValidLlmConfig = (value: LlmServiceFields | null) => isLlmServiceUsable(value); @@ -80,6 +80,7 @@ export const createFallbackEntry = (provider: LlmProviderPreset): LlmProviderEnt provider, base_url: LLM_PRESETS[provider].baseUrl, model: "", + insecure: false, enabled: true, }); @@ -112,10 +113,11 @@ export const promoteFallbackToPrimary = ( provider: primary.provider, base_url: primary.base_url, model: primary.model, + insecure: primary.insecure ?? false, enabled: true, }; return { - primary: { provider: target.provider, base_url: target.base_url, model: target.model }, + primary: { provider: target.provider, base_url: target.base_url, model: target.model, insecure: target.insecure ?? false }, fallbacks: fallbacks.map((item, i) => (i === index ? demoted : item)), swappedId: target.id, }; @@ -159,6 +161,33 @@ export interface LlmConfigPanelProps { onPersistAll?: () => Promise; } +/** 跳过 TLS 证书校验开关:主用服务与备用条目共用同一份 UI,风险提示写死在组件里 */ +function TlsInsecureSwitch({ + entryId, + checked, + onChange, +}: { + entryId: string; + checked: boolean; + onChange: (next: boolean) => void; +}) { + return ( +
+
+ 跳过 TLS 证书校验 + + 仅用于自签证书的内网/自建服务。开启后中间人可窃取传输内容(含 API Key),切勿对公网服务开启。 + +
+ +
+ ); +} + export function LlmConfigPanel({ config, enabled, @@ -326,7 +355,7 @@ export function LlmConfigPanel({ try { // 一律按界面上的当前值取列表,不读已落盘的配置: // 用户刚改完地址就想看新服务有哪些模型,这时磁盘上还是旧的那份。 - const draft = { provider: service.provider, base_url: baseUrl }; + const draft = { provider: service.provider, base_url: baseUrl, insecure: service.insecure ?? false }; const result = entryId === PRIMARY_LLM_ENTRY_ID ? await listLlmModels(draft) : await listLlmModelsFor(entryId, draft); @@ -554,19 +583,26 @@ export function LlmConfigPanel({ }; const renderServiceFields = (entryId: string, service: LlmServiceFields) => ( -
- - patchEntry(entryId, { base_url: e.target.value })} - /> - - - {renderModelField(entryId, service)} - -
+ <> +
+ + patchEntry(entryId, { base_url: e.target.value })} + /> + + + {renderModelField(entryId, service)} + +
+ patchEntry(entryId, { insecure: next })} + /> + ); const renderCredentialFields = (entryId: string) => { @@ -644,6 +680,7 @@ export function LlmConfigPanel({ provider, base_url: LLM_PRESETS[provider].baseUrl, model: "", + insecure: false, })} /> @@ -686,6 +723,11 @@ export function LlmConfigPanel({ onChange={(e) => patchEntry(PRIMARY_LLM_ENTRY_ID, { base_url: e.target.value })} /> + patchEntry(PRIMARY_LLM_ENTRY_ID, { insecure: next })} + /> {renderCredentialFields(PRIMARY_LLM_ENTRY_ID)} {!isLlmServiceUsable(config) && ( { provider: "deepseek" as const, base_url: "https://api.deepseek.com", model: "", + insecure: false, enabled: true, }], }; @@ -69,10 +70,10 @@ describe("Onboarding", () => { const onFinish = vi.fn().mockResolvedValue(true); const configured = { ...config, - llm_config: { provider: "openai" as const, base_url: "https://api.openai.com/v1", model: "gpt-test" }, + llm_config: { provider: "openai" as const, base_url: "https://api.openai.com/v1", model: "gpt-test", insecure: false }, llm_fallbacks: [ - { id: "backup-ok", label: null, provider: "deepseek" as const, base_url: "https://api.deepseek.com", model: "deepseek-chat", enabled: true }, - { id: "backup-draft", label: null, provider: "deepseek" as const, base_url: "https://api.deepseek.com", model: "", enabled: true }, + { id: "backup-ok", label: null, provider: "deepseek" as const, base_url: "https://api.deepseek.com", model: "deepseek-chat", enabled: true, insecure: false }, + { id: "backup-draft", label: null, provider: "deepseek" as const, base_url: "https://api.deepseek.com", model: "", enabled: true, insecure: false }, ], }; render(); @@ -90,7 +91,7 @@ describe("Onboarding", () => { it("automatically saves a pending API key before entering the app", async () => { const configured = { ...config, - llm_config: { provider: "openai" as const, base_url: "https://api.openai.com/v1", model: "gpt-test" }, + llm_config: { provider: "openai" as const, base_url: "https://api.openai.com/v1", model: "gpt-test", insecure: false }, }; const onFinish = vi.fn().mockResolvedValue(true); render(); diff --git a/src/view/playground/fixtures.ts b/src/view/playground/fixtures.ts index 00d7734..4489ca3 100644 --- a/src/view/playground/fixtures.ts +++ b/src/view/playground/fixtures.ts @@ -34,7 +34,7 @@ export function makeConfig(profiles: JobProfile[] = [makeProfile()]): AppRuntime return { schema_version: 1, onboarding_completed: true, - llm_config: { provider: "openai", base_url: "https://example.test", model: "gpt-x" }, + llm_config: { provider: "openai", base_url: "https://example.test", model: "gpt-x", insecure: false }, llm_enabled: true, llm_fallbacks: [], llm_retry_config: { network_retry_attempts: 1, retry_base_delay_ms: 200, request_timeout_seconds: 120 },