@yeaft/webchat-agent 1.0.370 → 1.0.372

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.
@@ -2,7 +2,6 @@ import WebSocket from 'ws';
2
2
  import ctx from '../context.js';
3
3
  import { encrypt, decrypt, isEncrypted } from '../encryption.js';
4
4
 
5
- // 需要在断连期间缓冲的消息类型(CLI / Session 输出相关的关键消息)
6
5
  export const BUFFERABLE_TYPES = new Set([
7
6
  'claude_output', 'yeaft_output', 'yeaft_session_output', 'session_output',
8
7
  'yeaft_history_chunk',
@@ -10,43 +9,56 @@ export const BUFFERABLE_TYPES = new Set([
10
9
  'session_id_update', 'compact_status', 'slash_commands_update',
11
10
  'background_task_started', 'background_task_output',
12
11
  'subagent_started', 'subagent_message', 'subagent_completed',
13
- // Work Center broadcasts are projections over Agent-local SQLite. Buffering
14
- // prevents a terminal transition from disappearing during a short reconnect;
15
- // clients still refresh with `list` after reconnect for authoritative state.
16
12
  'work_center_event'
17
13
  ]);
18
14
 
15
+ function messageBytes(msg) {
16
+ try { return Buffer.byteLength(JSON.stringify(msg), 'utf8'); }
17
+ catch { return 0; }
18
+ }
19
+
20
+ const TERMINAL_TYPES = new Set(['turn_completed', 'conversation_closed']);
21
+
22
+ function removeBufferedAt(index) {
23
+ const [removed] = ctx.messageBuffer.splice(index, 1);
24
+ ctx.messageBufferBytes = Math.max(0, Number(ctx.messageBufferBytes || 0) - messageBytes(removed));
25
+ }
26
+
27
+ function removeOutboundAt(index, outcome = 'dropped') {
28
+ const [removed] = ctx.outboundSendQueue.splice(index, 1);
29
+ ctx.outboundSendQueueBytes = Math.max(0, Number(ctx.outboundSendQueueBytes || 0) - Number(removed?.bytes || 0));
30
+ removed?.resolve?.(outcome);
31
+ }
32
+
19
33
  function bufferMessage(msg, reason) {
20
34
  if (!BUFFERABLE_TYPES.has(msg.type)) {
21
35
  console.warn(`[WS] Cannot send message, WebSocket not open: ${msg.type}`);
22
36
  return 'dropped';
23
37
  }
24
- if (ctx.messageBuffer.length < ctx.messageBufferMaxSize) {
25
- ctx.messageBuffer.push(msg);
26
- console.log(`[WS] ${reason}, buffered: ${msg.type} (queue: ${ctx.messageBuffer.length})`);
27
- return 'buffered';
38
+ const bytes = messageBytes(msg);
39
+ const maxBytes = Math.max(1, Number(ctx.messageBufferMaxBytes) || 8 * 1024 * 1024);
40
+ if (bytes > maxBytes) {
41
+ console.warn(`[WS] Message exceeds disconnected buffer byte budget, dropping: ${msg.type}`);
42
+ return 'dropped';
28
43
  }
29
- // Buffer full: drop oldest non-status messages to make room
30
- const dropIdx = ctx.messageBuffer.findIndex(m => m.type !== 'turn_completed');
31
- if (dropIdx >= 0) {
32
- ctx.messageBuffer.splice(dropIdx, 1);
33
- ctx.messageBuffer.push(msg);
34
- console.warn(`[WS] Buffer full, dropped oldest to make room for: ${msg.type}`);
35
- return 'buffered';
44
+ while (ctx.messageBuffer.length > 0 && (
45
+ ctx.messageBuffer.length >= ctx.messageBufferMaxSize
46
+ || Number(ctx.messageBufferBytes || 0) + bytes > maxBytes
47
+ )) {
48
+ const nonTerminal = ctx.messageBuffer.findIndex(m => !TERMINAL_TYPES.has(m.type));
49
+ if (nonTerminal < 0) break;
50
+ removeBufferedAt(nonTerminal);
36
51
  }
37
- console.warn(`[WS] Buffer full (${ctx.messageBufferMaxSize}), dropping: ${msg.type}`);
38
- return 'dropped';
52
+ if (ctx.messageBuffer.length >= ctx.messageBufferMaxSize
53
+ || Number(ctx.messageBufferBytes || 0) + bytes > maxBytes) return 'dropped';
54
+ ctx.messageBuffer.push(msg);
55
+ ctx.messageBufferBytes = Number(ctx.messageBufferBytes || 0) + bytes;
56
+ console.log(`[WS] ${reason}, buffered: ${msg.type} (queue: ${ctx.messageBuffer.length})`);
57
+ return 'buffered';
39
58
  }
40
59
 
41
60
  async function sendNow(msg) {
42
- if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) {
43
- return bufferMessage(msg, 'Disconnected');
44
- }
45
-
46
- // feat-ws-plaintext-negotiation: encrypt only when the server has
47
- // NOT advertised plaintext acceptance. Defaults to encrypted for
48
- // back-compat with old servers; flipped to plaintext when the
49
- // `registered` frame includes `acceptPlaintext: true`.
61
+ if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) return bufferMessage(msg, 'Disconnected');
50
62
  if (ctx.serverEncryptionRequired && ctx.sessionKey) {
51
63
  const encrypted = await encrypt(msg, ctx.sessionKey);
52
64
  ctx.ws.send(JSON.stringify(encrypted));
@@ -63,6 +75,7 @@ function scheduleOutboundDrain() {
63
75
  try {
64
76
  while (ctx.outboundSendQueue.length > 0) {
65
77
  const item = ctx.outboundSendQueue.shift();
78
+ ctx.outboundSendQueueBytes = Math.max(0, Number(ctx.outboundSendQueueBytes || 0) - Number(item?.bytes || 0));
66
79
  const msg = item?.msg ?? item;
67
80
  try {
68
81
  const outcome = await sendNow(msg);
@@ -72,8 +85,6 @@ function scheduleOutboundDrain() {
72
85
  const outcome = msg ? bufferMessage(msg, 'Send failed') : 'dropped';
73
86
  item?.resolve?.(outcome);
74
87
  }
75
- // Yield between frames so ping/pong, inbound control messages and UI
76
- // events cannot be starved by a reconnect flush or a burst of tool output.
77
88
  await new Promise(resolve => setImmediate(resolve));
78
89
  }
79
90
  } finally {
@@ -83,42 +94,45 @@ function scheduleOutboundDrain() {
83
94
  });
84
95
  }
85
96
 
86
- // Send message to server (with encryption if available)
87
- // 断连时对关键消息类型进行缓冲,重连后自动 flush
88
97
  export async function sendToServer(msg) {
89
- if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) {
90
- return bufferMessage(msg, 'Disconnected');
98
+ if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) return bufferMessage(msg, 'Disconnected');
99
+ const bytes = messageBytes(msg);
100
+ const maxBytes = Math.max(1, Number(ctx.outboundSendQueueMaxBytes) || 8 * 1024 * 1024);
101
+ if (bytes > maxBytes) {
102
+ console.warn(`[WS] Outbound message exceeds byte budget, dropping: ${msg.type}`);
103
+ return 'dropped';
104
+ }
105
+ while (TERMINAL_TYPES.has(msg.type)
106
+ && Number(ctx.outboundSendQueueBytes || 0) + bytes > maxBytes) {
107
+ const nonTerminal = ctx.outboundSendQueue.findIndex(item => !TERMINAL_TYPES.has(item?.msg?.type));
108
+ if (nonTerminal < 0) break;
109
+ removeOutboundAt(nonTerminal);
110
+ }
111
+ if (Number(ctx.outboundSendQueueBytes || 0) + bytes > maxBytes) {
112
+ console.warn(`[WS] Outbound queue byte budget exceeded, dropping: ${msg.type}`);
113
+ return 'dropped';
91
114
  }
92
115
  const promise = new Promise((resolve, reject) => {
93
- ctx.outboundSendQueue.push({ msg, resolve, reject });
116
+ ctx.outboundSendQueue.push({ msg, bytes, resolve, reject });
117
+ ctx.outboundSendQueueBytes = Number(ctx.outboundSendQueueBytes || 0) + bytes;
94
118
  });
95
119
  scheduleOutboundDrain();
96
120
  return promise;
97
121
  }
98
122
 
99
- // Flush 断连期间缓冲的消息
100
123
  export async function flushMessageBuffer() {
101
124
  if (ctx.messageBuffer.length === 0) return;
102
-
103
125
  const buffered = ctx.messageBuffer.splice(0);
126
+ ctx.messageBufferBytes = 0;
104
127
  console.log(`[WS] Flushing ${buffered.length} buffered messages...`);
105
-
106
- for (const msg of buffered) {
107
- await sendToServer(msg);
108
- }
109
-
110
- console.log(`[WS] Flush queued`);
128
+ for (const msg of buffered) await sendToServer(msg);
129
+ console.log('[WS] Flush queued');
111
130
  }
112
131
 
113
- // Parse incoming message (decrypt if encrypted)
114
132
  export async function parseMessage(data) {
115
133
  try {
116
134
  const parsed = JSON.parse(data.toString());
117
-
118
- if (ctx.sessionKey && isEncrypted(parsed)) {
119
- return await decrypt(parsed, ctx.sessionKey);
120
- }
121
-
135
+ if (ctx.sessionKey && isEncrypted(parsed)) return await decrypt(parsed, ctx.sessionKey);
122
136
  return parsed;
123
137
  } catch (e) {
124
138
  console.error('Failed to parse message:', e);
package/context.js CHANGED
@@ -47,11 +47,15 @@ export default {
47
47
  lastHeartbeatStallAt: 0,
48
48
  lastHeartbeatStallMs: 0,
49
49
  outboundSendQueue: [],
50
+ outboundSendQueueBytes: 0,
51
+ outboundSendQueueMaxBytes: 8 * 1024 * 1024,
50
52
  outboundSendQueueActive: false,
51
53
  assetOutbox: null,
52
54
  // 断连期间的消息缓冲队列(重连后 flush)
53
55
  messageBuffer: [],
54
- messageBufferMaxSize: 5000, // 防止内存无限增长
56
+ messageBufferBytes: 0,
57
+ messageBufferMaxSize: 5000,
58
+ messageBufferMaxBytes: 8 * 1024 * 1024,
55
59
  // 由 connection.js 注册的通信函数
56
60
  sendToServer: null,
57
61
  // 由 index.js 注册的配置保存函数
@@ -1 +1 @@
1
- {"version":"1.0.370"}
1
+ {"version":"1.0.372"}