mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-27 08:32:32 +08:00
refactor(claude-desktop): align provider form UI with Claude Code
- Rename field labels: "Gateway Base URL" → "API Endpoint", "Bearer Token" → "API Key" - Change layout from 2-column grid to vertical sections matching Claude Code - Reuse shared EndpointField component with format-aware amber hint box - Replace native <datalist> with vendor-grouped ModelDropdown (OpenCode pattern) - Move Fetch/Add buttons to section headers with compact sm styling - Extract ModelDropdown to shared module, deduplicate from OpenCodeFormFields - Extract renderActionButtons helper to eliminate proxy/direct button duplication - Remove dead i18n keys (gatewayBaseUrl, bearerToken) from all 3 locales
This commit is contained in:
@@ -3,7 +3,14 @@ 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 {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -30,6 +37,8 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { BasicFormFields } from "./BasicFormFields";
|
||||
import { EndpointField } from "./shared/EndpointField";
|
||||
import { ModelDropdown } from "./shared/ModelDropdown";
|
||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||
import type {
|
||||
ClaudeApiFormat,
|
||||
@@ -391,6 +400,36 @@ export function ClaudeDesktopProviderForm({
|
||||
});
|
||||
};
|
||||
|
||||
const renderActionButtons = (onAdd: () => void, addLabel: string) => (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("providerForm.fetchModels", { defaultValue: "获取模型" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAdd}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -400,34 +439,34 @@ export function ClaudeDesktopProviderForm({
|
||||
>
|
||||
<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 className="space-y-1">
|
||||
<Label>{"API Key"}</Label>
|
||||
<Input
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EndpointField
|
||||
id="baseUrl"
|
||||
label={t("providerForm.apiEndpoint")}
|
||||
value={baseUrl}
|
||||
onChange={(v) => setBaseUrl(v)}
|
||||
placeholder={t("providerForm.apiEndpointPlaceholder")}
|
||||
hint={
|
||||
needsModelMapping && apiFormat === "openai_responses"
|
||||
? t("providerForm.apiHintResponses")
|
||||
: needsModelMapping && apiFormat === "openai_chat"
|
||||
? t("providerForm.apiHintOAI")
|
||||
: needsModelMapping && apiFormat === "gemini_native"
|
||||
? t("providerForm.apiHintGeminiNative")
|
||||
: t("providerForm.apiHint")
|
||||
}
|
||||
showManageButton={false}
|
||||
/>
|
||||
|
||||
<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">
|
||||
@@ -460,68 +499,45 @@ export function ClaudeDesktopProviderForm({
|
||||
|
||||
{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 className="space-y-2">
|
||||
<Label>
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</Label>
|
||||
<Select
|
||||
value={apiFormat}
|
||||
onValueChange={(value) =>
|
||||
setApiFormat(value as ClaudeApiFormat)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
@@ -530,6 +546,14 @@ export function ClaudeDesktopProviderForm({
|
||||
defaultValue: "模型映射",
|
||||
})}
|
||||
</Label>
|
||||
{renderActionButtons(
|
||||
() =>
|
||||
setRoutes((current) => [
|
||||
...current,
|
||||
nextRouteRow(current, defaultProxyRouteRows),
|
||||
]),
|
||||
t("claudeDesktop.addRoute", { defaultValue: "添加路由" }),
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("claudeDesktop.routeMapHint", {
|
||||
@@ -574,14 +598,22 @@ export function ClaudeDesktopProviderForm({
|
||||
}
|
||||
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"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={route.model}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, { model: event.target.value })
|
||||
}
|
||||
placeholder="kimi-k2 / deepseek-chat"
|
||||
className="flex-1"
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<ModelDropdown
|
||||
models={fetchedModels}
|
||||
onSelect={(id) => updateRoute(index, { model: id })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={route.displayName}
|
||||
onChange={(event) =>
|
||||
@@ -613,20 +645,6 @@ export function ClaudeDesktopProviderForm({
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -664,33 +682,27 @@ export function ClaudeDesktopProviderForm({
|
||||
<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">
|
||||
<p className="flex-1 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>
|
||||
{renderActionButtons(
|
||||
() =>
|
||||
setRoutes((current) => [
|
||||
...current,
|
||||
{
|
||||
route: "",
|
||||
model: "",
|
||||
displayName: "",
|
||||
supports1m: false,
|
||||
},
|
||||
]),
|
||||
t("claudeDesktop.addModel", { defaultValue: "添加模型" }),
|
||||
)}
|
||||
</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) => (
|
||||
@@ -698,14 +710,24 @@ export function ClaudeDesktopProviderForm({
|
||||
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"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={route.route}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, { route: event.target.value })
|
||||
}
|
||||
placeholder="claude-deepseek-chat"
|
||||
className="flex-1"
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<ModelDropdown
|
||||
models={fetchedModels}
|
||||
onSelect={(id) =>
|
||||
updateRoute(index, { route: id })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex h-9 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={route.supports1m}
|
||||
@@ -733,25 +755,6 @@ export function ClaudeDesktopProviderForm({
|
||||
))}
|
||||
</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>
|
||||
|
||||
@@ -11,23 +11,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
Plus,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ApiKeySection } from "./shared";
|
||||
import { Download, Plus, Trash2, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { ApiKeySection, ModelDropdown } from "./shared";
|
||||
import {
|
||||
fetchModelsForConfig,
|
||||
showFetchModelsError,
|
||||
@@ -157,49 +142,6 @@ function ModelOptionKeyInput({
|
||||
);
|
||||
}
|
||||
|
||||
/** Dropdown button to select from fetched models */
|
||||
function ModelDropdown({
|
||||
models,
|
||||
onSelect,
|
||||
}: {
|
||||
models: FetchedModel[];
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const grouped: Record<string, FetchedModel[]> = {};
|
||||
for (const model of models) {
|
||||
const vendor = model.ownedBy || "Other";
|
||||
if (!grouped[vendor]) grouped[vendor] = [];
|
||||
grouped[vendor].push(model);
|
||||
}
|
||||
const vendors = Object.keys(grouped).sort();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-64 overflow-y-auto z-[200]"
|
||||
>
|
||||
{vendors.map((vendor, vi) => (
|
||||
<div key={vendor}>
|
||||
{vi > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel>{vendor}</DropdownMenuLabel>
|
||||
{grouped[vendor].map((m) => (
|
||||
<DropdownMenuItem key={m.id} onSelect={() => onSelect(m.id)}>
|
||||
{m.id}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface OpenCodeFormFieldsProps {
|
||||
// NPM Package
|
||||
npm: string;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { FetchedModel } from "@/lib/api/model-fetch";
|
||||
|
||||
export function ModelDropdown({
|
||||
models,
|
||||
onSelect,
|
||||
}: {
|
||||
models: FetchedModel[];
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const grouped: Record<string, FetchedModel[]> = {};
|
||||
for (const model of models) {
|
||||
const vendor = model.ownedBy || "Other";
|
||||
if (!grouped[vendor]) grouped[vendor] = [];
|
||||
grouped[vendor].push(model);
|
||||
}
|
||||
const vendors = Object.keys(grouped).sort();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-64 overflow-y-auto z-[200]"
|
||||
>
|
||||
{vendors.map((vendor, vi) => (
|
||||
<div key={vendor}>
|
||||
{vi > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel>{vendor}</DropdownMenuLabel>
|
||||
{grouped[vendor].map((m) => (
|
||||
<DropdownMenuItem key={m.id} onSelect={() => onSelect(m.id)}>
|
||||
{m.id}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { ApiKeySection } from "./ApiKeySection";
|
||||
export { EndpointField } from "./EndpointField";
|
||||
export { ModelDropdown } from "./ModelDropdown";
|
||||
export { ModelInputWithFetch } from "./ModelInputWithFetch";
|
||||
|
||||
@@ -177,8 +177,6 @@
|
||||
"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.",
|
||||
|
||||
@@ -177,8 +177,6 @@
|
||||
"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 のローカルルーティングが実際の上流モデルへマッピングします。ローカルルーティングを起動したままにしてください。",
|
||||
|
||||
@@ -177,8 +177,6 @@
|
||||
"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 本地路由会映射到真实上游模型;需要保持本地路由运行。",
|
||||
|
||||
Reference in New Issue
Block a user