@eyeclaw/eyeclaw 2.4.1 → 2.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/websocket-client.ts +226 -17
package/package.json
CHANGED
package/src/websocket-client.ts
CHANGED
|
@@ -29,14 +29,18 @@ export class EyeClawWebSocketClient {
|
|
|
29
29
|
private reconnecting = false
|
|
30
30
|
private subscribed = false
|
|
31
31
|
private pingInterval: any = null
|
|
32
|
+
private connectionHealthCheckTimer: any = null
|
|
32
33
|
private chunkSequence = 0 // 每个会话的 chunk 序号
|
|
33
34
|
private accumulatedContent = '' // 累积完整内容用于兜底
|
|
34
35
|
private lastConnectedAt = 0 // 上次连接成功时间戳
|
|
35
36
|
private deploymentDetected = false // 是否检测到部署重启
|
|
36
37
|
private wasConnected = false // 之前是否连接成功过
|
|
37
38
|
private reconnectTimer: any = null // 重连定时器引用
|
|
38
|
-
private healthCheckInterval: any = null // 健康检查定时器
|
|
39
39
|
private serverVersion = '' // 服务器版本(用于检测部署)
|
|
40
|
+
private consecutiveFailures = 0 // 连续失败次数
|
|
41
|
+
private lastCloseCode = 0 // 上次关闭码
|
|
42
|
+
private networkOnline = true // 网络在线状态
|
|
43
|
+
private networkCheckTimer: any = null // 网络检测定时器
|
|
40
44
|
|
|
41
45
|
// 🔥 ACK 机制:追踪已发送和已确认的 chunks
|
|
42
46
|
private sentChunks = 0 // 已发送的 chunks 数量
|
|
@@ -59,6 +63,9 @@ export class EyeClawWebSocketClient {
|
|
|
59
63
|
return
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
// 启动网络状态监控
|
|
67
|
+
this.startNetworkMonitoring()
|
|
68
|
+
|
|
62
69
|
const wsUrl = serverUrl.replace(/^http/, 'ws') + `/cable?sdk_token=${sdkToken}&bot_id=${botId}`
|
|
63
70
|
this.api.logger.info(`[EyeClaw] WebSocket connecting to: ${wsUrl}`)
|
|
64
71
|
|
|
@@ -69,6 +76,7 @@ export class EyeClawWebSocketClient {
|
|
|
69
76
|
this.ws.onopen = () => {
|
|
70
77
|
this.api.logger.info('[EyeClaw] WebSocket connected')
|
|
71
78
|
this.reconnectAttempts = 0
|
|
79
|
+
this.consecutiveFailures = 0 // 重置连续失败计数
|
|
72
80
|
this.lastConnectedAt = Date.now()
|
|
73
81
|
this.wasConnected = true
|
|
74
82
|
|
|
@@ -85,19 +93,60 @@ export class EyeClawWebSocketClient {
|
|
|
85
93
|
this.handleMessage(event.data)
|
|
86
94
|
}
|
|
87
95
|
|
|
88
|
-
this.ws.onerror = (error) => {
|
|
89
|
-
|
|
96
|
+
this.ws.onerror = (error: any) => {
|
|
97
|
+
// 尝试提取更详细的错误信息
|
|
98
|
+
let errorMsg = 'Unknown WebSocket error'
|
|
99
|
+
|
|
100
|
+
// 浏览器环境
|
|
101
|
+
if (error && typeof error === 'object' && 'message' in error) {
|
|
102
|
+
errorMsg = (error as any).message || 'ErrorEvent without message'
|
|
103
|
+
if ((error as any).error) {
|
|
104
|
+
errorMsg += ` | Error: ${(error as any).error}`
|
|
105
|
+
}
|
|
106
|
+
} else if (error) {
|
|
107
|
+
// Node.js 环境或其他错误
|
|
108
|
+
errorMsg = String(error)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.api.logger.error(`[EyeClaw] WebSocket error: ${errorMsg}`)
|
|
112
|
+
// 网络错误也应该触发重连,而不是等待 onclose
|
|
113
|
+
// 这样可以更快地从网络中断中恢复
|
|
90
114
|
}
|
|
91
115
|
|
|
92
116
|
this.ws.onclose = () => {
|
|
93
|
-
this.
|
|
117
|
+
const closeCode = this.ws?.closeCode || 0
|
|
118
|
+
const wasClean = closeCode === 1000 || closeCode === 1001
|
|
119
|
+
|
|
120
|
+
this.api.logger.warn(`[EyeClaw] WebSocket closed (code: ${closeCode}, clean: ${wasClean})`)
|
|
121
|
+
|
|
94
122
|
this.subscribed = false
|
|
95
123
|
this.stopPing()
|
|
124
|
+
this.lastCloseCode = closeCode
|
|
125
|
+
|
|
126
|
+
// 统计连续失败
|
|
127
|
+
this.consecutiveFailures++
|
|
96
128
|
|
|
97
|
-
//
|
|
129
|
+
// 检测是否是部署导致的断连(之前已成功连接过,且断开时间 > 5 秒)
|
|
130
|
+
// 或者关闭码为 1000/1001(正常关闭)但之前已连接
|
|
98
131
|
if (this.wasConnected && Date.now() - this.lastConnectedAt > 5000) {
|
|
99
|
-
|
|
100
|
-
|
|
132
|
+
// 正常关闭或部署重启
|
|
133
|
+
if (wasClean) {
|
|
134
|
+
this.deploymentDetected = true
|
|
135
|
+
this.api.logger.info('[EyeClaw] 🔄 Deployment or clean close detected, will reconnect immediately')
|
|
136
|
+
} else {
|
|
137
|
+
// 非正常关闭但之前已连接,很可能是服务器重启
|
|
138
|
+
this.deploymentDetected = true
|
|
139
|
+
this.api.logger.info('[EyeClaw] 🔄 Server restart detected (unclean close), will reconnect immediately')
|
|
140
|
+
}
|
|
141
|
+
} else if (!this.wasConnected && !wasClean) {
|
|
142
|
+
// 首次连接失败,增加重试延迟
|
|
143
|
+
this.api.logger.warn('[EyeClaw] Initial connection failed, increasing delay...')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 网络断开也会导致 onclose 被调用(code 0)
|
|
147
|
+
// 检测是否是网络断开(code 0 且非部署场景)
|
|
148
|
+
if (closeCode === 0 && !this.deploymentDetected) {
|
|
149
|
+
this.api.logger.info('[EyeClaw] 🌐 Network disconnection detected, will retry with backoff')
|
|
101
150
|
}
|
|
102
151
|
|
|
103
152
|
this.scheduleReconnect()
|
|
@@ -105,15 +154,93 @@ export class EyeClawWebSocketClient {
|
|
|
105
154
|
|
|
106
155
|
} catch (error) {
|
|
107
156
|
this.api.logger.error(`[EyeClaw] WebSocket connection failed: ${error}`)
|
|
157
|
+
// 连接失败也应该触发重连
|
|
108
158
|
this.scheduleReconnect()
|
|
109
159
|
}
|
|
110
160
|
}
|
|
111
161
|
|
|
162
|
+
/**
|
|
163
|
+
* 启动网络状态监控
|
|
164
|
+
* 定期检测网络是否恢复,以便在网络中断后自动重连
|
|
165
|
+
*/
|
|
166
|
+
private startNetworkMonitoring() {
|
|
167
|
+
// 防止重复启动
|
|
168
|
+
if (this.networkCheckTimer) {
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 每 5 秒检测一次网络状态
|
|
173
|
+
this.networkCheckTimer = setInterval(() => {
|
|
174
|
+
this.checkNetworkAndReconnectIfNeeded()
|
|
175
|
+
}, 5000)
|
|
176
|
+
|
|
177
|
+
// 尝试立即检测一次
|
|
178
|
+
this.checkNetworkAndReconnectIfNeeded()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 检测网络状态并在需要时触发重连
|
|
183
|
+
*/
|
|
184
|
+
private async checkNetworkAndReconnectIfNeeded() {
|
|
185
|
+
// 如果已经在重连中或者 WebSocket 已经连接,跳过
|
|
186
|
+
if (this.reconnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
// 尝试一个简单的 HTTP 请求来检测网络是否可用
|
|
192
|
+
const serverUrl = this.config.serverUrl.replace(/^ws/, 'http').replace(/^wss/, 'https')
|
|
193
|
+
const controller = new AbortController()
|
|
194
|
+
const timeoutId = setTimeout(() => controller.abort(), 3000)
|
|
195
|
+
|
|
196
|
+
const response = await fetch(`${serverUrl}/api/v1/health`, {
|
|
197
|
+
method: 'GET',
|
|
198
|
+
signal: controller.signal,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
clearTimeout(timeoutId)
|
|
202
|
+
|
|
203
|
+
const wasOnline = this.networkOnline
|
|
204
|
+
this.networkOnline = response.ok
|
|
205
|
+
|
|
206
|
+
if (!wasOnline && this.networkOnline) {
|
|
207
|
+
// 网络从离线变为在线,触发重连
|
|
208
|
+
this.api.logger.info('[EyeClaw] 🌐 Network restored, triggering immediate reconnect')
|
|
209
|
+
this.deploymentDetected = true // 使用快速重连模式
|
|
210
|
+
this.scheduleReconnect()
|
|
211
|
+
} else if (this.networkOnline && !this.wasConnected) {
|
|
212
|
+
// 网络在线但未连接过,触发连接
|
|
213
|
+
this.api.logger.info('[EyeClaw] 🌐 Network available, connecting...')
|
|
214
|
+
this.scheduleReconnect()
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
// 网络请求失败,说明网络不可用
|
|
218
|
+
const wasOnline = this.networkOnline
|
|
219
|
+
this.networkOnline = false
|
|
220
|
+
|
|
221
|
+
if (wasOnline) {
|
|
222
|
+
this.api.logger.warn('[EyeClaw] 🌐 Network became unavailable')
|
|
223
|
+
}
|
|
224
|
+
// 网络不可用时,不触发重连,等待下次检测
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 停止网络状态监控
|
|
230
|
+
*/
|
|
231
|
+
private stopNetworkMonitoring() {
|
|
232
|
+
if (this.networkCheckTimer) {
|
|
233
|
+
clearInterval(this.networkCheckTimer)
|
|
234
|
+
this.networkCheckTimer = null
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
112
238
|
/**
|
|
113
239
|
* 停止 WebSocket 连接
|
|
114
240
|
*/
|
|
115
241
|
stop() {
|
|
116
242
|
this.stopPing()
|
|
243
|
+
this.stopNetworkMonitoring() // 停止网络监控
|
|
117
244
|
this.reconnecting = false
|
|
118
245
|
this.resetReconnectDelay()
|
|
119
246
|
|
|
@@ -122,10 +249,7 @@ export class EyeClawWebSocketClient {
|
|
|
122
249
|
clearTimeout(this.reconnectTimer)
|
|
123
250
|
this.reconnectTimer = null
|
|
124
251
|
}
|
|
125
|
-
|
|
126
|
-
clearInterval(this.healthCheckInterval)
|
|
127
|
-
this.healthCheckInterval = null
|
|
128
|
-
}
|
|
252
|
+
// 注意:不再需要清理 healthCheckInterval,因为已合并到 connectionHealthCheckTimer
|
|
129
253
|
|
|
130
254
|
if (this.ws) {
|
|
131
255
|
this.ws.close()
|
|
@@ -141,19 +265,30 @@ export class EyeClawWebSocketClient {
|
|
|
141
265
|
private resetReconnectDelay() {
|
|
142
266
|
this.currentReconnectDelay = this.baseReconnectDelay
|
|
143
267
|
this.reconnectAttempts = 0
|
|
268
|
+
// 注意:不重置 consecutiveFailures,因为这是跨会话的统计
|
|
144
269
|
}
|
|
145
270
|
|
|
146
271
|
/**
|
|
147
|
-
* 计算下一次重连延迟(指数退避 +
|
|
272
|
+
* 计算下一次重连延迟(指数退避 + 随机抖动 + 智能调整)
|
|
148
273
|
*/
|
|
149
274
|
private calculateReconnectDelay(): number {
|
|
150
|
-
//
|
|
151
|
-
|
|
275
|
+
// 基础延迟:1s, 2s, 4s, 8s, 16s, 30s (cap)
|
|
276
|
+
let delay = Math.min(
|
|
152
277
|
this.currentReconnectDelay * 2,
|
|
153
278
|
this.maxReconnectDelay
|
|
154
279
|
)
|
|
155
280
|
this.currentReconnectDelay = delay
|
|
156
281
|
|
|
282
|
+
// 如果连续失败多次,增加额外延迟
|
|
283
|
+
if (this.consecutiveFailures > 3) {
|
|
284
|
+
delay = Math.min(delay * 1.5, this.maxReconnectDelay)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 如果上次关闭码不是正常关闭(1000/1001),增加延迟
|
|
288
|
+
if (this.lastCloseCode !== 0 && this.lastCloseCode !== 1000 && this.lastCloseCode !== 1001) {
|
|
289
|
+
delay = Math.min(delay * 1.5, this.maxReconnectDelay)
|
|
290
|
+
}
|
|
291
|
+
|
|
157
292
|
// 添加随机抖动 (±25%)
|
|
158
293
|
const jitter = delay * 0.25 * (Math.random() * 2 - 1)
|
|
159
294
|
return Math.floor(delay + jitter)
|
|
@@ -533,8 +668,12 @@ export class EyeClawWebSocketClient {
|
|
|
533
668
|
|
|
534
669
|
/**
|
|
535
670
|
* 启动心跳
|
|
671
|
+
* 心跳间隔设置为 30 秒,比负载均衡器的超时时间短
|
|
536
672
|
*/
|
|
537
673
|
private startPing() {
|
|
674
|
+
this.stopPing() // 先清理旧的心跳定时器
|
|
675
|
+
|
|
676
|
+
// 主心跳:每 30 秒发送一次(比负载均衡器超时短)
|
|
538
677
|
this.pingInterval = setInterval(() => {
|
|
539
678
|
// 调用 Rails BotChannel 的 ping 方法(使用 ActionCable 标准协议)
|
|
540
679
|
const channelIdentifier = JSON.stringify({
|
|
@@ -550,7 +689,58 @@ export class EyeClawWebSocketClient {
|
|
|
550
689
|
timestamp: new Date().toISOString(),
|
|
551
690
|
}),
|
|
552
691
|
})
|
|
553
|
-
|
|
692
|
+
|
|
693
|
+
this.api.logger?.debug?.('[EyeClaw] 🔔 Heartbeat sent')
|
|
694
|
+
}, 30000) // 30 秒心跳 - 适合负载均衡器
|
|
695
|
+
|
|
696
|
+
// 连接健康检查:每 10 秒检查一次 WebSocket 状态
|
|
697
|
+
this.connectionHealthCheckTimer = setInterval(() => {
|
|
698
|
+
this.checkConnectionHealth()
|
|
699
|
+
}, 10000)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* 检查 WebSocket 连接健康状态
|
|
704
|
+
* 如果连接异常,自动触发重连
|
|
705
|
+
*/
|
|
706
|
+
private checkConnectionHealth() {
|
|
707
|
+
if (!this.ws) {
|
|
708
|
+
this.api.logger?.warn?.('[EyeClaw] ⚠️ No WebSocket instance')
|
|
709
|
+
this.scheduleReconnect()
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const state = this.ws.readyState
|
|
714
|
+
|
|
715
|
+
if (state === WebSocket.CLOSED || state === WebSocket.CLOSING) {
|
|
716
|
+
this.api.logger?.warn?.(`[EyeClaw] ⚠️ WebSocket state: ${state} (${this.getStateName(state)}), scheduling reconnect`)
|
|
717
|
+
this.scheduleReconnect()
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// WebSocket.CONNECTING 状态可能持续太久,也需要处理
|
|
722
|
+
if (state === WebSocket.CONNECTING) {
|
|
723
|
+
this.api.logger?.warn?.('[EyeClaw] ⚠️ WebSocket stuck in CONNECTING state for too long')
|
|
724
|
+
// 不立即重连,等待 onclose 触发
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// CONNECTED 状态正常
|
|
728
|
+
if (state === WebSocket.OPEN) {
|
|
729
|
+
this.api.logger?.debug?.('[EyeClaw] ✅ WebSocket connection healthy')
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* 获取 WebSocket 状态名称
|
|
735
|
+
*/
|
|
736
|
+
private getStateName(state: number): string {
|
|
737
|
+
switch (state) {
|
|
738
|
+
case WebSocket.CONNECTING: return 'CONNECTING'
|
|
739
|
+
case WebSocket.OPEN: return 'OPEN'
|
|
740
|
+
case WebSocket.CLOSING: return 'CLOSING'
|
|
741
|
+
case WebSocket.CLOSED: return 'CLOSED'
|
|
742
|
+
default: return 'UNKNOWN'
|
|
743
|
+
}
|
|
554
744
|
}
|
|
555
745
|
|
|
556
746
|
/**
|
|
@@ -561,6 +751,10 @@ export class EyeClawWebSocketClient {
|
|
|
561
751
|
clearInterval(this.pingInterval)
|
|
562
752
|
this.pingInterval = null
|
|
563
753
|
}
|
|
754
|
+
if (this.connectionHealthCheckTimer) {
|
|
755
|
+
clearInterval(this.connectionHealthCheckTimer)
|
|
756
|
+
this.connectionHealthCheckTimer = null
|
|
757
|
+
}
|
|
564
758
|
}
|
|
565
759
|
|
|
566
760
|
/**
|
|
@@ -590,9 +784,9 @@ export class EyeClawWebSocketClient {
|
|
|
590
784
|
return
|
|
591
785
|
}
|
|
592
786
|
|
|
593
|
-
// 🚀
|
|
787
|
+
// 🚀 部署恢复场景或网络恢复:立即重连(无延迟)
|
|
594
788
|
if (this.deploymentDetected) {
|
|
595
|
-
this.api.logger.info('[EyeClaw] ⚡ Deployment recovery
|
|
789
|
+
this.api.logger.info('[EyeClaw] ⚡ Deployment recovery or network restored: immediate reconnect')
|
|
596
790
|
this.reconnectTimer = setTimeout(() => {
|
|
597
791
|
this.reconnecting = false
|
|
598
792
|
this.start()
|
|
@@ -601,6 +795,21 @@ export class EyeClawWebSocketClient {
|
|
|
601
795
|
return
|
|
602
796
|
}
|
|
603
797
|
|
|
798
|
+
// 检测是否是网络断开导致的失败(code 0)
|
|
799
|
+
// 如果是网络断开,不要使用指数退避,而是使用固定间隔重试
|
|
800
|
+
if (this.lastCloseCode === 0) {
|
|
801
|
+
// 网络断开场景:每 3 秒尝试一次(有网络后能快速恢复)
|
|
802
|
+
const networkRetryDelay = 3000
|
|
803
|
+
this.reconnectAttempts++
|
|
804
|
+
this.api.logger.info(`[EyeClaw] 🌐 Network disconnection scenario: retrying in ${networkRetryDelay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
|
805
|
+
|
|
806
|
+
this.reconnectTimer = setTimeout(() => {
|
|
807
|
+
this.reconnecting = false
|
|
808
|
+
this.performHealthCheckAndReconnect()
|
|
809
|
+
}, networkRetryDelay)
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
|
|
604
813
|
const delay = this.calculateReconnectDelay()
|
|
605
814
|
this.reconnectAttempts++
|
|
606
815
|
this.api.logger.info(`[EyeClaw] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|