feat: add Hermes UI components, presets, and config panels (Phase 8)

- Add 7 provider presets (OpenRouter, Anthropic, OpenAI, Google, DeepSeek, Together, Nous)
- Create HermesFormFields + useHermesFormState for provider form integration
- Create Model/Agent/Env config panels with save/load functionality
- Create HermesHealthBanner for config warnings
- Add hermes icon (violet winged H) to icon system
- Integrate into App.tsx: 3 new view types (hermesModel/hermesAgent/hermesEnv),
  sidebar buttons (Brain/Bot/KeyRound), health banner, session support
- Integrate into ProviderForm: presets, form state, key validation, rendering
- Integrate into AddProviderDialog: universal tab exclusion, providerKey, base_url extraction
- Add i18n keys for all Hermes UI (zh/en/ja)
This commit is contained in:
Jason
2026-04-15 21:02:46 +08:00
parent a0b585992a
commit 240969d8c7
23 changed files with 1512 additions and 54 deletions
+249
View File
@@ -0,0 +1,249 @@
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
@@ -0,0 +1,128 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Save } from "lucide-react";
import { toast } from "sonner";
import { useHermesEnv, useSaveHermesEnv } from "@/hooks/useHermes";
import { extractErrorMessage } from "@/utils/errorUtils";
import { Button } from "@/components/ui/button";
import JsonEditor from "@/components/JsonEditor";
function parseEnvEditorValue(raw: string): Record<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;
@@ -0,0 +1,78 @@
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TriangleAlert } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import type { HermesHealthWarning } from "@/types";
interface HermesHealthBannerProps {
warnings: HermesHealthWarning[];
}
function getWarningText(
code: string,
fallback: string,
t: ReturnType<typeof useTranslation>["t"],
) {
switch (code) {
case "config_parse_failed":
return t("hermes.health.parseFailed", {
defaultValue:
"config.yaml could not be parsed as valid YAML. Fix the file before editing it here.",
});
case "config_not_found":
return t("hermes.health.configNotFound", {
defaultValue:
"Hermes config.yaml not found. Create it at ~/.hermes/config.yaml or configure the path in settings.",
});
case "env_parse_failed":
return t("hermes.health.envParseFailed", {
defaultValue: "The .env file could not be parsed.",
});
default:
return fallback;
}
}
const HermesHealthBanner: React.FC<HermesHealthBannerProps> = ({
warnings,
}) => {
const { t } = useTranslation();
const items = useMemo(
() =>
warnings.map((warning) => ({
...warning,
text: getWarningText(warning.code, warning.message, t),
})),
[t, warnings],
);
if (warnings.length === 0) {
return null;
}
return (
<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>
<AlertDescription>
<ul className="list-disc space-y-1 pl-5">
{items.map((warning) => (
<li key={`${warning.code}:${warning.path ?? warning.message}`}>
{warning.text}
{warning.path ? ` (${warning.path})` : ""}
</li>
))}
</ul>
</AlertDescription>
</Alert>
</div>
);
};
export default HermesHealthBanner;
+202
View File
@@ -0,0 +1,202 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Save } from "lucide-react";
import { toast } from "sonner";
import {
useHermesModelConfig,
useSaveHermesModelConfig,
} from "@/hooks/useHermes";
import { extractErrorMessage } from "@/utils/errorUtils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { HermesModelConfig } from "@/types";
const ModelPanel: React.FC = () => {
const { t } = useTranslation();
const { data: modelData, isLoading } = useHermesModelConfig(true);
const saveModelMutation = useSaveHermesModelConfig();
const [defaultModel, setDefaultModel] = useState("");
const [provider, setProvider] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [contextLength, setContextLength] = useState("");
const [maxTokens, setMaxTokens] = useState("");
// Preserve unknown fields from the original config
const [extra, setExtra] = useState<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-6"
/>
<p className="text-xs text-muted-foreground">
{t("hermes.model.defaultHint", {
defaultValue:
"The default model to use, e.g. anthropic/claude-opus-4-6",
})}
</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;