diff --git a/scripts/dev-api.js b/scripts/dev-api.js index d2b352e..636232a 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2939,6 +2939,24 @@ const handlers = { const current = getLocalOpenclawVersion() const latest = await getLatestVersionFor(source) const recommended = recommendedVersionFor(source) + + // CLI 路径解析(Web 模式下用 which/where) + let cli_path = null + let cli_source = null + try { + const { execSync } = require('child_process') + const cmd = process.platform === 'win32' ? 'where openclaw' : 'which openclaw' + const out = execSync(cmd, { timeout: 3000 }).toString().trim() + cli_path = out.split('\n')[0]?.trim() || null + if (cli_path) { + const lower = cli_path.replace(/\\/g, '/').toLowerCase() + if (lower.includes('/programs/openclaw/') || lower.includes('/openclaw-bin/') || lower.includes('/opt/openclaw/')) cli_source = 'standalone' + else if (lower.includes('openclaw-zh') || lower.includes('@qingchencloud')) cli_source = 'npm-zh' + else if (lower.includes('/npm/') || lower.includes('/node_modules/')) cli_source = 'npm-official' + else cli_source = 'unknown' + } + } catch {} + return { current, latest, @@ -2948,7 +2966,10 @@ const handlers = { is_recommended: !!current && !!recommended && versionsMatch(current, recommended), ahead_of_recommended: !!current && !!recommended && recommendedIsNewer(current, recommended), panel_version: PANEL_VERSION, - source + source, + cli_path, + cli_source, + all_installations: null } }, diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index bf16d69..6c03a92 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -1365,6 +1365,16 @@ pub async fn get_version_info() -> Result { (Some(c), Some(r)) => recommended_is_newer(c, r), _ => false, }; + + // 解析当前实际使用的 CLI 路径 + let cli_path = crate::utils::resolve_openclaw_cli_path(); + let cli_source = cli_path + .as_ref() + .map(|p| crate::utils::classify_cli_source(p)); + + // 扫描所有可检测到的 OpenClaw 安装 + let all_installations = scan_all_installations(&cli_path); + Ok(VersionInfo { current, latest, @@ -1375,9 +1385,148 @@ pub async fn get_version_info() -> Result { ahead_of_recommended, panel_version: panel_version().to_string(), source, + cli_path, + cli_source, + all_installations: Some(all_installations), }) } +/// 扫描系统中所有可检测到的 OpenClaw 安装 +fn scan_all_installations( + active_path: &Option, +) -> Vec { + use crate::models::types::OpenClawInstallation; + let mut results: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + let mut try_add = |path: std::path::PathBuf| { + if !path.exists() { + return; + } + let canonical = path + .canonicalize() + .unwrap_or_else(|_| path.clone()) + .to_string_lossy() + .to_string(); + if seen.contains(&canonical) { + return; + } + seen.insert(canonical.clone()); + let path_str = path.to_string_lossy().to_string(); + let source = crate::utils::classify_cli_source(&path_str); + let version = read_version_from_installation(&path); + let is_active = active_path + .as_ref() + .map(|a| { + let a_canon = std::path::Path::new(a) + .canonicalize() + .unwrap_or_else(|_| std::path::PathBuf::from(a)) + .to_string_lossy() + .to_string(); + a_canon == canonical + }) + .unwrap_or(false); + results.push(OpenClawInstallation { + path: path_str, + source, + version, + active: is_active, + }); + }; + + // standalone 安装目录 + for sa_dir in all_standalone_dirs() { + #[cfg(target_os = "windows")] + try_add(sa_dir.join("openclaw.cmd")); + #[cfg(not(target_os = "windows"))] + try_add(sa_dir.join("openclaw")); + } + + // npm 全局目录 + #[cfg(target_os = "windows")] + { + if let Ok(appdata) = std::env::var("APPDATA") { + try_add( + std::path::PathBuf::from(&appdata) + .join("npm") + .join("openclaw.cmd"), + ); + } + } + + // PATH 中找到的所有 openclaw + let enhanced = super::enhanced_path(); + #[cfg(target_os = "windows")] + let sep = ';'; + #[cfg(not(target_os = "windows"))] + let sep = ':'; + for dir in enhanced.split(sep) { + let dir = dir.trim(); + if dir.is_empty() { + continue; + } + let base = std::path::Path::new(dir); + #[cfg(target_os = "windows")] + { + try_add(base.join("openclaw.cmd")); + } + #[cfg(not(target_os = "windows"))] + { + try_add(base.join("openclaw")); + } + } + + results +} + +/// 从安装路径附近读取版本信息 +fn read_version_from_installation(cli_path: &std::path::Path) -> Option { + // 尝试从同目录的 VERSION 文件读取 + if let Some(dir) = cli_path.parent() { + let version_file = dir.join("VERSION"); + if let Ok(content) = std::fs::read_to_string(&version_file) { + for line in content.lines() { + if let Some(ver) = line.strip_prefix("openclaw_version=") { + let ver = ver.trim(); + if !ver.is_empty() { + return Some(ver.to_string()); + } + } + } + } + // 尝试从 package.json 读取 + for pkg_name in &["@qingchencloud/openclaw-zh", "openclaw"] { + let pkg_json = dir.join("node_modules").join(pkg_name).join("package.json"); + if let Ok(content) = std::fs::read_to_string(&pkg_json) { + if let Some(ver) = serde_json::from_str::(&content) + .ok() + .and_then(|v| v.get("version")?.as_str().map(String::from)) + { + return Some(ver); + } + } + } + // npm shim 情况:向上查找 node_modules + if let Some(parent) = dir.parent() { + for pkg_name in &["@qingchencloud/openclaw-zh", "openclaw"] { + let pkg_json = parent + .join("node_modules") + .join(pkg_name) + .join("package.json"); + if let Ok(content) = std::fs::read_to_string(&pkg_json) { + if let Some(ver) = serde_json::from_str::(&content) + .ok() + .and_then(|v| v.get("version")?.as_str().map(String::from)) + { + return Some(ver); + } + } + } + } + } + None +} + /// 获取 OpenClaw 运行时状态摘要(openclaw status --json) /// 包含 runtimeVersion、会话列表(含 token 用量、fastMode 等标签) #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1b6793a..ef7ceff 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -85,7 +85,7 @@ fn panel_config_path() -> PathBuf { default_openclaw_dir().join("clawpanel.json") } -fn read_panel_config_value() -> Option { +pub fn read_panel_config_value() -> Option { std::fs::read_to_string(panel_config_path()) .ok() .and_then(|content| serde_json::from_str(&content).ok()) diff --git a/src-tauri/src/models/types.rs b/src-tauri/src/models/types.rs index 91915c1..c866f6b 100644 --- a/src-tauri/src/models/types.rs +++ b/src-tauri/src/models/types.rs @@ -21,4 +21,18 @@ pub struct VersionInfo { pub ahead_of_recommended: bool, pub panel_version: String, pub source: String, + /// 当前实际使用的 CLI 完整路径 + pub cli_path: Option, + /// CLI 安装来源标签: standalone / npm-zh / npm-official / unknown + pub cli_source: Option, + /// 所有检测到的 OpenClaw 安装(路径 + 来源 + 版本) + pub all_installations: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct OpenClawInstallation { + pub path: String, + pub source: String, + pub version: Option, + pub active: bool, } diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index aba48cb..1cdf82e 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -1,11 +1,30 @@ #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; +/// 读取 clawpanel.json 中用户绑定的 CLI 路径 +fn bound_cli_path() -> Option { + let config = crate::commands::read_panel_config_value()?; + let raw = config.get("openclawCliPath")?.as_str()?; + if raw.is_empty() { + return None; + } + let p = std::path::PathBuf::from(raw); + if p.exists() { + Some(p) + } else { + None + } +} + /// Windows: 在 PATH 中查找 openclaw.cmd 的完整路径 /// 避免通过 `cmd /c openclaw` 调用时 npm .cmd shim 中的引号导致 /// "\"node\"" is not recognized 错误 #[cfg(target_os = "windows")] fn find_openclaw_cmd() -> Option { + // 优先使用用户绑定的路径 + if let Some(bound) = bound_cli_path() { + return Some(bound); + } let path = crate::commands::enhanced_path(); for dir in path.split(';') { let candidate = std::path::Path::new(dir).join("openclaw.cmd"); @@ -16,6 +35,62 @@ fn find_openclaw_cmd() -> Option { None } +/// 解析当前实际使用的 openclaw CLI 完整路径(跨平台) +pub fn resolve_openclaw_cli_path() -> Option { + // 优先使用用户绑定的路径 + if let Some(bound) = bound_cli_path() { + return Some(bound.to_string_lossy().to_string()); + } + #[cfg(target_os = "windows")] + { + let path = crate::commands::enhanced_path(); + for dir in path.split(';') { + let candidate = std::path::Path::new(dir).join("openclaw.cmd"); + if candidate.exists() { + return Some(candidate.to_string_lossy().to_string()); + } + } + None + } + #[cfg(not(target_os = "windows"))] + { + let path = crate::commands::enhanced_path(); + let sep = ':'; + for dir in path.split(sep) { + let candidate = std::path::Path::new(dir).join("openclaw"); + if candidate.exists() { + return Some(candidate.to_string_lossy().to_string()); + } + } + None + } +} + +/// 根据 CLI 路径判断安装来源 +pub fn classify_cli_source(cli_path: &str) -> String { + let lower = cli_path.replace('\\', "/").to_lowercase(); + // standalone 安装 + if lower.contains("/programs/openclaw/") + || lower.contains("/openclaw-bin/") + || lower.contains("/opt/openclaw/") + { + return "standalone".into(); + } + // npm 汉化版 + if lower.contains("openclaw-zh") || lower.contains("@qingchencloud") { + return "npm-zh".into(); + } + // npm 全局(大概率官方版) + if lower.contains("/npm/") || lower.contains("/node_modules/") { + return "npm-official".into(); + } + // Homebrew + if lower.contains("/homebrew/") || lower.contains("/usr/local/bin") { + return "npm-global".into(); + } + "unknown".into() +} + /// 跨平台获取 openclaw 命令的方法(同步版本) #[allow(dead_code)] pub fn openclaw_command() -> std::process::Command { @@ -42,7 +117,10 @@ pub fn openclaw_command() -> std::process::Command { } #[cfg(not(target_os = "windows"))] { - let mut cmd = std::process::Command::new("openclaw"); + let bin = bound_cli_path() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "openclaw".into()); + let mut cmd = std::process::Command::new(bin); cmd.env("PATH", crate::commands::enhanced_path()); crate::commands::apply_proxy_env(&mut cmd); cmd @@ -74,7 +152,10 @@ pub fn openclaw_command_async() -> tokio::process::Command { } #[cfg(not(target_os = "windows"))] { - let mut cmd = tokio::process::Command::new("openclaw"); + let bin = bound_cli_path() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "openclaw".into()); + let mut cmd = tokio::process::Command::new(bin); cmd.env("PATH", crate::commands::enhanced_path()); crate::commands::apply_proxy_env_tokio(&mut cmd); cmd diff --git a/src/components/sidebar.js b/src/components/sidebar.js index 00046ab..4548dcf 100644 --- a/src/components/sidebar.js +++ b/src/components/sidebar.js @@ -7,70 +7,71 @@ import { isOpenclawReady, getActiveInstance, switchInstance, onInstanceChange } import { api } from '../lib/tauri-api.js' import { toast } from './toast.js' import { version as APP_VERSION } from '../../package.json' +import { t } from '../lib/i18n.js' -const NAV_ITEMS_FULL = [ +function NAV_ITEMS_FULL() { return [ { - section: '概览', + section: t('sidebar.sectionMonitor'), items: [ - { route: '/dashboard', label: '仪表盘', icon: 'dashboard' }, - { route: '/assistant', label: '晴辰助手', icon: 'assistant' }, - { route: '/chat', label: '实时聊天', icon: 'chat' }, - { route: '/services', label: '服务管理', icon: 'services' }, - { route: '/logs', label: '日志查看', icon: 'logs' }, + { route: '/dashboard', label: t('sidebar.dashboard'), icon: 'dashboard' }, + { route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' }, + { route: '/chat', label: t('sidebar.chat'), icon: 'chat' }, + { route: '/services', label: t('sidebar.services'), icon: 'services' }, + { route: '/logs', label: t('sidebar.logs'), icon: 'logs' }, ] }, { - section: '配置', + section: t('sidebar.sectionConfig'), items: [ - { route: '/models', label: '模型配置', icon: 'models' }, - { route: '/agents', label: 'Agent 管理', icon: 'agents' }, - { route: '/gateway', label: 'Gateway', icon: 'gateway' }, - { route: '/channels', label: '消息渠道', icon: 'channels' }, - { route: '/communication', label: '通信与自动化', icon: 'settings' }, - { route: '/security', label: '安全设置', icon: 'security' }, + { route: '/models', label: t('sidebar.models'), icon: 'models' }, + { route: '/agents', label: t('sidebar.agents'), icon: 'agents' }, + { route: '/gateway', label: t('sidebar.gateway'), icon: 'gateway' }, + { route: '/channels', label: t('sidebar.channels'), icon: 'channels' }, + { route: '/communication', label: t('sidebar.communication'), icon: 'settings' }, + { route: '/security', label: t('sidebar.security'), icon: 'security' }, ] }, { - section: '数据', + section: t('sidebar.sectionData'), items: [ - { route: '/memory', label: '记忆文件', icon: 'memory' }, - { route: '/cron', label: '定时任务', icon: 'clock' }, - { route: '/usage', label: '使用情况', icon: 'bar-chart' }, + { route: '/memory', label: t('sidebar.memory'), icon: 'memory' }, + { route: '/cron', label: t('sidebar.cron'), icon: 'clock' }, + { route: '/usage', label: t('sidebar.usage'), icon: 'bar-chart' }, ] }, { - section: '扩展', + section: t('sidebar.sectionExtension'), items: [ - { route: '/skills', label: 'Skills', icon: 'skills' }, + { route: '/skills', label: t('sidebar.skills'), icon: 'skills' }, ] }, { section: '', items: [ - { route: '/settings', label: '面板设置', icon: 'settings' }, - { route: '/chat-debug', label: '系统诊断', icon: 'debug' }, - { route: '/about', label: '关于', icon: 'about' }, + { route: '/settings', label: t('sidebar.settings'), icon: 'settings' }, + { route: '/chat-debug', label: t('sidebar.chatDebug'), icon: 'debug' }, + { route: '/about', label: t('sidebar.about'), icon: 'about' }, ] } -] +] } -const NAV_ITEMS_SETUP = [ +function NAV_ITEMS_SETUP() { return [ { section: '', items: [ - { route: '/setup', label: '初始设置', icon: 'setup' }, - { route: '/assistant', label: '晴辰助手', icon: 'assistant' }, + { route: '/setup', label: t('sidebar.setup'), icon: 'setup' }, + { route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' }, ] }, { section: '', items: [ - { route: '/settings', label: '面板设置', icon: 'settings' }, - { route: '/chat-debug', label: '系统诊断', icon: 'debug' }, - { route: '/about', label: '关于', icon: 'about' }, + { route: '/settings', label: t('sidebar.settings'), icon: 'settings' }, + { route: '/chat-debug', label: t('sidebar.chatDebug'), icon: 'debug' }, + { route: '/about', label: t('sidebar.about'), icon: 'about' }, ] } -] +] } const ICONS = { setup: '', @@ -152,7 +153,7 @@ export function renderSidebar(el) {