mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-24 06:40:21 +08:00
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:
@@ -1,8 +1,15 @@
|
||||
use tauri::State;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use crate::hermes_config;
|
||||
use crate::store::AppState;
|
||||
|
||||
/// Error string returned when `open_hermes_web_ui` cannot reach the Hermes
|
||||
/// FastAPI server. Kept in sync with the `HERMES_WEB_OFFLINE_ERROR` constant
|
||||
/// in `src/hooks/useHermes.ts` so the frontend can branch on it.
|
||||
const HERMES_WEB_OFFLINE_ERROR: &str = "hermes_web_offline";
|
||||
|
||||
// ============================================================================
|
||||
// Hermes Provider Commands
|
||||
// ============================================================================
|
||||
@@ -43,52 +50,60 @@ pub fn scan_hermes_config_health() -> Result<Vec<hermes_config::HermesHealthWarn
|
||||
// Model Configuration Commands
|
||||
// ============================================================================
|
||||
|
||||
/// Get Hermes model config (model section of config.yaml)
|
||||
/// Get Hermes model config (model section of config.yaml). Read-only — writes
|
||||
/// happen implicitly through `apply_switch_defaults` when switching providers.
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_model_config() -> Result<Option<hermes_config::HermesModelConfig>, String> {
|
||||
hermes_config::get_model_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set Hermes model config (model section of config.yaml)
|
||||
#[tauri::command]
|
||||
pub fn set_hermes_model_config(
|
||||
model: hermes_config::HermesModelConfig,
|
||||
) -> Result<hermes_config::HermesWriteOutcome, String> {
|
||||
hermes_config::set_model_config(&model).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent Configuration Commands
|
||||
// Hermes Web UI launcher
|
||||
// ============================================================================
|
||||
|
||||
/// Get Hermes agent config (agent section of config.yaml)
|
||||
/// Probe the local Hermes Web UI (FastAPI) and open it in the system browser.
|
||||
///
|
||||
/// Port discovery priority:
|
||||
/// 1. `HERMES_WEB_PORT` environment variable
|
||||
/// 2. Default 9119
|
||||
///
|
||||
/// Hermes wraps all `/api/*` routes in a Bearer-token middleware, so a GET
|
||||
/// against `/api/status` returning **either 200 or 401** confirms the server
|
||||
/// is live. The session token lives only in the Hermes process memory and is
|
||||
/// injected into the returned HTML via `window.__HERMES_SESSION_TOKEN__`, so
|
||||
/// there is no need (and no way) for CC Switch to inject it — we just open
|
||||
/// the URL and let Hermes handle auth.
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_agent_config() -> Result<Option<hermes_config::HermesAgentConfig>, String> {
|
||||
hermes_config::get_agent_config().map_err(|e| e.to_string())
|
||||
}
|
||||
pub async fn open_hermes_web_ui(app: AppHandle, path: Option<String>) -> Result<(), String> {
|
||||
let port = std::env::var("HERMES_WEB_PORT")
|
||||
.ok()
|
||||
.and_then(|raw| raw.trim().parse::<u16>().ok())
|
||||
.unwrap_or(9119);
|
||||
|
||||
/// Set Hermes agent config (agent section of config.yaml)
|
||||
#[tauri::command]
|
||||
pub fn set_hermes_agent_config(
|
||||
agent: hermes_config::HermesAgentConfig,
|
||||
) -> Result<hermes_config::HermesWriteOutcome, String> {
|
||||
hermes_config::set_agent_config(&agent).map_err(|e| e.to_string())
|
||||
}
|
||||
let base = format!("http://127.0.0.1:{port}");
|
||||
|
||||
// ============================================================================
|
||||
// Env Configuration Commands
|
||||
// ============================================================================
|
||||
// Probe /api/status with a short timeout. Hermes returns 200 when open or
|
||||
// 401 when the session token is required — either way the server is live.
|
||||
// Only a connection error / timeout means the server isn't running.
|
||||
let probe_url = format!("{base}/api/status");
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(1200))
|
||||
.no_proxy()
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build probe client: {e}"))?;
|
||||
|
||||
/// Get Hermes env config (.env file)
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_env() -> Result<hermes_config::HermesEnvConfig, String> {
|
||||
hermes_config::read_env().map_err(|e| e.to_string())
|
||||
}
|
||||
match client.get(&probe_url).send().await {
|
||||
Ok(_) => {}
|
||||
Err(_) => return Err(HERMES_WEB_OFFLINE_ERROR.to_string()),
|
||||
}
|
||||
|
||||
/// Set Hermes env config (.env file)
|
||||
#[tauri::command]
|
||||
pub fn set_hermes_env(
|
||||
env: hermes_config::HermesEnvConfig,
|
||||
) -> Result<hermes_config::HermesWriteOutcome, String> {
|
||||
hermes_config::write_env(&env).map_err(|e| e.to_string())
|
||||
let target = match path.as_deref() {
|
||||
Some(p) if p.starts_with('/') => format!("{base}{p}"),
|
||||
Some(p) if !p.is_empty() => format!("{base}/{p}"),
|
||||
_ => format!("{base}/"),
|
||||
};
|
||||
|
||||
app.opener()
|
||||
.open_url(&target, None::<String>)
|
||||
.map_err(|e| format!("failed to open Hermes Web UI: {e}"))
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ use crate::error::AppError;
|
||||
use crate::settings::{effective_backup_retain_count, get_hermes_override_dir};
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
@@ -110,27 +110,6 @@ pub struct HermesModelConfig {
|
||||
pub extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Hermes agent section config (agent + approvals)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HermesAgentConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_turns: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_use_enforcement: Option<serde_json::Value>,
|
||||
/// Preserve unknown fields for forward compatibility
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Hermes env config (from .env file, not config.yaml)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HermesEnvConfig {
|
||||
#[serde(flatten)]
|
||||
pub vars: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core YAML Read Functions
|
||||
// ============================================================================
|
||||
@@ -666,148 +645,6 @@ pub fn apply_switch_defaults(
|
||||
set_model_config(&merged)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent Config Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Get the `agent` section as a typed config.
|
||||
pub fn get_agent_config() -> Result<Option<HermesAgentConfig>, AppError> {
|
||||
let config = read_hermes_config()?;
|
||||
let Some(agent_value) = config.get("agent") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let json_val = yaml_to_json(agent_value)?;
|
||||
let agent = serde_json::from_value(json_val)
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse Hermes agent config: {e}")))?;
|
||||
Ok(Some(agent))
|
||||
}
|
||||
|
||||
/// Set the `agent` section.
|
||||
pub fn set_agent_config(agent: &HermesAgentConfig) -> Result<HermesWriteOutcome, AppError> {
|
||||
let json_val =
|
||||
serde_json::to_value(agent).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
let yaml_val = json_to_yaml(&json_val)?;
|
||||
write_yaml_section_to_config("agent", &yaml_val)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// .env Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Read the Hermes `.env` file (`~/.hermes/.env`).
|
||||
///
|
||||
/// Parses dotenv format (KEY=VALUE, `#` comments, blank lines).
|
||||
pub fn read_env() -> Result<HermesEnvConfig, AppError> {
|
||||
let path = get_hermes_dir().join(".env");
|
||||
if !path.exists() {
|
||||
return Ok(HermesEnvConfig {
|
||||
vars: HashMap::new(),
|
||||
});
|
||||
}
|
||||
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
let mut vars = HashMap::new();
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = trimmed.split_once('=') {
|
||||
let key = key.trim().to_string();
|
||||
let value = value
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.to_string();
|
||||
vars.insert(key, serde_json::Value::String(value));
|
||||
}
|
||||
}
|
||||
Ok(HermesEnvConfig { vars })
|
||||
}
|
||||
|
||||
/// Write the Hermes `.env` file (`~/.hermes/.env`).
|
||||
///
|
||||
/// Preserves comment lines and ordering. Keys not present in the new env are
|
||||
/// removed; new keys are appended.
|
||||
pub fn write_env(env: &HermesEnvConfig) -> Result<HermesWriteOutcome, AppError> {
|
||||
let path = get_hermes_dir().join(".env");
|
||||
let _guard = hermes_write_lock().lock()?;
|
||||
|
||||
// Read existing file to preserve comments and ordering
|
||||
let existing_content = if path.exists() {
|
||||
fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Build new content: preserve comment lines, update/add key-value pairs
|
||||
let mut remaining_keys: HashSet<String> = env.vars.keys().cloned().collect();
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
|
||||
for line in existing_content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
lines.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
if let Some((key, _)) = trimmed.split_once('=') {
|
||||
let key = key.trim();
|
||||
if let Some(new_value) = env.vars.get(key) {
|
||||
// Update existing key
|
||||
let value_str = json_value_as_env_str(new_value);
|
||||
lines.push(format!("{key}={value_str}"));
|
||||
remaining_keys.remove(key);
|
||||
}
|
||||
// If key is not in new env, it's deleted (don't add it)
|
||||
} else {
|
||||
lines.push(line.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Add new keys that weren't in the original file (sorted for determinism)
|
||||
let mut new_keys: Vec<String> = remaining_keys.into_iter().collect();
|
||||
new_keys.sort();
|
||||
for key in new_keys {
|
||||
if let Some(value) = env.vars.get(&key) {
|
||||
let value_str = json_value_as_env_str(value);
|
||||
lines.push(format!("{key}={value_str}"));
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_content = lines.join("\n");
|
||||
if !new_content.is_empty() && !new_content.ends_with('\n') {
|
||||
new_content.push('\n');
|
||||
}
|
||||
|
||||
if new_content == existing_content {
|
||||
return Ok(HermesWriteOutcome::default());
|
||||
}
|
||||
|
||||
// Backup if file existed with content
|
||||
let backup_path = if !existing_content.is_empty() {
|
||||
Some(create_hermes_backup(&existing_content)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
atomic_write(&path, new_content.as_bytes())?;
|
||||
|
||||
Ok(HermesWriteOutcome {
|
||||
backup_path: backup_path.map(|p| p.display().to_string()),
|
||||
warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a serde_json::Value to a string suitable for .env files.
|
||||
fn json_value_as_env_str(value: &serde_json::Value) -> String {
|
||||
match value {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Health Check
|
||||
// ============================================================================
|
||||
@@ -1172,108 +1009,7 @@ model:
|
||||
});
|
||||
}
|
||||
|
||||
// ---- .env read/write tests ----
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn env_read_write_roundtrip() {
|
||||
with_test_home(|| {
|
||||
// Write initial env
|
||||
let env = HermesEnvConfig {
|
||||
vars: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(
|
||||
"API_KEY".to_string(),
|
||||
serde_json::Value::String("sk-test-123".to_string()),
|
||||
);
|
||||
m.insert(
|
||||
"DEBUG".to_string(),
|
||||
serde_json::Value::String("true".to_string()),
|
||||
);
|
||||
m
|
||||
},
|
||||
};
|
||||
write_env(&env).unwrap();
|
||||
|
||||
// Read back
|
||||
let env_read = read_env().unwrap();
|
||||
assert_eq!(
|
||||
env_read.vars.get("API_KEY").unwrap().as_str().unwrap(),
|
||||
"sk-test-123"
|
||||
);
|
||||
assert_eq!(
|
||||
env_read.vars.get("DEBUG").unwrap().as_str().unwrap(),
|
||||
"true"
|
||||
);
|
||||
|
||||
// Update: remove DEBUG, add NEW_VAR
|
||||
let env2 = HermesEnvConfig {
|
||||
vars: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(
|
||||
"API_KEY".to_string(),
|
||||
serde_json::Value::String("sk-test-456".to_string()),
|
||||
);
|
||||
m.insert(
|
||||
"NEW_VAR".to_string(),
|
||||
serde_json::Value::String("hello".to_string()),
|
||||
);
|
||||
m
|
||||
},
|
||||
};
|
||||
write_env(&env2).unwrap();
|
||||
|
||||
let env_read2 = read_env().unwrap();
|
||||
assert_eq!(
|
||||
env_read2.vars.get("API_KEY").unwrap().as_str().unwrap(),
|
||||
"sk-test-456"
|
||||
);
|
||||
assert!(env_read2.vars.get("DEBUG").is_none());
|
||||
assert_eq!(
|
||||
env_read2.vars.get("NEW_VAR").unwrap().as_str().unwrap(),
|
||||
"hello"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn env_preserves_comments() {
|
||||
with_test_home(|| {
|
||||
let hermes_dir = get_hermes_dir();
|
||||
fs::create_dir_all(&hermes_dir).unwrap();
|
||||
let env_path = hermes_dir.join(".env");
|
||||
fs::write(
|
||||
&env_path,
|
||||
"# Hermes environment config\nAPI_KEY=old-key\n# Keep this comment\nDEBUG=true\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let env = HermesEnvConfig {
|
||||
vars: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(
|
||||
"API_KEY".to_string(),
|
||||
serde_json::Value::String("new-key".to_string()),
|
||||
);
|
||||
m.insert(
|
||||
"DEBUG".to_string(),
|
||||
serde_json::Value::String("false".to_string()),
|
||||
);
|
||||
m
|
||||
},
|
||||
};
|
||||
write_env(&env).unwrap();
|
||||
|
||||
let content = fs::read_to_string(&env_path).unwrap();
|
||||
assert!(content.contains("# Hermes environment config"));
|
||||
assert!(content.contains("# Keep this comment"));
|
||||
assert!(content.contains("API_KEY=new-key"));
|
||||
assert!(content.contains("DEBUG=false"));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Model/Agent config tests ----
|
||||
// ---- Model config tests ----
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
@@ -1302,26 +1038,6 @@ model:
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn agent_config_roundtrip() {
|
||||
with_test_home(|| {
|
||||
assert!(get_agent_config().unwrap().is_none());
|
||||
|
||||
let agent = HermesAgentConfig {
|
||||
max_turns: Some(50),
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
tool_use_enforcement: None,
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
set_agent_config(&agent).unwrap();
|
||||
|
||||
let read_agent = get_agent_config().unwrap().unwrap();
|
||||
assert_eq!(read_agent.max_turns, Some(50));
|
||||
assert_eq!(read_agent.reasoning_effort.as_deref(), Some("high"));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Health check tests ----
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1257,11 +1257,7 @@ pub fn run() {
|
||||
commands::get_hermes_live_provider,
|
||||
commands::scan_hermes_config_health,
|
||||
commands::get_hermes_model_config,
|
||||
commands::set_hermes_model_config,
|
||||
commands::get_hermes_agent_config,
|
||||
commands::set_hermes_agent_config,
|
||||
commands::get_hermes_env,
|
||||
commands::set_hermes_env,
|
||||
commands::open_hermes_web_ui,
|
||||
// Global upstream proxy
|
||||
commands::get_global_proxy_url,
|
||||
commands::set_global_proxy_url,
|
||||
|
||||
+19
-55
@@ -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" ? (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 設定の警告を検出",
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user