import { useMemo, useState, useEffect } from "react"; import { GripVertical, ChevronDown, ChevronUp } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { DraggableAttributes, DraggableSyntheticListeners, } from "@dnd-kit/core"; import type { Provider } from "@/types"; import type { AppId } from "@/lib/api"; import { cn } from "@/lib/utils"; import { ProviderActions } from "@/components/providers/ProviderActions"; import { ProviderIcon } from "@/components/ProviderIcon"; import UsageFooter from "@/components/UsageFooter"; import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter"; import CopilotQuotaFooter from "@/components/CopilotQuotaFooter"; import { PROVIDER_TYPES } from "@/config/constants"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; import { useProviderHealth } from "@/lib/query/failover"; import { useUsageQuery } from "@/lib/query/queries"; interface DragHandleProps { attributes: DraggableAttributes; listeners: DraggableSyntheticListeners; isDragging: boolean; } interface ProviderCardProps { provider: Provider; isCurrent: boolean; appId: AppId; isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json isOmo?: boolean; isOmoSlim?: boolean; onSwitch: (provider: Provider) => void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; onRemoveFromConfig?: (provider: Provider) => void; onDisableOmo?: () => void; onDisableOmoSlim?: () => void; onConfigureUsage: (provider: Provider) => void; onOpenWebsite: (url: string) => void; onDuplicate: (provider: Provider) => void; onTest?: (provider: Provider) => void; onOpenTerminal?: (provider: Provider) => void; isTesting?: boolean; isProxyRunning: boolean; isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换) dragHandleProps?: DragHandleProps; isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移 failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...) isInFailoverQueue?: boolean; // 是否在故障转移队列中 onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列 activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框) // OpenClaw: default model isDefaultModel?: boolean; onSetAsDefault?: () => void; } /** 判断是否为官方供应商(无自定义 base URL / API key,直连官方 API) */ function isOfficialProvider(provider: Provider, appId: AppId): boolean { const config = provider.settingsConfig as Record; if (appId === "claude") { const baseUrl = config?.env?.ANTHROPIC_BASE_URL; return !baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""); } if (appId === "codex") { // 无 OPENAI_API_KEY → 使用 Codex CLI 内置 OAuth(官方) const apiKey = config?.auth?.OPENAI_API_KEY; return !apiKey || (typeof apiKey === "string" && apiKey.trim() === ""); } if (appId === "gemini") { // 无 GEMINI_API_KEY 且无 GOOGLE_GEMINI_BASE_URL → Google OAuth 官方模式 const apiKey = config?.env?.GEMINI_API_KEY; const baseUrl = config?.env?.GOOGLE_GEMINI_BASE_URL; return ( (!apiKey || (typeof apiKey === "string" && apiKey.trim() === "")) && (!baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === "")) ); } return false; } const extractApiUrl = (provider: Provider, fallbackText: string) => { if (provider.notes?.trim()) { return provider.notes.trim(); } if (provider.websiteUrl) { return provider.websiteUrl; } const config = provider.settingsConfig; if (config && typeof config === "object") { const envBase = (config as Record)?.env?.ANTHROPIC_BASE_URL || (config as Record)?.env?.GOOGLE_GEMINI_BASE_URL; if (typeof envBase === "string" && envBase.trim()) { return envBase; } const baseUrl = (config as Record)?.config; if (typeof baseUrl === "string" && baseUrl.includes("base_url")) { const extractedBaseUrl = extractCodexBaseUrl(baseUrl); if (extractedBaseUrl) { return extractedBaseUrl; } } } return fallbackText; }; export function ProviderCard({ provider, isCurrent, appId, isInConfig = true, isOmo = false, isOmoSlim = false, onSwitch, onEdit, onDelete, onRemoveFromConfig, onDisableOmo, onDisableOmoSlim, onConfigureUsage, onOpenWebsite, onDuplicate, onTest, onOpenTerminal, isTesting, isProxyRunning, isProxyTakeover = false, dragHandleProps, isAutoFailoverEnabled = false, failoverPriority, isInFailoverQueue = false, onToggleFailover, activeProviderId, // OpenClaw: default model isDefaultModel, onSetAsDefault, }: ProviderCardProps) { const { t } = useTranslation(); // OMO and OMO Slim share the same card behavior const isAnyOmo = isOmo || isOmoSlim; const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo; const isAdditiveMode = appId === "opencode" && !isAnyOmo; const { data: health } = useProviderHealth(provider.id, appId); const fallbackUrlText = t("provider.notConfigured", { defaultValue: "未配置接口地址", }); const displayUrl = useMemo(() => { return extractApiUrl(provider, fallbackUrlText); }, [provider, fallbackUrlText]); const isClickableUrl = useMemo(() => { if (provider.notes?.trim()) { return false; } if (displayUrl === fallbackUrlText) { return false; } return true; }, [provider.notes, displayUrl, fallbackUrlText]); const usageEnabled = provider.meta?.usage_script?.enabled ?? false; const isOfficial = isOfficialProvider(provider, appId); const isCopilot = provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT || provider.meta?.usage_script?.templateType === "github_copilot"; // 获取用量数据以判断是否有多套餐 // 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent const shouldAutoQuery = appId === "opencode" || appId === "openclaw" ? isInConfig : isCurrent; const autoQueryInterval = shouldAutoQuery ? provider.meta?.usage_script?.autoQueryInterval || 0 : 0; const { data: usage } = useUsageQuery(provider.id, appId, { enabled: usageEnabled, autoQueryInterval, }); const isTokenPlan = provider.meta?.usage_script?.templateType === "token_plan"; const hasMultiplePlans = usage?.success && usage.data && usage.data.length > 1 && !isTokenPlan; const [isExpanded, setIsExpanded] = useState(false); useEffect(() => { if (hasMultiplePlans) { setIsExpanded(true); } }, [hasMultiplePlans]); const handleOpenWebsite = () => { if (!isClickableUrl) { return; } onOpenWebsite(displayUrl); }; // 判断是否是"当前使用中"的供应商 // - OMO/OMO Slim 供应商:使用 isCurrent // - OpenClaw:使用默认模型归属的 provider 作为当前项(蓝色边框) // - OpenCode(非 OMO):不存在"当前"概念,返回 false // - 故障转移模式:代理实际使用的供应商(activeProviderId) // - 普通模式:isCurrent const isActiveProvider = isAnyOmo ? isCurrent : appId === "openclaw" ? Boolean(isDefaultModel) : appId === "opencode" ? false : isAutoFailoverEnabled ? activeProviderId === provider.id : isCurrent; const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider; const hasPersistentConfigHighlight = isAdditiveMode && isInConfig; const shouldUseBlue = (isAnyOmo && isActiveProvider) || (!isAnyOmo && !isProxyTakeover && (isActiveProvider || hasPersistentConfigHighlight)); return (

{provider.name}

{isOmo && ( OMO )} {isOmoSlim && ( Slim )} {isProxyRunning && isInFailoverQueue && health && ( )} {isAutoFailoverEnabled && isInFailoverQueue && failoverPriority && ( )} {provider.category === "third_party" && provider.meta?.isPartner && ( )}
{displayUrl && ( )}
{isCopilot ? ( ) : isOfficial ? ( ) : hasMultiplePlans ? (
{t("usage.multiplePlans", { count: usage?.data?.length || 0, defaultValue: `${usage?.data?.length || 0} 个套餐`, })}
) : ( )} {hasMultiplePlans && ( )}
onSwitch(provider)} onEdit={() => onEdit(provider)} onDuplicate={() => onDuplicate(provider)} onTest={ onTest && !isOfficial ? () => onTest(provider) : undefined } onConfigureUsage={ isOfficial ? undefined : () => onConfigureUsage(provider) } onDelete={() => onDelete(provider)} onRemoveFromConfig={ onRemoveFromConfig ? () => onRemoveFromConfig(provider) : undefined } onDisableOmo={handleDisableAnyOmo} onOpenTerminal={ onOpenTerminal ? () => onOpenTerminal(provider) : undefined } isAutoFailoverEnabled={isAutoFailoverEnabled} isInFailoverQueue={isInFailoverQueue} onToggleFailover={onToggleFailover} // OpenClaw: default model isDefaultModel={isDefaultModel} onSetAsDefault={onSetAsDefault} />
{isExpanded && hasMultiplePlans && (
)}
); }