@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.
- package/connection/buffer.js +60 -46
- package/context.js +5 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +52 -52
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/yeaft/debug-trace.js +93 -34
- package/yeaft/engine.js +14 -1
package/connection/buffer.js
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
ctx.messageBuffer.
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1
|
+
{"version":"1.0.372"}
|