From 8e9452b7ec7d0f972cf986dc5c1bf334a860fd8c Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 21 Apr 2026 21:11:15 +0800 Subject: [PATCH] feat(hermes): launch dashboard from toolbar when Web UI is offline When the Hermes Web UI probe fails, the toolbar entry now opens an info confirm dialog offering to run `hermes dashboard` in the user's preferred terminal. Accepting spawns it via a temp bash/batch script; `hermes dashboard` itself opens the browser once ready, so we do not poll. The Memory panel and Health banner keep the existing toast behavior. Also corrects the stale `hermes web` hint in the offline toast (the real command is `hermes dashboard`) and reorders Linux terminal detection to try `which` before stat'ing /usr/bin, /bin, /usr/local/bin. --- src-tauri/src/commands/hermes.rs | 13 +++ src-tauri/src/commands/misc.rs | 182 +++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/App.tsx | 28 ++++- src/hooks/useHermes.ts | 15 ++- src/i18n/locales/en.json | 9 +- src/i18n/locales/ja.json | 9 +- src/i18n/locales/zh.json | 9 +- src/lib/api/hermes.ts | 5 + 9 files changed, 259 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 95df3e96..ee1aebe3 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -134,3 +134,16 @@ pub async fn open_hermes_web_ui(app: AppHandle, path: Option) -> Result< .open_url(&target, None::) .map_err(|e| format!("failed to open Hermes Web UI: {e}")) } + +/// Open the preferred terminal and run `hermes dashboard`. Non-blocking — +/// callers should reinvoke `open_hermes_web_ui` once the server is ready, +/// since Hermes startup can take several seconds and may fail outright if +/// the `hermes-agent[web]` extras are missing. +#[tauri::command] +pub async fn launch_hermes_dashboard() -> Result<(), String> { + tokio::task::spawn_blocking(|| { + crate::commands::misc::launch_terminal_running("hermes dashboard", "hermes_dashboard") + }) + .await + .map_err(|e| format!("launch task join error: {e}"))? +} diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 623166e5..1c7f9991 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1310,6 +1310,188 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S Ok(()) } +/// 打开用户首选终端并在其中执行一条命令行。脚本尾部 `read -n 1` / `pause` +/// 是刻意设计的——让命令退出后窗口不要瞬间关闭,用户才看得到 `command +/// not found` / `ModuleNotFoundError` 这类诊断信息。 +/// +/// **Security**:`command_line` 会被原样拼进 shell/batch 脚本,调用方必须 +/// 保证它是可信字符串(当前只由后端硬编码调用)。 +pub(crate) fn launch_terminal_running(command_line: &str, label: &str) -> Result<(), String> { + let temp_dir = std::env::temp_dir(); + let pid = std::process::id(); + + #[cfg(any(target_os = "macos", target_os = "linux"))] + let (script_file, script_content) = { + let file = temp_dir.join(format!("cc_switch_{}_{}.sh", label, pid)); + let content = format!( + r#"#!/bin/bash +trap 'rm -f "{script_path}"' EXIT +echo "[cc-switch] Starting: {cmd}" +echo "" +{cmd} +echo "" +echo "[cc-switch] Command exited. Press any key to close." +read -n 1 -s +"#, + script_path = file.display(), + cmd = command_line, + ); + (file, content) + }; + + #[cfg(target_os = "macos")] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::write(&script_file, &script_content) + .map_err(|e| format!("写入启动脚本失败: {e}"))?; + std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("设置脚本权限失败: {e}"))?; + + let preferred = crate::settings::get_preferred_terminal(); + let terminal = preferred.as_deref().unwrap_or("terminal"); + + let result = match terminal { + "iterm2" => launch_macos_iterm2(&script_file), + "alacritty" => launch_macos_open_app("Alacritty", &script_file, true), + "kitty" => launch_macos_open_app("kitty", &script_file, false), + "ghostty" => launch_macos_open_app("Ghostty", &script_file, true), + "wezterm" => launch_macos_open_app("WezTerm", &script_file, true), + "kaku" => launch_macos_open_app("Kaku", &script_file, true), + _ => launch_macos_terminal_app(&script_file), + }; + + if result.is_err() && terminal != "terminal" { + log::warn!( + "首选终端 {} 启动失败,回退到 Terminal.app: {:?}", + terminal, + result.as_ref().err() + ); + return launch_macos_terminal_app(&script_file); + } + result + } + + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::PermissionsExt; + use std::process::Command; + + std::fs::write(&script_file, &script_content) + .map_err(|e| format!("写入启动脚本失败: {e}"))?; + std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("设置脚本权限失败: {e}"))?; + + let preferred = crate::settings::get_preferred_terminal(); + let default_terminals = [ + ("gnome-terminal", vec!["--"]), + ("konsole", vec!["-e"]), + ("xfce4-terminal", vec!["-e"]), + ("mate-terminal", vec!["--"]), + ("lxterminal", vec!["-e"]), + ("alacritty", vec!["-e"]), + ("kitty", vec!["-e"]), + ("ghostty", vec!["-e"]), + ]; + + let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred { + let pref_args = default_terminals + .iter() + .find(|(name, _)| *name == pref.as_str()) + .map(|(_, args)| args.to_vec()) + .unwrap_or_else(|| vec!["-e"]); + let mut list = vec![(pref.as_str(), pref_args)]; + for (name, args) in &default_terminals { + if *name != pref.as_str() { + list.push((*name, args.to_vec())); + } + } + list + } else { + default_terminals + .iter() + .map(|(name, args)| (*name, args.to_vec())) + .collect() + }; + + let mut last_error = String::from("未找到可用的终端"); + + for (terminal, args) in terminals_to_try { + let terminal_exists = which_command(terminal) + || ["/usr/bin", "/bin", "/usr/local/bin"] + .iter() + .any(|dir| std::path::Path::new(&format!("{}/{}", dir, terminal)).exists()); + + if terminal_exists { + let spawn_result = Command::new(terminal) + .args(&args) + .arg("bash") + .arg(script_file.to_string_lossy().as_ref()) + .spawn(); + match spawn_result { + Ok(_) => return Ok(()), + Err(e) => { + last_error = format!("执行 {} 失败: {}", terminal, e); + } + } + } + } + + let _ = std::fs::remove_file(&script_file); + Err(last_error) + } + + #[cfg(target_os = "windows")] + { + let preferred = crate::settings::get_preferred_terminal(); + let terminal = preferred.as_deref().unwrap_or("cmd"); + + let bat_file = temp_dir.join(format!("cc_switch_{}_{}.bat", label, pid)); + let content = format!( + "@echo off\r\necho [cc-switch] Starting: {cmd}\r\necho.\r\n{cmd}\r\necho.\r\necho [cc-switch] Command exited. Press any key to close.\r\npause >nul\r\ndel \"%~f0\" >nul 2>&1\r\n", + cmd = command_line, + ); + std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?; + + let bat_path = bat_file.to_string_lossy(); + let ps_cmd = format!("& '{}'", bat_path); + + let result = match terminal { + "powershell" => run_windows_start_command( + &["powershell", "-NoExit", "-Command", &ps_cmd], + "PowerShell", + ), + "wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"), + _ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), + }; + + let final_result = if result.is_err() && terminal != "cmd" { + log::warn!( + "首选终端 {} 启动失败,回退到 cmd: {:?}", + terminal, + result.as_ref().err() + ); + run_windows_start_command(&["cmd", "/K", &bat_path], "cmd") + } else { + result + }; + + // The .bat self-deletes (`del "%~f0"`) after it runs, but that only + // fires if *some* terminal actually launched it. If every attempt + // failed, sweep the temp file ourselves to avoid pollution. + if final_result.is_err() { + let _ = std::fs::remove_file(&bat_file); + } + final_result + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = (temp_dir, pid, command_line, label); + Err("不支持的操作系统".to_string()) + } +} + /// 设置窗口主题(Windows/macOS 标题栏颜色) /// theme: "dark" | "light" | "system" #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ebaa2f8c..72cff929 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1258,6 +1258,7 @@ pub fn run() { commands::scan_hermes_config_health, commands::get_hermes_model_config, commands::open_hermes_web_ui, + commands::launch_hermes_dashboard, commands::get_hermes_memory, commands::set_hermes_memory, commands::get_hermes_memory_limits, diff --git a/src/App.tsx b/src/App.tsx index de1841c8..656a8a39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,6 +46,7 @@ import { useHermesHealth, useOpenHermesWebUI, } from "@/hooks/useHermes"; +import { hermesApi } from "@/lib/api/hermes"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useAutoCompact } from "@/hooks/useAutoCompact"; import { useLastValidValue } from "@/hooks/useLastValidValue"; @@ -628,7 +629,10 @@ function App() { }; }, []); - const openHermesWebUI = useOpenHermesWebUI(); + const [launchDashboardOpen, setLaunchDashboardOpen] = useState(false); + const openHermesWebUI = useOpenHermesWebUI(() => + setLaunchDashboardOpen(true), + ); const handleOpenWebsite = async (url: string) => { try { @@ -1611,6 +1615,28 @@ function App() { onCancel={() => setConfirmAction(null)} /> + { + setLaunchDashboardOpen(false); + void (async () => { + try { + await hermesApi.launchDashboard(); + toast.success(t("hermes.webui.launching")); + } catch (error) { + toast.error(t("hermes.webui.launchFailed"), { + description: extractErrorMessage(error) || undefined, + }); + } + })(); + }} + onCancel={() => setLaunchDashboardOpen(false)} + /> + diff --git a/src/hooks/useHermes.ts b/src/hooks/useHermes.ts index 1b1c9174..246aba7c 100644 --- a/src/hooks/useHermes.ts +++ b/src/hooks/useHermes.ts @@ -155,10 +155,11 @@ export function useToggleHermesMemoryEnabled() { /** * 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. + * system browser, and surfaces a localized toast on failure. When + * `onOffline` is provided, it replaces the default offline toast — + * callers can use this to open a launch-dashboard confirm dialog instead. */ -export function useOpenHermesWebUI() { +export function useOpenHermesWebUI(onOffline?: () => void) { const { t } = useTranslation(); return useCallback( async (path?: string) => { @@ -167,7 +168,11 @@ export function useOpenHermesWebUI() { } catch (error) { const detail = extractErrorMessage(error); if (detail === HERMES_WEB_OFFLINE_ERROR) { - toast.error(t("hermes.webui.offline")); + if (onOffline) { + onOffline(); + } else { + toast.error(t("hermes.webui.offline")); + } } else { toast.error(t("hermes.webui.openFailed"), { description: detail || undefined, @@ -175,6 +180,6 @@ export function useOpenHermesWebUI() { } } }, - [t], + [t, onOffline], ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 614b01f9..959aa7ba 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1677,9 +1677,14 @@ }, "webui": { "open": "Open Hermes Web UI", - "offline": "Hermes Web UI is not running. Start it with `hermes web` first.", + "offline": "Hermes Web UI is not running. Start it with `hermes dashboard` first.", "openFailed": "Failed to open Hermes Web UI", - "fixInWebUI": "Fix in Hermes Web UI" + "fixInWebUI": "Fix in Hermes Web UI", + "launchConfirmTitle": "Hermes Dashboard is not running", + "launchConfirmMessage": "Open a terminal and start it now with `hermes dashboard`?\n\nThe browser will open automatically once startup completes.\n\nIf the terminal reports that `hermes` cannot be found or the web extras are missing, run first:\npip install hermes-agent[web]", + "launchConfirmAction": "Open terminal & launch", + "launching": "Started `hermes dashboard` in a terminal.", + "launchFailed": "Failed to open terminal" }, "health": { "title": "Hermes config warnings detected", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index dda71b04..5e4f2702 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1677,9 +1677,14 @@ }, "webui": { "open": "Hermes Web UI を開く", - "offline": "Hermes Web UI が起動していません。まず `hermes web` を実行してください。", + "offline": "Hermes Web UI が起動していません。まず `hermes dashboard` を実行してください。", "openFailed": "Hermes Web UI を開けませんでした", - "fixInWebUI": "Hermes Web UI で修正" + "fixInWebUI": "Hermes Web UI で修正", + "launchConfirmTitle": "Hermes Dashboard が起動していません", + "launchConfirmMessage": "ターミナルを開いて `hermes dashboard` を実行しますか?\n\n起動完了後、ブラウザが自動的に開きます。\n\nターミナルに `hermes` が見つからない、または web 依存が不足するというエラーが表示される場合は、まず次を実行してください:\npip install hermes-agent[web]", + "launchConfirmAction": "ターミナルを開いて起動", + "launching": "ターミナルで hermes dashboard を起動しました。", + "launchFailed": "ターミナルを開けませんでした" }, "health": { "title": "Hermes 設定の警告を検出", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f4170930..b14308b9 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1677,9 +1677,14 @@ }, "webui": { "open": "打开 Hermes Web UI", - "offline": "Hermes Web UI 未启动,请先运行 `hermes web` 启动服务。", + "offline": "Hermes Web UI 未启动,请先运行 `hermes dashboard` 启动服务。", "openFailed": "打开 Hermes Web UI 失败", - "fixInWebUI": "在 Hermes Web UI 修复" + "fixInWebUI": "在 Hermes Web UI 修复", + "launchConfirmTitle": "Hermes Dashboard 未启动", + "launchConfirmMessage": "是否打开终端并运行 `hermes dashboard` 启动服务?\n\n启动完成后会自动打开浏览器。\n\n若终端提示找不到 hermes 或缺少 web 依赖,请先运行:\npip install hermes-agent[web]", + "launchConfirmAction": "打开终端并启动", + "launching": "已在终端启动 hermes dashboard", + "launchFailed": "打开终端失败" }, "health": { "title": "检测到 Hermes 配置警告", diff --git a/src/lib/api/hermes.ts b/src/lib/api/hermes.ts index 3fba5c95..e8dea5d5 100644 --- a/src/lib/api/hermes.ts +++ b/src/lib/api/hermes.ts @@ -33,6 +33,11 @@ export const hermesApi = { await invoke("open_hermes_web_ui", { path: path ?? null }); }, + /** Open the preferred terminal and run `hermes dashboard` (non-blocking). */ + async launchDashboard(): Promise { + await invoke("launch_hermes_dashboard"); + }, + /** * Read one of Hermes' memory blobs (`MEMORY.md` or `USER.md`). Returns an * empty string when the file hasn't been created yet.