diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 7758d4b07..b94cb58dc 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -585,8 +585,9 @@ impl RequestForwarder { // 默认使用空白名单,过滤所有 _ 前缀字段 let filtered_body = filter_private_params_with_whitelist(request_body, &[]); - // 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置) - let client = super::http_client::get(); + // 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端 + let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref()); + let client = super::http_client::get_for_provider(proxy_config); let mut request = client.post(&url); // 只有当 timeout > 0 时才设置请求超时 diff --git a/src-tauri/src/proxy/http_client.rs b/src-tauri/src/proxy/http_client.rs index b4be31277..cb771b9c3 100644 --- a/src-tauri/src/proxy/http_client.rs +++ b/src-tauri/src/proxy/http_client.rs @@ -3,6 +3,7 @@ //! 提供支持全局代理配置的 HTTP 客户端。 //! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。 +use crate::provider::ProviderProxyConfig; use once_cell::sync::OnceCell; use reqwest::Client; use std::sync::RwLock; @@ -155,7 +156,7 @@ pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> { /// 获取全局 HTTP 客户端 /// -/// 返回配置了代理的客户端(如果已配置代理),否则返回直连客户端。 +/// 返回配置了代理的客户端(如果已配置代理),否则返回跟随系统代理的客户端。 pub fn get() -> Client { GLOBAL_CLIENT .get() @@ -163,13 +164,13 @@ pub fn get() -> Client { .map(|c| c.clone()) .unwrap_or_else(|| { // 如果还没初始化,创建一个默认客户端(配置与 build_client 一致) + // 不调用 no_proxy(),让 reqwest 自动检测系统代理 log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback"); Client::builder() .timeout(Duration::from_secs(600)) .connect_timeout(Duration::from_secs(30)) .pool_max_idle_per_host(10) .tcp_keepalive(Duration::from_secs(60)) - .no_proxy() .build() .unwrap_or_default() }) @@ -199,7 +200,7 @@ fn build_client(proxy_url: Option<&str>) -> Result { .pool_max_idle_per_host(10) .tcp_keepalive(Duration::from_secs(60)); - // 有代理地址则使用代理,否则直连 + // 有代理地址则使用代理,否则跟随系统代理 if let Some(url) = proxy_url { // 先验证 URL 格式和 scheme let parsed = url::Url::parse(url) @@ -219,8 +220,9 @@ fn build_client(proxy_url: Option<&str>) -> Result { builder = builder.proxy(proxy); log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url)); } else { - builder = builder.no_proxy(); - log::debug!("[GlobalProxy] Direct connection (no proxy)"); + // 未设置全局代理时,不调用 no_proxy(),让 reqwest 自动检测系统代理 + // reqwest 会自动读取 HTTP_PROXY、HTTPS_PROXY 等环境变量 + log::debug!("[GlobalProxy] Following system proxy (no explicit proxy configured)"); } builder @@ -247,6 +249,100 @@ pub fn mask_url(url: &str) -> String { } } +/// 根据供应商单独代理配置构建代理 URL +/// +/// 将 ProviderProxyConfig 转换为代理 URL 字符串 +fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option { + let proxy_type = config.proxy_type.as_deref().unwrap_or("http"); + let host = config.proxy_host.as_deref()?; + let port = config.proxy_port?; + + // 构建带认证的代理 URL + if let (Some(username), Some(password)) = (&config.proxy_username, &config.proxy_password) { + if !username.is_empty() && !password.is_empty() { + return Some(format!( + "{proxy_type}://{username}:{password}@{host}:{port}" + )); + } + } + + Some(format!("{proxy_type}://{host}:{port}")) +} + +/// 根据供应商单独代理配置构建 HTTP 客户端 +/// +/// 如果供应商配置了单独代理(enabled = true),则使用该代理构建客户端; +/// 否则返回 None,调用方应使用全局客户端。 +/// +/// # Arguments +/// * `proxy_config` - 供应商的代理配置 +/// +/// # Returns +/// 如果配置有效则返回 Some(Client),否则返回 None +pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Option { + let config = proxy_config.filter(|c| c.enabled)?; + + let proxy_url = build_proxy_url_from_config(config)?; + + log::debug!( + "[ProviderProxy] Building client with proxy: {}", + mask_url(&proxy_url) + ); + + // 构建带代理的客户端 + let proxy = match reqwest::Proxy::all(&proxy_url) { + Ok(p) => p, + Err(e) => { + log::error!( + "[ProviderProxy] Failed to create proxy from '{}': {}", + mask_url(&proxy_url), + e + ); + return None; + } + }; + + match Client::builder() + .timeout(Duration::from_secs(600)) + .connect_timeout(Duration::from_secs(30)) + .pool_max_idle_per_host(10) + .tcp_keepalive(Duration::from_secs(60)) + .proxy(proxy) + .build() + { + Ok(client) => { + log::info!( + "[ProviderProxy] Client built with proxy: {}", + mask_url(&proxy_url) + ); + Some(client) + } + Err(e) => { + log::error!("[ProviderProxy] Failed to build client: {e}"); + None + } + } +} + +/// 获取供应商专用的 HTTP 客户端 +/// +/// 优先使用供应商单独代理配置,如果未启用则返回全局客户端。 +/// +/// # Arguments +/// * `proxy_config` - 供应商的代理配置 +/// +/// # Returns +/// 返回适合该供应商的 HTTP 客户端 +pub fn get_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Client { + // 优先使用供应商单独代理 + if let Some(client) = build_client_for_provider(proxy_config) { + return client; + } + + // 回退到全局客户端 + get() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index bb49f5b4e..aeacdd6a4 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -190,8 +190,9 @@ impl StreamCheckService { .extract_auth(provider) .ok_or_else(|| AppError::Message("API Key not found".to_string()))?; - // 使用全局 HTTP 客户端(已包含代理配置) - let client = crate::proxy::http_client::get(); + // 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端 + let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref()); + let client = crate::proxy::http_client::get_for_provider(proxy_config); let request_timeout = std::time::Duration::from_secs(config.timeout_secs); let model_to_test = Self::resolve_test_model(app_type, provider, config); diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index 100d413d2..9d022e378 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -129,8 +129,8 @@ export function EditProviderDialog({ }; }, [ provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算 + provider?.meta, // 需要依赖 meta 以便正确初始化 testConfig 和 proxyConfig initialSettingsConfig, - // 注意:不依赖 provider 的其他字段,防止表单重置 ]); const handleSubmit = useCallback( diff --git a/src/components/providers/forms/ProviderAdvancedConfig.tsx b/src/components/providers/forms/ProviderAdvancedConfig.tsx index 02763b7b1..47f727bf6 100644 --- a/src/components/providers/forms/ProviderAdvancedConfig.tsx +++ b/src/components/providers/forms/ProviderAdvancedConfig.tsx @@ -81,20 +81,36 @@ export function ProviderAdvancedConfig({ ); const [showPassword, setShowPassword] = useState(false); - // 代理 URL 输入状态 + // 代理 URL 输入状态(仅在初始化时从 proxyConfig 构建) const [proxyUrl, setProxyUrl] = useState(() => buildProxyUrl(proxyConfig)); - // 同步外部 proxyConfig 变化到内部状态 + // 标记是否为用户主动输入(用于区分外部更新和用户输入) + const [isUserTyping, setIsUserTyping] = useState(false); + + // 同步外部 testConfig.enabled 变化到展开状态 useEffect(() => { - const newUrl = buildProxyUrl(proxyConfig); - if (newUrl !== proxyUrl) { - setProxyUrl(newUrl); + setIsTestConfigOpen(testConfig.enabled); + }, [testConfig.enabled]); + + // 同步外部 proxyConfig.enabled 变化到展开状态 + useEffect(() => { + setIsProxyConfigOpen(proxyConfig.enabled); + }, [proxyConfig.enabled]); + + // 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据) + useEffect(() => { + if (!isUserTyping) { + const newUrl = buildProxyUrl(proxyConfig); + if (newUrl !== proxyUrl) { + setProxyUrl(newUrl); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [proxyConfig.proxyType, proxyConfig.proxyHost, proxyConfig.proxyPort]); - // 处理代理 URL 变化 + // 处理代理 URL 变化(用户输入时不触发 URL 重建) const handleProxyUrlChange = (value: string) => { + setIsUserTyping(true); setProxyUrl(value); const parsed = parseProxyUrl(value); onProxyConfigChange({ @@ -103,6 +119,11 @@ export function ProviderAdvancedConfig({ }); }; + // 输入框失焦时结束用户输入状态 + const handleProxyUrlBlur = () => { + setIsUserTyping(false); + }; + // 清除代理配置 const handleClearProxy = () => { setProxyUrl(""); @@ -361,6 +382,7 @@ export function ProviderAdvancedConfig({ placeholder="http://127.0.0.1:7890 / socks5://127.0.0.1:1080" value={proxyUrl} onChange={(e) => handleProxyUrlChange(e.target.value)} + onBlur={handleProxyUrlBlur} className="font-mono text-sm flex-1" disabled={!proxyConfig.enabled} />