feat: add Hermes UI components, presets, and config panels (Phase 8)

- Add 7 provider presets (OpenRouter, Anthropic, OpenAI, Google, DeepSeek, Together, Nous)
- Create HermesFormFields + useHermesFormState for provider form integration
- Create Model/Agent/Env config panels with save/load functionality
- Create HermesHealthBanner for config warnings
- Add hermes icon (violet winged H) to icon system
- Integrate into App.tsx: 3 new view types (hermesModel/hermesAgent/hermesEnv),
  sidebar buttons (Brain/Bot/KeyRound), health banner, session support
- Integrate into ProviderForm: presets, form state, key validation, rendering
- Integrate into AddProviderDialog: universal tab exclusion, providerKey, base_url extraction
- Add i18n keys for all Hermes UI (zh/en/ja)
This commit is contained in:
Jason
2026-04-15 21:02:46 +08:00
parent a0b585992a
commit 240969d8c7
23 changed files with 1512 additions and 54 deletions
@@ -41,7 +41,8 @@ export function AddProviderDialog({
}: AddProviderDialogProps) {
const { t } = useTranslation();
// OpenCode and OpenClaw don't support universal providers
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
const showUniversalTab =
appId !== "opencode" && appId !== "openclaw" && appId !== "hermes";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
);
@@ -106,7 +107,7 @@ export function AddProviderDialog({
// OpenCode/OpenClaw: pass providerKey for ID generation
if (
(appId === "opencode" || appId === "openclaw") &&
(appId === "opencode" || appId === "openclaw" || appId === "hermes") &&
values.providerKey
) {
providerData.providerKey = values.providerKey;
@@ -203,6 +204,10 @@ export function AddProviderDialog({
if (parsedConfig.baseUrl) {
addUrl(parsedConfig.baseUrl as string);
}
} else if (appId === "hermes") {
if (parsedConfig.base_url) {
addUrl(parsedConfig.base_url as string);
}
}
const urls = Array.from(urlSet);
@@ -0,0 +1,64 @@
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ApiKeySection } from "./shared";
import type { ProviderCategory } from "@/types";
interface HermesFormFieldsProps {
baseUrl: string;
onBaseUrlChange: (value: string) => void;
apiKey: string;
onApiKeyChange: (value: string) => void;
category?: ProviderCategory;
shouldShowApiKeyLink: boolean;
websiteUrl: string;
isPartner?: boolean;
partnerPromotionKey?: string;
}
export function HermesFormFields({
baseUrl,
onBaseUrlChange,
apiKey,
onApiKeyChange,
category,
shouldShowApiKeyLink,
websiteUrl,
isPartner,
partnerPromotionKey,
}: HermesFormFieldsProps) {
const { t } = useTranslation();
return (
<>
{/* Base URL */}
<div className="space-y-2">
<FormLabel htmlFor="hermes-baseurl">
{t("hermes.form.baseUrl", { defaultValue: "API Endpoint" })}
</FormLabel>
<Input
id="hermes-baseurl"
value={baseUrl}
onChange={(e) => onBaseUrlChange(e.target.value)}
placeholder="https://api.example.com/v1"
/>
<p className="text-xs text-muted-foreground">
{t("hermes.form.baseUrlHint", {
defaultValue: "The API endpoint URL for this provider.",
})}
</p>
</div>
{/* API Key */}
<ApiKeySection
value={apiKey}
onChange={onApiKeyChange}
category={category}
shouldShowLink={shouldShowApiKeyLink}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
/>
</>
);
}
+211 -5
View File
@@ -37,8 +37,13 @@ import {
type OpenClawProviderPreset,
type OpenClawSuggestedDefaults,
} from "@/config/openclawProviderPresets";
import {
hermesProviderPresets,
type HermesProviderPreset,
} from "@/config/hermesProviderPresets";
import { OpenCodeFormFields } from "./OpenCodeFormFields";
import { OpenClawFormFields } from "./OpenClawFormFields";
import { HermesFormFields } from "./HermesFormFields";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import {
applyTemplateValues,
@@ -80,6 +85,7 @@ import {
useOpencodeFormState,
useOmoDraftState,
useOpenclawFormState,
useHermesFormState,
useCopilotAuth,
useCodexOauth,
} from "./hooks";
@@ -95,6 +101,7 @@ import {
} from "./helpers/opencodeFormUtils";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { useOpenClawLiveProviderIds } from "@/hooks/useOpenClaw";
import { useHermesLiveProviderIds } from "@/hooks/useHermes";
type PresetEntry = {
id: string;
@@ -103,7 +110,8 @@ type PresetEntry = {
| CodexProviderPreset
| GeminiProviderPreset
| OpenCodeProviderPreset
| OpenClawProviderPreset;
| OpenClawProviderPreset
| HermesProviderPreset;
};
interface ProviderFormProps {
@@ -449,6 +457,11 @@ export function ProviderForm({
id: `openclaw-${index}`,
preset,
}));
} else if (appId === "hermes") {
return hermesProviderPresets.map<PresetEntry>((preset, index) => ({
id: `hermes-${index}`,
preset,
}));
}
return providerPresets
.filter((p) => !p.hidden)
@@ -644,6 +657,18 @@ export function ProviderForm({
isLoading: isOpenclawLiveProviderIdsLoading,
} = useOpenClawLiveProviderIds(appId === "openclaw");
const hermesForm = useHermesFormState({
initialData,
appId,
providerId,
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
getSettingsConfig: () => form.getValues("settingsConfig"),
});
const {
data: hermesLiveProviderIds = [],
isLoading: isHermesLiveProviderIdsLoading,
} = useHermesLiveProviderIds(appId === "hermes");
const additiveExistingProviderKeys = useMemo(() => {
if (appId === "opencode" && !isAnyOmoCategory) {
return Array.from(
@@ -666,10 +691,22 @@ export function ProviderForm({
);
}
if (appId === "hermes") {
return Array.from(
new Set(
[...hermesForm.existingHermesKeys, ...hermesLiveProviderIds].filter(
(key) => key !== providerId,
),
),
);
}
return [];
}, [
appId,
existingOpencodeKeys,
hermesForm.existingHermesKeys,
hermesLiveProviderIds,
isAnyOmoCategory,
openclawForm.existingOpenclawKeys,
openclawLiveProviderIds,
@@ -685,11 +722,15 @@ export function ProviderForm({
if (appId === "openclaw") {
return isOpenclawLiveProviderIdsLoading;
}
if (appId === "hermes") {
return isHermesLiveProviderIdsLoading;
}
return false;
}, [
appId,
isAnyOmoCategory,
isEditMode,
isHermesLiveProviderIdsLoading,
isOpenclawLiveProviderIdsLoading,
isOpencodeLiveProviderIdsLoading,
]);
@@ -702,9 +743,13 @@ export function ProviderForm({
if (appId === "openclaw") {
return openclawLiveProviderIds.includes(providerId);
}
if (appId === "hermes") {
return hermesLiveProviderIds.includes(providerId);
}
return false;
}, [
appId,
hermesLiveProviderIds,
isAnyOmoCategory,
isEditMode,
openclawLiveProviderIds,
@@ -796,6 +841,34 @@ export function ProviderForm({
}
}
// Hermes: validate provider key
if (appId === "hermes") {
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
if (!hermesForm.hermesProviderKey.trim()) {
toast.error(t("hermes.form.providerKeyRequired"));
return;
}
if (!keyPattern.test(hermesForm.hermesProviderKey)) {
toast.error(t("hermes.form.providerKeyInvalid"));
return;
}
if (isProviderKeyLockStateLoading) {
toast.error(
t("providerForm.providerKeyStatusLoading", {
defaultValue: "正在加载供应商标识状态,请稍后再试",
}),
);
return;
}
if (
!isProviderKeyLocked &&
additiveExistingProviderKeys.includes(hermesForm.hermesProviderKey)
) {
toast.error(t("hermes.form.providerKeyDuplicate"));
return;
}
}
// 非官方供应商必填校验:端点和 API Key
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
// GitHub Copilot 使用 OAuth 认证,不需要 API Key
@@ -968,6 +1041,8 @@ export function ProviderForm({
}
} else if (appId === "openclaw") {
payload.providerKey = openclawForm.openclawProviderKey;
} else if (appId === "hermes") {
payload.providerKey = hermesForm.hermesProviderKey;
}
if (isAnyOmoCategory && !payload.presetCategory) {
@@ -1181,6 +1256,20 @@ export function ProviderForm({
formWebsiteUrl: form.watch("websiteUrl") || "",
});
// 使用 API Key 链接 hook (Hermes)
const {
shouldShowApiKeyLink: shouldShowHermesApiKeyLink,
websiteUrl: hermesWebsiteUrl,
isPartner: isHermesPartner,
partnerPromotionKey: hermesPartnerPromotionKey,
} = useApiKeyLink({
appId: "hermes",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
// 使用端点测速候选 hook
const speedTestEndpoints = useSpeedTestEndpoints({
appId,
@@ -1212,6 +1301,9 @@ export function ProviderForm({
if (appId === "openclaw") {
openclawForm.resetOpenclawState();
}
if (appId === "hermes") {
hermesForm.resetHermesState();
}
return;
}
@@ -1316,6 +1408,23 @@ export function ProviderForm({
return;
}
// Hermes preset handling
if (appId === "hermes") {
const preset = entry.preset as HermesProviderPreset;
const config = preset.settingsConfig;
hermesForm.resetHermesState(config);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
const preset = entry.preset as ProviderPreset;
const config = applyTemplateValues(
preset.settingsConfig,
@@ -1508,6 +1617,79 @@ export function ProviderForm({
</p>
)}
</div>
) : appId === "hermes" ? (
<div className="space-y-2">
<Label htmlFor="hermes-key">
{t("hermes.form.providerKey", {
defaultValue: "Provider Key",
})}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="hermes-key"
value={hermesForm.hermesProviderKey}
onChange={(e) =>
hermesForm.setHermesProviderKey(
e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
)
}
placeholder={t("hermes.form.providerKeyPlaceholder", {
defaultValue: "my-provider",
})}
disabled={
isProviderKeyLocked || isProviderKeyLockStateLoading
}
className={
(additiveExistingProviderKeys.includes(
hermesForm.hermesProviderKey,
) &&
!isProviderKeyLocked) ||
(hermesForm.hermesProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
hermesForm.hermesProviderKey,
))
? "border-destructive"
: ""
}
/>
{additiveExistingProviderKeys.includes(
hermesForm.hermesProviderKey,
) &&
!isProviderKeyLocked && (
<p className="text-xs text-destructive">
{t("hermes.form.providerKeyDuplicate")}
</p>
)}
{hermesForm.hermesProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
hermesForm.hermesProviderKey,
) && (
<p className="text-xs text-destructive">
{t("hermes.form.providerKeyInvalid")}
</p>
)}
{!(
additiveExistingProviderKeys.includes(
hermesForm.hermesProviderKey,
) && !isProviderKeyLocked
) &&
(hermesForm.hermesProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
hermesForm.hermesProviderKey,
)) && (
<p className="text-xs text-muted-foreground">
{isProviderKeyLocked
? t("hermes.form.providerKeyLockedHint", {
defaultValue:
"This provider is in Hermes config; key is locked.",
})
: t("hermes.form.providerKeyHint", {
defaultValue:
"Lowercase letters, numbers, and hyphens only. Used as the provider name in config.yaml.",
})}
</p>
)}
</div>
) : undefined
}
/>
@@ -1701,6 +1883,21 @@ export function ProviderForm({
/>
)}
{/* Hermes 专属字段 */}
{appId === "hermes" && (
<HermesFormFields
baseUrl={hermesForm.hermesBaseUrl}
onBaseUrlChange={hermesForm.handleHermesBaseUrlChange}
apiKey={hermesForm.hermesApiKey}
onApiKeyChange={hermesForm.handleHermesApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowHermesApiKeyLink}
websiteUrl={hermesWebsiteUrl}
isPartner={isHermesPartner}
partnerPromotionKey={hermesPartnerPromotionKey}
/>
)}
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
{appId === "codex" ? (
<>
@@ -1784,7 +1981,7 @@ export function ProviderForm({
</div>
{settingsConfigErrorField}
</>
) : appId === "openclaw" ? (
) : appId === "openclaw" || appId === "hermes" ? (
<>
<div className="space-y-2">
<Label htmlFor="settingsConfig">
@@ -1793,12 +1990,20 @@ export function ProviderForm({
<JsonEditor
value={form.getValues("settingsConfig")}
onChange={(config) => form.setValue("settingsConfig", config)}
placeholder={`{
placeholder={
appId === "hermes"
? `{
"name": "my-provider",
"base_url": "https://api.example.com/v1",
"api_key": ""
}`
: `{
"baseUrl": "https://api.example.com/v1",
"apiKey": "your-api-key-here",
"api": "openai-completions",
"models": []
}`}
}`
}
rows={14}
showValidation={true}
language="json"
@@ -1836,7 +2041,8 @@ export function ProviderForm({
{!isAnyOmoCategory &&
appId !== "opencode" &&
appId !== "openclaw" && (
appId !== "openclaw" &&
appId !== "hermes" && (
<ProviderAdvancedConfig
testConfig={testConfig}
pricingConfig={pricingConfig}
@@ -17,5 +17,6 @@ export { useOmoModelSource } from "./useOmoModelSource";
export { useOpencodeFormState } from "./useOpencodeFormState";
export { useOmoDraftState } from "./useOmoDraftState";
export { useOpenclawFormState } from "./useOpenclawFormState";
export { useHermesFormState } from "./useHermesFormState";
export { useCopilotAuth } from "./useCopilotAuth";
export { useCodexOauth } from "./useCodexOauth";
@@ -0,0 +1,138 @@
import { useState, useCallback, useMemo } from "react";
import type { AppId } from "@/lib/api";
import { useProvidersQuery } from "@/lib/query/queries";
interface UseHermesFormStateParams {
initialData?: {
settingsConfig?: Record<string, unknown>;
};
appId: AppId;
providerId?: string;
onSettingsConfigChange: (config: string) => void;
getSettingsConfig: () => string;
}
export const HERMES_DEFAULT_CONFIG = JSON.stringify(
{
name: "",
base_url: "",
api_key: "",
},
null,
2,
);
export interface HermesFormState {
hermesProviderKey: string;
setHermesProviderKey: (key: string) => void;
hermesBaseUrl: string;
hermesApiKey: string;
existingHermesKeys: string[];
handleHermesBaseUrlChange: (baseUrl: string) => void;
handleHermesApiKeyChange: (apiKey: string) => void;
resetHermesState: (config?: {
name?: string;
base_url?: string;
api_key?: string;
}) => void;
}
function parseHermesField<T>(
initialData: UseHermesFormStateParams["initialData"],
field: string,
fallback: T,
): T {
try {
if (initialData?.settingsConfig) {
return (initialData.settingsConfig[field] as T) || fallback;
}
const config = JSON.parse(HERMES_DEFAULT_CONFIG);
return (config[field] as T) || fallback;
} catch {
return fallback;
}
}
export function useHermesFormState({
initialData,
appId,
providerId,
onSettingsConfigChange,
getSettingsConfig,
}: UseHermesFormStateParams): HermesFormState {
const { data: hermesProvidersData } = useProvidersQuery("hermes");
const existingHermesKeys = useMemo(() => {
if (!hermesProvidersData?.providers) return [];
return Object.keys(hermesProvidersData.providers).filter(
(k) => k !== providerId,
);
}, [hermesProvidersData?.providers, providerId]);
const [hermesProviderKey, setHermesProviderKey] = useState<string>(() => {
if (appId !== "hermes") return "";
return providerId || "";
});
const [hermesBaseUrl, setHermesBaseUrl] = useState<string>(() => {
if (appId !== "hermes") return "";
return parseHermesField(initialData, "base_url", "");
});
const [hermesApiKey, setHermesApiKey] = useState<string>(() => {
if (appId !== "hermes") return "";
return parseHermesField(initialData, "api_key", "");
});
const updateHermesConfig = useCallback(
(updater: (config: Record<string, unknown>) => void) => {
try {
const config = JSON.parse(getSettingsConfig() || HERMES_DEFAULT_CONFIG);
updater(config);
onSettingsConfigChange(JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[getSettingsConfig, onSettingsConfigChange],
);
const handleHermesBaseUrlChange = useCallback(
(baseUrl: string) => {
setHermesBaseUrl(baseUrl);
updateHermesConfig((config) => {
config.base_url = baseUrl.trim().replace(/\/+$/, "");
});
},
[updateHermesConfig],
);
const handleHermesApiKeyChange = useCallback(
(apiKey: string) => {
setHermesApiKey(apiKey);
updateHermesConfig((config) => {
config.api_key = apiKey;
});
},
[updateHermesConfig],
);
const resetHermesState = useCallback(
(config?: { name?: string; base_url?: string; api_key?: string }) => {
setHermesProviderKey("");
setHermesBaseUrl(config?.base_url || "");
setHermesApiKey(config?.api_key || "");
},
[],
);
return {
hermesProviderKey,
setHermesProviderKey,
hermesBaseUrl,
hermesApiKey,
existingHermesKeys,
handleHermesBaseUrlChange,
handleHermesApiKeyChange,
resetHermesState,
};
}