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.
This commit is contained in:
Jason
2026-04-21 21:11:15 +08:00
parent 38709c94db
commit 8e9452b7ec
9 changed files with 259 additions and 12 deletions

View File

@@ -134,3 +134,16 @@ pub async fn open_hermes_web_ui(app: AppHandle, path: Option<String>) -> Result<
.open_url(&target, None::<String>)
.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}"))?
}

View File

@@ -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]

View File

@@ -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,

View File

@@ -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)}
/>
<ConfirmDialog
isOpen={launchDashboardOpen}
title={t("hermes.webui.launchConfirmTitle")}
message={t("hermes.webui.launchConfirmMessage")}
confirmText={t("hermes.webui.launchConfirmAction")}
variant="info"
onConfirm={() => {
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)}
/>
<DeepLinkImportDialog />
<FirstRunNoticeDialog />
</div>

View File

@@ -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],
);
}

View File

@@ -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",

View File

@@ -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 設定の警告を検出",

View File

@@ -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 配置警告",

View File

@@ -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<void> {
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.