diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 636232a..928d47c 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -425,11 +425,30 @@ function buildGitInstallEnv() { function detectInstalledSource() { if (isMac) { + // ARM Homebrew try { const target = fs.readlinkSync('/opt/homebrew/bin/openclaw') if (String(target).includes('openclaw-zh')) return 'chinese' return 'official' } catch {} + // Intel Homebrew + try { + const target = fs.readlinkSync('/usr/local/bin/openclaw') + if (String(target).includes('openclaw-zh')) return 'chinese' + return 'official' + } catch {} + // standalone (~/.openclaw-bin) + const saDir = path.join(homedir(), '.openclaw-bin') + if (fs.existsSync(path.join(saDir, 'openclaw')) || fs.existsSync(path.join(saDir, 'VERSION'))) return 'chinese' + if (fs.existsSync('/opt/openclaw/openclaw')) return 'chinese' + // findOpenclawBin fallback + const bin = findOpenclawBin() + if (bin) { + const lower = bin.replace(/\\/g, '/').toLowerCase() + if (lower.includes('openclaw-zh') || lower.includes('@qingchencloud') || lower.includes('/openclaw-bin/') || lower.includes('/opt/openclaw/')) return 'chinese' + return 'official' + } + return 'official' } if (isWindows) { try { @@ -452,11 +471,34 @@ function detectInstalledSource() { function getLocalOpenclawVersion() { let current = null if (isMac) { + // ARM Homebrew try { const target = fs.readlinkSync('/opt/homebrew/bin/openclaw') const pkgPath = path.resolve('/opt/homebrew/bin', target, '..', 'package.json') current = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version } catch {} + // Intel Homebrew + if (!current) { + try { + const target = fs.readlinkSync('/usr/local/bin/openclaw') + const pkgPath = path.resolve('/usr/local/bin', target, '..', 'package.json') + current = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version + } catch {} + } + // standalone (~/.openclaw-bin) + if (!current) { + try { + const vf = path.join(homedir(), '.openclaw-bin', 'VERSION') + if (fs.existsSync(vf)) { + const lines = fs.readFileSync(vf, 'utf8').split('\n') + for (const l of lines) { if (l.startsWith('openclaw_version=')) { current = l.split('=')[1]?.trim(); break } } + } + if (!current) { + const pkg = path.join(homedir(), '.openclaw-bin', 'node_modules', '@qingchencloud', 'openclaw-zh', 'package.json') + if (fs.existsSync(pkg)) current = JSON.parse(fs.readFileSync(pkg, 'utf8')).version + } + } catch {} + } } if (!current && isWindows) { try { @@ -1530,7 +1572,11 @@ const handlers = { let cliInstalled = false if (isMac) { - cliInstalled = fs.existsSync('/opt/homebrew/bin/openclaw') || fs.existsSync('/usr/local/bin/openclaw') + cliInstalled = fs.existsSync('/opt/homebrew/bin/openclaw') + || fs.existsSync('/usr/local/bin/openclaw') + || fs.existsSync(path.join(homedir(), '.openclaw-bin', 'openclaw')) + || fs.existsSync('/opt/openclaw/openclaw') + || !!findOpenclawBin() } else if (isWindows) { try { const paths = [ diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 5a8a8f1..1070221 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -1158,10 +1158,19 @@ pub fn write_mcp_config(config: Value) -> Result<(), String> { /// macOS: 优先从 npm 包的 package.json 读取(含完整后缀),fallback 到 CLI /// Windows/Linux: 优先读文件系统,fallback 到 CLI async fn get_local_version() -> Option { - // macOS: 通过 symlink 找到包目录,读 package.json 的 version #[cfg(target_os = "macos")] { - // 兼容 ARM (/opt/homebrew) 和 Intel (/usr/local) 两种 Homebrew 安装路径 + if let Some(cli_path) = crate::utils::resolve_openclaw_cli_path() { + let resolved = std::fs::canonicalize(&cli_path) + .ok() + .unwrap_or_else(|| PathBuf::from(&cli_path)); + if let Some(ver) = read_version_from_installation(&resolved) + .or_else(|| read_version_from_installation(std::path::Path::new(&cli_path))) + { + return Some(ver); + } + } + for brew_prefix in &["/opt/homebrew/bin", "/usr/local/bin"] { let openclaw_path = format!("{}/openclaw", brew_prefix); if let Ok(target) = fs::read_link(&openclaw_path) { @@ -1182,10 +1191,9 @@ async fn get_local_version() -> Option { } } } - // Windows: 先查 standalone 安装,再查 npm 全局目录 + #[cfg(target_os = "windows")] { - // 检查所有 standalone 安装目录 for sa_dir in all_standalone_dirs() { let version_file = sa_dir.join("VERSION"); if let Ok(content) = fs::read_to_string(&version_file) { @@ -1212,7 +1220,7 @@ async fn get_local_version() -> Option { } } } - // npm 全局目录 — 根据 CLI 来源决定检查顺序,避免读到非活跃包的版本 + if let Ok(appdata) = std::env::var("APPDATA") { let cli_is_zh = crate::utils::resolve_openclaw_cli_path() .map(|p| crate::utils::classify_cli_source(&p) == "npm-zh") @@ -1239,7 +1247,8 @@ async fn get_local_version() -> Option { } } } - // 所有平台通用 fallback: CLI 输出(异步) + + // 所有平台通用 fallback: CLI 输出 // Windows: 先确认 openclaw 不是第三方程序(如 CherryStudio) #[cfg(target_os = "windows")] { @@ -1259,6 +1268,7 @@ async fn get_local_version() -> Option { } } } + use crate::utils::openclaw_command_async; let output = openclaw_command_async() .arg("--version") @@ -1296,6 +1306,18 @@ fn detect_installed_source() -> String { // macOS: 检查 openclaw bin 的 symlink 指向 #[cfg(target_os = "macos")] { + if let Some(cli_path) = crate::utils::resolve_openclaw_cli_path() { + let resolved = std::fs::canonicalize(&cli_path) + .ok() + .unwrap_or_else(|| PathBuf::from(&cli_path)); + let source = crate::utils::classify_cli_source(&resolved.to_string_lossy()); + if source == "npm-zh" || source == "standalone" { + return "chinese".into(); + } + if source == "npm-official" || source == "npm-global" { + return "official".into(); + } + } // 兼容 ARM (/opt/homebrew) 和 Intel (/usr/local) 两种 Homebrew 路径 for brew_prefix in &["/opt/homebrew/bin/openclaw", "/usr/local/bin/openclaw"] { if let Ok(target) = std::fs::read_link(brew_prefix) { @@ -1305,6 +1327,11 @@ fn detect_installed_source() -> String { return "official".into(); } } + for sa_dir in all_standalone_dirs() { + if sa_dir.join("openclaw").exists() || sa_dir.join("VERSION").exists() { + return "chinese".into(); + } + } "official".into() } // Windows: 优先通过 CLI 路径判断,fallback 到文件系统检测 diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index 11851ee..8dc5fa6 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -322,13 +322,26 @@ pub fn guardian_status() -> Result { #[cfg(target_os = "macos")] mod platform { use std::fs; + use std::path::PathBuf; use std::process::Command; const OPENCLAW_PREFIXES: &[&str] = &["ai.openclaw."]; - /// macOS 上 CLI 是否安装(检查 plist 是否存在即可) + fn common_cli_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Some(home) = dirs::home_dir() { + candidates.push(home.join(".openclaw-bin").join("openclaw")); + } + candidates.push(PathBuf::from("/opt/openclaw/openclaw")); + candidates.push(PathBuf::from("/opt/homebrew/bin/openclaw")); + candidates.push(PathBuf::from("/usr/local/bin/openclaw")); + candidates + } + + /// macOS 上 CLI 是否安装(兼容手动安装 / standalone / Homebrew) pub fn is_cli_installed() -> bool { - true // macOS 通过 plist 扫描,不依赖 CLI 检测 + crate::utils::resolve_openclaw_cli_path().is_some() + || common_cli_candidates().into_iter().any(|p| p.exists()) } pub fn current_uid() -> Result { @@ -361,6 +374,9 @@ mod platform { } } labels.sort(); + if labels.is_empty() { + labels.push("ai.openclaw.gateway".to_string()); + } labels } diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 1cdf82e..da083b3 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -35,6 +35,20 @@ fn find_openclaw_cmd() -> Option { None } +#[cfg(not(target_os = "windows"))] +fn common_non_windows_cli_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Some(home) = dirs::home_dir() { + candidates.push(home.join(".openclaw-bin").join("openclaw")); + candidates.push(home.join(".local").join("bin").join("openclaw")); + } + candidates.push(std::path::PathBuf::from("/opt/openclaw/openclaw")); + candidates.push(std::path::PathBuf::from("/opt/homebrew/bin/openclaw")); + candidates.push(std::path::PathBuf::from("/usr/local/bin/openclaw")); + candidates.push(std::path::PathBuf::from("/usr/bin/openclaw")); + candidates +} + /// 解析当前实际使用的 openclaw CLI 完整路径(跨平台) pub fn resolve_openclaw_cli_path() -> Option { // 优先使用用户绑定的路径 @@ -54,6 +68,11 @@ pub fn resolve_openclaw_cli_path() -> Option { } #[cfg(not(target_os = "windows"))] { + for candidate in common_non_windows_cli_candidates() { + if candidate.exists() { + return Some(candidate.to_string_lossy().to_string()); + } + } let path = crate::commands::enhanced_path(); let sep = ':'; for dir in path.split(sep) { diff --git a/src/main.js b/src/main.js index a00a421..3a70fed 100644 --- a/src/main.js +++ b/src/main.js @@ -639,7 +639,7 @@ async function checkGlobalUpdate() { if (!ver) return // 用户已忽略过该版本,不再打扰 - const dismissed = sessionStorage.getItem('clawpanel_update_dismissed') + const dismissed = localStorage.getItem('clawpanel_update_dismissed') if (dismissed === ver) return const changelog = info.manifest?.changelog || '' @@ -665,7 +665,7 @@ async function checkGlobalUpdate() { // 关闭按钮:记住忽略的版本 banner.querySelector('#btn-update-dismiss')?.addEventListener('click', () => { - sessionStorage.setItem('clawpanel_update_dismissed', ver) + localStorage.setItem('clawpanel_update_dismissed', ver) banner.classList.add('update-banner-hidden') }) diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 263fa54..c5b956a 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -3168,6 +3168,22 @@ function showSettings() { btn.disabled = true btn.textContent = t('assistant.testing') resultEl.innerHTML = '' + t('assistant.testSending') + '' + + // Web 模式下浏览器 fetch 受 CORS 限制,优先走后端代理 + if (!window.__TAURI_INTERNALS__) { + const t0 = Date.now() + try { + const reply = await api.testModel(baseUrl, apiKey, model, selApiType) + const elapsed = Date.now() - t0 + resultEl.innerHTML = buildTestResult({ success: true, elapsed, usedApi: selApiType, reqUrl: baseUrl, reqBody: {}, respStatus: 200, respBody: '', reply: reply || '(ok)' }) + } catch (err) { + const elapsed = Date.now() - t0 + resultEl.innerHTML = buildTestResult({ success: false, elapsed, usedApi: selApiType, reqUrl: baseUrl, reqBody: {}, respStatus: 0, respBody: '', error: err.message || String(err) }) + } + btn.disabled = false; btn.textContent = t('assistant.testBtn') + return + } + const base = cleanBaseUrl(baseUrl, selApiType) const hdrs = authHeaders(selApiType, apiKey) const t0 = Date.now() diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 73d689b..111ab40 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -3,7 +3,7 @@ */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' -import { onGatewayChange } from '../lib/app-state.js' +import { getActiveInstance, onGatewayChange } from '../lib/app-state.js' import { navigate } from '../router.js' import { t } from '../lib/i18n.js' @@ -65,8 +65,22 @@ export function cleanup() { } let _dashboardInitialized = false +let _dashboardVersionCache = null +let _dashboardStatusSummaryCache = null +let _dashboardInstanceId = '' + +function syncDashboardInstanceScope() { + const instanceId = getActiveInstance()?.id || 'local' + if (_dashboardInstanceId && _dashboardInstanceId !== instanceId) { + _dashboardInitialized = false + _dashboardVersionCache = null + _dashboardStatusSummaryCache = null + } + _dashboardInstanceId = instanceId +} async function loadDashboardData(page, fullRefresh = false) { + syncDashboardInstanceScope() // 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待 // 轻量调用(读文件)每次都做;重量调用(spawn CLI/网络请求)只在首次或手动刷新时做 const withTimeout = (promise, ms) => Promise.race([ @@ -77,21 +91,23 @@ async function loadDashboardData(page, fullRefresh = false) { api.getServicesStatus(), api.readOpenclawConfig(), // 版本信息:首次加载或手动刷新时才查询(避免 ARM 设备上频繁查 npm registry) - (!_dashboardInitialized || fullRefresh) ? api.getVersionInfo() : Promise.resolve(null), + (!_dashboardInitialized || fullRefresh || !_dashboardVersionCache) ? api.getVersionInfo() : Promise.resolve(_dashboardVersionCache), ]), 15000) const secondaryP = withTimeout(Promise.allSettled([ api.listAgents(), api.readMcpConfig(), api.listBackups(), // getStatusSummary 是最重的调用(spawn openclaw status --json),只在首次加载时调用 - (!_dashboardInitialized || fullRefresh) ? api.getStatusSummary() : Promise.resolve(null), + (!_dashboardInitialized || fullRefresh || !_dashboardStatusSummaryCache) ? api.getStatusSummary() : Promise.resolve(_dashboardStatusSummaryCache), ]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }]) const logsP = api.readLogTail('gateway', 20).catch(() => '') // 第一波:服务状态 + 配置 + 版本 → 立即渲染统计卡片 const [servicesRes, configRes, versionRes] = await coreP const services = servicesRes.status === 'fulfilled' ? servicesRes.value : [] - const version = (versionRes.status === 'fulfilled' && versionRes.value) ? versionRes.value : {} + const version = (versionRes.status === 'fulfilled' && versionRes.value) + ? (_dashboardVersionCache = versionRes.value) + : (_dashboardVersionCache || {}) const config = configRes.status === 'fulfilled' ? configRes.value : null if (servicesRes.status === 'rejected') toast(t('dashboard.servicesLoadFail'), 'error') if (versionRes.status === 'rejected') toast(t('dashboard.versionLoadFail'), 'error') @@ -128,7 +144,9 @@ async function loadDashboardData(page, fullRefresh = false) { const agents = agentsRes.status === 'fulfilled' ? agentsRes.value : [] const mcpConfig = mcpRes.status === 'fulfilled' ? mcpRes.value : null const backups = backupsRes.status === 'fulfilled' ? backupsRes.value : [] - const statusSummary = statusRes.status === 'fulfilled' ? statusRes.value : null + const statusSummary = (statusRes.status === 'fulfilled' && statusRes.value) + ? (_dashboardStatusSummaryCache = statusRes.value) + : _dashboardStatusSummaryCache renderStatCards(page, services, version, agents, config) renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) @@ -475,6 +493,7 @@ function bindActions(page) { btnUpdate.textContent = t('dashboard.checking') try { const info = await api.getVersionInfo() + _dashboardVersionCache = info if (info.ahead_of_recommended && info.recommended) { toast(t('dashboard.versionAheadWarn', { current: info.current || '', recommended: info.recommended }), 'warning') } else if (info.update_available && info.recommended) {