diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 49a74812a..50ca7b136 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -171,9 +171,7 @@ fn is_top_level_key_line(line: &str) -> bool { } if let Some(colon_pos) = line.find(':') { let after_colon = &line[colon_pos + 1..]; - after_colon.is_empty() - || after_colon.starts_with(' ') - || after_colon.starts_with('\t') + after_colon.is_empty() || after_colon.starts_with(' ') || after_colon.starts_with('\t') } else { false } @@ -221,10 +219,7 @@ fn find_yaml_section_range(raw: &str, section_key: &str) -> Option<(usize, usize /// ``` fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result { let mut section = serde_yaml::Mapping::new(); - section.insert( - serde_yaml::Value::String(key.to_string()), - value.clone(), - ); + section.insert(serde_yaml::Value::String(key.to_string()), value.clone()); let yaml_str = serde_yaml::to_string(&serde_yaml::Value::Mapping(section)) .map_err(|e| AppError::Config(format!("Failed to serialize YAML section '{key}': {e}")))?; Ok(yaml_str) @@ -367,7 +362,10 @@ fn write_yaml_section_to_config_locked( let warnings = scan_hermes_health_internal(&new_raw); - log::debug!("Hermes config section '{section_key}' written to {:?}", config_path); + log::debug!( + "Hermes config section '{section_key}' written to {:?}", + config_path + ); Ok(HermesWriteOutcome { backup_path: backup_path.map(|p| p.display().to_string()), warnings, @@ -438,11 +436,10 @@ pub fn set_provider( ); } - if let Some(existing) = providers.iter_mut().find(|p| { - p.get("name") - .and_then(|n| n.as_str()) - == Some(name) - }) { + if let Some(existing) = providers + .iter_mut() + .find(|p| p.get("name").and_then(|n| n.as_str()) == Some(name)) + { *existing = yaml_val; } else { providers.push(yaml_val); @@ -467,11 +464,7 @@ pub fn remove_provider(name: &str) -> Result { .unwrap_or_default(); let original_len = providers.len(); - providers.retain(|p| { - p.get("name") - .and_then(|n| n.as_str()) - != Some(name) - }); + providers.retain(|p| p.get("name").and_then(|n| n.as_str()) != Some(name)); if providers.len() == original_len { return Ok(HermesWriteOutcome::default()); @@ -690,9 +683,8 @@ fn scan_hermes_health_internal(content: &str) -> Vec { if !providers.is_sequence() { warnings.push(HermesHealthWarning { code: "custom_providers_not_list".to_string(), - message: - "custom_providers should be a YAML list (sequence), not a mapping" - .to_string(), + message: "custom_providers should be a YAML list (sequence), not a mapping" + .to_string(), path: Some("custom_providers".to_string()), }); } diff --git a/src-tauri/src/mcp/hermes.rs b/src-tauri/src/mcp/hermes.rs index 69279b765..c2788212f 100644 --- a/src-tauri/src/mcp/hermes.rs +++ b/src-tauri/src/mcp/hermes.rs @@ -83,8 +83,7 @@ fn convert_to_hermes_format(spec: &Value) -> Result { result.insert("url".into(), url.clone()); } if let Some(headers) = obj.get("headers") { - if headers.is_object() - && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) + if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) { result.insert("headers".into(), headers.clone()); } @@ -142,9 +141,7 @@ fn convert_from_hermes_format(id: &str, spec: &Value) -> Result result.insert("url".into(), url.clone()); } if let Some(headers) = obj.get("headers") { - if headers.is_object() - && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) - { + if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) { result.insert("headers".into(), headers.clone()); } } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 37e04c1c6..a0026dd87 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -132,11 +132,7 @@ impl McpService { log::debug!("OpenClaw MCP support is still in development, skipping sync"); } AppType::Hermes => { - mcp::sync_single_server_to_hermes( - &Default::default(), - &server.id, - &server.server, - )?; + mcp::sync_single_server_to_hermes(&Default::default(), &server.id, &server.server)?; } } Ok(()) diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index e50fd1023..2127b51d3 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -1393,9 +1393,7 @@ pub fn remove_hermes_provider_from_live(provider_id: &str) -> Result<(), AppErro // Check if Hermes config directory exists if !hermes_config::get_hermes_dir().exists() { - log::debug!( - "Hermes config directory doesn't exist, skipping removal of '{provider_id}'" - ); + log::debug!("Hermes config directory doesn't exist, skipping removal of '{provider_id}'"); return Ok(()); } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index cae1cd5b9..a7df8892e 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -21,9 +21,8 @@ use crate::store::AppState; // Re-export sub-module functions for external access pub use live::{ - import_default_config, import_hermes_providers_from_live, - import_openclaw_providers_from_live, import_opencode_providers_from_live, read_live_settings, - sync_current_to_live, + import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live, + import_opencode_providers_from_live, read_live_settings, sync_current_to_live, }; // Internal re-exports (pub(crate)) diff --git a/src/App.tsx b/src/App.tsx index 8d2f04f71..b331aaea1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,8 @@ import { KeyRound, Shield, Cpu, + Brain, + Bot, } from "lucide-react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import type { Provider, VisibleApps } from "@/types"; @@ -39,7 +41,7 @@ import { import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { useProviderActions } from "@/hooks/useProviderActions"; import { openclawKeys, useOpenClawHealth } from "@/hooks/useOpenClaw"; -import { hermesKeys } from "@/hooks/useHermes"; +import { hermesKeys, useHermesHealth } from "@/hooks/useHermes"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useAutoCompact } from "@/hooks/useAutoCompact"; import { useLastValidValue } from "@/hooks/useLastValidValue"; @@ -83,6 +85,10 @@ import EnvPanel from "@/components/openclaw/EnvPanel"; import ToolsPanel from "@/components/openclaw/ToolsPanel"; import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel"; import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner"; +import HermesModelPanel from "@/components/hermes/ModelPanel"; +import HermesAgentPanel from "@/components/hermes/AgentPanel"; +import HermesEnvPanel from "@/components/hermes/EnvPanel"; +import HermesHealthBanner from "@/components/hermes/HermesHealthBanner"; type View = | "providers" @@ -97,7 +103,10 @@ type View = | "workspace" | "openclawEnv" | "openclawTools" - | "openclawAgents"; + | "openclawAgents" + | "hermesModel" + | "hermesAgent" + | "hermesEnv"; interface WebDavSyncStatusUpdatedPayload { source?: string; @@ -141,6 +150,9 @@ const VALID_VIEWS: View[] = [ "openclawEnv", "openclawTools", "openclawAgents", + "hermesModel", + "hermesAgent", + "hermesEnv", ]; const getInitialView = (): View => { @@ -203,7 +215,8 @@ function App() { activeApp !== "codex" && activeApp !== "opencode" && activeApp !== "openclaw" && - activeApp !== "gemini" + activeApp !== "gemini" && + activeApp !== "hermes" ) { setCurrentView("providers"); } @@ -259,13 +272,21 @@ function App() { currentView === "openclawAgents"); const { data: openclawHealthWarnings = [] } = useOpenClawHealth(isOpenClawView); + const isHermesView = + activeApp === "hermes" && + (currentView === "providers" || + currentView === "hermesModel" || + currentView === "hermesAgent" || + currentView === "hermesEnv"); + const { data: hermesHealthWarnings = [] } = useHermesHealth(isHermesView); const hasSkillsSupport = true; const hasSessionSupport = activeApp === "claude" || activeApp === "codex" || activeApp === "opencode" || activeApp === "openclaw" || - activeApp === "gemini"; + activeApp === "gemini" || + activeApp === "hermes"; const { addProvider, @@ -940,6 +961,12 @@ function App() { return ; case "openclawAgents": return ; + case "hermesModel": + return ; + case "hermesAgent": + return ; + case "hermesEnv": + return ; default: return (
@@ -971,7 +998,9 @@ function App() { setConfirmAction({ provider, action: "delete" }) } onRemoveFromConfig={ - activeApp === "opencode" || activeApp === "openclaw" + activeApp === "opencode" || + activeApp === "openclaw" || + activeApp === "hermes" ? (provider) => setConfirmAction({ provider, action: "remove" }) : undefined @@ -1153,6 +1182,9 @@ function App() { {currentView === "openclawTools" && t("openclaw.tools.title")} {currentView === "openclawAgents" && t("openclaw.agents.title")} + {currentView === "hermesModel" && t("hermes.model.title")} + {currentView === "hermesAgent" && t("hermes.agent.title")} + {currentView === "hermesEnv" && t("hermes.env.title")}
) : ( @@ -1213,7 +1245,8 @@ function App() {
{currentView === "providers" && activeApp !== "opencode" && - activeApp !== "openclaw" && ( + activeApp !== "openclaw" && + activeApp !== "hermes" && (
- {activeApp === "openclaw" ? ( + {activeApp === "hermes" ? ( + <> + + + + + + + ) : activeApp === "openclaw" ? ( <> +
+
+ ); +}; + +export default AgentPanel; diff --git a/src/components/hermes/EnvPanel.tsx b/src/components/hermes/EnvPanel.tsx new file mode 100644 index 000000000..b8ce9f735 --- /dev/null +++ b/src/components/hermes/EnvPanel.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Save } from "lucide-react"; +import { toast } from "sonner"; +import { useHermesEnv, useSaveHermesEnv } from "@/hooks/useHermes"; +import { extractErrorMessage } from "@/utils/errorUtils"; +import { Button } from "@/components/ui/button"; +import JsonEditor from "@/components/JsonEditor"; + +function parseEnvEditorValue(raw: string): Record { + const trimmed = raw.trim(); + if (!trimmed) throw new Error("HERMES_ENV_EMPTY"); + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + throw new Error("HERMES_ENV_INVALID_JSON"); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("HERMES_ENV_OBJECT_REQUIRED"); + } + return parsed as Record; +} + +const EnvPanel: React.FC = () => { + const { t } = useTranslation(); + const { data: envData, isLoading } = useHermesEnv(); + const saveEnvMutation = useSaveHermesEnv(); + const [editorValue, setEditorValue] = useState("{}"); + const [isDarkMode, setIsDarkMode] = useState(false); + + useEffect(() => { + const nextValue = + envData && Object.keys(envData).length > 0 + ? JSON.stringify(envData, null, 2) + : "{}"; + setEditorValue(nextValue); + }, [envData]); + + useEffect(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + + const observer = new MutationObserver(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => observer.disconnect(); + }, []); + + const handleSave = async () => { + try { + const env = parseEnvEditorValue(editorValue); + await saveEnvMutation.mutateAsync(env); + toast.success(t("hermes.env.saveSuccess")); + } catch (error) { + const detail = extractErrorMessage(error); + let description = detail || undefined; + if (detail === "HERMES_ENV_EMPTY") { + description = t("hermes.env.empty", { + defaultValue: + "Hermes env cannot be empty. Use {} for an empty object.", + }); + } else if (detail === "HERMES_ENV_INVALID_JSON") { + description = t("hermes.env.invalidJson", { + defaultValue: "Hermes env must be valid JSON.", + }); + } else if (detail === "HERMES_ENV_OBJECT_REQUIRED") { + description = t("hermes.env.objectRequired", { + defaultValue: "Hermes env must be a JSON object.", + }); + } + toast.error(t("hermes.env.saveFailed"), { + description, + }); + } + }; + + if (isLoading) { + return ( +
+
+ {t("common.loading")} +
+
+ ); + } + + return ( +
+

+ {t("hermes.env.description")} +

+

+ {t("hermes.env.editorHint", { + defaultValue: + "Edit the Hermes .env file as a JSON key-value map. Keys become environment variable names.", + })} +

+ + + +
+ +
+
+ ); +}; + +export default EnvPanel; diff --git a/src/components/hermes/HermesHealthBanner.tsx b/src/components/hermes/HermesHealthBanner.tsx new file mode 100644 index 000000000..6907c6138 --- /dev/null +++ b/src/components/hermes/HermesHealthBanner.tsx @@ -0,0 +1,78 @@ +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { TriangleAlert } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import type { HermesHealthWarning } from "@/types"; + +interface HermesHealthBannerProps { + warnings: HermesHealthWarning[]; +} + +function getWarningText( + code: string, + fallback: string, + t: ReturnType["t"], +) { + switch (code) { + case "config_parse_failed": + return t("hermes.health.parseFailed", { + defaultValue: + "config.yaml could not be parsed as valid YAML. Fix the file before editing it here.", + }); + case "config_not_found": + return t("hermes.health.configNotFound", { + defaultValue: + "Hermes config.yaml not found. Create it at ~/.hermes/config.yaml or configure the path in settings.", + }); + case "env_parse_failed": + return t("hermes.health.envParseFailed", { + defaultValue: "The .env file could not be parsed.", + }); + default: + return fallback; + } +} + +const HermesHealthBanner: React.FC = ({ + warnings, +}) => { + const { t } = useTranslation(); + + const items = useMemo( + () => + warnings.map((warning) => ({ + ...warning, + text: getWarningText(warning.code, warning.message, t), + })), + [t, warnings], + ); + + if (warnings.length === 0) { + return null; + } + + return ( +
+ + + + {t("hermes.health.title", { + defaultValue: "Hermes config warnings detected", + })} + + +
    + {items.map((warning) => ( +
  • + {warning.text} + {warning.path ? ` (${warning.path})` : ""} +
  • + ))} +
+
+
+
+ ); +}; + +export default HermesHealthBanner; diff --git a/src/components/hermes/ModelPanel.tsx b/src/components/hermes/ModelPanel.tsx new file mode 100644 index 000000000..08eb2fbf5 --- /dev/null +++ b/src/components/hermes/ModelPanel.tsx @@ -0,0 +1,202 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Save } from "lucide-react"; +import { toast } from "sonner"; +import { + useHermesModelConfig, + useSaveHermesModelConfig, +} from "@/hooks/useHermes"; +import { extractErrorMessage } from "@/utils/errorUtils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { HermesModelConfig } from "@/types"; + +const ModelPanel: React.FC = () => { + const { t } = useTranslation(); + const { data: modelData, isLoading } = useHermesModelConfig(true); + const saveModelMutation = useSaveHermesModelConfig(); + + const [defaultModel, setDefaultModel] = useState(""); + const [provider, setProvider] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [contextLength, setContextLength] = useState(""); + const [maxTokens, setMaxTokens] = useState(""); + + // Preserve unknown fields from the original config + const [extra, setExtra] = useState>({}); + + useEffect(() => { + if (modelData === undefined) return; + if (modelData) { + setDefaultModel(modelData.default ?? ""); + setProvider(modelData.provider ?? ""); + setBaseUrl(modelData.base_url ?? ""); + setContextLength( + modelData.context_length != null + ? String(modelData.context_length) + : "", + ); + setMaxTokens( + modelData.max_tokens != null ? String(modelData.max_tokens) : "", + ); + // Collect unknown fields + const { + default: _d, + provider: _p, + base_url: _b, + context_length: _c, + max_tokens: _m, + ...rest + } = modelData; + setExtra(rest); + } else { + setDefaultModel(""); + setProvider(""); + setBaseUrl(""); + setContextLength(""); + setMaxTokens(""); + setExtra({}); + } + }, [modelData]); + + const handleSave = async () => { + try { + const config: HermesModelConfig = { + ...extra, + }; + if (defaultModel.trim()) config.default = defaultModel.trim(); + if (provider.trim()) config.provider = provider.trim(); + if (baseUrl.trim()) config.base_url = baseUrl.trim(); + + const cl = parseInt(contextLength); + if (!isNaN(cl) && cl > 0) config.context_length = cl; + + const mt = parseInt(maxTokens); + if (!isNaN(mt) && mt > 0) config.max_tokens = mt; + + await saveModelMutation.mutateAsync(config); + toast.success(t("hermes.model.saveSuccess")); + } catch (error) { + toast.error(t("hermes.model.saveFailed"), { + description: extractErrorMessage(error), + }); + } + }; + + if (isLoading) { + return ( +
+
+ {t("common.loading")} +
+
+ ); + } + + return ( +
+

+ {t("hermes.model.description")} +

+ +
+
+
+ + setDefaultModel(e.target.value)} + placeholder="anthropic/claude-opus-4-6" + /> +

+ {t("hermes.model.defaultHint", { + defaultValue: + "The default model to use, e.g. anthropic/claude-opus-4-6", + })} +

+
+ +
+ + setProvider(e.target.value)} + placeholder="openrouter" + /> +

+ {t("hermes.model.providerHint", { + defaultValue: + "Provider name for model routing (e.g. openrouter, anthropic)", + })} +

+
+ +
+ + setBaseUrl(e.target.value)} + placeholder="https://api.example.com/v1" + /> +

+ {t("hermes.model.baseUrlHint", { + defaultValue: "Override the API endpoint URL for this model", + })} +

+
+ +
+ + setContextLength(e.target.value)} + placeholder="200000" + /> +
+ +
+ + setMaxTokens(e.target.value)} + placeholder="16384" + /> +
+
+
+ +
+ +
+
+ ); +}; + +export default ModelPanel; diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index b6e9075a7..c611d0acf 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -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); diff --git a/src/components/providers/forms/HermesFormFields.tsx b/src/components/providers/forms/HermesFormFields.tsx new file mode 100644 index 000000000..0e8b5e5ab --- /dev/null +++ b/src/components/providers/forms/HermesFormFields.tsx @@ -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 */} +
+ + {t("hermes.form.baseUrl", { defaultValue: "API Endpoint" })} + + onBaseUrlChange(e.target.value)} + placeholder="https://api.example.com/v1" + /> +

+ {t("hermes.form.baseUrlHint", { + defaultValue: "The API endpoint URL for this provider.", + })} +

+
+ + {/* API Key */} + + + ); +} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 6d414c769..fba7b2624 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -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((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({

)} + ) : appId === "hermes" ? ( +
+ + + 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 && ( +

+ {t("hermes.form.providerKeyDuplicate")} +

+ )} + {hermesForm.hermesProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + hermesForm.hermesProviderKey, + ) && ( +

+ {t("hermes.form.providerKeyInvalid")} +

+ )} + {!( + additiveExistingProviderKeys.includes( + hermesForm.hermesProviderKey, + ) && !isProviderKeyLocked + ) && + (hermesForm.hermesProviderKey.trim() === "" || + /^[a-z0-9]+(-[a-z0-9]+)*$/.test( + hermesForm.hermesProviderKey, + )) && ( +

+ {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.", + })} +

+ )} +
) : undefined } /> @@ -1701,6 +1883,21 @@ export function ProviderForm({ /> )} + {/* Hermes 专属字段 */} + {appId === "hermes" && ( + + )} + {/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */} {appId === "codex" ? ( <> @@ -1784,7 +1981,7 @@ export function ProviderForm({ {settingsConfigErrorField} - ) : appId === "openclaw" ? ( + ) : appId === "openclaw" || appId === "hermes" ? ( <>