Merge remote-tracking branch 'origin/main' into feature/error-request-logging

# Conflicts:
#	src-tauri/src/proxy/handlers.rs
#	src-tauri/src/proxy/mod.rs
#	src-tauri/src/proxy/provider_router.rs
#	src-tauri/src/services/proxy.rs
#	src/components/providers/ProviderActions.tsx
This commit is contained in:
YoVinchen
2025-12-16 17:40:49 +08:00
82 changed files with 3861 additions and 2008 deletions
+1
View File
@@ -142,6 +142,7 @@ function App() {
if (migrated) {
toast.success(
t("migration.success", { defaultValue: "配置迁移成功" }),
{ closeButton: true },
);
}
} catch (error) {
+85
View File
@@ -128,6 +128,7 @@ export function DeepLinkImportDialog() {
description: t("deeplink.mcpImportSuccessDescription", {
count: summary.importedCount,
}),
closeButton: true,
});
}
};
@@ -142,6 +143,7 @@ export function DeepLinkImportDialog() {
description: t("deeplink.importSuccessDescription", {
name: request.name,
}),
closeButton: true,
});
} else if (result.type === "prompt") {
// Prompts don't use React Query, trigger a custom event for refresh
@@ -154,6 +156,7 @@ export function DeepLinkImportDialog() {
description: t("deeplink.promptImportSuccessDescription", {
name: request.name,
}),
closeButton: true,
});
} else if (result.type === "mcp") {
await refreshMcp(result);
@@ -171,6 +174,7 @@ export function DeepLinkImportDialog() {
description: t("deeplink.skillImportSuccessDescription", {
repo: request.repo,
}),
closeButton: true,
});
}
} else if (isMcpImportResult(result)) {
@@ -185,6 +189,7 @@ export function DeepLinkImportDialog() {
description: t("deeplink.importSuccessDescription", {
name: request.name,
}),
closeButton: true,
});
}
@@ -605,6 +610,86 @@ export function DeepLinkImportDialog() {
</div>
)}
{/* Usage Script Configuration (v3.9+) */}
{request.usageScript && (
<div className="space-y-3 pt-2 border-t border-border-default">
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageScript", {
defaultValue: "用量查询",
})}
</div>
<div className="col-span-2 text-sm">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium ${
request.usageEnabled !== false
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{request.usageEnabled !== false
? t("deeplink.usageScriptEnabled", {
defaultValue: "已启用",
})
: t("deeplink.usageScriptDisabled", {
defaultValue: "未启用",
})}
</span>
</div>
</div>
{/* Usage API Key (if different from provider) */}
{request.usageApiKey &&
request.usageApiKey !== request.apiKey && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageApiKey", {
defaultValue: "用量 API Key",
})}
</div>
<div className="col-span-2 text-sm font-mono text-muted-foreground">
{request.usageApiKey.length > 4
? `${request.usageApiKey.substring(0, 4)}${"*".repeat(12)}`
: "****"}
</div>
</div>
)}
{/* Usage Base URL (if different from provider) */}
{request.usageBaseUrl &&
request.usageBaseUrl !== request.endpoint && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageBaseUrl", {
defaultValue: "用量查询地址",
})}
</div>
<div className="col-span-2 text-sm break-all">
{request.usageBaseUrl}
</div>
</div>
)}
{/* Auto Query Interval */}
{request.usageAutoInterval &&
request.usageAutoInterval > 0 && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageAutoInterval", {
defaultValue: "自动查询",
})}
</div>
<div className="col-span-2 text-sm">
{t("deeplink.usageAutoIntervalValue", {
defaultValue: "每 {{minutes}} 分钟",
minutes: request.usageAutoInterval,
})}
</div>
</div>
)}
</div>
)}
{/* Warning */}
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
{t("deeplink.warning")}
+3 -1
View File
@@ -234,7 +234,9 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
try {
const formatted = formatJSON(currentValue);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }), {
closeButton: true,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
+25 -8
View File
@@ -227,6 +227,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
.join(", ");
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
duration: 3000,
closeButton: true,
});
} else {
toast.error(
@@ -259,7 +260,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
printWidth: 80,
});
setScript({ ...script, code: formatted.trim() });
toast.success(t("usageScript.formatSuccess"), { duration: 1000 });
toast.success(t("usageScript.formatSuccess"), {
duration: 1000,
closeButton: true,
});
} catch (error: any) {
toast.error(
`${t("usageScript.formatFailed")}: ${error?.message || t("jsonEditor.invalidJson")}`,
@@ -400,15 +404,25 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
{/* 凭证配置 */}
{shouldShowCredentialsConfig && (
<div className="space-y-4">
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.credentialsConfig")}
</h4>
<div className="flex items-start justify-between">
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.credentialsConfig")}
</h4>
<p className="text-xs text-muted-foreground">
{t("usageScript.credentialsHint")}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
<>
<div className="space-y-2">
<Label htmlFor="usage-api-key">API Key</Label>
<Label htmlFor="usage-api-key">
API Key{" "}
<span className="text-xs text-muted-foreground font-normal">
({t("usageScript.optional")})
</span>
</Label>
<div className="relative">
<Input
id="usage-api-key"
@@ -417,7 +431,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onChange={(e) =>
setScript({ ...script, apiKey: e.target.value })
}
placeholder="sk-xxxxx"
placeholder={t("usageScript.apiKeyPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
@@ -444,7 +458,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
<div className="space-y-2">
<Label htmlFor="usage-base-url">
{t("usageScript.baseUrl")}
{t("usageScript.baseUrl")}{" "}
<span className="text-xs text-muted-foreground font-normal">
({t("usageScript.optional")})
</span>
</Label>
<Input
id="usage-base-url"
@@ -453,7 +470,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onChange={(e) =>
setScript({ ...script, baseUrl: e.target.value })
}
placeholder="https://api.example.com"
placeholder={t("usageScript.baseUrlPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
+1
View File
@@ -79,6 +79,7 @@ export function EnvWarningBanner({
path: backupInfo.backupPath,
}),
duration: 5000,
closeButton: true,
});
// 清空选择并通知父组件
+1 -1
View File
@@ -391,7 +391,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
await upsertMutation.mutateAsync(entry);
toast.success(t("common.success"));
toast.success(t("common.success"), { closeButton: true });
await onSave();
} catch (error: any) {
const detail = extractErrorMessage(error);
+1 -1
View File
@@ -99,7 +99,7 @@ const UnifiedMcpPanel = React.forwardRef<
try {
await deleteServerMutation.mutateAsync(id);
setConfirmDialog(null);
toast.success(t("common.success"));
toast.success(t("common.success"), { closeButton: true });
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
@@ -7,7 +7,6 @@ import {
Play,
TestTube2,
Trash2,
// RotateCcw, // TODO: 暂时注释,等待故障转移功能启用
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -23,9 +22,6 @@ interface ProviderActionsProps {
onTest?: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
onResetCircuitBreaker?: () => void;
isProxyTarget?: boolean;
consecutiveFailures?: number;
}
export function ProviderActions({
@@ -38,9 +34,6 @@ export function ProviderActions({
onTest,
onConfigureUsage,
onDelete,
onResetCircuitBreaker: _onResetCircuitBreaker, // 暂未使用,前缀 _ 避免 lint 警告
isProxyTarget: _isProxyTarget, // 暂未使用,前缀 _ 避免 lint 警告
consecutiveFailures: _consecutiveFailures = 0, // 暂未使用,前缀 _ 避免 lint 警告
}: ProviderActionsProps) {
const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1";
@@ -123,33 +116,6 @@ export function ProviderActions({
<BarChart3 className="h-4 w-4" />
</Button>
{/* 重置熔断器按钮 - 代理目标启用时显示 */}
{/* TODO: 暂时隐藏,后续根据故障转移功能启用 */}
{/* {onResetCircuitBreaker && isProxyTarget && (
<Button
size="icon"
variant="ghost"
onClick={onResetCircuitBreaker}
disabled={consecutiveFailures === 0}
title={
consecutiveFailures > 0
? t("provider.resetCircuitBreaker", {
defaultValue: "重置熔断器",
})
: t("provider.noFailures", {
defaultValue: "当前无失败记录",
})
}
className={cn(
iconButtonClass,
consecutiveFailures > 0 &&
"hover:text-orange-500 dark:hover:text-orange-400",
)}
>
<RotateCcw className="h-4 w-4" />
</Button>
)} */}
<Button
size="icon"
variant="ghost"
+90 -51
View File
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { GripVertical } from "lucide-react";
import { useMemo, useState, useEffect } from "react";
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import type {
DraggableAttributes,
@@ -12,11 +12,8 @@ import { ProviderActions } from "@/components/providers/ProviderActions";
import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import {
useProviderHealth,
useResetCircuitBreaker,
} from "@/lib/query/failover";
import { toast } from "sonner";
import { useProviderHealth } from "@/lib/query/failover";
import { useUsageQuery } from "@/lib/query/queries";
interface DragHandleProps {
attributes: DraggableAttributes;
@@ -97,31 +94,6 @@ export function ProviderCard({
// 获取供应商健康状态
const { data: health } = useProviderHealth(provider.id, appId);
// 重置熔断器
const resetCircuitBreaker = useResetCircuitBreaker();
const handleResetCircuitBreaker = async () => {
try {
await resetCircuitBreaker.mutateAsync({
providerId: provider.id,
appType: appId,
});
toast.success(
t("provider.circuitBreakerReset", {
defaultValue: "熔断器已重置",
}),
);
} catch (error) {
toast.error(
t("provider.circuitBreakerResetFailed", {
defaultValue: "重置失败",
}) +
": " +
String(error),
);
}
};
const fallbackUrlText = t("provider.notConfigured", {
defaultValue: "未配置接口地址",
});
@@ -146,6 +118,29 @@ export function ProviderCard({
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
// 获取用量数据以判断是否有多套餐
const autoQueryInterval = isCurrent
? provider.meta?.usage_script?.autoQueryInterval || 0
: 0;
const { data: usage } = useUsageQuery(provider.id, appId, {
enabled: usageEnabled,
autoQueryInterval,
});
const hasMultiplePlans =
usage?.success && usage.data && usage.data.length > 1;
// 多套餐默认展开
const [isExpanded, setIsExpanded] = useState(false);
// 当检测到多套餐时自动展开
useEffect(() => {
if (hasMultiplePlans) {
setIsExpanded(true);
}
}, [hasMultiplePlans]);
const handleOpenWebsite = () => {
if (!isClickableUrl) {
return;
@@ -209,7 +204,7 @@ export function ProviderCard({
</div>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2 min-h-[20px]">
<div className="flex flex-wrap items-center gap-2 min-h-7">
<h3 className="text-base font-semibold leading-none">
{provider.name}
</h3>
@@ -253,19 +248,56 @@ export function ProviderCard({
</div>
</div>
<div className="relative flex items-center ml-auto">
<div className="ml-auto transition-transform duration-300 ease-out group-hover:-translate-x-[14.5rem] group-focus-within:-translate-x-[14.5rem] sm:group-hover:-translate-x-[16.5rem] sm:group-focus-within:-translate-x-[16.5rem]">
<UsageFooter
provider={provider}
providerId={provider.id}
appId={appId}
usageEnabled={usageEnabled}
isCurrent={isCurrent}
inline={true}
/>
<div className="relative flex items-center ml-auto min-w-0">
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[14.5rem] group-focus-within:-translate-x-[14.5rem] sm:group-hover:-translate-x-[16rem] sm:group-focus-within:-translate-x-[16rem]">
<div className="flex items-center gap-1">
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
{hasMultiplePlans ? (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">
{t("usage.multiplePlans", {
count: usage?.data?.length || 0,
defaultValue: `${usage?.data?.length || 0} 个套餐`,
})}
</span>
</div>
) : (
<UsageFooter
provider={provider}
providerId={provider.id}
appId={appId}
usageEnabled={usageEnabled}
isCurrent={isCurrent}
inline={true}
/>
)}
{/* 展开/折叠按钮 - 仅在有多套餐时显示 */}
{hasMultiplePlans && (
<button
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-500 dark:text-gray-400 flex-shrink-0"
title={
isExpanded
? t("usage.collapse", { defaultValue: "收起" })
: t("usage.expand", { defaultValue: "展开" })
}
>
{isExpanded ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)}
</button>
)}
</div>
</div>
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-300 ease-out translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入 */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
<ProviderActions
isCurrent={isCurrent}
isTesting={isTesting}
@@ -276,17 +308,24 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
onResetCircuitBreaker={
isProxyRunning && provider.isProxyTarget
? handleResetCircuitBreaker
: undefined
}
isProxyTarget={provider.isProxyTarget}
consecutiveFailures={health?.consecutive_failures ?? 0}
/>
</div>
</div>
</div>
{/* 展开的完整套餐列表 */}
{isExpanded && hasMultiplePlans && (
<div className="mt-4 pt-4 border-t border-border-default">
<UsageFooter
provider={provider}
providerId={provider.id}
appId={appId}
usageEnabled={usageEnabled}
isCurrent={isCurrent}
inline={false}
/>
</div>
)}
</div>
);
}
@@ -54,6 +54,7 @@ export function AutoFailoverConfigPanel({
});
toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
{ closeButton: true },
);
} catch (e) {
toast.error(
@@ -100,7 +101,7 @@ export function AutoFailoverConfigPanel({
<AlertDescription className="text-sm">
{t(
"proxy.autoFailover.info",
"当启用多个代理目标时,系统会按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会自动打开,跳过该供应商。",
"当故障转移队列中配置了多个供应商时,系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会打开并在一段时间内跳过该供应商。",
)}
</AlertDescription>
</Alert>
@@ -34,7 +34,7 @@ export function CircuitBreakerConfigPanel() {
const handleSave = async () => {
try {
await updateConfig.mutateAsync(formData);
toast.success("熔断器配置已保存");
toast.success("熔断器配置已保存", { closeButton: true });
} catch (error) {
toast.error("保存失败: " + String(error));
}
@@ -0,0 +1,414 @@
/**
* 故障转移队列管理组件
*
* 允许用户管理代理模式下的故障转移队列,支持:
* - 拖拽排序
* - 添加/移除供应商
* - 启用/禁用队列项
*/
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { CSS } from "@dnd-kit/utilities";
import { DndContext, closestCenter } from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { toast } from "sonner";
import {
GripVertical,
Plus,
Trash2,
Loader2,
Info,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { FailoverQueueItem } from "@/types/proxy";
import type { AppId } from "@/lib/api";
import {
useFailoverQueue,
useAvailableProvidersForFailover,
useAddToFailoverQueue,
useRemoveFromFailoverQueue,
useReorderFailoverQueue,
useSetFailoverItemEnabled,
} from "@/lib/query/failover";
interface FailoverQueueManagerProps {
appType: AppId;
disabled?: boolean;
}
export function FailoverQueueManager({
appType,
disabled = false,
}: FailoverQueueManagerProps) {
const { t } = useTranslation();
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
// 查询数据
const {
data: queue,
isLoading: isQueueLoading,
error: queueError,
} = useFailoverQueue(appType);
const { data: availableProviders, isLoading: isProvidersLoading } =
useAvailableProvidersForFailover(appType);
// Mutations
const addToQueue = useAddToFailoverQueue();
const removeFromQueue = useRemoveFromFailoverQueue();
const reorderQueue = useReorderFailoverQueue();
const setItemEnabled = useSetFailoverItemEnabled();
// 拖拽配置
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// 排序后的队列
const sortedQueue = useMemo(() => {
if (!queue) return [];
return [...queue].sort((a, b) => a.queueOrder - b.queueOrder);
}, [queue]);
// 处理拖拽结束
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !sortedQueue) return;
const oldIndex = sortedQueue.findIndex(
(item) => item.providerId === active.id,
);
const newIndex = sortedQueue.findIndex(
(item) => item.providerId === over.id,
);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(sortedQueue, oldIndex, newIndex);
const providerIds = reordered.map((item) => item.providerId);
try {
await reorderQueue.mutateAsync({ appType, providerIds });
toast.success(
t("proxy.failoverQueue.reorderSuccess", "队列顺序已更新"),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.failoverQueue.reorderFailed", "更新顺序失败") +
": " +
String(error),
);
}
},
[sortedQueue, appType, reorderQueue, t],
);
// 添加供应商到队列
const handleAddProvider = async () => {
if (!selectedProviderId) return;
try {
await addToQueue.mutateAsync({
appType,
providerId: selectedProviderId,
});
setSelectedProviderId("");
toast.success(
t("proxy.failoverQueue.addSuccess", "已添加到故障转移队列"),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.failoverQueue.addFailed", "添加失败") + ": " + String(error),
);
}
};
// 从队列移除供应商
const handleRemoveProvider = async (providerId: string) => {
try {
await removeFromQueue.mutateAsync({ appType, providerId });
toast.success(
t("proxy.failoverQueue.removeSuccess", "已从故障转移队列移除"),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.failoverQueue.removeFailed", "移除失败") +
": " +
String(error),
);
}
};
// 切换启用状态
const handleToggleEnabled = async (providerId: string, enabled: boolean) => {
try {
await setItemEnabled.mutateAsync({ appType, providerId, enabled });
} catch (error) {
toast.error(
t("proxy.failoverQueue.toggleFailed", "状态更新失败") +
": " +
String(error),
);
}
};
if (isQueueLoading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (queueError) {
return (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{String(queueError)}</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-4">
{/* 说明信息 */}
<Alert className="border-blue-500/40 bg-blue-500/10">
<Info className="h-4 w-4" />
<AlertDescription className="text-sm">
{t(
"proxy.failoverQueue.info",
"当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
)}
</AlertDescription>
</Alert>
{/* 添加供应商 */}
<div className="flex items-center gap-2">
<Select
value={selectedProviderId}
onValueChange={setSelectedProviderId}
disabled={disabled || isProvidersLoading}
>
<SelectTrigger className="flex-1">
<SelectValue
placeholder={t(
"proxy.failoverQueue.selectProvider",
"选择供应商添加到队列",
)}
/>
</SelectTrigger>
<SelectContent>
{availableProviders?.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name}
</SelectItem>
))}
{(!availableProviders || availableProviders.length === 0) && (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
{t(
"proxy.failoverQueue.noAvailableProviders",
"没有可添加的供应商",
)}
</div>
)}
</SelectContent>
</Select>
<Button
onClick={handleAddProvider}
disabled={disabled || !selectedProviderId || addToQueue.isPending}
size="icon"
variant="outline"
>
{addToQueue.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</Button>
</div>
{/* 队列列表 */}
{sortedQueue.length === 0 ? (
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
<p className="text-sm text-muted-foreground">
{t(
"proxy.failoverQueue.empty",
"故障转移队列为空。添加供应商以启用自动故障转移。",
)}
</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedQueue.map((item) => item.providerId)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{sortedQueue.map((item, index) => (
<SortableQueueItem
key={item.providerId}
item={item}
index={index}
disabled={disabled}
onToggleEnabled={handleToggleEnabled}
onRemove={handleRemoveProvider}
isRemoving={removeFromQueue.isPending}
isToggling={setItemEnabled.isPending}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
{/* 队列说明 */}
{sortedQueue.length > 0 && (
<p className="text-xs text-muted-foreground">
{t(
"proxy.failoverQueue.dragHint",
"拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
)}
</p>
)}
</div>
);
}
interface SortableQueueItemProps {
item: FailoverQueueItem;
index: number;
disabled: boolean;
onToggleEnabled: (providerId: string, enabled: boolean) => void;
onRemove: (providerId: string) => void;
isRemoving: boolean;
isToggling: boolean;
}
function SortableQueueItem({
item,
index,
disabled,
onToggleEnabled,
onRemove,
isRemoving,
isToggling,
}: SortableQueueItemProps) {
const { t } = useTranslation();
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({ id: item.providerId, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors",
isDragging && "opacity-50 shadow-lg",
!item.enabled && "opacity-60",
)}
>
{/* 拖拽手柄 */}
<button
type="button"
className={cn(
"cursor-grab touch-none text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed opacity-50",
)}
{...attributes}
{...listeners}
disabled={disabled}
aria-label={t("provider.dragHandle", "拖拽排序")}
>
<GripVertical className="h-5 w-5" />
</button>
{/* 序号 */}
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
{index + 1}
</div>
{/* 供应商名称 */}
<div className="flex-1 min-w-0">
<span
className={cn(
"text-sm font-medium truncate block",
!item.enabled && "text-muted-foreground line-through",
)}
>
{item.providerName}
</span>
</div>
{/* 启用开关 */}
<Switch
checked={item.enabled}
onCheckedChange={(checked) => onToggleEnabled(item.providerId, checked)}
disabled={disabled || isToggling}
aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")}
/>
{/* 删除按钮 */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRemove(item.providerId)}
disabled={disabled || isRemoving}
aria-label={t("common.delete", "删除")}
>
{isRemoving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
);
}
+34 -15
View File
@@ -11,7 +11,7 @@ import { Button } from "@/components/ui/button";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { ProxySettingsDialog } from "./ProxySettingsDialog";
import { toast } from "sonner";
import { useProxyTargets } from "@/lib/query/failover";
import { useFailoverQueue } from "@/lib/query/failover";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { useProviderHealth } from "@/lib/query/failover";
import type { ProxyStatus } from "@/types/proxy";
@@ -20,10 +20,11 @@ export function ProxyPanel() {
const { status, isRunning } = useProxyStatus();
const [showSettings, setShowSettings] = useState(false);
// 获取所有三个应用类型的代理目标列表
const { data: claudeTargets = [] } = useProxyTargets("claude");
const { data: codexTargets = [] } = useProxyTargets("codex");
const { data: geminiTargets = [] } = useProxyTargets("gemini");
// 获取所有三个应用类型的故障转移队列(不包含当前供应商)
// 当前供应商始终优先,队列仅用于失败后的备用顺序
const { data: claudeQueue = [] } = useFailoverQueue("claude");
const { data: codexQueue = [] } = useFailoverQueue("codex");
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
const formatUptime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
@@ -69,7 +70,7 @@ export function ProxyPanel() {
navigator.clipboard.writeText(
`http://${status.address}:${status.port}`,
);
toast.success("地址已复制");
toast.success("地址已复制", { closeButton: true });
}}
>
@@ -113,9 +114,9 @@ export function ProxyPanel() {
</div>
{/* 供应商队列 - 按应用类型分组展示 */}
{(claudeTargets.length > 0 ||
codexTargets.length > 0 ||
geminiTargets.length > 0) && (
{(claudeQueue.length > 0 ||
codexQueue.length > 0 ||
geminiQueue.length > 0) && (
<div className="pt-3 border-t border-border space-y-3">
<div className="flex items-center gap-2">
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
@@ -125,31 +126,49 @@ export function ProxyPanel() {
</div>
{/* Claude 队列 */}
{claudeTargets.length > 0 && (
{claudeQueue.length > 0 && (
<ProviderQueueGroup
appType="claude"
appLabel="Claude"
targets={claudeTargets}
targets={claudeQueue
.filter((item) => item.enabled)
.sort((a, b) => a.queueOrder - b.queueOrder)
.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status}
/>
)}
{/* Codex 队列 */}
{codexTargets.length > 0 && (
{codexQueue.length > 0 && (
<ProviderQueueGroup
appType="codex"
appLabel="Codex"
targets={codexTargets}
targets={codexQueue
.filter((item) => item.enabled)
.sort((a, b) => a.queueOrder - b.queueOrder)
.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status}
/>
)}
{/* Gemini 队列 */}
{geminiTargets.length > 0 && (
{geminiQueue.length > 0 && (
<ProviderQueueGroup
appType="gemini"
appLabel="Gemini"
targets={geminiTargets}
targets={geminiQueue
.filter((item) => item.enabled)
.sort((a, b) => a.queueOrder - b.queueOrder)
.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status}
/>
)}
+1 -1
View File
@@ -142,7 +142,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
try {
const available = await checkUpdate();
if (!available) {
toast.success(t("settings.upToDate"));
toast.success(t("settings.upToDate"), { closeButton: true });
}
} catch (error) {
console.error("[AboutSection] Check update failed", error);
+85 -42
View File
@@ -7,7 +7,9 @@ import {
Coins,
Database,
Server,
ChevronDown,
} from "lucide-react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { toast } from "sonner";
import {
Dialog,
@@ -35,6 +37,7 @@ import { ProxyPanel } from "@/components/proxy";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
@@ -135,7 +138,7 @@ export function SettingsPage({
const handleRestartNow = useCallback(async () => {
setShowRestartPrompt(false);
if (import.meta.env.DEV) {
toast.success(t("settings.devModeRestartHint"));
toast.success(t("settings.devModeRestartHint"), { closeButton: true });
closeAfterSave();
return;
}
@@ -278,10 +281,10 @@ export function SettingsPage({
<AccordionItem
value="proxy"
className="rounded-xl glass-card overflow-hidden"
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex flex-1 items-center justify-between pr-4">
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-green-500" />
<div className="text-left">
@@ -293,27 +296,26 @@ export function SettingsPage({
</p>
</div>
</div>
<div
className="flex items-center gap-4"
onClick={(e) => e.stopPropagation()}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
<div className="flex items-center gap-4 pl-4">
<Badge
variant={isRunning ? "default" : "secondary"}
className="gap-1.5 h-6"
>
<Badge
variant={isRunning ? "default" : "secondary"}
className="gap-1.5 h-6"
>
<Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
/>
{isRunning ? "运行中" : "已停止"}
</Badge>
<Switch
checked={isRunning}
onCheckedChange={handleToggleProxy}
disabled={isProxyPending}
<Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
/>
</div>
{isRunning ? "运行中" : "已停止"}
</Badge>
<Switch
checked={isRunning}
onCheckedChange={handleToggleProxy}
disabled={isProxyPending}
/>
</div>
</AccordionTrigger>
</AccordionPrimitive.Header>
<AccordionContent className="px-6 pb-6 pt-0 border-t border-border/50">
<ProxyPanel />
</AccordionContent>
@@ -343,10 +345,10 @@ export function SettingsPage({
<AccordionItem
value="failover"
className="rounded-xl glass-card overflow-hidden"
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex flex-1 items-center justify-between pr-4">
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-orange-500" />
<div className="text-left">
@@ -354,29 +356,70 @@ export function SettingsPage({
</h3>
<p className="text-sm text-muted-foreground font-normal">
</p>
</div>
</div>
<div
className="flex items-center gap-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2">
{/* Removed status text as requested */}
<Switch
checked={failoverEnabled}
onCheckedChange={setFailoverEnabled}
/>
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
<div className="flex items-center gap-2 pl-4">
<Switch
checked={failoverEnabled}
onCheckedChange={setFailoverEnabled}
/>
</div>
</AccordionPrimitive.Header>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<div className="space-y-6">
{/* 故障转移队列管理 */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title", "故障转移队列")}
</h4>
<p className="text-xs text-muted-foreground">
{t(
"proxy.failoverQueue.description",
"管理各应用的供应商故障转移顺序",
)}
</p>
</div>
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList>
<TabsContent value="claude" className="mt-4">
<FailoverQueueManager
appType="claude"
disabled={!failoverEnabled}
/>
</TabsContent>
<TabsContent value="codex" className="mt-4">
<FailoverQueueManager
appType="codex"
disabled={!failoverEnabled}
/>
</TabsContent>
<TabsContent value="gemini" className="mt-4">
<FailoverQueueManager
appType="gemini"
disabled={!failoverEnabled}
/>
</TabsContent>
</Tabs>
</div>
{/* 熔断器配置 */}
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
enabled={failoverEnabled}
onEnabledChange={setFailoverEnabled}
/>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<AutoFailoverConfigPanel
enabled={failoverEnabled}
onEnabledChange={setFailoverEnabled}
/>
</AccordionContent>
</AccordionItem>
+10 -3
View File
@@ -103,7 +103,9 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const handleInstall = async (directory: string) => {
try {
await skillsApi.install(directory, selectedApp);
toast.success(t("skills.installSuccess", { name: directory }));
toast.success(t("skills.installSuccess", { name: directory }), {
closeButton: true,
});
await loadSkills();
} catch (error) {
const errorMessage =
@@ -132,7 +134,9 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const handleUninstall = async (directory: string) => {
try {
await skillsApi.uninstall(directory, selectedApp);
toast.success(t("skills.uninstallSuccess", { name: directory }));
toast.success(t("skills.uninstallSuccess", { name: directory }), {
closeButton: true,
});
await loadSkills();
} catch (error) {
const errorMessage =
@@ -180,12 +184,15 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
name: repo.name,
count: repoSkillCount,
}),
{ closeButton: true },
);
};
const handleRemoveRepo = async (owner: string, name: string) => {
await skillsApi.removeRepo(owner, name);
toast.success(t("skills.repo.removeSuccess", { owner, name }));
toast.success(t("skills.repo.removeSuccess", { owner, name }), {
closeButton: true,
});
await Promise.all([loadRepos(), loadSkills()]);
};
@@ -47,7 +47,9 @@ export function ModelTestConfigPanel() {
try {
setIsSaving(true);
await saveStreamCheckConfig(config);
toast.success(t("streamCheck.configSaved", "健康检查配置已保存"));
toast.success(t("streamCheck.configSaved", "健康检查配置已保存"), {
closeButton: true,
});
} catch (e) {
toast.error(
t("streamCheck.configSaveFailed", "保存失败") + ": " + String(e),
@@ -76,6 +76,7 @@ export function PricingEditModal({
isNew
? t("usage.pricingAdded", "定价已添加")
: t("usage.pricingUpdated", "定价已更新"),
{ closeButton: true },
);
onClose();
+1
View File
@@ -87,6 +87,7 @@ export function useDragSort(providers: Record<string, Provider>, appId: AppId) {
t("provider.sortUpdated", {
defaultValue: "排序已更新",
}),
{ closeButton: true },
);
} catch (error) {
console.error("Failed to update provider sort order", error);
+2
View File
@@ -113,6 +113,7 @@ export function useImportExport(
t("settings.importSuccess", {
defaultValue: "配置导入成功",
}),
{ closeButton: true },
);
successTimerRef.current = window.setTimeout(() => {
@@ -170,6 +171,7 @@ export function useImportExport(
t("settings.configExported", {
defaultValue: "配置已导出",
}) + `\n${displayPath}`,
{ closeButton: true },
);
} else {
toast.error(
+6 -6
View File
@@ -36,7 +36,7 @@ export function usePromptActions(appId: AppId) {
try {
await promptsApi.upsertPrompt(appId, id, prompt);
await reload();
toast.success(t("prompts.saveSuccess"));
toast.success(t("prompts.saveSuccess"), { closeButton: true });
} catch (error) {
toast.error(t("prompts.saveFailed"));
throw error;
@@ -50,7 +50,7 @@ export function usePromptActions(appId: AppId) {
try {
await promptsApi.deletePrompt(appId, id);
await reload();
toast.success(t("prompts.deleteSuccess"));
toast.success(t("prompts.deleteSuccess"), { closeButton: true });
} catch (error) {
toast.error(t("prompts.deleteFailed"));
throw error;
@@ -64,7 +64,7 @@ export function usePromptActions(appId: AppId) {
try {
await promptsApi.enablePrompt(appId, id);
await reload();
toast.success(t("prompts.enableSuccess"));
toast.success(t("prompts.enableSuccess"), { closeButton: true });
} catch (error) {
toast.error(t("prompts.enableFailed"));
throw error;
@@ -104,14 +104,14 @@ export function usePromptActions(appId: AppId) {
try {
if (enabled) {
await promptsApi.enablePrompt(appId, id);
toast.success(t("prompts.enableSuccess"));
toast.success(t("prompts.enableSuccess"), { closeButton: true });
} else {
// 禁用提示词 - 需要后端支持
await promptsApi.upsertPrompt(appId, id, {
...prompts[id],
enabled: false,
});
toast.success(t("prompts.disableSuccess"));
toast.success(t("prompts.disableSuccess"), { closeButton: true });
}
await reload();
} catch (error) {
@@ -130,7 +130,7 @@ export function usePromptActions(appId: AppId) {
try {
const id = await promptsApi.importFromFile(appId);
await reload();
toast.success(t("prompts.importSuccess"));
toast.success(t("prompts.importSuccess"), { closeButton: true });
return id;
} catch (error) {
toast.error(t("prompts.importFailed"));
+1
View File
@@ -119,6 +119,7 @@ export function useProviderActions(activeApp: AppId) {
t("provider.usageSaved", {
defaultValue: "用量查询配置已保存",
}),
{ closeButton: true },
);
} catch (error) {
const detail =
+1 -1
View File
@@ -24,7 +24,7 @@ export function useProxyConfig() {
mutationFn: (newConfig: ProxyConfig) =>
invoke("update_proxy_config", { config: newConfig }),
onSuccess: () => {
toast.success("代理配置已保存");
toast.success("代理配置已保存", { closeButton: true });
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
},
+4
View File
@@ -40,6 +40,7 @@ export function useProxyStatus() {
t("proxy.startedWithTakeover", {
defaultValue: `代理模式已启用 - ${info.address}:${info.port}`,
}),
{ closeButton: true },
);
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] });
@@ -62,9 +63,12 @@ export function useProxyStatus() {
t("proxy.stoppedWithRestore", {
defaultValue: "代理模式已关闭,配置已恢复",
}),
{ closeButton: true },
);
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] });
// 清除所有供应商健康状态缓存(后端已清空数据库记录)
queryClient.invalidateQueries({ queryKey: ["providerHealth"] });
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || "未知错误";
+1
View File
@@ -309,6 +309,7 @@ export function useSettings(): UseSettingsResult {
t("notifications.settingsSaved", {
defaultValue: "设置已保存",
}),
{ closeButton: true },
);
}
+1
View File
@@ -28,6 +28,7 @@ export function useStreamCheck(appId: AppId) {
time: result.responseTimeMs,
defaultValue: `${providerName} 运行正常 (${result.responseTimeMs}ms)`,
}),
{ closeButton: true },
);
} else if (result.status === "degraded") {
toast.warning(
+54 -5
View File
@@ -112,7 +112,7 @@
"providerAdded": "Provider added",
"providerSaved": "Provider configuration saved",
"providerDeleted": "Provider deleted successfully",
"switchSuccess": "Switch successful! Please restart {{appName}} terminal to take effect",
"switchSuccess": "Switch successful!",
"switchFailedTitle": "Switch failed",
"switchFailed": "Switch failed: {{error}}",
"autoImported": "Default provider created from existing configuration",
@@ -365,7 +365,10 @@
"justNow": "Just now",
"minutesAgo": "{{count}} min ago",
"hoursAgo": "{{count}} hr ago",
"daysAgo": "{{count}} day ago"
"daysAgo": "{{count}} day ago",
"multiplePlans": "{{count}} plans",
"expand": "Expand",
"collapse": "Collapse"
},
"usageScript": {
"title": "Configure Usage Query",
@@ -378,6 +381,10 @@
"templateGeneral": "General",
"templateNewAPI": "NewAPI",
"credentialsConfig": "Credentials",
"credentialsHint": "Leave empty to use provider config",
"optional": "optional",
"apiKeyPlaceholder": "Leave empty to use provider's API Key",
"baseUrlPlaceholder": "Leave empty to use provider's base URL",
"baseUrl": "Base URL",
"accessToken": "Access Token",
"accessTokenPlaceholder": "Generate in 'Security Settings'",
@@ -534,9 +541,6 @@
"env": "Environment (one per line, KEY=VALUE)",
"envPlaceholder": "FOO=bar\nHELLO=world",
"reset": "Reset",
"notice": {
"restartClaude": "Written. Restart Claude to take effect."
},
"msg": {
"saved": "Saved",
"deleted": "Deleted",
@@ -840,5 +844,50 @@
},
"agents": {
"title": "Agents"
},
"proxy": {
"failoverQueue": {
"title": "Failover Queue",
"description": "Manage failover order for each app's providers",
"info": "The current active provider always takes priority. When requests fail, the system will try other providers in queue order.",
"selectProvider": "Select a provider to add to queue",
"noAvailableProviders": "No providers available to add",
"empty": "Failover queue is empty. Add providers to enable automatic failover.",
"dragHint": "Drag providers to adjust failover order. Lower numbers have higher priority.",
"toggleEnabled": "Enable/Disable",
"addSuccess": "Added to failover queue",
"addFailed": "Failed to add",
"removeSuccess": "Removed from failover queue",
"removeFailed": "Failed to remove",
"reorderSuccess": "Queue order updated",
"reorderFailed": "Failed to update order",
"toggleFailed": "Failed to update status"
},
"autoFailover": {
"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",
"retrySettings": "Retry & Timeout Settings",
"failureThreshold": "Failure Threshold",
"failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)",
"timeout": "Recovery Wait Time (seconds)",
"timeoutHint": "Wait this long before trying to recover after circuit opens (recommended: 30-120)",
"circuitBreakerSettings": "Circuit Breaker Advanced Settings",
"successThreshold": "Recovery Success Threshold",
"successThresholdHint": "Close circuit breaker after this many successes in half-open state",
"errorRate": "Error Rate Threshold (%)",
"errorRateHint": "Open circuit breaker when error rate exceeds this value",
"minRequests": "Minimum Requests",
"minRequestsHint": "Minimum requests before calculating error rate",
"explanationTitle": "How It Works",
"failureThresholdLabel": "Failure Threshold",
"failureThresholdExplain": "Circuit breaker opens after this many consecutive failures, making the provider temporarily unavailable",
"timeoutLabel": "Recovery Wait Time",
"timeoutExplain": "After circuit opens, wait this long before trying half-open state",
"successThresholdLabel": "Recovery Success Threshold",
"successThresholdExplain": "In half-open state, close circuit breaker after this many successes, making provider available again",
"errorRateLabel": "Error Rate Threshold",
"errorRateExplain": "Open circuit breaker when error rate exceeds this value, even if failure threshold not reached"
}
}
}
+9 -5
View File
@@ -112,7 +112,7 @@
"providerAdded": "プロバイダーを追加しました",
"providerSaved": "プロバイダー設定を保存しました",
"providerDeleted": "プロバイダーを削除しました",
"switchSuccess": "切り替え成功! {{appName}} ターミナルを再起動すると反映されます",
"switchSuccess": "切り替え成功!",
"switchFailedTitle": "切り替えに失敗しました",
"switchFailed": "切り替えに失敗しました: {{error}}",
"autoImported": "既存設定からデフォルトプロバイダーを自動作成しました",
@@ -365,7 +365,10 @@
"justNow": "たった今",
"minutesAgo": "{{count}} 分前",
"hoursAgo": "{{count}} 時間前",
"daysAgo": "{{count}} 日前"
"daysAgo": "{{count}} 日前",
"multiplePlans": "{{count}} プラン",
"expand": "展開",
"collapse": "折りたたむ"
},
"usageScript": {
"title": "利用状況を設定",
@@ -378,6 +381,10 @@
"templateGeneral": "General",
"templateNewAPI": "NewAPI",
"credentialsConfig": "認証情報",
"credentialsHint": "空欄の場合はプロバイダー設定を使用",
"optional": "オプション",
"apiKeyPlaceholder": "空欄の場合はプロバイダーの API Key を使用",
"baseUrlPlaceholder": "空欄の場合はプロバイダーの Base URL を使用",
"baseUrl": "Base URL",
"accessToken": "Access Token",
"accessTokenPlaceholder": "「Security Settings」で生成",
@@ -534,9 +541,6 @@
"env": "環境変数(1 行に 1 件、KEY=VALUE",
"envPlaceholder": "FOO=bar\nHELLO=world",
"reset": "リセット",
"notice": {
"restartClaude": "書き込みました。Claude を再起動すると反映されます。"
},
"msg": {
"saved": "保存しました",
"deleted": "削除しました",
+54 -5
View File
@@ -112,7 +112,7 @@
"providerAdded": "供应商已添加",
"providerSaved": "供应商配置已保存",
"providerDeleted": "供应商删除成功",
"switchSuccess": "切换成功!请重启 {{appName}} 终端以生效",
"switchSuccess": "切换成功!",
"switchFailedTitle": "切换失败",
"switchFailed": "切换失败:{{error}}",
"autoImported": "已从现有配置创建默认供应商",
@@ -365,7 +365,10 @@
"justNow": "刚刚",
"minutesAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前"
"daysAgo": "{{count}} 天前",
"multiplePlans": "{{count}} 个套餐",
"expand": "展开",
"collapse": "收起"
},
"usageScript": {
"title": "配置用量查询",
@@ -378,6 +381,10 @@
"templateGeneral": "通用模板",
"templateNewAPI": "NewAPI",
"credentialsConfig": "凭证配置",
"credentialsHint": "留空则自动使用供应商配置",
"optional": "可选",
"apiKeyPlaceholder": "留空则使用供应商的 API Key",
"baseUrlPlaceholder": "留空则使用供应商的请求地址",
"baseUrl": "请求地址",
"accessToken": "访问令牌(在个人安全设置里获取)",
"accessTokenPlaceholder": "在'安全设置'里生成",
@@ -534,9 +541,6 @@
"env": "环境变量 (一行一个,KEY=VALUE)",
"envPlaceholder": "FOO=bar\nHELLO=world",
"reset": "重置",
"notice": {
"restartClaude": "已写入配置,重启 Claude 生效"
},
"msg": {
"saved": "已保存",
"deleted": "已删除",
@@ -840,5 +844,50 @@
},
"agents": {
"title": "智能体"
},
"proxy": {
"failoverQueue": {
"title": "故障转移队列",
"description": "管理各应用的供应商故障转移顺序",
"info": "当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
"selectProvider": "选择供应商添加到队列",
"noAvailableProviders": "没有可添加的供应商",
"empty": "故障转移队列为空。添加供应商以启用自动故障转移。",
"dragHint": "拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
"toggleEnabled": "启用/禁用",
"addSuccess": "已添加到故障转移队列",
"addFailed": "添加失败",
"removeSuccess": "已从故障转移队列移除",
"removeFailed": "移除失败",
"reorderSuccess": "队列顺序已更新",
"reorderFailed": "更新顺序失败",
"toggleFailed": "状态更新失败"
},
"autoFailover": {
"info": "当故障转移队列中配置了多个供应商时,系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会打开并在一段时间内跳过该供应商。",
"configSaved": "自动故障转移配置已保存",
"configSaveFailed": "保存失败",
"retrySettings": "重试与超时设置",
"failureThreshold": "失败阈值",
"failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)",
"timeout": "恢复等待时间(秒)",
"timeoutHint": "熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
"circuitBreakerSettings": "熔断器高级设置",
"successThreshold": "恢复成功阈值",
"successThresholdHint": "半开状态下成功多少次后关闭熔断器",
"errorRate": "错误率阈值 (%)",
"errorRateHint": "错误率超过此值时打开熔断器",
"minRequests": "最小请求数",
"minRequestsHint": "计算错误率前的最小请求数",
"explanationTitle": "工作原理",
"failureThresholdLabel": "失败阈值",
"failureThresholdExplain": "连续失败达到此次数时,熔断器打开,该供应商暂时不可用",
"timeoutLabel": "恢复等待时间",
"timeoutExplain": "熔断器打开后,等待此时间后尝试半开状态",
"successThresholdLabel": "恢复成功阈值",
"successThresholdExplain": "半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用",
"errorRateLabel": "错误率阈值",
"errorRateExplain": "错误率超过此值时,即使未达到失败阈值也会打开熔断器"
}
}
}
+9
View File
@@ -38,6 +38,15 @@ export interface DeepLinkImportRequest {
config?: string;
configFormat?: string;
configUrl?: string;
// Usage script fields (v3.9+)
usageEnabled?: boolean;
usageScript?: string;
usageApiKey?: string;
usageBaseUrl?: string;
usageAccessToken?: string;
usageUserId?: string;
usageAutoInterval?: number;
}
export interface McpImportResult {
+48 -14
View File
@@ -3,6 +3,7 @@ import type {
ProviderHealth,
CircuitBreakerConfig,
CircuitBreakerStats,
FailoverQueueItem,
} from "@/types/proxy";
export interface Provider {
@@ -17,23 +18,10 @@ export interface Provider {
meta?: unknown;
icon?: string;
iconColor?: string;
isProxyTarget?: boolean;
}
export const failoverApi = {
// 获取代理目标列表
async getProxyTargets(appType: string): Promise<Provider[]> {
return invoke("get_proxy_targets", { appType });
},
// 设置代理目标
async setProxyTarget(
providerId: string,
appType: string,
enabled: boolean,
): Promise<void> {
return invoke("set_proxy_target", { providerId, appType, enabled });
},
// ========== 熔断器 API ==========
// 获取供应商健康状态
async getProviderHealth(
@@ -70,4 +58,50 @@ export const failoverApi = {
): Promise<CircuitBreakerStats | null> {
return invoke("get_circuit_breaker_stats", { providerId, appType });
},
// ========== 故障转移队列 API(新) ==========
// 获取故障转移队列
async getFailoverQueue(appType: string): Promise<FailoverQueueItem[]> {
return invoke("get_failover_queue", { appType });
},
// 获取可添加到队列的供应商(不在队列中的)
async getAvailableProvidersForFailover(appType: string): Promise<Provider[]> {
return invoke("get_available_providers_for_failover", { appType });
},
// 添加供应商到故障转移队列
async addToFailoverQueue(appType: string, providerId: string): Promise<void> {
return invoke("add_to_failover_queue", { appType, providerId });
},
// 从故障转移队列移除供应商
async removeFromFailoverQueue(
appType: string,
providerId: string,
): Promise<void> {
return invoke("remove_from_failover_queue", { appType, providerId });
},
// 重新排序故障转移队列
async reorderFailoverQueue(
appType: string,
providerIds: string[],
): Promise<void> {
return invoke("reorder_failover_queue", { appType, providerIds });
},
// 设置故障转移队列项的启用状态
async setFailoverItemEnabled(
appType: string,
providerId: string,
enabled: boolean,
): Promise<void> {
return invoke("set_failover_item_enabled", {
appType,
providerId,
enabled,
});
},
};
-4
View File
@@ -38,10 +38,6 @@ export const providersApi = {
return await invoke("switch_provider", { id, app: appId });
},
async setProxyTarget(id: string, appId: AppId): Promise<boolean> {
return await invoke("set_proxy_target_provider", { id, app: appId });
},
async importDefault(appId: AppId): Promise<boolean> {
return await invoke("import_default_config", { app: appId });
},
+121 -41
View File
@@ -1,16 +1,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { failoverApi } from "@/lib/api/failover";
/**
*
*/
export function useProxyTargets(appType: string) {
return useQuery({
queryKey: ["proxyTargets", appType],
queryFn: () => failoverApi.getProxyTargets(appType),
enabled: !!appType,
});
}
// ========== 熔断器 Hooks ==========
/**
*
@@ -25,37 +16,6 @@ export function useProviderHealth(providerId: string, appType: string) {
});
}
/**
*
*/
export function useSetProxyTarget() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
providerId,
appType,
enabled,
}: {
providerId: string;
appType: string;
enabled: boolean;
}) => failoverApi.setProxyTarget(providerId, appType, enabled),
onSuccess: (_, variables) => {
// 刷新代理目标列表
queryClient.invalidateQueries({
queryKey: ["proxyTargets", variables.appType],
});
// 刷新供应商列表
queryClient.invalidateQueries({ queryKey: ["providers"] });
// 刷新健康状态
queryClient.invalidateQueries({
queryKey: ["providerHealth", variables.providerId, variables.appType],
});
},
});
}
/**
*
*/
@@ -114,3 +74,123 @@ export function useCircuitBreakerStats(providerId: string, appType: string) {
refetchInterval: 5000, // 每 5 秒刷新一次
});
}
// ========== 故障转移队列 Hooks(新) ==========
/**
*
*/
export function useFailoverQueue(appType: string) {
return useQuery({
queryKey: ["failoverQueue", appType],
queryFn: () => failoverApi.getFailoverQueue(appType),
enabled: !!appType,
});
}
/**
*
*/
export function useAvailableProvidersForFailover(appType: string) {
return useQuery({
queryKey: ["availableProvidersForFailover", appType],
queryFn: () => failoverApi.getAvailableProvidersForFailover(appType),
enabled: !!appType,
});
}
/**
*
*/
export function useAddToFailoverQueue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
appType,
providerId,
}: {
appType: string;
providerId: string;
}) => failoverApi.addToFailoverQueue(appType, providerId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["failoverQueue", variables.appType],
});
queryClient.invalidateQueries({
queryKey: ["availableProvidersForFailover", variables.appType],
});
},
});
}
/**
*
*/
export function useRemoveFromFailoverQueue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
appType,
providerId,
}: {
appType: string;
providerId: string;
}) => failoverApi.removeFromFailoverQueue(appType, providerId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["failoverQueue", variables.appType],
});
queryClient.invalidateQueries({
queryKey: ["availableProvidersForFailover", variables.appType],
});
},
});
}
/**
*
*/
export function useReorderFailoverQueue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
appType,
providerIds,
}: {
appType: string;
providerIds: string[];
}) => failoverApi.reorderFailoverQueue(appType, providerIds),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["failoverQueue", variables.appType],
});
},
});
}
/**
*
*/
export function useSetFailoverItemEnabled() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
appType,
providerId,
enabled,
}: {
appType: string;
providerId: string;
enabled: boolean;
}) => failoverApi.setFailoverItemEnabled(appType, providerId, enabled),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["failoverQueue", variables.appType],
});
},
});
}
-28
View File
@@ -180,34 +180,6 @@ export const useSwitchProviderMutation = (appId: AppId) => {
});
};
export const useSetProxyTargetMutation = (appId: AppId) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (providerId: string) => {
return await providersApi.setProxyTarget(providerId, appId);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["providers", appId] });
toast.success(
t("notifications.proxyTargetSet", {
defaultValue: "已设置代理目标",
}),
);
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || t("common.unknown");
toast.error(
t("notifications.setProxyTargetFailed", {
defaultValue: "设置代理目标失败: {{error}}",
error: detail,
}),
);
},
});
};
export const useSaveSettingsMutation = () => {
const queryClient = useQueryClient();
-2
View File
@@ -23,8 +23,6 @@ export interface Provider {
// 图标配置
icon?: string; // 图标名称(如 "openai", "anthropic"
iconColor?: string; // 图标颜色(Hex 格式,如 "#00A67E"
// 新增:是否为代理目标
isProxyTarget?: boolean;
}
export interface AppConfig {
+9
View File
@@ -93,3 +93,12 @@ export interface ProxyUsageRecord {
error: string | null;
timestamp: string;
}
// 故障转移队列条目
export interface FailoverQueueItem {
providerId: string;
providerName: string;
queueOrder: number;
enabled: boolean;
createdAt: number;
}
+3 -4
View File
@@ -1,6 +1,6 @@
/**
* JSON
* @param value - JSON
* @param value - JSON
* @returns JSON 2
* @throws JSON
*/
@@ -9,9 +9,8 @@ export function formatJSON(value: string): string {
if (!trimmed) {
return "";
}
// 使用智能解析器来处理可能的片段格式
const result = parseSmartMcpJson(trimmed);
return result.formattedConfig;
const parsed = JSON.parse(trimmed);
return JSON.stringify(parsed, null, 2);
}
/**