@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eyeclaw/eyeclaw",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "EyeClaw plugin for OpenClaw - HTTP SSE streaming + WebSocket client",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -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.debug('[EyeClaw] Received protocol-level ping (auto-handled by WebSocket)')
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.debug('[EyeClaw] Received pong from server')
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.debug(`[EyeClaw] ✅ Received ACK for chunk #${sequence}, total acked: ${this.ackedChunks.size}/${this.sentChunks}`)
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
- // 不放弃,继续重试(每 60 秒检查一次)
544
- setTimeout(() => {
584
+ // 不放弃,继续重试(每 30 秒检查一次)
585
+ this.reconnectTimer = setTimeout(() => {
545
586
  this.reconnecting = false
546
587
  this.resetReconnectDelay()
547
588
  this.scheduleReconnect()
548
- }, 60000)
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.start()
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
  }