mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-21 15:44:01 +08:00
1056 lines
37 KiB
JavaScript
1056 lines
37 KiB
JavaScript
/**
|
||
* WebSocket 客户端 - 直连 OpenClaw Gateway
|
||
*
|
||
* 协议流程(直连模式):
|
||
* 1. 连接 ws://host/ws?token=xxx
|
||
* 2. Gateway 发 connect.challenge(带 nonce)
|
||
* 3. 客户端调用 Tauri 后端生成 Ed25519 签名的 connect frame
|
||
* 4. Gateway 返回 connect 响应(带 snapshot)
|
||
* 5. 从 snapshot.sessionDefaults.mainSessionKey 获取 sessionKey
|
||
* 6. 开始正常通信
|
||
*/
|
||
import { api, isTauriRuntime } from './tauri-api.js'
|
||
import { t } from './i18n.js'
|
||
import { KERNEL_TARGET } from './feature-catalog.js'
|
||
|
||
export function uuid() {
|
||
if (crypto.randomUUID) return crypto.randomUUID()
|
||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||
const r = Math.random() * 16 | 0
|
||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
|
||
})
|
||
}
|
||
|
||
const REQUEST_TIMEOUT = 30000
|
||
const MAX_RECONNECT_DELAY = 60000
|
||
const PING_INTERVAL = 30000
|
||
const CHALLENGE_TIMEOUT = 15000
|
||
const MAX_RECONNECT_ATTEMPTS = 20
|
||
const HEARTBEAT_TIMEOUT = 90000
|
||
const MESSAGE_CACHE_SIZE = 100
|
||
// Gateway 启动前的初始重连延迟(更长,给 Gateway 充足的重启/初始化时间)
|
||
const INITIAL_RECONNECT_DELAY = 10000
|
||
|
||
/**
|
||
* 判断 RPC 错误是否为「method 不被当前 Gateway 支持」类型。
|
||
* 用于跨内核兼容降级:老内核没有的新 RPC 应该被静默吃掉,而不是 toast 给小白用户。
|
||
*
|
||
* 上游 Gateway 错误码可能是:METHOD_NOT_FOUND / UNKNOWN_METHOD / UNKNOWN_RPC / NOT_IMPLEMENTED
|
||
* 错误消息可能包含 "method ... not found" / "unknown method" / "no handler"
|
||
*/
|
||
function isMethodUnsupportedError(err) {
|
||
if (!err) return false
|
||
const code = String(err.code || '').toUpperCase()
|
||
if (
|
||
code === 'METHOD_NOT_FOUND' ||
|
||
code === 'UNKNOWN_METHOD' ||
|
||
code === 'UNKNOWN_RPC' ||
|
||
code === 'NOT_IMPLEMENTED' ||
|
||
code === 'UNSUPPORTED'
|
||
) return true
|
||
const msg = String(err.message || '').toLowerCase()
|
||
if (/method\s+.*\s+not\s+(found|implemented|supported)/.test(msg)) return true
|
||
if (/unknown\s+(method|rpc|handler)/.test(msg)) return true
|
||
if (/no\s+handler\s+for/.test(msg)) return true
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 判断 Gateway 关闭原因是否暗示 v3 协议 / 签名 payload 不被支持。
|
||
* 老内核(仅 v1/v2 签名 payload,minProtocol < 3)会用类似 `device signature invalid`
|
||
* 或 `protocol mismatch` 关闭,字面对小白用户毫无意义,需要替换为人话。
|
||
*/
|
||
function isProtocolIncompatReason(reason) {
|
||
return /signature\s+invalid|invalid\s+signature|protocol\s+mismatch|unsupported\s+protocol|min(imum)?\s*protocol|max(imum)?\s*protocol/i.test(reason || '')
|
||
}
|
||
|
||
/**
|
||
* 构造「Gateway 内核过旧、不支持当前握手协议」的友好提示文案。
|
||
* 直接读 feature-catalog 的 KERNEL_TARGET 常量,避免循环依赖 kernel.js。
|
||
*/
|
||
function kernelTooOldMessage() {
|
||
const recommended =
|
||
KERNEL_TARGET?.openclaw?.chinese ||
|
||
KERNEL_TARGET?.openclaw?.official ||
|
||
'2026.5.x'
|
||
return t('kernel.tooOldForProtocol', { recommended })
|
||
}
|
||
|
||
export class WsClient {
|
||
constructor() {
|
||
this._ws = null
|
||
this._url = ''
|
||
this._token = ''
|
||
this._pending = new Map()
|
||
this._eventListeners = []
|
||
this._statusListeners = []
|
||
this._readyCallbacks = []
|
||
this._reconnectAttempts = 0
|
||
this._reconnectTimer = null
|
||
this._connected = false
|
||
this._gatewayReady = false
|
||
this._handshaking = false
|
||
this._connecting = false
|
||
this._intentionalClose = false
|
||
this._snapshot = null
|
||
this._hello = null
|
||
this._sessionKey = null
|
||
this._pingTimer = null
|
||
this._challengeTimer = null
|
||
this._wsId = 0
|
||
this._autoPairAttempts = 0
|
||
this._authRetryCount = 0
|
||
this._password = ''
|
||
this._serverVersion = null
|
||
|
||
// 增强状态追踪
|
||
this._lastConnectedAt = null
|
||
this._lastMessageAt = null
|
||
this._pendingReconnect = false
|
||
this._missedHeartbeats = 0
|
||
this._heartbeatTimer = null
|
||
this._reconnectState = 'idle' // idle | attempting | scheduled
|
||
|
||
// 消息缓存
|
||
this._messageCache = new Map()
|
||
this._cacheSize = MESSAGE_CACHE_SIZE
|
||
this._seenMessageIds = new Set()
|
||
|
||
// 跨内核兼容:当前 Gateway 已确认不支持的 RPC method 名集合
|
||
// 由 requestCompat 在收到 method-not-found 类错误时填充,连接断开时清空
|
||
this._unsupportedMethods = new Set()
|
||
}
|
||
|
||
get connected() { return this._connected }
|
||
get connecting() { return this._connecting }
|
||
get gatewayReady() { return this._gatewayReady }
|
||
get snapshot() { return this._snapshot }
|
||
get hello() { return this._hello }
|
||
get sessionKey() { return this._sessionKey }
|
||
get serverVersion() { return this._serverVersion }
|
||
/**
|
||
* 当前 Gateway 与 ClawPanel 协商出的握手协议版本 (3 或 4)。
|
||
*
|
||
* 注意:这里的 "协议版本" 指 Gateway WebSocket 握手帧协议 (kernel ws frame protocol),
|
||
* 不要与设备签名 payload 的前缀 `v3|deviceId|...` 混淆,后者是 device-signature
|
||
* payload 字符串格式版本,两者完全独立。
|
||
*
|
||
* 取值优先级:
|
||
* 1. hello payload 中显式回传的 protocol / protocolVersion / negotiatedProtocol 字段
|
||
* 2. 按 serverVersion 推断:OpenClaw 内核 >= 2026.5.12 → v4,否则 v3
|
||
* (ClawPanel 客户端永远声明 minProtocol=3, maxProtocol=4,所以协商只会是 3 或 4)
|
||
*
|
||
* 未握手时返回 null。
|
||
*/
|
||
get negotiatedProtocol() {
|
||
if (!this._hello) return null
|
||
const explicit = this._hello.protocol ?? this._hello.protocolVersion ?? this._hello.negotiatedProtocol
|
||
if (Number.isFinite(explicit)) return explicit
|
||
const ver = this._serverVersion
|
||
if (!ver) return null
|
||
const base = String(ver).replace(/-.*$/, '')
|
||
const parts = base.split('.').map(n => Number(n))
|
||
if (parts.length >= 3 && parts.every(Number.isFinite)) {
|
||
const [y, mo, d] = parts
|
||
if (y > 2026 || (y === 2026 && (mo > 5 || (mo === 5 && d >= 12)))) return 4
|
||
}
|
||
return 3
|
||
}
|
||
get reconnectState() { return this._reconnectState }
|
||
get reconnectAttempts() { return this._reconnectAttempts }
|
||
get lastConnectedAt() { return this._lastConnectedAt }
|
||
get lastMessageAt() { return this._lastMessageAt }
|
||
|
||
/**
|
||
* 获取连接详细信息,供前端使用
|
||
*/
|
||
getConnectionInfo() {
|
||
return {
|
||
connected: this._connected,
|
||
gatewayReady: this._gatewayReady,
|
||
lastConnectedAt: this._lastConnectedAt,
|
||
lastMessageAt: this._lastMessageAt,
|
||
reconnectAttempts: this._reconnectAttempts,
|
||
reconnectState: this._reconnectState,
|
||
serverVersion: this._serverVersion,
|
||
negotiatedProtocol: this.negotiatedProtocol,
|
||
missedHeartbeats: this._missedHeartbeats,
|
||
pendingReconnect: this._pendingReconnect,
|
||
}
|
||
}
|
||
|
||
onStatusChange(fn) {
|
||
this._statusListeners.push(fn)
|
||
return () => { this._statusListeners = this._statusListeners.filter(cb => cb !== fn) }
|
||
}
|
||
|
||
onReady(fn) {
|
||
this._readyCallbacks.push(fn)
|
||
return () => { this._readyCallbacks = this._readyCallbacks.filter(cb => cb !== fn) }
|
||
}
|
||
|
||
connect(host, token, opts = {}) {
|
||
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)}`
|
||
if (this._connecting || this._handshaking || this._gatewayReady) {
|
||
if (this._url === nextUrl) return
|
||
}
|
||
if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) return
|
||
this._url = nextUrl
|
||
this._lastConnectedAt = Date.now()
|
||
this._doConnect()
|
||
}
|
||
|
||
disconnect() {
|
||
this._intentionalClose = true
|
||
this._stopPing()
|
||
this._stopHeartbeat()
|
||
this._clearReconnectTimer()
|
||
this._clearChallengeTimer()
|
||
this._flushPending()
|
||
this._closeWs()
|
||
this._setConnected(false)
|
||
this._gatewayReady = false
|
||
this._handshaking = false
|
||
this._reconnectState = 'idle'
|
||
this._pendingReconnect = false
|
||
}
|
||
|
||
reconnect() {
|
||
if (!this._url) return
|
||
this._intentionalClose = false
|
||
this._reconnectAttempts = 0
|
||
this._autoPairAttempts = 0
|
||
this._authRetryCount = 0
|
||
this._missedHeartbeats = 0
|
||
this._stopPing()
|
||
this._stopHeartbeat()
|
||
this._clearReconnectTimer()
|
||
this._clearChallengeTimer()
|
||
this._flushPending()
|
||
this._closeWs()
|
||
this._doConnect()
|
||
}
|
||
|
||
_doConnect() {
|
||
this._connecting = true
|
||
this._closeWs()
|
||
this._gatewayReady = false
|
||
this._handshaking = false
|
||
this._reconnectState = 'attempting'
|
||
this._setConnected(false, 'connecting')
|
||
const wsId = ++this._wsId
|
||
let ws
|
||
try { ws = new WebSocket(this._url) } catch { this._scheduleReconnect(); return }
|
||
this._ws = ws
|
||
|
||
ws.onopen = () => {
|
||
if (wsId !== this._wsId) return
|
||
this._connecting = false
|
||
this._reconnectAttempts = 0
|
||
this._missedHeartbeats = 0
|
||
this._lastConnectedAt = Date.now()
|
||
this._lastMessageAt = Date.now()
|
||
this._startHeartbeat()
|
||
this._setConnected(true)
|
||
this._startPing()
|
||
// 等 Gateway 发 connect.challenge,超时则主动发
|
||
this._challengeTimer = setTimeout(() => {
|
||
if (!this._handshaking && !this._gatewayReady) {
|
||
console.log('[ws] 未收到 challenge,主动发 connect')
|
||
this._sendConnectFrame('')
|
||
}
|
||
}, CHALLENGE_TIMEOUT)
|
||
}
|
||
|
||
ws.onmessage = (evt) => {
|
||
if (wsId !== this._wsId) return
|
||
let msg
|
||
try { msg = JSON.parse(evt.data) } catch { return }
|
||
this._handleMessage(msg)
|
||
}
|
||
|
||
ws.onclose = (e) => {
|
||
if (wsId !== this._wsId) return
|
||
this._ws = null
|
||
this._connecting = false
|
||
this._clearChallengeTimer()
|
||
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 (/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
|
||
}
|
||
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
|
||
}
|
||
// Gateway 内核过旧:不支持 ClawPanel 0.15+ 使用的 v3 签名 payload / minProtocol=3
|
||
// 关闭 reason 通常是 'device signature invalid' / 'protocol mismatch'
|
||
if (isProtocolIncompatReason(reason)) {
|
||
console.warn('[ws] Gateway 协议/签名不兼容(内核过旧):', e.reason)
|
||
this._intentionalClose = true
|
||
this._flushPending()
|
||
this._setConnected(false, 'error', kernelTooOldMessage())
|
||
return
|
||
}
|
||
// 其他 1008(如 invalid role)→ 显示错误
|
||
console.warn('[ws] 收到 1008 关闭:', e.reason)
|
||
this._setConnected(false, 'error', e.reason || '连接被 Gateway 拒绝')
|
||
return
|
||
}
|
||
|
||
// ── 其他关闭码 → 普通断线重连 ──
|
||
this._setConnected(false)
|
||
this._gatewayReady = false
|
||
this._handshaking = false
|
||
this._stopPing()
|
||
this._flushPending()
|
||
if (!this._intentionalClose) this._scheduleReconnect()
|
||
}
|
||
|
||
ws.onerror = (err) => {
|
||
console.error('[ws] WebSocket 错误:', err)
|
||
}
|
||
}
|
||
|
||
_handleMessage(msg) {
|
||
// 更新最后消息时间(用于心跳检测)
|
||
this._lastMessageAt = Date.now()
|
||
this._missedHeartbeats = 0
|
||
|
||
// 握手阶段:connect.challenge
|
||
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
||
console.log('[ws] 收到 connect.challenge')
|
||
this._clearChallengeTimer()
|
||
const nonce = msg.payload?.nonce || ''
|
||
this._sendConnectFrame(nonce)
|
||
return
|
||
}
|
||
|
||
// 握手响应:connect 的 res
|
||
if (msg.type === 'res' && msg.id?.startsWith('connect-')) {
|
||
this._clearChallengeTimer()
|
||
this._handshaking = false
|
||
if (!msg.ok || msg.error) {
|
||
const errMsg = msg.error?.message || 'Gateway 握手失败'
|
||
const errCode = msg.error?.code
|
||
const details = msg.error?.details || {}
|
||
const detailCode = details.code || ''
|
||
const nextStep = details.recommendedNextStep || ''
|
||
console.error('[ws] connect 失败:', { errCode, detailCode, nextStep, errMsg })
|
||
|
||
// 按 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
|
||
}
|
||
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
|
||
}
|
||
}
|
||
|
||
// 上游 5.4+:Gateway 启动期间收到的 connect 会返回 retryable UNAVAILABLE,
|
||
// details.reason='startup-sidecars',附带 retryAfterMs。
|
||
// 这种错误对小白用户应该完全无感——按建议时间静默重试,不显示红色错误。
|
||
const isStartupSidecars =
|
||
/^UNAVAILABLE$/i.test(errCode || '') &&
|
||
(
|
||
details.reason === 'startup-sidecars' ||
|
||
details.code === 'startup-sidecars' ||
|
||
detailCode === 'STARTUP_SIDECARS' ||
|
||
/startup[-_]?sidecars/i.test(errMsg)
|
||
)
|
||
if (!handled && isStartupSidecars) {
|
||
const retryMs = Math.max(500, Math.min(details.retryAfterMs || msg.error?.retryAfterMs || 1500, 10000))
|
||
console.log(`[ws] Gateway 启动中 (sidecars 加载),${retryMs}ms 后重试`)
|
||
// 标 reconnecting 而非 error,UI 上显示的是 spinner 不是红色叉
|
||
this._setConnected(false, 'reconnecting', 'Gateway 启动中...')
|
||
setTimeout(() => { if (!this._intentionalClose) this._doConnect() }, retryMs)
|
||
return
|
||
}
|
||
|
||
// Gateway 内核过旧:自动配对后仍签名失败,或上游已明确返回协议不兼容
|
||
// → 大概率是老 Gateway 不识别 v3 payload / minProtocol=3,给「内核过旧」提示
|
||
if (!handled && (
|
||
detailCode === 'DEVICE_AUTH_SIGNATURE_INVALID' ||
|
||
detailCode === 'DEVICE_AUTH_INVALID' ||
|
||
detailCode === 'PROTOCOL_VERSION_MISMATCH' ||
|
||
detailCode === 'UNSUPPORTED_PROTOCOL'
|
||
)) {
|
||
const friendly = kernelTooOldMessage()
|
||
this._intentionalClose = true
|
||
this._flushPending()
|
||
this._setConnected(false, 'error', friendly)
|
||
this._readyCallbacks.forEach(fn => {
|
||
try { fn(null, null, { error: true, message: friendly, detailCode, nextStep }) } catch {}
|
||
})
|
||
return
|
||
}
|
||
|
||
// 使用 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: displayMsg, detailCode, nextStep }) } catch {}
|
||
})
|
||
return
|
||
}
|
||
// 握手成功,提取 snapshot
|
||
this._handleConnectSuccess(msg.payload)
|
||
return
|
||
}
|
||
|
||
// RPC 响应
|
||
if (msg.type === 'res') {
|
||
const cb = this._pending.get(msg.id)
|
||
if (cb) {
|
||
this._pending.delete(msg.id)
|
||
clearTimeout(cb.timer)
|
||
if (msg.ok) cb.resolve(msg.payload)
|
||
else {
|
||
const err = new Error(msg.error?.message || msg.error?.code || 'request failed')
|
||
err.code = msg.error?.code || null
|
||
err.details = msg.error?.details || null
|
||
err.method = cb.method || null
|
||
cb.reject(err)
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// 事件转发
|
||
if (msg.type === 'event') {
|
||
// 消息去重检查
|
||
if (msg.id && this._seenMessageIds.has(msg.id)) {
|
||
console.log('[ws] 跳过重复消息:', msg.id)
|
||
return
|
||
}
|
||
if (msg.id) {
|
||
this._seenMessageIds.add(msg.id)
|
||
// 保持 Set 大小,防止内存泄漏
|
||
if (this._seenMessageIds.size > 1000) {
|
||
const arr = Array.from(this._seenMessageIds)
|
||
this._seenMessageIds = new Set(arr.slice(-500))
|
||
}
|
||
}
|
||
|
||
// 缓存聊天消息
|
||
if (msg.event === 'chat.message' && msg.payload?.sessionKey) {
|
||
this._cacheMessage(msg.payload.sessionKey, msg.payload)
|
||
}
|
||
|
||
this._eventListeners.forEach(fn => {
|
||
try { fn(msg) } catch (e) { console.error('[ws] handler error:', e) }
|
||
})
|
||
}
|
||
}
|
||
|
||
async _autoPairAndReconnect() {
|
||
this._autoPairAttempts++
|
||
try {
|
||
console.log('[ws] 执行自动配对(第', this._autoPairAttempts, '次)...')
|
||
const result = await api.autoPairDevice()
|
||
console.log('[ws] 配对结果:', result)
|
||
|
||
// 这里只修配对文件,不自动重启 Gateway。
|
||
// Windows 上手动启动的 Gateway 会被 restart/stop 打断,表现为“启动后一会就停止”。
|
||
// Gateway 对设备配对文件按连接读取;如遇 origin 配置变更,交由用户手动重启。
|
||
console.log('[ws] 自动配对文件已修复,跳过自动重启 Gateway')
|
||
|
||
// 修复 #160: 不调用 reconnect()(它会重置 _autoPairAttempts 导致无限循环),
|
||
// 而是直接重连一次。如果仍然失败,_autoPairAttempts 不会被重置,不会再次触发自动修复。
|
||
console.log('[ws] 配对成功,3秒后重新连接...')
|
||
setTimeout(() => {
|
||
if (!this._intentionalClose) {
|
||
this._reconnectAttempts = 0
|
||
this._closeWs()
|
||
this._doConnect()
|
||
}
|
||
}, 3000)
|
||
} catch (e) {
|
||
console.error('[ws] 自动配对失败:', e)
|
||
this._setConnected(false, 'error', `配对失败: ${e}`)
|
||
}
|
||
}
|
||
|
||
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, this._password)
|
||
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||
console.log('[ws] 发送 connect frame')
|
||
this._ws.send(JSON.stringify(frame))
|
||
}
|
||
} catch (e) {
|
||
console.error('[ws] 生成 connect frame 失败:', e)
|
||
this._handshaking = false
|
||
}
|
||
}
|
||
|
||
_handleConnectSuccess(payload) {
|
||
this._autoPairAttempts = 0
|
||
this._authRetryCount = 0
|
||
this._hello = payload || null
|
||
this._snapshot = payload?.snapshot || null
|
||
this._serverVersion = payload?.serverVersion || payload?.server?.version || null
|
||
// 新连接 → 清空 method 降级缓存(可能换了 Gateway 版本)
|
||
this.resetCompatCache()
|
||
const defaults = this._snapshot?.sessionDefaults
|
||
if (defaults?.mainSessionKey) {
|
||
this._sessionKey = defaults.mainSessionKey
|
||
} else {
|
||
const agentId = defaults?.defaultAgentId || 'main'
|
||
this._sessionKey = `agent:${agentId}:main`
|
||
}
|
||
this._gatewayReady = true
|
||
this._reconnectState = 'idle'
|
||
this._pendingReconnect = false
|
||
console.log('[ws] Gateway 就绪, sessionKey:', this._sessionKey)
|
||
this._setConnected(true, 'ready')
|
||
this._readyCallbacks.forEach(fn => {
|
||
try { fn(this._hello, this._sessionKey) } catch (e) {
|
||
console.error('[ws] ready cb error:', e)
|
||
}
|
||
})
|
||
}
|
||
|
||
_setConnected(val, status, errorMsg) {
|
||
this._connected = val
|
||
const s = status || (val ? 'connected' : 'disconnected')
|
||
this._statusListeners.forEach(fn => {
|
||
try { fn(s, errorMsg) } catch (e) { console.error('[ws] status listener error:', e) }
|
||
})
|
||
}
|
||
|
||
_closeWs() {
|
||
if (this._ws) {
|
||
const old = this._ws
|
||
this._ws = null
|
||
this._wsId++
|
||
try { old.close() } catch {}
|
||
}
|
||
}
|
||
|
||
_flushPending() {
|
||
for (const [, cb] of this._pending) {
|
||
clearTimeout(cb.timer)
|
||
cb.reject(new Error('连接已断开'))
|
||
}
|
||
this._pending.clear()
|
||
}
|
||
|
||
_clearReconnectTimer() {
|
||
if (this._reconnectTimer) {
|
||
clearTimeout(this._reconnectTimer)
|
||
this._reconnectTimer = null
|
||
}
|
||
}
|
||
|
||
_clearChallengeTimer() {
|
||
if (this._challengeTimer) {
|
||
clearTimeout(this._challengeTimer)
|
||
this._challengeTimer = null
|
||
}
|
||
}
|
||
|
||
_scheduleReconnect() {
|
||
// 超过最大重连次数,停止重连
|
||
if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||
console.warn('[ws] 已达到最大重连次数 (', MAX_RECONNECT_ATTEMPTS, '),停止自动重连')
|
||
this._reconnectState = 'idle'
|
||
this._pendingReconnect = false
|
||
this._setConnected(false, 'error', `连接失败,已停止重连。请手动刷新页面重试。`)
|
||
return
|
||
}
|
||
|
||
this._clearReconnectTimer()
|
||
// 指数退避:1s, 2s, 4s, 8s, 16s, 32s, 60s (最多 60s)
|
||
const baseDelay = 2000
|
||
const maxDelay = MAX_RECONNECT_DELAY
|
||
const exponentialDelay = Math.min(baseDelay * Math.pow(2, this._reconnectAttempts), maxDelay)
|
||
// 首次连接(Gateway 可能还未启动):使用更长的初始延迟
|
||
const delay = this._reconnectAttempts === 0
|
||
? INITIAL_RECONNECT_DELAY
|
||
: Math.round(exponentialDelay * (0.5 + Math.random())) // 50%~150% 抖动,防止同步风暴
|
||
|
||
this._reconnectAttempts++
|
||
this._reconnectState = 'scheduled'
|
||
this._pendingReconnect = true
|
||
this._setConnected(false, 'reconnecting', `重连中 (${this._reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}),${Math.round(delay/1000)}秒后...`)
|
||
console.log(`[ws] 计划重连 (${this._reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}),延迟 ${Math.round(delay/1000)}秒`)
|
||
this._reconnectTimer = setTimeout(() => {
|
||
if (!this._intentionalClose) {
|
||
this._reconnectState = 'attempting'
|
||
this._doConnect()
|
||
}
|
||
}, delay)
|
||
}
|
||
|
||
_startPing() {
|
||
this._stopPing()
|
||
this._pingTimer = setInterval(() => {
|
||
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||
try { this._ws.send('{"type":"ping"}') } catch {}
|
||
}
|
||
}, PING_INTERVAL)
|
||
}
|
||
|
||
_stopPing() {
|
||
if (this._pingTimer) {
|
||
clearInterval(this._pingTimer)
|
||
this._pingTimer = null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 心跳检测:如果超过 HEARTBEAT_TIMEOUT 没有收到任何消息,触发重连
|
||
* 这用于检测 Gateway 端崩溃或网络中断
|
||
*/
|
||
_startHeartbeat() {
|
||
this._stopHeartbeat()
|
||
this._missedHeartbeats = 0
|
||
this._heartbeatTimer = setInterval(() => {
|
||
if (!this._connected || !this._gatewayReady) return
|
||
|
||
const now = Date.now()
|
||
const timeSinceLastMessage = this._lastMessageAt ? now - this._lastMessageAt : 0
|
||
|
||
if (timeSinceLastMessage > HEARTBEAT_TIMEOUT) {
|
||
this._missedHeartbeats++
|
||
console.warn(`[ws] 心跳超时 (${Math.round(timeSinceLastMessage/1000)}秒),missedHeartbeats: ${this._missedHeartbeats}`)
|
||
// 增加容忍度:连续 3 次超时(检查间隔 30s × 3 = 约 90s)才强制重连
|
||
if (this._missedHeartbeats >= 3) {
|
||
console.error('[ws] 心跳检测失败超过3次,强制重连')
|
||
this._stopHeartbeat()
|
||
this.reconnect()
|
||
} else if (this._missedHeartbeats >= 2) {
|
||
// 2 次超时:先尝试发 ping 探测,不行再重连
|
||
console.warn('[ws] 心跳超时 2 次,发送探测 ping...')
|
||
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||
try { this._ws.send('{"type":"ping"}') } catch {}
|
||
}
|
||
}
|
||
}
|
||
}, HEARTBEAT_TIMEOUT / 3) // 每 30 秒检查一次
|
||
}
|
||
|
||
_stopHeartbeat() {
|
||
if (this._heartbeatTimer) {
|
||
clearInterval(this._heartbeatTimer)
|
||
this._heartbeatTimer = null
|
||
}
|
||
}
|
||
|
||
request(method, params = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN || !this._gatewayReady) {
|
||
if (!this._intentionalClose && (this._reconnectAttempts > 0 || !this._gatewayReady)) {
|
||
const waitTimeout = setTimeout(() => { unsub(); reject(new Error('等待重连超时')) }, 15000)
|
||
const unsub = this.onReady((hello, sessionKey, err) => {
|
||
clearTimeout(waitTimeout); unsub()
|
||
if (err?.error) { reject(new Error(err.message || 'Gateway 握手失败')); return }
|
||
this.request(method, params).then(resolve, reject)
|
||
})
|
||
return
|
||
}
|
||
return reject(new Error('WebSocket 未连接'))
|
||
}
|
||
const id = uuid()
|
||
const timer = setTimeout(() => { this._pending.delete(id); reject(new Error('请求超时')) }, REQUEST_TIMEOUT)
|
||
this._pending.set(id, { resolve, reject, timer, method })
|
||
this._ws.send(JSON.stringify({ type: 'req', id, method, params }))
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 跨内核兼容版 request:
|
||
* - 老内核不支持的 RPC 会返回 fallback(默认 null),不抛错
|
||
* - 第二次起跳过实际请求,直接返回 fallback
|
||
* - 真实失败(网络、参数错误等)仍然 throw
|
||
*
|
||
* 用法:
|
||
* const status = await wsClient.requestCompat('memory.status.deep', {})
|
||
* if (status) renderRich(status); else renderBasic()
|
||
*
|
||
* @param {string} method
|
||
* @param {object} [params]
|
||
* @param {*} [fallback=null] 老内核不支持时返回此值
|
||
* @returns {Promise<*>}
|
||
*/
|
||
async requestCompat(method, params = {}, fallback = null) {
|
||
if (this._unsupportedMethods.has(method)) {
|
||
return fallback
|
||
}
|
||
try {
|
||
return await this.request(method, params)
|
||
} catch (e) {
|
||
if (isMethodUnsupportedError(e)) {
|
||
this._unsupportedMethods.add(method)
|
||
console.warn(`[ws] RPC \`${method}\` 不被当前 Gateway 支持 (code=${e.code}), 已记入降级集合`)
|
||
return fallback
|
||
}
|
||
throw e
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 是否已确认某 method 在当前 Gateway 上不可用
|
||
*/
|
||
isMethodUnsupported(method) {
|
||
return this._unsupportedMethods.has(method)
|
||
}
|
||
|
||
/**
|
||
* 清空降级集合(在新连接成功时自动调用)
|
||
*/
|
||
resetCompatCache() {
|
||
if (this._unsupportedMethods.size > 0) {
|
||
console.log('[ws] 清空 method 降级集合', Array.from(this._unsupportedMethods))
|
||
}
|
||
this._unsupportedMethods.clear()
|
||
}
|
||
|
||
chatSend(sessionKey, message, attachments) {
|
||
const params = { sessionKey, message, deliver: false, idempotencyKey: uuid() }
|
||
if (attachments && attachments.length > 0) {
|
||
params.attachments = attachments
|
||
console.log('[ws] 发送附件:', attachments.length, '个')
|
||
console.log('[ws] 附件详情:', attachments.map(a => ({ type: a.type, mime: a.mimeType, name: a.fileName, size: a.content?.length })))
|
||
}
|
||
return this.request('chat.send', params)
|
||
}
|
||
|
||
chatHistory(sessionKey, limit = 200) {
|
||
return this.request('chat.history', { sessionKey, limit })
|
||
}
|
||
|
||
chatAbort(sessionKey, runId) {
|
||
const params = { sessionKey }
|
||
if (runId) params.runId = runId
|
||
return this.request('chat.abort', params)
|
||
}
|
||
|
||
sessionsList(limit = 50) {
|
||
return this.request('sessions.list', { limit })
|
||
}
|
||
|
||
sessionsDelete(key) {
|
||
return this.request('sessions.delete', { key })
|
||
}
|
||
|
||
sessionsReset(key) {
|
||
return this.request('sessions.reset', { key })
|
||
}
|
||
|
||
// ===== 4.9: Sessions Compaction =====
|
||
sessionsCompactionList(key) {
|
||
return this.request('sessions.compaction.list', { key })
|
||
}
|
||
|
||
sessionsCompactionGet(key, checkpointId) {
|
||
return this.request('sessions.compaction.get', { key, checkpointId })
|
||
}
|
||
|
||
sessionsCompactionBranch(key, checkpointId) {
|
||
return this.request('sessions.compaction.branch', { key, checkpointId })
|
||
}
|
||
|
||
sessionsCompactionRestore(key, checkpointId) {
|
||
return this.request('sessions.compaction.restore', { key, checkpointId })
|
||
}
|
||
|
||
// ===== 4.9: Skills Gateway RPC =====
|
||
skillsSearch(query, limit) {
|
||
return this.request('skills.search', { query, limit })
|
||
}
|
||
|
||
skillsDetail(slug) {
|
||
return this.request('skills.detail', { slug })
|
||
}
|
||
|
||
// ===== 4.9: Approval management =====
|
||
execApprovalList() {
|
||
return this.request('exec.approval.list', {})
|
||
}
|
||
|
||
execApprovalGet(id) {
|
||
return this.request('exec.approval.get', { id })
|
||
}
|
||
|
||
pluginApprovalList() {
|
||
return this.request('plugin.approval.list', {})
|
||
}
|
||
|
||
onEvent(callback) {
|
||
this._eventListeners.push(callback)
|
||
return () => { this._eventListeners = this._eventListeners.filter(fn => fn !== callback) }
|
||
}
|
||
|
||
// ==================== 消息缓存管理 ====================
|
||
|
||
/**
|
||
* 缓存消息
|
||
* @param {string} sessionKey - 会话 key
|
||
* @param {object} message - 消息对象
|
||
*/
|
||
_cacheMessage(sessionKey, message) {
|
||
if (!this._messageCache.has(sessionKey)) {
|
||
this._messageCache.set(sessionKey, [])
|
||
}
|
||
const messages = this._messageCache.get(sessionKey)
|
||
|
||
// 去重检查(基于消息 ID 或内容哈希)
|
||
const msgId = message.id || message.messageId
|
||
if (msgId && messages.some(m => (m.id || m.messageId) === msgId)) {
|
||
return
|
||
}
|
||
|
||
messages.push({
|
||
...message,
|
||
_cachedAt: Date.now(),
|
||
})
|
||
|
||
// 限制缓存大小
|
||
if (messages.length > this._cacheSize) {
|
||
messages.splice(0, messages.length - this._cacheSize)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取缓存的消息
|
||
* @param {string} sessionKey - 会话 key
|
||
* @returns {array} 缓存的消息数组
|
||
*/
|
||
_getCachedMessages(sessionKey) {
|
||
return this._messageCache.get(sessionKey) || []
|
||
}
|
||
|
||
/**
|
||
* 清除指定会话的缓存
|
||
* @param {string} sessionKey - 会话 key
|
||
*/
|
||
_clearCache(sessionKey) {
|
||
if (sessionKey) {
|
||
this._messageCache.delete(sessionKey)
|
||
} else {
|
||
this._messageCache.clear()
|
||
}
|
||
console.log('[ws] 消息缓存已清除:', sessionKey || '全部')
|
||
}
|
||
|
||
/**
|
||
* 清除消息去重记录
|
||
*/
|
||
_clearSeenMessageIds() {
|
||
this._seenMessageIds.clear()
|
||
}
|
||
|
||
/**
|
||
* 获取缓存状态信息
|
||
*/
|
||
getCacheInfo() {
|
||
const info = {}
|
||
for (const [key, messages] of this._messageCache) {
|
||
info[key] = {
|
||
count: messages.length,
|
||
oldest: messages[0]?._cachedAt,
|
||
newest: messages[messages.length - 1]?._cachedAt,
|
||
}
|
||
}
|
||
return info
|
||
}
|
||
|
||
/**
|
||
* 连接成功后自动拉取历史消息(供前端调用)
|
||
* @param {string} sessionKey - 会话 key
|
||
* @param {number} limit - 消息数量限制
|
||
*/
|
||
async fetchHistoryOnReconnect(sessionKey, limit = 200) {
|
||
if (!sessionKey || !this._gatewayReady) {
|
||
return { error: 'not ready' }
|
||
}
|
||
try {
|
||
const history = await this.chatHistory(sessionKey, limit)
|
||
// 将历史消息缓存起来
|
||
if (history?.messages) {
|
||
for (const msg of history.messages) {
|
||
this._cacheMessage(sessionKey, msg)
|
||
}
|
||
}
|
||
return { history }
|
||
} catch (e) {
|
||
console.error('[ws] 拉取历史消息失败:', e)
|
||
return { error: e.message }
|
||
}
|
||
}
|
||
}
|
||
|
||
const _g = typeof window !== 'undefined' ? window : globalThis
|
||
if (!_g.__clawpanelWsClient) _g.__clawpanelWsClient = new WsClient()
|
||
export const wsClient = _g.__clawpanelWsClient
|