From 088b47b08a69634777bb6a7c83a16f56e3eb4774 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 19 Apr 2026 20:59:01 +0800 Subject: [PATCH] refactor(hermes): delegate deep config to Hermes Web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/commands/hermes.rs | 87 +++--- src-tauri/src/hermes_config.rs | 288 +------------------ src-tauri/src/lib.rs | 6 +- src/App.tsx | 74 ++--- src/components/hermes/AgentPanel.tsx | 249 ---------------- src/components/hermes/EnvPanel.tsx | 128 --------- src/components/hermes/HermesHealthBanner.tsx | 24 +- src/components/hermes/ModelPanel.tsx | 202 ------------- src/hooks/useHermes.ts | 120 +++----- src/i18n/locales/en.json | 43 +-- src/i18n/locales/ja.json | 43 +-- src/i18n/locales/zh.json | 43 +-- src/lib/api/hermes.ts | 86 +----- src/types.ts | 12 - 14 files changed, 152 insertions(+), 1253 deletions(-) delete mode 100644 src/components/hermes/AgentPanel.tsx delete mode 100644 src/components/hermes/EnvPanel.tsx delete mode 100644 src/components/hermes/ModelPanel.tsx diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index d6a9286d1..1034fc495 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -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 Result, 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::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, String> { - hermes_config::get_agent_config().map_err(|e| e.to_string()) -} +pub async fn open_hermes_web_ui(app: AppHandle, path: Option) -> Result<(), String> { + let port = std::env::var("HERMES_WEB_PORT") + .ok() + .and_then(|raw| raw.trim().parse::().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::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::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::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::) + .map_err(|e| format!("failed to open Hermes Web UI: {e}")) } diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 4f7d799e8..431e616d9 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -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, } -/// 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, - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning_effort: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_use_enforcement: Option, - /// Preserve unknown fields for forward compatibility - #[serde(flatten)] - pub extra: HashMap, -} - -/// Hermes env config (from .env file, not config.yaml) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HermesEnvConfig { - #[serde(flatten)] - pub vars: HashMap, -} - // ============================================================================ // 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, 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 { - 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 { - 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 { - 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 = env.vars.keys().cloned().collect(); - let mut lines: Vec = 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 = 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] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b31f0cf01..42d06b0b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/App.tsx b/src/App.tsx index d1dee843f..741d83d2f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ; case "openclawAgents": return ; - case "hermesModel": - return ; - case "hermesAgent": - return ; - case "hermesEnv": - return ; default: return (
@@ -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")}
) : ( @@ -1399,33 +1381,6 @@ function App() { > {activeApp === "hermes" ? ( <> - - - + ) : activeApp === "openclaw" ? ( <> diff --git a/src/components/hermes/AgentPanel.tsx b/src/components/hermes/AgentPanel.tsx deleted file mode 100644 index a90fde681..000000000 --- a/src/components/hermes/AgentPanel.tsx +++ /dev/null @@ -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>({}); - - 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 ( -
-
- {t("common.loading")} -
-
- ); - } - - return ( -
-

- {t("hermes.agent.description")} -

- -
-
-
- - setMaxTurns(e.target.value)} - placeholder="100" - /> -

- {t("hermes.agent.maxTurnsHint", { - defaultValue: "Maximum number of agent turns per session", - })} -

-
- -
- - -

- {t("hermes.agent.reasoningEffortHint", { - defaultValue: - "Controls the depth of reasoning: none, minimal, low, medium, high, xhigh", - })} -

-
- -
- - setToolUseEnforcement(e.target.value)} - placeholder="auto" - /> -

- {t("hermes.agent.toolUseHint", { - defaultValue: - 'Values: "auto", true, false, or a JSON array of tool names', - })} -

-
- -
- - -

- {t("hermes.agent.approvalsModeHint", { - defaultValue: - "Controls tool call approval: manual (always ask), smart (auto-approve safe), off (never ask)", - })} -

-
-
-
- -
- -
-
- ); -}; - -export default AgentPanel; diff --git a/src/components/hermes/EnvPanel.tsx b/src/components/hermes/EnvPanel.tsx deleted file mode 100644 index b8ce9f735..000000000 --- a/src/components/hermes/EnvPanel.tsx +++ /dev/null @@ -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 { - 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; -} - -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 ( -
-
- {t("common.loading")} -
-
- ); - } - - return ( -
-

- {t("hermes.env.description")} -

-

- {t("hermes.env.editorHint", { - defaultValue: - "Edit the Hermes .env file as a JSON key-value map. Keys become environment variable names.", - })} -

- - - -
- -
-
- ); -}; - -export default EnvPanel; diff --git a/src/components/hermes/HermesHealthBanner.tsx b/src/components/hermes/HermesHealthBanner.tsx index 6907c6138..bf6875c6f 100644 --- a/src/components/hermes/HermesHealthBanner.tsx +++ b/src/components/hermes/HermesHealthBanner.tsx @@ -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 = ({ warnings, }) => { const { t } = useTranslation(); + const openHermesWebUI = useOpenHermesWebUI(); const items = useMemo( () => @@ -55,10 +58,21 @@ const HermesHealthBanner: React.FC = ({
- - {t("hermes.health.title", { - defaultValue: "Hermes config warnings detected", - })} + + + {t("hermes.health.title", { + defaultValue: "Hermes config warnings detected", + })} + +
    diff --git a/src/components/hermes/ModelPanel.tsx b/src/components/hermes/ModelPanel.tsx deleted file mode 100644 index 4c5aeb762..000000000 --- a/src/components/hermes/ModelPanel.tsx +++ /dev/null @@ -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>({}); - - 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 ( -
    -
    - {t("common.loading")} -
    -
    - ); - } - - return ( -
    -

    - {t("hermes.model.description")} -

    - -
    -
    -
    - - setDefaultModel(e.target.value)} - placeholder="anthropic/claude-opus-4-7" - /> -

    - {t("hermes.model.defaultHint", { - defaultValue: - "The default model to use, e.g. anthropic/claude-opus-4-7", - })} -

    -
    - -
    - - setProvider(e.target.value)} - placeholder="openrouter" - /> -

    - {t("hermes.model.providerHint", { - defaultValue: - "Provider name for model routing (e.g. openrouter, anthropic)", - })} -

    -
    - -
    - - setBaseUrl(e.target.value)} - placeholder="https://api.example.com/v1" - /> -

    - {t("hermes.model.baseUrlHint", { - defaultValue: "Override the API endpoint URL for this model", - })} -

    -
    - -
    - - setContextLength(e.target.value)} - placeholder="200000" - /> -
    - -
    - - setMaxTokens(e.target.value)} - placeholder="16384" - /> -
    -
    -
    - -
    - -
    -
    - ); -}; - -export default ModelPanel; diff --git a/src/hooks/useHermes.ts b/src/hooks/useHermes.ts index 63ee17fd1..63599dd9c 100644 --- a/src/hooks/useHermes.ts +++ b/src/hooks/useHermes.ts @@ -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], + ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d53be2af9..a2a21aa5c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c9b59ab4b..50925fcea 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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 設定の警告を検出", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index aa989e8f4..b0edf8d9c 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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 配置警告", diff --git a/src/lib/api/hermes.ts b/src/lib/api/hermes.ts index 26bd17895..f5cbb504a 100644 --- a/src/lib/api/hermes.ts +++ b/src/lib/api/hermes.ts @@ -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 { return await invoke("get_hermes_model_config"); }, - /** - * Set model configuration - */ - async setModelConfig(config: HermesModelConfig): Promise { - return await invoke("set_hermes_model_config", { config }); - }, - - // ============================================================ - // Agent Configuration - // ============================================================ - - /** - * Get agent configuration - */ - async getAgentConfig(): Promise { - return await invoke("get_hermes_agent_config"); - }, - - /** - * Set agent configuration - */ - async setAgentConfig(config: HermesAgentConfig): Promise { - return await invoke("set_hermes_agent_config", { config }); - }, - - // ============================================================ - // Env Configuration - // ============================================================ - - /** - * Get env configuration (.env file) - */ - async getEnv(): Promise { - return await invoke("get_hermes_env"); - }, - - /** - * Set env configuration (.env file) - */ - async setEnv(env: HermesEnvConfig): Promise { - return await invoke("set_hermes_env", { env }); - }, - - // ============================================================ - // Health - // ============================================================ - - /** - * Scan config health and return warnings - */ async scanHealth(): Promise { 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 | null> { - return await invoke("get_hermes_live_provider", { providerId }); + async openWebUI(path?: string): Promise { + await invoke("open_hermes_web_ui", { path: path ?? null }); }, }; diff --git a/src/types.ts b/src/types.ts index ad675e183..b868faa74 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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;