@ynhcj/xiaoyi-channel 0.0.131-next → 0.0.132-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.
package/dist/src/bot.js CHANGED
@@ -254,6 +254,9 @@ export async function handleXYMessage(params) {
254
254
  // so the dispatcher skips all user-facing callbacks (deliver, onIdle, etc.)
255
255
  // and onSettled skips cleanup.
256
256
  const steerState = { steered: isUpdate };
257
+ // 🔑 第一条消息的 streaming 信号:deliver 首次触发时 resolve
258
+ // steer 消息通过串行队列等待此信号后再 dispatch
259
+ const streamingSignal = !isUpdate ? createStreamingSignal(parsed.sessionId) : undefined;
257
260
  // 🔑 创建dispatcher
258
261
  logger.log(`[BOT-DISPATCHER] 🎯 Creating reply dispatcher`);
259
262
  logger.log(`[BOT-DISPATCHER] - taskId: ${parsed.taskId}`);
@@ -265,6 +268,7 @@ export async function handleXYMessage(params) {
265
268
  messageId: parsed.messageId,
266
269
  accountId: route.accountId,
267
270
  steerState,
271
+ onFirstStream: streamingSignal?.notify,
268
272
  });
269
273
  // Steer injections don't need status intervals
270
274
  if (!skipReg) {
@@ -331,14 +335,12 @@ export async function handleXYMessage(params) {
331
335
  return dispatchPromise;
332
336
  },
333
337
  });
334
- // 🔑 Steer 重试:如果首次注入失败(run 还没 streaming),等待后重试
338
+ // 🔑 Steer 串行队列:等待 streaming 信号后 dispatch,多个 steer 按顺序处理
335
339
  if (isUpdate) {
336
- await retrySteerIfNeeded({
337
- steerState,
340
+ await enqueueSteer({
338
341
  sessionId: parsed.sessionId,
339
342
  sessionKey: route.sessionKey,
340
343
  steerText: textForAgent,
341
- ctxPayload,
342
344
  cfg,
343
345
  runtime,
344
346
  parsed,
@@ -401,114 +403,121 @@ function buildXYMediaPayload(mediaList) {
401
403
  MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
402
404
  };
403
405
  }
404
- const STEER_RETRY_DELAYS = [2000, 3000, 5000];
406
+ const streamingSignals = new Map();
407
+ function createStreamingSignal(sessionId) {
408
+ let resolve;
409
+ const promise = new Promise(r => { resolve = r; });
410
+ const signal = { promise, notify: resolve };
411
+ streamingSignals.set(sessionId, signal);
412
+ logger.log(`[STEER-QUEUE] 🟢 Streaming signal created for session ${sessionId}`);
413
+ return signal;
414
+ }
415
+ /** Per-session 串行队列:保证同一 session 的 steer 消息按顺序处理 */
416
+ const steerQueues = new Map();
405
417
  /**
406
- * Steer 注入重试:当首次注入因 run streaming 而失败时,
407
- * 等待递增延迟后重试,最多尝试 3 次。
418
+ * steer 消息放入 per-session 串行队列。
419
+ * 等待第一条消息的 streaming 信号(deliver 首次触发),然后 dispatch。
420
+ * 多个 steer 按到达顺序串行处理,无需重试。
408
421
  */
409
- async function retrySteerIfNeeded(params) {
410
- const { steerState, sessionId, steerText } = params;
411
- // 首次成功则无需重试
412
- if (steerState.steerResult === 'success') {
413
- logger.log(`[STEER-RETRY] Steer succeeded on first attempt`);
414
- return;
422
+ function enqueueSteer(params) {
423
+ const { sessionId } = params;
424
+ // 取出当前队列尾部(或 undefined),然后链上新的 Promise
425
+ const prev = steerQueues.get(sessionId);
426
+ const next = (prev ?? Promise.resolve()).then(() => dispatchSteerWhenReady(params));
427
+ steerQueues.set(sessionId, next);
428
+ // 链条结束后清理
429
+ next.catch(() => { }).finally(() => {
430
+ if (steerQueues.get(sessionId) === next) {
431
+ steerQueues.delete(sessionId);
432
+ }
433
+ });
434
+ return next;
435
+ }
436
+ async function dispatchSteerWhenReady(params) {
437
+ const { sessionId, sessionKey, steerText } = params;
438
+ // 1. 等待第一条消息开始 streaming
439
+ const signal = streamingSignals.get(sessionId);
440
+ if (signal) {
441
+ logger.log(`[STEER-QUEUE] ⏳ Waiting for streaming signal, session=${sessionId}`);
442
+ await signal.promise;
443
+ streamingSignals.delete(sessionId);
444
+ logger.log(`[STEER-QUEUE] ✅ Streaming signal received, session=${sessionId}`);
415
445
  }
416
- // 只有明确失败才重试;无结果可能是异常路径,也尝试重试
417
- if (steerState.steerResult !== 'fail' && steerState.steerResult !== undefined) {
446
+ // 2. 第一条消息已结束 → 放弃
447
+ if (!hasActiveTask(sessionId)) {
448
+ logger.log(`[STEER-QUEUE] ℹ️ First message completed, skip steer`);
418
449
  return;
419
450
  }
420
- logger.log(`[STEER-RETRY] ⚠️ First steer attempt result=${steerState.steerResult}, starting retry loop`);
451
+ // 3. 构建 dispatch 上下文并 dispatch /steer
421
452
  const core = getXYRuntime();
422
- for (let attempt = 0; attempt < STEER_RETRY_DELAYS.length; attempt++) {
423
- const delay = STEER_RETRY_DELAYS[attempt];
424
- logger.log(`[STEER-RETRY] Waiting ${delay}ms before retry #${attempt + 1}`);
425
- await new Promise(resolve => setTimeout(resolve, delay));
426
- // 第一条消息已结束,无需继续
427
- if (!hasActiveTask(sessionId)) {
428
- logger.log(`[STEER-RETRY] ℹ️ First message completed, skip retry`);
429
- return;
430
- }
431
- logger.log(`[STEER-RETRY] 🔄 Retrying steer dispatch #${attempt + 1}`);
432
- // 构建新的 steer 消息上下文
433
- const speaker = sessionId;
434
- const messageBody = `${speaker}: ${steerText}`;
435
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg);
436
- const body = core.channel.reply.formatAgentEnvelope({
437
- channel: "xiaoyi-channel",
438
- from: speaker,
439
- timestamp: new Date(),
440
- envelope: envelopeOptions,
441
- body: messageBody,
442
- });
443
- const retryCtx = core.channel.reply.finalizeInboundContext({
444
- Body: body,
445
- RawBody: steerText,
446
- CommandBody: steerText,
447
- From: sessionId,
448
- To: sessionId,
449
- SessionKey: params.route.sessionKey,
450
- AccountId: params.route.accountId,
451
- ChatType: "direct",
452
- GroupSubject: undefined,
453
- SenderName: sessionId,
454
- SenderId: sessionId,
455
- Provider: "xiaoyi-channel",
456
- Surface: "xiaoyi-channel",
457
- MessageSid: `${params.parsed.taskId}_${params.deviceType}`,
458
- Timestamp: Date.now(),
459
- WasMentioned: false,
460
- CommandAuthorized: true,
461
- OriginatingChannel: "xiaoyi-channel",
462
- OriginatingTo: sessionId,
463
- ReplyToBody: undefined,
464
- });
465
- const retryState = { steered: true };
466
- const { dispatcher: retryDispatcher, replyOptions: retryReplyOptions } = createXYReplyDispatcher({
467
- cfg: params.cfg,
468
- runtime: params.runtime,
469
- sessionId,
470
- taskId: params.parsed.taskId,
471
- messageId: params.parsed.messageId,
472
- accountId: params.route.accountId,
473
- steerState: retryState,
474
- });
475
- const sessionContext = {
476
- config: resolveXYConfig(params.cfg),
477
- sessionId,
478
- taskId: params.parsed.taskId,
479
- messageId: params.parsed.messageId,
480
- agentId: params.route.accountId,
481
- deviceType: params.deviceType,
482
- };
483
- try {
484
- await core.channel.reply.withReplyDispatcher({
485
- dispatcher: retryDispatcher,
486
- onSettled: () => {
487
- logger.log(`[STEER-RETRY] 🏁 Retry dispatch settled, result=${retryState.steerResult}`);
488
- },
489
- run: () => {
490
- return runWithSessionContext(sessionContext, async () => {
491
- const result = await core.channel.reply.dispatchReplyFromConfig({
492
- ctx: retryCtx,
493
- cfg: params.cfg,
494
- dispatcher: retryDispatcher,
495
- replyOptions: retryReplyOptions,
496
- });
497
- logger.log(`[STEER-RETRY] dispatch result: ${JSON.stringify(result)}`);
498
- return result;
499
- });
500
- },
453
+ const speaker = sessionId;
454
+ const messageBody = `${speaker}: ${steerText}`;
455
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg);
456
+ const body = core.channel.reply.formatAgentEnvelope({
457
+ channel: "xiaoyi-channel",
458
+ from: speaker,
459
+ timestamp: new Date(),
460
+ envelope: envelopeOptions,
461
+ body: messageBody,
462
+ });
463
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
464
+ Body: body,
465
+ RawBody: steerText,
466
+ CommandBody: steerText,
467
+ From: sessionId,
468
+ To: sessionId,
469
+ SessionKey: params.route.sessionKey,
470
+ AccountId: params.route.accountId,
471
+ ChatType: "direct",
472
+ GroupSubject: undefined,
473
+ SenderName: sessionId,
474
+ SenderId: sessionId,
475
+ Provider: "xiaoyi-channel",
476
+ Surface: "xiaoyi-channel",
477
+ MessageSid: `${params.parsed.taskId}_${params.deviceType}`,
478
+ Timestamp: Date.now(),
479
+ WasMentioned: false,
480
+ CommandAuthorized: true,
481
+ OriginatingChannel: "xiaoyi-channel",
482
+ OriginatingTo: sessionId,
483
+ ReplyToBody: undefined,
484
+ });
485
+ const steerState = { steered: true };
486
+ const { dispatcher, replyOptions } = createXYReplyDispatcher({
487
+ cfg: params.cfg,
488
+ runtime: params.runtime,
489
+ sessionId,
490
+ taskId: params.parsed.taskId,
491
+ messageId: params.parsed.messageId,
492
+ accountId: params.route.accountId,
493
+ steerState,
494
+ });
495
+ const sessionContext = {
496
+ config: resolveXYConfig(params.cfg),
497
+ sessionId,
498
+ taskId: params.parsed.taskId,
499
+ messageId: params.parsed.messageId,
500
+ agentId: params.route.accountId,
501
+ deviceType: params.deviceType,
502
+ };
503
+ logger.log(`[STEER-QUEUE] 🚀 Dispatching steer for session=${sessionId}`);
504
+ await core.channel.reply.withReplyDispatcher({
505
+ dispatcher,
506
+ onSettled: () => {
507
+ logger.log(`[STEER-QUEUE] 🏁 Steer dispatch settled for session=${sessionId}`);
508
+ },
509
+ run: () => {
510
+ return runWithSessionContext(sessionContext, async () => {
511
+ const result = await core.channel.reply.dispatchReplyFromConfig({
512
+ ctx: ctxPayload,
513
+ cfg: params.cfg,
514
+ dispatcher,
515
+ replyOptions,
516
+ });
517
+ logger.log(`[STEER-QUEUE] dispatch result: ${JSON.stringify(result)}`);
518
+ return result;
501
519
  });
502
- }
503
- catch (err) {
504
- logger.error(`[STEER-RETRY] Retry dispatch #${attempt + 1} threw: ${String(err)}`);
505
- continue;
506
- }
507
- if (retryState.steerResult === 'success') {
508
- logger.log(`[STEER-RETRY] ✅ Steer succeeded on retry #${attempt + 1}`);
509
- return;
510
- }
511
- logger.log(`[STEER-RETRY] ⚠️ Retry #${attempt + 1} result=${retryState.steerResult}, continuing`);
512
- }
513
- logger.warn(`[STEER-RETRY] ❌ All retries exhausted for session ${sessionId}`);
520
+ },
521
+ });
522
+ logger.log(`[STEER-QUEUE] Steer dispatch completed for session=${sessionId}`);
514
523
  }
@@ -8,8 +8,9 @@ export interface CreateXYReplyDispatcherParams {
8
8
  accountId: string;
9
9
  steerState: {
10
10
  steered: boolean;
11
- steerResult?: 'success' | 'fail';
12
11
  };
12
+ /** Called the first time deliver fires for a non-steered dispatch — signals the model is streaming. */
13
+ onFirstStream?: () => void;
13
14
  }
14
15
  /**
15
16
  * 清理 /tmp/xy_channel 目录中超过 24 小时的旧文件
@@ -45,7 +45,7 @@ export async function cleanupStaleTempFiles(tempDir = "/tmp/xy_channel") {
45
45
  * Runtime is expected to be validated before calling this function.
46
46
  */
47
47
  export function createXYReplyDispatcher(params) {
48
- const { cfg, runtime, sessionId, taskId, messageId, accountId, steerState } = params;
48
+ const { cfg, runtime, sessionId, taskId, messageId, accountId, steerState, onFirstStream } = params;
49
49
  logger.log(`[DISPATCHER-CREATE] ******* Creating dispatcher *******`);
50
50
  logger.log(`[DISPATCHER-CREATE] - taskId: ${taskId}`);
51
51
  // 初始taskId和messageId(作为fallback)
@@ -73,6 +73,7 @@ export function createXYReplyDispatcher(params) {
73
73
  let hasSentResponse = false;
74
74
  let finalSent = false;
75
75
  let accumulatedText = "";
76
+ let streamingSignaled = false;
76
77
  /**
77
78
  * Start the status update interval
78
79
  */
@@ -115,16 +116,14 @@ export function createXYReplyDispatcher(params) {
115
116
  deliver: async (payload, info) => {
116
117
  // 🔑 steered dispatch不发送内容(让主dispatcher处理)
117
118
  if (steerState.steered) {
118
- const text = payload.text ?? '';
119
- if (text.includes('steered current session')) {
120
- steerState.steerResult = 'success';
121
- }
122
- else if (text.includes('not accepting steering') || text.includes('No active run')) {
123
- steerState.steerResult = 'fail';
124
- }
125
- logger.log(`[DELIVER] Steered dispatch - result=${steerState.steerResult}, info.kind=${info?.kind}, text=${text.slice(0, 80)}`);
119
+ logger.log(`[DELIVER] Steered dispatch - skipping deliver, info.kind=${info?.kind}`);
126
120
  return;
127
121
  }
122
+ // 🔑 第一次 deliver = 模型开始 streaming,通知等待中的 steer
123
+ if (onFirstStream && !streamingSignaled) {
124
+ streamingSignaled = true;
125
+ onFirstStream();
126
+ }
128
127
  const text = payload.text ?? "";
129
128
  const currentTaskId = getActiveTaskId();
130
129
  const currentMessageId = getActiveMessageId();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.131-next",
3
+ "version": "0.0.132-next",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",