@ynhcj/xiaoyi-channel 0.0.15-beta → 0.0.15-next

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 (68) hide show
  1. package/dist/src/bot.js +120 -41
  2. package/dist/src/channel.js +17 -4
  3. package/dist/src/client.js +11 -24
  4. package/dist/src/config.js +2 -2
  5. package/dist/src/file-upload.d.ts +5 -0
  6. package/dist/src/file-upload.js +92 -0
  7. package/dist/src/formatter.d.ts +14 -0
  8. package/dist/src/formatter.js +46 -2
  9. package/dist/src/monitor.js +47 -5
  10. package/dist/src/outbound.js +52 -6
  11. package/dist/src/parser.d.ts +7 -0
  12. package/dist/src/parser.js +22 -0
  13. package/dist/src/push.d.ts +8 -1
  14. package/dist/src/push.js +30 -22
  15. package/dist/src/reply-dispatcher.d.ts +1 -0
  16. package/dist/src/reply-dispatcher.js +115 -100
  17. package/dist/src/task-manager.d.ts +55 -0
  18. package/dist/src/task-manager.js +136 -0
  19. package/dist/src/tools/calendar-tool.js +5 -3
  20. package/dist/src/tools/call-phone-tool.d.ts +5 -0
  21. package/dist/src/tools/call-phone-tool.js +183 -0
  22. package/dist/src/tools/create-alarm-tool.d.ts +7 -0
  23. package/dist/src/tools/create-alarm-tool.js +430 -0
  24. package/dist/src/tools/delete-alarm-tool.d.ts +11 -0
  25. package/dist/src/tools/delete-alarm-tool.js +227 -0
  26. package/dist/src/tools/image-reading-tool.d.ts +5 -0
  27. package/dist/src/tools/image-reading-tool.js +337 -0
  28. package/dist/src/tools/location-tool.js +8 -11
  29. package/dist/src/tools/modify-alarm-tool.d.ts +9 -0
  30. package/dist/src/tools/modify-alarm-tool.js +449 -0
  31. package/dist/src/tools/modify-note-tool.js +4 -9
  32. package/dist/src/tools/note-tool.js +32 -21
  33. package/dist/src/tools/search-alarm-tool.d.ts +8 -0
  34. package/dist/src/tools/search-alarm-tool.js +391 -0
  35. package/dist/src/tools/search-calendar-tool.js +6 -3
  36. package/dist/src/tools/search-contact-tool.js +24 -3
  37. package/dist/src/tools/search-file-tool.d.ts +5 -0
  38. package/dist/src/tools/search-file-tool.js +185 -0
  39. package/dist/src/tools/search-message-tool.d.ts +5 -0
  40. package/dist/src/tools/search-message-tool.js +173 -0
  41. package/dist/src/tools/search-note-tool.js +2 -2
  42. package/dist/src/tools/search-photo-gallery-tool.js +54 -17
  43. package/dist/src/tools/send-file-to-user-tool.d.ts +5 -0
  44. package/dist/src/tools/send-file-to-user-tool.js +318 -0
  45. package/dist/src/tools/send-message-tool.d.ts +5 -0
  46. package/dist/src/tools/send-message-tool.js +189 -0
  47. package/dist/src/tools/session-manager.d.ts +15 -0
  48. package/dist/src/tools/session-manager.js +99 -18
  49. package/dist/src/tools/upload-file-tool.d.ts +13 -0
  50. package/dist/src/tools/upload-file-tool.js +265 -0
  51. package/dist/src/tools/upload-photo-tool.js +14 -4
  52. package/dist/src/tools/view-push-result-tool.d.ts +5 -0
  53. package/dist/src/tools/view-push-result-tool.js +118 -0
  54. package/dist/src/tools/xiaoyi-collection-tool.d.ts +5 -0
  55. package/dist/src/tools/xiaoyi-collection-tool.js +190 -0
  56. package/dist/src/tools/xiaoyi-gui-tool.d.ts +6 -0
  57. package/dist/src/tools/xiaoyi-gui-tool.js +151 -0
  58. package/dist/src/trigger-handler.d.ts +22 -0
  59. package/dist/src/trigger-handler.js +59 -0
  60. package/dist/src/types.d.ts +1 -8
  61. package/dist/src/types.js +4 -0
  62. package/dist/src/utils/pushdata-manager.d.ts +28 -0
  63. package/dist/src/utils/pushdata-manager.js +171 -0
  64. package/dist/src/utils/pushid-manager.d.ts +12 -0
  65. package/dist/src/utils/pushid-manager.js +105 -0
  66. package/dist/src/websocket.d.ts +26 -31
  67. package/dist/src/websocket.js +227 -267
  68. package/package.json +1 -1
package/dist/src/bot.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { getXYRuntime } from "./runtime.js";
2
2
  import { createXYReplyDispatcher } from "./reply-dispatcher.js";
3
- import { parseA2AMessage, extractTextFromParts, extractFileParts, extractPushId } from "./parser.js";
3
+ import { parseA2AMessage, extractTextFromParts, extractFileParts, extractPushId, extractTriggerData } from "./parser.js";
4
4
  import { downloadFilesFromParts } from "./file-download.js";
5
5
  import { resolveXYConfig } from "./config.js";
6
- import { sendStatusUpdate, sendClearContextResponse, sendTasksCancelResponse } from "./formatter.js";
7
- import { registerSession, unregisterSession } from "./tools/session-manager.js";
6
+ import { sendStatusUpdate, sendClearContextResponse, sendTasksCancelResponse, sendA2AResponse } from "./formatter.js";
7
+ import { registerSession, unregisterSession, runWithSessionContext } from "./tools/session-manager.js";
8
8
  import { configManager } from "./utils/config-manager.js";
9
+ import { addPushId } from "./utils/pushid-manager.js";
10
+ import { getPushDataById } from "./utils/pushdata-manager.js";
11
+ import { registerTaskId, decrementTaskIdRef, lockTaskId, unlockTaskId, hasActiveTask, } from "./task-manager.js";
9
12
  /**
10
13
  * Handle an incoming A2A message.
11
14
  * This is the main entry point for message processing.
@@ -56,6 +59,60 @@ export async function handleXYMessage(params) {
56
59
  }
57
60
  // Parse the A2A message (for regular messages)
58
61
  const parsed = parseA2AMessage(message);
62
+ // ========== 检测 Trigger 消息 ==========
63
+ // 如果消息中包含 Trigger 事件数据,直接返回 pushData 内容,不走正常流程
64
+ const triggerData = extractTriggerData(parsed.parts);
65
+ if (triggerData) {
66
+ log(`[BOT] 📌 Detected Trigger message with pushDataId: ${triggerData.pushDataId}`);
67
+ log(`[BOT] - Session ID: ${parsed.sessionId}`);
68
+ log(`[BOT] - Task ID: ${parsed.taskId}`);
69
+ try {
70
+ // 读取 pushData
71
+ const pushDataItem = await getPushDataById(triggerData.pushDataId);
72
+ if (!pushDataItem) {
73
+ error(`[BOT] ❌ pushData not found for ID: ${triggerData.pushDataId}`);
74
+ return;
75
+ }
76
+ log(`[BOT] ✅ Found pushData, sending direct response`);
77
+ log(`[BOT] - pushDataId: ${pushDataItem.pushDataId}`);
78
+ log(`[BOT] - time: ${pushDataItem.time}`);
79
+ log(`[BOT] - content length: ${pushDataItem.dataDetail.length} chars`);
80
+ const config = resolveXYConfig(cfg);
81
+ // 直接发送响应(final=true,不走 openclaw 流程)
82
+ await sendA2AResponse({
83
+ config,
84
+ sessionId: parsed.sessionId,
85
+ taskId: parsed.taskId,
86
+ messageId: parsed.messageId,
87
+ text: pushDataItem.dataDetail,
88
+ append: false,
89
+ final: true,
90
+ });
91
+ log(`[BOT] ✅ Trigger response sent successfully, exiting early`);
92
+ return; // 提前返回,不继续处理
93
+ }
94
+ catch (err) {
95
+ error(`[BOT] ❌ Failed to handle Trigger message:`, err);
96
+ return;
97
+ }
98
+ }
99
+ // ========================================
100
+ // 🔑 检测steer模式和是否是第二条消息
101
+ const isSteerMode = cfg.messages?.queue?.mode === "steer";
102
+ const isSecondMessage = isSteerMode && hasActiveTask(parsed.sessionId);
103
+ if (isSecondMessage) {
104
+ log(`[BOT] 🔄 STEER MODE - Second message detected (will be follower)`);
105
+ log(`[BOT] - Session: ${parsed.sessionId}`);
106
+ log(`[BOT] - New taskId: ${parsed.taskId} (will replace current)`);
107
+ }
108
+ // 🔑 注册taskId(第二条消息会覆盖第一条的taskId)
109
+ const { isUpdate, refCount } = registerTaskId(parsed.sessionId, parsed.taskId, parsed.messageId, { incrementRef: true } // 增加引用计数
110
+ );
111
+ // 🔑 如果是第一条消息,锁定taskId防止被过早清理
112
+ if (!isUpdate) {
113
+ lockTaskId(parsed.sessionId);
114
+ log(`[BOT] 🔒 Locked taskId for first message`);
115
+ }
59
116
  // Extract and update push_id if present
60
117
  const pushId = extractPushId(parsed.parts);
61
118
  if (pushId) {
@@ -64,6 +121,10 @@ export async function handleXYMessage(params) {
64
121
  log(`[BOT] - Push ID preview: ${pushId.substring(0, 20)}...`);
65
122
  log(`[BOT] - Full push_id: ${pushId}`);
66
123
  configManager.updatePushId(parsed.sessionId, pushId);
124
+ // 持久化 pushId 到本地文件(异步,不阻塞主流程)
125
+ addPushId(pushId).catch((err) => {
126
+ error(`[BOT] Failed to persist pushId:`, err);
127
+ });
67
128
  }
68
129
  else {
69
130
  log(`[BOT] ℹ️ No push_id found in message, will use config default`);
@@ -83,11 +144,12 @@ export async function handleXYMessage(params) {
83
144
  },
84
145
  });
85
146
  log(`xy: resolved route accountId=${route.accountId}, sessionKey=${route.sessionKey}`);
86
- // Register session context for tools
147
+ // 🔑 注册session(带引用计数)
87
148
  log(`[BOT] 📝 About to register session for tools...`);
88
149
  log(`[BOT] - sessionKey: ${route.sessionKey}`);
89
150
  log(`[BOT] - sessionId: ${parsed.sessionId}`);
90
151
  log(`[BOT] - taskId: ${parsed.taskId}`);
152
+ log(`[BOT] - isSecondMessage: ${isSecondMessage}`);
91
153
  registerSession(route.sessionKey, {
92
154
  config,
93
155
  sessionId: parsed.sessionId,
@@ -96,14 +158,14 @@ export async function handleXYMessage(params) {
96
158
  agentId: route.accountId,
97
159
  });
98
160
  log(`[BOT] ✅ Session registered for tools`);
99
- // Send initial status update immediately after parsing message
161
+ // 🔑 发送初始状态更新(第二条消息也要发,用新taskId)
100
162
  log(`[STATUS] Sending initial status update for session ${parsed.sessionId}`);
101
163
  void sendStatusUpdate({
102
164
  config,
103
165
  sessionId: parsed.sessionId,
104
166
  taskId: parsed.taskId,
105
167
  messageId: parsed.messageId,
106
- text: "任务正在处理中,请稍后~",
168
+ text: isSecondMessage ? "新消息已接收,正在处理..." : "任务正在处理中,请稍后~",
107
169
  state: "working",
108
170
  }).catch((err) => {
109
171
  error(`Failed to send initial status update:`, err);
@@ -111,10 +173,9 @@ export async function handleXYMessage(params) {
111
173
  // Extract text and files from parts
112
174
  const text = extractTextFromParts(parsed.parts);
113
175
  const fileParts = extractFileParts(parsed.parts);
114
- // Download files if present (using core's media download)
115
- const mediaList = await downloadFilesFromParts(fileParts);
116
- // Build media payload for inbound context (following feishu pattern)
117
- const mediaPayload = buildXYMediaPayload(mediaList);
176
+ // Download files to local disk
177
+ const downloadedFiles = await downloadFilesFromParts(fileParts);
178
+ const mediaPayload = buildXYMediaPayload(downloadedFiles);
118
179
  // Resolve envelope format options (following feishu pattern)
119
180
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
120
181
  // Build message body with speaker prefix (following feishu pattern)
@@ -155,51 +216,66 @@ export async function handleXYMessage(params) {
155
216
  ReplyToBody: undefined, // A2A protocol doesn't support reply/quote
156
217
  ...mediaPayload,
157
218
  });
158
- // Send initial status update immediately after parsing message
159
- log(`[STATUS] Sending initial status update for session ${parsed.sessionId}`);
160
- void sendStatusUpdate({
161
- config,
162
- sessionId: parsed.sessionId,
163
- taskId: parsed.taskId,
164
- messageId: parsed.messageId,
165
- text: "任务正在处理中,请稍后~",
166
- state: "working",
167
- }).catch((err) => {
168
- error(`Failed to send initial status update:`, err);
169
- });
170
- // Create reply dispatcher (following feishu pattern)
171
- log(`[BOT-DISPATCHER] 🎯 Creating reply dispatcher for session=${parsed.sessionId}, taskId=${parsed.taskId}, messageId=${parsed.messageId}`);
219
+ // 🔑 创建dispatcher(dispatcher会自动使用动态taskId)
220
+ log(`[BOT-DISPATCHER] 🎯 Creating reply dispatcher`);
221
+ log(`[BOT-DISPATCHER] - session: ${parsed.sessionId}`);
222
+ log(`[BOT-DISPATCHER] - taskId: ${parsed.taskId}`);
223
+ log(`[BOT-DISPATCHER] - isSecondMessage: ${isSecondMessage}`);
172
224
  const { dispatcher, replyOptions, markDispatchIdle, startStatusInterval } = createXYReplyDispatcher({
173
225
  cfg,
174
226
  runtime,
175
227
  sessionId: parsed.sessionId,
176
228
  taskId: parsed.taskId,
177
229
  messageId: parsed.messageId,
178
- accountId: route.accountId, // ✅ Use route.accountId
230
+ accountId: route.accountId,
231
+ isSteerFollower: isSecondMessage, // 🔑 标记第二条消息
179
232
  });
180
233
  log(`[BOT-DISPATCHER] ✅ Reply dispatcher created successfully`);
181
- // Start status update interval (will send updates every 60 seconds)
182
- // Interval will be automatically stopped when onIdle/onCleanup is triggered
183
- startStatusInterval();
234
+ // 🔑 只有第一条消息启动状态定时器
235
+ // 第二条消息会很快返回,不需要定时器
236
+ if (!isSecondMessage) {
237
+ startStatusInterval();
238
+ log(`[BOT-DISPATCHER] ✅ Status interval started for first message`);
239
+ }
240
+ else {
241
+ log(`[BOT-DISPATCHER] ⏭️ Skipped status interval for steer follower`);
242
+ }
184
243
  log(`xy: dispatching to agent (session=${parsed.sessionId})`);
185
244
  // Dispatch to OpenClaw core using correct API (following feishu pattern)
186
245
  log(`[BOT] 🚀 Starting dispatcher with session: ${route.sessionKey}`);
246
+ // Build session context for AsyncLocalStorage
247
+ const sessionContext = {
248
+ config,
249
+ sessionId: parsed.sessionId,
250
+ taskId: parsed.taskId,
251
+ messageId: parsed.messageId,
252
+ agentId: route.accountId,
253
+ };
187
254
  await core.channel.reply.withReplyDispatcher({
188
255
  dispatcher,
189
256
  onSettled: () => {
190
257
  log(`[BOT] 🏁 onSettled called for session: ${route.sessionKey}`);
191
- log(`[BOT] - About to unregister session...`);
258
+ log(`[BOT] - isSecondMessage: ${isSecondMessage}`);
192
259
  markDispatchIdle();
193
- // Unregister session context when done
260
+ // 🔑 减少引用计数
261
+ decrementTaskIdRef(parsed.sessionId);
262
+ // 🔑 如果是第一条消息完成,解锁
263
+ if (!isSecondMessage) {
264
+ unlockTaskId(parsed.sessionId);
265
+ log(`[BOT] 🔓 Unlocked taskId (first message completed)`);
266
+ }
267
+ // 减少session引用计数
194
268
  unregisterSession(route.sessionKey);
195
- log(`[BOT] ✅ Session unregistered in onSettled`);
269
+ log(`[BOT] ✅ Cleanup completed`);
196
270
  },
197
- run: () => core.channel.reply.dispatchReplyFromConfig({
271
+ run: () =>
272
+ // 🔐 Use AsyncLocalStorage to provide session context to tools
273
+ runWithSessionContext(sessionContext, () => core.channel.reply.dispatchReplyFromConfig({
198
274
  ctx: ctxPayload,
199
275
  cfg,
200
276
  dispatcher,
201
277
  replyOptions,
202
- }),
278
+ })),
203
279
  });
204
280
  log(`[BOT] ✅ Dispatcher completed for session: ${parsed.sessionId}`);
205
281
  log(`xy: dispatch complete (session=${parsed.sessionId})`);
@@ -209,25 +285,28 @@ export async function handleXYMessage(params) {
209
285
  error("Failed to handle XY message:", err);
210
286
  runtime.error?.(`xy: Failed to handle message: ${String(err)}`);
211
287
  log(`[BOT] ❌ Error occurred, attempting cleanup...`);
212
- // Try to unregister session on error (if route was established)
288
+ // 🔑 错误时也要清理taskId和session
213
289
  try {
214
- const core = getXYRuntime();
215
290
  const params = message.params;
216
291
  const sessionId = params?.sessionId;
217
292
  if (sessionId) {
218
- log(`[BOT] 🧹 Cleaning up session after error: ${sessionId}`);
293
+ log(`[BOT] 🧹 Cleaning up after error: ${sessionId}`);
294
+ // 清理 taskId
295
+ decrementTaskIdRef(sessionId);
296
+ unlockTaskId(sessionId);
297
+ // 清理 session
298
+ const core = getXYRuntime();
219
299
  const route = core.channel.routing.resolveAgentRoute({
220
300
  cfg,
221
301
  channel: "xiaoyi-channel",
222
302
  accountId,
223
303
  peer: {
224
304
  kind: "direct",
225
- id: sessionId, // ✅ Use sessionId for cleanup consistency
305
+ id: sessionId,
226
306
  },
227
307
  });
228
- log(`[BOT] - Unregistering session: ${route.sessionKey}`);
229
308
  unregisterSession(route.sessionKey);
230
- log(`[BOT] ✅ Session unregistered after error`);
309
+ log(`[BOT] ✅ Cleanup completed after error`);
231
310
  }
232
311
  }
233
312
  catch (cleanupErr) {
@@ -240,6 +319,8 @@ export async function handleXYMessage(params) {
240
319
  /**
241
320
  * Build media payload for inbound context.
242
321
  * Following feishu pattern: buildFeishuMediaPayload().
322
+ *
323
+ * @param mediaList - Downloaded files with local paths
243
324
  */
244
325
  function buildXYMediaPayload(mediaList) {
245
326
  const first = mediaList[0];
@@ -248,9 +329,7 @@ function buildXYMediaPayload(mediaList) {
248
329
  return {
249
330
  MediaPath: first?.path,
250
331
  MediaType: first?.mimeType,
251
- MediaUrl: first?.path,
252
332
  MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
253
- MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
254
333
  MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
255
334
  };
256
335
  }
@@ -11,6 +11,20 @@ import { searchCalendarTool } from "./tools/search-calendar-tool.js";
11
11
  import { searchContactTool } from "./tools/search-contact-tool.js";
12
12
  import { searchPhotoGalleryTool } from "./tools/search-photo-gallery-tool.js";
13
13
  import { uploadPhotoTool } from "./tools/upload-photo-tool.js";
14
+ import { xiaoyiGuiTool } from "./tools/xiaoyi-gui-tool.js";
15
+ import { callPhoneTool } from "./tools/call-phone-tool.js";
16
+ import { searchMessageTool } from "./tools/search-message-tool.js";
17
+ import { sendMessageTool } from "./tools/send-message-tool.js";
18
+ import { searchFileTool } from "./tools/search-file-tool.js";
19
+ import { uploadFileTool } from "./tools/upload-file-tool.js";
20
+ import { createAlarmTool } from "./tools/create-alarm-tool.js";
21
+ import { searchAlarmTool } from "./tools/search-alarm-tool.js";
22
+ import { modifyAlarmTool } from "./tools/modify-alarm-tool.js";
23
+ import { deleteAlarmTool } from "./tools/delete-alarm-tool.js";
24
+ import { sendFileToUserTool } from "./tools/send-file-to-user-tool.js";
25
+ import { xiaoyiCollectionTool } from "./tools/xiaoyi-collection-tool.js";
26
+ import { viewPushResultTool } from "./tools/view-push-result-tool.js";
27
+ import { imageReadingTool } from "./tools/image-reading-tool.js";
14
28
  /**
15
29
  * Xiaoyi Channel Plugin for OpenClaw.
16
30
  * Implements Xiaoyi A2A protocol with dual WebSocket connections.
@@ -50,7 +64,7 @@ export const xyPlugin = {
50
64
  },
51
65
  outbound: xyOutbound,
52
66
  onboarding: xyOnboardingAdapter,
53
- agentTools: [locationTool, noteTool, searchNoteTool, modifyNoteTool, calendarTool, searchCalendarTool, searchContactTool, searchPhotoGalleryTool, uploadPhotoTool],
67
+ agentTools: [locationTool, noteTool, searchNoteTool, modifyNoteTool, calendarTool, searchCalendarTool, searchContactTool, searchPhotoGalleryTool, uploadPhotoTool, xiaoyiGuiTool, callPhoneTool, searchMessageTool, sendMessageTool, searchFileTool, uploadFileTool, createAlarmTool, searchAlarmTool, modifyAlarmTool, deleteAlarmTool, sendFileToUserTool, xiaoyiCollectionTool, viewPushResultTool, imageReadingTool],
54
68
  messaging: {
55
69
  normalizeTarget: (raw) => {
56
70
  const trimmed = raw.trim();
@@ -77,10 +91,9 @@ export const xyPlugin = {
77
91
  const account = resolveXYConfig(context.cfg);
78
92
  context.setStatus?.({
79
93
  accountId: context.accountId,
80
- wsUrl1: account.wsUrl1,
81
- wsUrl2: account.wsUrl2,
94
+ wsUrl: account.wsUrl,
82
95
  });
83
- context.log?.info(`[${context.accountId}] starting xiaoyi channel (wsUrl1: ${account.wsUrl1}, wsUrl2: ${account.wsUrl2})`);
96
+ context.log?.info(`[${context.accountId}] starting xiaoyi channel (wsUrl: ${account.wsUrl})`);
84
97
  return monitorXYProvider({
85
98
  config: context.cfg,
86
99
  runtime: context.runtime,
@@ -89,29 +89,16 @@ export function diagnoseAllManagers() {
89
89
  console.log(`📌 Manager: ${key}`);
90
90
  console.log(` Shutting down: ${diag.isShuttingDown}`);
91
91
  console.log(` Total event listeners on manager: ${diag.totalEventListeners}`);
92
- // Server 1
93
- console.log(` 🔌 Server1:`);
94
- console.log(` - Exists: ${diag.server1.exists}`);
95
- console.log(` - ReadyState: ${diag.server1.readyState}`);
96
- console.log(` - State connected/ready: ${diag.server1.stateConnected}/${diag.server1.stateReady}`);
97
- console.log(` - Reconnect attempts: ${diag.server1.reconnectAttempts}`);
98
- console.log(` - Listeners on WebSocket: ${diag.server1.listenerCount}`);
99
- console.log(` - Heartbeat active: ${diag.server1.heartbeatActive}`);
100
- console.log(` - Has reconnect timer: ${diag.server1.hasReconnectTimer}`);
101
- if (diag.server1.isOrphan) {
102
- console.log(` ⚠️ ORPHAN CONNECTION DETECTED!`);
103
- orphanCount++;
104
- }
105
- // Server 2
106
- console.log(` 🔌 Server2:`);
107
- console.log(` - Exists: ${diag.server2.exists}`);
108
- console.log(` - ReadyState: ${diag.server2.readyState}`);
109
- console.log(` - State connected/ready: ${diag.server2.stateConnected}/${diag.server2.stateReady}`);
110
- console.log(` - Reconnect attempts: ${diag.server2.reconnectAttempts}`);
111
- console.log(` - Listeners on WebSocket: ${diag.server2.listenerCount}`);
112
- console.log(` - Heartbeat active: ${diag.server2.heartbeatActive}`);
113
- console.log(` - Has reconnect timer: ${diag.server2.hasReconnectTimer}`);
114
- if (diag.server2.isOrphan) {
92
+ // Connection
93
+ console.log(` 🔌 Connection:`);
94
+ console.log(` - Exists: ${diag.connection.exists}`);
95
+ console.log(` - ReadyState: ${diag.connection.readyState}`);
96
+ console.log(` - State connected/ready: ${diag.connection.stateConnected}/${diag.connection.stateReady}`);
97
+ console.log(` - Reconnect attempts: ${diag.connection.reconnectAttempts}`);
98
+ console.log(` - Listeners on WebSocket: ${diag.connection.listenerCount}`);
99
+ console.log(` - Heartbeat active: ${diag.connection.heartbeatActive}`);
100
+ console.log(` - Has reconnect timer: ${diag.connection.hasReconnectTimer}`);
101
+ if (diag.connection.isOrphan) {
115
102
  console.log(` ⚠️ ORPHAN CONNECTION DETECTED!`);
116
103
  orphanCount++;
117
104
  }
@@ -134,7 +121,7 @@ export function cleanupOrphanConnections() {
134
121
  let cleanedCount = 0;
135
122
  wsManagerCache.forEach((manager, key) => {
136
123
  const diag = manager.getConnectionDiagnostics();
137
- if (diag.server1.isOrphan || diag.server2.isOrphan) {
124
+ if (diag.connection.isOrphan) {
138
125
  console.log(`🧹 Cleaning up orphan connections in manager: ${key}`);
139
126
  manager.disconnect();
140
127
  cleanedCount++;
@@ -17,8 +17,8 @@ export function resolveXYConfig(cfg) {
17
17
  // Return configuration with defaults
18
18
  return {
19
19
  enabled: xyConfig.enabled ?? false,
20
- wsUrl1: xyConfig.wsUrl1 ?? "ws://localhost:8765/ws/link",
21
- wsUrl2: xyConfig.wsUrl2 ?? "ws://localhost:8768/ws/link",
20
+ // ✅ 兼容旧配置:优先使用 wsUrl,然后 wsUrl2(wsUrl1 被忽略)
21
+ wsUrl: xyConfig.wsUrl ?? xyConfig.wsUrl2 ?? "ws://localhost:8768/ws/link",
22
22
  apiKey: xyConfig.apiKey,
23
23
  uid: xyConfig.uid,
24
24
  agentId: xyConfig.agentId,
@@ -12,6 +12,11 @@ export declare class XYFileUploadService {
12
12
  * Returns the objectId (as fileId) for use in A2A messages.
13
13
  */
14
14
  uploadFile(filePath: string, objectType?: string): Promise<string>;
15
+ /**
16
+ * Upload a file and return its publicly accessible URL.
17
+ * Uses completeAndQuery endpoint to get the file URL directly.
18
+ */
19
+ uploadFileAndGetUrl(filePath: string, objectType?: string): Promise<string>;
15
20
  /**
16
21
  * Upload multiple files and return their file IDs.
17
22
  */
@@ -105,6 +105,98 @@ export class XYFileUploadService {
105
105
  return "";
106
106
  }
107
107
  }
108
+ /**
109
+ * Upload a file and return its publicly accessible URL.
110
+ * Uses completeAndQuery endpoint to get the file URL directly.
111
+ */
112
+ async uploadFileAndGetUrl(filePath, objectType = "TEMPORARY_MATERIAL_DOC") {
113
+ console.log(`[XY File Upload] Starting file upload with URL retrieval: ${filePath}`);
114
+ try {
115
+ // Read file
116
+ const fileBuffer = await fs.readFile(filePath);
117
+ const fileName = path.basename(filePath);
118
+ const fileSha256 = calculateSHA256(fileBuffer);
119
+ const fileSize = fileBuffer.length;
120
+ // Phase 1: Prepare
121
+ console.log(`[XY File Upload] Phase 1: Prepare upload for ${fileName}`);
122
+ const prepareResp = await fetch(`${this.baseUrl}/osms/v1/file/manager/prepare`, {
123
+ method: "POST",
124
+ headers: {
125
+ "Content-Type": "application/json",
126
+ "x-uid": this.uid,
127
+ "x-api-key": this.apiKey,
128
+ "x-request-from": "openclaw",
129
+ },
130
+ body: JSON.stringify({
131
+ objectType,
132
+ fileName,
133
+ fileSha256,
134
+ fileSize,
135
+ fileOwnerInfo: {
136
+ uid: this.uid,
137
+ teamId: this.uid,
138
+ },
139
+ useEdge: false,
140
+ }),
141
+ });
142
+ if (!prepareResp.ok) {
143
+ throw new Error(`Prepare failed: HTTP ${prepareResp.status}`);
144
+ }
145
+ const prepareData = await prepareResp.json();
146
+ console.log(`[XY File Upload] Prepare response:`, JSON.stringify(prepareData, null, 2));
147
+ if (prepareData.code !== "0") {
148
+ throw new Error(`Prepare failed: ${prepareData.desc}`);
149
+ }
150
+ const { objectId, draftId, uploadInfos } = prepareData;
151
+ console.log(`[XY File Upload] Prepare complete: objectId=${objectId}, draftId=${draftId}`);
152
+ // Phase 2: Upload
153
+ console.log(`[XY File Upload] Phase 2: Upload file data`);
154
+ const uploadInfo = uploadInfos[0]; // Single-part upload
155
+ const uploadResp = await fetch(uploadInfo.url, {
156
+ method: uploadInfo.method,
157
+ headers: uploadInfo.headers,
158
+ body: fileBuffer,
159
+ });
160
+ console.log(`[XY File Upload] Upload response status: ${uploadResp.status}`);
161
+ if (!uploadResp.ok) {
162
+ const uploadErrorText = await uploadResp.text();
163
+ console.log(`[XY File Upload] Upload error response:`, uploadErrorText);
164
+ throw new Error(`Upload failed: HTTP ${uploadResp.status}`);
165
+ }
166
+ console.log(`[XY File Upload] Upload complete`);
167
+ // Phase 3: CompleteAndQuery - get file URL
168
+ console.log(`[XY File Upload] Phase 3: CompleteAndQuery to get file URL`);
169
+ const completeResp = await fetch(`${this.baseUrl}/osms/v1/file/manager/completeAndQuery`, {
170
+ method: "POST",
171
+ headers: {
172
+ "Content-Type": "application/json",
173
+ "x-uid": this.uid,
174
+ "x-api-key": this.apiKey,
175
+ "x-request-from": "openclaw",
176
+ },
177
+ body: JSON.stringify({
178
+ objectId,
179
+ draftId,
180
+ }),
181
+ });
182
+ if (!completeResp.ok) {
183
+ throw new Error(`CompleteAndQuery failed: HTTP ${completeResp.status}`);
184
+ }
185
+ const completeData = await completeResp.json();
186
+ console.log(`[XY File Upload] CompleteAndQuery response:`, JSON.stringify(completeData, null, 2));
187
+ // Extract file URL from response
188
+ const fileUrl = completeData?.fileDetailInfo?.url || "";
189
+ if (!fileUrl) {
190
+ throw new Error("No file URL returned from completeAndQuery");
191
+ }
192
+ console.log(`[XY File Upload] File upload successful: ${fileName} → URL=${fileUrl}`);
193
+ return fileUrl;
194
+ }
195
+ catch (error) {
196
+ console.error(`[XY File Upload] File upload with URL retrieval failed for ${filePath}:`, error);
197
+ throw error;
198
+ }
199
+ }
108
200
  /**
109
201
  * Upload multiple files and return their file IDs.
110
202
  */
@@ -92,3 +92,17 @@ export interface SendTasksCancelResponseParams {
92
92
  * Send a tasks/cancel response.
93
93
  */
94
94
  export declare function sendTasksCancelResponse(params: SendTasksCancelResponseParams): Promise<void>;
95
+ /**
96
+ * Parameters for sending a Trigger response.
97
+ */
98
+ export interface SendTriggerResponseParams {
99
+ config: XYChannelConfig;
100
+ sessionId: string;
101
+ taskId: string;
102
+ messageId: string;
103
+ content: string;
104
+ }
105
+ /**
106
+ * Send a Trigger response with pushData content.
107
+ */
108
+ export declare function sendTriggerResponse(params: SendTriggerResponseParams): Promise<void>;
@@ -106,9 +106,7 @@ export async function sendReasoningTextUpdate(params) {
106
106
  taskId,
107
107
  msgDetail: JSON.stringify(jsonRpcResponse),
108
108
  };
109
- log(`[REASONING_TEXT] 📤 Sending reasoningText update: sessionId=${sessionId}, taskId=${taskId}, text.length=${text.length}`);
110
109
  await wsManager.sendMessage(sessionId, outboundMessage);
111
- log(`[REASONING_TEXT] ✅ Sent successfully`);
112
110
  }
113
111
  /**
114
112
  * Send an A2A task status update.
@@ -293,3 +291,49 @@ export async function sendTasksCancelResponse(params) {
293
291
  await wsManager.sendMessage(sessionId, outboundMessage);
294
292
  log(`Sent tasks/cancel response: sessionId=${sessionId}, taskId=${taskId}`);
295
293
  }
294
+ /**
295
+ * Send a Trigger response with pushData content.
296
+ */
297
+ export async function sendTriggerResponse(params) {
298
+ const { config, sessionId, taskId, messageId, content } = params;
299
+ const runtime = getXYRuntime();
300
+ const log = runtime?.log ?? console.log;
301
+ const error = runtime?.error ?? console.error;
302
+ // Build JSON-RPC response for Trigger
303
+ const jsonRpcResponse = {
304
+ jsonrpc: "2.0",
305
+ id: messageId,
306
+ result: {
307
+ taskId: taskId,
308
+ kind: "artifact-update",
309
+ append: false,
310
+ lastChunk: true,
311
+ final: true,
312
+ artifact: {
313
+ artifactId: uuidv4(),
314
+ parts: [
315
+ {
316
+ kind: "text",
317
+ text: content,
318
+ },
319
+ ],
320
+ },
321
+ },
322
+ error: {
323
+ code: 0,
324
+ message: "",
325
+ },
326
+ };
327
+ // Send via WebSocket
328
+ const wsManager = getXYWebSocketManager(config);
329
+ const outboundMessage = {
330
+ msgType: "agent_response",
331
+ agentId: config.agentId,
332
+ sessionId,
333
+ taskId,
334
+ msgDetail: JSON.stringify(jsonRpcResponse),
335
+ };
336
+ log(`[TRIGGER_RESPONSE] Sending Trigger response: sessionId=${sessionId}, taskId=${taskId}`);
337
+ await wsManager.sendMessage(sessionId, outboundMessage);
338
+ log(`[TRIGGER_RESPONSE] Trigger response sent successfully`);
339
+ }
@@ -1,6 +1,9 @@
1
1
  import { resolveXYConfig } from "./config.js";
2
2
  import { getXYWebSocketManager, diagnoseAllManagers, cleanupOrphanConnections, removeXYWebSocketManager } from "./client.js";
3
3
  import { handleXYMessage } from "./bot.js";
4
+ import { parseA2AMessage } from "./parser.js";
5
+ import { hasActiveTask } from "./task-manager.js";
6
+ import { handleTriggerEvent } from "./trigger-handler.js";
4
7
  /**
5
8
  * Per-session serial queue that ensures messages from the same session are processed
6
9
  * in arrival order while allowing different sessions to run concurrently.
@@ -94,11 +97,39 @@ export async function monitorXYProvider(opts = {}) {
94
97
  log(`[MONITOR-HANDLER] 🧹 Cleaned up messageKey=${messageKey}, remaining active: ${activeMessages.size}`);
95
98
  }
96
99
  };
97
- void enqueue(sessionId, task).catch((err) => {
98
- // Error already logged in task, this is for queue failures
99
- error(`XY gateway: queue processing failed for session ${sessionId}: ${String(err)}`);
100
- activeMessages.delete(messageKey);
101
- });
100
+ // 🔑 核心改造:检测steer模式
101
+ // 需要提前解析消息以获取sessionId
102
+ try {
103
+ const parsed = parseA2AMessage(message);
104
+ const steerMode = cfg.messages?.queue?.mode === "steer";
105
+ const hasActiveRun = hasActiveTask(parsed.sessionId);
106
+ if (steerMode && hasActiveRun) {
107
+ // Steer模式且有活跃任务:不入队列,直接并发执行
108
+ log(`[MONITOR-HANDLER] 🔄 STEER MODE: Executing concurrently for messageKey=${messageKey}`);
109
+ log(`[MONITOR-HANDLER] - sessionId: ${parsed.sessionId}`);
110
+ log(`[MONITOR-HANDLER] - Bypassing queue to allow message insertion`);
111
+ void task().catch((err) => {
112
+ error(`XY gateway: concurrent steer task failed for ${messageKey}: ${String(err)}`);
113
+ activeMessages.delete(messageKey);
114
+ });
115
+ }
116
+ else {
117
+ // 正常模式:入队列串行执行
118
+ log(`[MONITOR-HANDLER] 📋 NORMAL MODE: Enqueuing for messageKey=${messageKey}`);
119
+ void enqueue(sessionId, task).catch((err) => {
120
+ error(`XY gateway: queue processing failed for session ${sessionId}: ${String(err)}`);
121
+ activeMessages.delete(messageKey);
122
+ });
123
+ }
124
+ }
125
+ catch (parseErr) {
126
+ // 解析失败,回退到正常队列模式
127
+ error(`[MONITOR-HANDLER] Failed to parse message for steer detection: ${String(parseErr)}`);
128
+ void enqueue(sessionId, task).catch((err) => {
129
+ error(`XY gateway: queue processing failed for session ${sessionId}: ${String(err)}`);
130
+ activeMessages.delete(messageKey);
131
+ });
132
+ }
102
133
  };
103
134
  const connectedHandler = (serverId) => {
104
135
  if (!loggedServers.has(serverId)) {
@@ -120,6 +151,15 @@ export async function monitorXYProvider(opts = {}) {
120
151
  const errorHandler = (err, serverId) => {
121
152
  error(`XY gateway: ${serverId} error: ${String(err)}`);
122
153
  };
154
+ const triggerEventHandler = (context) => {
155
+ log(`[MONITOR] 📌 Received trigger-event, dispatching to handler...`);
156
+ log(`[MONITOR] - sessionId: ${context.sessionId}`);
157
+ log(`[MONITOR] - taskId: ${context.taskId}`);
158
+ // 异步处理 Trigger 事件,不阻塞主流程
159
+ handleTriggerEvent(context, cfg, runtime, accountId).catch((err) => {
160
+ error(`[MONITOR] Failed to handle trigger-event:`, err);
161
+ });
162
+ };
123
163
  const cleanup = () => {
124
164
  log("XY gateway: cleaning up...");
125
165
  // 🔍 Diagnose before cleanup
@@ -136,6 +176,7 @@ export async function monitorXYProvider(opts = {}) {
136
176
  wsManager.off("connected", connectedHandler);
137
177
  wsManager.off("disconnected", disconnectedHandler);
138
178
  wsManager.off("error", errorHandler);
179
+ wsManager.off("trigger-event", triggerEventHandler);
139
180
  // ✅ Disconnect the wsManager to prevent connection leaks
140
181
  // This is safe because each gateway lifecycle should have clean connections
141
182
  wsManager.disconnect();
@@ -165,6 +206,7 @@ export async function monitorXYProvider(opts = {}) {
165
206
  wsManager.on("connected", connectedHandler);
166
207
  wsManager.on("disconnected", disconnectedHandler);
167
208
  wsManager.on("error", errorHandler);
209
+ wsManager.on("trigger-event", triggerEventHandler);
168
210
  // Start periodic health check (every 5 minutes)
169
211
  console.log("🏥 Starting periodic health check (every 5 minutes)...");
170
212
  healthCheckInterval = setInterval(() => {