refactor(hermes): delegate deep config to Hermes Web UI

Slim the Hermes surface in CC Switch to match its core positioning —
cross-client provider switching and shared MCP/prompts/skills — and
delegate deep configuration (model, agent, env, skills, cron, logs)
to the Hermes Web UI at http://127.0.0.1:9119.

- Drop AgentPanel/EnvPanel/ModelPanel and their mutation commands,
  hooks, types, and i18n keys across zh/en/ja.
- Add open_hermes_web_ui Tauri command that probes /api/status and
  launches the URL in the system browser. Hermes injects its own
  session token into the returned HTML, so CC Switch doesn't need
  to touch auth.
- Surface the launcher from the Hermes toolbar and the health banner
  via a shared useOpenHermesWebUI() hook; the offline error code is
  defined once per side and referenced across the contract.
- Keep read-only access to model.provider so ProviderList can still
  highlight the active supplier; apply_switch_defaults continues to
  write the top-level model section when switching providers.

Net diff: +152 / -1253.
This commit is contained in:
Jason
2026-04-19 20:59:01 +08:00
parent 041f74db18
commit 088b47b08a
14 changed files with 152 additions and 1253 deletions
+19 -55
View File
@@ -25,8 +25,7 @@ import {
KeyRound,
Shield,
Cpu,
Brain,
Bot,
ExternalLink,
} from "lucide-react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { Provider, VisibleApps } from "@/types";
@@ -41,7 +40,11 @@ import {
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { openclawKeys, useOpenClawHealth } from "@/hooks/useOpenClaw";
import { hermesKeys, useHermesHealth } from "@/hooks/useHermes";
import {
hermesKeys,
useHermesHealth,
useOpenHermesWebUI,
} from "@/hooks/useHermes";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useAutoCompact } from "@/hooks/useAutoCompact";
import { useLastValidValue } from "@/hooks/useLastValidValue";
@@ -85,9 +88,6 @@ 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 =
@@ -103,10 +103,7 @@ type View =
| "workspace"
| "openclawEnv"
| "openclawTools"
| "openclawAgents"
| "hermesModel"
| "hermesAgent"
| "hermesEnv";
| "openclawAgents";
interface WebDavSyncStatusUpdatedPayload {
source?: string;
@@ -150,9 +147,6 @@ const VALID_VIEWS: View[] = [
"openclawEnv",
"openclawTools",
"openclawAgents",
"hermesModel",
"hermesAgent",
"hermesEnv",
];
const getInitialView = (): View => {
@@ -272,12 +266,7 @@ function App() {
currentView === "openclawAgents");
const { data: openclawHealthWarnings = [] } =
useOpenClawHealth(isOpenClawView);
const isHermesView =
activeApp === "hermes" &&
(currentView === "providers" ||
currentView === "hermesModel" ||
currentView === "hermesAgent" ||
currentView === "hermesEnv");
const isHermesView = activeApp === "hermes" && currentView === "providers";
const { data: hermesHealthWarnings = [] } = useHermesHealth(isHermesView);
const hasSkillsSupport = true;
const hasSessionSupport =
@@ -635,6 +624,8 @@ function App() {
};
}, []);
const openHermesWebUI = useOpenHermesWebUI();
const handleOpenWebsite = async (url: string) => {
try {
await settingsApi.openExternal(url);
@@ -961,12 +952,6 @@ function App() {
return <ToolsPanel />;
case "openclawAgents":
return <AgentsDefaultsPanel />;
case "hermesModel":
return <HermesModelPanel />;
case "hermesAgent":
return <HermesAgentPanel />;
case "hermesEnv":
return <HermesEnvPanel />;
default:
return (
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden">
@@ -1186,9 +1171,6 @@ 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")}
</h1>
</div>
) : (
@@ -1399,33 +1381,6 @@ function App() {
>
{activeApp === "hermes" ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("hermesModel")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("hermes.model.title")}
>
<Brain className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("hermesAgent")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("hermes.agent.title")}
>
<Bot className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("hermesEnv")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("hermes.env.title")}
>
<KeyRound className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -1444,6 +1399,15 @@ function App() {
>
<McpIcon size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void openHermesWebUI()}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("hermes.webui.open")}
>
<ExternalLink className="w-4 h-4" />
</Button>
</>
) : activeApp === "openclaw" ? (
<>
-249
View File
@@ -1,249 +0,0 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Save } from "lucide-react";
import { toast } from "sonner";
import {
useHermesAgentConfig,
useSaveHermesAgentConfig,
} 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { HermesAgentConfig } from "@/types";
const UNSET_SENTINEL = "__unset__";
const REASONING_EFFORT_OPTIONS = [
{ value: UNSET_SENTINEL, labelKey: "hermes.agent.notSet" },
{ value: "none", label: "None" },
{ value: "minimal", label: "Minimal" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "Extra High" },
] as const;
const APPROVALS_MODE_OPTIONS = [
{ value: UNSET_SENTINEL, labelKey: "hermes.agent.notSet" },
{ value: "manual", label: "Manual" },
{ value: "smart", label: "Smart" },
{ value: "off", label: "Off" },
] as const;
const AgentPanel: React.FC = () => {
const { t } = useTranslation();
const { data: agentData, isLoading } = useHermesAgentConfig();
const saveAgentMutation = useSaveHermesAgentConfig();
const [maxTurns, setMaxTurns] = useState("");
const [reasoningEffort, setReasoningEffort] = useState(UNSET_SENTINEL);
const [toolUseEnforcement, setToolUseEnforcement] = useState("");
const [approvalsMode, setApprovalsMode] = useState(UNSET_SENTINEL);
// Preserve unknown fields
const [extra, setExtra] = useState<Record<string, unknown>>({});
useEffect(() => {
if (agentData === undefined) return;
if (agentData) {
setMaxTurns(
agentData.max_turns != null ? String(agentData.max_turns) : "",
);
setReasoningEffort(agentData.reasoning_effort ?? UNSET_SENTINEL);
setToolUseEnforcement(
agentData.tool_use_enforcement != null
? typeof agentData.tool_use_enforcement === "string"
? agentData.tool_use_enforcement
: JSON.stringify(agentData.tool_use_enforcement)
: "",
);
setApprovalsMode(agentData.approvals_mode ?? UNSET_SENTINEL);
const {
max_turns: _mt,
reasoning_effort: _re,
tool_use_enforcement: _tu,
approvals_mode: _am,
...rest
} = agentData;
setExtra(rest);
} else {
setMaxTurns("");
setReasoningEffort(UNSET_SENTINEL);
setToolUseEnforcement("");
setApprovalsMode(UNSET_SENTINEL);
setExtra({});
}
}, [agentData]);
const handleSave = async () => {
try {
const config: HermesAgentConfig = {
...extra,
};
const mt = parseInt(maxTurns);
if (!isNaN(mt) && mt > 0) config.max_turns = mt;
if (reasoningEffort !== UNSET_SENTINEL) {
config.reasoning_effort = reasoningEffort;
}
if (toolUseEnforcement.trim()) {
// Try parsing as JSON (for boolean/array values)
try {
config.tool_use_enforcement = JSON.parse(toolUseEnforcement.trim());
} catch {
config.tool_use_enforcement = toolUseEnforcement.trim();
}
}
if (approvalsMode !== UNSET_SENTINEL) {
config.approvals_mode = approvalsMode;
}
await saveAgentMutation.mutateAsync(config);
toast.success(t("hermes.agent.saveSuccess"));
} catch (error) {
toast.error(t("hermes.agent.saveFailed"), {
description: extractErrorMessage(error),
});
}
};
if (isLoading) {
return (
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
<div className="text-sm text-muted-foreground">
{t("common.loading")}
</div>
</div>
);
}
return (
<div className="px-6 pt-4 pb-8">
<p className="text-sm text-muted-foreground mb-4">
{t("hermes.agent.description")}
</p>
<div className="rounded-xl border border-border bg-card p-5 mb-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="hermes-agent-maxturns">
{t("hermes.agent.maxTurns", { defaultValue: "Max Turns" })}
</Label>
<Input
id="hermes-agent-maxturns"
type="number"
value={maxTurns}
onChange={(e) => setMaxTurns(e.target.value)}
placeholder="100"
/>
<p className="text-xs text-muted-foreground">
{t("hermes.agent.maxTurnsHint", {
defaultValue: "Maximum number of agent turns per session",
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="hermes-agent-reasoning">
{t("hermes.agent.reasoningEffort", {
defaultValue: "Reasoning Effort",
})}
</Label>
<Select value={reasoningEffort} onValueChange={setReasoningEffort}>
<SelectTrigger id="hermes-agent-reasoning">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REASONING_EFFORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{"label" in opt
? opt.label
: t(opt.labelKey, { defaultValue: "Not set" })}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("hermes.agent.reasoningEffortHint", {
defaultValue:
"Controls the depth of reasoning: none, minimal, low, medium, high, xhigh",
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="hermes-agent-tooluse">
{t("hermes.agent.toolUseEnforcement", {
defaultValue: "Tool Use Enforcement",
})}
</Label>
<Input
id="hermes-agent-tooluse"
value={toolUseEnforcement}
onChange={(e) => setToolUseEnforcement(e.target.value)}
placeholder="auto"
/>
<p className="text-xs text-muted-foreground">
{t("hermes.agent.toolUseHint", {
defaultValue:
'Values: "auto", true, false, or a JSON array of tool names',
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="hermes-agent-approvals">
{t("hermes.agent.approvalsMode", {
defaultValue: "Approvals Mode",
})}
</Label>
<Select value={approvalsMode} onValueChange={setApprovalsMode}>
<SelectTrigger id="hermes-agent-approvals">
<SelectValue />
</SelectTrigger>
<SelectContent>
{APPROVALS_MODE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{"label" in opt
? opt.label
: t(opt.labelKey, { defaultValue: "Not set" })}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("hermes.agent.approvalsModeHint", {
defaultValue:
"Controls tool call approval: manual (always ask), smart (auto-approve safe), off (never ask)",
})}
</p>
</div>
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleSave}
disabled={saveAgentMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
{saveAgentMutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
);
};
export default AgentPanel;
-128
View File
@@ -1,128 +0,0 @@
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<string, unknown> {
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<string, unknown>;
}
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 (
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
<div className="text-sm text-muted-foreground">
{t("common.loading")}
</div>
</div>
);
}
return (
<div className="px-6 pt-4 pb-8">
<p className="text-sm text-muted-foreground mb-4">
{t("hermes.env.description")}
</p>
<p className="text-xs text-muted-foreground mb-4">
{t("hermes.env.editorHint", {
defaultValue:
"Edit the Hermes .env file as a JSON key-value map. Keys become environment variable names.",
})}
</p>
<JsonEditor
value={editorValue}
onChange={setEditorValue}
darkMode={isDarkMode}
rows={18}
showValidation={true}
language="json"
/>
<div className="flex justify-end mt-4">
<Button
size="sm"
onClick={handleSave}
disabled={saveEnvMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
{saveEnvMutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
);
};
export default EnvPanel;
+19 -5
View File
@@ -1,7 +1,9 @@
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TriangleAlert } from "lucide-react";
import { ExternalLink, TriangleAlert } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useOpenHermesWebUI } from "@/hooks/useHermes";
import type { HermesHealthWarning } from "@/types";
interface HermesHealthBannerProps {
@@ -37,6 +39,7 @@ const HermesHealthBanner: React.FC<HermesHealthBannerProps> = ({
warnings,
}) => {
const { t } = useTranslation();
const openHermesWebUI = useOpenHermesWebUI();
const items = useMemo(
() =>
@@ -55,10 +58,21 @@ const HermesHealthBanner: React.FC<HermesHealthBannerProps> = ({
<div className="px-6 pt-4">
<Alert className="border-amber-500/30 bg-amber-500/5">
<TriangleAlert className="h-4 w-4" />
<AlertTitle>
{t("hermes.health.title", {
defaultValue: "Hermes config warnings detected",
})}
<AlertTitle className="flex items-center justify-between gap-2">
<span>
{t("hermes.health.title", {
defaultValue: "Hermes config warnings detected",
})}
</span>
<Button
variant="outline"
size="sm"
onClick={() => void openHermesWebUI("/config")}
className="shrink-0"
>
<ExternalLink className="w-3.5 h-3.5 mr-1" />
{t("hermes.webui.fixInWebUI")}
</Button>
</AlertTitle>
<AlertDescription>
<ul className="list-disc space-y-1 pl-5">
-202
View File
@@ -1,202 +0,0 @@
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<Record<string, unknown>>({});
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 (
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
<div className="text-sm text-muted-foreground">
{t("common.loading")}
</div>
</div>
);
}
return (
<div className="px-6 pt-4 pb-8">
<p className="text-sm text-muted-foreground mb-4">
{t("hermes.model.description")}
</p>
<div className="rounded-xl border border-border bg-card p-5 mb-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="hermes-model-default">
{t("hermes.model.default", { defaultValue: "Default Model" })}
</Label>
<Input
id="hermes-model-default"
value={defaultModel}
onChange={(e) => setDefaultModel(e.target.value)}
placeholder="anthropic/claude-opus-4-7"
/>
<p className="text-xs text-muted-foreground">
{t("hermes.model.defaultHint", {
defaultValue:
"The default model to use, e.g. anthropic/claude-opus-4-7",
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="hermes-model-provider">
{t("hermes.model.provider", { defaultValue: "Provider" })}
</Label>
<Input
id="hermes-model-provider"
value={provider}
onChange={(e) => setProvider(e.target.value)}
placeholder="openrouter"
/>
<p className="text-xs text-muted-foreground">
{t("hermes.model.providerHint", {
defaultValue:
"Provider name for model routing (e.g. openrouter, anthropic)",
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="hermes-model-baseurl">
{t("hermes.model.baseUrl", { defaultValue: "Base URL" })}
</Label>
<Input
id="hermes-model-baseurl"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com/v1"
/>
<p className="text-xs text-muted-foreground">
{t("hermes.model.baseUrlHint", {
defaultValue: "Override the API endpoint URL for this model",
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="hermes-model-context">
{t("hermes.model.contextLength", {
defaultValue: "Context Length",
})}
</Label>
<Input
id="hermes-model-context"
type="number"
value={contextLength}
onChange={(e) => setContextLength(e.target.value)}
placeholder="200000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="hermes-model-maxtokens">
{t("hermes.model.maxTokens", { defaultValue: "Max Tokens" })}
</Label>
<Input
id="hermes-model-maxtokens"
type="number"
value={maxTokens}
onChange={(e) => setMaxTokens(e.target.value)}
placeholder="16384"
/>
</div>
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleSave}
disabled={saveModelMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
{saveModelMutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
);
};
export default ModelPanel;
+33 -87
View File
@@ -1,16 +1,17 @@
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { useCallback } from "react";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { hermesApi } from "@/lib/api/hermes";
import { providersApi } from "@/lib/api/providers";
import type {
HermesEnvConfig,
HermesAgentConfig,
HermesModelConfig,
} from "@/types";
import { extractErrorMessage } from "@/utils/errorUtils";
/**
* Error code returned by the Rust `open_hermes_web_ui` command when probing
* `/api/status` fails. Must match the string constant in
* `src-tauri/src/commands/hermes.rs`.
*/
export const HERMES_WEB_OFFLINE_ERROR = "hermes_web_offline";
/**
* Centralized query keys for all Hermes-related queries.
@@ -20,8 +21,6 @@ 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,
};
@@ -42,10 +41,6 @@ export function invalidateHermesProviderCaches(queryClient: QueryClient) {
// 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,
@@ -54,9 +49,6 @@ export function useHermesLiveProviderIds(enabled: boolean) {
});
}
/**
* Query model configuration.
*/
export function useHermesModelConfig(enabled: boolean) {
return useQuery({
queryKey: hermesKeys.modelConfig,
@@ -65,33 +57,6 @@ export function useHermesModelConfig(enabled: boolean) {
});
}
/**
* Query agent configuration.
*/
export function useHermesAgentConfig(enabled = true) {
return useQuery({
queryKey: hermesKeys.agentConfig,
queryFn: () => hermesApi.getAgentConfig(),
staleTime: 30_000,
enabled,
});
}
/**
* Query env configuration.
*/
export function useHermesEnv(enabled = true) {
return useQuery({
queryKey: hermesKeys.env,
queryFn: () => hermesApi.getEnv(),
staleTime: 30_000,
enabled,
});
}
/**
* Query config health warnings.
*/
export function useHermesHealth(enabled: boolean) {
return useQuery({
queryKey: hermesKeys.health,
@@ -106,46 +71,27 @@ export function useHermesHealth(enabled: boolean) {
// ============================================================
/**
* Save model config. Invalidates modelConfig and health queries on success.
* Toast notifications are handled by the component.
* Returns a handler that probes the local Hermes Web UI, opens it in the
* system browser, and surfaces a localized toast on failure. Callers only
* need to wire the returned function to a click handler.
*/
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 });
export function useOpenHermesWebUI() {
const { t } = useTranslation();
return useCallback(
async (path?: string) => {
try {
await hermesApi.openWebUI(path);
} catch (error) {
const detail = extractErrorMessage(error);
if (detail === HERMES_WEB_OFFLINE_ERROR) {
toast.error(t("hermes.webui.offline"));
} else {
toast.error(t("hermes.webui.openFailed"), {
description: detail || undefined,
});
}
}
},
});
}
/**
* 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 });
},
});
[t],
);
}
+5 -38
View File
@@ -1664,44 +1664,11 @@
"primaryModel": "Default",
"fallbackModel": "Alternate"
},
"model": {
"title": "Model Config",
"description": "Configure the default model settings in config.yaml's model section.",
"default": "Default Model",
"defaultHint": "The default model to use, e.g. anthropic/claude-opus-4-7",
"provider": "Provider",
"providerHint": "Provider name for model routing (e.g. openrouter, anthropic)",
"baseUrl": "Base URL",
"baseUrlHint": "Override the API endpoint URL for this model",
"contextLength": "Context Length",
"maxTokens": "Max Tokens",
"saveSuccess": "Model config saved",
"saveFailed": "Failed to save model config"
},
"agent": {
"title": "Agent Config",
"description": "Configure the agent behavior settings in config.yaml's agent section.",
"maxTurns": "Max Turns",
"maxTurnsHint": "Maximum number of agent turns per session",
"reasoningEffort": "Reasoning Effort",
"reasoningEffortHint": "Controls the depth of reasoning: none, minimal, low, medium, high, xhigh",
"toolUseEnforcement": "Tool Use Enforcement",
"toolUseHint": "Values: \"auto\", true, false, or a JSON array of tool names",
"approvalsMode": "Approvals Mode",
"approvalsModeHint": "Controls tool call approval: manual (always ask), smart (auto-approve safe), off (never ask)",
"notSet": "Not set",
"saveSuccess": "Agent config saved",
"saveFailed": "Failed to save agent config"
},
"env": {
"title": "Env Config",
"description": "Edit the environment variables in Hermes .env file.",
"editorHint": "Edit the Hermes .env file as a JSON key-value map. Keys become environment variable names.",
"saveSuccess": "Env config saved",
"saveFailed": "Failed to save env config",
"empty": "Hermes env cannot be empty. Use {} for an empty object.",
"invalidJson": "Hermes env must be valid JSON.",
"objectRequired": "Hermes env must be a JSON object."
"webui": {
"open": "Open Hermes Web UI",
"offline": "Hermes Web UI is not running. Start it with `hermes web` first.",
"openFailed": "Failed to open Hermes Web UI",
"fixInWebUI": "Fix in Hermes Web UI"
},
"health": {
"title": "Hermes config warnings detected",
+5 -38
View File
@@ -1664,44 +1664,11 @@
"primaryModel": "デフォルト",
"fallbackModel": "予備"
},
"model": {
"title": "モデル設定",
"description": "config.yaml の model セクションのデフォルトモデル設定を構成します。",
"default": "デフォルトモデル",
"defaultHint": "使用するデフォルトモデル(例: anthropic/claude-opus-4-7",
"provider": "プロバイダー",
"providerHint": "モデルルーティングのプロバイダー名(例: openrouter、anthropic",
"baseUrl": "Base URL",
"baseUrlHint": "このモデルの API エンドポイント URL を上書き",
"contextLength": "コンテキスト長",
"maxTokens": "最大トークン数",
"saveSuccess": "モデル設定を保存しました",
"saveFailed": "モデル設定の保存に失敗しました"
},
"agent": {
"title": "エージェント設定",
"description": "config.yaml の agent セクションの動作設定を構成します。",
"maxTurns": "最大ターン数",
"maxTurnsHint": "セッションごとの最大エージェントターン数",
"reasoningEffort": "推論レベル",
"reasoningEffortHint": "推論の深さを制御: none、minimal、low、medium、high、xhigh",
"toolUseEnforcement": "ツール使用ポリシー",
"toolUseHint": "値: \"auto\"、true、false、またはツール名の JSON 配列",
"approvalsMode": "承認モード",
"approvalsModeHint": "ツール呼び出しの承認を制御: manual(常に確認)、smart(安全な操作を自動承認)、off(確認なし)",
"notSet": "未設定",
"saveSuccess": "エージェント設定を保存しました",
"saveFailed": "エージェント設定の保存に失敗しました"
},
"env": {
"title": "環境変数",
"description": "Hermes .env ファイルの環境変数を編集します。",
"editorHint": "Hermes .env ファイルを JSON キー・バリュー形式で編集します。キーが環境変数名になります。",
"saveSuccess": "環境変数を保存しました",
"saveFailed": "環境変数の保存に失敗しました",
"empty": "Hermes 環境変数は空にできません。空のオブジェクトには {} を使用してください。",
"invalidJson": "Hermes 環境変数は有効な JSON である必要があります。",
"objectRequired": "Hermes 環境変数は JSON オブジェクトである必要があります。"
"webui": {
"open": "Hermes Web UI を開く",
"offline": "Hermes Web UI が起動していません。まず `hermes web` を実行してください。",
"openFailed": "Hermes Web UI を開けませんでした",
"fixInWebUI": "Hermes Web UI で修正"
},
"health": {
"title": "Hermes 設定の警告を検出",
+5 -38
View File
@@ -1664,44 +1664,11 @@
"primaryModel": "默认模型",
"fallbackModel": "备选模型"
},
"model": {
"title": "模型配置",
"description": "配置 config.yaml 中 model 节的默认模型设置。",
"default": "默认模型",
"defaultHint": "使用的默认模型,如 anthropic/claude-opus-4-7",
"provider": "供应商",
"providerHint": "模型路由的供应商名称(如 openrouter、anthropic",
"baseUrl": "Base URL",
"baseUrlHint": "覆盖此模型的 API 端点 URL",
"contextLength": "上下文长度",
"maxTokens": "最大 Tokens",
"saveSuccess": "模型配置已保存",
"saveFailed": "保存模型配置失败"
},
"agent": {
"title": "Agent 配置",
"description": "配置 config.yaml 中 agent 节的行为设置。",
"maxTurns": "最大轮次",
"maxTurnsHint": "每个会话的最大 Agent 轮次",
"reasoningEffort": "推理深度",
"reasoningEffortHint": "控制推理深度:none、minimal、low、medium、high、xhigh",
"toolUseEnforcement": "工具使用策略",
"toolUseHint": "值:\"auto\"、true、false 或工具名称的 JSON 数组",
"approvalsMode": "审批模式",
"approvalsModeHint": "控制工具调用审批:manual(始终询问)、smart(自动审批安全操作)、off(从不询问)",
"notSet": "未设置",
"saveSuccess": "Agent 配置已保存",
"saveFailed": "保存 Agent 配置失败"
},
"env": {
"title": "环境变量",
"description": "编辑 Hermes .env 文件中的环境变量。",
"editorHint": "以 JSON 键值对格式编辑 Hermes .env 文件。键名将作为环境变量名。",
"saveSuccess": "环境变量已保存",
"saveFailed": "保存环境变量失败",
"empty": "Hermes 环境变量不能为空。使用 {} 表示空对象。",
"invalidJson": "Hermes 环境变量必须是有效的 JSON。",
"objectRequired": "Hermes 环境变量必须是 JSON 对象。"
"webui": {
"open": "打开 Hermes Web UI",
"offline": "Hermes Web UI 未启动,请先运行 `hermes web` 启动服务。",
"openFailed": "打开 Hermes Web UI 失败",
"fixInWebUI": "在 Hermes Web UI 修复"
},
"health": {
"title": "检测到 Hermes 配置警告",
+12 -74
View File
@@ -1,92 +1,30 @@
import { invoke } from "@tauri-apps/api/core";
import type {
HermesModelConfig,
HermesAgentConfig,
HermesEnvConfig,
HermesHealthWarning,
HermesWriteOutcome,
} from "@/types";
import type { HermesModelConfig, HermesHealthWarning } from "@/types";
/**
* Hermes Agent configuration API
* Hermes Agent configuration API (CC Switch side).
*
* Manages Hermes config sections:
* - model (model selection and provider)
* - agent (agent behavior)
* - env (environment variables)
* CC Switch intentionally keeps its Hermes surface minimal — deep configuration
* (model, agent behavior, env vars, skills, cron, logs, analytics) lives in
* the Hermes Web UI at http://127.0.0.1:9119. CC Switch only reads the `model`
* section to highlight the active provider, scans config health, and launches
* the Hermes Web UI for everything else. Writes to `model` happen implicitly
* via `apply_switch_defaults` when the user switches providers.
*/
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
* Probe the local Hermes Web UI and open it in the system browser.
* Optional `path` lets callers deep-link to specific pages like `/config`.
*/
async getLiveProvider(
providerId: string,
): Promise<Record<string, unknown> | null> {
return await invoke("get_hermes_live_provider", { providerId });
async openWebUI(path?: string): Promise<void> {
await invoke("open_hermes_web_ui", { path: path ?? null });
},
};
-12
View File
@@ -587,18 +587,6 @@ export interface HermesModelConfig {
[key: string]: unknown;
}
export interface HermesAgentConfig {
max_turns?: number;
reasoning_effort?: string;
tool_use_enforcement?: string | boolean | string[];
approvals_mode?: string;
[key: string]: unknown;
}
export interface HermesEnvConfig {
[key: string]: unknown;
}
export interface HermesHealthWarning {
code: string;
message: string;