feat(claude-desktop): add 3P provider switching with proxy gateway

Adds a new ClaudeDesktop AppType that writes Claude Desktop's third-party
inference profile under configLibrary/, sharing _meta.json with other
launchers (Ollama-compatible) so cc-switch can coexist with them.

Two switch modes:
- direct: provider already exposes claude-* / anthropic/claude-* model
  ids on Anthropic Messages, Claude Desktop connects to it directly.
- proxy: cc-switch's local proxy acts as the inference gateway,
  presenting only claude-* route names to Claude Desktop and mapping
  them to real upstream models. Required after Anthropic restricted
  Claude Desktop to claude-family ids.

Backend:
- New module claude_desktop_config with snapshot/rollback, official seed
  bypass, /claude-desktop/v1/{models,messages} routes, and a single
  source of truth for default proxy routes.
- Gateway token persisted in SQLite, validated on every proxied request.
- get_claude_desktop_status surfaces drift signals (stale models,
  missing routes, proxy stopped, base URL mismatch, missing token).

Frontend:
- Slim ClaudeDesktopProviderForm independent from ProviderForm,
  controlled by a top-level appId guard.
- ProviderList banner consumes the status query (5s polling) and
  renders actionable diagnostics.
- ClaudeDesktopRouteToggle in the header to start/stop the local
  gateway without touching takeover state.
- Three-locale i18n synchronised.
This commit is contained in:
Jason
2026-05-08 11:50:01 +08:00
parent e15bfbfe7a
commit 8b3ad9caf9
64 changed files with 3328 additions and 109 deletions
+37 -24
View File
@@ -65,6 +65,7 @@ import { SettingsPage } from "@/components/settings/SettingsPage";
import { UpdateBadge } from "@/components/UpdateBadge";
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
import { ProxyToggle } from "@/components/proxy/ProxyToggle";
import { ClaudeDesktopRouteToggle } from "@/components/proxy/ClaudeDesktopRouteToggle";
import { FailoverToggle } from "@/components/proxy/FailoverToggle";
import UsageScriptModal from "@/components/UsageScriptModal";
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
@@ -117,6 +118,7 @@ const HEADER_HEIGHT = 64; // px
const STORAGE_KEY = "cc-switch-last-app";
const VALID_APPS: AppId[] = [
"claude",
"claude-desktop",
"codex",
"gemini",
"opencode",
@@ -179,6 +181,7 @@ function App() {
const contentTopOffset = dragBarHeight + HEADER_HEIGHT;
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
claude: true,
"claude-desktop": true,
codex: true,
gemini: true,
opencode: true,
@@ -188,6 +191,7 @@ function App() {
const getFirstVisibleApp = (): AppId => {
if (visibleApps.claude) return "claude";
if (visibleApps["claude-desktop"]) return "claude-desktop";
if (visibleApps.codex) return "codex";
if (visibleApps.gemini) return "gemini";
if (visibleApps.opencode) return "opencode";
@@ -269,7 +273,7 @@ function App() {
currentView === "openclawAgents");
const { data: openclawHealthWarnings = [] } =
useOpenClawHealth(isOpenClawView);
const hasSkillsSupport = true;
const hasSkillsSupport = activeApp !== "claude-desktop";
const hasSessionSupport =
activeApp === "claude" ||
activeApp === "codex" ||
@@ -1241,12 +1245,17 @@ function App() {
className="flex shrink-0 items-center gap-1.5"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{settingsData?.enableLocalProxy && (
<ProxyToggle activeApp={activeApp} />
)}
{settingsData?.enableFailoverToggle && (
<FailoverToggle activeApp={activeApp} />
{activeApp === "claude-desktop" ? (
<ClaudeDesktopRouteToggle />
) : (
settingsData?.enableLocalProxy && (
<ProxyToggle activeApp={activeApp} />
)
)}
{activeApp !== "claude-desktop" &&
settingsData?.enableFailoverToggle && (
<FailoverToggle activeApp={activeApp} />
)}
</div>
)}
<div
@@ -1487,15 +1496,17 @@ function App() {
>
<Wrench className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("prompts.manage")}
>
<Book className="w-4 h-4" />
</Button>
{activeApp !== "claude-desktop" && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("prompts.manage")}
>
<Book className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -1511,15 +1522,17 @@ function App() {
>
<History className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
{activeApp !== "claude-desktop" && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
)}
</>
)}
</motion.div>
+3
View File
@@ -12,6 +12,7 @@ interface AppSwitcherProps {
const ALL_APPS: AppId[] = [
"claude",
"claude-desktop",
"codex",
"gemini",
"opencode",
@@ -34,6 +35,7 @@ export function AppSwitcher({
const iconSize = 20;
const appIconName: Record<AppId, string> = {
claude: "claude",
"claude-desktop": "claude",
codex: "openai",
gemini: "gemini",
opencode: "opencode",
@@ -42,6 +44,7 @@ export function AppSwitcher({
};
const appDisplayName: Record<AppId, string> = {
claude: "Claude",
"claude-desktop": "Claude Desktop",
codex: "Codex",
gemini: "Gemini",
opencode: "OpenCode",
+2 -2
View File
@@ -5,7 +5,7 @@ import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
interface AppCountBarProps {
totalLabel: string;
counts: Record<AppId, number>;
counts: Partial<Record<AppId, number>>;
appIds?: AppId[];
}
@@ -27,7 +27,7 @@ export const AppCountBar: React.FC<AppCountBarProps> = ({
className={APP_ICON_MAP[app].badgeClass}
>
<span className="opacity-75">{APP_ICON_MAP[app].label}:</span>
<span className="font-bold ml-1">{counts[app]}</span>
<span className="font-bold ml-1">{counts[app] ?? 0}</span>
</Badge>
))}
</div>
+1 -1
View File
@@ -8,7 +8,7 @@ import type { AppId } from "@/lib/api/types";
import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
interface AppToggleGroupProps {
apps: Record<AppId, boolean>;
apps: Partial<Record<AppId, boolean>>;
onToggle: (app: AppId, enabled: boolean) => void;
appIds?: AppId[];
}
+1
View File
@@ -58,6 +58,7 @@ const UnifiedMcpPanel = React.forwardRef<
const enabledCounts = useMemo(() => {
const counts = {
claude: 0,
"claude-desktop": 0,
codex: 0,
gemini: 0,
opencode: 0,
@@ -32,6 +32,7 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
const appName = t(`apps.${appId}`);
const filenameMap: Record<Exclude<AppId, "openclaw">, string> = {
claude: "CLAUDE.md",
"claude-desktop": "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
opencode: "AGENTS.md",
@@ -26,6 +26,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
const appName = t(`apps.${appId}`);
const filenameMap: Record<AppId, string> = {
claude: "CLAUDE.md",
"claude-desktop": "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
opencode: "AGENTS.md",
+17
View File
@@ -62,6 +62,10 @@ interface ProviderCardProps {
/** 判断是否为官方供应商(无自定义 base URL / API key,直连官方 API */
function isOfficialProvider(provider: Provider, appId: AppId): boolean {
if (provider.category === "official") {
return true;
}
const config = provider.settingsConfig as Record<string, any>;
if (appId === "claude") {
const baseUrl = config?.env?.ANTHROPIC_BASE_URL;
@@ -318,6 +322,19 @@ export function ProviderCard({
</span>
)}
{appId === "claude-desktop" &&
provider.category !== "official" && (
<span className="inline-flex items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
{provider.meta?.claudeDesktopMode === "proxy"
? t("claudeDesktop.modeProxy", {
defaultValue: "模型映射",
})
: t("claudeDesktop.modeDirect", {
defaultValue: "直连",
})}
</span>
)}
{isProxyRunning && isInFailoverQueue && health && (
<ProviderHealthBadge
consecutiveFailures={health.consecutive_failures}
@@ -36,7 +36,11 @@ export function ProviderEmptyState({
{onImport && (
<Button onClick={onImport}>
<Download className="mr-2 h-4 w-4" />
{t("provider.importCurrent")}
{appId === "claude-desktop"
? t("provider.importFromClaude", {
defaultValue: "从 Claude 导入兼容供应商",
})
: t("provider.importCurrent")}
</Button>
)}
{onCreate && (
+112 -1
View File
@@ -13,7 +13,7 @@ import {
type CSSProperties,
} from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Search, X } from "lucide-react";
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";
@@ -195,6 +195,12 @@ export function ProviderList({
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({
@@ -247,11 +253,18 @@ export function ProviderList({
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"));
@@ -301,6 +314,74 @@ export function ProviderList({
});
}, [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.proxyRunning
) {
messages.push(
t("claudeDesktop.statusProxyStopped", {
defaultValue:
"当前供应商需要 CC Switch 本地路由,但本地路由未运行;开启右上角路由开关后再重启 Claude Desktop。",
}),
);
}
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">
@@ -400,6 +481,36 @@ export function ProviderList({
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
@@ -0,0 +1,786 @@
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> | void;
onCancel: () => void;
onSubmittingChange?: (isSubmitting: boolean) => void;
initialData?: {
name?: string;
websiteUrl?: string;
notes?: string;
settingsConfig?: Record<string, unknown>;
category?: ProviderCategory;
meta?: ProviderMeta;
icon?: string;
iconColor?: string;
};
showButtons?: boolean;
}
type RouteRow = {
route: string;
model: string;
displayName: string;
supports1m: boolean;
};
function envString(
settingsConfig: Record<string, unknown> | undefined,
key: string,
) {
const env = settingsConfig?.env;
if (!env || typeof env !== "object") return "";
const value = (env as Record<string, unknown>)[key];
return typeof value === "string" ? value : "";
}
function clonePlainRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return { ...(value as Record<string, unknown>) };
}
function initialRouteRows(
routes: Record<string, ClaudeDesktopModelRoute> | 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<ClaudeApiFormat>(
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<RouteRow[]>(() =>
initialRouteRows(initialData?.meta?.claudeDesktopModelRoutes),
);
const didSeedDefaultRoutes = useRef(
Object.keys(initialData?.meta?.claudeDesktopModelRoutes ?? {}).length > 0,
);
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
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<ProviderFormData>({
resolver: zodResolver(providerSchema),
defaultValues,
mode: "onSubmit",
});
useEffect(() => {
onSubmittingChange?.(form.formState.isSubmitting || isFetchingModels);
}, [form.formState.isSubmitting, isFetchingModels, onSubmittingChange]);
const updateRoute = (index: number, patch: Partial<RouteRow>) => {
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<string, ClaudeDesktopModelRoute>
>((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 (
<Form {...form}>
<form
id="provider-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
>
<BasicFormFields form={form} />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>
{t("claudeDesktop.gatewayBaseUrl", {
defaultValue: "Gateway Base URL",
})}
</Label>
<Input
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="https://api.example.com"
/>
</div>
<div className="space-y-2">
<Label>
{t("claudeDesktop.bearerToken", {
defaultValue: "Bearer Token",
})}
</Label>
<Input
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
type="password"
placeholder="sk-..."
/>
</div>
</div>
<div className="space-y-3 rounded-lg border border-border-default bg-muted/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label>
{t("claudeDesktop.modelMappingToggle", {
defaultValue: "需要模型映射",
})}
</Label>
<p className="text-xs leading-relaxed text-muted-foreground">
{needsModelMapping
? t("claudeDesktop.modelMappingOnHint", {
defaultValue:
"Claude Desktop 只看到 claude-* 路由名,CC Switch 本地路由会映射到真实上游模型;需要保持本地路由运行。",
})
: t("claudeDesktop.modelMappingOffHint", {
defaultValue:
"适合供应商已经暴露并接受 claude-* / anthropic/claude-* 模型名的 Anthropic Messages API;请求会由 Claude Desktop 直连供应商。",
})}
</p>
</div>
<Switch
checked={needsModelMapping}
onCheckedChange={handleModelMappingChange}
aria-label={t("claudeDesktop.modelMappingToggle", {
defaultValue: "需要模型映射",
})}
/>
</div>
</div>
{needsModelMapping && (
<div className="space-y-4 rounded-lg border border-border-default p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-[220px_1fr]">
<div className="space-y-2">
<Label>
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
</Label>
<Select
value={apiFormat}
onValueChange={(value) =>
setApiFormat(value as ClaudeApiFormat)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">
{t("providerForm.apiFormatAnthropic", {
defaultValue: "Anthropic Messages (原生)",
})}
</SelectItem>
<SelectItem value="openai_chat">
{t("providerForm.apiFormatOpenAIChat", {
defaultValue: "OpenAI Chat Completions (需开启路由)",
})}
</SelectItem>
<SelectItem value="openai_responses">
{t("providerForm.apiFormatOpenAIResponses", {
defaultValue: "OpenAI Responses API (需开启路由)",
})}
</SelectItem>
<SelectItem value="gemini_native">
{t("providerForm.apiFormatGeminiNative", {
defaultValue:
"Gemini Native generateContent (需开启路由)",
})}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end justify-end">
<Button
type="button"
variant="outline"
onClick={handleFetchModels}
disabled={isFetchingModels}
>
{isFetchingModels ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{t("providerForm.fetchModels", {
defaultValue: "获取模型",
})}
</Button>
</div>
</div>
<datalist id="claude-desktop-upstream-models">
{fetchedModels.map((model) => (
<option key={model.id} value={model.id} />
))}
</datalist>
<div className="space-y-3">
<div className="space-y-1 border-t border-border-default pt-4">
<div className="flex items-center justify-between">
<Label>
{t("claudeDesktop.routeMapTitle", {
defaultValue: "模型映射",
})}
</Label>
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("claudeDesktop.routeMapHint", {
defaultValue:
"左侧是 Claude Desktop 看到的 claude-* 模型名,右侧是真实发送给供应商的上游模型名。",
})}
</p>
</div>
<div className="hidden grid-cols-[1fr_1fr_140px_92px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
<span>
{t("claudeDesktop.routeModelLabel", {
defaultValue: "Desktop 模型",
})}
</span>
<span>
{t("claudeDesktop.upstreamModelLabel", {
defaultValue: "上游模型",
})}
</span>
<span>
{t("claudeDesktop.displayNameLabel", {
defaultValue: "显示名",
})}
</span>
<span>
{t("claudeDesktop.supports1mLabel", {
defaultValue: "1M",
})}
</span>
<span />
</div>
{routes.map((route, index) => (
<div
key={`${route.route}-${index}`}
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_140px_92px_36px]"
>
<Input
value={route.route}
onChange={(event) =>
updateRoute(index, { route: event.target.value })
}
placeholder="claude-sonnet-4-6"
/>
<Input
value={route.model}
onChange={(event) =>
updateRoute(index, { model: event.target.value })
}
list="claude-desktop-upstream-models"
placeholder="kimi-k2 / deepseek-chat"
/>
<Input
value={route.displayName}
onChange={(event) =>
updateRoute(index, { displayName: event.target.value })
}
placeholder="Sonnet"
/>
<label className="flex h-9 items-center gap-2 text-sm text-muted-foreground">
<Checkbox
checked={route.supports1m}
onCheckedChange={(checked) =>
updateRoute(index, { supports1m: checked === true })
}
/>
1M
</label>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() =>
setRoutes((current) =>
current.filter((_, i) => i !== index),
)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
onClick={() =>
setRoutes((current) => [
...current,
nextRouteRow(current, defaultProxyRouteRows),
])
}
>
<Plus className="mr-2 h-4 w-4" />
{t("claudeDesktop.addRoute", { defaultValue: "添加路由" })}
</Button>
</div>
)}
{!needsModelMapping && (
<Collapsible
open={directModelsExpanded}
onOpenChange={setDirectModelsExpanded}
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant={null}
size="sm"
className="h-8 gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70"
>
{directModelsExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t("claudeDesktop.directModelListTitle", {
defaultValue:
"手动指定 Claude Desktop 模型列表(高级,可选)",
})}
</Button>
</CollapsibleTrigger>
{!directModelsExpanded && (
<p className="ml-1 mt-1 text-xs text-muted-foreground">
{t("claudeDesktop.directModelListCollapsedHint", {
defaultValue:
"原生 Claude 模型供应商通常不用填写,Claude Desktop 会自动读取 /v1/models。",
})}
</p>
)}
<CollapsibleContent className="space-y-4 pt-2">
<div className="space-y-4 rounded-lg border border-border-default p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<p className="text-xs leading-relaxed text-muted-foreground">
{t("claudeDesktop.directModelListHint", {
defaultValue:
"仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 claude-* 模型名时填写;这些模型名会原样发送给供应商。",
})}
</p>
<Button
type="button"
variant="outline"
onClick={handleFetchModels}
disabled={isFetchingModels}
>
{isFetchingModels ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{t("providerForm.fetchModels", {
defaultValue: "获取模型",
})}
</Button>
</div>
<datalist id="claude-desktop-direct-models">
{fetchedModels.map((model) => (
<option key={model.id} value={model.id} />
))}
</datalist>
{routes.length > 0 ? (
<div className="space-y-2">
{routes.map((route, index) => (
<div
key={`${route.route}-${index}`}
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_92px_36px]"
>
<Input
value={route.route}
onChange={(event) =>
updateRoute(index, { route: event.target.value })
}
list="claude-desktop-direct-models"
placeholder="claude-deepseek-chat"
/>
<label className="flex h-9 items-center gap-2 text-sm text-muted-foreground">
<Checkbox
checked={route.supports1m}
onCheckedChange={(checked) =>
updateRoute(index, {
supports1m: checked === true,
})
}
/>
1M
</label>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() =>
setRoutes((current) =>
current.filter((_, i) => i !== index),
)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : null}
<Button
type="button"
variant="outline"
onClick={() =>
setRoutes((current) => [
...current,
{
route: "",
model: "",
displayName: "",
supports1m: false,
},
])
}
>
<Plus className="mr-2 h-4 w-4" />
{t("claudeDesktop.addModel", { defaultValue: "添加模型" })}
</Button>
</div>
</CollapsibleContent>
</Collapsible>
)}
<FormField
control={form.control}
name="settingsConfig"
render={() => (
<FormItem className="space-y-0">
<FormControl>
<input type="hidden" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showButtons && (
<div className="flex justify-end gap-2">
<Button variant="outline" type="button" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{submitLabel}
</Button>
</div>
)}
</form>
</Form>
);
}
@@ -103,6 +103,7 @@ interface ClaudeFormFieldsProps {
onCustomEndpointsChange?: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
showEndpointTools?: boolean;
// Model Selector
shouldShowModelSelector: boolean;
@@ -167,6 +168,7 @@ export function ClaudeFormFields({
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
showEndpointTools = true,
shouldShowModelSelector,
claudeModel,
defaultHaikuModel,
@@ -464,15 +466,18 @@ export function ClaudeFormFields({
? t("providerForm.fullUrlHintGeminiNative")
: undefined
}
onManageClick={() => onEndpointModalToggle(true)}
showFullUrlToggle={true}
showManageButton={showEndpointTools}
onManageClick={
showEndpointTools ? () => onEndpointModalToggle(true) : undefined
}
showFullUrlToggle={showEndpointTools}
isFullUrl={isFullUrl}
onFullUrlChange={onFullUrlChange}
/>
)}
{/* 端点测速弹窗 */}
{shouldShowSpeedTest && isEndpointModalOpen && (
{shouldShowSpeedTest && showEndpointTools && isEndpointModalOpen && (
<EndpointSpeedTest
appId="claude"
providerId={providerId}
@@ -12,6 +12,7 @@ import type { CustomEndpoint, EndpointCandidate } from "@/types";
const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
codex: 12,
claude: 8,
"claude-desktop": 8,
gemini: 8,
opencode: 8,
openclaw: 8,
@@ -59,6 +59,7 @@ import { Label } from "@/components/ui/label";
import { ProviderPresetSelector } from "./ProviderPresetSelector";
import { BasicFormFields } from "./BasicFormFields";
import { ClaudeFormFields } from "./ClaudeFormFields";
import { ClaudeDesktopProviderForm } from "./ClaudeDesktopProviderForm";
import { CodexFormFields } from "./CodexFormFields";
import { GeminiFormFields } from "./GeminiFormFields";
import { OmoFormFields } from "./OmoFormFields";
@@ -115,7 +116,7 @@ type PresetEntry = {
| HermesProviderPreset;
};
interface ProviderFormProps {
export interface ProviderFormProps {
appId: AppId;
providerId?: string;
submitLabel: string;
@@ -137,7 +138,15 @@ interface ProviderFormProps {
showButtons?: boolean;
}
export function ProviderForm({
export function ProviderForm(props: ProviderFormProps) {
if (props.appId === "claude-desktop") {
return <ClaudeDesktopProviderForm {...props} />;
}
return <ProviderFormFull {...props} />;
}
function ProviderFormFull({
appId,
providerId,
submitLabel,
@@ -149,6 +158,10 @@ export function ProviderForm({
initialData,
showButtons = true,
}: ProviderFormProps) {
if (appId === "claude-desktop") {
throw new Error("ProviderFormFull should not receive claude-desktop");
}
const { t } = useTranslation();
const isEditMode = Boolean(initialData);
const queryClient = useQueryClient();
@@ -1165,6 +1178,7 @@ export function ProviderForm({
? useGeminiCommonConfigFlag
: undefined,
endpointAutoSelect,
claudeDesktopMode: undefined,
// 保存 providerType(用于识别 Copilot / Codex OAuth 等特殊供应商)
providerType,
authBinding: isCopilotProvider
@@ -1797,6 +1811,7 @@ export function ProviderForm({
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
showEndpointTools
shouldShowModelSelector={category !== "official"}
claudeModel={claudeModel}
defaultHaikuModel={defaultHaikuModel}
@@ -4,6 +4,16 @@ import type { PricingModelSourceOption } from "../ProviderAdvancedConfig";
// ── Default configs ──────────────────────────────────────────────────
export const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
export const CLAUDE_DESKTOP_DEFAULT_CONFIG = JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: "",
ANTHROPIC_AUTH_TOKEN: "",
},
},
null,
2,
);
export const CODEX_DEFAULT_CONFIG = JSON.stringify(
{ auth: {}, config: "" },
null,
@@ -32,9 +32,9 @@ export function useBaseUrlState({
const [geminiBaseUrl, setGeminiBaseUrl] = useState("");
const isUpdatingRef = useRef(false);
// 从配置同步到 stateClaude
// 从配置同步到 stateClaude / Claude Desktop
useEffect(() => {
if (appType !== "claude") return;
if (appType !== "claude" && appType !== "claude-desktop") return;
// 只有 official 类别不显示 Base URL 输入框,其他类别都需要回填
if (category === "official") return;
if (isUpdatingRef.current) return;
@@ -0,0 +1,96 @@
import { Loader2, Radio } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { cn } from "@/lib/utils";
interface ClaudeDesktopRouteToggleProps {
className?: string;
}
export function ClaudeDesktopRouteToggle({
className,
}: ClaudeDesktopRouteToggleProps) {
const { t } = useTranslation();
const {
isRunning,
status,
takeoverStatus,
startProxyServer,
stopProxyServer,
isStarting,
isStoppingServer,
} = useProxyStatus();
const isBusy = isStarting || isStoppingServer;
const otherTakeoverActive = Boolean(
takeoverStatus?.claude || takeoverStatus?.codex || takeoverStatus?.gemini,
);
const routeAddress = status?.address ?? "127.0.0.1";
const routePort = status?.port ?? 15721;
const handleToggle = async (checked: boolean) => {
try {
if (checked) {
await startProxyServer();
return;
}
if (otherTakeoverActive) {
toast.warning(
t("claudeDesktop.route.stopBlockedByTakeover", {
defaultValue:
"其它应用正在使用代理接管。请先在设置中关闭对应应用接管,再停止本地路由。",
}),
{ duration: 5000 },
);
return;
}
await stopProxyServer();
} catch (error) {
console.error("[ClaudeDesktopRouteToggle] Toggle route failed:", error);
}
};
const tooltipText = isRunning
? t("claudeDesktop.route.tooltip.active", {
address: routeAddress,
port: routePort,
defaultValue: `Claude Desktop 本地路由已开启 - ${routeAddress}:${routePort}`,
})
: t("claudeDesktop.route.tooltip.inactive", {
address: routeAddress,
port: routePort,
defaultValue: `开启 Claude Desktop 本地路由,用于需要模型映射或格式转换的供应商。当前配置地址:${routeAddress}:${routePort}`,
});
return (
<div
className={cn(
"flex items-center gap-1 px-1.5 h-8 rounded-lg bg-muted/50 transition-all",
className,
)}
title={tooltipText}
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Radio
className={cn(
"h-4 w-4 transition-colors",
isRunning
? "text-emerald-500 animate-pulse"
: "text-muted-foreground",
)}
/>
)}
<Switch
checked={isRunning}
onCheckedChange={handleToggle}
disabled={isBusy}
/>
</div>
);
}
@@ -17,6 +17,11 @@ const APP_CONFIG: Array<{
nameKey: string;
}> = [
{ id: "claude", icon: "claude", nameKey: "apps.claude" },
{
id: "claude-desktop",
icon: "claude",
nameKey: "apps.claudeDesktop",
},
{ id: "codex", icon: "openai", nameKey: "apps.codex" },
{ id: "gemini", icon: "gemini", nameKey: "apps.gemini" },
{ id: "opencode", icon: "opencode", nameKey: "apps.opencode" },
@@ -32,6 +37,7 @@ export function AppVisibilitySettings({
const visibleApps: VisibleApps = settings.visibleApps ?? {
claude: true,
"claude-desktop": true,
codex: true,
gemini: true,
opencode: true,
@@ -114,7 +120,7 @@ function AppButton({
size="sm"
variant={active ? "default" : "ghost"}
className={cn(
"w-[90px] gap-1.5",
"min-w-[90px] w-auto gap-1.5 px-3",
active
? "shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
@@ -6,6 +6,8 @@ import { useTranslation } from "react-i18next";
import type { AppId } from "@/lib/api";
import type { ResolvedDirectories } from "@/hooks/useSettings";
type DirectoryAppId = Exclude<AppId, "claude-desktop">;
interface DirectorySettingsProps {
appConfigDir?: string;
resolvedDirs: ResolvedDirectories;
@@ -18,9 +20,9 @@ interface DirectorySettingsProps {
opencodeDir?: string;
openclawDir?: string;
hermesDir?: string;
onDirectoryChange: (app: AppId, value?: string) => void;
onBrowseDirectory: (app: AppId) => Promise<void>;
onResetDirectory: (app: AppId) => Promise<void>;
onDirectoryChange: (app: DirectoryAppId, value?: string) => void;
onBrowseDirectory: (app: DirectoryAppId) => Promise<void>;
onResetDirectory: (app: DirectoryAppId) => Promise<void>;
}
export function DirectorySettings({
@@ -115,6 +115,7 @@ const UnifiedSkillsPanel = React.forwardRef<
const enabledCounts = useMemo(() => {
const counts = {
claude: 0,
"claude-desktop": 0,
codex: 0,
gemini: 0,
opencode: 0,
+9
View File
@@ -17,6 +17,7 @@ export interface AppConfig {
export const APP_IDS: AppId[] = [
"claude",
"claude-desktop",
"codex",
"gemini",
"opencode",
@@ -45,6 +46,14 @@ export const APP_ICON_MAP: Record<AppId, AppConfig> = {
badgeClass:
"bg-orange-500/10 text-orange-700 dark:text-orange-300 hover:bg-orange-500/20 border-0 gap-1.5",
},
"claude-desktop": {
label: "Claude Desktop",
icon: <ClaudeIcon size={14} />,
activeClass:
"bg-amber-500/10 ring-1 ring-amber-500/20 hover:bg-amber-500/20 text-amber-700 dark:text-amber-300",
badgeClass:
"bg-amber-500/10 text-amber-700 dark:text-amber-300 hover:bg-amber-500/20 border-0 gap-1.5",
},
codex: {
label: "Codex",
icon: <CodexIcon size={14} />,
+9 -8
View File
@@ -5,6 +5,7 @@ import { homeDir, join } from "@tauri-apps/api/path";
import { settingsApi, type AppId } from "@/lib/api";
import type { SettingsFormState } from "./useSettingsForm";
export type DirectoryAppId = Exclude<AppId, "claude-desktop">;
type AppDirectoryKey =
| "claude"
| "codex"
@@ -26,7 +27,7 @@ export interface ResolvedDirectories {
// Single source of truth for per-app directory metadata.
const APP_DIRECTORY_META: Record<
AppId,
DirectoryAppId,
{ key: AppDirectoryKey; defaultFolder: string }
> = {
claude: { key: "claude", defaultFolder: ".claude" },
@@ -69,7 +70,7 @@ const computeDefaultAppConfigDir = async (): Promise<string | undefined> => {
};
const computeDefaultConfigDir = async (
app: AppId,
app: DirectoryAppId,
): Promise<string | undefined> => {
try {
const home = await homeDir();
@@ -93,11 +94,11 @@ export interface UseDirectorySettingsResult {
resolvedDirs: ResolvedDirectories;
isLoading: boolean;
initialAppConfigDir?: string;
updateDirectory: (app: AppId, value?: string) => void;
updateDirectory: (app: DirectoryAppId, value?: string) => void;
updateAppConfigDir: (value?: string) => void;
browseDirectory: (app: AppId) => Promise<void>;
browseDirectory: (app: DirectoryAppId) => Promise<void>;
browseAppConfigDir: () => Promise<void>;
resetDirectory: (app: AppId) => Promise<void>;
resetDirectory: (app: DirectoryAppId) => Promise<void>;
resetAppConfigDir: () => Promise<void>;
resetAllDirectories: (overrides?: ResolvedAppDirectoryOverrides) => void;
}
@@ -259,14 +260,14 @@ export function useDirectorySettings({
);
const updateDirectory = useCallback(
(app: AppId, value?: string) => {
(app: DirectoryAppId, value?: string) => {
updateDirectoryState(APP_DIRECTORY_META[app].key, value);
},
[updateDirectoryState],
);
const browseDirectory = useCallback(
async (app: AppId) => {
async (app: DirectoryAppId) => {
const key = APP_DIRECTORY_META[app].key;
const settingsField = DIRECTORY_KEY_TO_SETTINGS_FIELD[key];
const currentValue =
@@ -310,7 +311,7 @@ export function useDirectorySettings({
}, [appConfigDir, resolvedDirs.appConfig, t, updateDirectoryState]);
const resetDirectory = useCallback(
async (app: AppId) => {
async (app: DirectoryAppId) => {
const key = APP_DIRECTORY_META[app].key;
if (!defaultsRef.current[key]) {
const fallback = await computeDefaultConfigDir(app);
+23 -6
View File
@@ -171,6 +171,13 @@ export function useProviderActions(
proxyRequiredReason = t("notifications.proxyReasonOpenAIResponses", {
defaultValue: "使用 OpenAI Responses 接口格式",
});
} else if (
activeApp === "claude-desktop" &&
provider.meta?.claudeDesktopMode === "proxy"
) {
proxyRequiredReason = t("notifications.proxyReasonClaudeDesktop", {
defaultValue: "使用 Claude Desktop 本地路由模式",
});
} else if (
provider.meta?.isFullUrl &&
(activeApp === "claude" || activeApp === "codex")
@@ -223,12 +230,22 @@ export function useProviderActions(
// OpenCode/OpenClaw: show "added to config" message instead of "switched"
const isMultiProviderApp =
activeApp === "opencode" || activeApp === "openclaw";
const messageKey = isMultiProviderApp
? "notifications.addToConfigSuccess"
: "notifications.switchSuccess";
const defaultMessage = isMultiProviderApp
? "已添加到配置"
: "切换成功!";
const messageKey =
activeApp === "claude-desktop"
? provider.meta?.claudeDesktopMode === "proxy"
? "notifications.claudeDesktopProxyRestartRequired"
: "notifications.claudeDesktopRestartRequired"
: isMultiProviderApp
? "notifications.addToConfigSuccess"
: "notifications.switchSuccess";
const defaultMessage =
activeApp === "claude-desktop"
? provider.meta?.claudeDesktopMode === "proxy"
? "切换成功,请保持 CC Switch 运行,并重启 Claude Desktop 后生效"
: "切换成功,重启 Claude Desktop 后生效"
: isMultiProviderApp
? "已添加到配置"
: "切换成功!";
toast.success(t(messageKey, { defaultValue: defaultMessage }), {
closeButton: true,
+28
View File
@@ -64,6 +64,31 @@ export function useProxyStatus() {
},
});
// 停止服务器(仅停止服务,不改写/恢复其它应用接管状态)
const stopProxyServerMutation = useMutation({
mutationFn: () => invoke("stop_proxy_server"),
onSuccess: () => {
toast.success(
t("proxy.server.stopped", {
defaultValue: "代理服务已停止",
}),
{ closeButton: true },
);
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
},
onError: (error: Error) => {
const detail =
extractErrorMessage(error) ||
t("common.unknown", { defaultValue: "未知错误" });
toast.error(
t("proxy.server.stopFailed", {
detail,
defaultValue: `停止代理服务失败: ${detail}`,
}),
);
},
});
// 停止服务器(总开关关闭:强制恢复所有已接管的 Live 配置)
const stopWithRestoreMutation = useMutation({
mutationFn: () => invoke("stop_proxy_with_restore"),
@@ -194,6 +219,7 @@ export function useProxyStatus() {
// 启动/停止(总开关)
startProxyServer: startProxyServerMutation.mutateAsync,
stopProxyServer: stopProxyServerMutation.mutateAsync,
stopWithRestore: stopWithRestoreMutation.mutateAsync,
// 按应用接管开关
@@ -208,9 +234,11 @@ export function useProxyStatus() {
// 加载状态
isStarting: startProxyServerMutation.isPending,
isStoppingServer: stopProxyServerMutation.isPending,
isStopping: stopWithRestoreMutation.isPending,
isPending:
startProxyServerMutation.isPending ||
stopProxyServerMutation.isPending ||
stopWithRestoreMutation.isPending ||
setTakeoverForAppMutation.isPending,
};
+5 -4
View File
@@ -2,13 +2,14 @@ import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { providersApi, settingsApi, type AppId } from "@/lib/api";
import { providersApi, settingsApi } from "@/lib/api";
import { syncCurrentProvidersLiveSafe } from "@/utils/postChangeSync";
import { useSettingsQuery, useSaveSettingsMutation } from "@/lib/query";
import type { Settings } from "@/types";
import { useSettingsForm, type SettingsFormState } from "./useSettingsForm";
import {
useDirectorySettings,
type DirectoryAppId,
type ResolvedDirectories,
} from "./useDirectorySettings";
import { useSettingsMetadata } from "./useSettingsMetadata";
@@ -28,11 +29,11 @@ export interface UseSettingsResult {
resolvedDirs: ResolvedDirectories;
requiresRestart: boolean;
updateSettings: (updates: Partial<SettingsFormState>) => void;
updateDirectory: (app: AppId, value?: string) => void;
updateDirectory: (app: DirectoryAppId, value?: string) => void;
updateAppConfigDir: (value?: string) => void;
browseDirectory: (app: AppId) => Promise<void>;
browseDirectory: (app: DirectoryAppId) => Promise<void>;
browseAppConfigDir: () => Promise<void>;
resetDirectory: (app: AppId) => Promise<void>;
resetDirectory: (app: DirectoryAppId) => Promise<void>;
resetAppConfigDir: () => Promise<void>;
saveSettings: (
overrides?: Partial<SettingsFormState>,
+41
View File
@@ -104,6 +104,7 @@
"noProvidersDescription": "If you already have a config, click \"Import Current Config\" — all data will be safely saved in a default provider",
"noProvidersDescriptionSnippet": "Settings other than API key and endpoint (e.g. plugins) will be saved to a common config snippet for sharing across providers",
"importCurrent": "Import Current Config",
"importFromClaude": "Import Compatible Providers from Claude",
"importCurrentDescription": "Import current live config as default provider",
"currentlyUsing": "Currently Using",
"enable": "Enable",
@@ -172,11 +173,50 @@
}
}
},
"claudeDesktop": {
"mode": "Model handling",
"modeDirect": "Direct",
"modeProxy": "Model mapping",
"gatewayBaseUrl": "Gateway Base URL",
"bearerToken": "Bearer Token",
"modelMappingToggle": "Needs model mapping",
"modelMappingOffHint": "Use this when the provider already exposes and accepts claude-* / anthropic/claude-* model IDs through Anthropic Messages. Claude Desktop connects to the provider directly.",
"modelMappingOnHint": "Claude Desktop only sees claude-* route names. CC Switch local routing maps them to the real upstream models, so local routing must stay running.",
"routeMapTitle": "Model mapping",
"routeMapHint": "The left side is the claude-* model ID shown to Claude Desktop; the right side is the real upstream model sent to the provider. Checking 1M appends [1M] to both model IDs.",
"routeModelLabel": "Desktop model",
"upstreamModelLabel": "Upstream model",
"displayNameLabel": "Display name",
"supports1mLabel": "1M",
"directModelListTitle": "Manually specify Claude Desktop models (advanced, optional)",
"directModelListCollapsedHint": "Native Claude model providers usually do not need this. Claude Desktop will fetch /v1/models automatically.",
"directModelListHint": "Only fill this when the provider's /v1/models is unavailable or does not return Claude Desktop-safe claude-* model IDs. Checking 1M writes the [1M] marker after the model ID.",
"directModelInvalid": "Direct models must use claude-* / anthropic/claude-* model IDs",
"addModel": "Add model",
"addRoute": "Add route",
"routeInvalid": "Model mapping requires a claude-* / anthropic/claude-* route name and an upstream model name",
"routesRequired": "At least one model route is required when model mapping is enabled",
"statusTitle": "Claude Desktop configuration needs attention",
"statusUnsupported": "This platform does not support writing Claude Desktop 3P configuration yet.",
"statusStaleRawModels": "The Claude Desktop profile contains non-claude-* model IDs. Newer Claude Desktop versions may reject it; switch to the current provider again to repair it.",
"statusMissingRouteMappings": "The current provider has model mapping enabled but no valid routes. Edit the provider and add at least one mapping.",
"statusProxyStopped": "The current provider needs CC Switch local routing, but local routing is not running. Turn on the route switch in the top-right, then restart Claude Desktop.",
"statusGatewayTokenMissing": "The local routing token has not been generated yet. Switching to this provider again will write a new local token.",
"statusBaseUrlMismatch": "The Claude Desktop profile points to a different URL than the current provider. Current: {{actual}}; expected: {{expected}}. Switch to the current provider again to repair it.",
"route": {
"tooltip": {
"active": "Claude Desktop local routing is running - {{address}}:{{port}}",
"inactive": "Turn on Claude Desktop local routing for providers that need model mapping or format conversion. Configured address: {{address}}:{{port}}"
}
}
},
"notifications": {
"providerAdded": "Provider added",
"providerSaved": "Provider configuration saved",
"providerDeleted": "Provider deleted successfully",
"switchSuccess": "Switch successful!",
"claudeDesktopRestartRequired": "Switched successfully. Restart Claude Desktop to apply changes.",
"claudeDesktopProxyRestartRequired": "Switched successfully. Keep CC Switch running and restart Claude Desktop to apply changes.",
"addToConfigSuccess": "Added to config",
"removeFromConfigSuccess": "Removed from config",
"switchFailedTitle": "Switch failed",
@@ -661,6 +701,7 @@
},
"apps": {
"claude": "Claude",
"claudeDesktop": "Claude Desktop",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
+41
View File
@@ -104,6 +104,7 @@
"noProvidersDescription": "既存の設定がある場合は「現在の設定をインポート」をクリックしてください。すべてのデータが default プロバイダーに安全に保存されます",
"noProvidersDescriptionSnippet": "API キーとリクエスト URL 以外のデータ(プラグインなど)は共通設定スニペットに保存され、プロバイダー間で共有できます",
"importCurrent": "現在の設定をインポート",
"importFromClaude": "Claude から互換プロバイダーをインポート",
"importCurrentDescription": "現在使用中の設定をデフォルトプロバイダーとしてインポート",
"currentlyUsing": "現在使用中",
"enable": "有効化",
@@ -172,11 +173,50 @@
}
}
},
"claudeDesktop": {
"mode": "モデル処理方式",
"modeDirect": "直結",
"modeProxy": "モデルマッピング",
"gatewayBaseUrl": "Gateway Base URL",
"bearerToken": "Bearer Token",
"modelMappingToggle": "モデルマッピングが必要",
"modelMappingOffHint": "プロバイダーが Anthropic Messages で claude-* / anthropic/claude-* のモデル ID を公開し、そのまま受け付ける場合に使います。Claude Desktop はプロバイダーへ直接接続します。",
"modelMappingOnHint": "Claude Desktop には claude-* のルート名だけを見せ、CC Switch のローカルルーティングが実際の上流モデルへマッピングします。ローカルルーティングを起動したままにしてください。",
"routeMapTitle": "モデルマッピング",
"routeMapHint": "左側は Claude Desktop に表示する claude-* モデル ID、右側はプロバイダーへ送る実際の上流モデルです。1M をオンにすると両方のモデル ID に [1M] を追加します。",
"routeModelLabel": "Desktop モデル",
"upstreamModelLabel": "上流モデル",
"displayNameLabel": "表示名",
"supports1mLabel": "1M",
"directModelListTitle": "Claude Desktop モデルを手動指定(高度・任意)",
"directModelListCollapsedHint": "ネイティブ Claude モデルのプロバイダーでは通常不要です。Claude Desktop が /v1/models を自動取得します。",
"directModelListHint": "プロバイダーの /v1/models が使えない、または Claude Desktop が認識できる claude-* モデル ID を返さない場合だけ入力してください。1M をオンにするとモデル ID の後ろに [1M] マーカーを書き込みます。",
"directModelInvalid": "直結モデルは claude-* / anthropic/claude-* のモデル ID を使う必要があります",
"addModel": "モデルを追加",
"addRoute": "ルートを追加",
"routeInvalid": "モデルマッピングには claude-* / anthropic/claude-* のルート名と上流モデル名が必要です",
"routesRequired": "モデルマッピングを有効にする場合は、少なくとも 1 つのモデルルートが必要です",
"statusTitle": "Claude Desktop 設定の確認が必要です",
"statusUnsupported": "このプラットフォームでは Claude Desktop 3P 設定の書き込みはまだサポートされていません。",
"statusStaleRawModels": "Claude Desktop profile に claude-* ではないモデル ID が含まれています。新しい Claude Desktop では拒否される可能性があります。現在のプロバイダーへ再度切り替えると修復できます。",
"statusMissingRouteMappings": "現在のプロバイダーはモデルマッピングを有効にしていますが、有効なルートがありません。プロバイダーを編集し、少なくとも 1 つのマッピングを追加してください。",
"statusProxyStopped": "現在のプロバイダーには CC Switch のローカルルーティングが必要ですが、起動していません。右上のルートスイッチをオンにしてから Claude Desktop を再起動してください。",
"statusGatewayTokenMissing": "ローカルルーティング token がまだ生成されていません。このプロバイダーへ再度切り替えると新しい token が書き込まれます。",
"statusBaseUrlMismatch": "Claude Desktop profile の URL が現在のプロバイダーと一致しません。現在: {{actual}}、期待値: {{expected}}。現在のプロバイダーへ再度切り替えると修復できます。",
"route": {
"tooltip": {
"active": "Claude Desktop ローカルルーティングは起動中です - {{address}}:{{port}}",
"inactive": "モデルマッピングや形式変換が必要なプロバイダー向けに Claude Desktop ローカルルーティングを起動します。設定アドレス: {{address}}:{{port}}"
}
}
},
"notifications": {
"providerAdded": "プロバイダーを追加しました",
"providerSaved": "プロバイダー設定を保存しました",
"providerDeleted": "プロバイダーを削除しました",
"switchSuccess": "切り替え成功!",
"claudeDesktopRestartRequired": "切り替えました。反映するには Claude Desktop を再起動してください",
"claudeDesktopProxyRestartRequired": "切り替えました。CC Switch を起動したまま Claude Desktop を再起動してください",
"addToConfigSuccess": "設定に追加しました",
"removeFromConfigSuccess": "設定から削除しました",
"switchFailedTitle": "切り替えに失敗しました",
@@ -661,6 +701,7 @@
},
"apps": {
"claude": "Claude",
"claudeDesktop": "Claude Desktop",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
+41
View File
@@ -104,6 +104,7 @@
"noProvidersDescription": "如果你已有配置,请点击\"导入当前配置\",所有数据将安全保存在 default 供应商中",
"noProvidersDescriptionSnippet": "除 Key 和请求地址外的数据(如插件配置)会被保存到通用配置片段,用于在不同供应商之间共享",
"importCurrent": "导入当前配置",
"importFromClaude": "从 Claude 导入兼容供应商",
"importCurrentDescription": "将当前正在使用的配置导入为默认供应商",
"currentlyUsing": "当前使用",
"enable": "启用",
@@ -172,11 +173,50 @@
}
}
},
"claudeDesktop": {
"mode": "模型处理方式",
"modeDirect": "直连",
"modeProxy": "模型映射",
"gatewayBaseUrl": "Gateway Base URL",
"bearerToken": "Bearer Token",
"modelMappingToggle": "需要模型映射",
"modelMappingOffHint": "适合供应商已经暴露并接受 claude-* / anthropic/claude-* 模型名的 Anthropic Messages API;请求会由 Claude Desktop 直连供应商。",
"modelMappingOnHint": "Claude Desktop 只看到 claude-* 路由名,CC Switch 本地路由会映射到真实上游模型;需要保持本地路由运行。",
"routeMapTitle": "模型映射",
"routeMapHint": "左侧是 Claude Desktop 看到的 claude-* 模型名,右侧是真实发送给供应商的上游模型名;勾选 1M 会在两侧模型名后追加 [1M]。",
"routeModelLabel": "Desktop 模型",
"upstreamModelLabel": "上游模型",
"displayNameLabel": "显示名",
"supports1mLabel": "1M",
"directModelListTitle": "手动指定 Claude Desktop 模型列表(高级,可选)",
"directModelListCollapsedHint": "原生 Claude 模型供应商通常不用填写,Claude Desktop 会自动读取 /v1/models。",
"directModelListHint": "仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 claude-* 模型名时填写;勾选 1M 会在模型名后写入 [1M] 标记。",
"directModelInvalid": "直连模型必须使用 claude-* / anthropic/claude-* 模型名",
"addModel": "添加模型",
"addRoute": "添加路由",
"routeInvalid": "模型映射必须填写 claude-* / anthropic/claude-* 路由名和上游模型名",
"routesRequired": "需要模型映射时至少填写一个模型路由",
"statusTitle": "Claude Desktop 配置需要检查",
"statusUnsupported": "当前平台暂不支持 Claude Desktop 3P 配置写入。",
"statusStaleRawModels": "Claude Desktop profile 中存在非 claude-* 模型名,新版 Claude Desktop 可能拒绝加载;重新切换当前供应商可修复。",
"statusMissingRouteMappings": "当前供应商启用了模型映射,但没有有效路由;请编辑供应商并补全至少一个模型映射。",
"statusProxyStopped": "当前供应商需要 CC Switch 本地路由,但本地路由未运行;开启右上角路由开关后再重启 Claude Desktop。",
"statusGatewayTokenMissing": "当前本地路由 token 尚未生成;重新切换该供应商会写入新的本地 token。",
"statusBaseUrlMismatch": "Claude Desktop profile 指向的地址与当前供应商不一致;当前为 {{actual}},应为 {{expected}}。重新切换当前供应商可修复。",
"route": {
"tooltip": {
"active": "Claude Desktop 本地路由已开启 - {{address}}:{{port}}",
"inactive": "开启 Claude Desktop 本地路由,用于需要模型映射或格式转换的供应商。当前配置地址:{{address}}:{{port}}"
}
}
},
"notifications": {
"providerAdded": "供应商已添加",
"providerSaved": "供应商配置已保存",
"providerDeleted": "供应商删除成功",
"switchSuccess": "切换成功!",
"claudeDesktopRestartRequired": "切换成功,重启 Claude Desktop 后生效",
"claudeDesktopProxyRestartRequired": "切换成功,请保持 CC Switch 运行,并重启 Claude Desktop 后生效",
"addToConfigSuccess": "已添加到配置",
"removeFromConfigSuccess": "已从配置移除",
"switchFailedTitle": "切换失败",
@@ -661,6 +701,7 @@
},
"apps": {
"claude": "Claude",
"claudeDesktop": "Claude Desktop",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
+34
View File
@@ -25,6 +25,28 @@ export interface OpenTerminalOptions {
cwd?: string;
}
export interface ClaudeDesktopStatus {
supported: boolean;
configured: boolean;
appliedId?: string | null;
profilePath?: string | null;
configLibraryPath?: string | null;
mode?: "direct" | "proxy" | null;
expectedBaseUrl?: string | null;
actualBaseUrl?: string | null;
proxyRunning: boolean;
staleRawModels: boolean;
missingRouteMappings: boolean;
gatewayTokenConfigured: boolean;
}
export interface ClaudeDesktopDefaultRoute {
routeId: string;
envKey: string;
displayName: string;
supports1m: boolean;
}
export const providersApi = {
async getAll(appId: AppId): Promise<Record<string, Provider>> {
return await invoke("get_providers", { app: appId });
@@ -74,6 +96,18 @@ export const providersApi = {
return await invoke("import_default_config", { app: appId });
},
async importClaudeDesktopFromClaude(): Promise<number> {
return await invoke("import_claude_desktop_providers_from_claude");
},
async getClaudeDesktopStatus(): Promise<ClaudeDesktopStatus> {
return await invoke("get_claude_desktop_status");
},
async getClaudeDesktopDefaultRoutes(): Promise<ClaudeDesktopDefaultRoute[]> {
return await invoke("get_claude_desktop_default_routes");
},
async updateTrayMenu(): Promise<boolean> {
return await invoke("update_tray_menu");
},
+2
View File
@@ -4,6 +4,7 @@ import type { AppId } from "@/lib/api/types";
export type AppType =
| "claude"
| "claude-desktop"
| "codex"
| "gemini"
| "opencode"
@@ -13,6 +14,7 @@ export type AppType =
/** Skill 应用启用状态 */
export interface SkillApps {
claude: boolean;
"claude-desktop"?: boolean;
codex: boolean;
gemini: boolean;
opencode: boolean;
+1
View File
@@ -1,6 +1,7 @@
// 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致)
export type AppId =
| "claude"
| "claude-desktop"
| "codex"
| "gemini"
| "opencode"
+6
View File
@@ -232,6 +232,12 @@ export const useSwitchProviderMutation = (appId: AppId) => {
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["providers", appId] });
if (appId === "claude-desktop") {
await queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
await queryClient.invalidateQueries({
queryKey: ["claudeDesktopStatus"],
});
}
// OpenCode/OpenClaw: also invalidate live provider IDs cache to update button state
if (appId === "opencode") {
+1
View File
@@ -26,6 +26,7 @@ export const settingsSchema = z.object({
// 当前供应商 ID(设备级)
currentProviderClaude: z.string().optional(),
currentProviderClaudeDesktop: z.string().optional(),
currentProviderCodex: z.string().optional(),
currentProviderGemini: z.string().optional(),
+14
View File
@@ -131,12 +131,22 @@ export interface AuthBinding {
accountId?: string;
}
export interface ClaudeDesktopModelRoute {
model: string;
displayName?: string;
supports1m?: boolean;
}
// 供应商元数据(字段名与后端一致,保持 snake_case
export interface ProviderMeta {
// 自定义端点:以 URL 为键,值为端点信息
custom_endpoints?: Record<string, CustomEndpoint>;
// 是否在切换/同步到 live 时应用通用配置片段
commonConfigEnabled?: boolean;
// Claude Desktop 3P 配置写入模式
claudeDesktopMode?: "direct" | "proxy";
// Claude Desktop 本地路由模式:Claude-safe route -> upstream model
claudeDesktopModelRoutes?: Record<string, ClaudeDesktopModelRoute>;
// 用量查询脚本配置
usage_script?: UsageScript;
// 请求地址管理:测速后自动选择最佳端点
@@ -200,6 +210,7 @@ export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
// 主页面显示的应用配置
export interface VisibleApps {
claude: boolean;
"claude-desktop": boolean;
codex: boolean;
gemini: boolean;
opencode: boolean;
@@ -304,6 +315,8 @@ export interface Settings {
// ===== 当前供应商 ID(设备级)=====
// 当前 Claude 供应商 ID(优先于数据库 is_current
currentProviderClaude?: string;
// 当前 Claude Desktop 供应商 ID(优先于数据库 is_current
currentProviderClaudeDesktop?: string;
// 当前 Codex 供应商 ID(优先于数据库 is_current
currentProviderCodex?: string;
// 当前 Gemini 供应商 ID(优先于数据库 is_current
@@ -369,6 +382,7 @@ export interface McpServerSpec {
// v3.7.0: MCP 服务器应用启用状态
export interface McpApps {
claude: boolean;
"claude-desktop"?: boolean;
codex: boolean;
gemini: boolean;
opencode: boolean;
+1
View File
@@ -43,6 +43,7 @@ export interface ProxyServerInfo {
export interface ProxyTakeoverStatus {
claude: boolean;
"claude-desktop"?: boolean;
codex: boolean;
gemini: boolean;
opencode: boolean;