diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 5e9ab7328..c1bcf3040 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -142,6 +142,55 @@ pub struct UsageResult { pub error: Option, } +/// 供应商单独的模型测试配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProviderTestConfig { + /// 是否启用单独配置(false 时使用全局配置) + #[serde(default)] + pub enabled: bool, + /// 测试用的模型名称(覆盖全局配置) + #[serde(rename = "testModel", skip_serializing_if = "Option::is_none")] + pub test_model: Option, + /// 超时时间(秒) + #[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")] + pub timeout_secs: Option, + /// 测试提示词 + #[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")] + pub test_prompt: Option, + /// 降级阈值(毫秒) + #[serde( + rename = "degradedThresholdMs", + skip_serializing_if = "Option::is_none" + )] + pub degraded_threshold_ms: Option, + /// 最大重试次数 + #[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")] + pub max_retries: Option, +} + +/// 供应商单独的代理配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProviderProxyConfig { + /// 是否启用单独配置(false 时使用全局/系统代理) + #[serde(default)] + pub enabled: bool, + /// 代理类型:http, https, socks5 + #[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")] + pub proxy_type: Option, + /// 代理主机 + #[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")] + pub proxy_host: Option, + /// 代理端口 + #[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")] + pub proxy_port: Option, + /// 代理用户名(可选) + #[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")] + pub proxy_username: Option, + /// 代理密码(可选) + #[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")] + pub proxy_password: Option, +} + /// 供应商元数据 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProviderMeta { @@ -172,6 +221,12 @@ pub struct ProviderMeta { /// 每月消费限额(USD) #[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")] pub limit_monthly_usd: Option, + /// 供应商单独的模型测试配置 + #[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")] + pub test_config: Option, + /// 供应商单独的代理配置 + #[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")] + pub proxy_config: Option, } impl ProviderManager { diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 3a77fad07..bb49f5b4e 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -78,15 +78,19 @@ pub struct StreamCheckService; impl StreamCheckService { /// 执行流式健康检查(带重试) + /// + /// 如果 Provider 配置了单独的测试配置(meta.testConfig),则使用该配置覆盖全局配置 pub async fn check_with_retry( app_type: &AppType, provider: &Provider, config: &StreamCheckConfig, ) -> Result { + // 合并供应商单独配置和全局配置 + let effective_config = Self::merge_provider_config(provider, config); let mut last_result = None; - for attempt in 0..=config.max_retries { - let result = Self::check_once(app_type, provider, config).await; + for attempt in 0..=effective_config.max_retries { + let result = Self::check_once(app_type, provider, &effective_config).await; match &result { Ok(r) if r.success => { @@ -97,7 +101,7 @@ impl StreamCheckService { } Ok(r) => { // 失败但非异常,判断是否重试 - if Self::should_retry(&r.message) && attempt < config.max_retries { + if Self::should_retry(&r.message) && attempt < effective_config.max_retries { last_result = Some(r.clone()); continue; } @@ -107,7 +111,8 @@ impl StreamCheckService { }); } Err(e) => { - if Self::should_retry(&e.to_string()) && attempt < config.max_retries { + if Self::should_retry(&e.to_string()) && attempt < effective_config.max_retries + { continue; } return Err(AppError::Message(e.to_string())); @@ -123,10 +128,51 @@ impl StreamCheckService { http_status: None, model_used: String::new(), tested_at: chrono::Utc::now().timestamp(), - retry_count: config.max_retries, + retry_count: effective_config.max_retries, })) } + /// 合并供应商单独配置和全局配置 + /// + /// 如果供应商配置了 meta.testConfig 且 enabled 为 true,则使用供应商配置覆盖全局配置 + fn merge_provider_config( + provider: &Provider, + global_config: &StreamCheckConfig, + ) -> StreamCheckConfig { + let test_config = provider + .meta + .as_ref() + .and_then(|m| m.test_config.as_ref()) + .filter(|tc| tc.enabled); + + match test_config { + Some(tc) => StreamCheckConfig { + timeout_secs: tc.timeout_secs.unwrap_or(global_config.timeout_secs), + max_retries: tc.max_retries.unwrap_or(global_config.max_retries), + degraded_threshold_ms: tc + .degraded_threshold_ms + .unwrap_or(global_config.degraded_threshold_ms), + claude_model: tc + .test_model + .clone() + .unwrap_or_else(|| global_config.claude_model.clone()), + codex_model: tc + .test_model + .clone() + .unwrap_or_else(|| global_config.codex_model.clone()), + gemini_model: tc + .test_model + .clone() + .unwrap_or_else(|| global_config.gemini_model.clone()), + test_prompt: tc + .test_prompt + .clone() + .unwrap_or_else(|| global_config.test_prompt.clone()), + }, + None => global_config.clone(), + } + } + /// 单次流式检查 async fn check_once( app_type: &AppType, diff --git a/src/components/providers/forms/ProviderAdvancedConfig.tsx b/src/components/providers/forms/ProviderAdvancedConfig.tsx new file mode 100644 index 000000000..02763b7b1 --- /dev/null +++ b/src/components/providers/forms/ProviderAdvancedConfig.tsx @@ -0,0 +1,433 @@ +import { useTranslation } from "react-i18next"; +import { useState, useEffect } from "react"; +import { + ChevronDown, + ChevronRight, + FlaskConical, + Globe, + Eye, + EyeOff, + X, +} from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { ProviderTestConfig, ProviderProxyConfig } from "@/types"; + +interface ProviderAdvancedConfigProps { + testConfig: ProviderTestConfig; + proxyConfig: ProviderProxyConfig; + onTestConfigChange: (config: ProviderTestConfig) => void; + onProxyConfigChange: (config: ProviderProxyConfig) => void; +} + +/** 从 ProviderProxyConfig 构建完整 URL */ +function buildProxyUrl(config: ProviderProxyConfig): string { + if (!config.proxyHost) return ""; + + const protocol = config.proxyType || "http"; + const host = config.proxyHost; + const port = config.proxyPort || (protocol === "socks5" ? 1080 : 7890); + + return `${protocol}://${host}:${port}`; +} + +/** 从完整 URL 解析为 ProviderProxyConfig */ +function parseProxyUrl(url: string): Partial { + if (!url.trim()) { + return { proxyHost: undefined, proxyPort: undefined, proxyType: undefined }; + } + + try { + const parsed = new URL(url); + const protocol = parsed.protocol.replace(":", "") as + | "http" + | "https" + | "socks5"; + const host = parsed.hostname; + const port = parsed.port ? parseInt(parsed.port, 10) : undefined; + + return { + proxyType: protocol, + proxyHost: host || undefined, + proxyPort: port, + }; + } catch { + // 尝试简单解析(不是标准 URL 格式) + const match = url.match(/^(?:(\w+):\/\/)?([^:]+)(?::(\d+))?$/); + if (match) { + return { + proxyType: (match[1] as "http" | "https" | "socks5") || "http", + proxyHost: match[2] || undefined, + proxyPort: match[3] ? parseInt(match[3], 10) : undefined, + }; + } + return {}; + } +} + +export function ProviderAdvancedConfig({ + testConfig, + proxyConfig, + onTestConfigChange, + onProxyConfigChange, +}: ProviderAdvancedConfigProps) { + const { t } = useTranslation(); + const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled); + const [isProxyConfigOpen, setIsProxyConfigOpen] = useState( + proxyConfig.enabled, + ); + const [showPassword, setShowPassword] = useState(false); + + // 代理 URL 输入状态 + const [proxyUrl, setProxyUrl] = useState(() => buildProxyUrl(proxyConfig)); + + // 同步外部 proxyConfig 变化到内部状态 + useEffect(() => { + const newUrl = buildProxyUrl(proxyConfig); + if (newUrl !== proxyUrl) { + setProxyUrl(newUrl); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [proxyConfig.proxyType, proxyConfig.proxyHost, proxyConfig.proxyPort]); + + // 处理代理 URL 变化 + const handleProxyUrlChange = (value: string) => { + setProxyUrl(value); + const parsed = parseProxyUrl(value); + onProxyConfigChange({ + ...proxyConfig, + ...parsed, + }); + }; + + // 清除代理配置 + const handleClearProxy = () => { + setProxyUrl(""); + onProxyConfigChange({ + ...proxyConfig, + proxyType: undefined, + proxyHost: undefined, + proxyPort: undefined, + proxyUsername: undefined, + proxyPassword: undefined, + }); + }; + + return ( +
+ {/* 模型测试配置 */} +
+ +
+
+

+ {t("providerAdvanced.testConfigDesc", { + defaultValue: + "为此供应商配置单独的模型测试参数,不启用时使用全局配置。", + })} +

+
+
+ + + onTestConfigChange({ + ...testConfig, + testModel: e.target.value || undefined, + }) + } + placeholder={t("providerAdvanced.testModelPlaceholder", { + defaultValue: "留空使用全局配置", + })} + disabled={!testConfig.enabled} + /> +
+
+ + + onTestConfigChange({ + ...testConfig, + timeoutSecs: e.target.value + ? parseInt(e.target.value, 10) + : undefined, + }) + } + placeholder="45" + disabled={!testConfig.enabled} + /> +
+
+ + + onTestConfigChange({ + ...testConfig, + testPrompt: e.target.value || undefined, + }) + } + placeholder="Who are you?" + disabled={!testConfig.enabled} + /> +
+
+ + + onTestConfigChange({ + ...testConfig, + degradedThresholdMs: e.target.value + ? parseInt(e.target.value, 10) + : undefined, + }) + } + placeholder="6000" + disabled={!testConfig.enabled} + /> +
+
+ + + onTestConfigChange({ + ...testConfig, + maxRetries: e.target.value + ? parseInt(e.target.value, 10) + : undefined, + }) + } + placeholder="2" + disabled={!testConfig.enabled} + /> +
+
+
+
+
+ + {/* 代理配置 */} +
+ +
+
+

+ {t("providerAdvanced.proxyConfigDesc", { + defaultValue: + "为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。", + })} +

+ + {/* 代理地址输入框(仿照全局代理样式) */} +
+ handleProxyUrlChange(e.target.value)} + className="font-mono text-sm flex-1" + disabled={!proxyConfig.enabled} + /> + +
+ + {/* 认证信息:用户名 + 密码(可选) */} +
+ + onProxyConfigChange({ + ...proxyConfig, + proxyUsername: e.target.value || undefined, + }) + } + className="font-mono text-sm flex-1" + disabled={!proxyConfig.enabled} + /> +
+ + onProxyConfigChange({ + ...proxyConfig, + proxyPassword: e.target.value || undefined, + }) + } + className="font-mono text-sm pr-10" + disabled={!proxyConfig.enabled} + /> + +
+
+
+
+
+
+ ); +} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 2c366a16a..a459039ab 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -8,7 +8,12 @@ import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; import type { AppId } from "@/lib/api"; -import type { ProviderCategory, ProviderMeta } from "@/types"; +import type { + ProviderCategory, + ProviderMeta, + ProviderTestConfig, + ProviderProxyConfig, +} from "@/types"; import { providerPresets, type ProviderPreset, @@ -41,6 +46,7 @@ import { BasicFormFields } from "./BasicFormFields"; import { ClaudeFormFields } from "./ClaudeFormFields"; import { CodexFormFields } from "./CodexFormFields"; import { GeminiFormFields } from "./GeminiFormFields"; +import { ProviderAdvancedConfig } from "./ProviderAdvancedConfig"; import { useProviderCategory, useApiKeyState, @@ -151,6 +157,14 @@ export function ProviderForm({ () => initialData?.meta?.endpointAutoSelect ?? true, ); + // 高级配置:模型测试和代理配置 + const [testConfig, setTestConfig] = useState( + () => initialData?.meta?.testConfig ?? { enabled: false }, + ); + const [proxyConfig, setProxyConfig] = useState( + () => initialData?.meta?.proxyConfig ?? { enabled: false }, + ); + // 使用 category hook const { category } = useProviderCategory({ appId, @@ -168,6 +182,8 @@ export function ProviderForm({ setDraftCustomEndpoints([]); } setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true); + setTestConfig(initialData?.meta?.testConfig ?? { enabled: false }); + setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false }); }, [appId, initialData]); const defaultValues: ProviderFormData = useMemo( @@ -883,6 +899,9 @@ export function ProviderForm({ payload.meta = { ...(baseMeta ?? {}), endpointAutoSelect, + // 添加高级配置 + testConfig: testConfig.enabled ? testConfig : undefined, + proxyConfig: proxyConfig.enabled ? proxyConfig : undefined, }; onSubmit(payload); @@ -1391,6 +1410,14 @@ export function ProviderForm({ )} + {/* 高级配置:模型测试和代理配置 */} + + {showButtons && (