Files
CC-Switch/src/components/settings/DirectorySettings.tsx
T
Jason 8b3ad9caf9 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.
2026-05-08 22:34:46 +08:00

230 lines
6.9 KiB
TypeScript

import { useMemo } from "react";
import { FolderSearch, Undo2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
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;
onAppConfigChange: (value?: string) => void;
onBrowseAppConfig: () => Promise<void>;
onResetAppConfig: () => Promise<void>;
claudeDir?: string;
codexDir?: string;
geminiDir?: string;
opencodeDir?: string;
openclawDir?: string;
hermesDir?: string;
onDirectoryChange: (app: DirectoryAppId, value?: string) => void;
onBrowseDirectory: (app: DirectoryAppId) => Promise<void>;
onResetDirectory: (app: DirectoryAppId) => Promise<void>;
}
export function DirectorySettings({
appConfigDir,
resolvedDirs,
onAppConfigChange,
onBrowseAppConfig,
onResetAppConfig,
claudeDir,
codexDir,
geminiDir,
opencodeDir,
openclawDir,
hermesDir,
onDirectoryChange,
onBrowseDirectory,
onResetDirectory,
}: DirectorySettingsProps) {
const { t } = useTranslation();
return (
<div className="space-y-6">
{/* CC Switch 配置目录 - 独立区块 */}
<section className="space-y-4">
<header className="space-y-1">
<h3 className="text-sm font-medium">{t("settings.appConfigDir")}</h3>
<p className="text-xs text-muted-foreground">
{t("settings.appConfigDirDescription")}
</p>
</header>
<div className="flex items-center gap-2">
<Input
value={appConfigDir ?? resolvedDirs.appConfig ?? ""}
placeholder={t("settings.browsePlaceholderApp")}
className="text-xs"
onChange={(event) => onAppConfigChange(event.target.value)}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={onBrowseAppConfig}
title={t("settings.browseDirectory")}
>
<FolderSearch className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={onResetAppConfig}
title={t("settings.resetDefault")}
>
<Undo2 className="h-4 w-4" />
</Button>
</div>
</section>
{/* Claude/Codex 配置目录 - 独立区块 */}
<section className="space-y-4">
<header className="space-y-1">
<h3 className="text-sm font-medium">
{t("settings.configDirectoryOverride")}
</h3>
<p className="text-xs text-muted-foreground">
{t("settings.configDirectoryDescription")}
</p>
</header>
<DirectoryInput
label={t("settings.claudeConfigDir")}
description={undefined}
value={claudeDir}
resolvedValue={resolvedDirs.claude}
placeholder={t("settings.browsePlaceholderClaude")}
onChange={(val) => onDirectoryChange("claude", val)}
onBrowse={() => onBrowseDirectory("claude")}
onReset={() => onResetDirectory("claude")}
/>
<DirectoryInput
label={t("settings.codexConfigDir")}
description={undefined}
value={codexDir}
resolvedValue={resolvedDirs.codex}
placeholder={t("settings.browsePlaceholderCodex")}
onChange={(val) => onDirectoryChange("codex", val)}
onBrowse={() => onBrowseDirectory("codex")}
onReset={() => onResetDirectory("codex")}
/>
<DirectoryInput
label={t("settings.geminiConfigDir")}
description={undefined}
value={geminiDir}
resolvedValue={resolvedDirs.gemini}
placeholder={t("settings.browsePlaceholderGemini")}
onChange={(val) => onDirectoryChange("gemini", val)}
onBrowse={() => onBrowseDirectory("gemini")}
onReset={() => onResetDirectory("gemini")}
/>
<DirectoryInput
label={t("settings.opencodeConfigDir")}
description={undefined}
value={opencodeDir}
resolvedValue={resolvedDirs.opencode}
placeholder={t("settings.browsePlaceholderOpencode")}
onChange={(val) => onDirectoryChange("opencode", val)}
onBrowse={() => onBrowseDirectory("opencode")}
onReset={() => onResetDirectory("opencode")}
/>
<DirectoryInput
label={t("settings.openclawConfigDir")}
description={undefined}
value={openclawDir}
resolvedValue={resolvedDirs.openclaw}
placeholder={t("settings.browsePlaceholderOpenclaw")}
onChange={(val) => onDirectoryChange("openclaw", val)}
onBrowse={() => onBrowseDirectory("openclaw")}
onReset={() => onResetDirectory("openclaw")}
/>
<DirectoryInput
label={t("settings.hermesConfigDir")}
description={undefined}
value={hermesDir}
resolvedValue={resolvedDirs.hermes}
placeholder={t("settings.browsePlaceholderHermes")}
onChange={(val) => onDirectoryChange("hermes", val)}
onBrowse={() => onBrowseDirectory("hermes")}
onReset={() => onResetDirectory("hermes")}
/>
</section>
</div>
);
}
interface DirectoryInputProps {
label: string;
description?: string;
value?: string;
resolvedValue: string;
placeholder?: string;
onChange: (value?: string) => void;
onBrowse: () => Promise<void>;
onReset: () => Promise<void>;
}
function DirectoryInput({
label,
description,
value,
resolvedValue,
placeholder,
onChange,
onBrowse,
onReset,
}: DirectoryInputProps) {
const { t } = useTranslation();
const displayValue = useMemo(
() => value ?? resolvedValue ?? "",
[value, resolvedValue],
);
return (
<div className="space-y-1.5">
<div className="space-y-1">
<p className="text-xs font-medium text-foreground">{label}</p>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</div>
<div className="flex items-center gap-2">
<Input
value={displayValue}
placeholder={placeholder}
className="text-xs"
onChange={(event) => onChange(event.target.value)}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={onBrowse}
title={t("settings.browseDirectory")}
>
<FolderSearch className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={onReset}
title={t("settings.resetDefault")}
>
<Undo2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}