import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, Loader2, Plus, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Form, FormControl, FormField, FormItem, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { BasicFormFields } from "./BasicFormFields"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; import type { ClaudeApiFormat, ClaudeDesktopModelRoute, ProviderCategory, ProviderMeta, } from "@/types"; import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets"; import { fetchModelsForConfig, showFetchModelsError, type FetchedModel, } from "@/lib/api/model-fetch"; import { providersApi, type ClaudeDesktopDefaultRoute, } from "@/lib/api/providers"; export type ClaudeDesktopProviderFormValues = ProviderFormData & { presetId?: string; presetCategory?: ProviderCategory; isPartner?: boolean; meta?: ProviderMeta; providerKey?: string; suggestedDefaults?: OpenClawSuggestedDefaults; }; export interface ClaudeDesktopProviderFormProps { submitLabel: string; onSubmit: (values: ClaudeDesktopProviderFormValues) => Promise | void; onCancel: () => void; onSubmittingChange?: (isSubmitting: boolean) => void; initialData?: { name?: string; websiteUrl?: string; notes?: string; settingsConfig?: Record; category?: ProviderCategory; meta?: ProviderMeta; icon?: string; iconColor?: string; }; showButtons?: boolean; } type RouteRow = { route: string; model: string; displayName: string; supports1m: boolean; }; function envString( settingsConfig: Record | undefined, key: string, ) { const env = settingsConfig?.env; if (!env || typeof env !== "object") return ""; const value = (env as Record)[key]; return typeof value === "string" ? value : ""; } function clonePlainRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { return {}; } return { ...(value as Record) }; } function initialRouteRows( routes: Record | undefined, ): RouteRow[] { return Object.entries(routes ?? {}).map(([route, value]) => ({ route, model: value.model ?? "", displayName: value.displayName ?? "", supports1m: value.supports1m ?? false, })); } function isClaudeSafeRoute(route: string) { const normalized = route.trim().toLowerCase(); return ( normalized.startsWith("claude-") || normalized.startsWith("anthropic/claude-") ); } function defaultRouteRows( defaults: ClaudeDesktopDefaultRoute[], defaultModel: string, ): RouteRow[] { return defaults.map((route, index) => ({ route: route.routeId, model: index === 0 ? defaultModel : "", displayName: route.displayName, supports1m: route.supports1m, })); } function nextRouteRow(current: RouteRow[], defaults: RouteRow[]): RouteRow { return ( defaults.find( (route) => !current.some((existing) => existing.route === route.route), ) ?? { route: "", model: "", displayName: "", supports1m: true, } ); } export function ClaudeDesktopProviderForm({ submitLabel, onSubmit, onCancel, onSubmittingChange, initialData, showButtons = true, }: ClaudeDesktopProviderFormProps) { const { t } = useTranslation(); const initialMode = initialData?.meta?.claudeDesktopMode ?? "direct"; const [mode, setMode] = useState<"direct" | "proxy">(initialMode); const needsModelMapping = mode === "proxy"; const [apiFormat, setApiFormat] = useState( initialData?.meta?.apiFormat ?? "anthropic", ); const [baseUrl, setBaseUrl] = useState( envString(initialData?.settingsConfig, "ANTHROPIC_BASE_URL"), ); const [apiKey, setApiKey] = useState( envString(initialData?.settingsConfig, "ANTHROPIC_AUTH_TOKEN") || envString(initialData?.settingsConfig, "ANTHROPIC_API_KEY"), ); const [routes, setRoutes] = useState(() => initialRouteRows(initialData?.meta?.claudeDesktopModelRoutes), ); const didSeedDefaultRoutes = useRef( Object.keys(initialData?.meta?.claudeDesktopModelRoutes ?? {}).length > 0, ); const [fetchedModels, setFetchedModels] = useState([]); const [isFetchingModels, setIsFetchingModels] = useState(false); const [directModelsExpanded, setDirectModelsExpanded] = useState( initialMode === "direct" && Object.keys(initialData?.meta?.claudeDesktopModelRoutes ?? {}).length > 0, ); const { data: defaultRoutes = [] } = useQuery({ queryKey: ["claudeDesktopDefaultRoutes"], queryFn: () => providersApi.getClaudeDesktopDefaultRoutes(), }); const defaultProxyRouteRows = useMemo( () => defaultRouteRows( defaultRoutes, envString(initialData?.settingsConfig, "ANTHROPIC_MODEL"), ), [defaultRoutes, initialData?.settingsConfig], ); const defaultValues: ProviderFormData = useMemo( () => ({ name: initialData?.name ?? "", websiteUrl: initialData?.websiteUrl ?? "", notes: initialData?.notes ?? "", settingsConfig: JSON.stringify( initialData?.settingsConfig ?? { env: {} }, null, 2, ), icon: initialData?.icon ?? "", iconColor: initialData?.iconColor ?? "", }), [initialData], ); const form = useForm({ resolver: zodResolver(providerSchema), defaultValues, mode: "onSubmit", }); useEffect(() => { onSubmittingChange?.(form.formState.isSubmitting || isFetchingModels); }, [form.formState.isSubmitting, isFetchingModels, onSubmittingChange]); const updateRoute = (index: number, patch: Partial) => { setRoutes((current) => current.map((row, i) => (i === index ? { ...row, ...patch } : row)), ); }; const handleModelMappingChange = (checked: boolean) => { setMode(checked ? "proxy" : "direct"); if (checked) { setRoutes((current) => { if (current.length > 0 || defaultProxyRouteRows.length === 0) { return current; } didSeedDefaultRoutes.current = true; return defaultProxyRouteRows; }); } }; useEffect(() => { if ( didSeedDefaultRoutes.current || mode !== "proxy" || routes.length > 0 || defaultProxyRouteRows.length === 0 ) { return; } didSeedDefaultRoutes.current = true; setRoutes(defaultProxyRouteRows); }, [defaultProxyRouteRows, mode, routes.length]); const handleFetchModels = async () => { if (!baseUrl.trim() || !apiKey.trim()) { showFetchModelsError(null, t, { hasBaseUrl: Boolean(baseUrl.trim()), hasApiKey: Boolean(apiKey.trim()), }); return; } setIsFetchingModels(true); try { const models = await fetchModelsForConfig(baseUrl.trim(), apiKey.trim()); setFetchedModels(models); toast.success( t("providerForm.fetchModelsSuccess", { count: models.length, defaultValue: `已获取 ${models.length} 个模型`, }), ); } catch (error) { showFetchModelsError(error, t, { hasBaseUrl: Boolean(baseUrl.trim()), hasApiKey: Boolean(apiKey.trim()), }); } finally { setIsFetchingModels(false); } }; const handleSubmit = async (values: ProviderFormData) => { if (!values.name.trim()) { toast.error( t("providerForm.fillSupplierName", { defaultValue: "请填写供应商名称", }), ); return; } if (!baseUrl.trim()) { toast.error( t("providerForm.fetchModelsNeedEndpoint", { defaultValue: "请先填写接口地址", }), ); return; } if (!apiKey.trim()) { toast.error( t("providerForm.fetchModelsNeedApiKey", { defaultValue: "请先填写 API Key", }), ); return; } const routeEntries = routes .map((route) => ({ ...route, route: route.route.trim(), model: route.model.trim(), displayName: route.displayName.trim(), })) .filter((route) => route.route || route.model); if (mode === "proxy") { const invalid = routeEntries.find( (route) => !route.route || !route.model || !isClaudeSafeRoute(route.route), ); if (invalid) { toast.error( t("claudeDesktop.routeInvalid", { defaultValue: "模型映射必须填写 claude-* / anthropic/claude-* 路由名和上游模型名", }), ); return; } if (routeEntries.length === 0) { toast.error( t("claudeDesktop.routesRequired", { defaultValue: "需要模型映射时至少填写一个模型路由", }), ); return; } } else { const invalid = routeEntries.find( (route) => !route.route || !isClaudeSafeRoute(route.route), ); if (invalid) { toast.error( t("claudeDesktop.directModelInvalid", { defaultValue: "直连模型必须使用 claude-* / anthropic/claude-* 模型名", }), ); return; } } const settingsConfig = clonePlainRecord(initialData?.settingsConfig); const env = clonePlainRecord(settingsConfig.env); settingsConfig.env = { ...env, ANTHROPIC_BASE_URL: baseUrl.trim().replace(/\/+$/, ""), ANTHROPIC_AUTH_TOKEN: apiKey.trim(), }; const routeMap = routeEntries.reduce< Record >((acc, route) => { acc[route.route] = { model: route.model || route.route, displayName: route.displayName || undefined, supports1m: route.supports1m || undefined, }; return acc; }, {}); const meta: ProviderMeta = { ...(initialData?.meta ?? {}), claudeDesktopMode: mode, apiFormat: mode === "proxy" ? apiFormat : "anthropic", }; meta.claudeDesktopModelRoutes = routeMap; delete meta.endpointAutoSelect; delete meta.providerType; delete meta.isFullUrl; await onSubmit({ ...values, name: values.name.trim(), websiteUrl: values.websiteUrl?.trim() ?? "", notes: values.notes?.trim() ?? "", settingsConfig: JSON.stringify(settingsConfig, null, 2), meta, }); }; return (
setBaseUrl(event.target.value)} placeholder="https://api.example.com" />
setApiKey(event.target.value)} type="password" placeholder="sk-..." />

{needsModelMapping ? t("claudeDesktop.modelMappingOnHint", { defaultValue: "Claude Desktop 只看到 claude-* 路由名,CC Switch 本地路由会映射到真实上游模型;需要保持本地路由运行。", }) : t("claudeDesktop.modelMappingOffHint", { defaultValue: "适合供应商已经暴露并接受 claude-* / anthropic/claude-* 模型名的 Anthropic Messages API;请求会由 Claude Desktop 直连供应商。", })}

{needsModelMapping && (
{fetchedModels.map((model) => (

{t("claudeDesktop.routeMapHint", { defaultValue: "左侧是 Claude Desktop 看到的 claude-* 模型名,右侧是真实发送给供应商的上游模型名。", })}

{t("claudeDesktop.routeModelLabel", { defaultValue: "Desktop 模型", })} {t("claudeDesktop.upstreamModelLabel", { defaultValue: "上游模型", })} {t("claudeDesktop.displayNameLabel", { defaultValue: "显示名", })} {t("claudeDesktop.supports1mLabel", { defaultValue: "1M", })}
{routes.map((route, index) => (
updateRoute(index, { route: event.target.value }) } placeholder="claude-sonnet-4-6" /> updateRoute(index, { model: event.target.value }) } list="claude-desktop-upstream-models" placeholder="kimi-k2 / deepseek-chat" /> updateRoute(index, { displayName: event.target.value }) } placeholder="Sonnet" />
))}
)} {!needsModelMapping && ( {!directModelsExpanded && (

{t("claudeDesktop.directModelListCollapsedHint", { defaultValue: "原生 Claude 模型供应商通常不用填写,Claude Desktop 会自动读取 /v1/models。", })}

)}

{t("claudeDesktop.directModelListHint", { defaultValue: "仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 claude-* 模型名时填写;这些模型名会原样发送给供应商。", })}

{fetchedModels.map((model) => ( {routes.length > 0 ? (
{routes.map((route, index) => (
updateRoute(index, { route: event.target.value }) } list="claude-desktop-direct-models" placeholder="claude-deepseek-chat" />
))}
) : null}
)} ( )} /> {showButtons && (
)} ); }