@eyeclaw/eyeclaw 2.4.0 → 2.4.1
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 +118 -10
package/package.json
CHANGED
package/src/websocket-client.ts
CHANGED
|
@@ -31,6 +31,12 @@ export class EyeClawWebSocketClient {
|
|
|
31
31
|
private pingInterval: any = null
|
|
32
32
|
private chunkSequence = 0 // 每个会话的 chunk 序号
|
|
33
33
|
private accumulatedContent = '' // 累积完整内容用于兜底
|
|
34
|
+
private lastConnectedAt = 0 // 上次连接成功时间戳
|
|
35
|
+
private deploymentDetected = false // 是否检测到部署重启
|
|
36
|
+
private wasConnected = false // 之前是否连接成功过
|
|
37
|
+
private reconnectTimer: any = null // 重连定时器引用
|
|
38
|
+
private healthCheckInterval: any = null // 健康检查定时器
|
|
39
|
+
private serverVersion = '' // 服务器版本(用于检测部署)
|
|
34
40
|
|
|
35
41
|
// 🔥 ACK 机制:追踪已发送和已确认的 chunks
|
|
36
42
|
private sentChunks = 0 // 已发送的 chunks 数量
|
|
@@ -63,6 +69,16 @@ export class EyeClawWebSocketClient {
|
|
|
63
69
|
this.ws.onopen = () => {
|
|
64
70
|
this.api.logger.info('[EyeClaw] WebSocket connected')
|
|
65
71
|
this.reconnectAttempts = 0
|
|
72
|
+
this.lastConnectedAt = Date.now()
|
|
73
|
+
this.wasConnected = true
|
|
74
|
+
|
|
75
|
+
// 如果检测到部署重启(之前已连接过),立即触发一次快速心跳
|
|
76
|
+
if (this.deploymentDetected) {
|
|
77
|
+
this.api.logger.info('[EyeClaw] 🚀 Deployment recovery detected, sending immediate ping')
|
|
78
|
+
this.deploymentDetected = false
|
|
79
|
+
// 延迟 500ms 确保订阅完成后再发心跳
|
|
80
|
+
setTimeout(() => this.sendPing(), 500)
|
|
81
|
+
}
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
this.ws.onmessage = (event) => {
|
|
@@ -77,6 +93,13 @@ export class EyeClawWebSocketClient {
|
|
|
77
93
|
this.api.logger.warn('[EyeClaw] WebSocket disconnected')
|
|
78
94
|
this.subscribed = false
|
|
79
95
|
this.stopPing()
|
|
96
|
+
|
|
97
|
+
// 检测是否是部署导致的断连(之前已成功连接过)
|
|
98
|
+
if (this.wasConnected && Date.now() - this.lastConnectedAt > 5000) {
|
|
99
|
+
this.deploymentDetected = true
|
|
100
|
+
this.api.logger.info('[EyeClaw] 🔄 Deployment detected, will reconnect immediately')
|
|
101
|
+
}
|
|
102
|
+
|
|
80
103
|
this.scheduleReconnect()
|
|
81
104
|
}
|
|
82
105
|
|
|
@@ -93,11 +116,23 @@ export class EyeClawWebSocketClient {
|
|
|
93
116
|
this.stopPing()
|
|
94
117
|
this.reconnecting = false
|
|
95
118
|
this.resetReconnectDelay()
|
|
119
|
+
|
|
120
|
+
// 清理所有定时器
|
|
121
|
+
if (this.reconnectTimer) {
|
|
122
|
+
clearTimeout(this.reconnectTimer)
|
|
123
|
+
this.reconnectTimer = null
|
|
124
|
+
}
|
|
125
|
+
if (this.healthCheckInterval) {
|
|
126
|
+
clearInterval(this.healthCheckInterval)
|
|
127
|
+
this.healthCheckInterval = null
|
|
128
|
+
}
|
|
129
|
+
|
|
96
130
|
if (this.ws) {
|
|
97
131
|
this.ws.close()
|
|
98
132
|
this.ws = null
|
|
99
133
|
}
|
|
100
134
|
this.subscribed = false
|
|
135
|
+
this.wasConnected = false
|
|
101
136
|
}
|
|
102
137
|
|
|
103
138
|
/**
|
|
@@ -140,13 +175,13 @@ export class EyeClawWebSocketClient {
|
|
|
140
175
|
|
|
141
176
|
// Ping/pong (WebSocket 协议级别的 ping 由浏览器自动响应,无需手动处理)
|
|
142
177
|
if (message.type === 'ping') {
|
|
143
|
-
this.api.logger
|
|
178
|
+
this.api.logger?.debug?.('[EyeClaw] Received protocol-level ping (auto-handled by WebSocket)')
|
|
144
179
|
return
|
|
145
180
|
}
|
|
146
181
|
|
|
147
182
|
// 处理 Rails BotChannel 的 pong 响应
|
|
148
183
|
if (message.type === 'pong') {
|
|
149
|
-
this.api.logger
|
|
184
|
+
this.api.logger?.debug?.('[EyeClaw] Received pong from server')
|
|
150
185
|
return
|
|
151
186
|
}
|
|
152
187
|
|
|
@@ -172,7 +207,7 @@ export class EyeClawWebSocketClient {
|
|
|
172
207
|
if (payload.type === 'chunk_received') {
|
|
173
208
|
const sequence = payload.sequence
|
|
174
209
|
this.ackedChunks.add(sequence)
|
|
175
|
-
this.api.logger
|
|
210
|
+
this.api.logger?.debug?.(`[EyeClaw] ✅ Received ACK for chunk #${sequence}, total acked: ${this.ackedChunks.size}/${this.sentChunks}`)
|
|
176
211
|
return
|
|
177
212
|
}
|
|
178
213
|
|
|
@@ -529,23 +564,40 @@ export class EyeClawWebSocketClient {
|
|
|
529
564
|
}
|
|
530
565
|
|
|
531
566
|
/**
|
|
532
|
-
*
|
|
567
|
+
* 计划重连(带指数退避、部署感知加速和健康检查)
|
|
533
568
|
*/
|
|
534
569
|
private scheduleReconnect() {
|
|
535
|
-
// 防止重复调度
|
|
570
|
+
// 防止重复调度 - 先清理旧定时器
|
|
571
|
+
if (this.reconnectTimer) {
|
|
572
|
+
clearTimeout(this.reconnectTimer)
|
|
573
|
+
this.reconnectTimer = null
|
|
574
|
+
}
|
|
575
|
+
|
|
536
576
|
if (this.reconnecting) {
|
|
537
577
|
return
|
|
538
578
|
}
|
|
539
579
|
this.reconnecting = true
|
|
540
580
|
|
|
581
|
+
// 如果超过最大重试次数,继续重试
|
|
541
582
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
542
583
|
this.api.logger.error('[EyeClaw] Max reconnect attempts reached, will retry later...')
|
|
543
|
-
// 不放弃,继续重试(每
|
|
544
|
-
setTimeout(() => {
|
|
584
|
+
// 不放弃,继续重试(每 30 秒检查一次)
|
|
585
|
+
this.reconnectTimer = setTimeout(() => {
|
|
545
586
|
this.reconnecting = false
|
|
546
587
|
this.resetReconnectDelay()
|
|
547
588
|
this.scheduleReconnect()
|
|
548
|
-
},
|
|
589
|
+
}, 30000)
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// 🚀 部署恢复场景:立即重连(无延迟)
|
|
594
|
+
if (this.deploymentDetected) {
|
|
595
|
+
this.api.logger.info('[EyeClaw] ⚡ Deployment recovery mode: immediate reconnect')
|
|
596
|
+
this.reconnectTimer = setTimeout(() => {
|
|
597
|
+
this.reconnecting = false
|
|
598
|
+
this.start()
|
|
599
|
+
}, 100) // 100ms 延迟(给服务器一点启动时间)
|
|
600
|
+
this.deploymentDetected = false // 重置标志
|
|
549
601
|
return
|
|
550
602
|
}
|
|
551
603
|
|
|
@@ -553,9 +605,65 @@ export class EyeClawWebSocketClient {
|
|
|
553
605
|
this.reconnectAttempts++
|
|
554
606
|
this.api.logger.info(`[EyeClaw] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
|
555
607
|
|
|
556
|
-
setTimeout(() => {
|
|
608
|
+
this.reconnectTimer = setTimeout(() => {
|
|
557
609
|
this.reconnecting = false
|
|
558
|
-
this.
|
|
610
|
+
this.performHealthCheckAndReconnect()
|
|
559
611
|
}, delay)
|
|
560
612
|
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* 健康检查后重连
|
|
616
|
+
*/
|
|
617
|
+
private async performHealthCheckAndReconnect() {
|
|
618
|
+
try {
|
|
619
|
+
// 尝试 HTTP 健康检查
|
|
620
|
+
const serverUrl = this.config.serverUrl.replace(/^http/, 'http') // 使用 http 而非 ws
|
|
621
|
+
const healthUrl = `${serverUrl}/api/v1/health`
|
|
622
|
+
|
|
623
|
+
const controller = new AbortController()
|
|
624
|
+
const timeoutId = setTimeout(() => controller.abort(), 3000)
|
|
625
|
+
|
|
626
|
+
const response = await fetch(healthUrl, {
|
|
627
|
+
method: 'GET',
|
|
628
|
+
signal: controller.signal,
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
clearTimeout(timeoutId)
|
|
632
|
+
|
|
633
|
+
if (response.ok) {
|
|
634
|
+
this.api.logger.info('[EyeClaw] ✅ Health check passed, proceeding with reconnect')
|
|
635
|
+
this.start()
|
|
636
|
+
} else {
|
|
637
|
+
this.api.logger.warn(`[EyeClaw] ⚠️ Health check failed (${response.status}), retrying soon...`)
|
|
638
|
+
// 健康检查失败,稍后重试
|
|
639
|
+
this.reconnectTimer = setTimeout(() => {
|
|
640
|
+
this.reconnecting = false
|
|
641
|
+
this.scheduleReconnect()
|
|
642
|
+
}, 2000)
|
|
643
|
+
}
|
|
644
|
+
} catch (error) {
|
|
645
|
+
this.api.logger.warn(`[EyeClaw] ⚠️ Health check error: ${error}, proceeding with reconnect anyway`)
|
|
646
|
+
// 即使健康检查失败也尝试重连
|
|
647
|
+
this.start()
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 主动发送 ping(用于部署恢复后的快速状态同步)
|
|
653
|
+
*/
|
|
654
|
+
private sendPing() {
|
|
655
|
+
const channelIdentifier = JSON.stringify({
|
|
656
|
+
channel: 'BotChannel',
|
|
657
|
+
bot_id: this.config.botId,
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
this.send({
|
|
661
|
+
command: 'message',
|
|
662
|
+
identifier: channelIdentifier,
|
|
663
|
+
data: JSON.stringify({
|
|
664
|
+
action: 'ping',
|
|
665
|
+
timestamp: new Date().toISOString(),
|
|
666
|
+
}),
|
|
667
|
+
})
|
|
668
|
+
}
|
|
561
669
|
}
|