@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.
Files changed (52) hide show
  1. package/README.md +207 -0
  2. package/dist/auth.d.ts +36 -0
  3. package/dist/auth.js +111 -0
  4. package/dist/channel.d.ts +189 -0
  5. package/dist/channel.js +354 -0
  6. package/dist/config-schema.d.ts +46 -0
  7. package/dist/config-schema.js +28 -0
  8. package/dist/file-download.d.ts +17 -0
  9. package/dist/file-download.js +69 -0
  10. package/dist/file-handler.d.ts +36 -0
  11. package/dist/file-handler.js +113 -0
  12. package/dist/index.d.ts +29 -0
  13. package/dist/index.js +49 -0
  14. package/dist/onboarding.d.ts +6 -0
  15. package/dist/onboarding.js +167 -0
  16. package/dist/push.d.ts +28 -0
  17. package/dist/push.js +135 -0
  18. package/dist/runtime.d.ts +191 -0
  19. package/dist/runtime.js +438 -0
  20. package/dist/types.d.ts +280 -0
  21. package/dist/types.js +8 -0
  22. package/dist/websocket.d.ts +219 -0
  23. package/dist/websocket.js +1068 -0
  24. package/dist/xiaoyi-media.d.ts +81 -0
  25. package/dist/xiaoyi-media.js +216 -0
  26. package/dist/xy-bot.d.ts +19 -0
  27. package/dist/xy-bot.js +277 -0
  28. package/dist/xy-client.d.ts +26 -0
  29. package/dist/xy-client.js +78 -0
  30. package/dist/xy-config.d.ts +18 -0
  31. package/dist/xy-config.js +37 -0
  32. package/dist/xy-formatter.d.ts +94 -0
  33. package/dist/xy-formatter.js +303 -0
  34. package/dist/xy-monitor.d.ts +17 -0
  35. package/dist/xy-monitor.js +194 -0
  36. package/dist/xy-parser.d.ts +49 -0
  37. package/dist/xy-parser.js +109 -0
  38. package/dist/xy-reply-dispatcher.d.ts +17 -0
  39. package/dist/xy-reply-dispatcher.js +308 -0
  40. package/dist/xy-tools/session-manager.d.ts +29 -0
  41. package/dist/xy-tools/session-manager.js +80 -0
  42. package/dist/xy-utils/config-manager.d.ts +26 -0
  43. package/dist/xy-utils/config-manager.js +61 -0
  44. package/dist/xy-utils/crypto.d.ts +8 -0
  45. package/dist/xy-utils/crypto.js +21 -0
  46. package/dist/xy-utils/logger.d.ts +6 -0
  47. package/dist/xy-utils/logger.js +37 -0
  48. package/dist/xy-utils/session.d.ts +34 -0
  49. package/dist/xy-utils/session.js +55 -0
  50. package/openclaw.plugin.json +9 -0
  51. package/package.json +73 -0
  52. 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