@eyeclaw/eyeclaw 2.3.12 → 2.4.0
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 +135 -14
package/package.json
CHANGED
package/src/websocket-client.ts
CHANGED
|
@@ -22,12 +22,19 @@ 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
|
+
|
|
35
|
+
// 🔥 ACK 机制:追踪已发送和已确认的 chunks
|
|
36
|
+
private sentChunks = 0 // 已发送的 chunks 数量
|
|
37
|
+
private ackedChunks = new Set<number>() // 已确认的 chunk 序号集合
|
|
31
38
|
|
|
32
39
|
constructor(api: OpenClawPluginApi, config: EyeClawConfig, getState: () => any) {
|
|
33
40
|
this.api = api
|
|
@@ -84,6 +91,8 @@ export class EyeClawWebSocketClient {
|
|
|
84
91
|
*/
|
|
85
92
|
stop() {
|
|
86
93
|
this.stopPing()
|
|
94
|
+
this.reconnecting = false
|
|
95
|
+
this.resetReconnectDelay()
|
|
87
96
|
if (this.ws) {
|
|
88
97
|
this.ws.close()
|
|
89
98
|
this.ws = null
|
|
@@ -91,6 +100,30 @@ export class EyeClawWebSocketClient {
|
|
|
91
100
|
this.subscribed = false
|
|
92
101
|
}
|
|
93
102
|
|
|
103
|
+
/**
|
|
104
|
+
* 重置重连延迟
|
|
105
|
+
*/
|
|
106
|
+
private resetReconnectDelay() {
|
|
107
|
+
this.currentReconnectDelay = this.baseReconnectDelay
|
|
108
|
+
this.reconnectAttempts = 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 计算下一次重连延迟(指数退避 + 随机抖动)
|
|
113
|
+
*/
|
|
114
|
+
private calculateReconnectDelay(): number {
|
|
115
|
+
// 指数增长: 1s, 2s, 4s, 8s, 16s, 30s (cap)
|
|
116
|
+
const delay = Math.min(
|
|
117
|
+
this.currentReconnectDelay * 2,
|
|
118
|
+
this.maxReconnectDelay
|
|
119
|
+
)
|
|
120
|
+
this.currentReconnectDelay = delay
|
|
121
|
+
|
|
122
|
+
// 添加随机抖动 (±25%)
|
|
123
|
+
const jitter = delay * 0.25 * (Math.random() * 2 - 1)
|
|
124
|
+
return Math.floor(delay + jitter)
|
|
125
|
+
}
|
|
126
|
+
|
|
94
127
|
/**
|
|
95
128
|
* 处理 WebSocket 消息
|
|
96
129
|
*/
|
|
@@ -134,6 +167,15 @@ export class EyeClawWebSocketClient {
|
|
|
134
167
|
// 实际消息 - 从 Rails 发送的消息
|
|
135
168
|
if (message.identifier && message.message) {
|
|
136
169
|
const payload = message.message
|
|
170
|
+
|
|
171
|
+
// 🔥 ACK 机制:处理 chunk_received 确认
|
|
172
|
+
if (payload.type === 'chunk_received') {
|
|
173
|
+
const sequence = payload.sequence
|
|
174
|
+
this.ackedChunks.add(sequence)
|
|
175
|
+
this.api.logger.debug(`[EyeClaw] ✅ Received ACK for chunk #${sequence}, total acked: ${this.ackedChunks.size}/${this.sentChunks}`)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
137
179
|
this.handleCommand(payload)
|
|
138
180
|
}
|
|
139
181
|
|
|
@@ -197,8 +239,10 @@ export class EyeClawWebSocketClient {
|
|
|
197
239
|
* 调用自己的 HTTP 端点 /eyeclaw/chat
|
|
198
240
|
*/
|
|
199
241
|
private async processWithOpenClaw(message: string, sessionId?: string, openclawSessionId?: string) {
|
|
200
|
-
// 重置 chunk
|
|
242
|
+
// 重置 chunk 序号和 ACK 计数器(每个新会话)
|
|
201
243
|
this.chunkSequence = 0
|
|
244
|
+
this.sentChunks = 0
|
|
245
|
+
this.ackedChunks.clear()
|
|
202
246
|
|
|
203
247
|
const state = this.getState()
|
|
204
248
|
const gatewayPort = state.gatewayPort
|
|
@@ -240,14 +284,12 @@ export class EyeClawWebSocketClient {
|
|
|
240
284
|
let currentEvent = ''
|
|
241
285
|
|
|
242
286
|
// 解析 SSE 流式响应
|
|
287
|
+
let streamEnded = false
|
|
288
|
+
|
|
243
289
|
while (true) {
|
|
244
290
|
const { done, value } = await reader.read()
|
|
245
291
|
if (done) {
|
|
246
|
-
|
|
247
|
-
this.sendMessage('stream_end', { session_id: sessionId })
|
|
248
|
-
|
|
249
|
-
// 发送 stream_summary 用于兜底机制
|
|
250
|
-
this.sendStreamSummary(sessionId)
|
|
292
|
+
this.api.logger.info(`[EyeClaw] Reader done, stream ended flag: ${streamEnded}`)
|
|
251
293
|
break
|
|
252
294
|
}
|
|
253
295
|
|
|
@@ -287,14 +329,25 @@ export class EyeClawWebSocketClient {
|
|
|
287
329
|
|
|
288
330
|
// stream_end 事件:流结束(由 HTTP handler 发送)
|
|
289
331
|
if (currentEvent === 'stream_end') {
|
|
290
|
-
this.api.logger.info(`[EyeClaw]
|
|
291
|
-
|
|
332
|
+
this.api.logger.info(`[EyeClaw] Received stream_end event: ${eventData.stream_id}`)
|
|
333
|
+
streamEnded = true
|
|
334
|
+
|
|
335
|
+
// 🔥 等待所有 chunks 被确认后再发送 stream_end
|
|
336
|
+
await this.waitForAllAcks(sessionId)
|
|
337
|
+
|
|
338
|
+
// 发送 stream_end 和 stream_summary
|
|
292
339
|
this.sendMessage('stream_end', { session_id: sessionId })
|
|
340
|
+
this.sendStreamSummary(sessionId)
|
|
341
|
+
|
|
342
|
+
// 退出循环
|
|
343
|
+
return
|
|
293
344
|
}
|
|
294
345
|
|
|
295
346
|
// stream_error 事件:错误
|
|
296
347
|
if (currentEvent === 'stream_error') {
|
|
297
348
|
this.api.logger.error(`[EyeClaw] Stream error: ${eventData.error}`)
|
|
349
|
+
this.sendMessage('stream_error', { error: eventData.error, session_id: sessionId })
|
|
350
|
+
return
|
|
298
351
|
}
|
|
299
352
|
} catch (e) {
|
|
300
353
|
this.api.logger.warn(`[EyeClaw] Failed to parse SSE data: ${data}`)
|
|
@@ -302,6 +355,14 @@ export class EyeClawWebSocketClient {
|
|
|
302
355
|
}
|
|
303
356
|
}
|
|
304
357
|
}
|
|
358
|
+
|
|
359
|
+
// 如果循环正常结束(没有收到 stream_end 事件),也要等待 ACK
|
|
360
|
+
if (!streamEnded) {
|
|
361
|
+
this.api.logger.info(`[EyeClaw] Stream ended without stream_end event, waiting for ACKs`)
|
|
362
|
+
await this.waitForAllAcks(sessionId)
|
|
363
|
+
this.sendMessage('stream_end', { session_id: sessionId })
|
|
364
|
+
this.sendStreamSummary(sessionId)
|
|
365
|
+
}
|
|
305
366
|
|
|
306
367
|
this.api.logger.info(`[EyeClaw] Stream processing completed for session: ${sessionId}`)
|
|
307
368
|
|
|
@@ -326,6 +387,7 @@ export class EyeClawWebSocketClient {
|
|
|
326
387
|
private sendChunk(content: string, sessionId?: string) {
|
|
327
388
|
const timestamp = new Date().toISOString();
|
|
328
389
|
const sequence = this.chunkSequence++;
|
|
390
|
+
this.sentChunks++; // 🔥 记录已发送数量
|
|
329
391
|
|
|
330
392
|
// 累积完整内容用于兜底
|
|
331
393
|
this.accumulatedContent += content;
|
|
@@ -338,6 +400,51 @@ export class EyeClawWebSocketClient {
|
|
|
338
400
|
})
|
|
339
401
|
}
|
|
340
402
|
|
|
403
|
+
/**
|
|
404
|
+
* 🔥 等待所有 chunks 被 Rails 确认
|
|
405
|
+
* 实现 TCP 三次握手的应用层版本
|
|
406
|
+
* 超时 2 秒后强制返回,依赖 stream_summary 兜底机制
|
|
407
|
+
*/
|
|
408
|
+
private async waitForAllAcks(sessionId?: string): Promise<void> {
|
|
409
|
+
const startTime = Date.now()
|
|
410
|
+
const timeout = 2000 // 2秒超时
|
|
411
|
+
const checkInterval = 50 // 每 50ms 检查一次
|
|
412
|
+
|
|
413
|
+
this.api.logger.info(`[EyeClaw] 🕒 Waiting for all ACKs: sent=${this.sentChunks}, acked=${this.ackedChunks.size}`)
|
|
414
|
+
|
|
415
|
+
while (this.ackedChunks.size < this.sentChunks) {
|
|
416
|
+
const elapsed = Date.now() - startTime
|
|
417
|
+
|
|
418
|
+
if (elapsed >= timeout) {
|
|
419
|
+
const missing = this.sentChunks - this.ackedChunks.size
|
|
420
|
+
const missingSequences: number[] = []
|
|
421
|
+
for (let i = 0; i < this.sentChunks; i++) {
|
|
422
|
+
if (!this.ackedChunks.has(i)) {
|
|
423
|
+
missingSequences.push(i)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
this.api.logger.warn(
|
|
428
|
+
`[EyeClaw] ⚠️ ACK timeout after ${elapsed}ms: ` +
|
|
429
|
+
`sent=${this.sentChunks}, acked=${this.ackedChunks.size}, ` +
|
|
430
|
+
`missing=${missing}, missing_sequences=[${missingSequences.join(', ')}]`
|
|
431
|
+
)
|
|
432
|
+
this.api.logger.info(`[EyeClaw] Relying on stream_summary fallback mechanism`)
|
|
433
|
+
break
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// 等待 50ms 后再检查
|
|
437
|
+
await new Promise(resolve => setTimeout(resolve, checkInterval))
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (this.ackedChunks.size === this.sentChunks) {
|
|
441
|
+
const elapsed = Date.now() - startTime
|
|
442
|
+
this.api.logger.info(
|
|
443
|
+
`[EyeClaw] ✅ All chunks ACKed: ${this.ackedChunks.size}/${this.sentChunks} in ${elapsed}ms`
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
341
448
|
/**
|
|
342
449
|
* 发送 stream_summary 用于兜底机制
|
|
343
450
|
* 告诉 Rails 完整内容是什么,以便检测丢包并补偿
|
|
@@ -422,19 +529,33 @@ export class EyeClawWebSocketClient {
|
|
|
422
529
|
}
|
|
423
530
|
|
|
424
531
|
/**
|
|
425
|
-
*
|
|
532
|
+
* 计划重连(带指数退避)
|
|
426
533
|
*/
|
|
427
534
|
private scheduleReconnect() {
|
|
535
|
+
// 防止重复调度
|
|
536
|
+
if (this.reconnecting) {
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
this.reconnecting = true
|
|
540
|
+
|
|
428
541
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
429
|
-
this.api.logger.error('[EyeClaw] Max reconnect attempts reached')
|
|
542
|
+
this.api.logger.error('[EyeClaw] Max reconnect attempts reached, will retry later...')
|
|
543
|
+
// 不放弃,继续重试(每 60 秒检查一次)
|
|
544
|
+
setTimeout(() => {
|
|
545
|
+
this.reconnecting = false
|
|
546
|
+
this.resetReconnectDelay()
|
|
547
|
+
this.scheduleReconnect()
|
|
548
|
+
}, 60000)
|
|
430
549
|
return
|
|
431
550
|
}
|
|
432
551
|
|
|
552
|
+
const delay = this.calculateReconnectDelay()
|
|
433
553
|
this.reconnectAttempts++
|
|
434
|
-
this.api.logger.info(`[EyeClaw] Reconnecting in ${
|
|
554
|
+
this.api.logger.info(`[EyeClaw] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
|
435
555
|
|
|
436
556
|
setTimeout(() => {
|
|
557
|
+
this.reconnecting = false
|
|
437
558
|
this.start()
|
|
438
|
-
},
|
|
559
|
+
}, delay)
|
|
439
560
|
}
|
|
440
561
|
}
|