@xmanrui/dsh-im 4.36.1 → 4.37.0

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.en.md +14 -2
  2. package/README.md +13 -1
  3. package/lib/client.js +322 -190
  4. package/lib/index.js +314 -313
  5. package/package.json +2 -2
  6. package/plugin-src/client/bot-general-settings.js +83 -0
  7. package/plugin-src/client/channels/dingtalk/api.js +3 -1
  8. package/plugin-src/client/channels/feishu/api.js +3 -1
  9. package/plugin-src/client/channels/qq/api.js +3 -1
  10. package/plugin-src/client/channels/shared/token-api.js +3 -1
  11. package/plugin-src/client/channels/wecom/api.js +3 -1
  12. package/plugin-src/client/channels/wecom-app/api.js +3 -1
  13. package/plugin-src/client/channels/weixin/api.js +3 -1
  14. package/plugin-src/client/channels/whatsapp/api.js +3 -1
  15. package/plugin-src/client/delivery-settings.js +5 -1
  16. package/plugin-src/client/i18n.js +13 -0
  17. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  18. package/plugin-src/host/channels/email/rpc.mjs +1 -0
  19. package/plugin-src/host/channels/feishu/rpc.mjs +13 -0
  20. package/plugin-src/host/channels/imessage/rpc.mjs +1 -0
  21. package/plugin-src/host/channels/matrix/rpc.mjs +10 -0
  22. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  23. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  24. package/plugin-src/host/channels/slack/rpc.mjs +10 -0
  25. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  26. package/plugin-src/host/channels/wecom-app/rpc.mjs +10 -0
  27. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  28. package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
  29. package/src/channels/dingtalk/dingtalk-bridge.mjs +31 -2
  30. package/src/channels/dingtalk/dingtalk-menu.mjs +1 -1
  31. package/src/channels/dingtalk/dingtalk-runtime.mjs +2 -1
  32. package/src/channels/discord/discord-runtime.mjs +3 -1
  33. package/src/channels/feishu/bridge.mjs +69 -5
  34. package/src/channels/feishu/feishu-cards.mjs +8 -4
  35. package/src/channels/feishu/slash-command-panel.mjs +1 -0
  36. package/src/channels/qq/qq-bridge.mjs +40 -2
  37. package/src/channels/qq/qq-menu.mjs +2 -2
  38. package/src/channels/shared/bot-workspace-store.mjs +69 -1
  39. package/src/channels/shared/busy-message-dispatcher.mjs +94 -0
  40. package/src/channels/shared/clear-command.mjs +85 -0
  41. package/src/channels/shared/command-catalog.mjs +3 -0
  42. package/src/channels/shared/command-permission.mjs +2 -0
  43. package/src/channels/shared/control-command.mjs +20 -11
  44. package/src/channels/shared/i18n-en/feishu.mjs +6 -4
  45. package/src/channels/shared/i18n-en/qq.mjs +2 -1
  46. package/src/channels/shared/i18n-en/shared-b.mjs +27 -0
  47. package/src/channels/shared/message-mode.mjs +19 -0
  48. package/src/channels/shared/text-harness-bridge.mjs +48 -5
  49. package/src/channels/wecom/wecom-bridge.mjs +26 -2
  50. package/src/channels/wecom/wecom-cards.mjs +1 -1
  51. package/src/channels/wecom-app/wecom-app-bridge.mjs +26 -2
  52. package/src/channels/weixin/weixin-bridge.mjs +22 -2
@@ -1,3 +1,4 @@
1
+ import { SET_MESSAGE_MODE_ENDPOINT, validMessageModePayload } from '../../../../src/channels/shared/message-mode.mjs';
1
2
  import { SET_ALIAS_ENDPOINT, validAliasPayload } from '../shared/bot-alias-rpc.mjs';
2
3
  import { registerManagementRpc } from '../../../management-rpc.mjs';
3
4
  import QRCode from 'qrcode';
@@ -32,6 +33,7 @@ export const WEIXIN_ENDPOINTS = Object.freeze({
32
33
  setModel: SET_MODEL_ENDPOINT,
33
34
  setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
34
35
  setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
36
+ setBusyMessageMode: SET_MESSAGE_MODE_ENDPOINT,
35
37
  setAccessPolicy: SET_ACCESS_POLICY_ENDPOINT,
36
38
  setAlias: SET_ALIAS_ENDPOINT,
37
39
  });
@@ -95,6 +97,10 @@ function payloadFailure(endpoint, payload) {
95
97
  return validAgentPresetPayload(payload)
96
98
  ? null : '请选择 Agent Preset。';
97
99
  }
100
+ if (endpoint === WEIXIN_ENDPOINTS.setBusyMessageMode) {
101
+ return validMessageModePayload(payload)
102
+ ? null : '请提交有效的消息处理方式。';
103
+ }
98
104
  if (endpoint === WEIXIN_ENDPOINTS.setContextEnhancement) {
99
105
  return validContextEnhancementPayload(payload)
100
106
  ? null : '请提交有效的上下文增强设置。';
@@ -235,6 +241,11 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl, logge
235
241
  await controller.updateModel(payload.botId, payload.model),
236
242
  cachedEncode,
237
243
  );
244
+ } else if (endpoint === WEIXIN_ENDPOINTS.setBusyMessageMode) {
245
+ if (typeof controller.updateBusyMessageMode !== 'function') throw new Error('Message mode update is unavailable');
246
+ value = await controller.updateBusyMessageMode(
247
+ payload.botId, payload.busyMessageMode, (status) => publicStatus(status, cachedEncode),
248
+ );
238
249
  } else if (endpoint === WEIXIN_ENDPOINTS.setContextEnhancement) {
239
250
  if (typeof controller.updateContextEnhancement !== 'function') throw new Error('Context enhancement update is unavailable');
240
251
  value = await controller.updateContextEnhancement(
@@ -1,3 +1,4 @@
1
+ import { SET_MESSAGE_MODE_ENDPOINT, validMessageModePayload } from '../../../../src/channels/shared/message-mode.mjs';
1
2
  import { createConnectionDiagnostics, diagnosticRpcResult } from '../../../../src/channels/shared/connection-error.mjs';
2
3
  import { SET_ALIAS_ENDPOINT, validAliasPayload } from '../shared/bot-alias-rpc.mjs';
3
4
  import { registerManagementRpc } from '../../../management-rpc.mjs';
@@ -25,6 +26,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
25
26
  setModel: SET_MODEL_ENDPOINT,
26
27
  setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
27
28
  setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
29
+ setBusyMessageMode: SET_MESSAGE_MODE_ENDPOINT,
28
30
  });
29
31
  export const WHATSAPP_RPC_ENDPOINTS = Object.freeze(Object.values(WHATSAPP_ENDPOINTS));
30
32
 
@@ -79,6 +81,10 @@ function payloadFailure(endpoint, payload) {
79
81
  return validAgentPresetPayload(payload)
80
82
  ? null : '请选择 Agent Preset。';
81
83
  }
84
+ if (endpoint === WHATSAPP_ENDPOINTS.setBusyMessageMode) {
85
+ return validMessageModePayload(payload)
86
+ ? null : '请提交有效的消息处理方式。';
87
+ }
82
88
  if (endpoint === WHATSAPP_ENDPOINTS.setContextEnhancement) {
83
89
  return validContextEnhancementPayload(payload)
84
90
  ? null : '请提交有效的上下文增强设置。';
@@ -192,6 +198,11 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
192
198
  await controller.updateModel(payload.botId, payload.model),
193
199
  cachedEncode,
194
200
  );
201
+ } else if (endpoint === WHATSAPP_ENDPOINTS.setBusyMessageMode) {
202
+ if (typeof controller.updateBusyMessageMode !== 'function') throw new Error('Message mode update is unavailable');
203
+ value = await controller.updateBusyMessageMode(
204
+ payload.botId, payload.busyMessageMode, (status) => publicStatus(status, cachedEncode),
205
+ );
195
206
  } else if (endpoint === WHATSAPP_ENDPOINTS.setContextEnhancement) {
196
207
  if (typeof controller.updateContextEnhancement !== 'function') throw new Error('Context enhancement update is unavailable');
197
208
  value = await controller.updateContextEnhancement(
@@ -1,3 +1,4 @@
1
+ import { BusyMessageDispatcher, canAutomaticallySteer } from '../shared/busy-message-dispatcher.mjs';
1
2
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
2
3
  import {
3
4
  DINGTALK_DONE_REACTION_NAME,
@@ -15,6 +16,7 @@ import {
15
16
  } from '../shared/harness-question.mjs';
16
17
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
17
18
  import { runCompactCommand } from '../shared/compact-command.mjs';
19
+ import { isClearCommand, runClearCommand } from '../shared/clear-command.mjs';
18
20
  import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
19
21
  import {
20
22
  isControlCommand,
@@ -87,6 +89,7 @@ const HELP_TEXT_LINES = [
87
89
  '/m 或 /menu 打开下拉操作菜单',
88
90
  '/new 开启一个全新会话',
89
91
  '/compact 压缩当前会话的较早上下文',
92
+ '/clear 清空当前会话上下文(保留 Session 绑定和历史记录)',
90
93
  '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
91
94
  '/workspace 工作区序号或绝对路径 切换工作区',
92
95
  '/workspacelist 列出工作区绝对路径',
@@ -539,6 +542,7 @@ export class DingtalkHarnessBridge {
539
542
  #maxMessageChars;
540
543
  #signal;
541
544
  #queues = new Map();
545
+ #busyMessages = new BusyMessageDispatcher();
542
546
  #pendingInteractions = new Map();
543
547
  #interactionKeys = new Map();
544
548
  #interactionTasks = new Set();
@@ -597,7 +601,7 @@ export class DingtalkHarnessBridge {
597
601
  return structuredClone(this.#status);
598
602
  }
599
603
 
600
- accept(message, { contextSnapshot } = {}) {
604
+ accept(message, { contextSnapshot, busyMessageMode } = {}) {
601
605
  if (this.#signal?.aborted) return Promise.resolve();
602
606
  const messageId = nonEmptyString(message?.msgId);
603
607
  const sender = senderStaffId(message);
@@ -716,6 +720,7 @@ export class DingtalkHarnessBridge {
716
720
  const commandRunner = isDingtalkMenuCommand(commandText)
717
721
  && !hasInboundFiles(promptMessage) && !hasInboundImages(promptMessage)
718
722
  ? async () => { await this.#showMenu(message, key); }
723
+ : isClearCommand(commandText) ? runClearCommand
719
724
  : isHistoryCommand(commandText) ? runHistoryCommand
720
725
  : hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
721
726
  ? runControlCommand
@@ -834,7 +839,28 @@ export class DingtalkHarnessBridge {
834
839
  pending.queue = current;
835
840
  return finish(current);
836
841
  }
837
- return finish(this.#enqueueMessage(message, messageId, sender, key, { statusReaction }));
842
+ return finish(this.#busyMessages.dispatch({
843
+ key, messageId, text: commandText, mode: busyMessageMode,
844
+ onFailure: (error) => this.#finishStatusReaction(statusReaction,
845
+ this.#signal?.aborted || error?.code === 'turn-stopped' ? 'clear' : 'error'),
846
+ eligible: () => Boolean(sessionWebhook) && addressed && String(message.msgtype).toLowerCase() === 'text'
847
+ && !hasInboundImages(promptMessage) && !hasInboundFiles(promptMessage)
848
+ && !hasReplyReference(promptMessage) && !isDingtalkMenuCommand(commandText)
849
+ && canAutomaticallySteer(this.#accessPolicy, conversationType, sender),
850
+ harness: this.#harness, state: this.#state, status: this.#status,
851
+ signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
852
+ acceptedMessageIds: this.#acceptedMessageIds,
853
+ pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
854
+ enhancement: {
855
+ snapshot: this.#acceptedMessageIds.get(messageId),
856
+ source: () => ({ channel: 'dingtalk', senderId: sender, senderName: message.senderNick,
857
+ conversationTitle: message.conversationTitle, chatId: message.conversationId }),
858
+ },
859
+ isQueued: () => this.#queues.has(key),
860
+ enqueue: (options) => this.#enqueueMessage(message, messageId, sender, key, { ...options, statusReaction }),
861
+ send: (reply) => sessionWebhook && addressed
862
+ ? this.#send(sessionWebhook, reply, this.#atUsersFor(message)) : Promise.resolve(),
863
+ }));
838
864
  }
839
865
 
840
866
  async #showMenu(message, key) {
@@ -933,6 +959,7 @@ export class DingtalkHarnessBridge {
933
959
  result = { message: helpText() };
934
960
  } else {
935
961
  result = await runWorkspaceCommand(command, this.#harness, key)
962
+ ?? await runClearCommand(command, this.#harness, this.#state, key, options)
936
963
  ?? await runCompactCommand(command, this.#harness, this.#state, key, options)
937
964
  ?? await runControlCommand(command, this.#harness, this.#state, key, options)
938
965
  ?? await runHistoryCommand(command, this.#harness, this.#state, key, options)
@@ -1133,6 +1160,7 @@ export class DingtalkHarnessBridge {
1133
1160
  }
1134
1161
 
1135
1162
  async waitForIdle() {
1163
+ await this.#busyMessages.whenIdle();
1136
1164
  await Promise.allSettled([
1137
1165
  ...this.#queues.values(),
1138
1166
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
@@ -1162,6 +1190,7 @@ export class DingtalkHarnessBridge {
1162
1190
  hasFiles: hasInboundFiles(prompt),
1163
1191
  pendingInteraction: this.#pendingInteractions.has(key)
1164
1192
  || this.#approvals.hasPending(key),
1193
+ busy: this.#queues.has(key) || this.#batchInputs.status(key).phase !== 'idle',
1165
1194
  control: { owner: this, key },
1166
1195
  deferredDelivery: this.#deferred,
1167
1196
  enhancement: captureContextEnhancementSource(
@@ -5,7 +5,7 @@ import { t } from '../shared/i18n.mjs';
5
5
  export const DINGTALK_MENU_TEMPLATE_ID = '9ca31362-e5b3-409c-a694-6993706a6004.schema';
6
6
  export const DINGTALK_CARD_TOPIC = '/v1.0/card/instances/callback';
7
7
  export const DINGTALK_MENU_BUTTONS = Object.freeze({
8
- new: '/new', history: '/history', stop: '/stop', compact: '/compact',
8
+ new: '/new', history: '/history', stop: '/stop', compact: '/compact', clear: '/clear',
9
9
  status: '/status', help: '/help',
10
10
  });
11
11
 
@@ -311,6 +311,7 @@ export class DingtalkRuntime {
311
311
  } catch {
312
312
  // Optional settings failures leave the original message path active.
313
313
  }
314
+ const busyMessageMode = this.#harness.currentBusyMessageMode?.() ?? 'queue';
314
315
  const task = Promise.resolve().then(async () => {
315
316
  if (this.#bridge !== bridge) return;
316
317
  let message;
@@ -330,7 +331,7 @@ export class DingtalkRuntime {
330
331
  get botId() { return contextEnhancement?.botId; },
331
332
  }, message.conversationType === '1' || message.conversationType === 1 ? 'direct'
332
333
  : message.conversationType === '2' || message.conversationType === 2 ? 'group' : null);
333
- await bridge.accept(message, { contextSnapshot });
334
+ await bridge.accept(message, { contextSnapshot, busyMessageMode });
334
335
  }).catch(() => {
335
336
  if (signal.aborted || this.#bridge !== bridge) return;
336
337
  this.#status.lastError = t('钉钉消息处理失败。');
@@ -876,13 +876,14 @@ export class DiscordRuntime {
876
876
  this.#contextEnhancement,
877
877
  message.guild_id ? 'group' : 'direct',
878
878
  );
879
+ const busyMessageMode = this.#harness.currentBusyMessageMode?.() ?? 'queue';
879
880
  const pendingRoute = resolveDiscordMessageRoute(message, this.#config.platformId, {
880
881
  api: this.#api,
881
882
  channel: this.#channels.get(String(message.channel_id)),
882
883
  signal: this.#abortController?.signal,
883
884
  onChannel: (resolved) => this.#rememberChannel(resolved),
884
885
  });
885
- route = { pendingRoute, contextSnapshot };
886
+ route = { pendingRoute, contextSnapshot, busyMessageMode };
886
887
  this.#routing.set(messageId, route);
887
888
  void pendingRoute.finally(() => {
888
889
  if (this.#routing.get(messageId) === route) this.#routing.delete(messageId);
@@ -893,6 +894,7 @@ export class DiscordRuntime {
893
894
  if (normalized) {
894
895
  await bridge.accept(normalized, {
895
896
  contextSnapshot: route.contextSnapshot,
897
+ busyMessageMode: route.busyMessageMode,
896
898
  ...(accessDecision ? { accessDecision } : {}),
897
899
  });
898
900
  }
@@ -1,3 +1,4 @@
1
+ import { BusyMessageDispatcher, canAutomaticallySteer } from '../shared/busy-message-dispatcher.mjs';
1
2
  import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import QRCode from 'qrcode';
@@ -43,6 +44,7 @@ import {
43
44
  isBatchInputCommand,
44
45
  } from '../shared/batch-input.mjs';
45
46
  import { runCompactCommand } from '../shared/compact-command.mjs';
47
+ import { isClearCommand, runClearCommand } from '../shared/clear-command.mjs';
46
48
  import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
47
49
  import {
48
50
  isControlCommand,
@@ -178,7 +180,7 @@ const REPAIR_URL_HOSTS = new Set([
178
180
 
179
181
  const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
180
182
  /** Matches fast card commands that should not be queued behind a running task. */
181
- const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|workspaces|wsl|watchlist|archived(?:\s+(on|off))?)$/i;
183
+ const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|clear|(?:sessionlist|sessions)(?:\s|$)|workspacelist|workspaces|wsl|watchlist|archived(?:\s+(on|off))?)$/i;
182
184
 
183
185
  // ── Step push (分步直推) limits ──────────────────────────────────────────────
184
186
  /** Feishu allows 5 msg/s per chat; step push stays well below that. */
@@ -617,6 +619,7 @@ export class FeishuHarnessBridge {
617
619
  #contextEnhancement;
618
620
  #accessPolicy;
619
621
  #queues = new Map();
622
+ #busyMessages = new BusyMessageDispatcher();
620
623
  #batchInputs = new BatchInputManager();
621
624
  #pendingInteractions = new Map();
622
625
  #interactionKeys = new Map();
@@ -1388,7 +1391,27 @@ export class FeishuHarnessBridge {
1388
1391
  this.#interactionTasks.add(current);
1389
1392
  return current;
1390
1393
  }
1391
- return this.#enqueueMessage(event, messageId, key, processingReaction);
1394
+ return this.#busyMessages.dispatch({
1395
+ key, messageId, text: commandText,
1396
+ eligible: () => event.message.message_type === 'text' && !hasImages && !hasFiles
1397
+ && !hasReplyReference(commandMessage) && !isFeishuLocalCommand(commandText)
1398
+ && !(NUMBER_REPLY.test(commandText) && this.#menus.has(key))
1399
+ && canAutomaticallySteer(this.#accessPolicy, conversationType, senderOpenId(event)),
1400
+ harness: this.#harness, state: this.#state, status: this.#status,
1401
+ signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
1402
+ acceptedMessageIds: this.#acceptedMessageIds,
1403
+ pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
1404
+ enhancement: {
1405
+ snapshot: this.#acceptedMessageIds.get(messageId),
1406
+ source: () => ({ channel: 'feishu', senderId: senderOpenId(event),
1407
+ chatId: event.message.chat_id, threadId: event.message.thread_id }),
1408
+ },
1409
+ isQueued: () => this.#queues.has(key),
1410
+ enqueue: (options) => this.#enqueueMessage(event, messageId, key, processingReaction, options),
1411
+ send: (reply) => this.#send(event.message.chat_id, reply, { replyTo: messageId }),
1412
+ onSteered: () => this.#finishReaction(messageId, processingReaction, 'DONE'),
1413
+ onError: (error) => this.#handleMessageFailure(event, messageId, processingReaction, error),
1414
+ });
1392
1415
  }
1393
1416
 
1394
1417
  #finishBatchResult(event, messageId, processingReaction, result) {
@@ -1545,6 +1568,7 @@ export class FeishuHarnessBridge {
1545
1568
  }
1546
1569
 
1547
1570
  async waitForIdle() {
1571
+ await this.#busyMessages.whenIdle();
1548
1572
  // Drain to a fixed point: awaited work can register compensation or
1549
1573
  // another serialized tail before it settles.
1550
1574
  for (;;) {
@@ -1600,6 +1624,7 @@ export class FeishuHarnessBridge {
1600
1624
  deferredDelivery: this.#deferred,
1601
1625
  hasFiles: hasInboundFiles(message),
1602
1626
  pendingInteraction: this.#hasPendingInteraction(key),
1627
+ busy: this.#queues.has(key),
1603
1628
  control: { owner: this, key },
1604
1629
  enhancement: captureContextEnhancementSource(
1605
1630
  this.#contextEnhancement,
@@ -1691,7 +1716,8 @@ export class FeishuHarnessBridge {
1691
1716
  // 命令识别对 text 与纯文本 post 一视同仁:post 富文本若仅含单个
1692
1717
  // 文本段落(如复制粘贴的 /new),同样按命令处理;带图片/文件不认。
1693
1718
  // accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
1694
- const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
1719
+ const rawCommandText = text ? text.trim() : '';
1720
+ const commandText = !hasImages && !hasFiles && text ? rawCommandText : null;
1695
1721
  if (!text && !hasImages && !hasFiles && !hasReply) {
1696
1722
  // An "@bot" with nothing else carries no instruction to parse, and the
1697
1723
  // menu card is what the reader is reaching for — answer it the way `/m`
@@ -1751,6 +1777,19 @@ export class FeishuHarnessBridge {
1751
1777
  }
1752
1778
  return;
1753
1779
  }
1780
+ if (isClearCommand(rawCommandText)) {
1781
+ const clearCommand = await runClearCommand(rawCommandText, this.#harness, this.#state, key, {
1782
+ signal: this.#signal,
1783
+ hasImages,
1784
+ hasFiles,
1785
+ pendingInteraction: this.#hasPendingInteraction(key),
1786
+ busy: this.#queues.has(key),
1787
+ });
1788
+ if (clearCommand) {
1789
+ await this.#send(event.message.chat_id, clearCommand.message, { replyTo: event.message.message_id });
1790
+ }
1791
+ return;
1792
+ }
1754
1793
  if (SESSION_LIST_PREFIX.test(commandText)) {
1755
1794
  const argument = commandText.replace(/^\/(?:sessionlist|sessions)/i, '').trim();
1756
1795
  const request = parseSessionListArgument(argument);
@@ -2551,6 +2590,7 @@ export class FeishuHarnessBridge {
2551
2590
  actor = null,
2552
2591
  menuContext = null,
2553
2592
  newSessionActionId = null,
2593
+ fromMessageQueue = false,
2554
2594
  }) {
2555
2595
  // Confirmations triggered by a card interaction stay anchored to the
2556
2596
  // card's message so they land inside the same Feishu topic.
@@ -2704,7 +2744,9 @@ export class FeishuHarnessBridge {
2704
2744
  await this.#handleNewSessionMenu({ key, chatId, messageId, menuContext, newSessionActionId });
2705
2745
  return;
2706
2746
  }
2707
- if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
2747
+ // A queued menu pick runs after earlier messages; its own unfinished
2748
+ // queue (including later messages) must not prevent the reset.
2749
+ if ((!fromMessageQueue && this.#queues.has(key)) || this.#hasPendingInteraction(key)) {
2708
2750
  await reply(t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
2709
2751
  return;
2710
2752
  }
@@ -2739,6 +2781,10 @@ export class FeishuHarnessBridge {
2739
2781
  await this.#handleCompact(key, chatId, messageId);
2740
2782
  return;
2741
2783
  }
2784
+ if (action === 'clear') {
2785
+ await this.#handleClear(key, chatId, messageId);
2786
+ return;
2787
+ }
2742
2788
  if (action === 'stop') {
2743
2789
  await this.#handleStop(key, chatId, messageId);
2744
2790
  return;
@@ -2927,7 +2973,9 @@ export class FeishuHarnessBridge {
2927
2973
  await this.#handleRepairCommand(event, '/repair');
2928
2974
  return;
2929
2975
  }
2930
- await this.#handleCardAction(action, { chatId, key, messageId: replyTo });
2976
+ await this.#handleCardAction(action, {
2977
+ chatId, key, messageId: replyTo, fromMessageQueue: action === 'new',
2978
+ });
2931
2979
  return;
2932
2980
  }
2933
2981
  if (menu.kind === 'sessions') {
@@ -3509,6 +3557,22 @@ export class FeishuHarnessBridge {
3509
3557
  }
3510
3558
  }
3511
3559
 
3560
+ /** Run /clear while retaining the current Session binding. */
3561
+ async #handleClear(key, chatId, replyTo = null) {
3562
+ try {
3563
+ const result = await runClearCommand(
3564
+ '/clear', this.#harness, this.#state, key, {
3565
+ signal: this.#signal,
3566
+ pendingInteraction: this.#hasPendingInteraction(key),
3567
+ busy: this.#queues.has(key),
3568
+ },
3569
+ );
3570
+ await this.#send(chatId, result?.message || t('清空当前会话上下文失败,当前会话未修改,请稍后重试。'), { replyTo });
3571
+ } catch (error) {
3572
+ await this.#sendFailure(chatId, error, { logLabel: 'clear', replyTo });
3573
+ }
3574
+ }
3575
+
3512
3576
  /**
3513
3577
  * Stop the running task in the bound session (mirrors `/stop`).
3514
3578
  */
@@ -353,6 +353,7 @@ export function menuCard(ctx) {
353
353
  // ── 任务控制(对运行中任务的操作)────────────────────────
354
354
  elements.push({ tag: 'div', text: markdown(t('**任务控制**')) });
355
355
  elements.push(buttonPair(t('⏹ 停止'), 'stop', t('📐 压缩'), 'compact'));
356
+ elements.push(button(t('🧹 清空上下文'), 'clear'));
356
357
  elements.push({ tag: 'hr' });
357
358
 
358
359
  // ── 补充指令 + 归档切换(并列)────────────────────────────
@@ -573,6 +574,7 @@ export function menuHelpText() {
573
574
  '/status 连接状态',
574
575
  '/version 查看插件版本',
575
576
  '/compact 压缩当前会话上下文',
577
+ '/clear 清空当前会话上下文(保留 Session 绑定和历史记录)',
576
578
  '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
577
579
  '/archived on/off 会话列表显示/隐藏归档',
578
580
  '',
@@ -620,10 +622,11 @@ const HELP_CARD_FEATURES = [
620
622
  '6. 📋 会话/关注 — 查看/绑定会话,管理关注',
621
623
  '7. ⏹ 停止 — 停止当前任务',
622
624
  '8. 📐 压缩 — 压缩当前会话上下文',
623
- '9. 补充指令 — 给 Agent 发送指令',
624
- '10. 🗄 归档切换 — 显示/隐藏归档会话',
625
- '11. 📊 状态 — 查看系统连接状态',
626
- '12. 📖 帮助 — 查看本帮助',
625
+ '9. 🧹 清空上下文 — 清空上下文并保留绑定和历史记录',
626
+ '10. 补充指令 — 给 Agent 发送指令',
627
+ '11. 🗄 归档切换 — 显示/隐藏归档会话',
628
+ '12. 📊 状态 — 查看系统连接状态',
629
+ '13. 📖 帮助 — 查看本帮助',
627
630
  ].join('\n');
628
631
 
629
632
  const HELP_TEXT_COMMANDS = [
@@ -638,6 +641,7 @@ const HELP_TEXT_COMMANDS = [
638
641
  '`/workspacelist` — 列出工作区',
639
642
  '`/status` — 查看连接状态',
640
643
  '`/compact` — 压缩上下文',
644
+ '`/clear` — 清空上下文并保留 Session 绑定和历史记录',
641
645
  '`/stop` — 停止当前任务',
642
646
  '`/steer 指令` — 补充指令',
643
647
  '`/watch ID` — 关注会话',
@@ -30,6 +30,7 @@ export const SLASH_COMMAND_MANIFEST = Object.freeze([
30
30
  { command: 'help', icon: 'promptword_outlined', default: '查看帮助', en_us: 'Show help' },
31
31
  { command: 'status', icon: 'ai-functions_outlined', default: '查看机器人状态', en_us: 'Show bot status' },
32
32
  { command: 'compact', icon: 'ai-block_outlined', default: '压缩当前会话上下文', en_us: 'Compact the current session' },
33
+ { command: 'clear', icon: 'clear_outlined', default: '清空当前会话上下文', en_us: 'Clear the current session context' },
33
34
  { command: 'sessionlist', icon: 'chat-ai_outlined', default: '列出会话', en_us: 'List sessions' },
34
35
  { command: 'workspacelist', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
35
36
  { command: 'workspaces', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
@@ -1,7 +1,9 @@
1
+ import { BusyMessageDispatcher, canAutomaticallySteer } from '../shared/busy-message-dispatcher.mjs';
1
2
  import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
2
3
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
3
4
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
4
5
  import { runCompactCommand } from '../shared/compact-command.mjs';
6
+ import { isClearCommand, runClearCommand } from '../shared/clear-command.mjs';
5
7
  import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
6
8
  import {
7
9
  isControlCommand,
@@ -101,6 +103,7 @@ function helpText() {
101
103
  t('/menu 或 /m 打开可点击的功能菜单'),
102
104
  t('/new 开启一个全新会话'),
103
105
  t('/compact 压缩当前会话的较早上下文'),
106
+ t('/clear 清空当前会话上下文(保留 Session 绑定和历史记录)'),
104
107
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
105
108
  t('/workspace 工作区序号或绝对路径 切换工作区'),
106
109
  t('/workspacelist 列出工作区绝对路径'),
@@ -445,6 +448,7 @@ export class QqHarnessBridge {
445
448
  #fetchImpl;
446
449
  #fileUploadTimeoutMs;
447
450
  #queues = new Map();
451
+ #busyMessages = new BusyMessageDispatcher();
448
452
  #pendingInteractions = new Map();
449
453
  #interactionKeys = new Map();
450
454
  // Keep the accepted configuration through the existing queue/reply lifecycle.
@@ -599,6 +603,7 @@ export class QqHarnessBridge {
599
603
  }
600
604
  const commandRunner = explicitMenu || numericMenu
601
605
  ? () => this.#runMenuCommand(message, menuInput, key)
606
+ : isClearCommand(commandText) ? runClearCommand
602
607
  : isHistoryCommand(commandText) ? runHistoryCommand
603
608
  : isPermissionCommand(commandText) ? runPermissionCommand
604
609
  : hasQqFileAttachments(message) ? null : isControlCommand(commandText)
@@ -683,7 +688,25 @@ export class QqHarnessBridge {
683
688
  pending.queue = current;
684
689
  return current;
685
690
  }
686
- return this.#enqueueMessage(message, messageId, key);
691
+ return this.#busyMessages.dispatch({
692
+ key, messageId, text: commandText,
693
+ eligible: () => addressed && menuTextOnly && !explicitMenu && !numericMenu
694
+ && canAutomaticallySteer(this.#accessPolicy, message.kind === 'c2c' ? 'direct' : 'group', sender),
695
+ harness: this.#harness, state: this.#state, status: this.#status,
696
+ signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
697
+ acceptedMessageIds: this.#acceptedMessageIds,
698
+ pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
699
+ enhancement: {
700
+ snapshot: this.#acceptedMessageIds.get(messageId),
701
+ source: () => ({ channel: 'qq', senderId: sender,
702
+ senderName: message.kind === 'group' ? message.senderName : undefined,
703
+ chatId: message.kind === 'group' ? message.groupOpenid : sender }),
704
+ },
705
+ isQueued: () => this.#queues.has(key),
706
+ enqueue: (options) => this.#enqueueMessage(message, messageId, key, options),
707
+ send: (reply) => addressed ? this.#bot.sendText(message.replyTarget, reply) : Promise.resolve(),
708
+ onError: (error) => this.#handleInteractionFailure(message, messageId, error),
709
+ });
687
710
  }
688
711
 
689
712
  #enqueueMessage(message, messageId, key, {
@@ -765,7 +788,7 @@ export class QqHarnessBridge {
765
788
  const pendingInteraction = this.#pendingInteractions.has(key) || this.#approvals.hasPending(key);
766
789
  const isBusy = () => this.#queues.has(key) || this.#pendingInteractions.has(key)
767
790
  || this.#approvals.hasPending(key) || this.#batchInputs.status(key).phase !== 'idle';
768
- const needsIdle = /^\/(?:new|session|workspace|compact)(?:\s|$)/u.test(command);
791
+ const needsIdle = /^\/(?:new|session|workspace|compact|clear)(?:\s|$)/u.test(command);
769
792
  if (isBusy() && needsIdle) {
770
793
  return { message: t('当前任务仍在运行,请先停止任务或等待任务完成后再执行此操作。') };
771
794
  }
@@ -806,6 +829,7 @@ export class QqHarnessBridge {
806
829
  await this.#state.clearSession(key);
807
830
  return { message: t('已开启新会话。请发送你的问题。') };
808
831
  });
832
+ if (isClearCommand(command)) return runClearCommand(command, this.#harness, this.#state, key, options);
809
833
  if (command === '/compact') return runCompactCommand(command, this.#harness, this.#state, key, options);
810
834
  return runWorkspaceCommand(command, this.#harness, key);
811
835
  };
@@ -819,6 +843,7 @@ export class QqHarnessBridge {
819
843
  }
820
844
 
821
845
  async waitForIdle() {
846
+ await this.#busyMessages.whenIdle();
822
847
  await Promise.allSettled([
823
848
  ...this.#queues.values(),
824
849
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
@@ -843,6 +868,7 @@ export class QqHarnessBridge {
843
868
  hasFiles: hasQqFileAttachments(message),
844
869
  pendingInteraction: this.#pendingInteractions.has(key)
845
870
  || this.#approvals.hasPending(key),
871
+ busy: this.#queues.has(key),
846
872
  control: { owner: this, key },
847
873
  deferredDelivery: this.#deferred,
848
874
  enhancement: captureContextEnhancementSource(
@@ -1020,6 +1046,18 @@ export class QqHarnessBridge {
1020
1046
  await markMessageSeen();
1021
1047
  return;
1022
1048
  }
1049
+ const clearCommand = await runClearCommand(text, this.#harness, this.#state, key, {
1050
+ signal: this.#signal,
1051
+ hasImages,
1052
+ hasFiles,
1053
+ pendingInteraction: this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
1054
+ busy: this.#queues.has(key),
1055
+ });
1056
+ if (clearCommand) {
1057
+ await this.#bot.sendText(target, clearCommand.message);
1058
+ await markMessageSeen();
1059
+ return;
1060
+ }
1023
1061
  const workspaceCommand = hasImages || hasFiles
1024
1062
  ? null
1025
1063
  : await runWorkspaceCommand(text, this.#harness, key);
@@ -122,7 +122,7 @@ export async function qqMenuView(name, harness, state, key, { signal, busy = fal
122
122
  columns: 3, entries: name === 'status' ? [back()] : [
123
123
  section(t('💬 会话选择'), 'sessions'), section(t('📁 工作区'), 'workspaces'), section(t('🤖 模式/预设'), 'presets'),
124
124
  section(t('🧠 模型'), 'models'), command(t('🆕 新会话'), '/new'), section(t('📋 会话列表'), 'sessions'),
125
- command(t('⏹ 停止'), '/stop'), command(t('📐 压缩'), '/compact'), section(t('💬 补充指令'), 'steer'),
125
+ command(t('⏹ 停止'), '/stop'), command(t('📐 压缩'), '/compact'), command(t('🧹 清空上下文'), '/clear'), section(t('💬 补充指令'), 'steer'),
126
126
  { label: t('切换归档显示'), action: { kind: 'archive', include: !archived } },
127
127
  section(t('📊 状态'), 'status'), section(t('📖 帮助'), 'help'),
128
128
  ] };
@@ -163,7 +163,7 @@ export async function qqMenuView(name, harness, state, key, { signal, busy = fal
163
163
  t('发送 /m 打开助手中心,点击按钮或回复当前列表的数字选择。'),
164
164
  t('设置:会话、工作区、模式/预设、模型。'),
165
165
  t('会话:新会话、会话列表、归档显示切换。'),
166
- t('任务:停止、压缩、快捷或自定义补充指令。'),
166
+ t('任务:停止、压缩、清空上下文、快捷或自定义补充指令。'),
167
167
  t('信息:状态、帮助。'),
168
168
  t('菜单有效期为 15 分钟;发送普通消息会退出数字选择。'),
169
169
  t('回答问题、审批和批量输入时,数字优先用于当前交互。'),