mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 12:22:09 +08:00
a5903d8600
The provider panel health check sent a real streaming model request, which many third-party providers block (401/403/WAF), causing false negatives while only stable official endpoints passed. Replace it with a lightweight reachability probe: GET the provider base_url and treat any HTTP response (200/4xx/5xx) as reachable; only DNS/connect/TLS/timeout count as failure. Latency is the probe's TTFB. Backend (services/stream_check.rs): rewrite ~2200 -> ~350 lines, dropping real-request building, format conversion, auth and API-path resolution while keeping per-app base_url extraction. Defaults: 8s timeout, 1 retry, 1500ms degraded threshold. Failover invariant: the reachability check must never reset the circuit breaker (reachable != usable; a 403 host is reachable but broken for real traffic). Remove the resetCircuitBreaker call from useStreamCheck; failover failure detection stays driven solely by real proxy traffic (forwarder/circuit_breaker untouched). useResetCircuitBreaker is kept dormant for a future manual-recovery entry. Open the check to all providers: drop the official/copilot/codex-oauth/third-party gating and the 'sends a real request' confirm dialog. For official providers whose base_url is intentionally empty, fall back to the endpoint the client actually uses (Claude -> api.anthropic.com, Codex -> chatgpt.com/backend-api/codex, Gemini -> generativelanguage). Non-official providers with a missing base_url still error to avoid a false green light. Claude Desktop Official is native 1P mode (talks to claude.ai, cc-switch not in the request path, no reliable endpoint) so its button stays hidden. Slim StreamCheckConfig and per-provider testConfig to timeout/threshold/retries (drop test model + prompt); sync zh/en/ja/zh-TW. Retain the now-unused anthropic_to_openai/anthropic_to_gemini transform utilities and their test suites.
372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
import {
|
||
Activity,
|
||
BarChart3,
|
||
Check,
|
||
Copy,
|
||
Edit,
|
||
Loader2,
|
||
Minus,
|
||
Play,
|
||
Plus,
|
||
Terminal,
|
||
Trash2,
|
||
Zap,
|
||
} from "lucide-react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { Button } from "@/components/ui/button";
|
||
import { cn } from "@/lib/utils";
|
||
import type { AppId } from "@/lib/api";
|
||
|
||
interface ProviderActionsProps {
|
||
appId?: AppId;
|
||
isCurrent: boolean;
|
||
isInConfig?: boolean;
|
||
isTesting?: boolean;
|
||
isProxyTakeover?: boolean;
|
||
isOmo?: boolean;
|
||
onSwitch: () => void;
|
||
onEdit: () => void;
|
||
onDuplicate: () => void;
|
||
onTest?: () => void;
|
||
onConfigureUsage?: () => void;
|
||
onDelete: () => void;
|
||
onRemoveFromConfig?: () => void;
|
||
onDisableOmo?: () => void;
|
||
onOpenTerminal?: () => void;
|
||
isAutoFailoverEnabled?: boolean;
|
||
isInFailoverQueue?: boolean;
|
||
onToggleFailover?: (enabled: boolean) => void;
|
||
isOfficialBlockedByProxy?: boolean;
|
||
// Hermes v12+ providers: dict overlay — edit/delete must go through Web UI
|
||
isReadOnly?: boolean;
|
||
// OpenClaw: default model
|
||
isDefaultModel?: boolean;
|
||
onSetAsDefault?: () => void;
|
||
}
|
||
|
||
// 主按钮的呈现状态。title 用于 disabled 态向用户解释为何不可点击;
|
||
// 因 Button 基类带 disabled:pointer-events-none,title 必须挂在外层非禁用
|
||
// 的 wrapper 上才会在 hover 时显示(见下方 <span> 包裹)。
|
||
interface MainButtonState {
|
||
disabled: boolean;
|
||
variant: "default" | "secondary";
|
||
className: string;
|
||
icon: JSX.Element;
|
||
text: string;
|
||
title?: string;
|
||
}
|
||
|
||
export function ProviderActions({
|
||
appId,
|
||
isCurrent,
|
||
isInConfig = false,
|
||
isTesting,
|
||
isProxyTakeover = false,
|
||
isOmo = false,
|
||
onSwitch,
|
||
onEdit,
|
||
onDuplicate,
|
||
onTest,
|
||
onConfigureUsage,
|
||
onDelete,
|
||
onRemoveFromConfig,
|
||
onDisableOmo,
|
||
onOpenTerminal,
|
||
isAutoFailoverEnabled = false,
|
||
isInFailoverQueue = false,
|
||
onToggleFailover,
|
||
isOfficialBlockedByProxy = false,
|
||
isReadOnly = false,
|
||
// OpenClaw: default model
|
||
isDefaultModel = false,
|
||
onSetAsDefault,
|
||
}: ProviderActionsProps) {
|
||
const { t } = useTranslation();
|
||
const iconButtonClass = "h-8 w-8 p-1";
|
||
|
||
// 累加模式应用(OpenCode 非 OMO / OpenClaw / Hermes)
|
||
const isAdditiveMode =
|
||
(appId === "opencode" && !isOmo) ||
|
||
appId === "openclaw" ||
|
||
appId === "hermes";
|
||
|
||
// 故障转移模式下的按钮逻辑(累加模式和 OMO 应用不支持故障转移)
|
||
const isFailoverMode =
|
||
!isAdditiveMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
|
||
|
||
const handleMainButtonClick = () => {
|
||
if (isOmo) {
|
||
if (isCurrent) {
|
||
onDisableOmo?.();
|
||
} else {
|
||
onSwitch();
|
||
}
|
||
} else if (isAdditiveMode) {
|
||
// 累加模式:切换配置状态(添加/移除)
|
||
if (isInConfig) {
|
||
if (onRemoveFromConfig) {
|
||
onRemoveFromConfig();
|
||
} else {
|
||
onDelete();
|
||
}
|
||
} else {
|
||
onSwitch(); // 添加到配置
|
||
}
|
||
} else if (isFailoverMode) {
|
||
onToggleFailover(!isInFailoverQueue);
|
||
} else {
|
||
onSwitch();
|
||
}
|
||
};
|
||
|
||
const getMainButtonState = (): MainButtonState => {
|
||
if (isOmo) {
|
||
if (isCurrent) {
|
||
return {
|
||
disabled: false,
|
||
variant: "secondary" as const,
|
||
className:
|
||
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||
icon: <Check className="h-4 w-4" />,
|
||
text: t("provider.inUse"),
|
||
};
|
||
}
|
||
return {
|
||
disabled: false,
|
||
variant: "default" as const,
|
||
className: "",
|
||
icon: <Play className="h-4 w-4" />,
|
||
text: t("provider.enable"),
|
||
};
|
||
}
|
||
|
||
// 累加模式(OpenCode 非 OMO / OpenClaw)
|
||
if (isAdditiveMode) {
|
||
if (isInConfig) {
|
||
return {
|
||
disabled: isDefaultModel === true,
|
||
variant: "secondary" as const,
|
||
className: cn(
|
||
"bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70",
|
||
isDefaultModel && "opacity-40 cursor-not-allowed",
|
||
),
|
||
icon: <Minus className="h-4 w-4" />,
|
||
text: t("provider.removeFromConfig", { defaultValue: "移除" }),
|
||
};
|
||
}
|
||
return {
|
||
disabled: false,
|
||
variant: "default" as const,
|
||
className:
|
||
"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
|
||
icon: <Plus className="h-4 w-4" />,
|
||
text: t("provider.addToConfig", { defaultValue: "添加" }),
|
||
};
|
||
}
|
||
|
||
if (isFailoverMode) {
|
||
if (isInFailoverQueue) {
|
||
return {
|
||
disabled: false,
|
||
variant: "secondary" as const,
|
||
className:
|
||
"bg-blue-100 text-blue-600 hover:bg-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:hover:bg-blue-900/70",
|
||
icon: <Check className="h-4 w-4" />,
|
||
text: t("failover.inQueue", { defaultValue: "已加入" }),
|
||
};
|
||
}
|
||
return {
|
||
disabled: false,
|
||
variant: "default" as const,
|
||
className:
|
||
"bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||
icon: <Plus className="h-4 w-4" />,
|
||
text: t("failover.addQueue", { defaultValue: "加入" }),
|
||
};
|
||
}
|
||
|
||
if (isCurrent) {
|
||
return {
|
||
disabled: true,
|
||
variant: "secondary" as const,
|
||
className:
|
||
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||
icon: <Check className="h-4 w-4" />,
|
||
text: t("provider.inUse"),
|
||
};
|
||
}
|
||
|
||
if (isOfficialBlockedByProxy) {
|
||
return {
|
||
disabled: true,
|
||
variant: "default" as const,
|
||
className: "",
|
||
icon: <Play className="h-4 w-4" />,
|
||
text: t("provider.enable"),
|
||
title: t("provider.blockedByProxyHint"),
|
||
};
|
||
}
|
||
|
||
return {
|
||
disabled: false,
|
||
variant: "default" as const,
|
||
className: isProxyTakeover
|
||
? "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
|
||
: "",
|
||
icon: <Play className="h-4 w-4" />,
|
||
text: t("provider.enable"),
|
||
};
|
||
};
|
||
|
||
const buttonState = getMainButtonState();
|
||
|
||
const canDelete =
|
||
!isReadOnly && (isOmo || isAdditiveMode ? true : !isCurrent);
|
||
const readOnlyHint = t("provider.managedByHermesHint", {
|
||
defaultValue: "由 Hermes 管理,请在 Hermes Web UI 中编辑",
|
||
});
|
||
|
||
return (
|
||
<div className="flex items-center gap-1.5">
|
||
{(appId === "openclaw" || appId === "hermes") &&
|
||
isInConfig &&
|
||
onSetAsDefault &&
|
||
(() => {
|
||
const activeLabel =
|
||
appId === "hermes"
|
||
? t("provider.inUse", { defaultValue: "已在用" })
|
||
: t("provider.isDefault", { defaultValue: "当前默认" });
|
||
const inactiveLabel =
|
||
appId === "hermes"
|
||
? t("provider.enable", { defaultValue: "启用" })
|
||
: t("provider.setAsDefault", { defaultValue: "设为默认" });
|
||
return (
|
||
<Button
|
||
size="sm"
|
||
variant={isDefaultModel ? "secondary" : "default"}
|
||
onClick={isDefaultModel ? undefined : onSetAsDefault}
|
||
disabled={isDefaultModel}
|
||
className={cn(
|
||
"w-fit px-2.5",
|
||
isDefaultModel
|
||
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
|
||
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||
)}
|
||
>
|
||
<Zap className="h-4 w-4" />
|
||
{isDefaultModel ? activeLabel : inactiveLabel}
|
||
</Button>
|
||
);
|
||
})()}
|
||
|
||
{/* wrapper span 承接 hover:disabled 按钮自身 pointer-events:none,
|
||
原生 title 与 cursor 都必须挂在未禁用的外层元素上才会生效 */}
|
||
<span
|
||
title={buttonState.title}
|
||
className={cn(
|
||
"inline-flex",
|
||
buttonState.disabled && "cursor-not-allowed",
|
||
)}
|
||
>
|
||
<Button
|
||
size="sm"
|
||
variant={buttonState.variant}
|
||
onClick={handleMainButtonClick}
|
||
disabled={buttonState.disabled}
|
||
className={cn("w-[4.5rem] px-2.5", buttonState.className)}
|
||
>
|
||
{buttonState.icon}
|
||
{buttonState.text}
|
||
</Button>
|
||
</span>
|
||
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={isReadOnly ? undefined : onEdit}
|
||
disabled={isReadOnly}
|
||
title={isReadOnly ? readOnlyHint : t("common.edit")}
|
||
className={cn(
|
||
iconButtonClass,
|
||
isReadOnly && "opacity-40 cursor-not-allowed text-muted-foreground",
|
||
)}
|
||
>
|
||
<Edit className="h-4 w-4" />
|
||
</Button>
|
||
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={onDuplicate}
|
||
title={t("provider.duplicate")}
|
||
className={iconButtonClass}
|
||
>
|
||
<Copy className="h-4 w-4" />
|
||
</Button>
|
||
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={onTest || undefined}
|
||
disabled={isTesting}
|
||
title={t("provider.connectivityCheck", "检测连通")}
|
||
className={cn(
|
||
iconButtonClass,
|
||
!onTest && "opacity-40 cursor-not-allowed text-muted-foreground",
|
||
)}
|
||
>
|
||
{isTesting ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Activity className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={onConfigureUsage || undefined}
|
||
title={t("provider.configureUsage")}
|
||
className={cn(
|
||
iconButtonClass,
|
||
!onConfigureUsage &&
|
||
"opacity-40 cursor-not-allowed text-muted-foreground",
|
||
)}
|
||
>
|
||
<BarChart3 className="h-4 w-4" />
|
||
</Button>
|
||
|
||
{onOpenTerminal && (
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={onOpenTerminal}
|
||
title={t("provider.openTerminal", "打开终端")}
|
||
className={cn(
|
||
iconButtonClass,
|
||
"hover:text-emerald-600 dark:hover:text-emerald-400",
|
||
)}
|
||
>
|
||
<Terminal className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={canDelete ? onDelete : undefined}
|
||
title={isReadOnly ? readOnlyHint : t("common.delete")}
|
||
className={cn(
|
||
iconButtonClass,
|
||
canDelete && "hover:text-red-500 dark:hover:text-red-400",
|
||
!canDelete && "opacity-40 cursor-not-allowed text-muted-foreground",
|
||
)}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|