mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 16:56:16 +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:
@@ -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