mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
Refactor/simplify proxy logs (#585)
* refactor(proxy): simplify logging for better readability - Delete 17 verbose debug logs from handlers, streaming, and response_processor - Convert excessive INFO logs to DEBUG level for internal processing details - Add 2 critical INFO logs in forwarder.rs for failover scenarios: - Log when switching to next provider after failure - Log when all providers have been exhausted - Fix clippy uninlined_format_args warning This reduces log noise while maintaining visibility into key user-facing decisions. * fix: replace unsafe unwrap() calls with proper error handling - database/dao/mcp.rs: Use map_err for serde_json serialization - database/dao/providers.rs: Use map_err for settings_config and meta serialization - commands/misc.rs: Use expect() for compile-time regex pattern - services/prompt.rs: Use unwrap_or_default() for SystemTime - deeplink/provider.rs: Replace unwrap() with is_none_or pattern for Option checks Reduces potential panic points from 26 to 1 (static regex init, safe). * refactor(proxy): simplify verbose logging output - Remove response JSON full output logging in response_processor - Remove per-request INFO logs in provider_router (failover status, provider selection) - Change model mapping log from INFO to DEBUG - Change usage logging failure from INFO to WARN - Remove redundant debug logs for circuit breaker operations Reduces log noise significantly while preserving important warnings and errors. * feat(proxy): add structured log codes for i18n support Add error code system to proxy module logs for multi-language support: - CB-001~006: Circuit breaker state transitions and triggers - SRV-001~004: Proxy server lifecycle events - FWD-001~002: Request forwarding and failover - FO-001~005: Failover switch operations - USG-001~002: Usage logging errors Log format: [CODE] Chinese message Frontend/log tools can map codes to any language. New file: src/proxy/log_codes.rs - centralized code definitions * chore: bump version to 3.9.1 * style: format code with prettier and rustfmt * fix(ui): allow number inputs to be fully cleared before saving - Convert numeric state to string type for controlled inputs - Use isNaN() check instead of || fallback to allow 0 values - Apply fix to ProxyPanel, CircuitBreakerConfigPanel, AutoFailoverConfigPanel, and ModelTestConfigPanel * feat(pricing): support @ separator in model name matching - Refactor model name cleaning into chained method calls - Add @ to - replacement (e.g., gpt-5.2-codex@low → gpt-5.2-codex-low) - Add test case for @ separator matching * fix(proxy): improve validation and error handling in proxy config panels - Add StopTimeout/StopFailed error types for proper stop() error reporting - Replace silent clamp with validation-and-block in config panels - Add listenAddress format validation in ProxyPanel - Use log_codes constants instead of hardcoded strings - Use once_cell::Lazy for regex precompilation * fix(proxy): harden error handling and input validation - Handle RwLock poisoning in settings.rs with unwrap_or_else - Add fallback for dirs::home_dir() in config modules - Normalize localhost to 127.0.0.1 in ProxyPanel - Format IPv6 addresses with brackets for valid URLs - Strict port validation with pure digit regex - Treat NaN as validation failure in config panels - Log warning on cost_multiplier parse failure - Align timeoutSeconds range to [0, 300] across all panels
This commit is contained in:
+6
-4
@@ -624,10 +624,12 @@ function App() {
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<UpdateBadge onClick={() => {
|
||||
setSettingsDefaultTab("about");
|
||||
setCurrentView("settings");
|
||||
}} />
|
||||
<UpdateBadge
|
||||
onClick={() => {
|
||||
setSettingsDefaultTab("about");
|
||||
setCurrentView("settings");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -21,52 +21,157 @@ export function AutoFailoverConfigPanel({
|
||||
const { data: config, isLoading, error } = useAppProxyConfig(appType);
|
||||
const updateConfig = useUpdateAppProxyConfig();
|
||||
|
||||
// 使用字符串状态以支持完全清空数字输入框
|
||||
const [formData, setFormData] = useState({
|
||||
autoFailoverEnabled: false,
|
||||
maxRetries: 3,
|
||||
streamingFirstByteTimeout: 30,
|
||||
streamingIdleTimeout: 60,
|
||||
nonStreamingTimeout: 300,
|
||||
circuitFailureThreshold: 5,
|
||||
circuitSuccessThreshold: 2,
|
||||
circuitTimeoutSeconds: 60,
|
||||
circuitErrorRateThreshold: 0.5,
|
||||
circuitMinRequests: 10,
|
||||
maxRetries: "3",
|
||||
streamingFirstByteTimeout: "30",
|
||||
streamingIdleTimeout: "60",
|
||||
nonStreamingTimeout: "300",
|
||||
circuitFailureThreshold: "5",
|
||||
circuitSuccessThreshold: "2",
|
||||
circuitTimeoutSeconds: "60",
|
||||
circuitErrorRateThreshold: "50", // 存储百分比值
|
||||
circuitMinRequests: "10",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setFormData({
|
||||
autoFailoverEnabled: config.autoFailoverEnabled,
|
||||
maxRetries: config.maxRetries,
|
||||
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
|
||||
streamingIdleTimeout: config.streamingIdleTimeout,
|
||||
nonStreamingTimeout: config.nonStreamingTimeout,
|
||||
circuitFailureThreshold: config.circuitFailureThreshold,
|
||||
circuitSuccessThreshold: config.circuitSuccessThreshold,
|
||||
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
|
||||
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
|
||||
circuitMinRequests: config.circuitMinRequests,
|
||||
maxRetries: String(config.maxRetries),
|
||||
streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
|
||||
streamingIdleTimeout: String(config.streamingIdleTimeout),
|
||||
nonStreamingTimeout: String(config.nonStreamingTimeout),
|
||||
circuitFailureThreshold: String(config.circuitFailureThreshold),
|
||||
circuitSuccessThreshold: String(config.circuitSuccessThreshold),
|
||||
circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
|
||||
circuitErrorRateThreshold: String(
|
||||
Math.round(config.circuitErrorRateThreshold * 100),
|
||||
),
|
||||
circuitMinRequests: String(config.circuitMinRequests),
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
// 解析数字,返回 NaN 表示无效输入
|
||||
const parseNum = (val: string) => {
|
||||
const trimmed = val.trim();
|
||||
// 必须是纯数字
|
||||
if (!/^-?\d+$/.test(trimmed)) return NaN;
|
||||
return parseInt(trimmed);
|
||||
};
|
||||
|
||||
// 定义各字段的有效范围
|
||||
const ranges = {
|
||||
maxRetries: { min: 0, max: 10 },
|
||||
streamingFirstByteTimeout: { min: 0, max: 180 },
|
||||
streamingIdleTimeout: { min: 0, max: 600 },
|
||||
nonStreamingTimeout: { min: 0, max: 1800 },
|
||||
circuitFailureThreshold: { min: 1, max: 20 },
|
||||
circuitSuccessThreshold: { min: 1, max: 10 },
|
||||
circuitTimeoutSeconds: { min: 0, max: 300 },
|
||||
circuitErrorRateThreshold: { min: 0, max: 100 },
|
||||
circuitMinRequests: { min: 5, max: 100 },
|
||||
};
|
||||
|
||||
// 解析原始值
|
||||
const raw = {
|
||||
maxRetries: parseNum(formData.maxRetries),
|
||||
streamingFirstByteTimeout: parseNum(formData.streamingFirstByteTimeout),
|
||||
streamingIdleTimeout: parseNum(formData.streamingIdleTimeout),
|
||||
nonStreamingTimeout: parseNum(formData.nonStreamingTimeout),
|
||||
circuitFailureThreshold: parseNum(formData.circuitFailureThreshold),
|
||||
circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold),
|
||||
circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds),
|
||||
circuitErrorRateThreshold: parseNum(formData.circuitErrorRateThreshold),
|
||||
circuitMinRequests: parseNum(formData.circuitMinRequests),
|
||||
};
|
||||
|
||||
// 校验是否超出范围(NaN 也视为无效)
|
||||
const errors: string[] = [];
|
||||
const checkRange = (
|
||||
value: number,
|
||||
range: { min: number; max: number },
|
||||
label: string,
|
||||
) => {
|
||||
if (isNaN(value) || value < range.min || value > range.max) {
|
||||
errors.push(`${label}: ${range.min}-${range.max}`);
|
||||
}
|
||||
};
|
||||
|
||||
checkRange(
|
||||
raw.maxRetries,
|
||||
ranges.maxRetries,
|
||||
t("proxy.autoFailover.maxRetries", "最大重试次数"),
|
||||
);
|
||||
checkRange(
|
||||
raw.streamingFirstByteTimeout,
|
||||
ranges.streamingFirstByteTimeout,
|
||||
t("proxy.autoFailover.streamingFirstByte", "流式首字节超时"),
|
||||
);
|
||||
checkRange(
|
||||
raw.streamingIdleTimeout,
|
||||
ranges.streamingIdleTimeout,
|
||||
t("proxy.autoFailover.streamingIdle", "流式静默超时"),
|
||||
);
|
||||
checkRange(
|
||||
raw.nonStreamingTimeout,
|
||||
ranges.nonStreamingTimeout,
|
||||
t("proxy.autoFailover.nonStreaming", "非流式超时"),
|
||||
);
|
||||
checkRange(
|
||||
raw.circuitFailureThreshold,
|
||||
ranges.circuitFailureThreshold,
|
||||
t("proxy.autoFailover.failureThreshold", "失败阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.circuitSuccessThreshold,
|
||||
ranges.circuitSuccessThreshold,
|
||||
t("proxy.autoFailover.successThreshold", "恢复成功阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.circuitTimeoutSeconds,
|
||||
ranges.circuitTimeoutSeconds,
|
||||
t("proxy.autoFailover.timeout", "恢复等待时间"),
|
||||
);
|
||||
checkRange(
|
||||
raw.circuitErrorRateThreshold,
|
||||
ranges.circuitErrorRateThreshold,
|
||||
t("proxy.autoFailover.errorRate", "错误率阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.circuitMinRequests,
|
||||
ranges.circuitMinRequests,
|
||||
t("proxy.autoFailover.minRequests", "最小请求数"),
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
toast.error(
|
||||
t("proxy.autoFailover.validationFailed", {
|
||||
fields: errors.join("; "),
|
||||
defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateConfig.mutateAsync({
|
||||
appType,
|
||||
enabled: config.enabled,
|
||||
autoFailoverEnabled: formData.autoFailoverEnabled,
|
||||
maxRetries: formData.maxRetries,
|
||||
streamingFirstByteTimeout: formData.streamingFirstByteTimeout,
|
||||
streamingIdleTimeout: formData.streamingIdleTimeout,
|
||||
nonStreamingTimeout: formData.nonStreamingTimeout,
|
||||
circuitFailureThreshold: formData.circuitFailureThreshold,
|
||||
circuitSuccessThreshold: formData.circuitSuccessThreshold,
|
||||
circuitTimeoutSeconds: formData.circuitTimeoutSeconds,
|
||||
circuitErrorRateThreshold: formData.circuitErrorRateThreshold,
|
||||
circuitMinRequests: formData.circuitMinRequests,
|
||||
maxRetries: raw.maxRetries,
|
||||
streamingFirstByteTimeout: raw.streamingFirstByteTimeout,
|
||||
streamingIdleTimeout: raw.streamingIdleTimeout,
|
||||
nonStreamingTimeout: raw.nonStreamingTimeout,
|
||||
circuitFailureThreshold: raw.circuitFailureThreshold,
|
||||
circuitSuccessThreshold: raw.circuitSuccessThreshold,
|
||||
circuitTimeoutSeconds: raw.circuitTimeoutSeconds,
|
||||
circuitErrorRateThreshold: raw.circuitErrorRateThreshold / 100,
|
||||
circuitMinRequests: raw.circuitMinRequests,
|
||||
});
|
||||
toast.success(
|
||||
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
|
||||
@@ -83,15 +188,17 @@ export function AutoFailoverConfigPanel({
|
||||
if (config) {
|
||||
setFormData({
|
||||
autoFailoverEnabled: config.autoFailoverEnabled,
|
||||
maxRetries: config.maxRetries,
|
||||
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
|
||||
streamingIdleTimeout: config.streamingIdleTimeout,
|
||||
nonStreamingTimeout: config.nonStreamingTimeout,
|
||||
circuitFailureThreshold: config.circuitFailureThreshold,
|
||||
circuitSuccessThreshold: config.circuitSuccessThreshold,
|
||||
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
|
||||
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
|
||||
circuitMinRequests: config.circuitMinRequests,
|
||||
maxRetries: String(config.maxRetries),
|
||||
streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
|
||||
streamingIdleTimeout: String(config.streamingIdleTimeout),
|
||||
nonStreamingTimeout: String(config.nonStreamingTimeout),
|
||||
circuitFailureThreshold: String(config.circuitFailureThreshold),
|
||||
circuitSuccessThreshold: String(config.circuitSuccessThreshold),
|
||||
circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
|
||||
circuitErrorRateThreshold: String(
|
||||
Math.round(config.circuitErrorRateThreshold * 100),
|
||||
),
|
||||
circuitMinRequests: String(config.circuitMinRequests),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -142,13 +249,9 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="10"
|
||||
value={formData.maxRetries}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
maxRetries: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, maxRetries: e.target.value })
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -169,13 +272,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="1"
|
||||
max="20"
|
||||
value={formData.circuitFailureThreshold}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val),
|
||||
});
|
||||
}}
|
||||
circuitFailureThreshold: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -208,13 +310,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="180"
|
||||
value={formData.streamingFirstByteTimeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
streamingFirstByteTimeout: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
streamingFirstByteTimeout: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -235,13 +336,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="600"
|
||||
value={formData.streamingIdleTimeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
streamingIdleTimeout: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
streamingIdleTimeout: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -262,13 +362,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="1800"
|
||||
value={formData.nonStreamingTimeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
nonStreamingTimeout: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
nonStreamingTimeout: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -298,13 +397,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="1"
|
||||
max="10"
|
||||
value={formData.circuitSuccessThreshold}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val),
|
||||
});
|
||||
}}
|
||||
circuitSuccessThreshold: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -322,16 +420,15 @@ export function AutoFailoverConfigPanel({
|
||||
<Input
|
||||
id={`timeoutSeconds-${appType}`}
|
||||
type="number"
|
||||
min="10"
|
||||
min="0"
|
||||
max="300"
|
||||
value={formData.circuitTimeoutSeconds}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val),
|
||||
});
|
||||
}}
|
||||
circuitTimeoutSeconds: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -352,14 +449,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={Math.round(formData.circuitErrorRateThreshold * 100)}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
value={formData.circuitErrorRateThreshold}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100,
|
||||
});
|
||||
}}
|
||||
circuitErrorRateThreshold: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -380,13 +476,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="5"
|
||||
max="100"
|
||||
value={formData.circuitMinRequests}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val),
|
||||
});
|
||||
}}
|
||||
circuitMinRequests: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -7,42 +7,141 @@ import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* 熔断器配置面板
|
||||
* 允许用户调整熔断器参数
|
||||
*/
|
||||
export function CircuitBreakerConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config, isLoading } = useCircuitBreakerConfig();
|
||||
const updateConfig = useUpdateCircuitBreakerConfig();
|
||||
|
||||
// 使用字符串状态以支持完全清空输入框
|
||||
const [formData, setFormData] = useState({
|
||||
failureThreshold: 5,
|
||||
successThreshold: 2,
|
||||
timeoutSeconds: 60,
|
||||
errorRateThreshold: 0.5,
|
||||
minRequests: 10,
|
||||
failureThreshold: "5",
|
||||
successThreshold: "2",
|
||||
timeoutSeconds: "60",
|
||||
errorRateThreshold: "50", // 存储百分比值
|
||||
minRequests: "10",
|
||||
});
|
||||
|
||||
// 当配置加载完成时更新表单数据
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setFormData(config);
|
||||
setFormData({
|
||||
failureThreshold: String(config.failureThreshold),
|
||||
successThreshold: String(config.successThreshold),
|
||||
timeoutSeconds: String(config.timeoutSeconds),
|
||||
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
|
||||
minRequests: String(config.minRequests),
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// 解析数字,返回 NaN 表示无效输入
|
||||
const parseNum = (val: string) => {
|
||||
const trimmed = val.trim();
|
||||
// 必须是纯数字
|
||||
if (!/^-?\d+$/.test(trimmed)) return NaN;
|
||||
return parseInt(trimmed);
|
||||
};
|
||||
|
||||
// 定义各字段的有效范围
|
||||
const ranges = {
|
||||
failureThreshold: { min: 1, max: 20 },
|
||||
successThreshold: { min: 1, max: 10 },
|
||||
timeoutSeconds: { min: 0, max: 300 },
|
||||
errorRateThreshold: { min: 0, max: 100 },
|
||||
minRequests: { min: 5, max: 100 },
|
||||
};
|
||||
|
||||
// 解析原始值
|
||||
const raw = {
|
||||
failureThreshold: parseNum(formData.failureThreshold),
|
||||
successThreshold: parseNum(formData.successThreshold),
|
||||
timeoutSeconds: parseNum(formData.timeoutSeconds),
|
||||
errorRateThreshold: parseNum(formData.errorRateThreshold),
|
||||
minRequests: parseNum(formData.minRequests),
|
||||
};
|
||||
|
||||
// 校验是否超出范围(NaN 也视为无效)
|
||||
const errors: string[] = [];
|
||||
const checkRange = (
|
||||
value: number,
|
||||
range: { min: number; max: number },
|
||||
label: string,
|
||||
) => {
|
||||
if (isNaN(value) || value < range.min || value > range.max) {
|
||||
errors.push(`${label}: ${range.min}-${range.max}`);
|
||||
}
|
||||
};
|
||||
|
||||
checkRange(
|
||||
raw.failureThreshold,
|
||||
ranges.failureThreshold,
|
||||
t("circuitBreaker.failureThreshold", "失败阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.successThreshold,
|
||||
ranges.successThreshold,
|
||||
t("circuitBreaker.successThreshold", "成功阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.timeoutSeconds,
|
||||
ranges.timeoutSeconds,
|
||||
t("circuitBreaker.timeoutSeconds", "超时时间"),
|
||||
);
|
||||
checkRange(
|
||||
raw.errorRateThreshold,
|
||||
ranges.errorRateThreshold,
|
||||
t("circuitBreaker.errorRateThreshold", "错误率阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.minRequests,
|
||||
ranges.minRequests,
|
||||
t("circuitBreaker.minRequests", "最小请求数"),
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
toast.error(
|
||||
t("circuitBreaker.validationFailed", {
|
||||
fields: errors.join("; "),
|
||||
defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateConfig.mutateAsync(formData);
|
||||
toast.success("熔断器配置已保存", { closeButton: true });
|
||||
await updateConfig.mutateAsync({
|
||||
failureThreshold: raw.failureThreshold,
|
||||
successThreshold: raw.successThreshold,
|
||||
timeoutSeconds: raw.timeoutSeconds,
|
||||
errorRateThreshold: raw.errorRateThreshold / 100,
|
||||
minRequests: raw.minRequests,
|
||||
});
|
||||
toast.success(t("circuitBreaker.configSaved", "熔断器配置已保存"), {
|
||||
closeButton: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("保存失败: " + String(error));
|
||||
toast.error(
|
||||
t("circuitBreaker.saveFailed", "保存失败") + ": " + String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (config) {
|
||||
setFormData(config);
|
||||
setFormData({
|
||||
failureThreshold: String(config.failureThreshold),
|
||||
successThreshold: String(config.successThreshold),
|
||||
timeoutSeconds: String(config.timeoutSeconds),
|
||||
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
|
||||
minRequests: String(config.minRequests),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,10 +171,7 @@ export function CircuitBreakerConfigPanel() {
|
||||
max="20"
|
||||
value={formData.failureThreshold}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
failureThreshold: parseInt(e.target.value) || 5,
|
||||
})
|
||||
setFormData({ ...formData, failureThreshold: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -89,14 +185,11 @@ export function CircuitBreakerConfigPanel() {
|
||||
<Input
|
||||
id="timeoutSeconds"
|
||||
type="number"
|
||||
min="10"
|
||||
min="0"
|
||||
max="300"
|
||||
value={formData.timeoutSeconds}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
timeoutSeconds: parseInt(e.target.value) || 60,
|
||||
})
|
||||
setFormData({ ...formData, timeoutSeconds: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -114,10 +207,7 @@ export function CircuitBreakerConfigPanel() {
|
||||
max="10"
|
||||
value={formData.successThreshold}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
successThreshold: parseInt(e.target.value) || 2,
|
||||
})
|
||||
setFormData({ ...formData, successThreshold: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -134,12 +224,9 @@ export function CircuitBreakerConfigPanel() {
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={Math.round(formData.errorRateThreshold * 100)}
|
||||
value={formData.errorRateThreshold}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
|
||||
})
|
||||
setFormData({ ...formData, errorRateThreshold: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -157,10 +244,7 @@ export function CircuitBreakerConfigPanel() {
|
||||
max="100"
|
||||
value={formData.minRequests}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
minRequests: parseInt(e.target.value) || 10,
|
||||
})
|
||||
setFormData({ ...formData, minRequests: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -38,15 +38,15 @@ export function ProxyPanel() {
|
||||
const { data: globalConfig } = useGlobalProxyConfig();
|
||||
const updateGlobalConfig = useUpdateGlobalProxyConfig();
|
||||
|
||||
// 监听地址/端口的本地状态
|
||||
// 监听地址/端口的本地状态(端口用字符串以支持完全清空)
|
||||
const [listenAddress, setListenAddress] = useState("127.0.0.1");
|
||||
const [listenPort, setListenPort] = useState(15721);
|
||||
const [listenPort, setListenPort] = useState("15721");
|
||||
|
||||
// 同步全局配置到本地状态
|
||||
useEffect(() => {
|
||||
if (globalConfig) {
|
||||
setListenAddress(globalConfig.listenAddress);
|
||||
setListenPort(globalConfig.listenPort);
|
||||
setListenPort(String(globalConfig.listenPort));
|
||||
}
|
||||
}, [globalConfig]);
|
||||
|
||||
@@ -102,12 +102,57 @@ export function ProxyPanel() {
|
||||
|
||||
const handleSaveBasicConfig = async () => {
|
||||
if (!globalConfig) return;
|
||||
|
||||
// 校验地址格式(简单的 IP 地址或 localhost 校验)
|
||||
const addressTrimmed = listenAddress.trim();
|
||||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
// 规范化 localhost 为 127.0.0.1
|
||||
const normalizedAddress =
|
||||
addressTrimmed === "localhost" ? "127.0.0.1" : addressTrimmed;
|
||||
const isValidAddress =
|
||||
normalizedAddress === "0.0.0.0" ||
|
||||
(ipv4Regex.test(normalizedAddress) &&
|
||||
normalizedAddress.split(".").every((n) => {
|
||||
const num = parseInt(n);
|
||||
return num >= 0 && num <= 255;
|
||||
}));
|
||||
if (!isValidAddress) {
|
||||
toast.error(
|
||||
t("proxy.settings.invalidAddress", {
|
||||
defaultValue:
|
||||
"地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 严格校验端口:必须是纯数字
|
||||
const portTrimmed = listenPort.trim();
|
||||
if (!/^\d+$/.test(portTrimmed)) {
|
||||
toast.error(
|
||||
t("proxy.settings.invalidPort", {
|
||||
defaultValue: "端口无效,请输入 1024-65535 之间的数字",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const port = parseInt(portTrimmed);
|
||||
if (isNaN(port) || port < 1024 || port > 65535) {
|
||||
toast.error(
|
||||
t("proxy.settings.invalidPort", {
|
||||
defaultValue: "端口无效,请输入 1024-65535 之间的数字",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateGlobalConfig.mutateAsync({
|
||||
...globalConfig,
|
||||
listenAddress,
|
||||
listenPort,
|
||||
listenAddress: normalizedAddress,
|
||||
listenPort: port,
|
||||
});
|
||||
// 同步更新本地状态为规范化后的值
|
||||
setListenAddress(normalizedAddress);
|
||||
toast.success(
|
||||
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
|
||||
{ closeButton: true },
|
||||
@@ -133,6 +178,13 @@ export function ProxyPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化地址用于 URL(IPv6 需要方括号)
|
||||
const formatAddressForUrl = (address: string, port: number): string => {
|
||||
const isIPv6 = address.includes(":");
|
||||
const host = isIPv6 ? `[${address}]` : address;
|
||||
return `http://${host}:${port}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-6">
|
||||
@@ -147,14 +199,14 @@ export function ProxyPanel() {
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
|
||||
http://{status.address}:{status.port}
|
||||
{formatAddressForUrl(status.address, status.port)}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`http://${status.address}:${status.port}`,
|
||||
formatAddressForUrl(status.address, status.port),
|
||||
);
|
||||
toast.success(
|
||||
t("proxy.panel.addressCopied", {
|
||||
@@ -389,9 +441,12 @@ export function ProxyPanel() {
|
||||
id="listen-address"
|
||||
value={listenAddress}
|
||||
onChange={(e) => setListenAddress(e.target.value)}
|
||||
placeholder={t("proxy.settings.fields.listenAddress.placeholder", {
|
||||
defaultValue: "127.0.0.1",
|
||||
})}
|
||||
placeholder={t(
|
||||
"proxy.settings.fields.listenAddress.placeholder",
|
||||
{
|
||||
defaultValue: "127.0.0.1",
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.settings.fields.listenAddress.description", {
|
||||
@@ -411,12 +466,13 @@ export function ProxyPanel() {
|
||||
id="listen-port"
|
||||
type="number"
|
||||
value={listenPort}
|
||||
onChange={(e) =>
|
||||
setListenPort(parseInt(e.target.value) || 15721)
|
||||
}
|
||||
placeholder={t("proxy.settings.fields.listenPort.placeholder", {
|
||||
defaultValue: "15721",
|
||||
})}
|
||||
onChange={(e) => setListenPort(e.target.value)}
|
||||
placeholder={t(
|
||||
"proxy.settings.fields.listenPort.placeholder",
|
||||
{
|
||||
defaultValue: "15721",
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.settings.fields.listenPort.description", {
|
||||
|
||||
@@ -17,10 +17,11 @@ export function ModelTestConfigPanel() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [config, setConfig] = useState<StreamCheckConfig>({
|
||||
timeoutSecs: 45,
|
||||
maxRetries: 2,
|
||||
degradedThresholdMs: 6000,
|
||||
// 使用字符串状态以支持完全清空数字输入框
|
||||
const [config, setConfig] = useState({
|
||||
timeoutSecs: "45",
|
||||
maxRetries: "2",
|
||||
degradedThresholdMs: "6000",
|
||||
claudeModel: "claude-haiku-4-5-20251001",
|
||||
codexModel: "gpt-5.1-codex@low",
|
||||
geminiModel: "gemini-3-pro-preview",
|
||||
@@ -35,7 +36,14 @@ export function ModelTestConfigPanel() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await getStreamCheckConfig();
|
||||
setConfig(data);
|
||||
setConfig({
|
||||
timeoutSecs: String(data.timeoutSecs),
|
||||
maxRetries: String(data.maxRetries),
|
||||
degradedThresholdMs: String(data.degradedThresholdMs),
|
||||
claudeModel: data.claudeModel,
|
||||
codexModel: data.codexModel,
|
||||
geminiModel: data.geminiModel,
|
||||
});
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -44,9 +52,22 @@ export function ModelTestConfigPanel() {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// 解析数字,空值使用默认值,0 是有效值
|
||||
const parseNum = (val: string, defaultVal: number) => {
|
||||
const n = parseInt(val);
|
||||
return isNaN(n) ? defaultVal : n;
|
||||
};
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await saveStreamCheckConfig(config);
|
||||
const parsed: StreamCheckConfig = {
|
||||
timeoutSecs: parseNum(config.timeoutSecs, 45),
|
||||
maxRetries: parseNum(config.maxRetries, 2),
|
||||
degradedThresholdMs: parseNum(config.degradedThresholdMs, 6000),
|
||||
claudeModel: config.claudeModel,
|
||||
codexModel: config.codexModel,
|
||||
geminiModel: config.geminiModel,
|
||||
};
|
||||
await saveStreamCheckConfig(parsed);
|
||||
toast.success(t("streamCheck.configSaved"), {
|
||||
closeButton: true,
|
||||
});
|
||||
@@ -132,10 +153,7 @@ export function ModelTestConfigPanel() {
|
||||
max={120}
|
||||
value={config.timeoutSecs}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
timeoutSecs: parseInt(e.target.value) || 45,
|
||||
})
|
||||
setConfig({ ...config, timeoutSecs: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -149,10 +167,7 @@ export function ModelTestConfigPanel() {
|
||||
max={5}
|
||||
value={config.maxRetries}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
maxRetries: parseInt(e.target.value) || 2,
|
||||
})
|
||||
setConfig({ ...config, maxRetries: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -169,10 +184,7 @@ export function ModelTestConfigPanel() {
|
||||
step={1000}
|
||||
value={config.degradedThresholdMs}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
degradedThresholdMs: parseInt(e.target.value) || 6000,
|
||||
})
|
||||
setConfig({ ...config, degradedThresholdMs: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -405,9 +405,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
// 请求地址候选(用于地址管理/测速)
|
||||
endpointCandidates: [
|
||||
"https://api.aigocode.com",
|
||||
],
|
||||
endpointCandidates: ["https://api.aigocode.com"],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "aigocode", // 促销信息 i18n key
|
||||
|
||||
@@ -181,7 +181,11 @@ requires_openai_auth = true`,
|
||||
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||
category: "third_party",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig("aigocode", "https://api.aigocode.com/openai", "gpt-5.2"),
|
||||
config: generateThirdPartyConfig(
|
||||
"aigocode",
|
||||
"https://api.aigocode.com/openai",
|
||||
"gpt-5.2",
|
||||
),
|
||||
endpointCandidates: ["https://api.aigocode.com"],
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "aigocode", // 促销信息 i18n key
|
||||
|
||||
@@ -70,7 +70,7 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
"https://www.packyapi.com",
|
||||
],
|
||||
icon: "packycode",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Cubence",
|
||||
websiteUrl: "https://cubence.com",
|
||||
|
||||
@@ -1107,7 +1107,12 @@
|
||||
"toast": {
|
||||
"saved": "Proxy configuration saved",
|
||||
"saveFailed": "Save failed: {{error}}"
|
||||
}
|
||||
},
|
||||
"invalidPort": "Invalid port, please enter a number between 1024-65535",
|
||||
"invalidAddress": "Invalid address, please enter a valid IP address (e.g. 127.0.0.1) or localhost",
|
||||
"configSaved": "Proxy configuration saved",
|
||||
"configSaveFailed": "Failed to save configuration",
|
||||
"restartRequired": "Restart proxy service for address or port changes to take effect"
|
||||
},
|
||||
"switchFailed": "Switch failed: {{error}}",
|
||||
"failover": {
|
||||
@@ -1134,6 +1139,7 @@
|
||||
"info": "When the failover queue has multiple providers, the system will try them in priority order when requests fail. When a provider reaches the consecutive failure threshold, the circuit breaker will open and skip it temporarily.",
|
||||
"configSaved": "Auto failover config saved",
|
||||
"configSaveFailed": "Failed to save",
|
||||
"validationFailed": "The following fields are out of valid range: {{fields}}",
|
||||
"retrySettings": "Retry & Timeout Settings",
|
||||
"failureThreshold": "Failure Threshold",
|
||||
"failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)",
|
||||
@@ -1185,6 +1191,16 @@
|
||||
"streamingIdle": "Streaming Idle Timeout",
|
||||
"nonStreaming": "Non-Streaming Timeout"
|
||||
},
|
||||
"circuitBreaker": {
|
||||
"failureThreshold": "Failure Threshold",
|
||||
"successThreshold": "Success Threshold",
|
||||
"timeoutSeconds": "Timeout (seconds)",
|
||||
"errorRateThreshold": "Error Rate Threshold",
|
||||
"minRequests": "Min Requests",
|
||||
"validationFailed": "The following fields are out of valid range: {{fields}}",
|
||||
"configSaved": "Circuit breaker config saved",
|
||||
"saveFailed": "Failed to save"
|
||||
},
|
||||
"universalProvider": {
|
||||
"title": "Universal Provider",
|
||||
"description": "Universal providers manage Claude, Codex, and Gemini configurations simultaneously. Changes are automatically synced to all enabled apps.",
|
||||
|
||||
@@ -1107,7 +1107,12 @@
|
||||
"toast": {
|
||||
"saved": "代理配置已保存",
|
||||
"saveFailed": "保存失败: {{error}}"
|
||||
}
|
||||
},
|
||||
"invalidPort": "端口无效,请输入 1024-65535 之间的数字",
|
||||
"invalidAddress": "地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
|
||||
"configSaved": "代理配置已保存",
|
||||
"configSaveFailed": "保存配置失败",
|
||||
"restartRequired": "修改地址或端口后需要重启代理服务才能生效"
|
||||
},
|
||||
"switchFailed": "切换失败: {{error}}",
|
||||
"failover": {
|
||||
@@ -1134,6 +1139,7 @@
|
||||
"info": "当故障转移队列中配置了多个供应商时,系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会打开并在一段时间内跳过该供应商。",
|
||||
"configSaved": "自动故障转移配置已保存",
|
||||
"configSaveFailed": "保存失败",
|
||||
"validationFailed": "以下字段超出有效范围: {{fields}}",
|
||||
"retrySettings": "重试与超时设置",
|
||||
"failureThreshold": "失败阈值",
|
||||
"failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)",
|
||||
@@ -1185,6 +1191,16 @@
|
||||
"streamingIdle": "流式静默超时",
|
||||
"nonStreaming": "非流式超时"
|
||||
},
|
||||
"circuitBreaker": {
|
||||
"failureThreshold": "失败阈值",
|
||||
"successThreshold": "成功阈值",
|
||||
"timeoutSeconds": "超时时间",
|
||||
"errorRateThreshold": "错误率阈值",
|
||||
"minRequests": "最小请求数",
|
||||
"validationFailed": "以下字段超出有效范围: {{fields}}",
|
||||
"configSaved": "熔断器配置已保存",
|
||||
"saveFailed": "保存失败"
|
||||
},
|
||||
"universalProvider": {
|
||||
"title": "统一供应商",
|
||||
"description": "统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。",
|
||||
|
||||
Reference in New Issue
Block a user