mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +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,
|
||||
|
||||
Reference in New Issue
Block a user