mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 16:56:16 +08:00
07568286fc
Show an informational dialog when users first click the health check button, explaining its limitations (OAuth providers, relay services, Bedrock). The dialog persists the confirmation in settings so it only appears once per device.
584 lines
19 KiB
TypeScript
584 lines
19 KiB
TypeScript
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 { 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 { 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",
|
||
);
|
||
|
||
// 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw)
|
||
const isProviderInConfig = useCallback(
|
||
(providerId: string): boolean => {
|
||
if (appId === "opencode") {
|
||
return opencodeLiveIds?.includes(providerId) ?? false;
|
||
}
|
||
if (appId === "openclaw") {
|
||
return openclawLiveIds?.includes(providerId) ?? false;
|
||
}
|
||
return true; // 其他应用始终返回 true
|
||
},
|
||
[appId, opencodeLiveIds, openclawLiveIds],
|
||
);
|
||
|
||
// 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);
|
||
|
||
// 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) {
|
||
await settingsApi.save({ ...settings, 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;
|
||
}
|
||
return providersApi.importDefault(appId);
|
||
},
|
||
onSuccess: (imported) => {
|
||
if (imported) {
|
||
queryClient.invalidateQueries({ queryKey: ["providers", appId] });
|
||
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]);
|
||
|
||
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
|
||
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 || "");
|
||
return (
|
||
<SortableProviderCard
|
||
key={provider.id}
|
||
provider={provider}
|
||
isCurrent={
|
||
isOmo
|
||
? isOmoCurrent
|
||
: isOmoSlim
|
||
? isOmoSlimCurrent
|
||
: 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={
|
||
appId !== "opencode" && appId !== "openclaw"
|
||
? handleTest
|
||
: undefined
|
||
}
|
||
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
|
||
isDefaultModel={isProviderDefaultModel(provider.id)}
|
||
onSetAsDefault={
|
||
onSetAsDefault ? () => onSetAsDefault(provider) : undefined
|
||
}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
);
|
||
|
||
return (
|
||
<div className="mt-4 space-y-4">
|
||
<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>
|
||
);
|
||
}
|