@ynhcj/xiaoyi 0.0.1-beta
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/README.md +207 -0
- package/dist/auth.d.ts +36 -0
- package/dist/auth.js +111 -0
- package/dist/channel.d.ts +189 -0
- package/dist/channel.js +354 -0
- package/dist/config-schema.d.ts +46 -0
- package/dist/config-schema.js +28 -0
- package/dist/file-download.d.ts +17 -0
- package/dist/file-download.js +69 -0
- package/dist/file-handler.d.ts +36 -0
- package/dist/file-handler.js +113 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +49 -0
- package/dist/onboarding.d.ts +6 -0
- package/dist/onboarding.js +167 -0
- package/dist/push.d.ts +28 -0
- package/dist/push.js +135 -0
- package/dist/runtime.d.ts +191 -0
- package/dist/runtime.js +438 -0
- package/dist/types.d.ts +280 -0
- package/dist/types.js +8 -0
- package/dist/websocket.d.ts +219 -0
- package/dist/websocket.js +1068 -0
- package/dist/xiaoyi-media.d.ts +81 -0
- package/dist/xiaoyi-media.js +216 -0
- package/dist/xy-bot.d.ts +19 -0
- package/dist/xy-bot.js +277 -0
- package/dist/xy-client.d.ts +26 -0
- package/dist/xy-client.js +78 -0
- package/dist/xy-config.d.ts +18 -0
- package/dist/xy-config.js +37 -0
- package/dist/xy-formatter.d.ts +94 -0
- package/dist/xy-formatter.js +303 -0
- package/dist/xy-monitor.d.ts +17 -0
- package/dist/xy-monitor.js +194 -0
- package/dist/xy-parser.d.ts +49 -0
- package/dist/xy-parser.js +109 -0
- package/dist/xy-reply-dispatcher.d.ts +17 -0
- package/dist/xy-reply-dispatcher.js +308 -0
- package/dist/xy-tools/session-manager.d.ts +29 -0
- package/dist/xy-tools/session-manager.js +80 -0
- package/dist/xy-utils/config-manager.d.ts +26 -0
- package/dist/xy-utils/config-manager.js +61 -0
- package/dist/xy-utils/crypto.d.ts +8 -0
- package/dist/xy-utils/crypto.js +21 -0
- package/dist/xy-utils/logger.d.ts +6 -0
- package/dist/xy-utils/logger.js +37 -0
- package/dist/xy-utils/session.d.ts +34 -0
- package/dist/xy-utils/session.js +55 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +73 -0
- package/xiaoyi.js +1 -0
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.XiaoYiWebSocketManager = void 0;
|
|
7
|
+
const ws_1 = __importDefault(require("ws"));
|
|
8
|
+
const events_1 = require("events");
|
|
9
|
+
const url_1 = require("url");
|
|
10
|
+
const auth_js_1 = require("./auth.js");
|
|
11
|
+
const types_js_1 = require("./types.js");
|
|
12
|
+
class XiaoYiWebSocketManager extends events_1.EventEmitter {
|
|
13
|
+
constructor(config) {
|
|
14
|
+
super();
|
|
15
|
+
// ==================== Dual WebSocket Connections ====================
|
|
16
|
+
this.ws1 = null;
|
|
17
|
+
this.ws2 = null;
|
|
18
|
+
// ==================== Dual Server States ====================
|
|
19
|
+
this.state1 = {
|
|
20
|
+
connected: false,
|
|
21
|
+
ready: false,
|
|
22
|
+
lastHeartbeat: 0,
|
|
23
|
+
reconnectAttempts: 0
|
|
24
|
+
};
|
|
25
|
+
this.state2 = {
|
|
26
|
+
connected: false,
|
|
27
|
+
ready: false,
|
|
28
|
+
lastHeartbeat: 0,
|
|
29
|
+
reconnectAttempts: 0
|
|
30
|
+
};
|
|
31
|
+
// ==================== Session → Server Mapping ====================
|
|
32
|
+
this.sessionServerMap = new Map();
|
|
33
|
+
// ==================== Session Cleanup State ====================
|
|
34
|
+
// Track sessions that are pending cleanup (user cleared context but task still running)
|
|
35
|
+
this.sessionCleanupStateMap = new Map();
|
|
36
|
+
// ==================== Active Tasks ====================
|
|
37
|
+
this.activeTasks = new Map();
|
|
38
|
+
// Resolve configuration with defaults and backward compatibility
|
|
39
|
+
this.config = this.resolveConfig(config);
|
|
40
|
+
this.auth = new auth_js_1.XiaoYiAuth(this.config.ak, this.config.sk, this.config.agentId);
|
|
41
|
+
console.log(`[WS Manager] Initialized with dual server:`);
|
|
42
|
+
console.log(` Server 1: ${this.config.wsUrl1}`);
|
|
43
|
+
console.log(` Server 2: ${this.config.wsUrl2}`);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Check if URL is wss + IP format (skip certificate verification)
|
|
47
|
+
*/
|
|
48
|
+
isWssWithIp(urlString) {
|
|
49
|
+
try {
|
|
50
|
+
const url = new url_1.URL(urlString);
|
|
51
|
+
// Check if protocol is wss
|
|
52
|
+
if (url.protocol !== 'wss:') {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const hostname = url.hostname;
|
|
56
|
+
// Check for IPv4 address (e.g., 192.168.1.1)
|
|
57
|
+
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
58
|
+
if (ipv4Regex.test(hostname)) {
|
|
59
|
+
// Validate each octet is 0-255
|
|
60
|
+
const octets = hostname.split('.');
|
|
61
|
+
return octets.every(octet => {
|
|
62
|
+
const num = parseInt(octet, 10);
|
|
63
|
+
return num >= 0 && num <= 255;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// Check for IPv6 address (e.g., [::1] or 2001:db8::1)
|
|
67
|
+
// IPv6 in URL might be wrapped in brackets
|
|
68
|
+
const ipv6Regex = /^[\[::0-9a-fA-F]+$/;
|
|
69
|
+
const ipv6WithoutBrackets = hostname.replace(/[\[\]]/g, '');
|
|
70
|
+
// Simple check for IPv6: contains colons and valid hex characters
|
|
71
|
+
if (hostname.includes('[') && hostname.includes(']')) {
|
|
72
|
+
return ipv6Regex.test(hostname);
|
|
73
|
+
}
|
|
74
|
+
// Check for plain IPv6 format
|
|
75
|
+
if (hostname.includes(':')) {
|
|
76
|
+
const ipv6RegexPlain = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
|
77
|
+
return ipv6RegexPlain.test(ipv6WithoutBrackets);
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
console.warn(`[WS Manager] Invalid URL format: ${urlString}`);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve configuration with defaults and backward compatibility
|
|
88
|
+
*/
|
|
89
|
+
resolveConfig(userConfig) {
|
|
90
|
+
// Backward compatibility: if wsUrl is provided but wsUrl1/wsUrl2 are not,
|
|
91
|
+
// use wsUrl for server1 and default for server2
|
|
92
|
+
let wsUrl1 = userConfig.wsUrl1;
|
|
93
|
+
let wsUrl2 = userConfig.wsUrl2;
|
|
94
|
+
if (!wsUrl1 && userConfig.wsUrl) {
|
|
95
|
+
wsUrl1 = userConfig.wsUrl;
|
|
96
|
+
}
|
|
97
|
+
// Apply defaults if not provided
|
|
98
|
+
if (!wsUrl1) {
|
|
99
|
+
console.warn(`[WS Manager] wsUrl1 not provided, using default: ${types_js_1.DEFAULT_WS_URL_1}`);
|
|
100
|
+
wsUrl1 = types_js_1.DEFAULT_WS_URL_1;
|
|
101
|
+
}
|
|
102
|
+
if (!wsUrl2) {
|
|
103
|
+
console.warn(`[WS Manager] wsUrl2 not provided, using default: ${types_js_1.DEFAULT_WS_URL_2}`);
|
|
104
|
+
wsUrl2 = types_js_1.DEFAULT_WS_URL_2;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
wsUrl1,
|
|
108
|
+
wsUrl2,
|
|
109
|
+
agentId: userConfig.agentId,
|
|
110
|
+
ak: userConfig.ak,
|
|
111
|
+
sk: userConfig.sk,
|
|
112
|
+
enableStreaming: userConfig.enableStreaming ?? true,
|
|
113
|
+
sessionCleanupTimeoutMs: userConfig.sessionCleanupTimeoutMs ?? XiaoYiWebSocketManager.DEFAULT_CLEANUP_TIMEOUT_MS,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Connect to both WebSocket servers
|
|
118
|
+
*/
|
|
119
|
+
async connect() {
|
|
120
|
+
console.log("[WS Manager] Connecting to both servers...");
|
|
121
|
+
const results = await Promise.allSettled([
|
|
122
|
+
this.connectToServer1(),
|
|
123
|
+
this.connectToServer2(),
|
|
124
|
+
]);
|
|
125
|
+
// Check if at least one connection succeeded
|
|
126
|
+
const server1Success = results[0].status === 'fulfilled';
|
|
127
|
+
const server2Success = results[1].status === 'fulfilled';
|
|
128
|
+
if (!server1Success && !server2Success) {
|
|
129
|
+
console.error("[WS Manager] Failed to connect to both servers");
|
|
130
|
+
throw new Error("Failed to connect to both servers");
|
|
131
|
+
}
|
|
132
|
+
console.log(`[WS Manager] Connection results: Server1=${server1Success}, Server2=${server2Success}`);
|
|
133
|
+
// Start application-level heartbeat (only if at least one connection is ready)
|
|
134
|
+
if (this.state1.connected || this.state2.connected) {
|
|
135
|
+
this.startAppHeartbeat();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Connect to server 1
|
|
140
|
+
*/
|
|
141
|
+
async connectToServer1() {
|
|
142
|
+
console.log(`[Server1] Connecting to ${this.config.wsUrl1}...`);
|
|
143
|
+
try {
|
|
144
|
+
const authHeaders = this.auth.generateAuthHeaders();
|
|
145
|
+
// Check if URL is wss + IP format, skip certificate verification
|
|
146
|
+
const skipCertVerify = this.isWssWithIp(this.config.wsUrl1);
|
|
147
|
+
if (skipCertVerify) {
|
|
148
|
+
console.log(`[Server1] WSS + IP detected, skipping certificate verification`);
|
|
149
|
+
}
|
|
150
|
+
this.ws1 = new ws_1.default(this.config.wsUrl1, {
|
|
151
|
+
headers: authHeaders,
|
|
152
|
+
rejectUnauthorized: !skipCertVerify,
|
|
153
|
+
});
|
|
154
|
+
this.setupWebSocketHandlers(this.ws1, 'server1');
|
|
155
|
+
await new Promise((resolve, reject) => {
|
|
156
|
+
const timeout = setTimeout(() => reject(new Error("Connection timeout")), 30000);
|
|
157
|
+
this.ws1.once("open", () => {
|
|
158
|
+
clearTimeout(timeout);
|
|
159
|
+
resolve();
|
|
160
|
+
});
|
|
161
|
+
this.ws1.once("error", (error) => {
|
|
162
|
+
clearTimeout(timeout);
|
|
163
|
+
reject(error);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
this.state1.connected = true;
|
|
167
|
+
this.state1.ready = true;
|
|
168
|
+
console.log(`[Server1] Connected successfully`);
|
|
169
|
+
this.emit("connected", "server1");
|
|
170
|
+
// Schedule connection stability check before resetting reconnect counter
|
|
171
|
+
this.scheduleStableConnectionCheck('server1');
|
|
172
|
+
// Send init message
|
|
173
|
+
this.sendInitMessage(this.ws1, 'server1');
|
|
174
|
+
// Start protocol heartbeat
|
|
175
|
+
this.startProtocolHeartbeat('server1');
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
console.error(`[Server1] Connection failed:`, error);
|
|
179
|
+
this.state1.connected = false;
|
|
180
|
+
this.state1.ready = false;
|
|
181
|
+
this.emit("error", { serverId: 'server1', error });
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Connect to server 2
|
|
187
|
+
*/
|
|
188
|
+
async connectToServer2() {
|
|
189
|
+
console.log(`[Server2] Connecting to ${this.config.wsUrl2}...`);
|
|
190
|
+
try {
|
|
191
|
+
const authHeaders = this.auth.generateAuthHeaders();
|
|
192
|
+
// Check if URL is wss + IP format, skip certificate verification
|
|
193
|
+
const skipCertVerify = this.isWssWithIp(this.config.wsUrl2);
|
|
194
|
+
if (skipCertVerify) {
|
|
195
|
+
console.log(`[Server2] WSS + IP detected, skipping certificate verification`);
|
|
196
|
+
}
|
|
197
|
+
this.ws2 = new ws_1.default(this.config.wsUrl2, {
|
|
198
|
+
headers: authHeaders,
|
|
199
|
+
rejectUnauthorized: !skipCertVerify,
|
|
200
|
+
});
|
|
201
|
+
this.setupWebSocketHandlers(this.ws2, 'server2');
|
|
202
|
+
await new Promise((resolve, reject) => {
|
|
203
|
+
const timeout = setTimeout(() => reject(new Error("Connection timeout")), 30000);
|
|
204
|
+
this.ws2.once("open", () => {
|
|
205
|
+
clearTimeout(timeout);
|
|
206
|
+
resolve();
|
|
207
|
+
});
|
|
208
|
+
this.ws2.once("error", (error) => {
|
|
209
|
+
clearTimeout(timeout);
|
|
210
|
+
reject(error);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
this.state2.connected = true;
|
|
214
|
+
this.state2.ready = true;
|
|
215
|
+
console.log(`[Server2] Connected successfully`);
|
|
216
|
+
this.emit("connected", "server2");
|
|
217
|
+
// Schedule connection stability check before resetting reconnect counter
|
|
218
|
+
this.scheduleStableConnectionCheck('server2');
|
|
219
|
+
// Send init message
|
|
220
|
+
this.sendInitMessage(this.ws2, 'server2');
|
|
221
|
+
// Start protocol heartbeat
|
|
222
|
+
this.startProtocolHeartbeat('server2');
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
console.error(`[Server2] Connection failed:`, error);
|
|
226
|
+
this.state2.connected = false;
|
|
227
|
+
this.state2.ready = false;
|
|
228
|
+
this.emit("error", { serverId: 'server2', error });
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Disconnect from all servers
|
|
234
|
+
*/
|
|
235
|
+
disconnect() {
|
|
236
|
+
console.log("[WS Manager] Disconnecting from all servers...");
|
|
237
|
+
this.clearTimers();
|
|
238
|
+
if (this.ws1) {
|
|
239
|
+
this.ws1.close();
|
|
240
|
+
this.ws1 = null;
|
|
241
|
+
}
|
|
242
|
+
if (this.ws2) {
|
|
243
|
+
this.ws2.close();
|
|
244
|
+
this.ws2 = null;
|
|
245
|
+
}
|
|
246
|
+
this.state1.connected = false;
|
|
247
|
+
this.state1.ready = false;
|
|
248
|
+
this.state2.connected = false;
|
|
249
|
+
this.state2.ready = false;
|
|
250
|
+
this.sessionServerMap.clear();
|
|
251
|
+
this.activeTasks.clear();
|
|
252
|
+
// Cleanup session cleanup state map
|
|
253
|
+
for (const [sessionId, state] of this.sessionCleanupStateMap.entries()) {
|
|
254
|
+
if (state.cleanupTimeoutId) {
|
|
255
|
+
clearTimeout(state.cleanupTimeoutId);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
this.sessionCleanupStateMap.clear();
|
|
259
|
+
this.emit("disconnected");
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Send init message to specific server
|
|
263
|
+
*/
|
|
264
|
+
sendInitMessage(ws, serverId) {
|
|
265
|
+
const initMessage = {
|
|
266
|
+
msgType: "clawd_bot_init",
|
|
267
|
+
agentId: this.config.agentId,
|
|
268
|
+
};
|
|
269
|
+
try {
|
|
270
|
+
ws.send(JSON.stringify(initMessage));
|
|
271
|
+
console.log(`[${serverId}] Sent clawd_bot_init message`);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
console.error(`[${serverId}] Failed to send init message:`, error);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Setup WebSocket event handlers for specific server
|
|
279
|
+
*/
|
|
280
|
+
setupWebSocketHandlers(ws, serverId) {
|
|
281
|
+
ws.on("open", () => {
|
|
282
|
+
console.log(`[${serverId}] WebSocket opened`);
|
|
283
|
+
});
|
|
284
|
+
ws.on("message", (data) => {
|
|
285
|
+
this.handleIncomingMessage(data, serverId);
|
|
286
|
+
});
|
|
287
|
+
ws.on("close", (code, reason) => {
|
|
288
|
+
console.log(`[${serverId}] WebSocket closed: ${code} ${reason.toString()}`);
|
|
289
|
+
// Clear stable connection timer - connection was not stable
|
|
290
|
+
this.clearStableConnectionCheck(serverId);
|
|
291
|
+
if (serverId === 'server1') {
|
|
292
|
+
this.state1.connected = false;
|
|
293
|
+
this.state1.ready = false;
|
|
294
|
+
this.clearProtocolHeartbeat('server1');
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
this.state2.connected = false;
|
|
298
|
+
this.state2.ready = false;
|
|
299
|
+
this.clearProtocolHeartbeat('server2');
|
|
300
|
+
}
|
|
301
|
+
this.emit("disconnected", serverId);
|
|
302
|
+
this.scheduleReconnect(serverId);
|
|
303
|
+
});
|
|
304
|
+
ws.on("error", (error) => {
|
|
305
|
+
console.error(`[${serverId}] WebSocket error:`, error);
|
|
306
|
+
this.emit("error", { serverId, error });
|
|
307
|
+
});
|
|
308
|
+
ws.on("pong", () => {
|
|
309
|
+
if (serverId === 'server1') {
|
|
310
|
+
this.state1.lastHeartbeat = Date.now();
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
this.state2.lastHeartbeat = Date.now();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Extract sessionId from message based on method type
|
|
319
|
+
* Different methods have sessionId in different locations:
|
|
320
|
+
* - message/stream: sessionId in params, fallback to top-level sessionId
|
|
321
|
+
* - tasks/cancel: sessionId at top level
|
|
322
|
+
* - clearContext: sessionId at top level
|
|
323
|
+
*/
|
|
324
|
+
extractSessionId(message) {
|
|
325
|
+
// For message/stream, prioritize params.sessionId, fallback to top-level sessionId
|
|
326
|
+
if (message.method === "message/stream") {
|
|
327
|
+
return message.params?.sessionId || message.sessionId;
|
|
328
|
+
}
|
|
329
|
+
// For tasks/cancel and clearContext, sessionId is at top level
|
|
330
|
+
if (message.method === "tasks/cancel" ||
|
|
331
|
+
message.method === "clearContext" ||
|
|
332
|
+
message.action === "clear") {
|
|
333
|
+
return message.sessionId;
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Handle incoming message from specific server
|
|
339
|
+
*/
|
|
340
|
+
handleIncomingMessage(data, sourceServer) {
|
|
341
|
+
try {
|
|
342
|
+
const message = JSON.parse(data.toString());
|
|
343
|
+
// Log received message
|
|
344
|
+
console.log("\n" + "=".repeat(80));
|
|
345
|
+
console.log(`[${sourceServer}] Received message:`);
|
|
346
|
+
console.log(JSON.stringify(message, null, 2));
|
|
347
|
+
console.log("=".repeat(80) + "\n");
|
|
348
|
+
// Validate agentId
|
|
349
|
+
if (message.agentId && message.agentId !== this.config.agentId) {
|
|
350
|
+
console.warn(`[${sourceServer}] Mismatched agentId: ${message.agentId}, expected: ${this.config.agentId}. Discarding.`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// Extract sessionId based on method type
|
|
354
|
+
const sessionId = this.extractSessionId(message);
|
|
355
|
+
// Record session → server mapping
|
|
356
|
+
if (sessionId) {
|
|
357
|
+
this.sessionServerMap.set(sessionId, sourceServer);
|
|
358
|
+
console.log(`[MAP] Session ${sessionId} -> ${sourceServer}`);
|
|
359
|
+
}
|
|
360
|
+
// Handle special messages (clearContext, tasks/cancel)
|
|
361
|
+
if (message.method === "clearContext") {
|
|
362
|
+
this.handleClearContext(message, sourceServer);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (message.action === "clear") {
|
|
366
|
+
this.handleClearMessage(message, sourceServer);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (message.method === "tasks/cancel" || message.action === "tasks/cancel") {
|
|
370
|
+
this.handleTasksCancelMessage(message, sourceServer);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// Handle regular A2A request
|
|
374
|
+
if (this.isA2ARequestMessage(message)) {
|
|
375
|
+
// Store task for potential cancellation (support params.sessionId or top-level sessionId)
|
|
376
|
+
const sessionId = message.params?.sessionId || message.sessionId;
|
|
377
|
+
this.activeTasks.set(message.id, {
|
|
378
|
+
sessionId: sessionId,
|
|
379
|
+
timestamp: Date.now(),
|
|
380
|
+
});
|
|
381
|
+
// Emit with server info
|
|
382
|
+
this.emit("message", message);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
console.warn(`[${sourceServer}] Unknown message format`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
console.error(`[${sourceServer}] Failed to parse message:`, error);
|
|
390
|
+
this.emit("error", { serverId: sourceServer, error });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Send A2A response message with automatic routing
|
|
395
|
+
*/
|
|
396
|
+
async sendResponse(response, taskId, sessionId, isFinal = true, append = true) {
|
|
397
|
+
// Check if session is pending cleanup
|
|
398
|
+
const cleanupState = this.sessionCleanupStateMap.get(sessionId);
|
|
399
|
+
if (cleanupState) {
|
|
400
|
+
// Session is pending cleanup, silently discard response
|
|
401
|
+
console.log(`[RESPONSE] Discarding response for pending cleanup session ${sessionId}`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// Find which server this session belongs to
|
|
405
|
+
const targetServer = this.sessionServerMap.get(sessionId);
|
|
406
|
+
if (!targetServer) {
|
|
407
|
+
console.error(`[ROUTE] Unknown server for session ${sessionId}`);
|
|
408
|
+
throw new Error(`Cannot route response: unknown session ${sessionId}`);
|
|
409
|
+
}
|
|
410
|
+
// Get the corresponding WebSocket connection
|
|
411
|
+
const ws = targetServer === 'server1' ? this.ws1 : this.ws2;
|
|
412
|
+
const state = targetServer === 'server1' ? this.state1 : this.state2;
|
|
413
|
+
if (!ws || ws.readyState !== ws_1.default.OPEN) {
|
|
414
|
+
console.error(`[ROUTE] ${targetServer} not connected for session ${sessionId}`);
|
|
415
|
+
throw new Error(`${targetServer} is not available`);
|
|
416
|
+
}
|
|
417
|
+
// Convert to JSON-RPC format
|
|
418
|
+
const jsonRpcResponse = this.convertToJsonRpcFormat(response, taskId, isFinal, append);
|
|
419
|
+
const message = {
|
|
420
|
+
msgType: "agent_response",
|
|
421
|
+
agentId: this.config.agentId,
|
|
422
|
+
sessionId: sessionId,
|
|
423
|
+
taskId: taskId,
|
|
424
|
+
msgDetail: JSON.stringify(jsonRpcResponse),
|
|
425
|
+
};
|
|
426
|
+
try {
|
|
427
|
+
ws.send(JSON.stringify(message));
|
|
428
|
+
console.log(`[ROUTE] Response sent to ${targetServer} for session ${sessionId} (isFinal=${isFinal}, append=${append})`);
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
console.error(`[ROUTE] Failed to send to ${targetServer}:`, error);
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Send clear context response to specific server
|
|
437
|
+
*/
|
|
438
|
+
async sendClearContextResponse(requestId, sessionId, success = true, targetServer) {
|
|
439
|
+
const serverId = targetServer || this.sessionServerMap.get(sessionId);
|
|
440
|
+
if (!serverId) {
|
|
441
|
+
console.error(`[CLEAR] Unknown server for session ${sessionId}`);
|
|
442
|
+
throw new Error(`Cannot send clear response: unknown session ${sessionId}`);
|
|
443
|
+
}
|
|
444
|
+
const ws = serverId === 'server1' ? this.ws1 : this.ws2;
|
|
445
|
+
if (!ws || ws.readyState !== ws_1.default.OPEN) {
|
|
446
|
+
console.error(`[CLEAR] ${serverId} not connected`);
|
|
447
|
+
throw new Error(`${serverId} is not available`);
|
|
448
|
+
}
|
|
449
|
+
const jsonRpcResponse = {
|
|
450
|
+
jsonrpc: "2.0",
|
|
451
|
+
id: requestId,
|
|
452
|
+
result: {
|
|
453
|
+
status: {
|
|
454
|
+
state: success ? "cleared" : "failed"
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
const message = {
|
|
459
|
+
msgType: "agent_response",
|
|
460
|
+
agentId: this.config.agentId,
|
|
461
|
+
sessionId: sessionId,
|
|
462
|
+
taskId: requestId,
|
|
463
|
+
msgDetail: JSON.stringify(jsonRpcResponse),
|
|
464
|
+
};
|
|
465
|
+
console.log(`\n[CLEAR] Sending clearContext response to ${serverId}:`);
|
|
466
|
+
console.log(` sessionId: ${sessionId}`);
|
|
467
|
+
console.log(` requestId: ${requestId}`);
|
|
468
|
+
console.log(` success: ${success}\n`);
|
|
469
|
+
try {
|
|
470
|
+
ws.send(JSON.stringify(message));
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
console.error(`[CLEAR] Failed to send to ${serverId}:`, error);
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Send status update (for intermediate status messages, e.g., timeout warnings)
|
|
479
|
+
* This uses "status-update" event type which keeps the conversation active
|
|
480
|
+
*/
|
|
481
|
+
async sendStatusUpdate(taskId, sessionId, message, targetServer) {
|
|
482
|
+
// Check if session is pending cleanup
|
|
483
|
+
const cleanupState = this.sessionCleanupStateMap.get(sessionId);
|
|
484
|
+
if (cleanupState) {
|
|
485
|
+
// Session is pending cleanup, silently discard status updates
|
|
486
|
+
console.log(`[STATUS] Discarding status update for pending cleanup session ${sessionId}`);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const serverId = targetServer || this.sessionServerMap.get(sessionId);
|
|
490
|
+
if (!serverId) {
|
|
491
|
+
console.error(`[STATUS] Unknown server for session ${sessionId}`);
|
|
492
|
+
throw new Error(`Cannot send status update: unknown session ${sessionId}`);
|
|
493
|
+
}
|
|
494
|
+
const ws = serverId === 'server1' ? this.ws1 : this.ws2;
|
|
495
|
+
if (!ws || ws.readyState !== ws_1.default.OPEN) {
|
|
496
|
+
console.error(`[STATUS] ${serverId} not connected`);
|
|
497
|
+
throw new Error(`${serverId} is not available`);
|
|
498
|
+
}
|
|
499
|
+
// Create unique ID for this status update
|
|
500
|
+
const messageId = `status_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
501
|
+
const jsonRpcResponse = {
|
|
502
|
+
jsonrpc: "2.0",
|
|
503
|
+
id: messageId,
|
|
504
|
+
result: {
|
|
505
|
+
taskId: taskId,
|
|
506
|
+
kind: "status-update",
|
|
507
|
+
final: false, // IMPORTANT: Not final, keeps conversation active
|
|
508
|
+
status: {
|
|
509
|
+
message: {
|
|
510
|
+
role: "agent",
|
|
511
|
+
parts: [
|
|
512
|
+
{
|
|
513
|
+
kind: "text",
|
|
514
|
+
text: message,
|
|
515
|
+
},
|
|
516
|
+
],
|
|
517
|
+
},
|
|
518
|
+
state: "working", // Indicates task is still being processed
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
const outboundMessage = {
|
|
523
|
+
msgType: "agent_response",
|
|
524
|
+
agentId: this.config.agentId,
|
|
525
|
+
sessionId: sessionId,
|
|
526
|
+
taskId: taskId,
|
|
527
|
+
msgDetail: JSON.stringify(jsonRpcResponse),
|
|
528
|
+
};
|
|
529
|
+
console.log(`[STATUS] Sending status update to ${serverId}:`);
|
|
530
|
+
console.log(` sessionId: ${sessionId}`);
|
|
531
|
+
console.log(` taskId: ${taskId}`);
|
|
532
|
+
console.log(` message: ${message}`);
|
|
533
|
+
console.log(` final: false, state: working\n`);
|
|
534
|
+
try {
|
|
535
|
+
ws.send(JSON.stringify(outboundMessage));
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
console.error(`[STATUS] Failed to send to ${serverId}:`, error);
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Send PUSH message (主动推送) via HTTP API
|
|
544
|
+
*
|
|
545
|
+
* This is used when SubAgent completes execution and needs to push results to user
|
|
546
|
+
* independently of the original A2A request-response flow.
|
|
547
|
+
*
|
|
548
|
+
* Unlike sendResponse (which responds to a specific request via WebSocket), push messages are
|
|
549
|
+
* sent through HTTP API asynchronously.
|
|
550
|
+
*
|
|
551
|
+
* @param sessionId - User's session ID
|
|
552
|
+
* @param message - Message content to push
|
|
553
|
+
*
|
|
554
|
+
* Reference: 华为小艺推送消息 API
|
|
555
|
+
* TODO: 实现实际的推送消息发送逻辑
|
|
556
|
+
*/
|
|
557
|
+
async sendPushMessage(sessionId, message) {
|
|
558
|
+
console.log(`[PUSH] Would send push message to session ${sessionId}, length: ${message.length} chars`);
|
|
559
|
+
console.log(`[PUSH] Content: ${message.substring(0, 50)}${message.length > 50 ? "..." : ""}`);
|
|
560
|
+
// TODO: Implement actual push message sending via HTTP API
|
|
561
|
+
// Need to confirm correct push message format with XiaoYi API documentation
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Send an outbound WebSocket message directly.
|
|
565
|
+
* This is a low-level method that sends a pre-formatted OutboundWebSocketMessage.
|
|
566
|
+
*
|
|
567
|
+
* @param sessionId - Session ID for routing
|
|
568
|
+
* @param message - Pre-formatted outbound message
|
|
569
|
+
*/
|
|
570
|
+
async sendMessage(sessionId, message) {
|
|
571
|
+
// Check if session is pending cleanup
|
|
572
|
+
const cleanupState = this.sessionCleanupStateMap.get(sessionId);
|
|
573
|
+
if (cleanupState) {
|
|
574
|
+
console.log(`[SEND_MESSAGE] Discarding message for pending cleanup session ${sessionId}`);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
// Find which server this session belongs to
|
|
578
|
+
const targetServer = this.sessionServerMap.get(sessionId);
|
|
579
|
+
if (!targetServer) {
|
|
580
|
+
console.error(`[SEND_MESSAGE] Unknown server for session ${sessionId}`);
|
|
581
|
+
throw new Error(`Cannot route message: unknown session ${sessionId}`);
|
|
582
|
+
}
|
|
583
|
+
// Get the corresponding WebSocket connection
|
|
584
|
+
const ws = targetServer === 'server1' ? this.ws1 : this.ws2;
|
|
585
|
+
const state = targetServer === 'server1' ? this.state1 : this.state2;
|
|
586
|
+
if (!ws || ws.readyState !== ws_1.default.OPEN) {
|
|
587
|
+
console.error(`[SEND_MESSAGE] ${targetServer} not connected for session ${sessionId}`);
|
|
588
|
+
throw new Error(`${targetServer} is not available`);
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
ws.send(JSON.stringify(message));
|
|
592
|
+
console.log(`[SEND_MESSAGE] Message sent to ${targetServer} for session ${sessionId}, msgType=${message.msgType}`);
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
console.error(`[SEND_MESSAGE] Failed to send to ${targetServer}:`, error);
|
|
596
|
+
throw error;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Send tasks cancel response to specific server
|
|
601
|
+
*/
|
|
602
|
+
async sendTasksCancelResponse(requestId, sessionId, success = true, targetServer) {
|
|
603
|
+
const serverId = targetServer || this.sessionServerMap.get(sessionId);
|
|
604
|
+
if (!serverId) {
|
|
605
|
+
console.error(`[CANCEL] Unknown server for session ${sessionId}`);
|
|
606
|
+
throw new Error(`Cannot send cancel response: unknown session ${sessionId}`);
|
|
607
|
+
}
|
|
608
|
+
const ws = serverId === 'server1' ? this.ws1 : this.ws2;
|
|
609
|
+
if (!ws || ws.readyState !== ws_1.default.OPEN) {
|
|
610
|
+
console.error(`[CANCEL] ${serverId} not connected`);
|
|
611
|
+
throw new Error(`${serverId} is not available`);
|
|
612
|
+
}
|
|
613
|
+
const jsonRpcResponse = {
|
|
614
|
+
jsonrpc: "2.0",
|
|
615
|
+
id: requestId,
|
|
616
|
+
result: {
|
|
617
|
+
id: requestId,
|
|
618
|
+
status: {
|
|
619
|
+
state: success ? "canceled" : "failed"
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
const message = {
|
|
624
|
+
msgType: "agent_response",
|
|
625
|
+
agentId: this.config.agentId,
|
|
626
|
+
sessionId: sessionId,
|
|
627
|
+
taskId: requestId,
|
|
628
|
+
msgDetail: JSON.stringify(jsonRpcResponse),
|
|
629
|
+
};
|
|
630
|
+
try {
|
|
631
|
+
ws.send(JSON.stringify(message));
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
console.error(`[CANCEL] Failed to send to ${serverId}:`, error);
|
|
635
|
+
throw error;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Handle clearContext method
|
|
640
|
+
*/
|
|
641
|
+
handleClearContext(message, sourceServer) {
|
|
642
|
+
const sessionId = this.extractSessionId(message);
|
|
643
|
+
if (!sessionId) {
|
|
644
|
+
console.error(`[${sourceServer}] Failed to extract sessionId from clearContext message`);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
console.log(`[${sourceServer}] Received clearContext for session: ${sessionId}`);
|
|
648
|
+
this.sendClearContextResponse(message.id, sessionId, true, sourceServer)
|
|
649
|
+
.catch(error => console.error(`[${sourceServer}] Failed to send clearContext response:`, error));
|
|
650
|
+
this.emit("clear", {
|
|
651
|
+
sessionId: sessionId,
|
|
652
|
+
id: message.id,
|
|
653
|
+
serverId: sourceServer,
|
|
654
|
+
});
|
|
655
|
+
// Mark session for cleanup instead of immediate deletion
|
|
656
|
+
this.markSessionForCleanup(sessionId, sourceServer, this.config.sessionCleanupTimeoutMs ?? XiaoYiWebSocketManager.DEFAULT_CLEANUP_TIMEOUT_MS);
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Handle clear message (legacy format)
|
|
660
|
+
*/
|
|
661
|
+
handleClearMessage(message, sourceServer) {
|
|
662
|
+
console.log(`[${sourceServer}] Received clear message for session: ${message.sessionId}`);
|
|
663
|
+
this.sendClearContextResponse(message.id, message.sessionId, true, sourceServer)
|
|
664
|
+
.catch(error => console.error(`[${sourceServer}] Failed to send clear response:`, error));
|
|
665
|
+
this.emit("clear", {
|
|
666
|
+
sessionId: message.sessionId,
|
|
667
|
+
id: message.id,
|
|
668
|
+
serverId: sourceServer,
|
|
669
|
+
});
|
|
670
|
+
// Mark session for cleanup instead of immediate deletion
|
|
671
|
+
this.markSessionForCleanup(message.sessionId, sourceServer, this.config.sessionCleanupTimeoutMs ?? XiaoYiWebSocketManager.DEFAULT_CLEANUP_TIMEOUT_MS);
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Handle tasks/cancel message
|
|
675
|
+
*/
|
|
676
|
+
handleTasksCancelMessage(message, sourceServer) {
|
|
677
|
+
const sessionId = this.extractSessionId(message);
|
|
678
|
+
if (!sessionId) {
|
|
679
|
+
console.error(`[${sourceServer}] Failed to extract sessionId from tasks/cancel message`);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const effectiveTaskId = message.taskId || message.id;
|
|
683
|
+
console.log("\n" + "=".repeat(60));
|
|
684
|
+
console.log(`[${sourceServer}] Received cancel request`);
|
|
685
|
+
console.log(` Session: ${sessionId}`);
|
|
686
|
+
console.log(` Task ID: ${effectiveTaskId}`);
|
|
687
|
+
console.log("=".repeat(60) + "\n");
|
|
688
|
+
this.sendTasksCancelResponse(message.id, sessionId, true, sourceServer)
|
|
689
|
+
.catch(error => console.error(`[${sourceServer}] Failed to send cancel response:`, error));
|
|
690
|
+
this.emit("cancel", {
|
|
691
|
+
sessionId: sessionId,
|
|
692
|
+
taskId: effectiveTaskId,
|
|
693
|
+
id: message.id,
|
|
694
|
+
serverId: sourceServer,
|
|
695
|
+
});
|
|
696
|
+
this.activeTasks.delete(effectiveTaskId);
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Convert A2AResponseMessage to JSON-RPC 2.0 format
|
|
700
|
+
*/
|
|
701
|
+
convertToJsonRpcFormat(response, taskId, isFinal = true, append = true) {
|
|
702
|
+
const artifactId = `artifact_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
703
|
+
if (response.status === "error" && response.error) {
|
|
704
|
+
return {
|
|
705
|
+
jsonrpc: "2.0",
|
|
706
|
+
id: response.messageId,
|
|
707
|
+
error: {
|
|
708
|
+
code: response.error.code,
|
|
709
|
+
message: response.error.message,
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
const parts = [];
|
|
714
|
+
if (response.content.type === "text" && response.content.text) {
|
|
715
|
+
// When isFinal=true, use empty string for text (no content needed for final chunk)
|
|
716
|
+
const textContent = isFinal ? "" : response.content.text;
|
|
717
|
+
parts.push({
|
|
718
|
+
kind: "text",
|
|
719
|
+
text: textContent,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
else if (response.content.type === "file") {
|
|
723
|
+
parts.push({
|
|
724
|
+
kind: "file",
|
|
725
|
+
file: {
|
|
726
|
+
name: response.content.fileName || "file",
|
|
727
|
+
mimeType: response.content.mimeType || "application/octet-stream",
|
|
728
|
+
uri: response.content.mediaUrl,
|
|
729
|
+
},
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
// When isFinal=true, append should be true and text should be empty
|
|
733
|
+
const artifactEvent = {
|
|
734
|
+
taskId: taskId,
|
|
735
|
+
kind: "artifact-update",
|
|
736
|
+
append: isFinal ? true : append,
|
|
737
|
+
lastChunk: isFinal,
|
|
738
|
+
final: isFinal,
|
|
739
|
+
artifact: {
|
|
740
|
+
artifactId: artifactId,
|
|
741
|
+
parts: parts,
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
return {
|
|
745
|
+
jsonrpc: "2.0",
|
|
746
|
+
id: response.messageId,
|
|
747
|
+
result: artifactEvent,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Check if at least one server is ready
|
|
752
|
+
*/
|
|
753
|
+
isReady() {
|
|
754
|
+
return (this.state1.ready && this.ws1?.readyState === ws_1.default.OPEN) ||
|
|
755
|
+
(this.state2.ready && this.ws2?.readyState === ws_1.default.OPEN);
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Get combined connection state
|
|
759
|
+
*/
|
|
760
|
+
getState() {
|
|
761
|
+
const connected = this.state1.connected || this.state2.connected;
|
|
762
|
+
const authenticated = connected; // Auth via headers
|
|
763
|
+
return {
|
|
764
|
+
connected,
|
|
765
|
+
authenticated,
|
|
766
|
+
lastHeartbeat: Math.max(this.state1.lastHeartbeat, this.state2.lastHeartbeat),
|
|
767
|
+
lastAppHeartbeat: 0,
|
|
768
|
+
reconnectAttempts: Math.max(this.state1.reconnectAttempts, this.state2.reconnectAttempts),
|
|
769
|
+
maxReconnectAttempts: 50,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Get individual server states
|
|
774
|
+
*/
|
|
775
|
+
getServerStates() {
|
|
776
|
+
return {
|
|
777
|
+
server1: { ...this.state1 },
|
|
778
|
+
server2: { ...this.state2 },
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Start protocol-level heartbeat for specific server
|
|
783
|
+
*/
|
|
784
|
+
startProtocolHeartbeat(serverId) {
|
|
785
|
+
const interval = setInterval(() => {
|
|
786
|
+
const ws = serverId === 'server1' ? this.ws1 : this.ws2;
|
|
787
|
+
const state = serverId === 'server1' ? this.state1 : this.state2;
|
|
788
|
+
if (ws && ws.readyState === ws_1.default.OPEN) {
|
|
789
|
+
ws.ping();
|
|
790
|
+
const now = Date.now();
|
|
791
|
+
if (state.lastHeartbeat > 0 && now - state.lastHeartbeat > 90000) {
|
|
792
|
+
console.warn(`[${serverId}] Heartbeat timeout, reconnecting...`);
|
|
793
|
+
ws.close();
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}, 30000);
|
|
797
|
+
if (serverId === 'server1') {
|
|
798
|
+
this.heartbeatTimeout1 = interval;
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
this.heartbeatTimeout2 = interval;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Clear protocol heartbeat for specific server
|
|
806
|
+
*/
|
|
807
|
+
clearProtocolHeartbeat(serverId) {
|
|
808
|
+
const interval = serverId === 'server1' ? this.heartbeatTimeout1 : this.heartbeatTimeout2;
|
|
809
|
+
if (interval) {
|
|
810
|
+
clearInterval(interval);
|
|
811
|
+
if (serverId === 'server1') {
|
|
812
|
+
this.heartbeatTimeout1 = undefined;
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
this.heartbeatTimeout2 = undefined;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Start application-level heartbeat (shared across both servers)
|
|
821
|
+
*/
|
|
822
|
+
startAppHeartbeat() {
|
|
823
|
+
this.appHeartbeatInterval = setInterval(() => {
|
|
824
|
+
const heartbeatMessage = {
|
|
825
|
+
msgType: "heartbeat",
|
|
826
|
+
agentId: this.config.agentId,
|
|
827
|
+
};
|
|
828
|
+
// Send to all connected servers
|
|
829
|
+
if (this.ws1?.readyState === ws_1.default.OPEN) {
|
|
830
|
+
try {
|
|
831
|
+
this.ws1.send(JSON.stringify(heartbeatMessage));
|
|
832
|
+
}
|
|
833
|
+
catch (error) {
|
|
834
|
+
console.error('[Server1] Failed to send app heartbeat:', error);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (this.ws2?.readyState === ws_1.default.OPEN) {
|
|
838
|
+
try {
|
|
839
|
+
this.ws2.send(JSON.stringify(heartbeatMessage));
|
|
840
|
+
}
|
|
841
|
+
catch (error) {
|
|
842
|
+
console.error('[Server2] Failed to send app heartbeat:', error);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}, 20000);
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Schedule reconnection for specific server
|
|
849
|
+
*/
|
|
850
|
+
scheduleReconnect(serverId) {
|
|
851
|
+
const state = serverId === 'server1' ? this.state1 : this.state2;
|
|
852
|
+
if (state.reconnectAttempts >= 50) {
|
|
853
|
+
console.error(`[${serverId}] Max reconnection attempts reached`);
|
|
854
|
+
this.emit("maxReconnectAttemptsReached", serverId);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const delay = Math.min(2000 * Math.pow(2, state.reconnectAttempts), 60000);
|
|
858
|
+
state.reconnectAttempts++;
|
|
859
|
+
console.log(`[${serverId}] Scheduling reconnect attempt ${state.reconnectAttempts}/50 in ${delay}ms`);
|
|
860
|
+
const timeout = setTimeout(async () => {
|
|
861
|
+
try {
|
|
862
|
+
if (serverId === 'server1') {
|
|
863
|
+
await this.connectToServer1();
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
await this.connectToServer2();
|
|
867
|
+
}
|
|
868
|
+
console.log(`[${serverId}] Reconnected successfully`);
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
console.error(`[${serverId}] Reconnection failed:`, error);
|
|
872
|
+
this.scheduleReconnect(serverId);
|
|
873
|
+
}
|
|
874
|
+
}, delay);
|
|
875
|
+
if (serverId === 'server1') {
|
|
876
|
+
this.reconnectTimeout1 = timeout;
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
this.reconnectTimeout2 = timeout;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Clear all timers
|
|
884
|
+
*/
|
|
885
|
+
clearTimers() {
|
|
886
|
+
if (this.heartbeatTimeout1) {
|
|
887
|
+
clearInterval(this.heartbeatTimeout1);
|
|
888
|
+
this.heartbeatTimeout1 = undefined;
|
|
889
|
+
}
|
|
890
|
+
if (this.heartbeatTimeout2) {
|
|
891
|
+
clearInterval(this.heartbeatTimeout2);
|
|
892
|
+
this.heartbeatTimeout2 = undefined;
|
|
893
|
+
}
|
|
894
|
+
if (this.appHeartbeatInterval) {
|
|
895
|
+
clearInterval(this.appHeartbeatInterval);
|
|
896
|
+
this.appHeartbeatInterval = undefined;
|
|
897
|
+
}
|
|
898
|
+
if (this.reconnectTimeout1) {
|
|
899
|
+
clearTimeout(this.reconnectTimeout1);
|
|
900
|
+
this.reconnectTimeout1 = undefined;
|
|
901
|
+
}
|
|
902
|
+
if (this.reconnectTimeout2) {
|
|
903
|
+
clearTimeout(this.reconnectTimeout2);
|
|
904
|
+
this.reconnectTimeout2 = undefined;
|
|
905
|
+
}
|
|
906
|
+
// Clear stable connection timers
|
|
907
|
+
this.clearStableConnectionCheck('server1');
|
|
908
|
+
this.clearStableConnectionCheck('server2');
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Schedule a connection stability check
|
|
912
|
+
* Only reset reconnect counter after connection has been stable for threshold time
|
|
913
|
+
*/
|
|
914
|
+
scheduleStableConnectionCheck(serverId) {
|
|
915
|
+
const timer = setTimeout(() => {
|
|
916
|
+
const state = serverId === 'server1' ? this.state1 : this.state2;
|
|
917
|
+
if (state.connected) {
|
|
918
|
+
console.log(`[${serverId}] Connection stable for ${XiaoYiWebSocketManager.STABLE_CONNECTION_THRESHOLD}ms, resetting reconnect counter`);
|
|
919
|
+
state.reconnectAttempts = 0;
|
|
920
|
+
}
|
|
921
|
+
}, XiaoYiWebSocketManager.STABLE_CONNECTION_THRESHOLD);
|
|
922
|
+
if (serverId === 'server1') {
|
|
923
|
+
this.stableConnectionTimer1 = timer;
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
this.stableConnectionTimer2 = timer;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Clear the connection stability check timer
|
|
931
|
+
*/
|
|
932
|
+
clearStableConnectionCheck(serverId) {
|
|
933
|
+
const timer = serverId === 'server1' ? this.stableConnectionTimer1 : this.stableConnectionTimer2;
|
|
934
|
+
if (timer) {
|
|
935
|
+
clearTimeout(timer);
|
|
936
|
+
if (serverId === 'server1') {
|
|
937
|
+
this.stableConnectionTimer1 = undefined;
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
this.stableConnectionTimer2 = undefined;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Type guard for A2A request messages
|
|
946
|
+
* sessionId can be in params OR at top level (fallback)
|
|
947
|
+
*/
|
|
948
|
+
isA2ARequestMessage(data) {
|
|
949
|
+
return data &&
|
|
950
|
+
typeof data.agentId === "string" &&
|
|
951
|
+
data.jsonrpc === "2.0" &&
|
|
952
|
+
typeof data.id === "string" &&
|
|
953
|
+
data.method === "message/stream" &&
|
|
954
|
+
data.params &&
|
|
955
|
+
typeof data.params.id === "string" &&
|
|
956
|
+
// sessionId can be in params OR at top level
|
|
957
|
+
(typeof data.params.sessionId === "string" || typeof data.sessionId === "string") &&
|
|
958
|
+
data.params.message &&
|
|
959
|
+
typeof data.params.message.role === "string" &&
|
|
960
|
+
Array.isArray(data.params.message.parts);
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Get active tasks
|
|
964
|
+
*/
|
|
965
|
+
getActiveTasks() {
|
|
966
|
+
return new Map(this.activeTasks);
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Remove task from active tasks
|
|
970
|
+
*/
|
|
971
|
+
removeActiveTask(taskId) {
|
|
972
|
+
this.activeTasks.delete(taskId);
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Get server for a specific session
|
|
976
|
+
*/
|
|
977
|
+
getServerForSession(sessionId) {
|
|
978
|
+
return this.sessionServerMap.get(sessionId);
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Remove session mapping
|
|
982
|
+
*/
|
|
983
|
+
removeSession(sessionId) {
|
|
984
|
+
this.sessionServerMap.delete(sessionId);
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Mark a session for delayed cleanup
|
|
988
|
+
* @param sessionId The session ID to mark for cleanup
|
|
989
|
+
* @param serverId The server ID associated with this session
|
|
990
|
+
* @param timeoutMs Timeout in milliseconds before forcing cleanup
|
|
991
|
+
*/
|
|
992
|
+
markSessionForCleanup(sessionId, serverId, timeoutMs) {
|
|
993
|
+
// Check if already marked
|
|
994
|
+
const existingState = this.sessionCleanupStateMap.get(sessionId);
|
|
995
|
+
if (existingState) {
|
|
996
|
+
// Already pending cleanup, reset timeout
|
|
997
|
+
if (existingState.cleanupTimeoutId) {
|
|
998
|
+
clearTimeout(existingState.cleanupTimeoutId);
|
|
999
|
+
}
|
|
1000
|
+
console.log(`[CLEANUP] Session ${sessionId} already pending cleanup, resetting timeout`);
|
|
1001
|
+
}
|
|
1002
|
+
// Create new cleanup state
|
|
1003
|
+
const newState = {
|
|
1004
|
+
sessionId,
|
|
1005
|
+
serverId,
|
|
1006
|
+
markedForCleanupAt: Date.now(),
|
|
1007
|
+
reason: 'user_cleared',
|
|
1008
|
+
};
|
|
1009
|
+
// Start cleanup timeout
|
|
1010
|
+
const timeoutId = setTimeout(() => {
|
|
1011
|
+
console.log(`[CLEANUP] Timeout reached for session ${sessionId}, forcing cleanup`);
|
|
1012
|
+
this.forceCleanupSession(sessionId);
|
|
1013
|
+
}, timeoutMs);
|
|
1014
|
+
newState.cleanupTimeoutId = timeoutId;
|
|
1015
|
+
this.sessionCleanupStateMap.set(sessionId, newState);
|
|
1016
|
+
console.log(`[CLEANUP] Session ${sessionId} marked for cleanup (timeout: ${timeoutMs}ms)`);
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Force cleanup a session immediately
|
|
1020
|
+
* @param sessionId The session ID to cleanup
|
|
1021
|
+
*/
|
|
1022
|
+
forceCleanupSession(sessionId) {
|
|
1023
|
+
// Check if already cleaned
|
|
1024
|
+
const state = this.sessionCleanupStateMap.get(sessionId);
|
|
1025
|
+
if (!state) {
|
|
1026
|
+
console.log(`[CLEANUP] Session ${sessionId} already cleaned up, skipping`);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
// Clear timeout
|
|
1030
|
+
if (state.cleanupTimeoutId) {
|
|
1031
|
+
clearTimeout(state.cleanupTimeoutId);
|
|
1032
|
+
}
|
|
1033
|
+
// Remove from both maps
|
|
1034
|
+
this.sessionServerMap.delete(sessionId);
|
|
1035
|
+
this.sessionCleanupStateMap.delete(sessionId);
|
|
1036
|
+
console.log(`[CLEANUP] Session ${sessionId} cleanup completed`);
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Check if a session is pending cleanup
|
|
1040
|
+
* @param sessionId The session ID to check
|
|
1041
|
+
* @returns True if session is pending cleanup
|
|
1042
|
+
*/
|
|
1043
|
+
isSessionPendingCleanup(sessionId) {
|
|
1044
|
+
return this.sessionCleanupStateMap.has(sessionId);
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Get cleanup state for a session
|
|
1048
|
+
* @param sessionId The session ID to check
|
|
1049
|
+
* @returns Cleanup state if exists, undefined otherwise
|
|
1050
|
+
*/
|
|
1051
|
+
getSessionCleanupState(sessionId) {
|
|
1052
|
+
return this.sessionCleanupStateMap.get(sessionId);
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Update accumulated text for a pending cleanup session
|
|
1056
|
+
* @param sessionId The session ID
|
|
1057
|
+
* @param text The accumulated text
|
|
1058
|
+
*/
|
|
1059
|
+
updateAccumulatedTextForCleanup(sessionId, text) {
|
|
1060
|
+
const state = this.sessionCleanupStateMap.get(sessionId);
|
|
1061
|
+
if (state) {
|
|
1062
|
+
state.accumulatedText = text;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
exports.XiaoYiWebSocketManager = XiaoYiWebSocketManager;
|
|
1067
|
+
XiaoYiWebSocketManager.DEFAULT_CLEANUP_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
|
1068
|
+
XiaoYiWebSocketManager.STABLE_CONNECTION_THRESHOLD = 10000; // 10 seconds
|