mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-02 10:21:16 +08:00
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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user