mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
ed47480fc7
* feat(omo): integrate Oh My OpenCode profile management into Provider system Adds full-stack OMO support: backend config read/write/import, OMO-specific provider CRUD with exclusive switching, frontend profile editor with agent/category/model configuration, global config management, and i18n support. * feat(omo): add model/variant dropdowns from enabled providers Replace model text inputs with Select dropdowns sourced from enabled OpenCode providers, add thinking-level variant selection, and prevent auto-enabling newly added OMO providers. * fix(omo): use standard provider action styles for OMO switch button * fix(omo): replace hardcoded isZh strings with proper i18n t() calls
435 lines
14 KiB
TypeScript
435 lines
14 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 } from "@tanstack/react-query";
|
|
import type { Provider } from "@/types";
|
|
import type { AppId } from "@/lib/api";
|
|
import { providersApi } from "@/lib/api/providers";
|
|
import { useDragSort } from "@/hooks/useDragSort";
|
|
import { ProviderCard } from "@/components/providers/ProviderCard";
|
|
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
|
import {
|
|
useAutoFailoverEnabled,
|
|
useFailoverQueue,
|
|
useAddToFailoverQueue,
|
|
useRemoveFromFailoverQueue,
|
|
} from "@/lib/query/failover";
|
|
import { useCurrentOmoProviderId, useOmoProviderCount } from "@/lib/query/omo";
|
|
import { useCallback } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
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;
|
|
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(用于故障转移模式下标注绿色边框)
|
|
}
|
|
|
|
export function ProviderList({
|
|
providers,
|
|
currentProviderId,
|
|
appId,
|
|
onSwitch,
|
|
onEdit,
|
|
onDelete,
|
|
onRemoveFromConfig,
|
|
onDisableOmo,
|
|
onDuplicate,
|
|
onConfigureUsage,
|
|
onOpenWebsite,
|
|
onOpenTerminal,
|
|
onCreate,
|
|
isLoading = false,
|
|
isProxyRunning = false,
|
|
isProxyTakeover = false,
|
|
activeProviderId,
|
|
}: ProviderListProps) {
|
|
const { t } = useTranslation();
|
|
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
|
providers,
|
|
appId,
|
|
);
|
|
|
|
const { data: opencodeLiveIds } = useQuery({
|
|
queryKey: ["opencodeLiveProviderIds"],
|
|
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
|
|
enabled: appId === "opencode",
|
|
});
|
|
|
|
const isProviderInConfig = useCallback(
|
|
(providerId: string): boolean => {
|
|
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
|
|
return opencodeLiveIds?.includes(providerId) ?? false;
|
|
},
|
|
[appId, opencodeLiveIds],
|
|
);
|
|
|
|
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: omoProviderCount } = useOmoProviderCount(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);
|
|
|
|
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} />;
|
|
}
|
|
|
|
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 isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
|
|
return (
|
|
<SortableProviderCard
|
|
key={provider.id}
|
|
provider={provider}
|
|
isCurrent={
|
|
isOmo ? isOmoCurrent : provider.id === currentProviderId
|
|
}
|
|
appId={appId}
|
|
isInConfig={isProviderInConfig(provider.id)}
|
|
isOmo={isOmo}
|
|
isLastOmo={
|
|
isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent
|
|
}
|
|
onSwitch={onSwitch}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onRemoveFromConfig={onRemoveFromConfig}
|
|
onDisableOmo={onDisableOmo}
|
|
onDuplicate={onDuplicate}
|
|
onConfigureUsage={onConfigureUsage}
|
|
onOpenWebsite={onOpenWebsite}
|
|
onOpenTerminal={onOpenTerminal}
|
|
isTesting={false} // 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}
|
|
/>
|
|
);
|
|
})}
|
|
</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()
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SortableProviderCardProps {
|
|
provider: Provider;
|
|
isCurrent: boolean;
|
|
appId: AppId;
|
|
isInConfig: boolean;
|
|
isOmo: boolean;
|
|
isLastOmo: boolean;
|
|
onSwitch: (provider: Provider) => void;
|
|
onEdit: (provider: Provider) => void;
|
|
onDelete: (provider: Provider) => void;
|
|
onRemoveFromConfig?: (provider: Provider) => void;
|
|
onDisableOmo?: () => 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;
|
|
}
|
|
|
|
function SortableProviderCard({
|
|
provider,
|
|
isCurrent,
|
|
appId,
|
|
isInConfig,
|
|
isOmo,
|
|
isLastOmo,
|
|
onSwitch,
|
|
onEdit,
|
|
onDelete,
|
|
onRemoveFromConfig,
|
|
onDisableOmo,
|
|
onDuplicate,
|
|
onConfigureUsage,
|
|
onOpenWebsite,
|
|
onOpenTerminal,
|
|
onTest,
|
|
isTesting,
|
|
isProxyRunning,
|
|
isProxyTakeover,
|
|
isAutoFailoverEnabled,
|
|
failoverPriority,
|
|
isInFailoverQueue,
|
|
onToggleFailover,
|
|
activeProviderId,
|
|
}: 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}
|
|
isLastOmo={isLastOmo}
|
|
onSwitch={onSwitch}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onRemoveFromConfig={onRemoveFromConfig}
|
|
onDisableOmo={onDisableOmo}
|
|
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}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|