Files
CC-Switch/src/components/providers/forms/OpenClawFormFields.tsx
T
Jason 36e0378e78 feat(providers): show API key link in Claude Desktop, OpenClaw and Hermes forms
The "Get API Key" link (and partner promotion) below the API key input was
only wired for claude/codex/gemini/opencode. Claude Desktop uses a separate
form that never rendered it, and OpenClaw/Hermes were blocked by two gaps:

- useApiKeyLink whitelisted only 4 app ids, so the link was suppressed for
  claude-desktop/openclaw/hermes even when a preset carried apiKeyUrl.
- useProviderCategory only parsed claude/codex/gemini/opencode preset ids, so
  OpenClaw/Hermes category stayed undefined and the link condition
  (cn_official/aggregator/third_party) never held.

Changes:
- ClaudeDesktopProviderForm: call useApiKeyLink and replace the bare API key
  Input with the shared ApiKeySection.
- useApiKeyLink: add claude-desktop/openclaw/hermes to the whitelist and add
  ClaudeDesktopProviderPreset to the PresetEntry union.
- useProviderCategory: parse and resolve openclaw/hermes preset categories.
- Hermes/OpenClaw form fields: don't let an "official" category disable the
  API key input, since these apps have no OAuth-only official providers
  (e.g. Hermes' Nous Research is "official" but still needs a user key).
2026-06-29 23:30:58 +08:00

673 lines
25 KiB
TypeScript

import { useTranslation } from "react-i18next";
import { useState, useRef, useCallback } from "react";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { toast } from "sonner";
import {
Download,
Plus,
Trash2,
ChevronDown,
ChevronRight,
Loader2,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { ApiKeySection } from "./shared";
import {
fetchModelsForConfig,
showFetchModelsError,
type FetchedModel,
} from "@/lib/api/model-fetch";
import { openclawApiProtocols } from "@/config/openclawProviderPresets";
import type { ProviderCategory, OpenClawModel } from "@/types";
interface OpenClawFormFieldsProps {
// Base URL
baseUrl: string;
onBaseUrlChange: (value: string) => void;
// API Key
apiKey: string;
onApiKeyChange: (value: string) => void;
category?: ProviderCategory;
shouldShowApiKeyLink: boolean;
websiteUrl: string;
isPartner?: boolean;
partnerPromotionKey?: string;
// API Protocol
api: string;
onApiChange: (value: string) => void;
// Models
models: OpenClawModel[];
onModelsChange: (models: OpenClawModel[]) => void;
// User-Agent
userAgent: boolean;
onUserAgentChange: (checked: boolean) => void;
}
export function OpenClawFormFields({
baseUrl,
onBaseUrlChange,
apiKey,
onApiKeyChange,
category,
shouldShowApiKeyLink,
websiteUrl,
isPartner,
partnerPromotionKey,
api,
onApiChange,
models,
onModelsChange,
userAgent,
onUserAgentChange,
}: OpenClawFormFieldsProps) {
const { t } = useTranslation();
const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(
{},
);
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
// Stable key tracking for models list
const modelKeysRef = useRef<string[]>([]);
const getModelKeys = useCallback(() => {
// Grow keys array if models were added externally
while (modelKeysRef.current.length < models.length) {
modelKeysRef.current.push(crypto.randomUUID());
}
// Shrink if models were removed externally
if (modelKeysRef.current.length > models.length) {
modelKeysRef.current.length = models.length;
}
return modelKeysRef.current;
}, [models.length]);
const modelKeys = getModelKeys();
// Toggle advanced section for a model
const toggleModelAdvanced = (index: number) => {
setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] }));
};
// Add a new model entry
const handleAddModel = () => {
modelKeysRef.current.push(crypto.randomUUID());
onModelsChange([
...models,
{
id: "",
name: "",
contextWindow: undefined,
maxTokens: undefined,
cost: undefined,
input: ["text"],
},
]);
};
// Fetch models from API
const handleFetchModels = useCallback(() => {
if (!baseUrl || !apiKey) {
showFetchModelsError(null, t, {
hasApiKey: !!apiKey,
hasBaseUrl: !!baseUrl,
});
return;
}
setIsFetchingModels(true);
fetchModelsForConfig(baseUrl, apiKey)
.then((models) => {
setFetchedModels(models);
if (models.length === 0) {
toast.info(t("providerForm.fetchModelsEmpty"));
} else {
toast.success(
t("providerForm.fetchModelsSuccess", { count: models.length }),
);
}
})
.catch((err) => {
console.warn("[ModelFetch] Failed:", err);
showFetchModelsError(err, t);
})
.finally(() => setIsFetchingModels(false));
}, [baseUrl, apiKey, t]);
// Remove a model entry
const handleRemoveModel = (index: number) => {
modelKeysRef.current.splice(index, 1);
const newModels = [...models];
newModels.splice(index, 1);
onModelsChange(newModels);
// Clean up expanded state
setExpandedModels((prev) => {
const updated = { ...prev };
delete updated[index];
return updated;
});
};
// Update model field
const handleModelChange = (
index: number,
field: keyof OpenClawModel,
value: unknown,
) => {
const newModels = [...models];
newModels[index] = { ...newModels[index], [field]: value };
onModelsChange(newModels);
};
// Update model cost
const handleCostChange = (
index: number,
costField: "input" | "output" | "cacheRead" | "cacheWrite",
value: string,
) => {
const newModels = [...models];
const numValue = parseFloat(value);
const currentCost = newModels[index].cost || { input: 0, output: 0 };
newModels[index] = {
...newModels[index],
cost: {
...currentCost,
[costField]: isNaN(numValue) ? undefined : numValue,
},
};
onModelsChange(newModels);
};
return (
<>
{/* API Protocol Selector */}
<div className="space-y-2">
<FormLabel htmlFor="openclaw-api">
{t("openclaw.apiProtocol", {
defaultValue: "API 协议",
})}
</FormLabel>
<Select value={api} onValueChange={onApiChange}>
<SelectTrigger id="openclaw-api">
<SelectValue
placeholder={t("openclaw.selectProtocol", {
defaultValue: "选择 API 协议",
})}
/>
</SelectTrigger>
<SelectContent>
{openclawApiProtocols.map((protocol) => (
<SelectItem key={protocol.value} value={protocol.value}>
{protocol.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("openclaw.apiProtocolHint", {
defaultValue:
"选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
})}
</p>
</div>
{/* Base URL */}
<div className="space-y-2">
<FormLabel htmlFor="openclaw-baseurl">
{t("openclaw.baseUrl", { defaultValue: "API 端点" })}
</FormLabel>
<Input
id="openclaw-baseurl"
value={baseUrl}
onChange={(e) => onBaseUrlChange(e.target.value)}
placeholder="https://api.example.com/v1"
/>
<p className="text-xs text-muted-foreground">
{t("openclaw.baseUrlHint", {
defaultValue: "供应商的 API 端点地址。",
})}
</p>
</div>
{/* API Key */}
<ApiKeySection
value={apiKey}
onChange={onApiKeyChange}
// OpenClaw 的 API key 始终由用户自填,没有 OAuth-only 的免 key 官方供应商,
// 故不让 official 禁用输入框(与 Hermes 对齐)。
category={category === "official" ? undefined : category}
shouldShowLink={shouldShowApiKeyLink}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
/>
{/* User-Agent */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<FormLabel>
{t("openclaw.userAgent", { defaultValue: "发送 User-Agent" })}
</FormLabel>
<p className="text-xs text-muted-foreground">
{t("openclaw.userAgentHint", {
defaultValue: "部分供应商需要浏览器 User-Agent 才能正常访问。",
})}
</p>
</div>
<Switch checked={userAgent} onCheckedChange={onUserAgentChange} />
</div>
{/* Models Editor */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<FormLabel>
{t("openclaw.models", { defaultValue: "模型列表" })}
</FormLabel>
<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")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddModel}
className="h-7 gap-1"
>
<Plus className="h-3.5 w-3.5" />
{t("openclaw.addModel", { defaultValue: "添加模型" })}
</Button>
</div>
</div>
{models.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{t("openclaw.noModels", {
defaultValue: "暂无模型配置。点击添加模型来配置可用模型。",
})}
</p>
) : (
<div className="space-y-4">
{models.map((model, index) => (
<div
key={modelKeys[index]}
className="p-3 border border-border/50 rounded-lg space-y-3"
>
{/* Role badge */}
<div className="flex items-center">
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${
index === 0
? "bg-blue-500/15 text-blue-600 dark:text-blue-400"
: "bg-muted text-muted-foreground"
}`}
>
{index === 0
? t("openclaw.primaryModel", {
defaultValue: "默认模型",
})
: t("openclaw.fallbackModel", {
defaultValue: "回退模型",
})}
</span>
</div>
{/* Model ID and Name row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.modelId", { defaultValue: "模型 ID" })}
</label>
<div className="flex gap-1">
<Input
value={model.id}
onChange={(e) =>
handleModelChange(index, "id", e.target.value)
}
placeholder={t("openclaw.modelIdPlaceholder", {
defaultValue: "claude-3-sonnet",
})}
className="flex-1"
/>
{fetchedModels.length > 0 && (
<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]"
>
{Object.entries(
fetchedModels.reduce(
(acc, m) => {
const v = m.ownedBy || "Other";
if (!acc[v]) acc[v] = [];
acc[v].push(m);
return acc;
},
{} as Record<string, FetchedModel[]>,
),
)
.sort(([a], [b]) => a.localeCompare(b))
.map(([vendor, vModels], vi) => (
<div key={vendor}>
{vi > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel>
{vendor}
</DropdownMenuLabel>
{vModels.map((m) => (
<DropdownMenuItem
key={m.id}
onSelect={() =>
handleModelChange(index, "id", m.id)
}
>
{m.id}
</DropdownMenuItem>
))}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.modelName", { defaultValue: "显示名称" })}
</label>
<Input
value={model.name}
onChange={(e) =>
handleModelChange(index, "name", e.target.value)
}
placeholder={t("openclaw.modelNamePlaceholder", {
defaultValue: "Claude 3 Sonnet",
})}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveModel(index)}
className="h-9 w-9 mt-5 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Advanced Options (Collapsible) */}
<Collapsible
open={expandedModels[index] ?? false}
onOpenChange={() => toggleModelAdvanced(index)}
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{expandedModels[index] ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{t("openclaw.advancedOptions", {
defaultValue: "高级选项",
})}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
{/* Reasoning, Input Types row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.reasoning", {
defaultValue: "推理模式",
})}
</label>
<div className="flex items-center h-9 gap-2">
<Switch
checked={model.reasoning ?? false}
onCheckedChange={(checked) =>
handleModelChange(index, "reasoning", checked)
}
/>
<span className="text-xs text-muted-foreground">
{model.reasoning
? t("openclaw.reasoningOn", {
defaultValue: "启用",
})
: t("openclaw.reasoningOff", {
defaultValue: "关闭",
})}
</span>
</div>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.inputTypes", {
defaultValue: "输入类型",
})}
</label>
{/* "text" is checked by default but can be unchecked —
some models genuinely don't support text input, and
OpenClaw works fine with an empty or image-only array. */}
<div className="flex items-center gap-4 h-9">
{(["text", "image"] as const).map((type) => (
<label
key={type}
className="flex items-center gap-1.5 cursor-pointer select-none"
>
<Checkbox
checked={(model.input ?? ["text"]).includes(
type,
)}
onCheckedChange={(checked) => {
const current = model.input ?? ["text"];
const next = checked
? [...new Set([...current, type])]
: current.filter((v) => v !== type);
handleModelChange(index, "input", next);
}}
/>
<span className="text-xs">{type}</span>
</label>
))}
</div>
</div>
<div className="flex-1" />
</div>
{/* Context Window and Max Tokens row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.contextWindow", {
defaultValue: "上下文窗口",
})}
</label>
<Input
type="number"
value={model.contextWindow ?? ""}
onChange={(e) =>
handleModelChange(
index,
"contextWindow",
e.target.value
? parseInt(e.target.value)
: undefined,
)
}
placeholder="200000"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.maxTokens", {
defaultValue: "最大输出 Tokens",
})}
</label>
<Input
type="number"
value={model.maxTokens ?? ""}
onChange={(e) =>
handleModelChange(
index,
"maxTokens",
e.target.value
? parseInt(e.target.value)
: undefined,
)
}
placeholder="32000"
/>
</div>
<div className="flex-1" />
</div>
{/* Cost row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.inputCost", {
defaultValue: "输入价格 ($/M tokens)",
})}
</label>
<Input
type="number"
step="0.001"
value={model.cost?.input ?? ""}
onChange={(e) =>
handleCostChange(index, "input", e.target.value)
}
placeholder="3"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.outputCost", {
defaultValue: "输出价格 ($/M tokens)",
})}
</label>
<Input
type="number"
step="0.001"
value={model.cost?.output ?? ""}
onChange={(e) =>
handleCostChange(index, "output", e.target.value)
}
placeholder="15"
/>
</div>
<div className="flex-1" />
</div>
{/* Cache Cost row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.cacheReadCost", {
defaultValue: "缓存读取价格 ($/M tokens)",
})}
</label>
<Input
type="number"
step="0.001"
value={model.cost?.cacheRead ?? ""}
onChange={(e) =>
handleCostChange(index, "cacheRead", e.target.value)
}
placeholder="0.3"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.cacheWriteCost", {
defaultValue: "缓存写入价格 ($/M tokens)",
})}
</label>
<Input
type="number"
step="0.001"
value={model.cost?.cacheWrite ?? ""}
onChange={(e) =>
handleCostChange(
index,
"cacheWrite",
e.target.value,
)
}
placeholder="3.75"
/>
</div>
<div className="flex-1" />
</div>
<p className="text-xs text-muted-foreground">
{t("openclaw.cacheCostHint", {
defaultValue:
"缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
})}
</p>
</CollapsibleContent>
</Collapsible>
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{t("openclaw.modelsHint", {
defaultValue:
"配置该供应商支持的模型。第一个模型为默认模型(Primary),其余为回退模型(Fallback)。拖拽或调整顺序可更改默认模型。",
})}
</p>
</div>
</>
);
}