Files
CC-Switch/src/components/providers/ProviderList.tsx
T
Jason 34698723e3 fix(claude-desktop): remove proxy-stopped status alert
The route toggle in the top-right corner already communicates proxy
state; the extra warning banner was redundant and inconsistent with
other managed apps.
2026-05-08 22:34:46 +08:00

709 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CSS } from "@dnd-kit/utilities";
import { DndContext, closestCenter } from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import { AnimatePresence, motion } from "framer-motion";
import { AlertTriangle, Download, Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { providersApi } from "@/lib/api/providers";
import { useDragSort } from "@/hooks/useDragSort";
import {
useOpenClawLiveProviderIds,
useOpenClawDefaultModel,
} from "@/hooks/useOpenClaw";
import {
useHermesLiveProviderIds,
useHermesModelConfig,
} from "@/hooks/useHermes";
import { useStreamCheck } from "@/hooks/useStreamCheck";
import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
import {
useAutoFailoverEnabled,
useFailoverQueue,
useAddToFailoverQueue,
useRemoveFromFailoverQueue,
} from "@/lib/query/failover";
import {
useCurrentOmoProviderId,
useCurrentOmoSlimProviderId,
} from "@/lib/query/omo";
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { settingsApi } from "@/lib/api/settings";
interface ProviderListProps {
providers: Record<string, Provider>;
currentProviderId: string;
appId: AppId;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
onRemoveFromConfig?: (provider: Provider) => void;
onDisableOmo?: () => void;
onDisableOmoSlim?: () => void;
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onCreate?: () => void;
isLoading?: boolean;
isProxyRunning?: boolean; // 代理服务运行状态
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
onSetAsDefault?: (provider: Provider) => void; // OpenClaw: set as default model
}
export function ProviderList({
providers,
currentProviderId,
appId,
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onDisableOmoSlim,
onDuplicate,
onConfigureUsage,
onOpenWebsite,
onOpenTerminal,
onCreate,
isLoading = false,
isProxyRunning = false,
isProxyTakeover = false,
activeProviderId,
onSetAsDefault,
}: ProviderListProps) {
const { t } = useTranslation();
const { checkProvider, isChecking } = useStreamCheck(appId);
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
providers,
appId,
);
const { data: opencodeLiveIds } = useQuery({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
enabled: appId === "opencode",
});
// OpenClaw: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
const { data: openclawLiveIds } = useOpenClawLiveProviderIds(
appId === "openclaw",
);
// Hermes: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
const { data: hermesLiveIds } = useHermesLiveProviderIds(appId === "hermes");
// Hermes: 读取当前 model.provider,用于判断哪个供应商是"当前激活"(高亮)
const { data: hermesModelConfig } = useHermesModelConfig(appId === "hermes");
const hermesCurrentProviderId = hermesModelConfig?.provider;
// 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw/Hermes
const isProviderInConfig = useCallback(
(providerId: string): boolean => {
if (appId === "opencode") {
return opencodeLiveIds?.includes(providerId) ?? false;
}
if (appId === "openclaw") {
return openclawLiveIds?.includes(providerId) ?? false;
}
if (appId === "hermes") {
return hermesLiveIds?.includes(providerId) ?? false;
}
return true; // 其他应用始终返回 true
},
[appId, opencodeLiveIds, openclawLiveIds, hermesLiveIds],
);
// OpenClaw: query default model to determine which provider is default
const { data: openclawDefaultModel } = useOpenClawDefaultModel(
appId === "openclaw",
);
const isProviderDefaultModel = useCallback(
(providerId: string): boolean => {
if (appId !== "openclaw" || !openclawDefaultModel?.primary) return false;
return openclawDefaultModel.primary.startsWith(providerId + "/");
},
[appId, openclawDefaultModel],
);
// 故障转移相关
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
const { data: failoverQueue } = useFailoverQueue(appId);
const addToQueue = useAddToFailoverQueue();
const removeFromQueue = useRemoveFromFailoverQueue();
const isFailoverModeActive =
isProxyTakeover === true && isAutoFailoverEnabled === true;
const isOpenCode = appId === "opencode";
const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);
const { data: currentOmoSlimId } = useCurrentOmoSlimProviderId(isOpenCode);
const getFailoverPriority = useCallback(
(providerId: string): number | undefined => {
if (!isFailoverModeActive || !failoverQueue) return undefined;
const index = failoverQueue.findIndex(
(item) => item.providerId === providerId,
);
return index >= 0 ? index + 1 : undefined;
},
[isFailoverModeActive, failoverQueue],
);
const isInFailoverQueue = useCallback(
(providerId: string): boolean => {
if (!isFailoverModeActive || !failoverQueue) return false;
return failoverQueue.some((item) => item.providerId === providerId);
},
[isFailoverModeActive, failoverQueue],
);
const handleToggleFailover = useCallback(
(providerId: string, enabled: boolean) => {
if (enabled) {
addToQueue.mutate({ appType: appId, providerId });
} else {
removeFromQueue.mutate({ appType: appId, providerId });
}
},
[appId, addToQueue, removeFromQueue],
);
const [searchTerm, setSearchTerm] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const [showStreamCheckConfirm, setShowStreamCheckConfirm] = useState(false);
const [pendingTestProvider, setPendingTestProvider] =
useState<Provider | null>(null);
const { data: claudeDesktopStatus } = useQuery({
queryKey: ["claudeDesktopStatus"],
queryFn: () => providersApi.getClaudeDesktopStatus(),
enabled: appId === "claude-desktop",
refetchInterval: appId === "claude-desktop" ? 5000 : false,
});
// Query settings for streamCheckConfirmed flag
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsApi.get(),
});
const handleTest = useCallback(
(provider: Provider) => {
if (!settings?.streamCheckConfirmed) {
setPendingTestProvider(provider);
setShowStreamCheckConfirm(true);
} else {
checkProvider(provider.id, provider.name);
}
},
[checkProvider, settings?.streamCheckConfirmed],
);
const handleStreamCheckConfirm = async () => {
setShowStreamCheckConfirm(false);
try {
if (settings) {
const { webdavSync: _, ...rest } = settings;
await settingsApi.save({ ...rest, streamCheckConfirmed: true });
await queryClient.invalidateQueries({ queryKey: ["settings"] });
}
} catch (error) {
console.error("Failed to save stream check confirmed:", error);
}
if (pendingTestProvider) {
checkProvider(pendingTestProvider.id, pendingTestProvider.name);
setPendingTestProvider(null);
}
};
// Import current live config as default provider
const queryClient = useQueryClient();
const importMutation = useMutation({
mutationFn: async (): Promise<boolean> => {
if (appId === "opencode") {
const count = await providersApi.importOpenCodeFromLive();
return count > 0;
}
if (appId === "openclaw") {
const count = await providersApi.importOpenClawFromLive();
return count > 0;
}
if (appId === "hermes") {
const count = await providersApi.importHermesFromLive();
return count > 0;
}
if (appId === "claude-desktop") {
const count = await providersApi.importClaudeDesktopFromClaude();
return count > 0;
}
return providersApi.importDefault(appId);
},
onSuccess: (imported) => {
if (imported) {
queryClient.invalidateQueries({ queryKey: ["providers", appId] });
if (appId === "claude-desktop") {
queryClient.invalidateQueries({ queryKey: ["claudeDesktopStatus"] });
}
toast.success(t("provider.importCurrentDescription"));
} else {
toast.info(t("provider.noProviders"));
}
},
onError: (error: Error) => {
toast.error(error.message);
},
});
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
if ((event.metaKey || event.ctrlKey) && key === "f") {
event.preventDefault();
setIsSearchOpen(true);
return;
}
if (key === "escape") {
setIsSearchOpen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
useEffect(() => {
if (isSearchOpen) {
const frame = requestAnimationFrame(() => {
searchInputRef.current?.focus();
searchInputRef.current?.select();
});
return () => cancelAnimationFrame(frame);
}
}, [isSearchOpen]);
const filteredProviders = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase();
if (!keyword) return sortedProviders;
return sortedProviders.filter((provider) => {
const fields = [provider.name, provider.notes, provider.websiteUrl];
return fields.some((field) =>
field?.toString().toLowerCase().includes(keyword),
);
});
}, [searchTerm, sortedProviders]);
const claudeDesktopStatusMessages = useMemo(() => {
if (appId !== "claude-desktop" || !claudeDesktopStatus) return [];
const messages: string[] = [];
if (!claudeDesktopStatus.supported) {
messages.push(
t("claudeDesktop.statusUnsupported", {
defaultValue: "当前平台暂不支持 Claude Desktop 3P 配置写入。",
}),
);
return messages;
}
if (claudeDesktopStatus.staleRawModels) {
messages.push(
t("claudeDesktop.statusStaleRawModels", {
defaultValue:
"Claude Desktop profile 中存在非 claude-* 模型名,新版 Claude Desktop 可能拒绝加载;重新切换当前供应商可修复。",
}),
);
}
if (claudeDesktopStatus.missingRouteMappings) {
messages.push(
t("claudeDesktop.statusMissingRouteMappings", {
defaultValue:
"当前供应商启用了模型映射,但没有有效路由;请编辑供应商并补全至少一个模型映射。",
}),
);
}
if (
claudeDesktopStatus.mode === "proxy" &&
!claudeDesktopStatus.gatewayTokenConfigured
) {
messages.push(
t("claudeDesktop.statusGatewayTokenMissing", {
defaultValue:
"当前本地路由 token 尚未生成;重新切换该供应商会写入新的本地 token。",
}),
);
}
const expected = claudeDesktopStatus.expectedBaseUrl?.replace(/\/+$/, "");
const actual = claudeDesktopStatus.actualBaseUrl?.replace(/\/+$/, "");
if (expected && actual && expected !== actual) {
messages.push(
t("claudeDesktop.statusBaseUrlMismatch", {
expected,
actual,
defaultValue:
"Claude Desktop profile 指向的地址与当前供应商不一致;当前为 {{actual}},应为 {{expected}}。重新切换当前供应商可修复。",
}),
);
}
return messages;
}, [appId, claudeDesktopStatus, t]);
if (isLoading) {
return (
<div className="space-y-3">
{[0, 1, 2].map((index) => (
<div
key={index}
className="w-full border border-dashed rounded-lg h-28 border-muted-foreground/40 bg-muted/40"
/>
))}
</div>
);
}
if (sortedProviders.length === 0) {
return (
<ProviderEmptyState
appId={appId}
onCreate={onCreate}
onImport={() => importMutation.mutate()}
/>
);
}
const renderProviderList = () => (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filteredProviders.map((provider) => provider.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{filteredProviders.map((provider) => {
const isOmo = provider.category === "omo";
const isOmoSlim = provider.category === "omo-slim";
const isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
const isOmoSlimCurrent =
isOmoSlim && provider.id === (currentOmoSlimId || "");
const isHermesCurrent =
appId === "hermes" && hermesCurrentProviderId === provider.id;
return (
<SortableProviderCard
key={provider.id}
provider={provider}
isCurrent={
isOmo
? isOmoCurrent
: isOmoSlim
? isOmoSlimCurrent
: appId === "hermes"
? isHermesCurrent
: provider.id === currentProviderId
}
appId={appId}
isInConfig={isProviderInConfig(provider.id)}
isOmo={isOmo}
isOmoSlim={isOmoSlim}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDisableOmo={onDisableOmo}
onDisableOmoSlim={onDisableOmoSlim}
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={handleTest}
isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover}
isAutoFailoverEnabled={isFailoverModeActive}
failoverPriority={getFailoverPriority(provider.id)}
isInFailoverQueue={isInFailoverQueue(provider.id)}
onToggleFailover={(enabled) =>
handleToggleFailover(provider.id, enabled)
}
activeProviderId={activeProviderId}
// OpenClaw: default model / Hermes: model.provider === provider.id
isDefaultModel={
appId === "hermes"
? isHermesCurrent
: isProviderDefaultModel(provider.id)
}
onSetAsDefault={
onSetAsDefault ? () => onSetAsDefault(provider) : undefined
}
/>
);
})}
</div>
</SortableContext>
</DndContext>
);
return (
<div className="mt-4 space-y-4">
{appId === "claude-desktop" && (
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => importMutation.mutate()}
disabled={importMutation.isPending}
>
<Download className="mr-2 h-4 w-4" />
{t("provider.importFromClaude", {
defaultValue: "从 Claude 导入兼容供应商",
})}
</Button>
</div>
)}
{claudeDesktopStatusMessages.length > 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-200">
<div className="flex items-center gap-2 font-medium">
<AlertTriangle className="h-4 w-4 shrink-0" />
{t("claudeDesktop.statusTitle", {
defaultValue: "Claude Desktop 配置需要检查",
})}
</div>
<ul className="mt-2 space-y-1 text-xs leading-relaxed">
{claudeDesktopStatusMessages.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
</div>
)}
<AnimatePresence>
{isSearchOpen && (
<motion.div
key="provider-search"
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="fixed left-1/2 top-[6.5rem] z-40 w-[min(90vw,26rem)] -translate-x-1/2 sm:right-6 sm:left-auto sm:translate-x-0"
>
<div className="p-4 space-y-3 border shadow-md rounded-2xl border-white/10 bg-background/95 shadow-black/20 backdrop-blur-md">
<div className="relative flex items-center gap-2">
<Search className="absolute w-4 h-4 -translate-y-1/2 pointer-events-none left-3 top-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder={t("provider.searchPlaceholder", {
defaultValue: "Search name, notes, or URL...",
})}
aria-label={t("provider.searchAriaLabel", {
defaultValue: "Search providers",
})}
className="pr-16 pl-9"
/>
{searchTerm && (
<Button
variant="ghost"
size="sm"
className="absolute text-xs -translate-y-1/2 right-11 top-1/2"
onClick={() => setSearchTerm("")}
>
{t("common.clear", { defaultValue: "Clear" })}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="ml-auto"
onClick={() => setIsSearchOpen(false)}
aria-label={t("provider.searchCloseAriaLabel", {
defaultValue: "Close provider search",
})}
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span>
{t("provider.searchScopeHint", {
defaultValue: "Matches provider name, notes, and URL.",
})}
</span>
<span>
{t("provider.searchCloseHint", {
defaultValue: "Press Esc to close",
})}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{filteredProviders.length === 0 ? (
<div className="px-6 py-8 text-sm text-center border border-dashed rounded-lg border-border text-muted-foreground">
{t("provider.noSearchResults", {
defaultValue: "No providers match your search.",
})}
</div>
) : (
renderProviderList()
)}
<ConfirmDialog
isOpen={showStreamCheckConfirm}
variant="info"
title={t("confirm.streamCheck.title")}
message={t("confirm.streamCheck.message")}
confirmText={t("confirm.streamCheck.confirm")}
onConfirm={() => void handleStreamCheckConfirm()}
onCancel={() => {
setShowStreamCheckConfirm(false);
setPendingTestProvider(null);
}}
/>
</div>
);
}
interface SortableProviderCardProps {
provider: Provider;
isCurrent: boolean;
appId: AppId;
isInConfig: boolean;
isOmo: boolean;
isOmoSlim: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
onRemoveFromConfig?: (provider: Provider) => void;
onDisableOmo?: () => void;
onDisableOmoSlim?: () => void;
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onTest?: (provider: Provider) => void;
isTesting: boolean;
isProxyRunning: boolean;
isProxyTakeover: boolean;
isAutoFailoverEnabled: boolean;
failoverPriority?: number;
isInFailoverQueue: boolean;
onToggleFailover: (enabled: boolean) => void;
activeProviderId?: string;
// OpenClaw: default model
isDefaultModel?: boolean;
onSetAsDefault?: () => void;
}
function SortableProviderCard({
provider,
isCurrent,
appId,
isInConfig,
isOmo,
isOmoSlim,
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onDisableOmoSlim,
onDuplicate,
onConfigureUsage,
onOpenWebsite,
onOpenTerminal,
onTest,
isTesting,
isProxyRunning,
isProxyTakeover,
isAutoFailoverEnabled,
failoverPriority,
isInFailoverQueue,
onToggleFailover,
activeProviderId,
isDefaultModel,
onSetAsDefault,
}: SortableProviderCardProps) {
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({ id: provider.id });
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style}>
<ProviderCard
provider={provider}
isCurrent={isCurrent}
appId={appId}
isInConfig={isInConfig}
isOmo={isOmo}
isOmoSlim={isOmoSlim}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDisableOmo={onDisableOmo}
onDisableOmoSlim={onDisableOmoSlim}
onDuplicate={onDuplicate}
onConfigureUsage={
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={onTest}
isTesting={isTesting}
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover}
dragHandleProps={{
attributes,
listeners,
isDragging,
}}
isAutoFailoverEnabled={isAutoFailoverEnabled}
failoverPriority={failoverPriority}
isInFailoverQueue={isInFailoverQueue}
onToggleFailover={onToggleFailover}
activeProviderId={activeProviderId}
// OpenClaw: default model
isDefaultModel={isDefaultModel}
onSetAsDefault={onSetAsDefault}
/>
</div>
);
}