@eyeclaw/eyeclaw 2.3.11 → 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 +175 -11
package/package.json
CHANGED
package/src/websocket-client.ts
CHANGED
|
@@ -22,11 +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 序号
|
|
33
|
+
private accumulatedContent = '' // 累积完整内容用于兜底
|
|
34
|
+
|
|
35
|
+
// 🔥 ACK 机制:追踪已发送和已确认的 chunks
|
|
36
|
+
private sentChunks = 0 // 已发送的 chunks 数量
|
|
37
|
+
private ackedChunks = new Set<number>() // 已确认的 chunk 序号集合
|
|
30
38
|
|
|
31
39
|
constructor(api: OpenClawPluginApi, config: EyeClawConfig, getState: () => any) {
|
|
32
40
|
this.api = api
|
|
@@ -83,6 +91,8 @@ export class EyeClawWebSocketClient {
|
|
|
83
91
|
*/
|
|
84
92
|
stop() {
|
|
85
93
|
this.stopPing()
|
|
94
|
+
this.reconnecting = false
|
|
95
|
+
this.resetReconnectDelay()
|
|
86
96
|
if (this.ws) {
|
|
87
97
|
this.ws.close()
|
|
88
98
|
this.ws = null
|
|
@@ -90,6 +100,30 @@ export class EyeClawWebSocketClient {
|
|
|
90
100
|
this.subscribed = false
|
|
91
101
|
}
|
|
92
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
|
+
|
|
93
127
|
/**
|
|
94
128
|
* 处理 WebSocket 消息
|
|
95
129
|
*/
|
|
@@ -133,6 +167,15 @@ export class EyeClawWebSocketClient {
|
|
|
133
167
|
// 实际消息 - 从 Rails 发送的消息
|
|
134
168
|
if (message.identifier && message.message) {
|
|
135
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
|
+
|
|
136
179
|
this.handleCommand(payload)
|
|
137
180
|
}
|
|
138
181
|
|
|
@@ -196,8 +239,10 @@ export class EyeClawWebSocketClient {
|
|
|
196
239
|
* 调用自己的 HTTP 端点 /eyeclaw/chat
|
|
197
240
|
*/
|
|
198
241
|
private async processWithOpenClaw(message: string, sessionId?: string, openclawSessionId?: string) {
|
|
199
|
-
// 重置 chunk
|
|
242
|
+
// 重置 chunk 序号和 ACK 计数器(每个新会话)
|
|
200
243
|
this.chunkSequence = 0
|
|
244
|
+
this.sentChunks = 0
|
|
245
|
+
this.ackedChunks.clear()
|
|
201
246
|
|
|
202
247
|
const state = this.getState()
|
|
203
248
|
const gatewayPort = state.gatewayPort
|
|
@@ -239,11 +284,12 @@ export class EyeClawWebSocketClient {
|
|
|
239
284
|
let currentEvent = ''
|
|
240
285
|
|
|
241
286
|
// 解析 SSE 流式响应
|
|
287
|
+
let streamEnded = false
|
|
288
|
+
|
|
242
289
|
while (true) {
|
|
243
290
|
const { done, value } = await reader.read()
|
|
244
291
|
if (done) {
|
|
245
|
-
|
|
246
|
-
this.sendMessage('stream_end', { session_id: sessionId })
|
|
292
|
+
this.api.logger.info(`[EyeClaw] Reader done, stream ended flag: ${streamEnded}`)
|
|
247
293
|
break
|
|
248
294
|
}
|
|
249
295
|
|
|
@@ -283,14 +329,25 @@ export class EyeClawWebSocketClient {
|
|
|
283
329
|
|
|
284
330
|
// stream_end 事件:流结束(由 HTTP handler 发送)
|
|
285
331
|
if (currentEvent === 'stream_end') {
|
|
286
|
-
this.api.logger.info(`[EyeClaw]
|
|
287
|
-
|
|
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
|
|
288
339
|
this.sendMessage('stream_end', { session_id: sessionId })
|
|
340
|
+
this.sendStreamSummary(sessionId)
|
|
341
|
+
|
|
342
|
+
// 退出循环
|
|
343
|
+
return
|
|
289
344
|
}
|
|
290
345
|
|
|
291
346
|
// stream_error 事件:错误
|
|
292
347
|
if (currentEvent === 'stream_error') {
|
|
293
348
|
this.api.logger.error(`[EyeClaw] Stream error: ${eventData.error}`)
|
|
349
|
+
this.sendMessage('stream_error', { error: eventData.error, session_id: sessionId })
|
|
350
|
+
return
|
|
294
351
|
}
|
|
295
352
|
} catch (e) {
|
|
296
353
|
this.api.logger.warn(`[EyeClaw] Failed to parse SSE data: ${data}`)
|
|
@@ -298,6 +355,14 @@ export class EyeClawWebSocketClient {
|
|
|
298
355
|
}
|
|
299
356
|
}
|
|
300
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
|
+
}
|
|
301
366
|
|
|
302
367
|
this.api.logger.info(`[EyeClaw] Stream processing completed for session: ${sessionId}`)
|
|
303
368
|
|
|
@@ -322,6 +387,11 @@ export class EyeClawWebSocketClient {
|
|
|
322
387
|
private sendChunk(content: string, sessionId?: string) {
|
|
323
388
|
const timestamp = new Date().toISOString();
|
|
324
389
|
const sequence = this.chunkSequence++;
|
|
390
|
+
this.sentChunks++; // 🔥 记录已发送数量
|
|
391
|
+
|
|
392
|
+
// 累积完整内容用于兜底
|
|
393
|
+
this.accumulatedContent += content;
|
|
394
|
+
|
|
325
395
|
this.api.logger.info(`[EyeClaw] [${timestamp}] Sending chunk #${sequence} to Rails: "${content}"`);
|
|
326
396
|
this.sendMessage('stream_chunk', {
|
|
327
397
|
content,
|
|
@@ -329,6 +399,86 @@ export class EyeClawWebSocketClient {
|
|
|
329
399
|
sequence, // 添加序号
|
|
330
400
|
})
|
|
331
401
|
}
|
|
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
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 发送 stream_summary 用于兜底机制
|
|
450
|
+
* 告诉 Rails 完整内容是什么,以便检测丢包并补偿
|
|
451
|
+
*/
|
|
452
|
+
private sendStreamSummary(sessionId?: string) {
|
|
453
|
+
// 计算内容 hash
|
|
454
|
+
const contentHash = this.hashCode(this.accumulatedContent);
|
|
455
|
+
|
|
456
|
+
this.api.logger.info(`[EyeClaw] Sending stream_summary: chunks=${this.chunkSequence}, content_len=${this.accumulatedContent.length}, hash=${contentHash}`);
|
|
457
|
+
|
|
458
|
+
this.sendMessage('stream_summary', {
|
|
459
|
+
session_id: sessionId,
|
|
460
|
+
total_content: this.accumulatedContent,
|
|
461
|
+
total_chunks: this.chunkSequence,
|
|
462
|
+
content_hash: contentHash,
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
// 重置累积内容(为下一个会话做准备)
|
|
466
|
+
this.accumulatedContent = '';
|
|
467
|
+
this.chunkSequence = 0;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* 简单 hash 函数
|
|
472
|
+
*/
|
|
473
|
+
private hashCode(str: string): string {
|
|
474
|
+
let hash = 0;
|
|
475
|
+
for (let i = 0; i < str.length; i++) {
|
|
476
|
+
const char = str.charCodeAt(i);
|
|
477
|
+
hash = ((hash << 5) - hash) + char;
|
|
478
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
479
|
+
}
|
|
480
|
+
return hash.toString(16);
|
|
481
|
+
}
|
|
332
482
|
|
|
333
483
|
/**
|
|
334
484
|
* 发送消息到 Rails(带 channel identifier)
|
|
@@ -379,19 +529,33 @@ export class EyeClawWebSocketClient {
|
|
|
379
529
|
}
|
|
380
530
|
|
|
381
531
|
/**
|
|
382
|
-
*
|
|
532
|
+
* 计划重连(带指数退避)
|
|
383
533
|
*/
|
|
384
534
|
private scheduleReconnect() {
|
|
535
|
+
// 防止重复调度
|
|
536
|
+
if (this.reconnecting) {
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
this.reconnecting = true
|
|
540
|
+
|
|
385
541
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
386
|
-
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)
|
|
387
549
|
return
|
|
388
550
|
}
|
|
389
551
|
|
|
552
|
+
const delay = this.calculateReconnectDelay()
|
|
390
553
|
this.reconnectAttempts++
|
|
391
|
-
this.api.logger.info(`[EyeClaw] Reconnecting in ${
|
|
554
|
+
this.api.logger.info(`[EyeClaw] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
|
392
555
|
|
|
393
556
|
setTimeout(() => {
|
|
557
|
+
this.reconnecting = false
|
|
394
558
|
this.start()
|
|
395
|
-
},
|
|
559
|
+
}, delay)
|
|
396
560
|
}
|
|
397
561
|
}
|