diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 6532b45..b4693ce 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2354,10 +2354,10 @@ function matchesCurrentGatewayOwnerSignature(owner) { if (!owner || owner.startedBy !== 'clawpanel') return false const current = currentGatewayOwnerSignature() if (Number(owner.port || 0) !== current.port) return false - if (!current.cliPath) return false - const ownerCliPath = canonicalCliPath(owner.cliPath) - if (!ownerCliPath || ownerCliPath !== current.cliPath) return false if (!owner.openclawDir || path.resolve(owner.openclawDir) !== current.openclawDir) return false + // 仅当双方都有 cliPath 且不同时才视为不匹配;任一侧缺失时放宽为兼容(向后兼容旧记录/未绑定 CLI) + const ownerCliPath = canonicalCliPath(owner.cliPath) + if (ownerCliPath && current.cliPath && ownerCliPath !== current.cliPath) return false return true } @@ -3031,6 +3031,15 @@ const handlers = { return true }, + async claim_gateway() { + const label = 'ai.openclaw.gateway' + const status = await getLocalGatewayRuntime(label) + if (!status?.running) throw new Error('Gateway 未运行,无需认领') + writeGatewayOwner(status.pid || null) + serverCacheInvalidate('svc_status') + return true + }, + async stop_service({ label }) { const status = await getLocalGatewayRuntime(label) if (status?.running) { diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index 7bff1cc..a45267b 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -103,10 +103,11 @@ fn matches_current_gateway_owner_signature(owner: &GatewayOwnerRecord) -> bool { return false; } let owner_cli_path = owner.cli_path.as_ref().map(normalize_owned_path); - matches!( - (owner_cli_path.as_deref(), cli_path.as_deref()), - (Some(owner_cli), Some(current_cli)) if owner_cli == current_cli - ) + // 仅当双方都有 cli_path 且不同才视为不匹配;任一侧缺失时放宽为兼容(向后兼容旧记录/未绑定 CLI) + match (owner_cli_path.as_deref(), cli_path.as_deref()) { + (Some(a), Some(b)) => a == b, + _ => true, + } } fn gateway_owner_pid_needs_refresh(owner: &GatewayOwnerRecord, pid: Option) -> bool { @@ -1689,3 +1690,14 @@ pub async fn restart_service(label: String) -> Result<(), String> { guardian_resume("manual restart"); result } + +/// 认领外部 Gateway:将 gateway-owner.json 强制覆写为当前面板实例签名 +#[tauri::command] +pub async fn claim_gateway() -> Result<(), String> { + let (running, pid) = current_gateway_runtime("ai.openclaw.gateway").await; + if !running { + return Err("Gateway 未运行,无需认领".into()); + } + write_gateway_owner(pid)?; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c4b5d0..afdef55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -123,6 +123,7 @@ pub fn run() { service::start_service, service::stop_service, service::restart_service, + service::claim_gateway, service::guardian_status, // 日志 logs::read_log_tail, diff --git a/src/lib/app-state.js b/src/lib/app-state.js index 929e5f1..1782ad6 100644 --- a/src/lib/app-state.js +++ b/src/lib/app-state.js @@ -12,6 +12,7 @@ const isTauri = !!window.__TAURI_INTERNALS__ let _openclawReady = false let _gatewayRunning = false +let _gatewayForeign = false let _platform = '' // 'macos' | 'win32' | ... let _deployMode = 'local' // 'local' | 'docker' let _inDocker = false @@ -54,11 +55,16 @@ export function onGuardianGiveUp(fn) { return () => { _guardianListeners = _guardianListeners.filter(cb => cb !== fn) } } -/** Gateway 是否正在运行 */ +/** Gateway 是否正在运行(仅 owned) */ export function isGatewayRunning() { return _gatewayRunning } +/** Gateway 是否在运行但属于外部实例 */ +export function isGatewayForeign() { + return _gatewayForeign +} + /** 获取后端平台 ('macos' | 'win32') */ export function getPlatform() { return _platform @@ -130,7 +136,8 @@ export async function detectOpenclawStatus() { // 顺便检测 Gateway 运行状态 if (services.status === 'fulfilled' && services.value?.length > 0) { const gw = services.value.find?.(s => s.label === 'ai.openclaw.gateway') || services.value[0] - _setGatewayRunning(gw?.running === true && gw?.owned_by_current_instance !== false) + const foreign = gw?.running === true && gw?.owned_by_current_instance === false + _setGatewayRunning(gw?.running === true && !foreign, foreign) } } catch { _openclawReady = false @@ -139,22 +146,24 @@ export async function detectOpenclawStatus() { return _openclawReady } -function _setGatewayRunning(val) { +function _setGatewayRunning(val, foreign = false) { const wasRunning = _gatewayRunning - const changed = wasRunning !== val + const wasForeign = _gatewayForeign + const changed = wasRunning !== val || wasForeign !== foreign _gatewayRunning = val + _gatewayForeign = foreign if (changed) { if (val) { // 仅记录恢复运行时间,避免短暂存活就把重启计数清零 _gatewayRunningSince = Date.now() - } else if (wasRunning && !_userStopped && !_isUpgrading && _openclawReady) { + } else if (wasRunning && !_userStopped && !_isUpgrading && _openclawReady && !foreign) { _gatewayRunningSince = 0 // Gateway 意外停止,尝试自动重启 _tryAutoRestart() } else if (!val) { _gatewayRunningSince = 0 } - _gwListeners.forEach(fn => { try { fn(val) } catch {} }) + _gwListeners.forEach(fn => { try { fn(val, foreign) } catch {} }) } } @@ -216,7 +225,7 @@ export async function refreshGatewayStatus() { if (nowRunning) { _gwStopCount = 0 if (!_gatewayRunning) { - _setGatewayRunning(true) + _setGatewayRunning(true, false) } else if (shouldResetAutoRestartCount({ autoRestartCount: _autoRestartCount, runningSince: _gatewayRunningSince, @@ -231,7 +240,7 @@ export async function refreshGatewayStatus() { _gwStopCount++ } if (foreignRunning || _gwStopCount >= 2 || !_gatewayRunning) { - _setGatewayRunning(false) + _setGatewayRunning(false, foreignRunning) } } } diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 13e5f36..59fb36b 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -188,6 +188,7 @@ export const api = { startService: (label) => { invalidate('get_services_status'); return invoke('start_service', { label }) }, stopService: (label) => { invalidate('get_services_status'); return invoke('stop_service', { label }) }, restartService: (label) => { invalidate('get_services_status'); return invoke('restart_service', { label }) }, + claimGateway: () => { invalidate('get_services_status'); return invoke('claim_gateway') }, guardianStatus: () => invoke('guardian_status'), // 配置(读缓存,写清缓存) diff --git a/src/locales/modules/common.js b/src/locales/modules/common.js index c7944b9..265aece 100644 --- a/src/locales/modules/common.js +++ b/src/locales/modules/common.js @@ -68,4 +68,5 @@ export default { upgradeCompleted: _('升级完成', 'Upgrade completed', '升級完成', 'アップグレード完了', '업그레이드 완료', 'Nâng cấp hoàn tất'), upgradeFailed: _('升级失败', 'Upgrade failed', '升級失敗', 'アップグレード失敗', '업그레이드 실패', 'Nâng cấp thất bại'), unknownCommand: _('未知命令', 'Unknown command', '未知命令', '不明なコマンド', '알 수 없는 명령', 'Lệnh không xác định'), + processing: _('处理中...', 'Processing...', '處理中...', '処理中...', '처리 중...', 'Đang xử lý...', 'Procesando...', 'Processando...', 'Обработка...', 'Traitement...', 'Verarbeitung...'), } diff --git a/src/locales/modules/dashboard.js b/src/locales/modules/dashboard.js index 5dfb05f..439d893 100644 --- a/src/locales/modules/dashboard.js +++ b/src/locales/modules/dashboard.js @@ -107,4 +107,6 @@ export default { fixDoneRestartFail: _('✅ 修复完成,但 Gateway 启动失败,请手动检查', '✅ Fix completed, but Gateway failed to start. Please check manually', '✅ 修復完成,但 Gateway 啟動失敗,請手動檢查', '✅ 修復完了、しかし Gateway の起動に失敗。手動で確認してください', '✅ 수정 완료, 그러나 Gateway 시작 실패. 수동으로 확인하세요'), fixFailed: _('❌ 修复失败:', '❌ Fix failed:', '❌ 修復失敗:', '❌ 修復失敗:', '❌ 수정 실패:'), startSent: _('已发送启动命令', 'Start command sent', '已發送啟動指令', '起動コマンド送信済み', '시작 명령 전송됨'), + foreignGatewayBanner: _('检测到外部 Gateway 正在运行,当前面板无法管理', 'An external Gateway is running, not managed by this panel', '偵測到外部 Gateway 正在執行,目前面板無法管理', '外部 Gateway が実行中です。このパネルでは管理できません', '외부 Gateway가 실행 중이며, 이 패널에서 관리할 수 없습니다', 'Đã phát hiện Gateway bên ngoài đang chạy, bảng điều khiển này không thể quản lý', 'Se detectó un Gateway externo en ejecución, no gestionado por este panel', 'Um Gateway externo está em execução, não gerenciado por este painel', 'Обнаружен внешний Gateway, не управляемый этой панелью', 'Un Gateway externe est en cours d\'exécution, non géré par ce panneau', 'Ein externer Gateway läuft, der nicht von diesem Panel verwaltet wird'), + claimGateway: _('认领 Gateway', 'Claim Gateway', '認領 Gateway', 'Gateway を引き取る', 'Gateway 인수', 'Nhận Gateway', 'Reclamar Gateway', 'Reivindicar Gateway', 'Принять Gateway', 'Revendiquer Gateway', 'Gateway übernehmen'), } diff --git a/src/locales/modules/services.js b/src/locales/modules/services.js index d5f0261..dbbca65 100644 --- a/src/locales/modules/services.js +++ b/src/locales/modules/services.js @@ -162,4 +162,7 @@ export default { taskDone: _('操作完成', 'Operation complete'), upgradeDone: _('升级完成', 'Upgrade complete', '升級完成'), upgradeScene: _('升级 OpenClaw', 'Upgrade OpenClaw', '升級 OpenClaw'), + claimGateway: _('认领 Gateway', 'Claim Gateway', '認領 Gateway', 'Gateway を引き取る', 'Gateway 인수', 'Nhận Gateway', 'Reclamar Gateway', 'Reivindicar Gateway', 'Принять Gateway', 'Revendiquer Gateway', 'Gateway übernehmen'), + claimSuccess: _('Gateway 已认领,当前面板已接管管理权', 'Gateway claimed, this panel now manages it', 'Gateway 已認領,目前面板已接管管理權', 'Gateway を引き取りました。このパネルが管理します', 'Gateway를 인수했습니다. 이 패널이 관리합니다'), + claimFailed: _('认领失败', 'Claim failed', '認領失敗', '引き取り失敗', '인수 실패'), } diff --git a/src/main.js b/src/main.js index 9ce9f1a..6e2a05e 100644 --- a/src/main.js +++ b/src/main.js @@ -8,7 +8,7 @@ if (window._splashTimer) { clearTimeout(window._splashTimer); window._splashTime import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js' import { renderSidebar, openMobileSidebar } from './components/sidebar.js' import { initTheme } from './lib/theme.js' -import { detectOpenclawStatus, isOpenclawReady, isUpgrading, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js' +import { detectOpenclawStatus, isOpenclawReady, isUpgrading, isGatewayRunning, isGatewayForeign, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js' import { wsClient } from './lib/ws-client.js' import { api, checkBackendHealth, isBackendOnline, isTauriRuntime, onBackendStatusChange } from './lib/tauri-api.js' import { version as APP_VERSION } from '../package.json' @@ -525,17 +525,20 @@ function setupGatewayBanner() { const banner = document.getElementById('gw-banner') if (!banner) return - function update(running) { + function update(running, foreign) { if (running || sessionStorage.getItem('gw-banner-dismissed')) { banner.classList.add('gw-banner-hidden') return - } else { - banner.classList.remove('gw-banner-hidden') + } + banner.classList.remove('gw-banner-hidden') + + if (foreign) { + // Gateway 在运行但属于外部实例 —— 显示认领按钮 banner.innerHTML = `
- ${statusIcon('info', 16)} - ${t('dashboard.controlUINotRunning')} - + ${statusIcon('warning', 16)} + ${t('dashboard.foreignGatewayBanner')} + ${t('sidebar.services')}
@@ -544,7 +547,39 @@ function setupGatewayBanner() { banner.classList.add('gw-banner-hidden') sessionStorage.setItem('gw-banner-dismissed', '1') }) - banner.querySelector('#btn-gw-start')?.addEventListener('click', async (e) => { + banner.querySelector('#btn-gw-claim')?.addEventListener('click', async (e) => { + const btn = e.target + btn.disabled = true + btn.textContent = t('common.processing') + try { + await api.claimGateway() + // 认领后立刻刷新全局状态 + const { refreshGatewayStatus } = await import('./lib/app-state.js') + await refreshGatewayStatus() + } catch (err) { + btn.disabled = false + btn.textContent = t('dashboard.claimGateway') + console.error('[banner] claim failed:', err) + } + }) + return + } + + // Gateway 未运行 —— 显示启动按钮 + banner.innerHTML = ` +
+ ${statusIcon('info', 16)} + ${t('dashboard.controlUINotRunning')} + + ${t('sidebar.services')} + +
+ ` + banner.querySelector('#btn-gw-dismiss')?.addEventListener('click', () => { + banner.classList.add('gw-banner-hidden') + sessionStorage.setItem('gw-banner-dismissed', '1') + }) + banner.querySelector('#btn-gw-start')?.addEventListener('click', async (e) => { const btn = e.target btn.disabled = true btn.classList.add('btn-loading') @@ -600,10 +635,9 @@ function setupGatewayBanner() { ` update(false) }) - } } - update(isGatewayRunning()) + update(isGatewayRunning(), isGatewayForeign()) onGatewayChange(update) } diff --git a/src/pages/services.js b/src/pages/services.js index 6b8a025..6c819e9 100644 --- a/src/pages/services.js +++ b/src/pages/services.js @@ -459,7 +459,7 @@ function renderServices(container, services) { html += `
- +
${gw.label}
${cliMissing @@ -481,6 +481,7 @@ function renderServices(container, services) { ? `
${t('services.foreignGatewayHint')}
+
@@ -604,6 +605,9 @@ function bindEvents(page) { case 'refresh-services': await loadServices(page) break + case 'claim-gateway': + await handleClaimGateway(btn, page) + break case 'resolve-foreign-gateway': await openGatewayConflict(page) break @@ -721,6 +725,8 @@ async function handleServiceAction(action, label, page) { const svc = services?.find?.(s => s.label === label) || services?.[0] if (svc && svc.running === expectRunning) { toast(t('services.actionDone', { label, action: actionLabel }) + (svc.pid ? ' (PID: ' + svc.pid + ')' : ''), 'success') + // 立刻同步全局 Gateway 状态(顶部 banner + WS 连接) + import('../lib/app-state.js').then(m => m.refreshGatewayStatus()).catch(() => {}) await loadServices(page) return } @@ -971,6 +977,25 @@ async function handleSwitchSource(target, page) { await doUpgradeWithModal(target, page, null) } +// ===== 认领外部 Gateway ===== + +async function handleClaimGateway(btn, page) { + btn.classList.add('btn-loading') + btn.textContent = t('common.processing') + try { + await api.claimGateway() + toast(t('services.claimSuccess'), 'success') + // 立刻刷新全局 Gateway 状态 + const { refreshGatewayStatus } = await import('../lib/app-state.js') + await refreshGatewayStatus() + await loadServices(page) + } catch (e) { + toast(t('services.claimFailed') + ': ' + e, 'error') + btn.classList.remove('btn-loading') + btn.textContent = t('services.claimGateway') + } +} + // ===== Gateway 安装/卸载 ===== async function handleInstallGateway(btn, page) {