mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-28 01:09:23 +08:00
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:
@@ -171,9 +171,7 @@ fn is_top_level_key_line(line: &str) -> bool {
|
||||
}
|
||||
if let Some(colon_pos) = line.find(':') {
|
||||
let after_colon = &line[colon_pos + 1..];
|
||||
after_colon.is_empty()
|
||||
|| after_colon.starts_with(' ')
|
||||
|| after_colon.starts_with('\t')
|
||||
after_colon.is_empty() || after_colon.starts_with(' ') || after_colon.starts_with('\t')
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -221,10 +219,7 @@ fn find_yaml_section_range(raw: &str, section_key: &str) -> Option<(usize, usize
|
||||
/// ```
|
||||
fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result<String, AppError> {
|
||||
let mut section = serde_yaml::Mapping::new();
|
||||
section.insert(
|
||||
serde_yaml::Value::String(key.to_string()),
|
||||
value.clone(),
|
||||
);
|
||||
section.insert(serde_yaml::Value::String(key.to_string()), value.clone());
|
||||
let yaml_str = serde_yaml::to_string(&serde_yaml::Value::Mapping(section))
|
||||
.map_err(|e| AppError::Config(format!("Failed to serialize YAML section '{key}': {e}")))?;
|
||||
Ok(yaml_str)
|
||||
@@ -367,7 +362,10 @@ fn write_yaml_section_to_config_locked(
|
||||
|
||||
let warnings = scan_hermes_health_internal(&new_raw);
|
||||
|
||||
log::debug!("Hermes config section '{section_key}' written to {:?}", config_path);
|
||||
log::debug!(
|
||||
"Hermes config section '{section_key}' written to {:?}",
|
||||
config_path
|
||||
);
|
||||
Ok(HermesWriteOutcome {
|
||||
backup_path: backup_path.map(|p| p.display().to_string()),
|
||||
warnings,
|
||||
@@ -438,11 +436,10 @@ pub fn set_provider(
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(existing) = providers.iter_mut().find(|p| {
|
||||
p.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
== Some(name)
|
||||
}) {
|
||||
if let Some(existing) = providers
|
||||
.iter_mut()
|
||||
.find(|p| p.get("name").and_then(|n| n.as_str()) == Some(name))
|
||||
{
|
||||
*existing = yaml_val;
|
||||
} else {
|
||||
providers.push(yaml_val);
|
||||
@@ -467,11 +464,7 @@ pub fn remove_provider(name: &str) -> Result<HermesWriteOutcome, AppError> {
|
||||
.unwrap_or_default();
|
||||
|
||||
let original_len = providers.len();
|
||||
providers.retain(|p| {
|
||||
p.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
!= Some(name)
|
||||
});
|
||||
providers.retain(|p| p.get("name").and_then(|n| n.as_str()) != Some(name));
|
||||
|
||||
if providers.len() == original_len {
|
||||
return Ok(HermesWriteOutcome::default());
|
||||
@@ -690,9 +683,8 @@ fn scan_hermes_health_internal(content: &str) -> Vec<HermesHealthWarning> {
|
||||
if !providers.is_sequence() {
|
||||
warnings.push(HermesHealthWarning {
|
||||
code: "custom_providers_not_list".to_string(),
|
||||
message:
|
||||
"custom_providers should be a YAML list (sequence), not a mapping"
|
||||
.to_string(),
|
||||
message: "custom_providers should be a YAML list (sequence), not a mapping"
|
||||
.to_string(),
|
||||
path: Some("custom_providers".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,8 +83,7 @@ fn convert_to_hermes_format(spec: &Value) -> Result<Value, AppError> {
|
||||
result.insert("url".into(), url.clone());
|
||||
}
|
||||
if let Some(headers) = obj.get("headers") {
|
||||
if headers.is_object()
|
||||
&& !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||
{
|
||||
result.insert("headers".into(), headers.clone());
|
||||
}
|
||||
@@ -142,9 +141,7 @@ fn convert_from_hermes_format(id: &str, spec: &Value) -> Result<Value, AppError>
|
||||
result.insert("url".into(), url.clone());
|
||||
}
|
||||
if let Some(headers) = obj.get("headers") {
|
||||
if headers.is_object()
|
||||
&& !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||
{
|
||||
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||
result.insert("headers".into(), headers.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,11 +132,7 @@ impl McpService {
|
||||
log::debug!("OpenClaw MCP support is still in development, skipping sync");
|
||||
}
|
||||
AppType::Hermes => {
|
||||
mcp::sync_single_server_to_hermes(
|
||||
&Default::default(),
|
||||
&server.id,
|
||||
&server.server,
|
||||
)?;
|
||||
mcp::sync_single_server_to_hermes(&Default::default(), &server.id, &server.server)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1393,9 +1393,7 @@ pub fn remove_hermes_provider_from_live(provider_id: &str) -> Result<(), AppErro
|
||||
|
||||
// Check if Hermes config directory exists
|
||||
if !hermes_config::get_hermes_dir().exists() {
|
||||
log::debug!(
|
||||
"Hermes config directory doesn't exist, skipping removal of '{provider_id}'"
|
||||
);
|
||||
log::debug!("Hermes config directory doesn't exist, skipping removal of '{provider_id}'");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,8 @@ use crate::store::AppState;
|
||||
|
||||
// Re-export sub-module functions for external access
|
||||
pub use live::{
|
||||
import_default_config, import_hermes_providers_from_live,
|
||||
import_openclaw_providers_from_live, import_opencode_providers_from_live, read_live_settings,
|
||||
sync_current_to_live,
|
||||
import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live,
|
||||
import_opencode_providers_from_live, read_live_settings, sync_current_to_live,
|
||||
};
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
|
||||
+96
-8
@@ -25,6 +25,8 @@ import {
|
||||
KeyRound,
|
||||
Shield,
|
||||
Cpu,
|
||||
Brain,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import type { Provider, VisibleApps } from "@/types";
|
||||
@@ -39,7 +41,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 { hermesKeys, useHermesHealth } from "@/hooks/useHermes";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useAutoCompact } from "@/hooks/useAutoCompact";
|
||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||
@@ -83,6 +85,10 @@ 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 =
|
||||
| "providers"
|
||||
@@ -97,7 +103,10 @@ type View =
|
||||
| "workspace"
|
||||
| "openclawEnv"
|
||||
| "openclawTools"
|
||||
| "openclawAgents";
|
||||
| "openclawAgents"
|
||||
| "hermesModel"
|
||||
| "hermesAgent"
|
||||
| "hermesEnv";
|
||||
|
||||
interface WebDavSyncStatusUpdatedPayload {
|
||||
source?: string;
|
||||
@@ -141,6 +150,9 @@ const VALID_VIEWS: View[] = [
|
||||
"openclawEnv",
|
||||
"openclawTools",
|
||||
"openclawAgents",
|
||||
"hermesModel",
|
||||
"hermesAgent",
|
||||
"hermesEnv",
|
||||
];
|
||||
|
||||
const getInitialView = (): View => {
|
||||
@@ -203,7 +215,8 @@ function App() {
|
||||
activeApp !== "codex" &&
|
||||
activeApp !== "opencode" &&
|
||||
activeApp !== "openclaw" &&
|
||||
activeApp !== "gemini"
|
||||
activeApp !== "gemini" &&
|
||||
activeApp !== "hermes"
|
||||
) {
|
||||
setCurrentView("providers");
|
||||
}
|
||||
@@ -259,13 +272,21 @@ function App() {
|
||||
currentView === "openclawAgents");
|
||||
const { data: openclawHealthWarnings = [] } =
|
||||
useOpenClawHealth(isOpenClawView);
|
||||
const isHermesView =
|
||||
activeApp === "hermes" &&
|
||||
(currentView === "providers" ||
|
||||
currentView === "hermesModel" ||
|
||||
currentView === "hermesAgent" ||
|
||||
currentView === "hermesEnv");
|
||||
const { data: hermesHealthWarnings = [] } = useHermesHealth(isHermesView);
|
||||
const hasSkillsSupport = true;
|
||||
const hasSessionSupport =
|
||||
activeApp === "claude" ||
|
||||
activeApp === "codex" ||
|
||||
activeApp === "opencode" ||
|
||||
activeApp === "openclaw" ||
|
||||
activeApp === "gemini";
|
||||
activeApp === "gemini" ||
|
||||
activeApp === "hermes";
|
||||
|
||||
const {
|
||||
addProvider,
|
||||
@@ -940,6 +961,12 @@ 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">
|
||||
@@ -971,7 +998,9 @@ function App() {
|
||||
setConfirmAction({ provider, action: "delete" })
|
||||
}
|
||||
onRemoveFromConfig={
|
||||
activeApp === "opencode" || activeApp === "openclaw"
|
||||
activeApp === "opencode" ||
|
||||
activeApp === "openclaw" ||
|
||||
activeApp === "hermes"
|
||||
? (provider) =>
|
||||
setConfirmAction({ provider, action: "remove" })
|
||||
: undefined
|
||||
@@ -1153,6 +1182,9 @@ 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>
|
||||
) : (
|
||||
@@ -1213,7 +1245,8 @@ function App() {
|
||||
<div className="flex flex-1 min-w-0 items-center justify-end gap-1.5">
|
||||
{currentView === "providers" &&
|
||||
activeApp !== "opencode" &&
|
||||
activeApp !== "openclaw" && (
|
||||
activeApp !== "openclaw" &&
|
||||
activeApp !== "hermes" && (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
@@ -1348,7 +1381,11 @@ function App() {
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={
|
||||
activeApp === "openclaw" ? "openclaw" : "default"
|
||||
activeApp === "openclaw"
|
||||
? "openclaw"
|
||||
: activeApp === "hermes"
|
||||
? "hermes"
|
||||
: "default"
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -1356,7 +1393,55 @@ function App() {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{activeApp === "openclaw" ? (
|
||||
{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"
|
||||
onClick={() => setCurrentView("prompts")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("prompts.manage")}
|
||||
>
|
||||
<Book className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("mcp")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("mcp.title")}
|
||||
>
|
||||
<McpIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
) : activeApp === "openclaw" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1479,6 +1564,9 @@ function App() {
|
||||
{isOpenClawView && openclawHealthWarnings.length > 0 && (
|
||||
<OpenClawHealthBanner warnings={openclawHealthWarnings} />
|
||||
)}
|
||||
{isHermesView && hermesHealthWarnings.length > 0 && (
|
||||
<HermesHealthBanner warnings={hermesHealthWarnings} />
|
||||
)}
|
||||
{renderContent()}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -41,7 +41,8 @@ export function AddProviderDialog({
|
||||
}: AddProviderDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// OpenCode and OpenClaw don't support universal providers
|
||||
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
|
||||
const showUniversalTab =
|
||||
appId !== "opencode" && appId !== "openclaw" && appId !== "hermes";
|
||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||
"app-specific",
|
||||
);
|
||||
@@ -106,7 +107,7 @@ export function AddProviderDialog({
|
||||
|
||||
// OpenCode/OpenClaw: pass providerKey for ID generation
|
||||
if (
|
||||
(appId === "opencode" || appId === "openclaw") &&
|
||||
(appId === "opencode" || appId === "openclaw" || appId === "hermes") &&
|
||||
values.providerKey
|
||||
) {
|
||||
providerData.providerKey = values.providerKey;
|
||||
@@ -203,6 +204,10 @@ export function AddProviderDialog({
|
||||
if (parsedConfig.baseUrl) {
|
||||
addUrl(parsedConfig.baseUrl as string);
|
||||
}
|
||||
} else if (appId === "hermes") {
|
||||
if (parsedConfig.base_url) {
|
||||
addUrl(parsedConfig.base_url as string);
|
||||
}
|
||||
}
|
||||
|
||||
const urls = Array.from(urlSet);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ApiKeySection } from "./shared";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
|
||||
interface HermesFormFieldsProps {
|
||||
baseUrl: string;
|
||||
onBaseUrlChange: (value: string) => void;
|
||||
apiKey: string;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
category?: ProviderCategory;
|
||||
shouldShowApiKeyLink: boolean;
|
||||
websiteUrl: string;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
}
|
||||
|
||||
export function HermesFormFields({
|
||||
baseUrl,
|
||||
onBaseUrlChange,
|
||||
apiKey,
|
||||
onApiKeyChange,
|
||||
category,
|
||||
shouldShowApiKeyLink,
|
||||
websiteUrl,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
}: HermesFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Base URL */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="hermes-baseurl">
|
||||
{t("hermes.form.baseUrl", { defaultValue: "API Endpoint" })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="hermes-baseurl"
|
||||
value={baseUrl}
|
||||
onChange={(e) => onBaseUrlChange(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hermes.form.baseUrlHint", {
|
||||
defaultValue: "The API endpoint URL for this provider.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<ApiKeySection
|
||||
value={apiKey}
|
||||
onChange={onApiKeyChange}
|
||||
category={category}
|
||||
shouldShowLink={shouldShowApiKeyLink}
|
||||
websiteUrl={websiteUrl}
|
||||
isPartner={isPartner}
|
||||
partnerPromotionKey={partnerPromotionKey}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -37,8 +37,13 @@ import {
|
||||
type OpenClawProviderPreset,
|
||||
type OpenClawSuggestedDefaults,
|
||||
} from "@/config/openclawProviderPresets";
|
||||
import {
|
||||
hermesProviderPresets,
|
||||
type HermesProviderPreset,
|
||||
} from "@/config/hermesProviderPresets";
|
||||
import { OpenCodeFormFields } from "./OpenCodeFormFields";
|
||||
import { OpenClawFormFields } from "./OpenClawFormFields";
|
||||
import { HermesFormFields } from "./HermesFormFields";
|
||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||
import {
|
||||
applyTemplateValues,
|
||||
@@ -80,6 +85,7 @@ import {
|
||||
useOpencodeFormState,
|
||||
useOmoDraftState,
|
||||
useOpenclawFormState,
|
||||
useHermesFormState,
|
||||
useCopilotAuth,
|
||||
useCodexOauth,
|
||||
} from "./hooks";
|
||||
@@ -95,6 +101,7 @@ import {
|
||||
} from "./helpers/opencodeFormUtils";
|
||||
import { resolveManagedAccountId } from "@/lib/authBinding";
|
||||
import { useOpenClawLiveProviderIds } from "@/hooks/useOpenClaw";
|
||||
import { useHermesLiveProviderIds } from "@/hooks/useHermes";
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
@@ -103,7 +110,8 @@ type PresetEntry = {
|
||||
| CodexProviderPreset
|
||||
| GeminiProviderPreset
|
||||
| OpenCodeProviderPreset
|
||||
| OpenClawProviderPreset;
|
||||
| OpenClawProviderPreset
|
||||
| HermesProviderPreset;
|
||||
};
|
||||
|
||||
interface ProviderFormProps {
|
||||
@@ -449,6 +457,11 @@ export function ProviderForm({
|
||||
id: `openclaw-${index}`,
|
||||
preset,
|
||||
}));
|
||||
} else if (appId === "hermes") {
|
||||
return hermesProviderPresets.map<PresetEntry>((preset, index) => ({
|
||||
id: `hermes-${index}`,
|
||||
preset,
|
||||
}));
|
||||
}
|
||||
return providerPresets
|
||||
.filter((p) => !p.hidden)
|
||||
@@ -644,6 +657,18 @@ export function ProviderForm({
|
||||
isLoading: isOpenclawLiveProviderIdsLoading,
|
||||
} = useOpenClawLiveProviderIds(appId === "openclaw");
|
||||
|
||||
const hermesForm = useHermesFormState({
|
||||
initialData,
|
||||
appId,
|
||||
providerId,
|
||||
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
getSettingsConfig: () => form.getValues("settingsConfig"),
|
||||
});
|
||||
const {
|
||||
data: hermesLiveProviderIds = [],
|
||||
isLoading: isHermesLiveProviderIdsLoading,
|
||||
} = useHermesLiveProviderIds(appId === "hermes");
|
||||
|
||||
const additiveExistingProviderKeys = useMemo(() => {
|
||||
if (appId === "opencode" && !isAnyOmoCategory) {
|
||||
return Array.from(
|
||||
@@ -666,10 +691,22 @@ export function ProviderForm({
|
||||
);
|
||||
}
|
||||
|
||||
if (appId === "hermes") {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[...hermesForm.existingHermesKeys, ...hermesLiveProviderIds].filter(
|
||||
(key) => key !== providerId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [
|
||||
appId,
|
||||
existingOpencodeKeys,
|
||||
hermesForm.existingHermesKeys,
|
||||
hermesLiveProviderIds,
|
||||
isAnyOmoCategory,
|
||||
openclawForm.existingOpenclawKeys,
|
||||
openclawLiveProviderIds,
|
||||
@@ -685,11 +722,15 @@ export function ProviderForm({
|
||||
if (appId === "openclaw") {
|
||||
return isOpenclawLiveProviderIdsLoading;
|
||||
}
|
||||
if (appId === "hermes") {
|
||||
return isHermesLiveProviderIdsLoading;
|
||||
}
|
||||
return false;
|
||||
}, [
|
||||
appId,
|
||||
isAnyOmoCategory,
|
||||
isEditMode,
|
||||
isHermesLiveProviderIdsLoading,
|
||||
isOpenclawLiveProviderIdsLoading,
|
||||
isOpencodeLiveProviderIdsLoading,
|
||||
]);
|
||||
@@ -702,9 +743,13 @@ export function ProviderForm({
|
||||
if (appId === "openclaw") {
|
||||
return openclawLiveProviderIds.includes(providerId);
|
||||
}
|
||||
if (appId === "hermes") {
|
||||
return hermesLiveProviderIds.includes(providerId);
|
||||
}
|
||||
return false;
|
||||
}, [
|
||||
appId,
|
||||
hermesLiveProviderIds,
|
||||
isAnyOmoCategory,
|
||||
isEditMode,
|
||||
openclawLiveProviderIds,
|
||||
@@ -796,6 +841,34 @@ export function ProviderForm({
|
||||
}
|
||||
}
|
||||
|
||||
// Hermes: validate provider key
|
||||
if (appId === "hermes") {
|
||||
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
if (!hermesForm.hermesProviderKey.trim()) {
|
||||
toast.error(t("hermes.form.providerKeyRequired"));
|
||||
return;
|
||||
}
|
||||
if (!keyPattern.test(hermesForm.hermesProviderKey)) {
|
||||
toast.error(t("hermes.form.providerKeyInvalid"));
|
||||
return;
|
||||
}
|
||||
if (isProviderKeyLockStateLoading) {
|
||||
toast.error(
|
||||
t("providerForm.providerKeyStatusLoading", {
|
||||
defaultValue: "正在加载供应商标识状态,请稍后再试",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isProviderKeyLocked &&
|
||||
additiveExistingProviderKeys.includes(hermesForm.hermesProviderKey)
|
||||
) {
|
||||
toast.error(t("hermes.form.providerKeyDuplicate"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 非官方供应商必填校验:端点和 API Key
|
||||
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
|
||||
// GitHub Copilot 使用 OAuth 认证,不需要 API Key
|
||||
@@ -968,6 +1041,8 @@ export function ProviderForm({
|
||||
}
|
||||
} else if (appId === "openclaw") {
|
||||
payload.providerKey = openclawForm.openclawProviderKey;
|
||||
} else if (appId === "hermes") {
|
||||
payload.providerKey = hermesForm.hermesProviderKey;
|
||||
}
|
||||
|
||||
if (isAnyOmoCategory && !payload.presetCategory) {
|
||||
@@ -1181,6 +1256,20 @@ export function ProviderForm({
|
||||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||
});
|
||||
|
||||
// 使用 API Key 链接 hook (Hermes)
|
||||
const {
|
||||
shouldShowApiKeyLink: shouldShowHermesApiKeyLink,
|
||||
websiteUrl: hermesWebsiteUrl,
|
||||
isPartner: isHermesPartner,
|
||||
partnerPromotionKey: hermesPartnerPromotionKey,
|
||||
} = useApiKeyLink({
|
||||
appId: "hermes",
|
||||
category,
|
||||
selectedPresetId,
|
||||
presetEntries,
|
||||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||
});
|
||||
|
||||
// 使用端点测速候选 hook
|
||||
const speedTestEndpoints = useSpeedTestEndpoints({
|
||||
appId,
|
||||
@@ -1212,6 +1301,9 @@ export function ProviderForm({
|
||||
if (appId === "openclaw") {
|
||||
openclawForm.resetOpenclawState();
|
||||
}
|
||||
if (appId === "hermes") {
|
||||
hermesForm.resetHermesState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1316,6 +1408,23 @@ export function ProviderForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// Hermes preset handling
|
||||
if (appId === "hermes") {
|
||||
const preset = entry.preset as HermesProviderPreset;
|
||||
const config = preset.settingsConfig;
|
||||
|
||||
hermesForm.resetHermesState(config);
|
||||
|
||||
form.reset({
|
||||
name: preset.nameKey ? t(preset.nameKey) : preset.name,
|
||||
websiteUrl: preset.websiteUrl ?? "",
|
||||
settingsConfig: JSON.stringify(config, null, 2),
|
||||
icon: preset.icon ?? "",
|
||||
iconColor: preset.iconColor ?? "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = entry.preset as ProviderPreset;
|
||||
const config = applyTemplateValues(
|
||||
preset.settingsConfig,
|
||||
@@ -1508,6 +1617,79 @@ export function ProviderForm({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : appId === "hermes" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hermes-key">
|
||||
{t("hermes.form.providerKey", {
|
||||
defaultValue: "Provider Key",
|
||||
})}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="hermes-key"
|
||||
value={hermesForm.hermesProviderKey}
|
||||
onChange={(e) =>
|
||||
hermesForm.setHermesProviderKey(
|
||||
e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
|
||||
)
|
||||
}
|
||||
placeholder={t("hermes.form.providerKeyPlaceholder", {
|
||||
defaultValue: "my-provider",
|
||||
})}
|
||||
disabled={
|
||||
isProviderKeyLocked || isProviderKeyLockStateLoading
|
||||
}
|
||||
className={
|
||||
(additiveExistingProviderKeys.includes(
|
||||
hermesForm.hermesProviderKey,
|
||||
) &&
|
||||
!isProviderKeyLocked) ||
|
||||
(hermesForm.hermesProviderKey.trim() !== "" &&
|
||||
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||
hermesForm.hermesProviderKey,
|
||||
))
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{additiveExistingProviderKeys.includes(
|
||||
hermesForm.hermesProviderKey,
|
||||
) &&
|
||||
!isProviderKeyLocked && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("hermes.form.providerKeyDuplicate")}
|
||||
</p>
|
||||
)}
|
||||
{hermesForm.hermesProviderKey.trim() !== "" &&
|
||||
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||
hermesForm.hermesProviderKey,
|
||||
) && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("hermes.form.providerKeyInvalid")}
|
||||
</p>
|
||||
)}
|
||||
{!(
|
||||
additiveExistingProviderKeys.includes(
|
||||
hermesForm.hermesProviderKey,
|
||||
) && !isProviderKeyLocked
|
||||
) &&
|
||||
(hermesForm.hermesProviderKey.trim() === "" ||
|
||||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||
hermesForm.hermesProviderKey,
|
||||
)) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isProviderKeyLocked
|
||||
? t("hermes.form.providerKeyLockedHint", {
|
||||
defaultValue:
|
||||
"This provider is in Hermes config; key is locked.",
|
||||
})
|
||||
: t("hermes.form.providerKeyHint", {
|
||||
defaultValue:
|
||||
"Lowercase letters, numbers, and hyphens only. Used as the provider name in config.yaml.",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
@@ -1701,6 +1883,21 @@ export function ProviderForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hermes 专属字段 */}
|
||||
{appId === "hermes" && (
|
||||
<HermesFormFields
|
||||
baseUrl={hermesForm.hermesBaseUrl}
|
||||
onBaseUrlChange={hermesForm.handleHermesBaseUrlChange}
|
||||
apiKey={hermesForm.hermesApiKey}
|
||||
onApiKeyChange={hermesForm.handleHermesApiKeyChange}
|
||||
category={category}
|
||||
shouldShowApiKeyLink={shouldShowHermesApiKeyLink}
|
||||
websiteUrl={hermesWebsiteUrl}
|
||||
isPartner={isHermesPartner}
|
||||
partnerPromotionKey={hermesPartnerPromotionKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
|
||||
{appId === "codex" ? (
|
||||
<>
|
||||
@@ -1784,7 +1981,7 @@ export function ProviderForm({
|
||||
</div>
|
||||
{settingsConfigErrorField}
|
||||
</>
|
||||
) : appId === "openclaw" ? (
|
||||
) : appId === "openclaw" || appId === "hermes" ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settingsConfig">
|
||||
@@ -1793,12 +1990,20 @@ export function ProviderForm({
|
||||
<JsonEditor
|
||||
value={form.getValues("settingsConfig")}
|
||||
onChange={(config) => form.setValue("settingsConfig", config)}
|
||||
placeholder={`{
|
||||
placeholder={
|
||||
appId === "hermes"
|
||||
? `{
|
||||
"name": "my-provider",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"api_key": ""
|
||||
}`
|
||||
: `{
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
"apiKey": "your-api-key-here",
|
||||
"api": "openai-completions",
|
||||
"models": []
|
||||
}`}
|
||||
}`
|
||||
}
|
||||
rows={14}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
@@ -1836,7 +2041,8 @@ export function ProviderForm({
|
||||
|
||||
{!isAnyOmoCategory &&
|
||||
appId !== "opencode" &&
|
||||
appId !== "openclaw" && (
|
||||
appId !== "openclaw" &&
|
||||
appId !== "hermes" && (
|
||||
<ProviderAdvancedConfig
|
||||
testConfig={testConfig}
|
||||
pricingConfig={pricingConfig}
|
||||
|
||||
@@ -17,5 +17,6 @@ export { useOmoModelSource } from "./useOmoModelSource";
|
||||
export { useOpencodeFormState } from "./useOpencodeFormState";
|
||||
export { useOmoDraftState } from "./useOmoDraftState";
|
||||
export { useOpenclawFormState } from "./useOpenclawFormState";
|
||||
export { useHermesFormState } from "./useHermesFormState";
|
||||
export { useCopilotAuth } from "./useCopilotAuth";
|
||||
export { useCodexOauth } from "./useCodexOauth";
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { useProvidersQuery } from "@/lib/query/queries";
|
||||
|
||||
interface UseHermesFormStateParams {
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
appId: AppId;
|
||||
providerId?: string;
|
||||
onSettingsConfigChange: (config: string) => void;
|
||||
getSettingsConfig: () => string;
|
||||
}
|
||||
|
||||
export const HERMES_DEFAULT_CONFIG = JSON.stringify(
|
||||
{
|
||||
name: "",
|
||||
base_url: "",
|
||||
api_key: "",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
export interface HermesFormState {
|
||||
hermesProviderKey: string;
|
||||
setHermesProviderKey: (key: string) => void;
|
||||
hermesBaseUrl: string;
|
||||
hermesApiKey: string;
|
||||
existingHermesKeys: string[];
|
||||
handleHermesBaseUrlChange: (baseUrl: string) => void;
|
||||
handleHermesApiKeyChange: (apiKey: string) => void;
|
||||
resetHermesState: (config?: {
|
||||
name?: string;
|
||||
base_url?: string;
|
||||
api_key?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function parseHermesField<T>(
|
||||
initialData: UseHermesFormStateParams["initialData"],
|
||||
field: string,
|
||||
fallback: T,
|
||||
): T {
|
||||
try {
|
||||
if (initialData?.settingsConfig) {
|
||||
return (initialData.settingsConfig[field] as T) || fallback;
|
||||
}
|
||||
const config = JSON.parse(HERMES_DEFAULT_CONFIG);
|
||||
return (config[field] as T) || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function useHermesFormState({
|
||||
initialData,
|
||||
appId,
|
||||
providerId,
|
||||
onSettingsConfigChange,
|
||||
getSettingsConfig,
|
||||
}: UseHermesFormStateParams): HermesFormState {
|
||||
const { data: hermesProvidersData } = useProvidersQuery("hermes");
|
||||
const existingHermesKeys = useMemo(() => {
|
||||
if (!hermesProvidersData?.providers) return [];
|
||||
return Object.keys(hermesProvidersData.providers).filter(
|
||||
(k) => k !== providerId,
|
||||
);
|
||||
}, [hermesProvidersData?.providers, providerId]);
|
||||
|
||||
const [hermesProviderKey, setHermesProviderKey] = useState<string>(() => {
|
||||
if (appId !== "hermes") return "";
|
||||
return providerId || "";
|
||||
});
|
||||
|
||||
const [hermesBaseUrl, setHermesBaseUrl] = useState<string>(() => {
|
||||
if (appId !== "hermes") return "";
|
||||
return parseHermesField(initialData, "base_url", "");
|
||||
});
|
||||
|
||||
const [hermesApiKey, setHermesApiKey] = useState<string>(() => {
|
||||
if (appId !== "hermes") return "";
|
||||
return parseHermesField(initialData, "api_key", "");
|
||||
});
|
||||
|
||||
const updateHermesConfig = useCallback(
|
||||
(updater: (config: Record<string, unknown>) => void) => {
|
||||
try {
|
||||
const config = JSON.parse(getSettingsConfig() || HERMES_DEFAULT_CONFIG);
|
||||
updater(config);
|
||||
onSettingsConfigChange(JSON.stringify(config, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[getSettingsConfig, onSettingsConfigChange],
|
||||
);
|
||||
|
||||
const handleHermesBaseUrlChange = useCallback(
|
||||
(baseUrl: string) => {
|
||||
setHermesBaseUrl(baseUrl);
|
||||
updateHermesConfig((config) => {
|
||||
config.base_url = baseUrl.trim().replace(/\/+$/, "");
|
||||
});
|
||||
},
|
||||
[updateHermesConfig],
|
||||
);
|
||||
|
||||
const handleHermesApiKeyChange = useCallback(
|
||||
(apiKey: string) => {
|
||||
setHermesApiKey(apiKey);
|
||||
updateHermesConfig((config) => {
|
||||
config.api_key = apiKey;
|
||||
});
|
||||
},
|
||||
[updateHermesConfig],
|
||||
);
|
||||
|
||||
const resetHermesState = useCallback(
|
||||
(config?: { name?: string; base_url?: string; api_key?: string }) => {
|
||||
setHermesProviderKey("");
|
||||
setHermesBaseUrl(config?.base_url || "");
|
||||
setHermesApiKey(config?.api_key || "");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
hermesProviderKey,
|
||||
setHermesProviderKey,
|
||||
hermesBaseUrl,
|
||||
hermesApiKey,
|
||||
existingHermesKeys,
|
||||
handleHermesBaseUrlChange,
|
||||
handleHermesApiKeyChange,
|
||||
resetHermesState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Hermes Agent provider presets configuration
|
||||
* Hermes uses custom_providers array in config.yaml
|
||||
*/
|
||||
import type { ProviderCategory } from "../types";
|
||||
import type { PresetTheme, TemplateValueConfig } from "./claudeProviderPresets";
|
||||
|
||||
export interface HermesProviderPreset {
|
||||
name: string;
|
||||
nameKey?: string;
|
||||
websiteUrl: string;
|
||||
apiKeyUrl?: string;
|
||||
settingsConfig: HermesProviderSettingsConfig;
|
||||
isOfficial?: boolean;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
category?: ProviderCategory;
|
||||
templateValues?: Record<string, TemplateValueConfig>;
|
||||
theme?: PresetTheme;
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
isCustomTemplate?: boolean;
|
||||
}
|
||||
|
||||
export interface HermesProviderSettingsConfig {
|
||||
name: string;
|
||||
base_url?: string;
|
||||
api_key?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
{
|
||||
name: "OpenRouter",
|
||||
nameKey: "providerForm.presets.openrouter",
|
||||
websiteUrl: "https://openrouter.ai",
|
||||
apiKeyUrl: "https://openrouter.ai/keys",
|
||||
settingsConfig: {
|
||||
name: "openrouter",
|
||||
base_url: "https://openrouter.ai/api/v1",
|
||||
api_key: "",
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "openrouter",
|
||||
iconColor: "#6366F1",
|
||||
},
|
||||
{
|
||||
name: "Anthropic",
|
||||
nameKey: "providerForm.presets.anthropic",
|
||||
websiteUrl: "https://console.anthropic.com",
|
||||
apiKeyUrl: "https://console.anthropic.com/settings/keys",
|
||||
settingsConfig: {
|
||||
name: "anthropic",
|
||||
base_url: "https://api.anthropic.com",
|
||||
api_key: "",
|
||||
},
|
||||
isOfficial: true,
|
||||
category: "official",
|
||||
icon: "anthropic",
|
||||
iconColor: "#D4915D",
|
||||
},
|
||||
{
|
||||
name: "OpenAI",
|
||||
nameKey: "providerForm.presets.openai",
|
||||
websiteUrl: "https://platform.openai.com",
|
||||
apiKeyUrl: "https://platform.openai.com/api-keys",
|
||||
settingsConfig: {
|
||||
name: "openai",
|
||||
base_url: "https://api.openai.com/v1",
|
||||
api_key: "",
|
||||
},
|
||||
isOfficial: true,
|
||||
category: "official",
|
||||
icon: "openai",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "Google AI",
|
||||
nameKey: "providerForm.presets.googleai",
|
||||
websiteUrl: "https://ai.google.dev",
|
||||
apiKeyUrl: "https://aistudio.google.com/apikey",
|
||||
settingsConfig: {
|
||||
name: "google",
|
||||
api_key: "",
|
||||
},
|
||||
isOfficial: true,
|
||||
category: "official",
|
||||
icon: "gemini",
|
||||
iconColor: "#4285F4",
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
nameKey: "providerForm.presets.deepseek",
|
||||
websiteUrl: "https://platform.deepseek.com",
|
||||
apiKeyUrl: "https://platform.deepseek.com/api_keys",
|
||||
settingsConfig: {
|
||||
name: "deepseek",
|
||||
base_url: "https://api.deepseek.com",
|
||||
api_key: "",
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "deepseek",
|
||||
iconColor: "#4D6BFE",
|
||||
},
|
||||
{
|
||||
name: "Together AI",
|
||||
nameKey: "providerForm.presets.together",
|
||||
websiteUrl: "https://together.ai",
|
||||
apiKeyUrl: "https://api.together.ai/settings/api-keys",
|
||||
settingsConfig: {
|
||||
name: "together",
|
||||
base_url: "https://api.together.xyz/v1",
|
||||
api_key: "",
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "together",
|
||||
iconColor: "#0F6FFF",
|
||||
},
|
||||
{
|
||||
name: "Nous Research",
|
||||
websiteUrl: "https://nousresearch.com",
|
||||
settingsConfig: {
|
||||
name: "nous",
|
||||
base_url: "https://inference.nous.hermes.dev/v1",
|
||||
api_key: "",
|
||||
},
|
||||
isOfficial: true,
|
||||
category: "official",
|
||||
icon: "hermes",
|
||||
iconColor: "#7C3AED",
|
||||
},
|
||||
];
|
||||
@@ -50,22 +50,24 @@ export function useHermesModelConfig(enabled: boolean) {
|
||||
/**
|
||||
* Query agent configuration.
|
||||
*/
|
||||
export function useHermesAgentConfig() {
|
||||
export function useHermesAgentConfig(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: hermesKeys.agentConfig,
|
||||
queryFn: () => hermesApi.getAgentConfig(),
|
||||
staleTime: 30_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query env configuration.
|
||||
*/
|
||||
export function useHermesEnv() {
|
||||
export function useHermesEnv(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: hermesKeys.env,
|
||||
queryFn: () => hermesApi.getEnv(),
|
||||
staleTime: 30_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1630,6 +1630,64 @@
|
||||
"primaryModel": "Primary Model",
|
||||
"fallbackModel": "Fallback Model"
|
||||
},
|
||||
"hermes": {
|
||||
"form": {
|
||||
"baseUrl": "API Endpoint",
|
||||
"baseUrlHint": "The API endpoint URL for this provider.",
|
||||
"providerKey": "Provider Key",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "Lowercase letters, numbers, and hyphens only. Used as the provider name in config.yaml.",
|
||||
"providerKeyLockedHint": "This provider is in Hermes config; key is locked.",
|
||||
"providerKeyRequired": "Provider key is required",
|
||||
"providerKeyInvalid": "Provider key can only contain lowercase letters, numbers, and hyphens",
|
||||
"providerKeyDuplicate": "This provider key already exists"
|
||||
},
|
||||
"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-6",
|
||||
"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."
|
||||
},
|
||||
"health": {
|
||||
"title": "Hermes config warnings detected",
|
||||
"parseFailed": "config.yaml could not be parsed as valid YAML. Fix the file before editing it here.",
|
||||
"configNotFound": "Hermes config.yaml not found. Create it at ~/.hermes/config.yaml or configure the path in settings.",
|
||||
"envParseFailed": "The .env file could not be parsed."
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"warning": {
|
||||
"title": "Environment Variable Conflicts Detected",
|
||||
|
||||
@@ -1630,6 +1630,64 @@
|
||||
"primaryModel": "プライマリモデル",
|
||||
"fallbackModel": "フォールバックモデル"
|
||||
},
|
||||
"hermes": {
|
||||
"form": {
|
||||
"baseUrl": "API エンドポイント",
|
||||
"baseUrlHint": "プロバイダーの API エンドポイント URL。",
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "小文字、数字、ハイフンのみ。config.yaml のプロバイダー名として使用されます。",
|
||||
"providerKeyLockedHint": "このプロバイダーは Hermes 設定に追加済みのため、キーは変更できません。",
|
||||
"providerKeyRequired": "プロバイダーキーは必須です",
|
||||
"providerKeyInvalid": "プロバイダーキーは小文字、数字、ハイフンのみ使用できます",
|
||||
"providerKeyDuplicate": "このプロバイダーキーは既に存在します"
|
||||
},
|
||||
"model": {
|
||||
"title": "モデル設定",
|
||||
"description": "config.yaml の model セクションのデフォルトモデル設定を構成します。",
|
||||
"default": "デフォルトモデル",
|
||||
"defaultHint": "使用するデフォルトモデル(例: anthropic/claude-opus-4-6)",
|
||||
"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 オブジェクトである必要があります。"
|
||||
},
|
||||
"health": {
|
||||
"title": "Hermes 設定の警告を検出",
|
||||
"parseFailed": "config.yaml を有効な YAML として解析できません。ここで編集する前にファイルを修正してください。",
|
||||
"configNotFound": "Hermes config.yaml が見つかりません。~/.hermes/config.yaml に作成するか、設定でパスを構成してください。",
|
||||
"envParseFailed": ".env ファイルを解析できません。"
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"warning": {
|
||||
"title": "競合する環境変数を検出しました",
|
||||
|
||||
@@ -1631,6 +1631,64 @@
|
||||
"primaryModel": "默认模型",
|
||||
"fallbackModel": "回退模型"
|
||||
},
|
||||
"hermes": {
|
||||
"form": {
|
||||
"baseUrl": "API 端点",
|
||||
"baseUrlHint": "供应商的 API 端点地址。",
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "只能使用小写字母、数字和连字符。用作 config.yaml 中的供应商名称。",
|
||||
"providerKeyLockedHint": "该供应商已添加到 Hermes 配置中,标识不可修改。",
|
||||
"providerKeyRequired": "供应商标识不能为空",
|
||||
"providerKeyInvalid": "供应商标识只能包含小写字母、数字和连字符",
|
||||
"providerKeyDuplicate": "该供应商标识已存在"
|
||||
},
|
||||
"model": {
|
||||
"title": "模型配置",
|
||||
"description": "配置 config.yaml 中 model 节的默认模型设置。",
|
||||
"default": "默认模型",
|
||||
"defaultHint": "使用的默认模型,如 anthropic/claude-opus-4-6",
|
||||
"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 对象。"
|
||||
},
|
||||
"health": {
|
||||
"title": "检测到 Hermes 配置警告",
|
||||
"parseFailed": "config.yaml 无法解析为有效 YAML。请先修复文件再在此编辑。",
|
||||
"configNotFound": "未找到 Hermes config.yaml。请在 ~/.hermes/config.yaml 创建或在设置中配置路径。",
|
||||
"envParseFailed": ".env 文件无法解析。"
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"warning": {
|
||||
"title": "检测到系统环境变量冲突",
|
||||
|
||||
@@ -76,6 +76,7 @@ export const icons: Record<string, string> = {
|
||||
novita: `<svg width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg"><title>Novita</title><g clip-path="url(#clip0_3135_1230)"><path d="M15.5564 8.26172V16.5239L2.1875 29.8928H15.5564V21.6302L23.8194 29.8928H37.1875L15.5564 8.26172Z" fill="#000000"/></g><defs><clipPath id="clip0_3135_1230"><rect width="35" height="21.6311" fill="white" transform="translate(2.1875 8.26172)"/></clipPath></defs></svg>`,
|
||||
nvidia: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Nvidia</title><path d="M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z" fill="#74B71B" fill-rule="nonzero"></path></svg>`,
|
||||
bailian: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>BaiLian</title><path d="M6.336 8.919v6.162l5.335-3.083L6.337 8.92z" fill-opacity=".4"></path><path d="M21.394 5.288s-.006-.006-.01-.006L17.01 2.754 6.336 8.92l5.335 3.082 9.701-5.6.016-.01a.635.635 0 00.006-1.1v-.003z" fill-opacity=".8"></path><path d="M21.71 12.465a.62.62 0 00-.316.085s-.006 0-.009.003l-4.375 2.528 5.05 2.915h.006a2.06 2.06 0 00.28-1.04v-3.855a.637.637 0 00-.636-.636z"></path><path d="M22.06 17.996l-5.05-2.915L6.34 21.242l4.27 2.465s.016.006.022.012a2.102 2.102 0 002.093 0c.006-.003.016-.006.022-.012l8.538-4.93c.003 0 .006-.003.01-.006.321-.183.589-.45.775-.772h-.006l-.004-.003z" fill-opacity=".8"></path><path d="M11.672 11.998l-5.336 3.083-1.444.832-3.605 2.083H1.28c.173.303.416.555.709.738l.078.044.016.01.02.012 4.232 2.442 10.671-6.161-5.335-3.082z"></path><path d="M12.74.29c-.1-.06-.208-.107-.315-.148-.02-.006-.038-.016-.057-.022a2.121 2.121 0 00-.7-.12c-.233 0-.457.038-.668.11l-.031.01a2.196 2.196 0 00-.372.17L2.068 5.222s-.003 0-.006.003c-.324.183-.592.451-.781.773h.006l5.049 2.918L17.01 2.758 12.74.29z" fill-opacity=".6"></path><path d="M1.287 6.001H1.28A2.06 2.06 0 001 7.041v9.915c0 .378.1.735.28 1.043h.007l5.049-2.918V8.919l-5.05-2.918z" fill-opacity=".3"></path></svg>`,
|
||||
hermes: `<svg height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><title>Hermes</title><path d="M6 3a1.5 1.5 0 013 0v7.5h6V3a1.5 1.5 0 013 0v18a1.5 1.5 0 01-3 0v-7.5H9V21a1.5 1.5 0 01-3 0V3z" fill="#7C3AED"/><path d="M2.5 6a.5.5 0 01.4-.49L6 5v2l-3.1.51A.5.5 0 012.5 7V6zm19 0a.5.5 0 00-.4-.49L18 5v2l3.1.51a.5.5 0 00.4-.49V6z" fill="#7C3AED" fill-opacity=".5"/></svg>`,
|
||||
};
|
||||
|
||||
export const iconUrls: Record<string, string> = {
|
||||
|
||||
@@ -268,6 +268,13 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["openclaw", "lobster", "claw"],
|
||||
defaultColor: "#ff4f40",
|
||||
},
|
||||
hermes: {
|
||||
name: "hermes",
|
||||
displayName: "Hermes",
|
||||
category: "ai-provider",
|
||||
keywords: ["hermes", "agent", "nous", "nousresearch"],
|
||||
defaultColor: "#7C3AED",
|
||||
},
|
||||
packycode: {
|
||||
name: "packycode",
|
||||
displayName: "PackyCode",
|
||||
|
||||
@@ -591,6 +591,7 @@ export interface HermesAgentConfig {
|
||||
max_turns?: number;
|
||||
reasoning_effort?: string;
|
||||
tool_use_enforcement?: string | boolean | string[];
|
||||
approvals_mode?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user