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
+10 -1
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)
+18
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>
+12 -5
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">
@@ -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("");
@@ -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("");
+13 -2
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) => {
@@ -15,6 +15,7 @@ const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
gemini: 8,
opencode: 8,
openclaw: 8,
hermes: 8,
};
interface TestResult {
@@ -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
+17 -6
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