/** * 检测与修复页面 — 杀毒软件风格 * 一键检测 + 一键修复,简洁易懂 */ import { api, getRequestLogs, clearRequestLogs, isTauriRuntime } from '../lib/tauri-api.js' import { wsClient } from '../lib/ws-client.js' import { isOpenclawReady, isGatewayRunning } from '../lib/app-state.js' import { isForeignGatewayError, showGatewayConflictGuidance } from '../lib/gateway-ownership.js' import { icon, statusIcon } from '../lib/icons.js' import { toast } from '../components/toast.js' import { navigate } from '../router.js' import { t } from '../lib/i18n.js' /* ── 状态 ── */ let _scanning = false let _fixing = false let _results = null // { items: [{label, ok, detail}], hasIssues, fixable } let _page = null /* ── CSS 注入(扫描动画) ── */ const SCAN_CSS_ID = 'scan-anim-css' function injectScanCSS() { if (document.getElementById(SCAN_CSS_ID)) return const style = document.createElement('style') style.id = SCAN_CSS_ID style.textContent = ` @keyframes scan-ring { 0% { transform: rotate(0deg) } 100% { transform: rotate(360deg) } } @keyframes scan-pulse { 0%,100% { transform: scale(1); opacity: 1 } 50% { transform: scale(1.08); opacity: .85 } } @keyframes scan-item-in { from { opacity: 0; transform: translateY(8px) } to { opacity: 1; transform: translateY(0) } } @keyframes result-pop { from { opacity:0; transform:scale(.9) } to { opacity:1; transform:scale(1) } } .scan-hero { display:flex; flex-direction:column; align-items:center; padding:48px 0 32px } .scan-circle { position:relative; width:180px; height:180px; cursor:pointer; user-select:none } .scan-circle.disabled { pointer-events:none; opacity:.6 } .scan-ring-outer { position:absolute; inset:0; border-radius:50%; border:3px solid var(--border); transition:border-color .3s } .scan-circle:hover .scan-ring-outer { border-color:var(--accent) } .scan-ring-spin { position:absolute; inset:-4px; border-radius:50%; border:3px solid transparent; border-top-color:var(--accent); animation:scan-ring 1.2s linear infinite; display:none } .scanning .scan-ring-spin { display:block } .scanning .scan-ring-outer { border-color:var(--accent) } .scan-inner { position:absolute; inset:16px; border-radius:50%; background:var(--bg-secondary); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:6px; transition:background .3s,box-shadow .3s } .scan-circle:hover .scan-inner { background:var(--bg-tertiary,var(--bg-secondary)); box-shadow:0 0 24px rgba(99,102,241,.1) } .scanning .scan-inner { animation:scan-pulse 1.5s ease-in-out infinite } .scan-icon { width:40px; height:40px; color:var(--accent) } .scan-label { font-size:15px; font-weight:600; color:var(--text-primary) } .scan-sub { font-size:12px; color:var(--text-tertiary); margin-top:4px } .result-summary { animation:result-pop .3s ease-out; display:flex; align-items:center; gap:12px; padding:16px 20px; border-radius:12px; margin:0 auto 24px; max-width:480px } .result-summary.ok { background:var(--success-bg,#f0fdf4); border:1px solid var(--success-border,#86efac) } .result-summary.warn { background:var(--warning-bg,#fffbeb); border:1px solid var(--warning-border,#fde68a) } .result-summary.err { background:var(--error-bg,#fef2f2); border:1px solid var(--error-border,#fca5a5) } .result-icon { font-size:32px; flex-shrink:0 } .result-text-title { font-size:16px; font-weight:700 } .result-text-desc { font-size:13px; color:var(--text-secondary); margin-top:2px } .scan-items { max-width:520px; margin:0 auto; display:flex; flex-direction:column; gap:6px } .scan-item { display:flex; align-items:center; gap:10px; padding:10px 14px; border-radius:10px; background:var(--bg-secondary); animation:scan-item-in .25s ease-out both; font-size:13px } .scan-item .si-icon { flex-shrink:0; width:22px; height:22px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:12px } .scan-item .si-icon.ok { background:#dcfce7; color:#16a34a } .scan-item .si-icon.err { background:#fee2e2; color:#dc2626 } .scan-item .si-icon.warn { background:#fef3c7; color:#d97706 } .scan-item .si-label { flex:1; min-width:0 } .scan-item .si-detail { font-size:11px; color:var(--text-tertiary); margin-top:1px } .fix-btn-area { text-align:center; margin:20px 0 8px } .fix-btn { padding:10px 32px; font-size:15px; font-weight:600; border-radius:10px; background:var(--accent); color:#fff; border:none; cursor:pointer; transition:opacity .2s,transform .2s } .fix-btn:hover { opacity:.9; transform:translateY(-1px) } .fix-btn:disabled { opacity:.5; cursor:not-allowed; transform:none } .advanced-toggle { text-align:center; margin-top:28px; font-size:12px; color:var(--text-tertiary); cursor:pointer; user-select:none } .advanced-toggle:hover { color:var(--accent) } .advanced-panel { display:none; margin-top:12px; padding:16px; background:var(--bg-secondary); border-radius:10px } .advanced-panel.open { display:block } .adv-btn { padding:6px 14px; font-size:12px; border-radius:6px; border:1px solid var(--border); background:var(--bg-primary); color:var(--text-primary); cursor:pointer; transition:border-color .2s } .adv-btn:hover { border-color:var(--accent) } .adv-btn:disabled { opacity:.5 } .adv-output { margin-top:12px; background:var(--bg-primary); border-radius:8px; padding:12px; font-size:11px; max-height:300px; overflow:auto; white-space:pre-wrap; word-break:break-all; display:none } ` document.head.appendChild(style) } /* ── 渲染 ── */ export async function render() { injectScanCSS() const page = document.createElement('div') page.className = 'page' _page = page _results = null renderPage(page) return page } export function cleanup() { _page = null } function renderPage(page) { const shieldIcon = `` const loadingIcon = `` let html = `
${_scanning ? loadingIcon : shieldIcon} ${_scanning ? t('chatDebug.scanning') : t('chatDebug.startScan')}
${_scanning ? t('chatDebug.scanningHint') : (_results ? t('chatDebug.clickRescan') : t('chatDebug.clickToScan'))}
` if (_results && !_scanning) { const issueCount = _results.items.filter(i => !i.ok && !i.warn).length const warnCount = _results.items.filter(i => !i.ok && i.warn).length const totalCount = _results.items.length if (issueCount === 0 && warnCount === 0) { html += `
${t('chatDebug.allHealthy')}
${t('chatDebug.allHealthyDesc', { count: totalCount })}
` } else if (issueCount === 0 && warnCount > 0) { html += `
💡
${t('chatDebug.warningsOnly', { count: warnCount })}
${t('chatDebug.warningsOnlyDesc')}
` } else { html += `
⚠️
${t('chatDebug.issuesCount', { count: issueCount })}
${t('chatDebug.issuesCountDesc')}
` } html += `
` _results.items.forEach((item, i) => { const cls = item.ok ? 'ok' : (item.warn ? 'warn' : 'err') const icon = item.ok ? '✓' : (item.warn ? '!' : '✕') html += `
${icon}
${esc(item.label)}${item.detail ? `
${esc(item.detail)}
` : ''}
` }) html += `
` if ((issueCount > 0) && _results.fixable) { html += `
` } } // 高级工具(折叠) html += `
▾ ${t('chatDebug.advancedTools')}
` page.innerHTML = html bindEvents(page) } function bindEvents(page) { page.querySelector('#scan-btn')?.addEventListener('click', () => runScan(page)) page.querySelector('#fix-btn')?.addEventListener('click', () => runFix(page)) page.querySelector('#adv-toggle')?.addEventListener('click', () => { const panel = page.querySelector('#adv-panel') panel?.classList.toggle('open') }) // 高级工具 page.querySelector('#adv-doctor-check')?.addEventListener('click', () => handleDoctorAdv(page, false)) page.querySelector('#adv-doctor-fix')?.addEventListener('click', () => handleDoctorAdv(page, true)) page.querySelector('#adv-conn-diag')?.addEventListener('click', () => runConnDiagAdv(page)) page.querySelector('#adv-test-ws')?.addEventListener('click', () => testWebSocketAdv(page)) page.querySelector('#adv-fix-pairing')?.addEventListener('click', () => fixPairingAdv(page)) page.querySelector('#adv-network-log')?.addEventListener('click', () => toggleNetworkLogAdv(page)) } /* ── 一键检测 ── */ async function runScan(page) { if (_scanning || _fixing) return _scanning = true _results = null renderPage(page) const items = [] let fixable = false try { // 并行检测 const [configRes, servicesRes, versionRes, nodeRes] = await Promise.allSettled([ api.readOpenclawConfig(), api.getServicesStatus(), api.getVersionInfo(), api.checkNode(), ]) const config = configRes.status === 'fulfilled' ? configRes.value : null const services = servicesRes.status === 'fulfilled' ? servicesRes.value : null const version = versionRes.status === 'fulfilled' ? versionRes.value : null const node = nodeRes.status === 'fulfilled' ? nodeRes.value : null const gw = services?.[0] // 1. OpenClaw 安装 const cliInstalled = gw?.cli_installed !== false items.push({ label: t('chatDebug.scanCliInstall'), ok: cliInstalled, detail: cliInstalled ? (version?.current || '') : t('chatDebug.scanCliMissing') }) if (!cliInstalled) fixable = true // 2. Node.js items.push({ label: 'Node.js', ok: !!node?.installed, detail: node?.installed ? (node.version || '') : t('chatDebug.scanNodeMissing') }) // 3. 配置文件 const configOk = !!config && configRes.status !== 'rejected' items.push({ label: t('chatDebug.scanConfig'), ok: configOk, detail: configOk ? '' : (configRes.reason?.message || t('chatDebug.scanConfigMissing')) }) if (!configOk) fixable = true // 4. Gateway 运行 const gwRunning = !!gw?.running items.push({ label: 'Gateway', ok: gwRunning, detail: gwRunning ? `PID ${gw.pid}` : t('chatDebug.scanGwStopped') }) if (!gwRunning) fixable = true // 5. WebSocket 连接 const wsOk = wsClient.connected && wsClient.gatewayReady items.push({ label: 'WebSocket', ok: wsOk, detail: wsOk ? (wsClient.serverVersion ? `Gateway ${wsClient.serverVersion}` : t('chatDebug.connected')) : t('chatDebug.scanWsDown') }) if (!wsOk && gwRunning) fixable = true // 6. Token 配置 const hasToken = !!config?.gateway?.auth?.token items.push({ label: t('chatDebug.scanToken'), ok: hasToken, warn: !hasToken, detail: hasToken ? '' : t('chatDebug.scanTokenMissing') }) // 7. 设备密钥 let deviceOk = false try { const rawToken = config?.gateway?.auth?.token const token = (typeof rawToken === 'string') ? rawToken : '' await api.createConnectFrame('test-nonce', token) deviceOk = true } catch {} items.push({ label: t('chatDebug.scanDeviceKey'), ok: deviceOk, detail: deviceOk ? '' : t('chatDebug.scanDeviceKeyFail') }) if (!deviceOk) fixable = true // 8. 版本 if (version?.ahead_of_recommended) { items.push({ label: t('chatDebug.scanVersion'), ok: true, warn: true, detail: t('chatDebug.scanVersionAhead', { current: version.current, recommended: version.recommended }) }) } else if (version?.is_recommended) { items.push({ label: t('chatDebug.scanVersion'), ok: true, detail: version.current || '' }) } else if (version?.update_available) { items.push({ label: t('chatDebug.scanVersion'), ok: true, warn: true, detail: t('chatDebug.scanVersionUpdate', { version: version.recommended }) }) } // 9. 连接诊断(仅在 Gateway 运行时) if (gwRunning) { try { const diag = await api.diagnoseGatewayConnection() const failedSteps = diag.steps?.filter(s => !s.ok) || [] if (failedSteps.length > 0) { items.push({ label: t('chatDebug.scanConnDiag'), ok: false, detail: failedSteps.map(s => s.name).join(', ') }) fixable = true } else { items.push({ label: t('chatDebug.scanConnDiag'), ok: true, detail: t('chatDebug.scanConnOk') }) } } catch {} } _results = { items, hasIssues: items.some(i => !i.ok), fixable } } catch (e) { items.push({ label: t('chatDebug.scanError'), ok: false, detail: String(e) }) _results = { items, hasIssues: true, fixable: true } } _scanning = false if (_page === page) renderPage(page) } /* ── 一键修复 ── */ async function runFix(page) { if (_fixing || _scanning) return _fixing = true renderPage(page) try { // 1. 自动配对设备 toast(t('chatDebug.fixStepPairing'), 'info') try { await api.autoPairDevice() } catch {} // 2. 执行 openclaw doctor --fix toast(t('chatDebug.fixStepDoctor'), 'info') try { await api.doctorFix() } catch {} // 3. 重启 Gateway toast(t('chatDebug.fixStepGateway'), 'info') try { await api.restartService('ai.openclaw.gateway') } catch (e) { if (isForeignGatewayError(e)) { await openGatewayConflict(e) } } // 4. 等待 Gateway 启动(轮询,最多 15 秒) toast(t('chatDebug.fixStepWaiting'), 'info') for (let i = 0; i < 6; i++) { await new Promise(r => setTimeout(r, 2500)) try { const services = await api.getServicesStatus() if (services?.[0]?.running) break } catch {} } // 5. 触发 WebSocket 重连 + 等待就绪(最多 12 秒) toast(t('chatDebug.fixStepWebSocket'), 'info') wsClient.reconnect() for (let i = 0; i < 8; i++) { await new Promise(r => setTimeout(r, 1500)) if (wsClient.connected && wsClient.gatewayReady) break } toast(t('chatDebug.fixDone'), 'success') } catch (e) { toast(t('chatDebug.fixFailed') + ': ' + (e?.message || e), 'error') } _fixing = false // 自动重新检测 await runScan(page) } /* ── 高级工具 ── */ function showAdvOutput(page) { const el = page.querySelector('#adv-output') if (el) el.style.display = 'block' return el } function advLog(page, text, color) { const el = page.querySelector('#adv-output') if (!el) return el.style.display = 'block' el.style.color = color || 'var(--text-primary)' el.textContent = text } async function handleDoctorAdv(page, fix) { const btn = page.querySelector(fix ? '#adv-doctor-fix' : '#adv-doctor-check') if (btn) btn.disabled = true advLog(page, fix ? t('chatDebug.runningDoctorFix') : t('chatDebug.runningDoctor')) try { const result = fix ? await api.doctorFix() : await api.doctorCheck() let text = result.output || '' if (result.errors) text += '\n' + result.errors advLog(page, text.trim() || (result.success ? t('chatDebug.noIssues') : t('chatDebug.diagDone')), result.success ? 'var(--success)' : 'var(--warning)') if (fix && result.success) toast(t('chatDebug.configFixDone'), 'success') } catch (e) { advLog(page, t('chatDebug.execFailed') + (e?.message || e), 'var(--error)') } finally { if (btn) btn.disabled = false } } const CONN_STEP_LABELS = { config: () => t('diagnose.stepConfig'), device_key: () => t('diagnose.stepDeviceKey'), allowed_origins: () => t('diagnose.stepOrigins'), tcp_port: () => t('diagnose.stepTcp'), http_health: () => t('diagnose.stepHttp'), err_log: () => t('diagnose.stepErrLog'), } async function runConnDiagAdv(page) { const btn = page.querySelector('#adv-conn-diag') if (btn) btn.disabled = true advLog(page, t('diagnose.running')) try { const result = await api.diagnoseGatewayConnection() const lines = result.steps.map(s => `${s.ok ? '✅' : '❌'} ${CONN_STEP_LABELS[s.name]?.() || s.name} (${s.durationMs}ms)\n ${s.message}`) advLog(page, (result.overallOk ? '✅ ' + t('diagnose.allPassed') : '⚠️ ' + result.summary) + '\n\n' + lines.join('\n\n'), result.overallOk ? 'var(--success)' : 'var(--warning)') } catch (e) { advLog(page, String(e), 'var(--error)') } finally { if (btn) btn.disabled = false } } async function testWebSocketAdv(page) { const el = showAdvOutput(page) if (!el) return el.textContent = '' const log = (msg) => { el.textContent += msg + '\n'; el.scrollTop = el.scrollHeight } log(t('chatDebug.wsTestStart')) try { const config = await api.readOpenclawConfig() const port = config?.gateway?.port || 18789 const rawToken = config?.gateway?.auth?.token const token = (typeof rawToken === 'string') ? rawToken : '' const wsHost = isTauriRuntime() ? `127.0.0.1:${port}` : location.host const url = `ws://${wsHost}/ws?token=${encodeURIComponent(token)}` log(`→ ${url}`) const ws = new WebSocket(url) ws.onopen = () => log('✅ ' + t('chatDebug.wsConnected')) ws.onmessage = (evt) => { try { const msg = JSON.parse(evt.data) log('← ' + JSON.stringify(msg, null, 2)) if (msg.type === 'event' && msg.event === 'connect.challenge') { api.createConnectFrame(msg.payload?.nonce || '', token).then(frame => { ws.send(JSON.stringify(frame)) log('→ connect frame sent') }).catch(e => log('❌ frame: ' + e)) } if (msg.type === 'res' && msg.id?.startsWith('connect-')) { log(msg.ok ? '✅ Handshake OK' : '❌ Handshake failed: ' + (msg.error?.message || msg.error?.code)) } } catch (e) { log('⚠ parse: ' + e) } } ws.onerror = () => log('❌ WebSocket error') ws.onclose = (e) => log(`WebSocket closed (${e.code})`) } catch (e) { log('❌ ' + e) } } async function fixPairingAdv(page) { const btn = page.querySelector('#adv-fix-pairing') if (btn) btn.disabled = true const el = showAdvOutput(page) if (!el) { if (btn) btn.disabled = false; return } el.textContent = '' const log = (msg) => { el.textContent += msg + '\n'; el.scrollTop = el.scrollHeight } try { log('🔧 ' + t('chatDebug.fixStarting')) log('📝 ' + t('chatDebug.fixWritingPair')) const result = await api.autoPairDevice() log('✅ ' + result) log('⚡ ' + t('chatDebug.fixStoppingGw')) try { await api.stopService('ai.openclaw.gateway') } catch (e) { if (isForeignGatewayError(e)) { await openGatewayConflict(e); throw e } } await new Promise(r => setTimeout(r, 3000)) log('⚡ ' + t('chatDebug.fixStartingGw')) try { await api.startService('ai.openclaw.gateway') } catch (e) { if (isForeignGatewayError(e)) await openGatewayConflict(e) throw e } log('✅ ' + t('chatDebug.fixGwStartSent')) await new Promise(r => setTimeout(r, 5000)) const services = await api.getServicesStatus() log(services?.[0]?.running ? '✅ Gateway running' : '⚠ Gateway may still be starting') wsClient.reconnect() log('🔄 ' + t('chatDebug.fixReconnecting')) } catch (e) { log('❌ ' + t('chatDebug.fixFailed') + ': ' + e) } finally { if (btn) btn.disabled = false } } function toggleNetworkLogAdv(page) { const el = showAdvOutput(page) if (!el) return const logs = getRequestLogs() if (logs.length === 0) { el.textContent = t('chatDebug.noRequests'); return } const total = logs.length const cached = logs.filter(l => l.cached).length let text = `Total: ${total} | Cached: ${cached}\n${'─'.repeat(60)}\n` for (let i = logs.length - 1; i >= Math.max(0, logs.length - 50); i--) { const l = logs[i] text += `${l.time} ${l.cmd} ${l.duration}${l.cached ? ' [cache]' : ''}\n` } el.textContent = text } async function openGatewayConflict(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 }) } function esc(str) { if (!str) return '' return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') }