feat: new pages + dashboard enhancements + backend improvements

New pages:
- Plugin Hub: grid cards, search, install/toggle/enable plugins
- Route Map: SVG visualization of channels→agents bindings with legends
- Diagnose: gateway connectivity diagnosis with step-by-step checks

Dashboard enhancements:
- WebSocket status indicator (connected/handshaking/reconnecting/disconnected)
- Connected channels overview with platform icons
- Colored log level badges (ERROR/WARN/INFO/DEBUG) with timestamps
- Channels data loading in dashboard secondary fetch

Splash screen:
- Multi-stage boot detection (JS not loaded vs boot slow vs timeout)
- 15s: WebView2/resource load failure
- 20s: "initializing..." hint with elapsed counter
- 90s: true timeout error

Backend (Rust):
- diagnose.rs: gateway connectivity diagnosis command
- messaging.rs: plugin management commands
- service.rs: improvements
- lib.rs: register new commands

Frontend libs:
- feature-gates.js: feature flag system
- ws-client.js: reconnect state tracking
- tauri-api.js: new API bindings
- model-presets.js: provider fixes
- Remove gateway-guardian-policy.js (unused)

Dev API (scripts/dev-api.js):
- list_all_plugins, toggle_plugin, install_plugin handlers
- probe_gateway_port, diagnose_gateway_connection handlers

i18n: dashboard, sidebar, diagnose, extensions, routeMap locale modules
CSS: plugin-hub cards, route-map SVG styles
This commit is contained in:
晴天
2026-04-11 00:44:06 +08:00
parent c1fb674c44
commit 70d768be17
27 changed files with 2337 additions and 187 deletions

View File

@@ -3,10 +3,6 @@
* 管理 openclaw 安装状态,供各组件查询
*/
import { api } from './tauri-api.js'
import {
evaluateAutoRestartAttempt,
shouldResetAutoRestartCount,
} from './gateway-guardian-policy.js'
const isTauri = !!window.__TAURI_INTERNALS__
@@ -22,10 +18,8 @@ let _gwListeners = []
let _gwStopCount = 0 // 连续检测到"停止"的次数,防抖用
let _isUpgrading = false // 升级/切换版本期间,阻止 setup 跳转
let _userStopped = false // 用户主动停止,不自动拉起
let _autoRestartCount = 0 // 自动重启次数
let _lastRestartTime = 0 // 上次重启时间
let _gatewayRunningSince = 0 // Gateway 最近一次进入稳定运行状态的时间
let _guardianListeners = [] // 守护放弃时的回调
let _guardianListeners = [] // 守护放弃时的回调(后端 guardian-event 触发)
/** openclaw 是否就绪CLI 已安装 + 配置文件存在) */
export function isOpenclawReady() {
@@ -41,10 +35,8 @@ export function isUpgrading() { return _isUpgrading }
/** 标记用户主动停止 Gateway不触发自动重启 */
export function setUserStopped(v) { _userStopped = !!v }
/** 重置自动重启计数(用户手动启动后重置) */
/** 重置守护状态(用户手动启动后重置) */
export function resetAutoRestart() {
_autoRestartCount = 0
_lastRestartTime = 0
_gatewayRunningSince = 0
_userStopped = false
}
@@ -158,8 +150,8 @@ function _setGatewayRunning(val, foreign = false) {
_gatewayRunningSince = Date.now()
} else if (wasRunning && !_userStopped && !_isUpgrading && _openclawReady && !foreign) {
_gatewayRunningSince = 0
// Gateway 意外停止,尝试自动重启
_tryAutoRestart()
// Gateway 意外停止 → 后端 Rust guardian 负责自动重启,前端仅更新 UI 状态
console.log('[app-state] Gateway 意外停止,等待后端 guardian 重启...')
} else if (!val) {
_gatewayRunningSince = 0
}
@@ -167,54 +159,6 @@ function _setGatewayRunning(val, foreign = false) {
}
}
async function _tryAutoRestart() {
const now = Date.now()
const decision = evaluateAutoRestartAttempt({
now,
lastRestartTime: _lastRestartTime,
autoRestartCount: _autoRestartCount,
})
if (decision.action === 'cooldown') return
if (decision.action === 'give_up') {
console.warn('[guardian] Gateway 已达到自动重启上限,停止守护,请手动检查')
_guardianListeners.forEach(fn => { try { fn() } catch {} })
return
}
// 延迟 3 秒后再次确认端口确实空闲,防止瞬态 TCP 超时误判触发不必要的重启
await new Promise(r => setTimeout(r, 3000))
try {
const { invalidate } = await import('./tauri-api.js')
invalidate('get_services_status')
const services = await api.getServicesStatus()
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0]
if (gw?.running) {
console.log(gw?.owned_by_current_instance === false
? '[guardian] 检测到外部 Gateway 正在占用端口,跳过自动重启'
: '[guardian] 端口仍在使用中,跳过自动重启')
_gwStopCount = 0
if (gw?.owned_by_current_instance !== false) {
_gatewayRunning = true
_gatewayRunningSince = Date.now()
_gwListeners.forEach(fn => { try { fn(true) } catch {} })
}
return
}
} catch {}
_autoRestartCount = decision.autoRestartCount
_lastRestartTime = decision.lastRestartTime
console.log(`[guardian] Gateway 意外停止,自动重启 (${_autoRestartCount}/3)...`)
try {
await api.startService('ai.openclaw.gateway')
console.log('[guardian] Gateway 自动重启成功')
} catch (e) {
console.error('[guardian] Gateway 自动重启失败:', e)
}
}
/** 刷新 Gateway 运行状态(轻量,仅查服务状态)
* 防抖running→stopped 需要连续 3 次检测才切换,避免瞬态误判 */
export async function refreshGatewayStatus() {
@@ -229,12 +173,6 @@ export async function refreshGatewayStatus() {
_gwStopCount = 0
if (!_gatewayRunning) {
_setGatewayRunning(true, false)
} else if (shouldResetAutoRestartCount({
autoRestartCount: _autoRestartCount,
runningSince: _gatewayRunningSince,
now: Date.now(),
})) {
_autoRestartCount = 0
}
} else {
if (foreignRunning) {

135
src/lib/feature-gates.js Normal file
View File

@@ -0,0 +1,135 @@
/**
* 功能版本门控 — 根据 OpenClaw 版本动态显示/隐藏功能
*
* 工作原理:
* 1. 从 api.getVersionInfo() 获取当前 OpenClaw 版本
* 2. 对比功能所需最低版本
* 3. sidebar 和页面可调用 isFeatureAvailable() 判断是否显示
*
* 版本格式: x.y.z 或 x.y.z-zh.w汉化版
*/
import { api } from './tauri-api.js'
import { wsClient } from './ws-client.js'
// 功能 → 最低版本映射(语义化版本号,不含 -zh 后缀)
const FEATURE_MIN_VERSIONS = {
dreaming: '0.11.0',
cron: '0.10.0',
skills: '0.10.0',
'route-map': '0.9.0',
'plugin-hub': '0.9.0',
memory: '0.8.0',
}
let _cachedVersion = null
let _cacheTime = 0
const CACHE_TTL = 60000
/**
* 解析版本号为可比较的数组 [major, minor, patch]
* 支持 '0.11.6', '0.11.6-zh.2', '2026.3.18' 等格式
*/
function parseVersion(ver) {
if (!ver) return null
// 移除 -zh.xxx / -beta.xxx 等后缀,只保留主版本号
const base = ver.replace(/-.*$/, '')
const parts = base.split('.').map(Number)
if (parts.some(isNaN)) return null
while (parts.length < 3) parts.push(0)
return parts.slice(0, 3)
}
/**
* 比较版本: a >= b 返回 true
*/
function versionGte(a, b) {
const pa = parseVersion(a)
const pb = parseVersion(b)
if (!pa || !pb) return true // 无法解析时默认允许
for (let i = 0; i < 3; i++) {
if (pa[i] > pb[i]) return true
if (pa[i] < pb[i]) return false
}
return true // equal
}
/**
* 获取当前 OpenClaw 版本(带缓存)
*/
async function getCurrentVersion() {
if (_cachedVersion && Date.now() - _cacheTime < CACHE_TTL) return _cachedVersion
// 优先从 wsClient.serverVersion 获取(实时)
if (wsClient.serverVersion) {
_cachedVersion = wsClient.serverVersion
_cacheTime = Date.now()
return _cachedVersion
}
// 回退到 API
try {
const info = await api.getVersionInfo()
if (info?.current) {
_cachedVersion = info.current
_cacheTime = Date.now()
}
} catch {}
return _cachedVersion
}
/**
* 同步获取上次缓存的版本(不发请求)
*/
export function getCachedVersion() {
return _cachedVersion || wsClient.serverVersion || null
}
/**
* 同步检查功能是否可用(基于缓存版本)
* 如果版本信息尚未获取,默认返回 true避免隐藏功能
*/
export function isFeatureAvailable(featureId) {
const minVer = FEATURE_MIN_VERSIONS[featureId]
if (!minVer) return true // 无门控 → 始终可用
const currentVer = getCachedVersion()
if (!currentVer) return true // 版本未知 → 默认显示
return versionGte(currentVer, minVer)
}
/**
* 异步检查功能是否可用(会先获取版本)
*/
export async function checkFeatureAvailable(featureId) {
await getCurrentVersion()
return isFeatureAvailable(featureId)
}
/**
* 初始化:预加载版本信息
*/
export async function initFeatureGates() {
await getCurrentVersion()
}
/**
* 刷新缓存
*/
export function invalidateVersionCache() {
_cachedVersion = null
_cacheTime = 0
}
/**
* 获取所有功能门控状态(调试用)
*/
export function getAllFeatureStatus() {
const ver = getCachedVersion()
const result = {}
for (const [feature, minVer] of Object.entries(FEATURE_MIN_VERSIONS)) {
result[feature] = { minVersion: minVer, available: isFeatureAvailable(feature) }
}
return { currentVersion: ver, features: result }
}

View File

@@ -1,38 +0,0 @@
/**
* Gateway 守护策略
* 纯函数,便于测试自动重启与计数重置规则
*/
export const MAX_AUTO_RESTART = 3
export const RESTART_COOLDOWN = 60000
export const STABLE_RUNNING_MS = 120000
export function evaluateAutoRestartAttempt({
now,
lastRestartTime,
autoRestartCount,
}) {
if (now - lastRestartTime < RESTART_COOLDOWN) {
return { action: 'cooldown' }
}
if (autoRestartCount >= MAX_AUTO_RESTART) {
return { action: 'give_up' }
}
return {
action: 'restart',
autoRestartCount: autoRestartCount + 1,
lastRestartTime: now,
}
}
export function shouldResetAutoRestartCount({
autoRestartCount,
runningSince,
now,
}) {
if (autoRestartCount <= 0) return false
if (!runningSince) return false
return now - runningSince >= STABLE_RUNNING_MS
}

View File

@@ -5,11 +5,14 @@
// API 接口类型选项
export const API_TYPES = [
{ value: 'openai-completions', label: 'OpenAI 兼容 (最常用)' },
{ value: 'anthropic-messages', label: 'Anthropic 原生' },
{ value: 'openai-completions', label: 'OpenAI Chat Completions (最常用)' },
{ value: 'anthropic-messages', label: 'Anthropic Messages' },
{ value: 'openai-responses', label: 'OpenAI Responses' },
{ value: 'openai-codex-responses', label: 'OpenAI Codex Responses' },
{ value: 'google-generative-ai', label: 'Google Gemini' },
{ value: 'ollama', label: 'Ollama 原生' },
{ value: 'github-copilot', label: 'GitHub Copilot' },
{ value: 'bedrock-converse-stream', label: 'AWS Bedrock' },
{ value: 'ollama', label: 'Ollama 本地模型' },
]
// 服务商快捷预设
@@ -20,13 +23,17 @@ export const PROVIDER_PRESETS = [
{ key: 'volcengine', label: '火山引擎', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', api: 'openai-completions', site: 'https://volcengine.com/L/Ph1OP5I3_GY', desc: '字节跳动旗下云平台,支持豆包等模型' },
{ key: 'aliyun', label: '阿里云百炼', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', api: 'openai-completions', site: 'https://www.aliyun.com/benefit/ai/aistar?userCode=keahn2zr&clubBiz=subTask..12435175..10263..', desc: '阿里云 AI 大模型平台,支持通义千问全系列' },
{ key: 'zhipu', label: '智谱 AI', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', api: 'openai-completions', site: 'https://www.bigmodel.cn/glm-coding?ic=3F6F9XYKTS', desc: '国产大模型领军企业,支持 GLM-4 全系列' },
{ key: 'minimax', label: 'MiniMax', baseUrl: 'https://api.minimax.io/v1', api: 'openai-completions', site: 'https://platform.minimaxi.com/', desc: '国产多模态大模型,支持 MiniMax-M2.7 / M2.5 系列,兼容 OpenAI 接口' },
{ key: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1', api: 'openai-completions' },
{ key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com', api: 'anthropic-messages' },
{ key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions' },
{ key: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', api: 'google-generative-ai' },
{ key: 'nvidia', label: 'NVIDIA NIM', baseUrl: 'https://integrate.api.nvidia.com/v1', api: 'openai-completions', desc: '英伟达推理平台,支持 Llama、Mistral 等模型' },
{ key: 'ollama', label: 'Ollama (本地)', baseUrl: 'http://127.0.0.1:11434/v1', api: 'openai-completions' },
{ key: 'minimax', label: 'MiniMax', baseUrl: 'https://api.minimaxi.com/anthropic/v1', api: 'anthropic-messages', site: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', desc: '国产多模态大模型,支持 MiniMax-M2.7 / M2.5 系列' },
{ key: 'moonshot', label: 'Moonshot / Kimi', baseUrl: 'https://api.moonshot.ai/v1', api: 'openai-completions', site: 'https://platform.moonshot.ai/console/api-keys', desc: 'Kimi 大模型平台,支持超长上下文' },
{ key: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1', api: 'openai-completions', site: 'https://platform.openai.com/api-keys' },
{ key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com/v1', api: 'anthropic-messages', site: 'https://console.anthropic.com/settings/keys' },
{ key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions', site: 'https://platform.deepseek.com/api_keys' },
{ key: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', api: 'google-generative-ai', site: 'https://aistudio.google.com/app/apikey' },
{ key: 'xai', label: 'xAI (Grok)', baseUrl: 'https://api.x.ai/v1', api: 'openai-completions', site: 'https://console.x.ai/', desc: 'Elon Musk 旗下 AI支持 Grok 系列模型' },
{ key: 'groq', label: 'Groq', baseUrl: 'https://api.groq.com/openai/v1', api: 'openai-completions', site: 'https://console.groq.com/keys', desc: '超快推理平台,支持 Llama、Mixtral 等开源模型' },
{ key: 'openrouter', label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', api: 'openai-completions', site: 'https://openrouter.ai/keys', desc: '模型聚合路由,一个 Key 访问所有主流模型' },
{ key: 'nvidia', label: 'NVIDIA NIM', baseUrl: 'https://integrate.api.nvidia.com/v1', api: 'openai-completions', site: 'https://build.nvidia.com/models', desc: '英伟达推理平台,支持 Llama、Mistral 等模型' },
{ key: 'ollama', label: 'Ollama (本地)', baseUrl: 'http://127.0.0.1:11434/v1', api: 'openai-completions', site: 'https://ollama.com/' },
]
// 晴辰云配置
@@ -71,15 +78,25 @@ export const MODEL_PRESETS = {
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1000000 },
],
minimax: [
{ id: 'MiniMax-M2.7', name: 'MiniMax M2.7', contextWindow: 1000000 },
{ id: 'MiniMax-M2.7-highspeed', name: 'MiniMax M2.7 Highspeed', contextWindow: 1000000 },
{ id: 'MiniMax-M2.5', name: 'MiniMax M2.5', contextWindow: 204000 },
{ id: 'MiniMax-M2.5-highspeed', name: 'MiniMax M2.5 Highspeed', contextWindow: 204000 },
],
moonshot: [
{ id: 'kimi-k2.5', name: 'Kimi K2.5', contextWindow: 131072 },
{ id: 'kimi-k2', name: 'Kimi K2', contextWindow: 131072 },
{ id: 'kimi-latest', name: 'Kimi Latest', contextWindow: 131072 },
],
xai: [
{ id: 'grok-4', name: 'Grok 4', contextWindow: 131072 },
{ id: 'grok-4-fast', name: 'Grok 4 Fast', contextWindow: 131072 },
],
groq: [
{ id: 'llama-3.3-70b-versatile', name: 'Llama 3.3 70B', contextWindow: 32768 },
{ id: 'mixtral-8x7b-32768', name: 'Mixtral 8x7B', contextWindow: 32768 },
],
ollama: [
{ id: 'qwen2.5:7b', name: 'Qwen 2.5 7B', contextWindow: 32768 },
{ id: 'llama3.2', name: 'Llama 3.2', contextWindow: 8192 },
{ id: 'gemma3', name: 'Gemma 3', contextWindow: 32768 },
{ id: 'qwen3:32b', name: 'Qwen 3 32B', contextWindow: 32768 },
{ id: 'llama3.3:70b', name: 'Llama 3.3 70B', contextWindow: 8192 },
{ id: 'deepseek-r1:32b', name: 'DeepSeek R1 32B', contextWindow: 32768, reasoning: true },
],
}

View File

@@ -189,6 +189,8 @@ export const api = {
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') },
probeGatewayPort: () => invoke('probe_gateway_port'),
diagnoseGatewayConnection: () => invoke('diagnose_gateway_connection'),
guardianStatus: () => invoke('guardian_status'),
// 配置(读缓存,写清缓存)
@@ -256,6 +258,9 @@ export const api = {
return invoke('repair_qqbot_channel_setup')
},
listConfiguredPlatforms: () => cachedInvoke('list_configured_platforms', {}, 5000),
listAllPlugins: () => cachedInvoke('list_all_plugins', {}, 5000),
togglePlugin: (pluginId, enabled) => { invalidate('list_all_plugins'); return invoke('toggle_plugin', { pluginId, enabled }) },
installPlugin: (packageName) => { invalidate('list_all_plugins'); return invoke('install_plugin', { packageName }) },
getChannelPluginStatus: (pluginId) => invoke('get_channel_plugin_status', { pluginId }),
installQqbotPlugin: (version = null) => invoke('install_qqbot_plugin', { version }),
installChannelPlugin: (packageName, pluginId, version = null) => invoke('install_channel_plugin', { packageName, pluginId, version }),
@@ -302,7 +307,7 @@ export const api = {
deleteBackup: (name) => { invalidate('list_backups'); return invoke('delete_backup', { name }) },
// 设备密钥 + Gateway 握手
createConnectFrame: (nonce, gatewayToken) => invoke('create_connect_frame', { nonce, gatewayToken }),
createConnectFrame: (nonce, gatewayToken, gatewayPassword) => invoke('create_connect_frame', { nonce, gatewayToken, gatewayPassword: gatewayPassword || null }),
// 设备配对
autoPairDevice: () => invoke('auto_pair_device'),

View File

@@ -52,6 +52,8 @@ export class WsClient {
this._challengeTimer = null
this._wsId = 0
this._autoPairAttempts = 0
this._authRetryCount = 0
this._password = ''
this._serverVersion = null
// 增强状态追踪
@@ -111,6 +113,7 @@ export class WsClient {
this._intentionalClose = false
this._autoPairAttempts = 0
this._token = token || ''
this._password = opts.password || ''
// 自动检测协议:如果页面通过 HTTPS 加载(反代场景),使用 wss://
const proto = opts.secure ?? (typeof location !== 'undefined' && location.protocol === 'https:') ? 'wss' : 'ws'
const nextUrl = `${proto}://${host}/ws?token=${encodeURIComponent(this._token)}`
@@ -143,6 +146,7 @@ export class WsClient {
this._intentionalClose = false
this._reconnectAttempts = 0
this._autoPairAttempts = 0
this._authRetryCount = 0
this._missedHeartbeats = 0
this._stopPing()
this._stopHeartbeat()
@@ -196,23 +200,91 @@ export class WsClient {
this._ws = null
this._connecting = false
this._clearChallengeTimer()
if (e.code === 4001 || e.code === 4003 || e.code === 4004) {
this._setConnected(false, 'auth_failed', e.reason || 'Token 认证失败')
this._intentionalClose = true
this._flushPending()
const reason = (e.reason || '').toLowerCase()
// ── 4001: Gateway 配置热重载 / 设备被移除 ──
// 上游仅在 config reload 和 device removal 时发 4001
// 正确做法:短延迟后自动重连,而非永久断开
if (e.code === 4001) {
console.log('[ws] Gateway 配置变更3秒后自动重连:', e.reason)
this._setConnected(false, 'reconnecting', 'Gateway 配置已更新,自动重连中...')
this._gatewayReady = false
this._handshaking = false
this._stopPing()
setTimeout(() => {
if (!this._intentionalClose) {
this._reconnectAttempts = 0
this._doConnect()
}
}, 3000)
return
}
// ── 1008: 握手期策略拒绝(按 reason 文本精确分流)──
if (e.code === 1008 && !this._intentionalClose) {
if (this._autoPairAttempts < 1) {
console.log('[ws] origin not allowed (1008),尝试自动修复...')
this._setConnected(false, 'reconnecting', 'origin not allowed修复中...')
this._autoPairAndReconnect()
if (/origin not allowed/i.test(reason)) {
// Origin 不在白名单 → 自动配对(写 allowedOrigins + reload
if (this._autoPairAttempts < 1) {
console.log('[ws] origin not allowed尝试自动修复...')
this._setConnected(false, 'reconnecting', 'origin 修复中...')
this._autoPairAndReconnect()
return
}
this._setConnected(false, 'error', 'origin not allowed请检查 gateway.controlUi.allowedOrigins 配置')
return
}
console.warn('[ws] origin 1008 自动修复已尝试过,显示错误')
this._setConnected(false, 'error', e.reason || 'origin not allowed请点击「修复并重连」')
if (/unauthorized/i.test(reason)) {
// Token/password 不匹配 → 尝试刷新凭据并重连
if (this._authRetryCount < 2) {
this._authRetryCount++
console.log(`[ws] 认证失败,刷新凭据 (${this._authRetryCount}/2):`, e.reason)
this._setConnected(false, 'reconnecting', `认证失败,刷新凭据中 (${this._authRetryCount}/2)...`)
this._refreshCredentialsAndReconnect()
return
}
this._setConnected(false, 'auth_failed', `认证失败: ${e.reason || 'token mismatch'}。请检查 Gateway Token 配置。`)
this._intentionalClose = true
this._flushPending()
return
}
if (/pairing required/i.test(reason) || /not.paired/i.test(reason)) {
// 设备未配对 → 自动配对
if (this._autoPairAttempts < 1) {
console.log('[ws] 设备未配对,尝试自动配对...')
this._setConnected(false, 'reconnecting', '设备配对中...')
this._autoPairAndReconnect()
return
}
this._setConnected(false, 'error', '设备配对失败,请手动执行 openclaw pairing approve')
return
}
if (/device identity required/i.test(reason) || /device auth/i.test(reason)) {
// 设备认证问题 → 重新配对
if (this._autoPairAttempts < 1) {
console.log('[ws] 设备认证问题,尝试重新配对:', e.reason)
this._setConnected(false, 'reconnecting', '设备认证修复中...')
this._autoPairAndReconnect()
return
}
this._setConnected(false, 'error', `设备认证失败: ${e.reason}`)
return
}
if (/rate.?limit/i.test(reason)) {
// 被限流 → 等待后重试
console.log('[ws] 被限流30秒后重试')
this._setConnected(false, 'reconnecting', '请求过于频繁30秒后重试...')
setTimeout(() => {
if (!this._intentionalClose) this._doConnect()
}, 30000)
return
}
// 其他 1008如 invalid role、protocol mismatch→ 显示错误
console.warn('[ws] 收到 1008 关闭:', e.reason)
this._setConnected(false, 'error', e.reason || '连接被 Gateway 拒绝')
return
}
// ── 其他关闭码 → 普通断线重连 ──
this._setConnected(false)
this._gatewayReady = false
this._handshaking = false
@@ -247,21 +319,90 @@ export class WsClient {
if (!msg.ok || msg.error) {
const errMsg = msg.error?.message || 'Gateway 握手失败'
const errCode = msg.error?.code
console.error('[ws] connect 失败:', errMsg, errCode)
const details = msg.error?.details || {}
const detailCode = details.code || ''
const nextStep = details.recommendedNextStep || ''
console.error('[ws] connect 失败:', { errCode, detailCode, nextStep, errMsg })
// 如果是配对/origin 错误,尝试自动配对(仅一次,防止无限循环
if (errCode === 'NOT_PAIRED' || errCode === 'PAIRING_REQUIRED' || /origin not allowed/i.test(errMsg)) {
if (this._autoPairAttempts < 1) {
console.log('[ws] 检测到配对/origin 错误,尝试自动修复...', errCode || errMsg)
this._autoPairAndReconnect()
// 按 detailCode 精确分流(上游 ConnectErrorDetailCodes
let handled = false
switch (detailCode) {
case 'PAIRING_REQUIRED':
case 'CONTROL_UI_ORIGIN_NOT_ALLOWED':
// 可自动修复:配对 + 写 origins
if (this._autoPairAttempts < 1) {
console.log('[ws] 自动修复:', detailCode)
this._autoPairAndReconnect()
return
}
break
case 'AUTH_TOKEN_MISMATCH':
case 'AUTH_TOKEN_MISSING':
case 'AUTH_TOKEN_NOT_CONFIGURED':
case 'AUTH_PASSWORD_MISMATCH':
case 'AUTH_PASSWORD_MISSING':
case 'AUTH_PASSWORD_NOT_CONFIGURED':
case 'AUTH_DEVICE_TOKEN_MISMATCH':
// 认证凭据问题 → 刷新凭据重试
if (this._authRetryCount < 2) {
this._authRetryCount++
console.log(`[ws] 认证失败 (${detailCode}),刷新凭据 (${this._authRetryCount}/2)`)
this._refreshCredentialsAndReconnect()
return
}
handled = true
break
case 'AUTH_RATE_LIMITED': {
// 被限流 → 等待后重试
const retryMs = msg.error?.retryAfterMs || 30000
console.log(`[ws] 被限流,${Math.round(retryMs / 1000)}秒后重试`)
this._setConnected(false, 'reconnecting', `请求过于频繁,${Math.round(retryMs / 1000)}秒后重试...`)
setTimeout(() => { if (!this._intentionalClose) this._doConnect() }, retryMs)
return
}
console.warn('[ws] 自动修复已尝试过,不再重试')
case 'DEVICE_IDENTITY_REQUIRED':
case 'CONTROL_UI_DEVICE_IDENTITY_REQUIRED':
case 'DEVICE_AUTH_SIGNATURE_INVALID':
case 'DEVICE_AUTH_NONCE_MISMATCH':
case 'DEVICE_AUTH_NONCE_REQUIRED':
case 'DEVICE_AUTH_PUBLIC_KEY_INVALID':
case 'DEVICE_AUTH_INVALID':
// 设备签名/认证问题 → 重新配对
if (this._autoPairAttempts < 1) {
console.log('[ws] 设备认证问题:', detailCode)
this._autoPairAndReconnect()
return
}
break
default:
// 兼容旧版 Gateway不含 details按 errCode / errMsg 分流
if (errCode === 'NOT_PAIRED' || /origin not allowed/i.test(errMsg)) {
if (this._autoPairAttempts < 1) {
console.log('[ws] 检测到配对/origin 错误,尝试自动修复...', errCode || errMsg)
this._autoPairAndReconnect()
return
}
}
if (/unauthorized/i.test(errMsg) && this._authRetryCount < 2) {
this._authRetryCount++
this._refreshCredentialsAndReconnect()
return
}
}
this._setConnected(false, 'error', errMsg)
// 使用 recommendedNextStep 给用户更好的提示
const hints = {
'retry_with_device_token': '设备令牌需要更新,请重启面板',
'update_auth_configuration': '请检查 Gateway 认证配置',
'update_auth_credentials': '请检查 Gateway Token 是否正确',
'wait_then_retry': '请稍后重试',
'review_auth_configuration': '请检查 Gateway 安全配置',
}
const hint = hints[nextStep] || ''
const displayMsg = hint ? `${errMsg}${hint}` : errMsg
this._setConnected(false, 'error', displayMsg)
this._readyCallbacks.forEach(fn => {
try { fn(null, null, { error: true, message: errMsg }) } catch {}
try { fn(null, null, { error: true, message: displayMsg, detailCode, nextStep }) } catch {}
})
return
}
@@ -340,10 +481,37 @@ export class WsClient {
}
}
async _refreshCredentialsAndReconnect() {
try {
// 重新从 openclaw.json 读取最新凭据
const config = await api.readOpenclawConfig()
const newToken = config?.gateway?.auth?.token || ''
const newPassword = config?.gateway?.auth?.password || ''
if ((newToken && newToken !== this._token) || (newPassword && newPassword !== this._password)) {
console.log('[ws] 检测到凭据变更,使用新凭据重连')
this._token = newToken
this._password = newPassword
const base = this._url.split('?')[0]
this._url = `${base}?token=${encodeURIComponent(this._token)}`
}
// 确保配对和 origins
try { await api.autoPairDevice() } catch {}
// 3秒后重连
setTimeout(() => {
if (!this._intentionalClose) {
this._doConnect()
}
}, 3000)
} catch (e) {
console.error('[ws] 刷新凭据失败:', e)
this._setConnected(false, 'error', `凭据刷新失败: ${e}`)
}
}
async _sendConnectFrame(nonce) {
this._handshaking = true
try {
const frame = await api.createConnectFrame(nonce, this._token)
const frame = await api.createConnectFrame(nonce, this._token, this._password)
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
console.log('[ws] 发送 connect frame')
this._ws.send(JSON.stringify(frame))
@@ -356,6 +524,7 @@ export class WsClient {
_handleConnectSuccess(payload) {
this._autoPairAttempts = 0
this._authRetryCount = 0
this._hello = payload || null
this._snapshot = payload?.snapshot || null
this._serverVersion = payload?.serverVersion || null