@eyeclaw/eyeclaw 2.3.12 → 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 +246 -17
package/package.json
CHANGED
package/src/websocket-client.ts
CHANGED
|
@@ -22,12 +22,25 @@ export class EyeClawWebSocketClient {
|
|
|
22
22
|
private config: EyeClawConfig
|
|
23
23
|
private getState: () => any
|
|
24
24
|
private reconnectAttempts = 0
|
|
25
|
-
private maxReconnectAttempts =
|
|
26
|
-
private
|
|
25
|
+
private maxReconnectAttempts = 10
|
|
26
|
+
private baseReconnectDelay = 1000 // 1 second
|
|
27
|
+
private maxReconnectDelay = 30000 // 30 seconds
|
|
28
|
+
private currentReconnectDelay = 1000
|
|
29
|
+
private reconnecting = false
|
|
27
30
|
private subscribed = false
|
|
28
31
|
private pingInterval: any = null
|
|
29
32
|
private chunkSequence = 0 // 每个会话的 chunk 序号
|
|
30
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 = '' // 服务器版本(用于检测部署)
|
|
40
|
+
|
|
41
|
+
// 🔥 ACK 机制:追踪已发送和已确认的 chunks
|
|
42
|
+
private sentChunks = 0 // 已发送的 chunks 数量
|
|
43
|
+
private ackedChunks = new Set<number>() // 已确认的 chunk 序号集合
|
|
31
44
|
|
|
32
45
|
constructor(api: OpenClawPluginApi, config: EyeClawConfig, getState: () => any) {
|
|
33
46
|
this.api = api
|
|
@@ -56,6 +69,16 @@ export class EyeClawWebSocketClient {
|
|
|
56
69
|
this.ws.onopen = () => {
|
|
57
70
|
this.api.logger.info('[EyeClaw] WebSocket connected')
|
|
58
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
|
+
}
|
|
59
82
|
}
|
|
60
83
|
|
|
61
84
|
this.ws.onmessage = (event) => {
|
|
@@ -70,6 +93,13 @@ export class EyeClawWebSocketClient {
|
|
|
70
93
|
this.api.logger.warn('[EyeClaw] WebSocket disconnected')
|
|
71
94
|
this.subscribed = false
|
|
72
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
|
+
|
|
73
103
|
this.scheduleReconnect()
|
|
74
104
|
}
|
|
75
105
|
|
|
@@ -84,11 +114,49 @@ export class EyeClawWebSocketClient {
|
|
|
84
114
|
*/
|
|
85
115
|
stop() {
|
|
86
116
|
this.stopPing()
|
|
117
|
+
this.reconnecting = false
|
|
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
|
+
|
|
87
130
|
if (this.ws) {
|
|
88
131
|
this.ws.close()
|
|
89
132
|
this.ws = null
|
|
90
133
|
}
|
|
91
134
|
this.subscribed = false
|
|
135
|
+
this.wasConnected = false
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 重置重连延迟
|
|
140
|
+
*/
|
|
141
|
+
private resetReconnectDelay() {
|
|
142
|
+
this.currentReconnectDelay = this.baseReconnectDelay
|
|
143
|
+
this.reconnectAttempts = 0
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 计算下一次重连延迟(指数退避 + 随机抖动)
|
|
148
|
+
*/
|
|
149
|
+
private calculateReconnectDelay(): number {
|
|
150
|
+
// 指数增长: 1s, 2s, 4s, 8s, 16s, 30s (cap)
|
|
151
|
+
const delay = Math.min(
|
|
152
|
+
this.currentReconnectDelay * 2,
|
|
153
|
+
this.maxReconnectDelay
|
|
154
|
+
)
|
|
155
|
+
this.currentReconnectDelay = delay
|
|
156
|
+
|
|
157
|
+
// 添加随机抖动 (±25%)
|
|
158
|
+
const jitter = delay * 0.25 * (Math.random() * 2 - 1)
|
|
159
|
+
return Math.floor(delay + jitter)
|
|
92
160
|
}
|
|
93
161
|
|
|
94
162
|
/**
|
|
@@ -107,13 +175,13 @@ export class EyeClawWebSocketClient {
|
|
|
107
175
|
|
|
108
176
|
// Ping/pong (WebSocket 协议级别的 ping 由浏览器自动响应,无需手动处理)
|
|
109
177
|
if (message.type === 'ping') {
|
|
110
|
-
this.api.logger
|
|
178
|
+
this.api.logger?.debug?.('[EyeClaw] Received protocol-level ping (auto-handled by WebSocket)')
|
|
111
179
|
return
|
|
112
180
|
}
|
|
113
181
|
|
|
114
182
|
// 处理 Rails BotChannel 的 pong 响应
|
|
115
183
|
if (message.type === 'pong') {
|
|
116
|
-
this.api.logger
|
|
184
|
+
this.api.logger?.debug?.('[EyeClaw] Received pong from server')
|
|
117
185
|
return
|
|
118
186
|
}
|
|
119
187
|
|
|
@@ -134,6 +202,15 @@ export class EyeClawWebSocketClient {
|
|
|
134
202
|
// 实际消息 - 从 Rails 发送的消息
|
|
135
203
|
if (message.identifier && message.message) {
|
|
136
204
|
const payload = message.message
|
|
205
|
+
|
|
206
|
+
// 🔥 ACK 机制:处理 chunk_received 确认
|
|
207
|
+
if (payload.type === 'chunk_received') {
|
|
208
|
+
const sequence = payload.sequence
|
|
209
|
+
this.ackedChunks.add(sequence)
|
|
210
|
+
this.api.logger?.debug?.(`[EyeClaw] ✅ Received ACK for chunk #${sequence}, total acked: ${this.ackedChunks.size}/${this.sentChunks}`)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
137
214
|
this.handleCommand(payload)
|
|
138
215
|
}
|
|
139
216
|
|
|
@@ -197,8 +274,10 @@ export class EyeClawWebSocketClient {
|
|
|
197
274
|
* 调用自己的 HTTP 端点 /eyeclaw/chat
|
|
198
275
|
*/
|
|
199
276
|
private async processWithOpenClaw(message: string, sessionId?: string, openclawSessionId?: string) {
|
|
200
|
-
// 重置 chunk
|
|
277
|
+
// 重置 chunk 序号和 ACK 计数器(每个新会话)
|
|
201
278
|
this.chunkSequence = 0
|
|
279
|
+
this.sentChunks = 0
|
|
280
|
+
this.ackedChunks.clear()
|
|
202
281
|
|
|
203
282
|
const state = this.getState()
|
|
204
283
|
const gatewayPort = state.gatewayPort
|
|
@@ -240,14 +319,12 @@ export class EyeClawWebSocketClient {
|
|
|
240
319
|
let currentEvent = ''
|
|
241
320
|
|
|
242
321
|
// 解析 SSE 流式响应
|
|
322
|
+
let streamEnded = false
|
|
323
|
+
|
|
243
324
|
while (true) {
|
|
244
325
|
const { done, value } = await reader.read()
|
|
245
326
|
if (done) {
|
|
246
|
-
|
|
247
|
-
this.sendMessage('stream_end', { session_id: sessionId })
|
|
248
|
-
|
|
249
|
-
// 发送 stream_summary 用于兜底机制
|
|
250
|
-
this.sendStreamSummary(sessionId)
|
|
327
|
+
this.api.logger.info(`[EyeClaw] Reader done, stream ended flag: ${streamEnded}`)
|
|
251
328
|
break
|
|
252
329
|
}
|
|
253
330
|
|
|
@@ -287,14 +364,25 @@ export class EyeClawWebSocketClient {
|
|
|
287
364
|
|
|
288
365
|
// stream_end 事件:流结束(由 HTTP handler 发送)
|
|
289
366
|
if (currentEvent === 'stream_end') {
|
|
290
|
-
this.api.logger.info(`[EyeClaw]
|
|
291
|
-
|
|
367
|
+
this.api.logger.info(`[EyeClaw] Received stream_end event: ${eventData.stream_id}`)
|
|
368
|
+
streamEnded = true
|
|
369
|
+
|
|
370
|
+
// 🔥 等待所有 chunks 被确认后再发送 stream_end
|
|
371
|
+
await this.waitForAllAcks(sessionId)
|
|
372
|
+
|
|
373
|
+
// 发送 stream_end 和 stream_summary
|
|
292
374
|
this.sendMessage('stream_end', { session_id: sessionId })
|
|
375
|
+
this.sendStreamSummary(sessionId)
|
|
376
|
+
|
|
377
|
+
// 退出循环
|
|
378
|
+
return
|
|
293
379
|
}
|
|
294
380
|
|
|
295
381
|
// stream_error 事件:错误
|
|
296
382
|
if (currentEvent === 'stream_error') {
|
|
297
383
|
this.api.logger.error(`[EyeClaw] Stream error: ${eventData.error}`)
|
|
384
|
+
this.sendMessage('stream_error', { error: eventData.error, session_id: sessionId })
|
|
385
|
+
return
|
|
298
386
|
}
|
|
299
387
|
} catch (e) {
|
|
300
388
|
this.api.logger.warn(`[EyeClaw] Failed to parse SSE data: ${data}`)
|
|
@@ -302,6 +390,14 @@ export class EyeClawWebSocketClient {
|
|
|
302
390
|
}
|
|
303
391
|
}
|
|
304
392
|
}
|
|
393
|
+
|
|
394
|
+
// 如果循环正常结束(没有收到 stream_end 事件),也要等待 ACK
|
|
395
|
+
if (!streamEnded) {
|
|
396
|
+
this.api.logger.info(`[EyeClaw] Stream ended without stream_end event, waiting for ACKs`)
|
|
397
|
+
await this.waitForAllAcks(sessionId)
|
|
398
|
+
this.sendMessage('stream_end', { session_id: sessionId })
|
|
399
|
+
this.sendStreamSummary(sessionId)
|
|
400
|
+
}
|
|
305
401
|
|
|
306
402
|
this.api.logger.info(`[EyeClaw] Stream processing completed for session: ${sessionId}`)
|
|
307
403
|
|
|
@@ -326,6 +422,7 @@ export class EyeClawWebSocketClient {
|
|
|
326
422
|
private sendChunk(content: string, sessionId?: string) {
|
|
327
423
|
const timestamp = new Date().toISOString();
|
|
328
424
|
const sequence = this.chunkSequence++;
|
|
425
|
+
this.sentChunks++; // 🔥 记录已发送数量
|
|
329
426
|
|
|
330
427
|
// 累积完整内容用于兜底
|
|
331
428
|
this.accumulatedContent += content;
|
|
@@ -338,6 +435,51 @@ export class EyeClawWebSocketClient {
|
|
|
338
435
|
})
|
|
339
436
|
}
|
|
340
437
|
|
|
438
|
+
/**
|
|
439
|
+
* 🔥 等待所有 chunks 被 Rails 确认
|
|
440
|
+
* 实现 TCP 三次握手的应用层版本
|
|
441
|
+
* 超时 2 秒后强制返回,依赖 stream_summary 兜底机制
|
|
442
|
+
*/
|
|
443
|
+
private async waitForAllAcks(sessionId?: string): Promise<void> {
|
|
444
|
+
const startTime = Date.now()
|
|
445
|
+
const timeout = 2000 // 2秒超时
|
|
446
|
+
const checkInterval = 50 // 每 50ms 检查一次
|
|
447
|
+
|
|
448
|
+
this.api.logger.info(`[EyeClaw] 🕒 Waiting for all ACKs: sent=${this.sentChunks}, acked=${this.ackedChunks.size}`)
|
|
449
|
+
|
|
450
|
+
while (this.ackedChunks.size < this.sentChunks) {
|
|
451
|
+
const elapsed = Date.now() - startTime
|
|
452
|
+
|
|
453
|
+
if (elapsed >= timeout) {
|
|
454
|
+
const missing = this.sentChunks - this.ackedChunks.size
|
|
455
|
+
const missingSequences: number[] = []
|
|
456
|
+
for (let i = 0; i < this.sentChunks; i++) {
|
|
457
|
+
if (!this.ackedChunks.has(i)) {
|
|
458
|
+
missingSequences.push(i)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
this.api.logger.warn(
|
|
463
|
+
`[EyeClaw] ⚠️ ACK timeout after ${elapsed}ms: ` +
|
|
464
|
+
`sent=${this.sentChunks}, acked=${this.ackedChunks.size}, ` +
|
|
465
|
+
`missing=${missing}, missing_sequences=[${missingSequences.join(', ')}]`
|
|
466
|
+
)
|
|
467
|
+
this.api.logger.info(`[EyeClaw] Relying on stream_summary fallback mechanism`)
|
|
468
|
+
break
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 等待 50ms 后再检查
|
|
472
|
+
await new Promise(resolve => setTimeout(resolve, checkInterval))
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (this.ackedChunks.size === this.sentChunks) {
|
|
476
|
+
const elapsed = Date.now() - startTime
|
|
477
|
+
this.api.logger.info(
|
|
478
|
+
`[EyeClaw] ✅ All chunks ACKed: ${this.ackedChunks.size}/${this.sentChunks} in ${elapsed}ms`
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
341
483
|
/**
|
|
342
484
|
* 发送 stream_summary 用于兜底机制
|
|
343
485
|
* 告诉 Rails 完整内容是什么,以便检测丢包并补偿
|
|
@@ -422,19 +564,106 @@ export class EyeClawWebSocketClient {
|
|
|
422
564
|
}
|
|
423
565
|
|
|
424
566
|
/**
|
|
425
|
-
*
|
|
567
|
+
* 计划重连(带指数退避、部署感知加速和健康检查)
|
|
426
568
|
*/
|
|
427
569
|
private scheduleReconnect() {
|
|
570
|
+
// 防止重复调度 - 先清理旧定时器
|
|
571
|
+
if (this.reconnectTimer) {
|
|
572
|
+
clearTimeout(this.reconnectTimer)
|
|
573
|
+
this.reconnectTimer = null
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (this.reconnecting) {
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
this.reconnecting = true
|
|
580
|
+
|
|
581
|
+
// 如果超过最大重试次数,继续重试
|
|
428
582
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
429
|
-
this.api.logger.error('[EyeClaw] Max reconnect attempts reached')
|
|
583
|
+
this.api.logger.error('[EyeClaw] Max reconnect attempts reached, will retry later...')
|
|
584
|
+
// 不放弃,继续重试(每 30 秒检查一次)
|
|
585
|
+
this.reconnectTimer = setTimeout(() => {
|
|
586
|
+
this.reconnecting = false
|
|
587
|
+
this.resetReconnectDelay()
|
|
588
|
+
this.scheduleReconnect()
|
|
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 // 重置标志
|
|
430
601
|
return
|
|
431
602
|
}
|
|
432
603
|
|
|
604
|
+
const delay = this.calculateReconnectDelay()
|
|
433
605
|
this.reconnectAttempts++
|
|
434
|
-
this.api.logger.info(`[EyeClaw] Reconnecting in ${
|
|
606
|
+
this.api.logger.info(`[EyeClaw] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
|
435
607
|
|
|
436
|
-
setTimeout(() => {
|
|
608
|
+
this.reconnectTimer = setTimeout(() => {
|
|
609
|
+
this.reconnecting = false
|
|
610
|
+
this.performHealthCheckAndReconnect()
|
|
611
|
+
}, delay)
|
|
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
|
+
// 即使健康检查失败也尝试重连
|
|
437
647
|
this.start()
|
|
438
|
-
}
|
|
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
|
+
})
|
|
439
668
|
}
|
|
440
669
|
}
|