mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-06 22:01:44 +08:00
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:
@@ -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}"))?
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
src/App.tsx
28
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)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 設定の警告を検出",
|
||||
|
||||
@@ -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 配置警告",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user