/** * 服务管理页面 * 服务启停 + 更新检测 + 配置备份管理 */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' import { showConfirm, showModal, showUpgradeModal } from '../components/modal.js' import { isMacPlatform, isInDocker, setUpgrading, setUserStopped, resetAutoRestart } from '../lib/app-state.js' import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGatewayBindingPrompt, showGatewayConflictGuidance } from '../lib/gateway-ownership.js' import { diagnoseInstallError } from '../lib/error-diagnosis.js' import { icon, statusIcon } from '../lib/icons.js' import { t } from '../lib/i18n.js' // HTML 转义,防止 XSS function escapeHtml(str) { if (!str) return '' return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } export async function render() { const page = document.createElement('div') page.className = 'page' page.innerHTML = `
${t('services.dockerManager')}
${t('services.dockerManagerHint')}
${t('services.configCalibration')}
${t('services.configCalibrationHint')}
${t('services.calibrateInheritHint')}
${t('services.calibrateResetHint')}
${t('services.configBackup')}
${t('services.configBackupHint')}
` bindEvents(page) loadAll(page) return page } async function loadAll(page) { const tasks = [loadVersion(page), loadServices(page), loadDockerManager(page), loadBackups(page), loadConfigEditor(page)] await Promise.all(tasks) } // ===== 版本检测 ===== // 后端检测到的当前安装源 let detectedSource = 'chinese' let lastVersionInfo = null async function loadVersion(page) { const bar = page.querySelector('#version-bar') try { const [info, panelConfig] = await Promise.all([ api.getVersionInfo(), api.readPanelConfig().catch(() => ({})), ]) lastVersionInfo = info detectedSource = info.source || 'chinese' const ver = info.current || t('common.unknown') const hasRecommended = !!info.recommended const aheadOfRecommended = !!info.current && hasRecommended && !!info.ahead_of_recommended const driftFromRecommended = !!info.current && hasRecommended && !info.is_recommended && !aheadOfRecommended const isChinese = detectedSource === 'chinese' const sourceTag = isChinese ? t('services.chineseEdition') : t('services.officialEdition') const switchLabel = isChinese ? t('services.switchToOfficial') : t('services.switchToChinese') const switchTarget = isChinese ? 'official' : 'chinese' const dockerImage = (panelConfig?.dockerDefaultImage || '').trim() || 'ghcr.io/qingchencloud/openclaw' const policyNote = aheadOfRecommended ? t('services.policyAhead', { ver, recommended: info.recommended }) : t('services.policyDefault') if (isInDocker()) { bar.innerHTML = `
${t('services.currentVersion')} · ${t('services.dockerDeploy')}
${ver}
${info.latest_update_available ? t('services.latestUpstream', { version: info.latest }) + '(' + t('services.pullNewImage') + ')' : t('services.currentImageVer')}
${info.latest_update_available ? `
${escapeHtml(`docker pull ${dockerImage}:latest`)}
` : ''}
` } else { bar.innerHTML = `
${t('services.currentVersion')} · ${sourceTag}
${ver}
${hasRecommended ? (aheadOfRecommended ? t('services.aheadOfRecommended', { version: info.recommended }) : driftFromRecommended ? t('services.recommendedStable', { version: info.recommended }) : t('services.alignedRecommended', { version: info.recommended })) : t('services.noRecommended')} ${info.latest_update_available && info.latest ? ' · ' + t('services.latestUpstream', { version: info.latest }) : ''}
${aheadOfRecommended ? `` : driftFromRecommended ? `` : ''}
${policyNote}
` } } catch (e) { bar.innerHTML = `
${t('services.versionLoadFailed')}
` } } function configuredDockerImage(panelConfig) { return (panelConfig?.dockerDefaultImage || '').trim() || 'ghcr.io/qingchencloud/openclaw' } function formatDockerBytes(bytes) { const value = Number(bytes || 0) if (!Number.isFinite(value) || value <= 0) return '0 B' if (value >= 1024 * 1024 * 1024) return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB` if (value >= 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB` if (value >= 1024) return `${(value / 1024).toFixed(1)} KB` return `${value} B` } function parseOptionalPort(value) { const raw = String(value || '').trim() if (!raw) return null const num = Number(raw) if (!Number.isInteger(num) || num < 1 || num > 65535) throw new Error(t('services.invalidPort', { value: raw })) return num } async function hasDockerManagerBackend() { try { const resp = await fetch('/__api/health', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }) const ct = (resp.headers.get('content-type') || '').toLowerCase() return resp.ok && !ct.includes('text/html') && !ct.includes('text/plain') } catch { return false } } async function loadDockerManager(page) { const bar = page.querySelector('#docker-manager-bar') if (!bar) return const backendReady = await hasDockerManagerBackend() if (!backendReady) { bar.innerHTML = `
${t('services.dockerManagerUnavailable')}
` return } try { const [overview, panelConfig] = await Promise.all([ api.dockerClusterOverview(), api.readPanelConfig().catch(() => ({})), ]) const totalNodes = overview.length const onlineNodes = overview.filter(node => node.online).length const totalContainers = overview.reduce((sum, node) => sum + (node.containers?.length || 0), 0) const runningContainers = overview.reduce((sum, node) => sum + (node.containers?.filter?.(ct => ct.state === 'running').length || 0), 0) bar.innerHTML = `
${t('services.dockerManager')}
${onlineNodes}/${totalNodes} ${t('services.dockerOnline')} · ${runningContainers}/${totalContainers} ${t('services.dockerContainersLabel')}
${overview.map(node => { const containers = node.containers || [] const nodeMeta = node.online ? `${escapeHtml(node.endpoint || '')} · Docker ${escapeHtml(node.dockerVersion || t('common.unknown'))} · ${formatDockerBytes(node.memory)} · CPU ${node.cpus || 0}` : `${escapeHtml(node.endpoint || '')} · ${escapeHtml(node.error || t('services.dockerOffline'))}` return `
${escapeHtml(node.name)}${node.id === 'local' ? ` ${t('services.dockerLocalNode')}` : ''}
${nodeMeta}
${node.online ? `${t('services.dockerContainersLabel')}: ${node.runningContainers || 0}/${node.totalContainers || containers.length}` : t('services.dockerOffline')}
${node.id !== 'local' ? `` : ''}
${containers.length ? containers.map(ct => `
${escapeHtml(ct.name)}
${escapeHtml(ct.image)} · ${escapeHtml(ct.status || ct.state || t('common.unknown'))}${ct.ports ? ` · ${escapeHtml(ct.ports)}` : ''}
${ct.state === 'running' ? ` ` : ``}
`).join('') : `
${t('services.dockerNoContainers')}
`}
` }).join('')}
${t('services.dockerDefaultImageHint')} ${escapeHtml(configuredDockerImage(panelConfig))}
` } catch (e) { bar.innerHTML = `
${t('services.dockerManagerLoadFailed')}: ${escapeHtml(e?.message || e)}
` } } // ===== 服务列表 ===== async function loadServices(page) { const container = page.querySelector('#services-list') try { const services = await api.getServicesStatus() renderServices(container, services) const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null if (gw) { maybeShowForeignGatewayBindingPrompt({ service: gw, onRefresh: () => loadServices(page), }).catch(() => {}) } } catch (e) { container.innerHTML = `
${t('services.serviceLoadFailed')}: ${escapeHtml(String(e))}
` } } async function openDockerAddNode(page) { showModal({ title: t('services.dockerAddNode'), fields: [ { name: 'name', label: t('services.dockerNodeName'), value: '', placeholder: 'docker-node-1' }, { name: 'endpoint', label: t('services.dockerNodeEndpoint'), value: '', placeholder: 'tcp://192.168.1.20:2375' }, ], onConfirm: async ({ name, endpoint }) => { try { await api.dockerAddNode((name || '').trim(), (endpoint || '').trim()) toast(t('services.dockerNodeAdded'), 'success') await loadDockerManager(page) } catch (e) { toast(e?.message || e, 'error') } }, }) } async function openDockerPullImage(page) { const [nodes, panelConfig] = await Promise.all([ api.dockerListNodes(), api.readPanelConfig().catch(() => ({})), ]) showModal({ title: t('services.dockerPullTitle'), fields: [ { name: 'nodeId', type: 'select', label: t('services.dockerNodeName'), value: nodes[0]?.id || 'local', options: nodes.map(node => ({ value: node.id, label: node.name })) }, { name: 'image', label: t('services.dockerImageLabel'), value: configuredDockerImage(panelConfig), hint: t('services.dockerDefaultImageHint') }, { name: 'tag', label: t('services.dockerTagLabel'), value: 'latest' }, ], onConfirm: async ({ nodeId, image, tag }) => { const requestId = `pull-${Date.now()}` const modal = showUpgradeModal(t('services.dockerPullTitle')) let lastMessage = '' const timer = setInterval(async () => { try { const status = await api.dockerPullStatus(requestId) if (Number.isFinite(status?.percent)) modal.setProgress(status.percent) if (status?.message && status.message !== lastMessage) { lastMessage = status.message modal.appendLog(status.message) } } catch {} }, 800) try { const result = await api.dockerPullImage({ nodeId: nodeId || null, image: (image || '').trim() || configuredDockerImage(panelConfig), tag: (tag || '').trim() || 'latest', requestId, }) clearInterval(timer) modal.setProgress(100) if (result?.message) modal.appendLog(result.message) modal.setDone(t('services.dockerPullDone')) toast(t('services.dockerPullDone'), 'success') await loadDockerManager(page) } catch (e) { clearInterval(timer) modal.appendLog(e?.message || String(e)) modal.setError(e?.message || String(e)) toast(e?.message || e, 'error') } }, }) } async function openDockerCreateContainer(page) { const [nodes, panelConfig] = await Promise.all([ api.dockerListNodes(), api.readPanelConfig().catch(() => ({})), ]) showModal({ title: t('services.dockerCreateTitle'), fields: [ { name: 'nodeId', type: 'select', label: t('services.dockerNodeName'), value: nodes[0]?.id || 'local', options: nodes.map(node => ({ value: node.id, label: node.name })) }, { name: 'name', label: t('services.dockerContainerNameLabel'), value: '', placeholder: 'openclaw-worker-1' }, { name: 'image', label: t('services.dockerImageLabel'), value: configuredDockerImage(panelConfig), hint: t('services.dockerDefaultImageHint') }, { name: 'tag', label: t('services.dockerTagLabel'), value: 'latest' }, { name: 'panelPort', label: t('services.dockerPanelPortLabel'), value: '1420', hint: t('services.dockerPortOptionalHint') }, { name: 'gatewayPort', label: t('services.dockerGatewayPortLabel'), value: '18789', hint: t('services.dockerPortOptionalHint') }, { name: 'volume', type: 'checkbox', label: t('services.dockerUseVolume'), value: true }, ], onConfirm: async ({ nodeId, name, image, tag, panelPort, gatewayPort, volume }) => { try { await api.dockerCreateContainer({ nodeId: nodeId || null, name: (name || '').trim() || undefined, image: (image || '').trim() || configuredDockerImage(panelConfig), tag: (tag || '').trim() || 'latest', panelPort: parseOptionalPort(panelPort), gatewayPort: parseOptionalPort(gatewayPort), volume: !!volume, }) toast(t('services.dockerContainerCreated'), 'success') await loadDockerManager(page) } catch (e) { toast(e?.message || e, 'error') } }, }) } async function handleDockerRemoveNode(btn, page) { const name = btn.dataset.name || btn.dataset.nodeId || '' const yes = await showConfirm(t('services.dockerRemoveNodeConfirm', { name })) if (!yes) return await api.dockerRemoveNode(btn.dataset.nodeId) toast(t('services.dockerNodeRemoved'), 'success') await loadDockerManager(page) } async function handleDockerContainerAction(action, btn, page) { const nodeId = btn.dataset.nodeId || null const containerId = btn.dataset.containerId const name = btn.dataset.name || containerId if (!containerId) throw new Error(t('services.missingContainerId')) if (action === 'docker-remove-container') { const yes = await showConfirm(t('services.dockerRemoveContainerConfirm', { name })) if (!yes) return await api.dockerRemoveContainer(nodeId, containerId, btn.dataset.running === '1') toast(t('services.dockerContainerRemoved'), 'success') await loadDockerManager(page) return } const label = { 'docker-start-container': t('services.start'), 'docker-stop-container': t('services.stop'), 'docker-restart-container': t('services.restart'), }[action] const fn = { 'docker-start-container': api.dockerStartContainer, 'docker-stop-container': api.dockerStopContainer, 'docker-restart-container': api.dockerRestartContainer, }[action] await fn(nodeId, containerId) toast(t('services.actionDone', { label: name, action: label }), 'success') await loadDockerManager(page) } async function openGatewayConflict(page, error = null) { const services = await api.getServicesStatus().catch(() => []) const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null await showGatewayConflictGuidance({ error, service: gw, onRefresh: async () => { await loadVersion(page) await loadServices(page) }, }) } function renderServices(container, services) { const gw = services.find(s => s.label === 'ai.openclaw.gateway') let html = '' if (gw) { // 检测 CLI 是否安装 const cliMissing = gw.cli_installed === false const foreignGateway = !cliMissing && isForeignGatewayService(gw) const foreignPidText = gw.pid ? ` (PID: ${gw.pid})` : '' html += `
${gw.label}
${cliMissing ? t('services.cliNotInstalled') : foreignGateway ? t('services.foreignGatewayDesc', { pid: foreignPidText, settings: t('sidebar.settings') }) : (gw.description || '') + (gw.pid ? ' (PID: ' + gw.pid + ')' : '') }
${cliMissing ? `
${t('services.installCliHint')}
npm install -g @qingchencloud/openclaw-zh
` : foreignGateway ? `
${t('services.foreignGatewayHint')}
` : gw.running ? ` ${isMacPlatform() ? `` : ''}` : ` ${isMacPlatform() ? `` : ''}` }
` } else { html += `
ai.openclaw.gateway
${t('services.gwNotInstalled')}
` } container.innerHTML = html } // ===== 备份管理 ===== async function loadBackups(page) { const list = page.querySelector('#backup-list') try { const backups = await api.listBackups() renderBackups(list, backups) } catch (e) { list.innerHTML = `
${t('services.backupLoadFailed')}: ${e}
` } } function renderBackups(container, backups) { if (!backups || !backups.length) { container.innerHTML = `
${t('services.noBackup')}
` return } container.innerHTML = backups.map(b => { const date = b.created_at ? new Date(b.created_at * 1000).toLocaleString() : t('common.unknown') const size = b.size ? (b.size / 1024).toFixed(1) + ' KB' : '' return `
${b.name}
${date}${size ? ' · ' + size : ''}
` }).join('') } // ===== 事件绑定(事件委托) ===== function bindEvents(page) { page.addEventListener('click', async (e) => { const btn = e.target.closest('[data-action]') if (!btn) return const action = btn.dataset.action btn.disabled = true try { switch (action) { case 'start': case 'stop': case 'restart': await handleServiceAction(action, btn.dataset.label, page) break case 'save-config': await handleSaveConfig(page, true) break case 'save-config-only': await handleSaveConfig(page, false) break case 'reload-config': await loadConfigEditor(page) break case 'calibrate-config-inherit': await handleCalibrateConfig(page, 'inherit') break case 'calibrate-config-reset': await handleCalibrateConfig(page, 'reset') break case 'create-backup': await handleCreateBackup(page) break case 'restore-backup': await handleRestoreBackup(btn.dataset.name, page) break case 'delete-backup': await handleDeleteBackup(btn.dataset.name, page) break case 'upgrade': await handleUpgrade(btn, page) break case 'switch-source': await handleSwitchSource(btn.dataset.source, page) break case 'install-gateway': await handleInstallGateway(btn, page) break case 'uninstall-gateway': await handleUninstallGateway(btn, page) break case 'refresh-services': await loadServices(page) break case 'resolve-foreign-gateway': await openGatewayConflict(page) break case 'docker-refresh': await loadDockerManager(page) break case 'docker-add-node': await openDockerAddNode(page) break case 'docker-pull-image': await openDockerPullImage(page) break case 'docker-create-container': await openDockerCreateContainer(page) break case 'docker-remove-node': await handleDockerRemoveNode(btn, page) break case 'docker-start-container': case 'docker-stop-container': case 'docker-restart-container': case 'docker-remove-container': await handleDockerContainerAction(action, btn, page) break } } catch (e) { toast(e.toString(), 'error') } finally { btn.disabled = false } }) } // ===== 服务操作 ===== const ACTION_LABELS = { start: t('services.start'), stop: t('services.stop'), restart: t('services.restart') } const POLL_INTERVAL = 1500 // 轮询间隔 ms const POLL_TIMEOUT = 30000 // 最长等待 30s async function handleServiceAction(action, label, page) { const fn = { start: api.startService, stop: api.stopService, restart: api.restartService }[action] const actionLabel = ACTION_LABELS[action] const expectRunning = action !== 'stop' // 通知守护模块:用户主动操作 if (action === 'stop') setUserStopped(true) if (action === 'start') resetAutoRestart() // 找到触发按钮所在的 service-card,替换按钮区域为加载状态 const card = page.querySelector(`.service-card[data-label="${label}"]`) const actionsEl = card?.querySelector('.service-actions') const origHtml = actionsEl?.innerHTML || '' let cancelled = false if (actionsEl) { actionsEl.innerHTML = `
${t('services.actionProgress', { action: actionLabel })}
` const cancelBtn = actionsEl.querySelector('.service-cancel-btn') if (cancelBtn) { cancelBtn.addEventListener('click', () => { cancelled = true }) } } // 更新状态点为加载中 const dot = card?.querySelector('.status-dot') if (dot) { dot.className = 'status-dot loading' } try { await fn(label) } catch (e) { if (isForeignGatewayError(e)) { await openGatewayConflict(page, e) } else { toast(t('services.actionCmdFailed', { action: actionLabel, error: e.message || e }), 'error') } if (actionsEl) actionsEl.innerHTML = origHtml if (dot) dot.className = 'status-dot stopped' return } // 轮询等待实际状态变化 const startTime = Date.now() let showedCancel = false const loadingText = actionsEl?.querySelector('.service-loading-text') const cancelBtn = actionsEl?.querySelector('.service-cancel-btn') while (!cancelled) { const elapsed = Date.now() - startTime // 5 秒后显示取消按钮 if (!showedCancel && elapsed > 5000 && cancelBtn) { cancelBtn.style.display = '' showedCancel = true } // 更新等待时间 if (loadingText) { const sec = Math.floor(elapsed / 1000) loadingText.textContent = t('services.actionProgressSec', { action: actionLabel, sec }) } // 超时 if (elapsed > POLL_TIMEOUT) { toast(t('services.actionTimeout', { action: actionLabel }), 'warning') break } // 检查实际状态 try { const services = await api.getServicesStatus() 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') await loadServices(page) return } } catch {} await new Promise(r => setTimeout(r, POLL_INTERVAL)) } if (cancelled) { toast(t('services.cancelled'), 'info') } await loadServices(page) } // ===== 备份操作 ===== async function handleCreateBackup(page) { const result = await api.createBackup() toast(t('services.backupCreated', { name: result.name }), 'success') await loadBackups(page) } async function handleRestoreBackup(name, page) { const yes = await showConfirm(t('services.restoreConfirm', { name })) if (!yes) return await api.restoreBackup(name) toast(t('services.restored'), 'success') await loadBackups(page) } async function handleDeleteBackup(name, page) { const yes = await showConfirm(t('services.deleteConfirm', { name })) if (!yes) return await api.deleteBackup(name) toast(t('services.backupDeleted'), 'success') await loadBackups(page) } function calibrationSourceLabel(source) { if (source === 'backup') return t('services.calibrationSourceBackup') if (source === 'current') return t('services.calibrationSourceCurrent') return t('services.calibrationSourceEmpty') } async function handleCalibrateConfig(page, mode) { const yes = await showConfirm(mode === 'reset' ? t('services.calibrateResetConfirm') : t('services.calibrateInheritConfirm')) if (!yes) return const status = page.querySelector('#config-calibration-status') if (status) status.innerHTML = `${t('services.calibrating')}` const result = await api.calibrateOpenclawConfig(mode) const summary = t('services.calibrationSummary', { mode: mode === 'reset' ? t('services.calibrateReset') : t('services.calibrateInherit'), source: calibrationSourceLabel(result?.source), count: String(result?.inheritedKeys?.length || 0), }) const warnings = Array.isArray(result?.warnings) ? result.warnings.filter(Boolean) : [] if (status) status.innerHTML = `${escapeHtml(summary)}${warnings.length ? `
${escapeHtml(warnings.join(';'))}` : ''}` toast(t('services.calibrationDone') + ' · ' + summary, 'success') if (warnings.length) toast(warnings.join(';'), 'warning') await Promise.all([ loadConfigEditor(page), loadBackups(page), loadServices(page), ]) } // ===== 配置文件编辑器 ===== let _configOriginal = '' async function loadConfigEditor(page) { const section = page.querySelector('#config-editor-section') const area = page.querySelector('#config-editor-area') const status = page.querySelector('#config-editor-status') const btnSave = page.querySelector('[data-action="save-config"]') const btnSaveOnly = page.querySelector('[data-action="save-config-only"]') try { const config = await api.readOpenclawConfig() const json = JSON.stringify(config, null, 2) _configOriginal = json area.value = json area.disabled = false btnSave.disabled = false btnSaveOnly.disabled = false section.style.display = '' status.innerHTML = `${t('services.configLoaded')} · ${(json.length / 1024).toFixed(1)} KB` // 实时检测 JSON 语法 area.oninput = () => { try { JSON.parse(area.value) const changed = area.value !== _configOriginal status.innerHTML = changed ? `● ${t('services.configUnsaved')}` : `${t('services.configNoChange')}` btnSave.disabled = !changed btnSaveOnly.disabled = !changed } catch (e) { status.innerHTML = `${t('services.configJsonError')}: ${e.message.split(' at ')[0]}` btnSave.disabled = true btnSaveOnly.disabled = true } } } catch { // openclaw.json 不存在,隐藏编辑器 section.style.display = 'none' } } async function handleSaveConfig(page, restart) { const area = page.querySelector('#config-editor-area') const status = page.querySelector('#config-editor-status') let config try { config = JSON.parse(area.value) } catch (e) { toast(t('services.configSaveJsonError'), 'error') return } status.innerHTML = `${t('services.autoBackingUp')}` try { // 保存前自动备份 await api.createBackup() } catch (e) { const yes = await showConfirm(t('services.autoBackupFailed') + ': ' + e + '\n\n' + t('services.continueWithoutBackup')) if (!yes) return } status.innerHTML = `${t('services.saving')}` try { await api.writeOpenclawConfig(config) _configOriginal = area.value toast(restart ? t('services.configSavedRestarting') : t('services.configSaved'), 'success') status.innerHTML = `${t('services.configSaved')}` page.querySelector('[data-action="save-config"]').disabled = true page.querySelector('[data-action="save-config-only"]').disabled = true if (restart) { try { await api.restartGateway() toast(t('services.gwRestarted'), 'success') } catch (e) { toast(t('services.configSavedGwFailed') + ': ' + e, 'warning') } await loadServices(page) } await loadBackups(page) } catch (e) { toast(t('common.saveFailed') + ': ' + e, 'error') status.innerHTML = `${t('common.saveFailed')}: ${e}` } } // ===== 升级操作 ===== async function doUpgradeWithModal(source, page, version = null, method = 'auto') { const modal = showUpgradeModal(t('services.upgradeTitle')) let unlistenLog, unlistenProgress, unlistenDone, unlistenError setUpgrading(true) // 清理所有监听 const cleanup = () => { setUpgrading(false) unlistenLog?.() unlistenProgress?.() unlistenDone?.() unlistenError?.() } try { if (window.__TAURI_INTERNALS__) { const { listen } = await import('@tauri-apps/api/event') unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload)) unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload)) // 后台任务完成事件 unlistenDone = await listen('upgrade-done', (e) => { cleanup() modal.setDone(typeof e.payload === 'string' ? e.payload : t('services.taskDone')) loadVersion(page) }) // 后台任务失败事件 unlistenError = await listen('upgrade-error', (e) => { cleanup() const errStr = String(e.payload || t('common.error')) modal.appendLog(errStr) const fullLog = modal.getLogText() + '\n' + errStr const diagnosis = diagnoseInstallError(fullLog) modal.setError(diagnosis.title) if (diagnosis.hint) modal.appendLog('') if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`) if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`) if (window.__openAIDrawerWithError) { window.__openAIDrawerWithError({ title: diagnosis.title, error: fullLog, scene: t('services.upgradeScene'), hint: diagnosis.hint }) } }) // 发起后台任务(立即返回) await api.upgradeOpenclaw(source, version, method) modal.appendLog(t('services.taskStarted')) } else { // Web 模式:仍然同步等待(dev-api 后端没有 spawn) modal.appendLog(t('services.webModeNoLog')) const msg = await api.upgradeOpenclaw(source, version, method) modal.setDone(typeof msg === 'string' ? msg : (msg?.message || t('services.upgradeDone'))) await loadVersion(page) cleanup() } } catch (e) { cleanup() const errStr = String(e) modal.appendLog(errStr) const fullLog = modal.getLogText() + '\n' + errStr const diagnosis = diagnoseInstallError(fullLog) modal.setError(diagnosis.title) } } async function handleUpgrade(btn, page) { const sourceLabel = detectedSource === 'official' ? t('services.officialEdition') : t('services.chineseEdition') const recommended = lastVersionInfo?.recommended const yes = await showConfirm(t('services.upgradeConfirm', { source: sourceLabel, version: recommended ? `(${recommended})` : '' })) if (!yes) return await doUpgradeWithModal(detectedSource, page, recommended || null) } async function handleSwitchSource(target, page) { const targetLabel = target === 'official' ? t('services.officialEdition') : t('services.chineseEdition') const recommended = target === 'official' ? (lastVersionInfo?.source === 'official' ? lastVersionInfo?.recommended : null) : (lastVersionInfo?.source === 'chinese' ? lastVersionInfo?.recommended : null) const yes = await showConfirm(t('services.switchSourceConfirm', { target: targetLabel, version: recommended ? `(${recommended})` : '' })) if (!yes) return await doUpgradeWithModal(target, page, null) } // ===== Gateway 安装/卸载 ===== async function handleInstallGateway(btn, page) { btn.classList.add('btn-loading') btn.textContent = t('services.installing') try { await api.installGateway() toast(t('services.gwInstalled'), 'success') await loadServices(page) } catch (e) { toast(t('services.installFailed') + ': ' + e, 'error') btn.classList.remove('btn-loading') btn.textContent = t('services.install') } } async function handleUninstallGateway(btn, page) { const yes = await showConfirm(t('services.uninstallConfirm')) if (!yes) return btn.classList.add('btn-loading') btn.textContent = t('services.uninstalling') try { await api.uninstallGateway() toast(t('services.gwUninstalled'), 'success') await loadServices(page) } catch (e) { toast(t('services.uninstallFailed') + ': ' + e, 'error') btn.classList.remove('btn-loading') btn.textContent = t('services.uninstall') } }