Feat/usage model extraction (#455)

* feat(proxy): extract model name from API response for accurate usage tracking

- Add model field extraction in TokenUsage parsing for Claude, OpenAI, and Codex
- Prioritize response model over request model in usage logging
- Update model extractors to use parsed usage.model first
- Add tests for model extraction in stream and non-stream responses

* feat(proxy): implement streaming timeout control with validation

- Add first byte timeout (0 or 1-180s) for streaming requests
- Add idle timeout (0 or 60-600s) for streaming data gaps
- Add non-streaming timeout (0 or 60-1800s) for total request
- Implement timeout logic in response processor
- Add 1800s global timeout fallback when disabled
- Add database schema migration for timeout fields
- Add i18n translations for timeout settings

* feat(proxy): add model mapping module for provider-based model substitution

- Add model_mapper.rs with ModelMapping struct to extract model configs from Provider
- Support ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL, and default models for haiku/sonnet/opus
- Implement thinking mode detection for reasoning model priority
- Include comprehensive unit tests for all mapping scenarios

* fix(proxy): bypass circuit breaker for single provider scenario

When failover is disabled (single provider), circuit breaker open state
would block all requests causing poor UX. Now bypasses circuit breaker
check in this scenario. Also integrates model mapping into request flow.

* feat(ui): add reasoning model field to Claude provider form

Add ANTHROPIC_REASONING_MODEL configuration field for Claude providers,
allowing users to specify a dedicated model for thinking/reasoning tasks.

* feat(proxy): add openrouter_compat_mode for optional format conversion

Add configurable OpenRouter compatibility mode that enables Anthropic to
OpenAI format conversion. When enabled, rewrites endpoint to /v1/chat/completions
and transforms request/response formats. Defaults to enabled for OpenRouter.

* feat(ui): add OpenRouter compatibility mode toggle

Add UI toggle for OpenRouter providers to enable/disable compatibility
mode which uses OpenAI Chat Completions format with SSE conversion.

* feat(stream-check): use provider-configured model for health checks

Extract model from provider's settings_config (ANTHROPIC_MODEL, GEMINI_MODEL,
or Codex config.toml) instead of always using default test models.

* refactor(ui): remove timeout settings from AutoFailoverConfigPanel

Remove streaming/non-streaming timeout configuration from failover panel
as these settings have been moved to a dedicated location.

* refactor(database): migrate proxy_config to per-app three-row structure

Replace singleton proxy_config table with app_type primary key structure,
allowing independent proxy settings for Claude, Codex, and Gemini.
Add GlobalProxyConfig queries and per-app config management in DAO layer.

* feat(proxy): add GlobalProxyConfig and AppProxyConfig types

Add new type definitions for the refactored proxy configuration:
- GlobalProxyConfig: shared settings (enabled, address, port, logging)
- AppProxyConfig: per-app settings (failover, timeouts, circuit breaker)

* refactor(proxy): update service layer for per-app config structure

Adapt proxy service, handler context, and provider router to use
the new per-app configuration model. Read enabled/timeout settings
from proxy_config table instead of settings table.

* feat(commands): add global and per-app proxy config commands

Add new Tauri commands for the refactored proxy configuration:
- get_global_proxy_config / update_global_proxy_config
- get_proxy_config_for_app / update_proxy_config_for_app
Update startup restore logic to read from proxy_config table.

* feat(api): add frontend API and Query hooks for proxy config

Add TypeScript wrappers and TanStack Query hooks for:
- Global proxy config (address, port, logging)
- Per-app proxy config (failover, timeouts, circuit breaker)
- Proxy takeover status management

* refactor(ui): redesign proxy panel with inline config controls

Replace ProxySettingsDialog with inline controls in ProxyPanel.
Add per-app takeover switches and global address/port settings.
Simplify AutoFailoverConfigPanel by removing timeout settings.

* feat(i18n): add proxy takeover translations and update types

Add i18n strings for proxy takeover status in zh/en/ja.
Update TypeScript types for GlobalProxyConfig and AppProxyConfig.

* refactor(proxy): load circuit breaker config per-app instead of globally

Extract app_type from router key and read circuit breaker settings
from the corresponding proxy_config row for each application.
This commit is contained in:
YoVinchen
2025-12-25 10:40:11 +08:00
committed by GitHub
parent 7d495aa772
commit e6f18ba801
37 changed files with 2813 additions and 1095 deletions
+208 -132
View File
@@ -6,51 +6,67 @@ import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Save, Loader2, Info } from "lucide-react";
import { toast } from "sonner";
import {
useCircuitBreakerConfig,
useUpdateCircuitBreakerConfig,
} from "@/lib/query/failover";
import { useAppProxyConfig, useUpdateAppProxyConfig } from "@/lib/query/proxy";
export interface AutoFailoverConfigPanelProps {
enabled?: boolean;
onEnabledChange?: (enabled: boolean) => void;
appType: string;
disabled?: boolean;
}
export function AutoFailoverConfigPanel({
enabled = true,
onEnabledChange: _onEnabledChange,
}: AutoFailoverConfigPanelProps = {}) {
// Note: onEnabledChange is currently unused but kept in the interface
// for potential future use by parent components
void _onEnabledChange;
appType,
disabled = false,
}: AutoFailoverConfigPanelProps) {
const { t } = useTranslation();
const { data: config, isLoading, error } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig();
const { data: config, isLoading, error } = useAppProxyConfig(appType);
const updateConfig = useUpdateAppProxyConfig();
const [formData, setFormData] = useState({
failureThreshold: 5,
successThreshold: 2,
timeoutSeconds: 60,
errorRateThreshold: 0.5,
minRequests: 10,
autoFailoverEnabled: false,
maxRetries: 3,
streamingFirstByteTimeout: 30,
streamingIdleTimeout: 60,
nonStreamingTimeout: 300,
circuitFailureThreshold: 5,
circuitSuccessThreshold: 2,
circuitTimeoutSeconds: 60,
circuitErrorRateThreshold: 0.5,
circuitMinRequests: 10,
});
useEffect(() => {
if (config) {
setFormData({
...config,
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,
});
}
}, [config]);
const handleSave = async () => {
if (!config) return;
try {
await updateConfig.mutateAsync({
failureThreshold: formData.failureThreshold,
successThreshold: formData.successThreshold,
timeoutSeconds: formData.timeoutSeconds,
errorRateThreshold: formData.errorRateThreshold,
minRequests: formData.minRequests,
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,
});
toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
@@ -66,7 +82,16 @@ export function AutoFailoverConfigPanel({
const handleReset = () => {
if (config) {
setFormData({
...config,
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,
});
}
};
@@ -79,16 +104,10 @@ export function AutoFailoverConfigPanel({
);
}
const isDisabled = disabled || updateConfig.isPending;
return (
<div className="border-0 rounded-none shadow-none bg-transparent">
{/* Header Switch moved to parent accordion logic or kept here absolutely positioned if styling permits.
Since we need it in the accordion header, and this component is inside the content, we can use a portal or
absolute positioning trick similar to ProxyPanel, OR cleaner, just duplicate the switch logic in SettingsPage
and pass it down. But for now, let's use the absolute positioning trick to "lift" it visually.
Better yet, let's just render the content directly without the wrapping Card header/collapse logic
since the user requested "click to expand is detailed info, no need to fold again" (implying the accordion handles folding).
*/}
<div className="space-y-4">
{error && (
<Alert variant="destructive">
@@ -114,22 +133,48 @@ export function AutoFailoverConfigPanel({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="failureThreshold">
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
<Label htmlFor={`maxRetries-${appType}`}>
{t("proxy.autoFailover.maxRetries", "最大重试次数")}
</Label>
<Input
id="failureThreshold"
id={`maxRetries-${appType}`}
type="number"
min="1"
max="20"
value={formData.failureThreshold}
min="0"
max="10"
value={formData.maxRetries}
onChange={(e) =>
setFormData({
...formData,
failureThreshold: parseInt(e.target.value) || 5,
maxRetries: parseInt(e.target.value) || 3,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.maxRetriesHint",
"请求失败时的重试次数(0-10",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`failureThreshold-${appType}`}>
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
</Label>
<Input
id={`failureThreshold-${appType}`}
type="number"
min="1"
max="20"
value={formData.circuitFailureThreshold}
onChange={(e) =>
setFormData({
...formData,
circuitFailureThreshold: parseInt(e.target.value) || 5,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -138,59 +183,123 @@ export function AutoFailoverConfigPanel({
)}
</p>
</div>
</div>
</div>
{/* 超时配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold">
{t("proxy.autoFailover.timeoutSettings", "超时配置")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="timeoutSeconds">
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
<Label htmlFor={`streamingFirstByte-${appType}`}>
{t(
"proxy.autoFailover.streamingFirstByte",
"流式首字节超时(秒)",
)}
</Label>
<Input
id="timeoutSeconds"
id={`streamingFirstByte-${appType}`}
type="number"
min="10"
max="300"
value={formData.timeoutSeconds}
min="0"
max="180"
value={formData.streamingFirstByteTimeout}
onChange={(e) =>
setFormData({
...formData,
timeoutSeconds: parseInt(e.target.value) || 60,
streamingFirstByteTimeout: parseInt(e.target.value) || 30,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.timeoutHint",
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
"proxy.autoFailover.streamingFirstByteHint",
"等待首个数据块的最大时间",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`streamingIdle-${appType}`}>
{t("proxy.autoFailover.streamingIdle", "流式静默超时(秒)")}
</Label>
<Input
id={`streamingIdle-${appType}`}
type="number"
min="0"
max="600"
value={formData.streamingIdleTimeout}
onChange={(e) =>
setFormData({
...formData,
streamingIdleTimeout: parseInt(e.target.value) || 60,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.streamingIdleHint",
"数据块之间的最大间隔",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`nonStreaming-${appType}`}>
{t("proxy.autoFailover.nonStreaming", "非流式超时(秒)")}
</Label>
<Input
id={`nonStreaming-${appType}`}
type="number"
min="0"
max="1800"
value={formData.nonStreamingTimeout}
onChange={(e) =>
setFormData({
...formData,
nonStreamingTimeout: parseInt(e.target.value) || 300,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.nonStreamingHint",
"非流式请求的总超时时间",
)}
</p>
</div>
</div>
</div>
{/* 熔断器高级配置 */}
{/* 熔断器配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold">
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器高级设置")}
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器置")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="successThreshold">
<Label htmlFor={`successThreshold-${appType}`}>
{t("proxy.autoFailover.successThreshold", "恢复成功阈值")}
</Label>
<Input
id="successThreshold"
id={`successThreshold-${appType}`}
type="number"
min="1"
max="10"
value={formData.successThreshold}
value={formData.circuitSuccessThreshold}
onChange={(e) =>
setFormData({
...formData,
successThreshold: parseInt(e.target.value) || 2,
circuitSuccessThreshold: parseInt(e.target.value) || 2,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -201,23 +310,50 @@ export function AutoFailoverConfigPanel({
</div>
<div className="space-y-2">
<Label htmlFor="errorRateThreshold">
<Label htmlFor={`timeoutSeconds-${appType}`}>
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
</Label>
<Input
id={`timeoutSeconds-${appType}`}
type="number"
min="10"
max="300"
value={formData.circuitTimeoutSeconds}
onChange={(e) =>
setFormData({
...formData,
circuitTimeoutSeconds: parseInt(e.target.value) || 60,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.timeoutHint",
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`errorRateThreshold-${appType}`}>
{t("proxy.autoFailover.errorRate", "错误率阈值 (%)")}
</Label>
<Input
id="errorRateThreshold"
id={`errorRateThreshold-${appType}`}
type="number"
min="0"
max="100"
step="5"
value={Math.round(formData.errorRateThreshold * 100)}
value={Math.round(formData.circuitErrorRateThreshold * 100)}
onChange={(e) =>
setFormData({
...formData,
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
circuitErrorRateThreshold:
(parseInt(e.target.value) || 50) / 100,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -228,22 +364,22 @@ export function AutoFailoverConfigPanel({
</div>
<div className="space-y-2">
<Label htmlFor="minRequests">
<Label htmlFor={`minRequests-${appType}`}>
{t("proxy.autoFailover.minRequests", "最小请求数")}
</Label>
<Input
id="minRequests"
id={`minRequests-${appType}`}
type="number"
min="5"
max="100"
value={formData.minRequests}
value={formData.circuitMinRequests}
onChange={(e) =>
setFormData({
...formData,
minRequests: parseInt(e.target.value) || 10,
circuitMinRequests: parseInt(e.target.value) || 10,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -257,17 +393,10 @@ export function AutoFailoverConfigPanel({
{/* 操作按钮 */}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="outline"
onClick={handleReset}
disabled={updateConfig.isPending || !enabled}
>
<Button variant="outline" onClick={handleReset} disabled={isDisabled}>
{t("common.reset", "重置")}
</Button>
<Button
onClick={handleSave}
disabled={updateConfig.isPending || !enabled}
>
<Button onClick={handleSave} disabled={isDisabled}>
{updateConfig.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -281,59 +410,6 @@ export function AutoFailoverConfigPanel({
)}
</Button>
</div>
{/* 说明信息 */}
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
<h4 className="font-medium">
{t("proxy.autoFailover.explanationTitle", "工作原理")}
</h4>
<ul className="space-y-1 text-muted-foreground">
<li>
{" "}
<strong>
{t("proxy.autoFailover.failureThresholdLabel", "失败阈值")}
</strong>
{t(
"proxy.autoFailover.failureThresholdExplain",
"连续失败达到此次数时,熔断器打开,该供应商暂时不可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.timeoutLabel", "恢复等待时间")}
</strong>
{t(
"proxy.autoFailover.timeoutExplain",
"熔断器打开后,等待此时间后尝试半开状态",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.successThresholdLabel", "恢复成功阈值")}
</strong>
{t(
"proxy.autoFailover.successThresholdExplain",
"半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.errorRateLabel", "错误率阈值")}
</strong>
{t(
"proxy.autoFailover.errorRateExplain",
"错误率超过此值时,即使未达到失败阈值也会打开熔断器",
)}
</li>
</ul>
</div>
</div>
</div>
);
+261 -46
View File
@@ -1,26 +1,54 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import {
Activity,
Clock,
TrendingUp,
Server,
ListOrdered,
Settings,
Save,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { ProxySettingsDialog } from "./ProxySettingsDialog";
import { toast } from "sonner";
import { useFailoverQueue } from "@/lib/query/failover";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { useProviderHealth } from "@/lib/query/failover";
import {
useProxyTakeoverStatus,
useSetProxyTakeoverForApp,
useGlobalProxyConfig,
useUpdateGlobalProxyConfig,
} from "@/lib/query/proxy";
import type { ProxyStatus } from "@/types/proxy";
import { useTranslation } from "react-i18next";
export function ProxyPanel() {
const { t } = useTranslation();
const { status, isRunning } = useProxyStatus();
const [showSettings, setShowSettings] = useState(false);
// 获取应用接管状态
const { data: takeoverStatus } = useProxyTakeoverStatus();
const setTakeoverForApp = useSetProxyTakeoverForApp();
// 获取全局代理配置
const { data: globalConfig } = useGlobalProxyConfig();
const updateGlobalConfig = useUpdateGlobalProxyConfig();
// 监听地址/端口的本地状态
const [listenAddress, setListenAddress] = useState("127.0.0.1");
const [listenPort, setListenPort] = useState(5000);
// 同步全局配置到本地状态
useEffect(() => {
if (globalConfig) {
setListenAddress(globalConfig.listenAddress);
setListenPort(globalConfig.listenPort);
}
}, [globalConfig]);
// 获取所有三个应用类型的故障转移队列(不包含当前供应商)
// 当前供应商始终优先,队列仅用于失败后的备用顺序
@@ -28,6 +56,69 @@ export function ProxyPanel() {
const { data: codexQueue = [] } = useFailoverQueue("codex");
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
const handleTakeoverChange = async (appType: string, enabled: boolean) => {
try {
await setTakeoverForApp.mutateAsync({ appType, enabled });
toast.success(
enabled
? t("proxy.takeover.enabled", {
app: appType,
defaultValue: `${appType} 接管已启用`,
})
: t("proxy.takeover.disabled", {
app: appType,
defaultValue: `${appType} 接管已关闭`,
}),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.takeover.failed", {
defaultValue: "切换接管状态失败",
}),
);
}
};
const handleLoggingChange = async (enabled: boolean) => {
if (!globalConfig) return;
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
enableLogging: enabled,
});
toast.success(
enabled
? t("proxy.logging.enabled", { defaultValue: "日志记录已启用" })
: t("proxy.logging.disabled", { defaultValue: "日志记录已关闭" }),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.logging.failed", { defaultValue: "切换日志状态失败" }),
);
}
};
const handleSaveBasicConfig = async () => {
if (!globalConfig) return;
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
listenAddress,
listenPort,
});
toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.settings.configSaveFailed", { defaultValue: "保存配置失败" }),
);
}
};
const formatUptime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
@@ -49,22 +140,11 @@ export function ProxyPanel() {
<div className="space-y-6">
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground">
{t("proxy.panel.serviceAddress", {
defaultValue: "服务地址",
})}
</p>
<Button
size="sm"
variant="ghost"
onClick={() => setShowSettings(true)}
className="h-7 gap-1.5 text-xs"
>
<Settings className="h-3.5 w-3.5" />
{t("common.settings")}
</Button>
</div>
<p className="text-xs text-muted-foreground mb-2">
{t("proxy.panel.serviceAddress", {
defaultValue: "服务地址",
})}
</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}
@@ -87,6 +167,11 @@ export function ProxyPanel() {
{t("common.copy")}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
{t("proxy.settings.restartRequired", {
defaultValue: "修改监听地址/端口需要先停止代理服务",
})}
</p>
</div>
<div className="pt-3 border-t border-border space-y-2">
@@ -130,6 +215,63 @@ export function ProxyPanel() {
)}
</div>
{/* 应用接管开关 */}
<div className="pt-3 border-t border-border space-y-3">
<p className="text-xs text-muted-foreground">
{t("proxyConfig.appTakeover", {
defaultValue: "应用接管",
})}
</p>
<div className="grid gap-2 sm:grid-cols-3">
{(["claude", "codex", "gemini"] as const).map((appType) => {
const isEnabled =
takeoverStatus?.[
appType as keyof typeof takeoverStatus
] ?? false;
return (
<div
key={appType}
className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2"
>
<span className="text-sm font-medium capitalize">
{appType}
</span>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleTakeoverChange(appType, checked)
}
disabled={setTakeoverForApp.isPending}
/>
</div>
);
})}
</div>
</div>
{/* 日志记录开关 */}
<div className="pt-3 border-t border-border">
<div className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{t("proxy.settings.fields.enableLogging.label", {
defaultValue: "启用日志记录",
})}
</Label>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.enableLogging.description", {
defaultValue: "记录所有代理请求,便于排查问题",
})}
</p>
</div>
<Switch
checked={globalConfig?.enableLogging ?? true}
onCheckedChange={handleLoggingChange}
disabled={updateGlobalConfig.isPending}
/>
</div>
</div>
{/* 供应商队列 - 按应用类型分组展示 */}
{(claudeQueue.length > 0 ||
codexQueue.length > 0 ||
@@ -217,36 +359,109 @@ export function ProxyPanel() {
</div>
</div>
) : (
<div className="text-center py-10 text-muted-foreground">
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Server className="h-8 w-8" />
<div className="space-y-6">
{/* 空白区域避免冲突 */}
<div className="h-4"></div>
{/* 基础设置 - 监听地址/端口 */}
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.settings.basic.title", {
defaultValue: "基础设置",
})}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.basic.description", {
defaultValue: "配置代理服务监听的地址与端口。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="listen-address">
{t("proxy.settings.fields.listenAddress.label", {
defaultValue: "监听地址",
})}
</Label>
<Input
id="listen-address"
value={listenAddress}
onChange={(e) => setListenAddress(e.target.value)}
placeholder="127.0.0.1"
/>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenAddress.description", {
defaultValue:
"代理服务器监听的 IP 地址(推荐 127.0.0.1",
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="listen-port">
{t("proxy.settings.fields.listenPort.label", {
defaultValue: "监听端口",
})}
</Label>
<Input
id="listen-port"
type="number"
value={listenPort}
onChange={(e) =>
setListenPort(parseInt(e.target.value) || 5000)
}
placeholder="5000"
/>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenPort.description", {
defaultValue: "代理服务器监听的端口号(1024 ~ 65535",
})}
</p>
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleSaveBasicConfig}
disabled={updateGlobalConfig.isPending}
>
{updateGlobalConfig.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("common.saving", { defaultValue: "保存中..." })}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save", { defaultValue: "保存" })}
</>
)}
</Button>
</div>
</div>
{/* 代理服务已停止提示 */}
<div className="text-center py-6 text-muted-foreground">
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Server className="h-8 w-8" />
</div>
<p className="text-base font-medium text-foreground mb-1">
{t("proxy.panel.stoppedTitle", {
defaultValue: "代理服务已停止",
})}
</p>
<p className="text-sm text-muted-foreground">
{t("proxy.panel.stoppedDescription", {
defaultValue: "使用右上角开关即可启动服务",
})}
</p>
</div>
<p className="text-base font-medium text-foreground mb-1">
{t("proxy.panel.stoppedTitle", {
defaultValue: "代理服务已停止",
})}
</p>
<p className="text-sm text-muted-foreground mb-4">
{t("proxy.panel.stoppedDescription", {
defaultValue: "使用右上角开关即可启动服务",
})}
</p>
<Button
size="sm"
variant="outline"
onClick={() => setShowSettings(true)}
className="gap-1.5"
>
<Settings className="h-4 w-4" />
{t("proxy.panel.openSettings", {
defaultValue: "配置代理服务",
})}
</Button>
</div>
)}
</section>
<ProxySettingsDialog open={showSettings} onOpenChange={setShowSettings} />
</>
);
}
@@ -1,403 +0,0 @@
/**
* 代理服务设置对话框
*/
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormDescription,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useProxyConfig } from "@/hooks/useProxyConfig";
import { useEffect, useMemo } from "react";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import type { ProxyConfig } from "@/types/proxy";
// 表单数据类型(仅包含可编辑字段)
type ProxyConfigForm = Pick<
ProxyConfig,
| "listen_address"
| "listen_port"
| "max_retries"
| "request_timeout"
| "enable_logging"
>;
const createProxyConfigSchema = (t: TFunction) => {
const requestTimeoutSchema = z
.number()
.min(
0,
t("proxy.settings.validation.timeoutNonNegative", {
defaultValue: "超时时间不能为负数",
}),
)
.max(
600,
t("proxy.settings.validation.timeoutMax", {
defaultValue: "超时时间最多600秒",
}),
)
.refine((value) => value === 0 || value >= 10, {
message: t("proxy.settings.validation.timeoutRange", {
defaultValue: "请输入 0 或 10-600 之间的数值",
}),
});
return z.object({
listen_address: z.string().regex(
/^(\d{1,3}\.){3}\d{1,3}$/,
t("proxy.settings.validation.addressInvalid", {
defaultValue: "请输入有效的IP地址",
}),
),
listen_port: z
.number()
.min(
1024,
t("proxy.settings.validation.portMin", {
defaultValue: "端口必须大于1024",
}),
)
.max(
65535,
t("proxy.settings.validation.portMax", {
defaultValue: "端口必须小于65535",
}),
),
max_retries: z
.number()
.min(
0,
t("proxy.settings.validation.retryMin", {
defaultValue: "重试次数不能为负",
}),
)
.max(
10,
t("proxy.settings.validation.retryMax", {
defaultValue: "重试次数不能超过10",
}),
),
request_timeout: requestTimeoutSchema,
enable_logging: z.boolean(),
});
};
interface ProxySettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ProxySettingsDialog({
open,
onOpenChange,
}: ProxySettingsDialogProps) {
const { config, isLoading, updateConfig, isUpdating } = useProxyConfig();
const { t } = useTranslation();
const schema = useMemo(() => createProxyConfigSchema(t), [t]);
const closePanel = () => onOpenChange(false);
const form = useForm<ProxyConfigForm>({
resolver: zodResolver(schema),
defaultValues: {
listen_address: "127.0.0.1",
listen_port: 5000,
max_retries: 3,
request_timeout: 300,
enable_logging: true,
},
});
// 当配置加载完成后更新表单
useEffect(() => {
if (config) {
form.reset({
listen_address: config.listen_address,
listen_port: config.listen_port,
max_retries: config.max_retries,
request_timeout: config.request_timeout,
enable_logging: config.enable_logging,
});
}
}, [config, form]);
const onSubmit = async (data: ProxyConfigForm) => {
try {
await updateConfig(data);
closePanel();
} catch (error) {
console.error("Save config failed:", error);
}
};
const formId = "proxy-settings-form";
return (
<FullScreenPanel
isOpen={open}
title={t("proxy.settings.title", { defaultValue: "代理服务设置" })}
onClose={closePanel}
footer={
<>
<Button
type="button"
variant="outline"
onClick={closePanel}
disabled={isUpdating}
>
{t("common.cancel", { defaultValue: "取消" })}
</Button>
<Button
type="submit"
form={formId}
disabled={isUpdating || isLoading}
>
{isUpdating
? t("common.saving", { defaultValue: "保存中..." })
: t("proxy.settings.actions.save", { defaultValue: "保存配置" })}
</Button>
</>
}
>
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
{t("proxy.settings.description", {
defaultValue:
"配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
})}
</p>
<Alert className="border-emerald-500/40 bg-emerald-500/10">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
{t("proxy.settings.alert.autoApply", {
defaultValue:
"保存后将自动同步到正在运行的代理服务,无需手动重启。",
})}
</AlertDescription>
</Alert>
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.basic.title", {
defaultValue: "基础设置",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.basic.description", {
defaultValue: "配置代理服务监听的地址与端口。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="listen_address"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenAddress.label", {
defaultValue: "监听地址",
})}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"proxy.settings.fields.listenAddress.placeholder",
{ defaultValue: "127.0.0.1" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenAddress.description", {
defaultValue:
"代理服务器监听的 IP 地址(推荐 127.0.0.1",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="listen_port"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenPort.label", {
defaultValue: "监听端口",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.listenPort.placeholder",
{ defaultValue: "5000" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenPort.description", {
defaultValue:
"代理服务器监听的端口号(1024 ~ 65535",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</section>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.advanced.title", {
defaultValue: "高级参数",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.advanced.description", {
defaultValue: "控制请求的稳定性和日志记录。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="max_retries"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.maxRetries.label", {
defaultValue: "最大重试次数",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.maxRetries.placeholder",
{ defaultValue: "3" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.maxRetries.description", {
defaultValue: "请求失败时的重试次数(0 ~ 10)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="request_timeout"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.requestTimeout.label", {
defaultValue: "请求超时(秒)",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.requestTimeout.placeholder",
{ defaultValue: "0(不限)或 300" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.requestTimeout.description", {
defaultValue:
"单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="enable_logging"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("proxy.settings.fields.enableLogging.label", {
defaultValue: "启用日志记录",
})}
</FormLabel>
<FormDescription>
{t("proxy.settings.fields.enableLogging.description", {
defaultValue: "记录所有代理请求,便于排查问题",
})}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</section>
</form>
</Form>
</div>
</FullScreenPanel>
);
}
-1
View File
@@ -3,4 +3,3 @@
*/
export { ProxyPanel } from "./ProxyPanel";
export { ProxySettingsDialog } from "./ProxySettingsDialog";