feat: add Hermes frontend types, API layer, and hooks (Phase 7)

- Add "hermes" to AppId union type and all exhaustive Record<AppId>
- 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)
This commit is contained in:
Jason
2026-04-15 17:41:33 +08:00
parent 576ff53a75
commit a0b585992a
23 changed files with 467 additions and 33 deletions

View File

@@ -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",

View File

@@ -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<AppId, string> = {
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)

View File

@@ -67,6 +67,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
gemini: boolean;
opencode: boolean;
openclaw: boolean;
hermes: boolean;
}>(() => {
if (initialData?.apps) {
return { ...initialData.apps };
@@ -77,6 +78,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
gemini: defaultEnabledApps.includes("gemini"),
opencode: defaultEnabledApps.includes("opencode"),
openclaw: defaultEnabledApps.includes("openclaw"),
hermes: defaultEnabledApps.includes("hermes"),
};
});
@@ -579,6 +581,22 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
{t("mcp.unifiedPanel.apps.opencode")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-hermes"
checked={enabledApps.hermes}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, hermes: checked })
}
/>
<label
htmlFor="enable-hermes"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.hermes")}
</label>
</div>
</div>
</div>

View File

@@ -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<
<AppCountBar
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
counts={enabledCounts}
appIds={MCP_SKILLS_APP_IDS}
appIds={MCP_APP_IDS}
/>
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
@@ -278,7 +285,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<AppToggleGroup
apps={server.apps}
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
appIds={MCP_SKILLS_APP_IDS}
appIds={MCP_APP_IDS}
/>
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">

View File

@@ -35,6 +35,7 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
codex: "AGENTS.md",
gemini: "GEMINI.md",
opencode: "AGENTS.md",
hermes: "AGENTS.md",
};
const filename = filenameMap[appId as Exclude<AppId, "openclaw">];
const [name, setName] = useState("");

View File

@@ -30,6 +30,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
gemini: "GEMINI.md",
opencode: "AGENTS.md",
openclaw: "AGENTS.md",
hermes: "AGENTS.md",
};
const filename = filenameMap[appId];
const [name, setName] = useState("");

View File

@@ -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) => {

View File

@@ -15,6 +15,7 @@ const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
gemini: 8,
opencode: 8,
openclaw: 8,
hermes: 8,
};
interface TestResult {

View File

@@ -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

View File

@@ -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<
<AppCountBar
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
counts={enabledCounts}
appIds={MCP_SKILLS_APP_IDS}
appIds={SKILLS_APP_IDS}
/>
<div className="flex items-center gap-1.5">
<div
@@ -546,7 +553,7 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
<AppToggleGroup
apps={skill.apps}
onToggle={(app, enabled) => onToggleApp(skill.id, app, enabled)}
appIds={MCP_SKILLS_APP_IDS}
appIds={SKILLS_APP_IDS}
/>
<div
@@ -736,6 +743,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
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<ImportSkillsDialogProps> = ({
gemini: false,
opencode: false,
openclaw: false,
hermes: false,
},
})),
);
@@ -803,6 +812,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
gemini: false,
opencode: false,
openclaw: false,
hermes: false,
}
}
onToggle={(app, enabled) => {
@@ -815,12 +825,13 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
gemini: false,
opencode: false,
openclaw: false,
hermes: false,
}),
[app]: enabled,
},
}));
}}
appIds={MCP_SKILLS_APP_IDS}
appIds={SKILLS_APP_IDS}
/>
</div>
<div

View File

@@ -21,16 +21,20 @@ export const APP_IDS: AppId[] = [
"gemini",
"opencode",
"openclaw",
"hermes",
];
/** App IDs shown in MCP & Skills panels (excludes OpenClaw) */
export const MCP_SKILLS_APP_IDS: AppId[] = [
/** App IDs shown in Skills panels (excludes OpenClaw and Hermes — neither supports Skills) */
export const SKILLS_APP_IDS: AppId[] = [
"claude",
"codex",
"gemini",
"opencode",
];
/** App IDs shown in MCP panels (excludes OpenClaw) */
export const MCP_APP_IDS: AppId[] = [...SKILLS_APP_IDS, "hermes"];
export const APP_ICON_MAP: Record<AppId, AppConfig> = {
claude: {
label: "Claude",
@@ -79,4 +83,19 @@ export const APP_ICON_MAP: Record<AppId, AppConfig> = {
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: (
<ProviderIcon
icon="hermes"
name="Hermes"
size={14}
showFallback={false}
/>
),
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",
},
};

131
src/hooks/useHermes.ts Normal file
View File

@@ -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 });
},
});
}

View File

@@ -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",

View File

@@ -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 パス",

View File

@@ -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 配置路径",

92
src/lib/api/hermes.ts Normal file
View File

@@ -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<HermesModelConfig | null> {
return await invoke("get_hermes_model_config");
},
/**
* Set model configuration
*/
async setModelConfig(config: HermesModelConfig): Promise<HermesWriteOutcome> {
return await invoke("set_hermes_model_config", { config });
},
// ============================================================
// Agent Configuration
// ============================================================
/**
* Get agent configuration
*/
async getAgentConfig(): Promise<HermesAgentConfig | null> {
return await invoke("get_hermes_agent_config");
},
/**
* Set agent configuration
*/
async setAgentConfig(config: HermesAgentConfig): Promise<HermesWriteOutcome> {
return await invoke("set_hermes_agent_config", { config });
},
// ============================================================
// Env Configuration
// ============================================================
/**
* Get env configuration (.env file)
*/
async getEnv(): Promise<HermesEnvConfig> {
return await invoke("get_hermes_env");
},
/**
* Set env configuration (.env file)
*/
async setEnv(env: HermesEnvConfig): Promise<HermesWriteOutcome> {
return await invoke("set_hermes_env", { env });
},
// ============================================================
// Health
// ============================================================
/**
* Scan config health and return warnings
*/
async scanHealth(): Promise<HermesHealthWarning[]> {
return await invoke("scan_hermes_config_health");
},
/**
* Get live provider config by ID
*/
async getLiveProvider(
providerId: string,
): Promise<Record<string, unknown> | null> {
return await invoke("get_hermes_live_provider", { providerId });
},
};

View File

@@ -136,6 +136,14 @@ export const providersApi = {
return await invoke("get_openclaw_live_provider_ids");
},
/**
* 获取 Hermes live 配置中的供应商 ID 列表
* 用于前端判断供应商是否已添加到 Hermes 配置
*/
async getHermesLiveProviderIds(): Promise<string[]> {
return await invoke("get_hermes_live_provider_ids");
},
/**
* 从 OpenClaw live 配置导入供应商到数据库
* OpenClaw 特有功能:由于累加模式,用户可能已在 openclaw.json 中配置供应商
@@ -143,6 +151,14 @@ export const providersApi = {
async importOpenClawFromLive(): Promise<number> {
return await invoke("import_openclaw_providers_from_live");
},
/**
* 从 Hermes live 配置导入供应商到数据库
* Hermes 特有功能:由于累加模式,用户可能已在 Hermes 配置中配置供应商
*/
async importHermesFromLive(): Promise<number> {
return await invoke("import_hermes_providers_from_live");
},
};
// ============================================================================

View File

@@ -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;
}
/** 已安装的 Skillv3.10.0+ 统一结构) */

View File

@@ -1,2 +1,8 @@
// 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致)
export type AppId = "claude" | "codex" | "gemini" | "opencode" | "openclaw";
export type AppId =
| "claude"
| "codex"
| "gemini"
| "opencode"
| "openclaw"
| "hermes";

View File

@@ -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();

View File

@@ -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[];
}

View File

@@ -47,6 +47,7 @@ export interface ProxyTakeoverStatus {
gemini: boolean;
opencode: boolean;
openclaw: boolean;
hermes: boolean;
}
export interface ProviderHealth {

View File

@@ -10,7 +10,7 @@ import type {
type ProvidersByApp = Record<AppId, Record<string, Provider>>;
type CurrentProviderState = Record<AppId, string>;
type McpConfigState = Record<AppId, Record<string, McpServer>>;
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];