mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
feat(provider): add individual test and proxy config for providers
Add support for provider-specific model test and proxy configurations: - Add ProviderTestConfig and ProviderProxyConfig types in Rust and TypeScript - Create ProviderAdvancedConfig component with collapsible panels - Update stream_check service to merge provider config with global config - Proxy config UI follows global proxy style (single URL input) Provider-level configs stored in meta field, no database schema changes needed.
This commit is contained in:
@@ -142,6 +142,55 @@ pub struct UsageResult {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 供应商单独的模型测试配置
|
||||
#[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<String>,
|
||||
/// 超时时间(秒)
|
||||
#[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_secs: Option<u64>,
|
||||
/// 测试提示词
|
||||
#[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")]
|
||||
pub test_prompt: Option<String>,
|
||||
/// 降级阈值(毫秒)
|
||||
#[serde(
|
||||
rename = "degradedThresholdMs",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub degraded_threshold_ms: Option<u64>,
|
||||
/// 最大重试次数
|
||||
#[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")]
|
||||
pub max_retries: Option<u32>,
|
||||
}
|
||||
|
||||
/// 供应商单独的代理配置
|
||||
#[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<String>,
|
||||
/// 代理主机
|
||||
#[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_host: Option<String>,
|
||||
/// 代理端口
|
||||
#[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_port: Option<u16>,
|
||||
/// 代理用户名(可选)
|
||||
#[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_username: Option<String>,
|
||||
/// 代理密码(可选)
|
||||
#[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_password: Option<String>,
|
||||
}
|
||||
|
||||
/// 供应商元数据
|
||||
#[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<String>,
|
||||
/// 供应商单独的模型测试配置
|
||||
#[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")]
|
||||
pub test_config: Option<ProviderTestConfig>,
|
||||
/// 供应商单独的代理配置
|
||||
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_config: Option<ProviderProxyConfig>,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
|
||||
@@ -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<StreamCheckResult, AppError> {
|
||||
// 合并供应商单独配置和全局配置
|
||||
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,
|
||||
|
||||
@@ -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<ProviderProxyConfig> {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* 模型测试配置 */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors"
|
||||
onClick={() => setIsTestConfigOpen(!isTestConfigOpen)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FlaskConical className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{t("providerAdvanced.testConfig", {
|
||||
defaultValue: "模型测试配置",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Label
|
||||
htmlFor="test-config-enabled"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("providerAdvanced.useCustomConfig", {
|
||||
defaultValue: "使用单独配置",
|
||||
})}
|
||||
</Label>
|
||||
<Switch
|
||||
id="test-config-enabled"
|
||||
checked={testConfig.enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onTestConfigChange({ ...testConfig, enabled: checked });
|
||||
if (checked) setIsTestConfigOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isTestConfigOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
isTestConfigOpen
|
||||
? "max-h-[500px] opacity-100"
|
||||
: "max-h-0 opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="border-t border-border/50 p-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("providerAdvanced.testConfigDesc", {
|
||||
defaultValue:
|
||||
"为此供应商配置单独的模型测试参数,不启用时使用全局配置。",
|
||||
})}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-model">
|
||||
{t("providerAdvanced.testModel", {
|
||||
defaultValue: "测试模型",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="test-model"
|
||||
value={testConfig.testModel || ""}
|
||||
onChange={(e) =>
|
||||
onTestConfigChange({
|
||||
...testConfig,
|
||||
testModel: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder={t("providerAdvanced.testModelPlaceholder", {
|
||||
defaultValue: "留空使用全局配置",
|
||||
})}
|
||||
disabled={!testConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-timeout">
|
||||
{t("providerAdvanced.timeoutSecs", {
|
||||
defaultValue: "超时时间(秒)",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="test-timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
max={300}
|
||||
value={testConfig.timeoutSecs || ""}
|
||||
onChange={(e) =>
|
||||
onTestConfigChange({
|
||||
...testConfig,
|
||||
timeoutSecs: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="45"
|
||||
disabled={!testConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-prompt">
|
||||
{t("providerAdvanced.testPrompt", {
|
||||
defaultValue: "测试提示词",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="test-prompt"
|
||||
value={testConfig.testPrompt || ""}
|
||||
onChange={(e) =>
|
||||
onTestConfigChange({
|
||||
...testConfig,
|
||||
testPrompt: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Who are you?"
|
||||
disabled={!testConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="degraded-threshold">
|
||||
{t("providerAdvanced.degradedThreshold", {
|
||||
defaultValue: "降级阈值(毫秒)",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="degraded-threshold"
|
||||
type="number"
|
||||
min={100}
|
||||
max={60000}
|
||||
value={testConfig.degradedThresholdMs || ""}
|
||||
onChange={(e) =>
|
||||
onTestConfigChange({
|
||||
...testConfig,
|
||||
degradedThresholdMs: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="6000"
|
||||
disabled={!testConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-retries">
|
||||
{t("providerAdvanced.maxRetries", {
|
||||
defaultValue: "最大重试次数",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="max-retries"
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={testConfig.maxRetries ?? ""}
|
||||
onChange={(e) =>
|
||||
onTestConfigChange({
|
||||
...testConfig,
|
||||
maxRetries: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="2"
|
||||
disabled={!testConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 代理配置 */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors"
|
||||
onClick={() => setIsProxyConfigOpen(!isProxyConfigOpen)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{t("providerAdvanced.proxyConfig", {
|
||||
defaultValue: "代理配置",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Label
|
||||
htmlFor="proxy-config-enabled"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("providerAdvanced.useCustomProxy", {
|
||||
defaultValue: "使用单独代理",
|
||||
})}
|
||||
</Label>
|
||||
<Switch
|
||||
id="proxy-config-enabled"
|
||||
checked={proxyConfig.enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onProxyConfigChange({ ...proxyConfig, enabled: checked });
|
||||
if (checked) setIsProxyConfigOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isProxyConfigOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
isProxyConfigOpen
|
||||
? "max-h-[500px] opacity-100"
|
||||
: "max-h-0 opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="border-t border-border/50 p-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("providerAdvanced.proxyConfigDesc", {
|
||||
defaultValue:
|
||||
"为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。",
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* 代理地址输入框(仿照全局代理样式) */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="http://127.0.0.1:7890 / socks5://127.0.0.1:1080"
|
||||
value={proxyUrl}
|
||||
onChange={(e) => handleProxyUrlChange(e.target.value)}
|
||||
className="font-mono text-sm flex-1"
|
||||
disabled={!proxyConfig.enabled}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={!proxyConfig.enabled || !proxyUrl}
|
||||
onClick={handleClearProxy}
|
||||
title={t("common.clear", { defaultValue: "清除" })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 认证信息:用户名 + 密码(可选) */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t("providerAdvanced.proxyUsername", {
|
||||
defaultValue: "用户名(可选)",
|
||||
})}
|
||||
value={proxyConfig.proxyUsername || ""}
|
||||
onChange={(e) =>
|
||||
onProxyConfigChange({
|
||||
...proxyConfig,
|
||||
proxyUsername: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
className="font-mono text-sm flex-1"
|
||||
disabled={!proxyConfig.enabled}
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t("providerAdvanced.proxyPassword", {
|
||||
defaultValue: "密码(可选)",
|
||||
})}
|
||||
value={proxyConfig.proxyPassword || ""}
|
||||
onChange={(e) =>
|
||||
onProxyConfigChange({
|
||||
...proxyConfig,
|
||||
proxyPassword: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
className="font-mono text-sm pr-10"
|
||||
disabled={!proxyConfig.enabled}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
disabled={!proxyConfig.enabled}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ProviderTestConfig>(
|
||||
() => initialData?.meta?.testConfig ?? { enabled: false },
|
||||
);
|
||||
const [proxyConfig, setProxyConfig] = useState<ProviderProxyConfig>(
|
||||
() => 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({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 高级配置:模型测试和代理配置 */}
|
||||
<ProviderAdvancedConfig
|
||||
testConfig={testConfig}
|
||||
proxyConfig={proxyConfig}
|
||||
onTestConfigChange={setTestConfig}
|
||||
onProxyConfigChange={setProxyConfig}
|
||||
/>
|
||||
|
||||
{showButtons && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" type="button" onClick={onCancel}>
|
||||
|
||||
@@ -87,6 +87,38 @@ export interface UsageResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 供应商单独的模型测试配置
|
||||
export interface ProviderTestConfig {
|
||||
// 是否启用单独配置(false 时使用全局配置)
|
||||
enabled: boolean;
|
||||
// 测试用的模型名称(覆盖全局配置)
|
||||
testModel?: string;
|
||||
// 超时时间(秒)
|
||||
timeoutSecs?: number;
|
||||
// 测试提示词
|
||||
testPrompt?: string;
|
||||
// 降级阈值(毫秒)
|
||||
degradedThresholdMs?: number;
|
||||
// 最大重试次数
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
// 供应商单独的代理配置
|
||||
export interface ProviderProxyConfig {
|
||||
// 是否启用单独配置(false 时使用全局/系统代理)
|
||||
enabled: boolean;
|
||||
// 代理类型:http, https, socks5
|
||||
proxyType?: "http" | "https" | "socks5";
|
||||
// 代理主机
|
||||
proxyHost?: string;
|
||||
// 代理端口
|
||||
proxyPort?: number;
|
||||
// 代理用户名(可选)
|
||||
proxyUsername?: string;
|
||||
// 代理密码(可选)
|
||||
proxyPassword?: string;
|
||||
}
|
||||
|
||||
// 供应商元数据(字段名与后端一致,保持 snake_case)
|
||||
export interface ProviderMeta {
|
||||
// 自定义端点:以 URL 为键,值为端点信息
|
||||
@@ -99,6 +131,10 @@ export interface ProviderMeta {
|
||||
isPartner?: boolean;
|
||||
// 合作伙伴促销 key(用于后端识别 PackyCode 等)
|
||||
partnerPromotionKey?: string;
|
||||
// 供应商单独的模型测试配置
|
||||
testConfig?: ProviderTestConfig;
|
||||
// 供应商单独的代理配置
|
||||
proxyConfig?: ProviderProxyConfig;
|
||||
}
|
||||
|
||||
// 应用设置类型(用于设置对话框与 Tauri API)
|
||||
|
||||
Reference in New Issue
Block a user