feat(hermes): add API mode dropdown and per-provider model editor in form

The Hermes provider form previously only exposed Base URL and API Key,
forcing users to drop into the Model panel to hand-edit model IDs after
adding a provider. Following OpenClaw's shape, the form now carries:

- An API Mode selector (auto-detect / chat_completions / anthropic_messages).
  "auto" is a UI-only sentinel — selecting it deletes api_mode from the
  config so the YAML doesn't leak a redundant field.
- A model list editor where the first row is badged as the default and
  each row has a collapsible Advanced panel for context_length and
  max_tokens. Adding/removing rows uses a UUID-keyed ref so typing in
  one input doesn't drop focus when another row is added.
- A Fetch Models button that pulls /v1/models from the configured
  endpoint and exposes the catalog in a per-row dropdown, identical to
  OpenClaw's flow. The vendor grouping is memoized so keystrokes don't
  trigger a reduce+sort per model row — Radix DropdownMenuContent does
  not lazy-mount, so the inner JSX evaluates on every render regardless
  of whether the menu is open.

Three-locale i18n keys are added together (zh/en/ja).
This commit is contained in:
Jason
2026-04-18 22:28:22 +08:00
parent f935bac633
commit 497a543c1b
7 changed files with 555 additions and 14 deletions
@@ -1,7 +1,48 @@
import { useTranslation } from "react-i18next";
import { useState, useRef, useCallback, useMemo } from "react";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
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 { ApiKeySection } from "./shared";
import {
fetchModelsForConfig,
showFetchModelsError,
type FetchedModel,
} from "@/lib/api/model-fetch";
import {
hermesApiModes,
type HermesApiModeChoice,
type HermesModel,
} from "@/config/hermesProviderPresets";
import type { ProviderCategory } from "@/types";
interface HermesFormFieldsProps {
@@ -14,6 +55,10 @@ interface HermesFormFieldsProps {
websiteUrl: string;
isPartner?: boolean;
partnerPromotionKey?: string;
apiMode: HermesApiModeChoice;
onApiModeChange: (mode: HermesApiModeChoice) => void;
models: HermesModel[];
onModelsChange: (models: HermesModel[]) => void;
}
export function HermesFormFields({
@@ -26,15 +71,140 @@ export function HermesFormFields({
websiteUrl,
isPartner,
partnerPromotionKey,
apiMode,
onApiModeChange,
models,
onModelsChange,
}: HermesFormFieldsProps) {
const { t } = useTranslation();
const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(
{},
);
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
// Stable list keys: a manual ref rather than UUID-in-state so adding/removing
// rows doesn't re-mount unrelated inputs (would drop focus mid-typing).
const modelKeysRef = useRef<string[]>([]);
while (modelKeysRef.current.length < models.length) {
modelKeysRef.current.push(crypto.randomUUID());
}
if (modelKeysRef.current.length > models.length) {
modelKeysRef.current.length = models.length;
}
const modelKeys = modelKeysRef.current;
// Group fetched models by vendor once — Radix DropdownMenuContent doesn't
// lazy-mount, so computing this in JSX would re-run per model row per render.
const groupedFetchedModels = useMemo(
() =>
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)),
[fetchedModels],
);
const toggleModelAdvanced = (index: number) => {
setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] }));
};
const handleAddModel = () => {
modelKeysRef.current.push(crypto.randomUUID());
onModelsChange([
...models,
{ id: "", name: "", context_length: undefined, max_tokens: undefined },
]);
};
const handleFetchModels = useCallback(() => {
if (!baseUrl || !apiKey) {
showFetchModelsError(null, t, {
hasApiKey: !!apiKey,
hasBaseUrl: !!baseUrl,
});
return;
}
setIsFetchingModels(true);
fetchModelsForConfig(baseUrl, apiKey)
.then((fetched) => {
setFetchedModels(fetched);
if (fetched.length === 0) {
toast.info(t("providerForm.fetchModelsEmpty"));
} else {
toast.success(
t("providerForm.fetchModelsSuccess", { count: fetched.length }),
);
}
})
.catch((err) => {
console.warn("[ModelFetch] Failed:", err);
showFetchModelsError(err, t);
})
.finally(() => setIsFetchingModels(false));
}, [baseUrl, apiKey, t]);
const handleRemoveModel = (index: number) => {
modelKeysRef.current.splice(index, 1);
const next = [...models];
next.splice(index, 1);
onModelsChange(next);
setExpandedModels((prev) => {
const updated = { ...prev };
delete updated[index];
return updated;
});
};
const handleModelChange = (
index: number,
field: keyof HermesModel,
value: unknown,
) => {
const next = [...models];
next[index] = { ...next[index], [field]: value };
onModelsChange(next);
};
return (
<>
{/* Base URL */}
<div className="space-y-2">
<FormLabel htmlFor="hermes-api-mode">
{t("hermes.form.apiMode", { defaultValue: "API 模式" })}
</FormLabel>
<Select
value={apiMode}
onValueChange={(v) => onApiModeChange(v as HermesApiModeChoice)}
>
<SelectTrigger id="hermes-api-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
{hermesApiModes.map((mode) => (
<SelectItem key={mode.value} value={mode.value}>
{t(mode.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("hermes.form.apiModeHint", {
defaultValue:
"供应商 API 协议。Auto 表示让 Hermes 按端点自动判断。",
})}
</p>
</div>
<div className="space-y-2">
<FormLabel htmlFor="hermes-baseurl">
{t("hermes.form.baseUrl", { defaultValue: "API Endpoint" })}
{t("hermes.form.baseUrl", { defaultValue: "API 端点" })}
</FormLabel>
<Input
id="hermes-baseurl"
@@ -44,12 +214,11 @@ export function HermesFormFields({
/>
<p className="text-xs text-muted-foreground">
{t("hermes.form.baseUrlHint", {
defaultValue: "The API endpoint URL for this provider.",
defaultValue: "供应商的 API 端点地址。",
})}
</p>
</div>
{/* API Key */}
<ApiKeySection
value={apiKey}
onChange={onApiKeyChange}
@@ -59,6 +228,236 @@ export function HermesFormFields({
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<FormLabel>
{t("hermes.form.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("hermes.form.addModel", { defaultValue: "添加模型" })}
</Button>
</div>
</div>
{models.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{t("hermes.form.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 — first entry is the default written to model.default on switch */}
<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("hermes.form.primaryModel", {
defaultValue: "默认模型",
})
: t("hermes.form.fallbackModel", {
defaultValue: "备选模型",
})}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("hermes.form.modelId", { defaultValue: "模型 ID" })}
</label>
<div className="flex gap-1">
<Input
value={model.id}
onChange={(e) =>
handleModelChange(index, "id", e.target.value)
}
placeholder={t("hermes.form.modelIdPlaceholder", {
defaultValue: "anthropic/claude-opus-4-7",
})}
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]"
>
{groupedFetchedModels.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("hermes.form.modelName", {
defaultValue: "显示名称",
})}
</label>
<Input
value={model.name ?? ""}
onChange={(e) =>
handleModelChange(index, "name", e.target.value)
}
placeholder={t("hermes.form.modelNamePlaceholder", {
defaultValue: "Claude Opus 4.7",
})}
/>
</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>
<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("hermes.form.advancedOptions", {
defaultValue: "高级选项",
})}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("hermes.form.contextLength", {
defaultValue: "上下文长度",
})}
</label>
<Input
type="number"
value={model.context_length ?? ""}
onChange={(e) =>
handleModelChange(
index,
"context_length",
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("hermes.form.maxTokens", {
defaultValue: "最大输出 Tokens",
})}
</label>
<Input
type="number"
value={model.max_tokens ?? ""}
onChange={(e) =>
handleModelChange(
index,
"max_tokens",
e.target.value
? parseInt(e.target.value)
: undefined,
)
}
placeholder="32000"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{t("hermes.form.modelsHint", {
defaultValue:
"切换到此供应商时,第一个模型会写入顶层 model.default。",
})}
</p>
</div>
</>
);
}
@@ -1898,6 +1898,10 @@ export function ProviderForm({
websiteUrl={hermesWebsiteUrl}
isPartner={isHermesPartner}
partnerPromotionKey={hermesPartnerPromotionKey}
apiMode={hermesForm.hermesApiMode}
onApiModeChange={hermesForm.handleHermesApiModeChange}
models={hermesForm.hermesModels}
onModelsChange={hermesForm.handleHermesModelsChange}
/>
)}
@@ -1,6 +1,11 @@
import { useState, useCallback, useMemo } from "react";
import type { AppId } from "@/lib/api";
import { useProvidersQuery } from "@/lib/query/queries";
import type {
HermesApiModeChoice,
HermesModel,
HermesProviderSettingsConfig,
} from "@/config/hermesProviderPresets";
interface UseHermesFormStateParams {
initialData?: {
@@ -29,14 +34,14 @@ export interface HermesFormState {
setHermesProviderKey: (key: string) => void;
hermesBaseUrl: string;
hermesApiKey: string;
hermesApiMode: HermesApiModeChoice;
hermesModels: HermesModel[];
existingHermesKeys: string[];
handleHermesBaseUrlChange: (baseUrl: string) => void;
handleHermesApiKeyChange: (apiKey: string) => void;
resetHermesState: (config?: {
name?: string;
base_url?: string;
api_key?: string;
}) => void;
handleHermesApiModeChange: (mode: HermesApiModeChoice) => void;
handleHermesModelsChange: (models: HermesModel[]) => void;
resetHermesState: (config?: Partial<HermesProviderSettingsConfig>) => void;
}
function parseHermesField<T>(
@@ -48,7 +53,10 @@ function parseHermesField<T>(
if (initialData?.settingsConfig) {
return (initialData.settingsConfig[field] as T) || fallback;
}
return ((HERMES_DEFAULT_CONFIG_OBJ as Record<string, unknown>)[field] as T) || fallback;
return (
((HERMES_DEFAULT_CONFIG_OBJ as Record<string, unknown>)[field] as T) ||
fallback
);
} catch {
return fallback;
}
@@ -84,6 +92,23 @@ export function useHermesFormState({
return parseHermesField(initialData, "api_key", "");
});
const [hermesApiMode, setHermesApiMode] = useState<HermesApiModeChoice>(
() => {
if (appId !== "hermes") return "auto";
const stored = parseHermesField<HermesApiModeChoice | "">(
initialData,
"api_mode",
"",
);
return stored || "auto";
},
);
const [hermesModels, setHermesModels] = useState<HermesModel[]>(() => {
if (appId !== "hermes") return [];
return parseHermesField<HermesModel[]>(initialData, "models", []);
});
const updateHermesConfig = useCallback(
(updater: (config: Record<string, unknown>) => void) => {
try {
@@ -117,11 +142,41 @@ export function useHermesFormState({
[updateHermesConfig],
);
const handleHermesApiModeChange = useCallback(
(mode: HermesApiModeChoice) => {
setHermesApiMode(mode);
updateHermesConfig((config) => {
if (mode === "auto") {
delete config.api_mode;
} else {
config.api_mode = mode;
}
});
},
[updateHermesConfig],
);
const handleHermesModelsChange = useCallback(
(models: HermesModel[]) => {
setHermesModels(models);
updateHermesConfig((config) => {
if (models.length === 0) {
delete config.models;
} else {
config.models = models;
}
});
},
[updateHermesConfig],
);
const resetHermesState = useCallback(
(config?: { name?: string; base_url?: string; api_key?: string }) => {
(config?: Partial<HermesProviderSettingsConfig>) => {
setHermesProviderKey("");
setHermesBaseUrl(config?.base_url || "");
setHermesApiKey(config?.api_key || "");
setHermesApiMode(config?.api_mode ?? "auto");
setHermesModels(config?.models ?? []);
},
[],
);
@@ -131,9 +186,13 @@ export function useHermesFormState({
setHermesProviderKey,
hermesBaseUrl,
hermesApiKey,
hermesApiMode,
hermesModels,
existingHermesKeys,
handleHermesBaseUrlChange,
handleHermesApiKeyChange,
handleHermesApiModeChange,
handleHermesModelsChange,
resetHermesState,
};
}
+25
View File
@@ -47,6 +47,31 @@ export interface HermesSuggestedDefaults {
/** Hermes custom_provider protocol mode (optional; auto-detected when omitted). */
export type HermesApiMode = "chat_completions" | "anthropic_messages";
/**
* Form-facing value used by the API Mode dropdown.
*
* `auto` is the UI-only sentinel for "omit `api_mode` and let Hermes detect the
* protocol from the endpoint". When serialized to `settings_config`, `auto`
* becomes `undefined` so the YAML doesn't include `api_mode` at all.
*/
export type HermesApiModeChoice = "auto" | HermesApiMode;
/**
* Dropdown options for the API Mode selector. `labelKey` is looked up in i18n;
* `value` of "auto" means "don't write `api_mode` to the config".
*/
export const hermesApiModes: Array<{
value: HermesApiModeChoice;
labelKey: string;
}> = [
{ value: "auto", labelKey: "hermes.form.apiModeAuto" },
{ value: "chat_completions", labelKey: "hermes.form.apiModeChatCompletions" },
{
value: "anthropic_messages",
labelKey: "hermes.form.apiModeAnthropicMessages",
},
];
export interface HermesProviderPreset {
name: string;
nameKey?: string;
+19 -1
View File
@@ -1640,7 +1640,25 @@
"providerKeyLockedHint": "This provider is in Hermes config; key is locked.",
"providerKeyRequired": "Provider key is required",
"providerKeyInvalid": "Provider key can only contain lowercase letters, numbers, and hyphens",
"providerKeyDuplicate": "This provider key already exists"
"providerKeyDuplicate": "This provider key already exists",
"apiMode": "API Mode",
"apiModeHint": "Provider API protocol. Auto lets Hermes detect it from the endpoint.",
"apiModeAuto": "Auto-detect",
"apiModeChatCompletions": "OpenAI Chat Completions",
"apiModeAnthropicMessages": "Anthropic Messages",
"models": "Models",
"addModel": "Add model",
"noModels": "No models configured. Switching to this provider won't change the default model.",
"modelId": "Model ID",
"modelIdPlaceholder": "anthropic/claude-opus-4-7",
"modelName": "Display name",
"modelNamePlaceholder": "Claude Opus 4.7",
"contextLength": "Context length",
"maxTokens": "Max output tokens",
"advancedOptions": "Advanced options",
"modelsHint": "On switch, the first model is written to top-level model.default.",
"primaryModel": "Default",
"fallbackModel": "Alternate"
},
"model": {
"title": "Model Config",
+19 -1
View File
@@ -1640,7 +1640,25 @@
"providerKeyLockedHint": "このプロバイダーは Hermes 設定に追加済みのため、キーは変更できません。",
"providerKeyRequired": "プロバイダーキーは必須です",
"providerKeyInvalid": "プロバイダーキーは小文字、数字、ハイフンのみ使用できます",
"providerKeyDuplicate": "このプロバイダーキーは既に存在します"
"providerKeyDuplicate": "このプロバイダーキーは既に存在します",
"apiMode": "API モード",
"apiModeHint": "プロバイダーの API プロトコル。Auto はエンドポイントから自動判定します。",
"apiModeAuto": "自動検出",
"apiModeChatCompletions": "OpenAI Chat Completions",
"apiModeAnthropicMessages": "Anthropic Messages",
"models": "モデル一覧",
"addModel": "モデルを追加",
"noModels": "モデル未設定。このプロバイダーに切り替えてもデフォルトモデルは更新されません。",
"modelId": "モデル ID",
"modelIdPlaceholder": "anthropic/claude-opus-4-7",
"modelName": "表示名",
"modelNamePlaceholder": "Claude Opus 4.7",
"contextLength": "コンテキスト長",
"maxTokens": "最大出力トークン",
"advancedOptions": "詳細オプション",
"modelsHint": "切り替え時、最初のモデルが model.default に書き込まれます。",
"primaryModel": "デフォルト",
"fallbackModel": "予備"
},
"model": {
"title": "モデル設定",
+19 -1
View File
@@ -1641,7 +1641,25 @@
"providerKeyLockedHint": "该供应商已添加到 Hermes 配置中,标识不可修改。",
"providerKeyRequired": "供应商标识不能为空",
"providerKeyInvalid": "供应商标识只能包含小写字母、数字和连字符",
"providerKeyDuplicate": "该供应商标识已存在"
"providerKeyDuplicate": "该供应商标识已存在",
"apiMode": "API 模式",
"apiModeHint": "供应商 API 协议。Auto 表示让 Hermes 按端点自动判断。",
"apiModeAuto": "自动检测",
"apiModeChatCompletions": "OpenAI Chat Completions",
"apiModeAnthropicMessages": "Anthropic Messages",
"models": "模型列表",
"addModel": "添加模型",
"noModels": "暂无模型配置。切换到此供应商时将不会更新默认模型。",
"modelId": "模型 ID",
"modelIdPlaceholder": "anthropic/claude-opus-4-7",
"modelName": "显示名称",
"modelNamePlaceholder": "Claude Opus 4.7",
"contextLength": "上下文长度",
"maxTokens": "最大输出 Tokens",
"advancedOptions": "高级选项",
"modelsHint": "切换到此供应商时,第一个模型会写入顶层 model.default。",
"primaryModel": "默认模型",
"fallbackModel": "备选模型"
},
"model": {
"title": "模型配置",