From a0b585992ab760b9eb2bdedd27b94a0ed8de6ba8 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Apr 2026 17:41:33 +0800 Subject: [PATCH] feat: add Hermes frontend types, API layer, and hooks (Phase 7) - Add "hermes" to AppId union type and all exhaustive Record - Add HermesModelConfig, HermesAgentConfig, HermesEnvConfig types - Add hermes field to VisibleApps, McpApps, ProxyTakeoverStatus - Create src/lib/api/hermes.ts with Tauri invoke wrappers - Create src/hooks/useHermes.ts with 5 query + 3 mutation hooks - Register hermes in APP_IDS, APP_ICON_MAP (violet color scheme) - Split MCP_SKILLS_APP_IDS into MCP_APP_IDS (includes hermes) and SKILLS_APP_IDS (excludes hermes, since Hermes has no Skills support) - Wire hermes additive-mode into App.tsx (remove/duplicate handlers), ProviderList.tsx (live provider ID query + In Config badge), mutations.ts (cache invalidation on switch/add/delete) - Add Hermes checkbox to McpFormModal - Add basic hermes i18n keys (en/zh/ja) --- src/App.tsx | 30 +++- src/components/AppSwitcher.tsx | 11 +- src/components/mcp/McpFormModal.tsx | 18 +++ src/components/mcp/UnifiedMcpPanel.tsx | 17 ++- src/components/prompts/PromptFormModal.tsx | 1 + src/components/prompts/PromptFormPanel.tsx | 1 + src/components/providers/ProviderList.tsx | 15 +- .../providers/forms/EndpointSpeedTest.tsx | 1 + .../settings/AppVisibilitySettings.tsx | 2 + src/components/skills/UnifiedSkillsPanel.tsx | 23 ++- src/config/appConfig.tsx | 23 ++- src/hooks/useHermes.ts | 131 ++++++++++++++++++ src/i18n/locales/en.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/zh.json | 6 +- src/lib/api/hermes.ts | 92 ++++++++++++ src/lib/api/providers.ts | 16 +++ src/lib/api/skills.ts | 9 +- src/lib/api/types.ts | 8 +- src/lib/query/mutations.ts | 28 +++- src/types.ts | 39 ++++++ src/types/proxy.ts | 1 + tests/msw/state.ts | 16 ++- 23 files changed, 467 insertions(+), 33 deletions(-) create mode 100644 src/hooks/useHermes.ts create mode 100644 src/lib/api/hermes.ts diff --git a/src/App.tsx b/src/App.tsx index 4406ac75..8d2f04f7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,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 { useProxyStatus } from "@/hooks/useProxyStatus"; import { useAutoCompact } from "@/hooks/useAutoCompact"; import { useLastValidValue } from "@/hooks/useLastValidValue"; @@ -114,6 +115,7 @@ const VALID_APPS: AppId[] = [ "gemini", "opencode", "openclaw", + "hermes", ]; const getInitialApp = (): AppId => { @@ -174,6 +176,7 @@ function App() { gemini: true, opencode: true, openclaw: true, + hermes: true, }; const getFirstVisibleApp = (): AppId => { @@ -182,6 +185,7 @@ function App() { if (visibleApps.gemini) return "gemini"; if (visibleApps.opencode) return "opencode"; if (visibleApps.openclaw) return "openclaw"; + if (visibleApps.hermes) return "hermes"; return "claude"; // fallback }; @@ -654,6 +658,13 @@ function App() { await queryClient.invalidateQueries({ queryKey: openclawKeys.health, }); + } else if (activeApp === "hermes") { + await queryClient.invalidateQueries({ + queryKey: hermesKeys.liveProviderIds, + }); + await queryClient.invalidateQueries({ + queryKey: hermesKeys.health, + }); } toast.success( t("notifications.removeFromConfigSuccess", { @@ -704,7 +715,11 @@ function App() { iconColor: provider.iconColor, }; - if (activeApp === "opencode" || activeApp === "openclaw") { + if ( + activeApp === "opencode" || + activeApp === "openclaw" || + activeApp === "hermes" + ) { let liveProviderIds: string[] = []; try { liveProviderIds = @@ -713,10 +728,15 @@ function App() { queryKey: ["opencodeLiveProviderIds"], queryFn: () => providersApi.getOpenCodeLiveProviderIds(), }) - : await queryClient.ensureQueryData({ - queryKey: openclawKeys.liveProviderIds, - queryFn: () => providersApi.getOpenClawLiveProviderIds(), - }); + : activeApp === "openclaw" + ? await queryClient.ensureQueryData({ + queryKey: openclawKeys.liveProviderIds, + queryFn: () => providersApi.getOpenClawLiveProviderIds(), + }) + : await queryClient.ensureQueryData({ + queryKey: hermesKeys.liveProviderIds, + queryFn: () => providersApi.getHermesLiveProviderIds(), + }); } catch (error) { console.error( "[App] Failed to load live provider IDs for duplication", diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 61d780e9..a1090024 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -10,7 +10,14 @@ interface AppSwitcherProps { compact?: boolean; } -const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode", "openclaw"]; +const ALL_APPS: AppId[] = [ + "claude", + "codex", + "gemini", + "opencode", + "openclaw", + "hermes", +]; const STORAGE_KEY = "cc-switch-last-app"; export function AppSwitcher({ @@ -31,6 +38,7 @@ export function AppSwitcher({ gemini: "gemini", opencode: "opencode", openclaw: "openclaw", + hermes: "hermes", }; const appDisplayName: Record = { claude: "Claude", @@ -38,6 +46,7 @@ export function AppSwitcher({ gemini: "Gemini", opencode: "OpenCode", openclaw: "OpenClaw", + hermes: "Hermes", }; // Filter apps based on visibility settings (default all visible) diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 70ab3b03..0fd784b0 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -67,6 +67,7 @@ const McpFormModal: React.FC = ({ gemini: boolean; opencode: boolean; openclaw: boolean; + hermes: boolean; }>(() => { if (initialData?.apps) { return { ...initialData.apps }; @@ -77,6 +78,7 @@ const McpFormModal: React.FC = ({ gemini: defaultEnabledApps.includes("gemini"), opencode: defaultEnabledApps.includes("opencode"), openclaw: defaultEnabledApps.includes("openclaw"), + hermes: defaultEnabledApps.includes("hermes"), }; }); @@ -579,6 +581,22 @@ const McpFormModal: React.FC = ({ {t("mcp.unifiedPanel.apps.opencode")} + +
+ + setEnabledApps({ ...enabledApps, hermes: checked }) + } + /> + +
diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index 761ddcd6..d83355a3 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -17,7 +17,7 @@ import { Edit3, Trash2, ExternalLink } from "lucide-react"; import { settingsApi } from "@/lib/api"; import { mcpPresets } from "@/config/mcpPresets"; import { toast } from "sonner"; -import { MCP_SKILLS_APP_IDS } from "@/config/appConfig"; +import { MCP_APP_IDS } from "@/config/appConfig"; import { AppCountBar } from "@/components/common/AppCountBar"; import { AppToggleGroup } from "@/components/common/AppToggleGroup"; import { ListItemRow } from "@/components/common/ListItemRow"; @@ -56,9 +56,16 @@ const UnifiedMcpPanel = React.forwardRef< }, [serversMap]); const enabledCounts = useMemo(() => { - const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 }; + const counts = { + claude: 0, + codex: 0, + gemini: 0, + opencode: 0, + openclaw: 0, + hermes: 0, + }; serverEntries.forEach(([_, server]) => { - for (const app of MCP_SKILLS_APP_IDS) { + for (const app of MCP_APP_IDS) { if (server.apps[app]) counts[app]++; } }); @@ -136,7 +143,7 @@ const UnifiedMcpPanel = React.forwardRef<
@@ -278,7 +285,7 @@ const UnifiedMcpListItem: React.FC = ({ onToggleApp(id, app, enabled)} - appIds={MCP_SKILLS_APP_IDS} + appIds={MCP_APP_IDS} />
diff --git a/src/components/prompts/PromptFormModal.tsx b/src/components/prompts/PromptFormModal.tsx index 84e18d25..0a9c86f9 100644 --- a/src/components/prompts/PromptFormModal.tsx +++ b/src/components/prompts/PromptFormModal.tsx @@ -35,6 +35,7 @@ const PromptFormModal: React.FC = ({ codex: "AGENTS.md", gemini: "GEMINI.md", opencode: "AGENTS.md", + hermes: "AGENTS.md", }; const filename = filenameMap[appId as Exclude]; const [name, setName] = useState(""); diff --git a/src/components/prompts/PromptFormPanel.tsx b/src/components/prompts/PromptFormPanel.tsx index c4481fa4..cdd80d0d 100644 --- a/src/components/prompts/PromptFormPanel.tsx +++ b/src/components/prompts/PromptFormPanel.tsx @@ -30,6 +30,7 @@ const PromptFormPanel: React.FC = ({ gemini: "GEMINI.md", opencode: "AGENTS.md", openclaw: "AGENTS.md", + hermes: "AGENTS.md", }; const filename = filenameMap[appId]; const [name, setName] = useState(""); diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index c0485b15..bdaaeb37 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -25,6 +25,7 @@ import { useOpenClawLiveProviderIds, useOpenClawDefaultModel, } from "@/hooks/useOpenClaw"; +import { useHermesLiveProviderIds } from "@/hooks/useHermes"; import { useStreamCheck } from "@/hooks/useStreamCheck"; import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; @@ -105,7 +106,10 @@ export function ProviderList({ appId === "openclaw", ); - // 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw) + // Hermes: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig + const { data: hermesLiveIds } = useHermesLiveProviderIds(appId === "hermes"); + + // 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw/Hermes) const isProviderInConfig = useCallback( (providerId: string): boolean => { if (appId === "opencode") { @@ -114,9 +118,12 @@ export function ProviderList({ if (appId === "openclaw") { return openclawLiveIds?.includes(providerId) ?? false; } + if (appId === "hermes") { + return hermesLiveIds?.includes(providerId) ?? false; + } return true; // 其他应用始终返回 true }, - [appId, opencodeLiveIds, openclawLiveIds], + [appId, opencodeLiveIds, openclawLiveIds, hermesLiveIds], ); // OpenClaw: query default model to determine which provider is default @@ -229,6 +236,10 @@ export function ProviderList({ const count = await providersApi.importOpenClawFromLive(); return count > 0; } + if (appId === "hermes") { + const count = await providersApi.importHermesFromLive(); + return count > 0; + } return providersApi.importDefault(appId); }, onSuccess: (imported) => { diff --git a/src/components/providers/forms/EndpointSpeedTest.tsx b/src/components/providers/forms/EndpointSpeedTest.tsx index 2143d21d..27913254 100644 --- a/src/components/providers/forms/EndpointSpeedTest.tsx +++ b/src/components/providers/forms/EndpointSpeedTest.tsx @@ -15,6 +15,7 @@ const ENDPOINT_TIMEOUT_SECS: Record = { gemini: 8, opencode: 8, openclaw: 8, + hermes: 8, }; interface TestResult { diff --git a/src/components/settings/AppVisibilitySettings.tsx b/src/components/settings/AppVisibilitySettings.tsx index 0c30472c..98f1bdd4 100644 --- a/src/components/settings/AppVisibilitySettings.tsx +++ b/src/components/settings/AppVisibilitySettings.tsx @@ -21,6 +21,7 @@ const APP_CONFIG: Array<{ { id: "gemini", icon: "gemini", nameKey: "apps.gemini" }, { id: "opencode", icon: "opencode", nameKey: "apps.opencode" }, { id: "openclaw", icon: "openclaw", nameKey: "apps.openclaw" }, + { id: "hermes", icon: "hermes", nameKey: "apps.hermes" }, ]; export function AppVisibilitySettings({ @@ -35,6 +36,7 @@ export function AppVisibilitySettings({ gemini: true, opencode: true, openclaw: true, + hermes: true, }; // Count how many apps are currently visible diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 701af492..77199ef1 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -31,7 +31,7 @@ import type { AppId } from "@/lib/api/types"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { settingsApi, skillsApi } from "@/lib/api"; import { toast } from "sonner"; -import { MCP_SKILLS_APP_IDS } from "@/config/appConfig"; +import { SKILLS_APP_IDS } from "@/config/appConfig"; import { AppCountBar } from "@/components/common/AppCountBar"; import { AppToggleGroup } from "@/components/common/AppToggleGroup"; import { ListItemRow } from "@/components/common/ListItemRow"; @@ -113,10 +113,17 @@ const UnifiedSkillsPanel = React.forwardRef< }, [skillUpdates]); const enabledCounts = useMemo(() => { - const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 }; + const counts = { + claude: 0, + codex: 0, + gemini: 0, + opencode: 0, + openclaw: 0, + hermes: 0, + }; if (!skills) return counts; skills.forEach((skill) => { - for (const app of MCP_SKILLS_APP_IDS) { + for (const app of SKILLS_APP_IDS) { if (skill.apps[app]) counts[app]++; } }); @@ -342,7 +349,7 @@ const UnifiedSkillsPanel = React.forwardRef<
= ({ onToggleApp(skill.id, app, enabled)} - appIds={MCP_SKILLS_APP_IDS} + appIds={SKILLS_APP_IDS} />
= ({ gemini: skill.foundIn.includes("gemini"), opencode: skill.foundIn.includes("opencode"), openclaw: false, + hermes: skill.foundIn.includes("hermes"), }, ]), ), @@ -761,6 +769,7 @@ const ImportSkillsDialog: React.FC = ({ gemini: false, opencode: false, openclaw: false, + hermes: false, }, })), ); @@ -803,6 +812,7 @@ const ImportSkillsDialog: React.FC = ({ gemini: false, opencode: false, openclaw: false, + hermes: false, } } onToggle={(app, enabled) => { @@ -815,12 +825,13 @@ const ImportSkillsDialog: React.FC = ({ gemini: false, opencode: false, openclaw: false, + hermes: false, }), [app]: enabled, }, })); }} - appIds={MCP_SKILLS_APP_IDS} + appIds={SKILLS_APP_IDS} />
= { claude: { label: "Claude", @@ -79,4 +83,19 @@ export const APP_ICON_MAP: Record = { badgeClass: "bg-rose-500/10 text-rose-700 dark:text-rose-300 hover:bg-rose-500/20 border-0 gap-1.5", }, + hermes: { + label: "Hermes", + icon: ( + + ), + activeClass: + "bg-violet-500/10 ring-1 ring-violet-500/20 hover:bg-violet-500/20 text-violet-600 dark:text-violet-400", + badgeClass: + "bg-violet-500/10 text-violet-700 dark:text-violet-300 hover:bg-violet-500/20 border-0 gap-1.5", + }, }; diff --git a/src/hooks/useHermes.ts b/src/hooks/useHermes.ts new file mode 100644 index 00000000..c963faeb --- /dev/null +++ b/src/hooks/useHermes.ts @@ -0,0 +1,131 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { hermesApi } from "@/lib/api/hermes"; +import { providersApi } from "@/lib/api/providers"; +import type { + HermesEnvConfig, + HermesAgentConfig, + HermesModelConfig, +} from "@/types"; + +/** + * Centralized query keys for all Hermes-related queries. + * Import this from any file that needs to invalidate Hermes caches. + */ +export const hermesKeys = { + all: ["hermes"] as const, + liveProviderIds: ["hermes", "liveProviderIds"] as const, + modelConfig: ["hermes", "modelConfig"] as const, + agentConfig: ["hermes", "agentConfig"] as const, + env: ["hermes", "env"] as const, + health: ["hermes", "health"] as const, +}; + +// ============================================================ +// Query hooks +// ============================================================ + +/** + * Query live provider IDs from Hermes config. + * Used by ProviderList to show "In Config" badge. + */ +export function useHermesLiveProviderIds(enabled: boolean) { + return useQuery({ + queryKey: hermesKeys.liveProviderIds, + queryFn: () => providersApi.getHermesLiveProviderIds(), + enabled, + }); +} + +/** + * Query model configuration. + */ +export function useHermesModelConfig(enabled: boolean) { + return useQuery({ + queryKey: hermesKeys.modelConfig, + queryFn: () => hermesApi.getModelConfig(), + enabled, + }); +} + +/** + * Query agent configuration. + */ +export function useHermesAgentConfig() { + return useQuery({ + queryKey: hermesKeys.agentConfig, + queryFn: () => hermesApi.getAgentConfig(), + staleTime: 30_000, + }); +} + +/** + * Query env configuration. + */ +export function useHermesEnv() { + return useQuery({ + queryKey: hermesKeys.env, + queryFn: () => hermesApi.getEnv(), + staleTime: 30_000, + }); +} + +/** + * Query config health warnings. + */ +export function useHermesHealth(enabled: boolean) { + return useQuery({ + queryKey: hermesKeys.health, + queryFn: () => hermesApi.scanHealth(), + staleTime: 30_000, + enabled, + }); +} + +// ============================================================ +// Mutation hooks +// ============================================================ + +/** + * Save model config. Invalidates modelConfig and health queries on success. + * Toast notifications are handled by the component. + */ +export function useSaveHermesModelConfig() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (config: HermesModelConfig) => hermesApi.setModelConfig(config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: hermesKeys.modelConfig }); + queryClient.invalidateQueries({ queryKey: hermesKeys.health }); + }, + }); +} + +/** + * Save agent config. Invalidates agentConfig and health queries on success. + * Toast notifications are handled by the component. + */ +export function useSaveHermesAgentConfig() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (config: HermesAgentConfig) => hermesApi.setAgentConfig(config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: hermesKeys.agentConfig }); + queryClient.invalidateQueries({ queryKey: hermesKeys.health }); + }, + }); +} + +/** + * Save env config. Invalidates env and health queries on success. + * Toast notifications are handled by the component. + */ +export function useSaveHermesEnv() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (env: HermesEnvConfig) => hermesApi.setEnv(env), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: hermesKeys.env }); + queryClient.invalidateQueries({ queryKey: hermesKeys.health }); + }, + }); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c7f089cd..790660aa 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -658,7 +658,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" }, "sessionManager": { "title": "Session Manager", @@ -1308,7 +1309,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" } }, "userLevelPath": "User-level MCP path", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 156653fd..c7b19cbe 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -658,7 +658,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" }, "sessionManager": { "title": "セッション管理", @@ -1308,7 +1309,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" } }, "userLevelPath": "ユーザーレベルの MCP パス", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f8a59d5d..6b18d5be 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -658,7 +658,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" }, "sessionManager": { "title": "会话管理", @@ -1309,7 +1310,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" } }, "userLevelPath": "用户级 MCP 配置路径", diff --git a/src/lib/api/hermes.ts b/src/lib/api/hermes.ts new file mode 100644 index 00000000..26bd1789 --- /dev/null +++ b/src/lib/api/hermes.ts @@ -0,0 +1,92 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { + HermesModelConfig, + HermesAgentConfig, + HermesEnvConfig, + HermesHealthWarning, + HermesWriteOutcome, +} from "@/types"; + +/** + * Hermes Agent configuration API + * + * Manages Hermes config sections: + * - model (model selection and provider) + * - agent (agent behavior) + * - env (environment variables) + */ +export const hermesApi = { + // ============================================================ + // Model Configuration + // ============================================================ + + /** + * Get model configuration + */ + async getModelConfig(): Promise { + return await invoke("get_hermes_model_config"); + }, + + /** + * Set model configuration + */ + async setModelConfig(config: HermesModelConfig): Promise { + return await invoke("set_hermes_model_config", { config }); + }, + + // ============================================================ + // Agent Configuration + // ============================================================ + + /** + * Get agent configuration + */ + async getAgentConfig(): Promise { + return await invoke("get_hermes_agent_config"); + }, + + /** + * Set agent configuration + */ + async setAgentConfig(config: HermesAgentConfig): Promise { + return await invoke("set_hermes_agent_config", { config }); + }, + + // ============================================================ + // Env Configuration + // ============================================================ + + /** + * Get env configuration (.env file) + */ + async getEnv(): Promise { + return await invoke("get_hermes_env"); + }, + + /** + * Set env configuration (.env file) + */ + async setEnv(env: HermesEnvConfig): Promise { + return await invoke("set_hermes_env", { env }); + }, + + // ============================================================ + // Health + // ============================================================ + + /** + * Scan config health and return warnings + */ + async scanHealth(): Promise { + return await invoke("scan_hermes_config_health"); + }, + + /** + * Get live provider config by ID + */ + async getLiveProvider( + providerId: string, + ): Promise | null> { + return await invoke("get_hermes_live_provider", { providerId }); + }, +}; diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index 89b6b7c7..ba7bd2e7 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -136,6 +136,14 @@ export const providersApi = { return await invoke("get_openclaw_live_provider_ids"); }, + /** + * 获取 Hermes live 配置中的供应商 ID 列表 + * 用于前端判断供应商是否已添加到 Hermes 配置 + */ + async getHermesLiveProviderIds(): Promise { + return await invoke("get_hermes_live_provider_ids"); + }, + /** * 从 OpenClaw live 配置导入供应商到数据库 * OpenClaw 特有功能:由于累加模式,用户可能已在 openclaw.json 中配置供应商 @@ -143,6 +151,14 @@ export const providersApi = { async importOpenClawFromLive(): Promise { return await invoke("import_openclaw_providers_from_live"); }, + + /** + * 从 Hermes live 配置导入供应商到数据库 + * Hermes 特有功能:由于累加模式,用户可能已在 Hermes 配置中配置供应商 + */ + async importHermesFromLive(): Promise { + return await invoke("import_hermes_providers_from_live"); + }, }; // ============================================================================ diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 456e57b6..3c526307 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -2,7 +2,13 @@ import { invoke } from "@tauri-apps/api/core"; import type { AppId } from "@/lib/api/types"; -export type AppType = "claude" | "codex" | "gemini" | "opencode" | "openclaw"; +export type AppType = + | "claude" + | "codex" + | "gemini" + | "opencode" + | "openclaw" + | "hermes"; /** Skill 应用启用状态 */ export interface SkillApps { @@ -11,6 +17,7 @@ export interface SkillApps { gemini: boolean; opencode: boolean; openclaw: boolean; + hermes: boolean; } /** 已安装的 Skill(v3.10.0+ 统一结构) */ diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 1fff721f..4ec68d6b 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -1,2 +1,8 @@ // 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致) -export type AppId = "claude" | "codex" | "gemini" | "opencode" | "openclaw"; +export type AppId = + | "claude" + | "codex" + | "gemini" + | "opencode" + | "openclaw" + | "hermes"; diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index e5ab2815..ed1ee04d 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -8,6 +8,7 @@ import type { Provider, SessionMeta, Settings } from "@/types"; import { extractErrorMessage } from "@/utils/errorUtils"; import { generateUUID } from "@/utils/uuid"; import { openclawKeys } from "@/hooks/useOpenClaw"; +import { hermesKeys } from "@/hooks/useHermes"; export const useAddProviderMutation = (appId: AppId) => { const queryClient = useQueryClient(); @@ -22,7 +23,7 @@ export const useAddProviderMutation = (appId: AppId) => { ) => { let id: string; - if (appId === "opencode" || appId === "openclaw") { + if (appId === "opencode" || appId === "openclaw" || appId === "hermes") { if ( providerInput.category === "omo" || providerInput.category === "omo-slim" @@ -75,6 +76,12 @@ export const useAddProviderMutation = (appId: AppId) => { }); } + if (appId === "hermes") { + await queryClient.invalidateQueries({ + queryKey: hermesKeys.health, + }); + } + try { await providersApi.updateTrayMenu(); } catch (trayError) { @@ -127,6 +134,11 @@ export const useUpdateProviderMutation = (appId: AppId) => { queryKey: openclawKeys.health, }); } + if (appId === "hermes") { + await queryClient.invalidateQueries({ + queryKey: hermesKeys.health, + }); + } toast.success( t("notifications.updateSuccess", { defaultValue: "供应商更新成功", @@ -180,6 +192,12 @@ export const useDeleteProviderMutation = (appId: AppId) => { }); } + if (appId === "hermes") { + await queryClient.invalidateQueries({ + queryKey: hermesKeys.health, + }); + } + try { await providersApi.updateTrayMenu(); } catch (trayError) { @@ -244,6 +262,14 @@ export const useSwitchProviderMutation = (appId: AppId) => { queryKey: openclawKeys.health, }); } + if (appId === "hermes") { + await queryClient.invalidateQueries({ + queryKey: hermesKeys.liveProviderIds, + }); + await queryClient.invalidateQueries({ + queryKey: hermesKeys.health, + }); + } try { await providersApi.updateTrayMenu(); diff --git a/src/types.ts b/src/types.ts index 8379c217..9c8e7b90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -188,6 +188,7 @@ export interface VisibleApps { gemini: boolean; opencode: boolean; openclaw: boolean; + hermes: boolean; } // WebDAV 同步状态 @@ -281,6 +282,8 @@ export interface Settings { opencodeConfigDir?: string; // 覆盖 OpenClaw 配置目录(可选) openclawConfigDir?: string; + // 覆盖 Hermes 配置目录(可选) + hermesConfigDir?: string; // ===== 当前供应商 ID(设备级)===== // 当前 Claude 供应商 ID(优先于数据库 is_current) @@ -354,6 +357,7 @@ export interface McpApps { gemini: boolean; opencode: boolean; openclaw: boolean; + hermes: boolean; } // MCP 服务器条目(v3.7.0 统一结构) @@ -569,3 +573,38 @@ export interface OpenClawToolsConfig { deny?: string[]; [key: string]: unknown; // preserve unknown fields } + +// ============================================================================ +// Hermes Agent 专属配置 +// ============================================================================ + +export interface HermesModelConfig { + default?: string; + provider?: string; + base_url?: string; + context_length?: number; + max_tokens?: number; + [key: string]: unknown; +} + +export interface HermesAgentConfig { + max_turns?: number; + reasoning_effort?: string; + tool_use_enforcement?: string | boolean | string[]; + [key: string]: unknown; +} + +export interface HermesEnvConfig { + [key: string]: unknown; +} + +export interface HermesHealthWarning { + code: string; + message: string; + path?: string; +} + +export interface HermesWriteOutcome { + backupPath?: string; + warnings: HermesHealthWarning[]; +} diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 29fac7fc..601ef3b1 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -47,6 +47,7 @@ export interface ProxyTakeoverStatus { gemini: boolean; opencode: boolean; openclaw: boolean; + hermes: boolean; } export interface ProviderHealth { diff --git a/tests/msw/state.ts b/tests/msw/state.ts index 9768914d..4e589d23 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -10,7 +10,7 @@ import type { type ProvidersByApp = Record>; type CurrentProviderState = Record; type McpConfigState = Record>; -type LiveProviderIdsByApp = Record<"opencode" | "openclaw", string[]>; +type LiveProviderIdsByApp = Record<"opencode" | "openclaw" | "hermes", string[]>; const createDefaultProviders = (): ProvidersByApp => ({ claude: { @@ -66,6 +66,7 @@ const createDefaultProviders = (): ProvidersByApp => ({ }, opencode: {}, openclaw: {}, + hermes: {}, }); const createDefaultCurrent = (): CurrentProviderState => ({ @@ -74,6 +75,7 @@ const createDefaultCurrent = (): CurrentProviderState => ({ gemini: "gemini-1", opencode: "", openclaw: "", + hermes: "", }); let providers = createDefaultProviders(); @@ -81,6 +83,7 @@ let current = createDefaultCurrent(); let liveProviderIds: LiveProviderIdsByApp = { opencode: [], openclaw: [], + hermes: [], }; let settingsState: Settings = { showInTray: true, @@ -153,6 +156,7 @@ let mcpConfigs: McpConfigState = { gemini: false, opencode: false, openclaw: false, + hermes: false, }, server: { type: "stdio", @@ -171,6 +175,7 @@ let mcpConfigs: McpConfigState = { gemini: false, opencode: false, openclaw: false, + hermes: false, }, server: { type: "http", @@ -181,6 +186,7 @@ let mcpConfigs: McpConfigState = { gemini: {}, opencode: {}, openclaw: {}, + hermes: {}, }; const cloneProviders = (value: ProvidersByApp) => @@ -192,6 +198,7 @@ export const resetProviderState = () => { liveProviderIds = { opencode: [], openclaw: [], + hermes: [], }; sessionsState = createDefaultSessions(); sessionMessagesState = createDefaultSessionMessages(); @@ -216,6 +223,7 @@ export const resetProviderState = () => { gemini: false, opencode: false, openclaw: false, + hermes: false, }, server: { type: "stdio", @@ -234,6 +242,7 @@ export const resetProviderState = () => { gemini: false, opencode: false, openclaw: false, + hermes: false, }, server: { type: "http", @@ -244,6 +253,7 @@ export const resetProviderState = () => { gemini: {}, opencode: {}, openclaw: {}, + hermes: {}, }; }; @@ -252,12 +262,12 @@ export const getProviders = (appType: AppId) => export const getCurrentProviderId = (appType: AppId) => current[appType] ?? ""; -export const getLiveProviderIds = (appType: "opencode" | "openclaw") => [ +export const getLiveProviderIds = (appType: "opencode" | "openclaw" | "hermes") => [ ...liveProviderIds[appType], ]; export const setLiveProviderIds = ( - appType: "opencode" | "openclaw", + appType: "opencode" | "openclaw" | "hermes", ids: string[], ) => { liveProviderIds[appType] = [...ids];