diff --git a/src/components/providers/forms/HermesFormFields.tsx b/src/components/providers/forms/HermesFormFields.tsx index 0e8b5e5ab..e74b5365b 100644 --- a/src/components/providers/forms/HermesFormFields.tsx +++ b/src/components/providers/forms/HermesFormFields.tsx @@ -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>( + {}, + ); + const [fetchedModels, setFetchedModels] = useState([]); + 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([]); + 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, + ), + ).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 */} +
+ + {t("hermes.form.apiMode", { defaultValue: "API 模式" })} + + +

+ {t("hermes.form.apiModeHint", { + defaultValue: + "供应商 API 协议。Auto 表示让 Hermes 按端点自动判断。", + })} +

+
+
- {t("hermes.form.baseUrl", { defaultValue: "API Endpoint" })} + {t("hermes.form.baseUrl", { defaultValue: "API 端点" })}

{t("hermes.form.baseUrlHint", { - defaultValue: "The API endpoint URL for this provider.", + defaultValue: "供应商的 API 端点地址。", })}

- {/* API Key */} + +
+
+ + {t("hermes.form.models", { defaultValue: "模型列表" })} + +
+ + +
+
+ + {models.length === 0 ? ( +

+ {t("hermes.form.noModels", { + defaultValue: "暂无模型配置。切换到此供应商时将无默认模型。", + })} +

+ ) : ( +
+ {models.map((model, index) => ( +
+ {/* Role badge — first entry is the default written to model.default on switch */} +
+ + {index === 0 + ? t("hermes.form.primaryModel", { + defaultValue: "默认模型", + }) + : t("hermes.form.fallbackModel", { + defaultValue: "备选模型", + })} + +
+ +
+
+ +
+ + handleModelChange(index, "id", e.target.value) + } + placeholder={t("hermes.form.modelIdPlaceholder", { + defaultValue: "anthropic/claude-opus-4-7", + })} + className="flex-1" + /> + {fetchedModels.length > 0 && ( + + + + + + {groupedFetchedModels.map( + ([vendor, vModels], vi) => ( +
+ {vi > 0 && } + + {vendor} + + {vModels.map((m) => ( + + handleModelChange(index, "id", m.id) + } + > + {m.id} + + ))} +
+ ), + )} +
+
+ )} +
+
+
+ + + handleModelChange(index, "name", e.target.value) + } + placeholder={t("hermes.form.modelNamePlaceholder", { + defaultValue: "Claude Opus 4.7", + })} + /> +
+ +
+ + toggleModelAdvanced(index)} + > + + + + +
+
+ + + handleModelChange( + index, + "context_length", + e.target.value + ? parseInt(e.target.value) + : undefined, + ) + } + placeholder="200000" + /> +
+
+ + + handleModelChange( + index, + "max_tokens", + e.target.value + ? parseInt(e.target.value) + : undefined, + ) + } + placeholder="32000" + /> +
+
+
+
+
+ ))} +
+ )} + +

+ {t("hermes.form.modelsHint", { + defaultValue: + "切换到此供应商时,第一个模型会写入顶层 model.default。", + })} +

+
); } diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 63bf2a0eb..398f76ae2 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -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} /> )} diff --git a/src/components/providers/forms/hooks/useHermesFormState.ts b/src/components/providers/forms/hooks/useHermesFormState.ts index 09f5d135e..d788e367b 100644 --- a/src/components/providers/forms/hooks/useHermesFormState.ts +++ b/src/components/providers/forms/hooks/useHermesFormState.ts @@ -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) => void; } function parseHermesField( @@ -48,7 +53,10 @@ function parseHermesField( if (initialData?.settingsConfig) { return (initialData.settingsConfig[field] as T) || fallback; } - return ((HERMES_DEFAULT_CONFIG_OBJ as Record)[field] as T) || fallback; + return ( + ((HERMES_DEFAULT_CONFIG_OBJ as Record)[field] as T) || + fallback + ); } catch { return fallback; } @@ -84,6 +92,23 @@ export function useHermesFormState({ return parseHermesField(initialData, "api_key", ""); }); + const [hermesApiMode, setHermesApiMode] = useState( + () => { + if (appId !== "hermes") return "auto"; + const stored = parseHermesField( + initialData, + "api_mode", + "", + ); + return stored || "auto"; + }, + ); + + const [hermesModels, setHermesModels] = useState(() => { + if (appId !== "hermes") return []; + return parseHermesField(initialData, "models", []); + }); + const updateHermesConfig = useCallback( (updater: (config: Record) => 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) => { 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, }; } diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index c586b257e..4cc5f7241 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -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; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 965ba823b..e50bf4e5b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b3e1186e8..3beed5464 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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": "モデル設定", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0fd198e0f..3abb2afcc 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "模型配置",