@xmanrui/dsh-im 4.36.1 → 4.38.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 (63) hide show
  1. package/README.en.md +14 -2
  2. package/README.md +13 -1
  3. package/assets/logo-plugin-message-link-gradient.webp +0 -0
  4. package/lib/client.js +417 -189
  5. package/lib/index.js +317 -316
  6. package/package.json +2 -2
  7. package/plugin-src/client/bot-general-settings.js +120 -0
  8. package/plugin-src/client/channels/dingtalk/api.js +3 -1
  9. package/plugin-src/client/channels/feishu/api.js +6 -2
  10. package/plugin-src/client/channels/qq/api.js +3 -1
  11. package/plugin-src/client/channels/shared/token-api.js +3 -1
  12. package/plugin-src/client/channels/wecom/api.js +3 -1
  13. package/plugin-src/client/channels/wecom-app/api.js +3 -1
  14. package/plugin-src/client/channels/weixin/api.js +3 -1
  15. package/plugin-src/client/channels/whatsapp/api.js +3 -1
  16. package/plugin-src/client/delivery-settings.js +5 -1
  17. package/plugin-src/client/i18n.js +14 -0
  18. package/plugin-src/client/styles.js +19 -0
  19. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  20. package/plugin-src/host/channels/email/rpc.mjs +1 -0
  21. package/plugin-src/host/channels/feishu/production.mjs +1 -0
  22. package/plugin-src/host/channels/feishu/rpc.mjs +31 -0
  23. package/plugin-src/host/channels/imessage/rpc.mjs +1 -0
  24. package/plugin-src/host/channels/matrix/rpc.mjs +10 -0
  25. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  26. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  27. package/plugin-src/host/channels/slack/rpc.mjs +10 -0
  28. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  29. package/plugin-src/host/channels/wecom-app/rpc.mjs +10 -0
  30. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  31. package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
  32. package/scripts/verify-package.mjs +11 -6
  33. package/src/channels/dingtalk/dingtalk-bridge.mjs +31 -2
  34. package/src/channels/dingtalk/dingtalk-menu.mjs +1 -1
  35. package/src/channels/dingtalk/dingtalk-runtime.mjs +2 -1
  36. package/src/channels/discord/discord-runtime.mjs +3 -1
  37. package/src/channels/feishu/bridge.mjs +91 -15
  38. package/src/channels/feishu/feishu-cards.mjs +16 -11
  39. package/src/channels/feishu/feishu-runtime.mjs +10 -1
  40. package/src/channels/feishu/multi-bot-controller.mjs +18 -0
  41. package/src/channels/feishu/plugin-config-store.mjs +2 -1
  42. package/src/channels/feishu/slash-command-panel.mjs +1 -0
  43. package/src/channels/feishu/step-push-mode.mjs +21 -0
  44. package/src/channels/matrix/matrix-crypto.mjs +19 -5
  45. package/src/channels/qq/qq-bridge.mjs +40 -2
  46. package/src/channels/qq/qq-menu.mjs +2 -2
  47. package/src/channels/shared/bot-workspace-store.mjs +69 -1
  48. package/src/channels/shared/busy-message-dispatcher.mjs +94 -0
  49. package/src/channels/shared/clear-command.mjs +85 -0
  50. package/src/channels/shared/command-catalog.mjs +3 -0
  51. package/src/channels/shared/command-permission.mjs +2 -0
  52. package/src/channels/shared/control-command.mjs +20 -11
  53. package/src/channels/shared/harness-approval.mjs +102 -2
  54. package/src/channels/shared/i18n-en/feishu.mjs +6 -4
  55. package/src/channels/shared/i18n-en/qq.mjs +2 -1
  56. package/src/channels/shared/i18n-en/shared-b.mjs +27 -0
  57. package/src/channels/shared/i18n-en/shared-c.mjs +4 -0
  58. package/src/channels/shared/message-mode.mjs +19 -0
  59. package/src/channels/shared/text-harness-bridge.mjs +48 -5
  60. package/src/channels/wecom/wecom-bridge.mjs +26 -2
  61. package/src/channels/wecom/wecom-cards.mjs +1 -1
  62. package/src/channels/wecom-app/wecom-app-bridge.mjs +26 -2
  63. package/src/channels/weixin/weixin-bridge.mjs +22 -2
@@ -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,
@@ -120,6 +122,7 @@ import {
120
122
  import {
121
123
  FEISHU_STEP_PUSH_MODES,
122
124
  normalizeFeishuStepPushMode,
125
+ normalizeFeishuStepCardPanels,
123
126
  } from './step-push-mode.mjs';
124
127
  import { FeishuLiveCot } from './live-cot.mjs';
125
128
 
@@ -178,7 +181,7 @@ const REPAIR_URL_HOSTS = new Set([
178
181
 
179
182
  const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
180
183
  /** 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;
184
+ const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|clear|(?:sessionlist|sessions)(?:\s|$)|workspacelist|workspaces|wsl|watchlist|archived(?:\s+(on|off))?)$/i;
182
185
 
183
186
  // ── Step push (分步直推) limits ──────────────────────────────────────────────
184
187
  /** Feishu allows 5 msg/s per chat; step push stays well below that. */
@@ -617,6 +620,7 @@ export class FeishuHarnessBridge {
617
620
  #contextEnhancement;
618
621
  #accessPolicy;
619
622
  #queues = new Map();
623
+ #busyMessages = new BusyMessageDispatcher();
620
624
  #batchInputs = new BatchInputManager();
621
625
  #pendingInteractions = new Map();
622
626
  #interactionKeys = new Map();
@@ -677,6 +681,7 @@ export class FeishuHarnessBridge {
677
681
  #stepPush = false;
678
682
  /** Step push presentation: discrete posts, a CardKit card, or native live CoT. */
679
683
  #stepPushMode = FEISHU_STEP_PUSH_MODES.POST;
684
+ #stepCardPanels;
680
685
  /** Per-conversation step push state: key → { lastSentAt, count, breakerLogged }. */
681
686
  #stepPushSendState = new Map();
682
687
  /** Live streaming step cards: key → per-turn card state (streaming_card mode). */
@@ -742,6 +747,7 @@ export class FeishuHarnessBridge {
742
747
  mentionTopicReply = true,
743
748
  stepPush = false,
744
749
  stepPushMode = FEISHU_STEP_PUSH_MODES.POST,
750
+ stepCardPanels,
745
751
  stepPushClock = null,
746
752
  repair,
747
753
  repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
@@ -802,6 +808,7 @@ export class FeishuHarnessBridge {
802
808
  this.#mentionTopicReply = mentionTopicReply !== false;
803
809
  this.#stepPush = stepPush === true;
804
810
  this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
811
+ this.#stepCardPanels = normalizeFeishuStepCardPanels(stepCardPanels);
805
812
  this.#stepPushClock = stepPushClock ?? DEFAULT_STEP_PUSH_CLOCK;
806
813
  this.#voice = voice ?? null;
807
814
  this.#repair = repair ?? null;
@@ -898,6 +905,10 @@ export class FeishuHarnessBridge {
898
905
  this.#stepPushMode = normalizeFeishuStepPushMode(value);
899
906
  }
900
907
 
908
+ setStepCardPanels(value) {
909
+ this.#stepCardPanels = normalizeFeishuStepCardPanels(value);
910
+ }
911
+
901
912
  setVoice(voice) {
902
913
  this.#voice = voice ?? null;
903
914
  }
@@ -1388,7 +1399,27 @@ export class FeishuHarnessBridge {
1388
1399
  this.#interactionTasks.add(current);
1389
1400
  return current;
1390
1401
  }
1391
- return this.#enqueueMessage(event, messageId, key, processingReaction);
1402
+ return this.#busyMessages.dispatch({
1403
+ key, messageId, text: commandText,
1404
+ eligible: () => event.message.message_type === 'text' && !hasImages && !hasFiles
1405
+ && !hasReplyReference(commandMessage) && !isFeishuLocalCommand(commandText)
1406
+ && !(NUMBER_REPLY.test(commandText) && this.#menus.has(key))
1407
+ && canAutomaticallySteer(this.#accessPolicy, conversationType, senderOpenId(event)),
1408
+ harness: this.#harness, state: this.#state, status: this.#status,
1409
+ signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
1410
+ acceptedMessageIds: this.#acceptedMessageIds,
1411
+ pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
1412
+ enhancement: {
1413
+ snapshot: this.#acceptedMessageIds.get(messageId),
1414
+ source: () => ({ channel: 'feishu', senderId: senderOpenId(event),
1415
+ chatId: event.message.chat_id, threadId: event.message.thread_id }),
1416
+ },
1417
+ isQueued: () => this.#queues.has(key),
1418
+ enqueue: (options) => this.#enqueueMessage(event, messageId, key, processingReaction, options),
1419
+ send: (reply) => this.#send(event.message.chat_id, reply, { replyTo: messageId }),
1420
+ onSteered: () => this.#finishReaction(messageId, processingReaction, 'DONE'),
1421
+ onError: (error) => this.#handleMessageFailure(event, messageId, processingReaction, error),
1422
+ });
1392
1423
  }
1393
1424
 
1394
1425
  #finishBatchResult(event, messageId, processingReaction, result) {
@@ -1545,6 +1576,7 @@ export class FeishuHarnessBridge {
1545
1576
  }
1546
1577
 
1547
1578
  async waitForIdle() {
1579
+ await this.#busyMessages.whenIdle();
1548
1580
  // Drain to a fixed point: awaited work can register compensation or
1549
1581
  // another serialized tail before it settles.
1550
1582
  for (;;) {
@@ -1600,6 +1632,7 @@ export class FeishuHarnessBridge {
1600
1632
  deferredDelivery: this.#deferred,
1601
1633
  hasFiles: hasInboundFiles(message),
1602
1634
  pendingInteraction: this.#hasPendingInteraction(key),
1635
+ busy: this.#queues.has(key),
1603
1636
  control: { owner: this, key },
1604
1637
  enhancement: captureContextEnhancementSource(
1605
1638
  this.#contextEnhancement,
@@ -1691,7 +1724,8 @@ export class FeishuHarnessBridge {
1691
1724
  // 命令识别对 text 与纯文本 post 一视同仁:post 富文本若仅含单个
1692
1725
  // 文本段落(如复制粘贴的 /new),同样按命令处理;带图片/文件不认。
1693
1726
  // accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
1694
- const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
1727
+ const rawCommandText = text ? text.trim() : '';
1728
+ const commandText = !hasImages && !hasFiles && text ? rawCommandText : null;
1695
1729
  if (!text && !hasImages && !hasFiles && !hasReply) {
1696
1730
  // An "@bot" with nothing else carries no instruction to parse, and the
1697
1731
  // menu card is what the reader is reaching for — answer it the way `/m`
@@ -1751,6 +1785,19 @@ export class FeishuHarnessBridge {
1751
1785
  }
1752
1786
  return;
1753
1787
  }
1788
+ if (isClearCommand(rawCommandText)) {
1789
+ const clearCommand = await runClearCommand(rawCommandText, this.#harness, this.#state, key, {
1790
+ signal: this.#signal,
1791
+ hasImages,
1792
+ hasFiles,
1793
+ pendingInteraction: this.#hasPendingInteraction(key),
1794
+ busy: this.#queues.has(key),
1795
+ });
1796
+ if (clearCommand) {
1797
+ await this.#send(event.message.chat_id, clearCommand.message, { replyTo: event.message.message_id });
1798
+ }
1799
+ return;
1800
+ }
1754
1801
  if (SESSION_LIST_PREFIX.test(commandText)) {
1755
1802
  const argument = commandText.replace(/^\/(?:sessionlist|sessions)/i, '').trim();
1756
1803
  const request = parseSessionListArgument(argument);
@@ -2551,6 +2598,7 @@ export class FeishuHarnessBridge {
2551
2598
  actor = null,
2552
2599
  menuContext = null,
2553
2600
  newSessionActionId = null,
2601
+ fromMessageQueue = false,
2554
2602
  }) {
2555
2603
  // Confirmations triggered by a card interaction stay anchored to the
2556
2604
  // card's message so they land inside the same Feishu topic.
@@ -2704,7 +2752,9 @@ export class FeishuHarnessBridge {
2704
2752
  await this.#handleNewSessionMenu({ key, chatId, messageId, menuContext, newSessionActionId });
2705
2753
  return;
2706
2754
  }
2707
- if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
2755
+ // A queued menu pick runs after earlier messages; its own unfinished
2756
+ // queue (including later messages) must not prevent the reset.
2757
+ if ((!fromMessageQueue && this.#queues.has(key)) || this.#hasPendingInteraction(key)) {
2708
2758
  await reply(t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
2709
2759
  return;
2710
2760
  }
@@ -2739,6 +2789,10 @@ export class FeishuHarnessBridge {
2739
2789
  await this.#handleCompact(key, chatId, messageId);
2740
2790
  return;
2741
2791
  }
2792
+ if (action === 'clear') {
2793
+ await this.#handleClear(key, chatId, messageId);
2794
+ return;
2795
+ }
2742
2796
  if (action === 'stop') {
2743
2797
  await this.#handleStop(key, chatId, messageId);
2744
2798
  return;
@@ -2927,7 +2981,9 @@ export class FeishuHarnessBridge {
2927
2981
  await this.#handleRepairCommand(event, '/repair');
2928
2982
  return;
2929
2983
  }
2930
- await this.#handleCardAction(action, { chatId, key, messageId: replyTo });
2984
+ await this.#handleCardAction(action, {
2985
+ chatId, key, messageId: replyTo, fromMessageQueue: action === 'new',
2986
+ });
2931
2987
  return;
2932
2988
  }
2933
2989
  if (menu.kind === 'sessions') {
@@ -3509,6 +3565,22 @@ export class FeishuHarnessBridge {
3509
3565
  }
3510
3566
  }
3511
3567
 
3568
+ /** Run /clear while retaining the current Session binding. */
3569
+ async #handleClear(key, chatId, replyTo = null) {
3570
+ try {
3571
+ const result = await runClearCommand(
3572
+ '/clear', this.#harness, this.#state, key, {
3573
+ signal: this.#signal,
3574
+ pendingInteraction: this.#hasPendingInteraction(key),
3575
+ busy: this.#queues.has(key),
3576
+ },
3577
+ );
3578
+ await this.#send(chatId, result?.message || t('清空当前会话上下文失败,当前会话未修改,请稍后重试。'), { replyTo });
3579
+ } catch (error) {
3580
+ await this.#sendFailure(chatId, error, { logLabel: 'clear', replyTo });
3581
+ }
3582
+ }
3583
+
3512
3584
  /**
3513
3585
  * Stop the running task in the bound session (mirrors `/stop`).
3514
3586
  */
@@ -4781,7 +4853,7 @@ export class FeishuHarnessBridge {
4781
4853
  * their lifecycle is owned by the ask path, not the mirror recovery.
4782
4854
  * lastContent keeps stepStreamCard's raw JSON string (single-encoded).
4783
4855
  */
4784
- async #persistMirrorState(card, liveBlocks, status) {
4856
+ async #persistMirrorState(card, liveBlocks, status, stepCardPanels) {
4785
4857
  const sessionId = card.sessionSyncSessionId;
4786
4858
  if (!sessionId || typeof this.#state.setMirror !== 'function') return;
4787
4859
  await this.#state.setMirror(card.sessionSyncKey, {
@@ -4789,7 +4861,7 @@ export class FeishuHarnessBridge {
4789
4861
  chatId: card.chatId,
4790
4862
  cardIds: [...card.cardIds],
4791
4863
  claimedAt: Date.now(),
4792
- lastContent: stepStreamCard(liveBlocks, { status }),
4864
+ lastContent: stepStreamCard(liveBlocks, { status, stepCardPanels }),
4793
4865
  blocks: structuredClone(card.blocks), answerStart: card.answerStart, answerEnd: card.answerEnd,
4794
4866
  lastSeq: this.#sessionSyncTurns.get(card.sessionSyncKey)?.lastSeq ?? -1,
4795
4867
  pendingStep: this.#sessionSyncTurns.get(card.sessionSyncKey)?.pendingStep ?? null,
@@ -4797,7 +4869,7 @@ export class FeishuHarnessBridge {
4797
4869
  }
4798
4870
 
4799
4871
  /** Sync only this interaction's cards; earlier interactions remain sealed. */
4800
- async #syncStepCardChunks(card, chunks, status) {
4872
+ async #syncStepCardChunks(card, chunks, status, stepCardPanels) {
4801
4873
  const answerVersion = card.answerVersion ?? 0;
4802
4874
  const activeIds = card.cardIds.slice(card.activeCardStart);
4803
4875
  const groups = chunks.length ? [...chunks] : [[]];
@@ -4810,6 +4882,7 @@ export class FeishuHarnessBridge {
4810
4882
  // payload halfway through this render or be marked as already delivered.
4811
4883
  const contents = groups.map((blocks, index) => stepStreamCard(blocks, {
4812
4884
  status: index === groups.length - 1 ? status : 'sealed',
4885
+ stepCardPanels,
4813
4886
  }));
4814
4887
  for (let index = 0; index < contents.length; index += 1) {
4815
4888
  let id = activeIds[index];
@@ -4834,12 +4907,13 @@ export class FeishuHarnessBridge {
4834
4907
 
4835
4908
  async #renderStepCardNow(chatId, card) {
4836
4909
  if (card.broken) return;
4837
- const chunks = splitStepStreamCardBlocks(card.blocks);
4910
+ const stepCardPanels = this.#stepCardPanels;
4911
+ const chunks = splitStepStreamCardBlocks(card.blocks, undefined, undefined, stepCardPanels);
4838
4912
  if (!chunks.length && card.messageId === null) return;
4839
4913
  const live = chunks[chunks.length - 1] ?? [];
4840
4914
  try {
4841
- await this.#syncStepCardChunks(card, chunks, 'running');
4842
- await this.#persistMirrorState(card, live, 'running');
4915
+ await this.#syncStepCardChunks(card, chunks, 'running', stepCardPanels);
4916
+ await this.#persistMirrorState(card, live, 'running', stepCardPanels);
4843
4917
  card.lastRenderAt = this.#stepPushClock.now();
4844
4918
  } catch (error) {
4845
4919
  card.broken = true;
@@ -4879,9 +4953,10 @@ export class FeishuHarnessBridge {
4879
4953
  if (body.trim()) {
4880
4954
  this.#writeStepCardAnswer(card, body);
4881
4955
  }
4882
- const chunks = splitStepStreamCardBlocks(card.blocks);
4956
+ const stepCardPanels = this.#stepCardPanels;
4957
+ const chunks = splitStepStreamCardBlocks(card.blocks, undefined, undefined, stepCardPanels);
4883
4958
  try {
4884
- await this.#syncStepCardChunks(card, chunks, status);
4959
+ await this.#syncStepCardChunks(card, chunks, status, stepCardPanels);
4885
4960
  return { ok: true, cardIds: card.cardIds };
4886
4961
  } catch (error) {
4887
4962
  // Only trust the streamed draft when the LAST SUCCESSFUL render actually
@@ -4932,8 +5007,9 @@ export class FeishuHarnessBridge {
4932
5007
  if (card.broken) return;
4933
5008
  if (card.messageId === null) return;
4934
5009
  try {
4935
- const chunks = splitStepStreamCardBlocks(card.blocks);
4936
- await this.#syncStepCardChunks(card, chunks, 'sealed');
5010
+ const stepCardPanels = this.#stepCardPanels;
5011
+ const chunks = splitStepStreamCardBlocks(card.blocks, undefined, undefined, stepCardPanels);
5012
+ await this.#syncStepCardChunks(card, chunks, 'sealed', stepCardPanels);
4937
5013
  } catch (error) {
4938
5014
  this.#logger.warn?.(
4939
5015
  '[dsh-feishu] step streaming card rotate seal failed:',
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { posix, win32 } from 'node:path';
18
18
  import { t } from '../shared/i18n.mjs';
19
+ import { normalizeFeishuStepCardPanels } from './step-push-mode.mjs';
19
20
 
20
21
  export const MENU_PAGE_SIZE = 10;
21
22
 
@@ -353,6 +354,7 @@ export function menuCard(ctx) {
353
354
  // ── 任务控制(对运行中任务的操作)────────────────────────
354
355
  elements.push({ tag: 'div', text: markdown(t('**任务控制**')) });
355
356
  elements.push(buttonPair(t('⏹ 停止'), 'stop', t('📐 压缩'), 'compact'));
357
+ elements.push(button(t('🧹 清空上下文'), 'clear'));
356
358
  elements.push({ tag: 'hr' });
357
359
 
358
360
  // ── 补充指令 + 归档切换(并列)────────────────────────────
@@ -573,6 +575,7 @@ export function menuHelpText() {
573
575
  '/status 连接状态',
574
576
  '/version 查看插件版本',
575
577
  '/compact 压缩当前会话上下文',
578
+ '/clear 清空当前会话上下文(保留 Session 绑定和历史记录)',
576
579
  '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
577
580
  '/archived on/off 会话列表显示/隐藏归档',
578
581
  '',
@@ -620,10 +623,11 @@ const HELP_CARD_FEATURES = [
620
623
  '6. 📋 会话/关注 — 查看/绑定会话,管理关注',
621
624
  '7. ⏹ 停止 — 停止当前任务',
622
625
  '8. 📐 压缩 — 压缩当前会话上下文',
623
- '9. 补充指令 — 给 Agent 发送指令',
624
- '10. 🗄 归档切换 — 显示/隐藏归档会话',
625
- '11. 📊 状态 — 查看系统连接状态',
626
- '12. 📖 帮助 — 查看本帮助',
626
+ '9. 🧹 清空上下文 — 清空上下文并保留绑定和历史记录',
627
+ '10. 补充指令 — 给 Agent 发送指令',
628
+ '11. 🗄 归档切换 — 显示/隐藏归档会话',
629
+ '12. 📊 状态 — 查看系统连接状态',
630
+ '13. 📖 帮助 — 查看本帮助',
627
631
  ].join('\n');
628
632
 
629
633
  const HELP_TEXT_COMMANDS = [
@@ -638,6 +642,7 @@ const HELP_TEXT_COMMANDS = [
638
642
  '`/workspacelist` — 列出工作区',
639
643
  '`/status` — 查看连接状态',
640
644
  '`/compact` — 压缩上下文',
645
+ '`/clear` — 清空上下文并保留 Session 绑定和历史记录',
641
646
  '`/stop` — 停止当前任务',
642
647
  '`/steer 指令` — 补充指令',
643
648
  '`/watch ID` — 关注会话',
@@ -996,15 +1001,16 @@ export const STEP_STREAM_CARD_MAX_BYTES = 24_000;
996
1001
  * Build one streaming step card from the accumulated process blocks:
997
1002
  * { kind: 'message', text } — interim note / warning / context line
998
1003
  * { kind: 'tools', lines: string[] } — tool-call summary panel
999
- * `status`: 'running' keeps panels expanded and ends with an italic status
1004
+ * `status`: 'running' uses the configured panel expansion and ends with an italic status
1000
1005
  * line; 'completed' / 'stopped' collapse the panels and swap the status text;
1001
1006
  * 'sealed' is an overflow spill chunk with no status line at all. Finished
1002
1007
  * turns (and sealed spill chunks) merge every tool/thinking panel into one
1003
1008
  * collapsed "process details" panel so the sealed card stays compact.
1004
1009
  */
1005
1010
 
1006
- export function stepStreamCard(rawBlocks, { status = 'running' } = {}) {
1011
+ export function stepStreamCard(rawBlocks, { status = 'running', stepCardPanels } = {}) {
1007
1012
  const running = status === 'running';
1013
+ const panels = normalizeFeishuStepCardPanels(stepCardPanels);
1008
1014
  const elements = [];
1009
1015
  const panelElements = [];
1010
1016
  for (const block of Array.isArray(rawBlocks) ? rawBlocks : []) {
@@ -1017,10 +1023,8 @@ export function stepStreamCard(rawBlocks, { status = 'running' } = {}) {
1017
1023
  title: block.kind === 'tools'
1018
1024
  ? t('🛠️ 工具摘要({count})', { count })
1019
1025
  : t('💭 思考过程({count})', { count }),
1020
- // Tool summaries stay visible while the turn runs; thinking notes
1021
- // remain folded at all times. Finished turns keep both panels but
1022
- // tuck them inside one collapsed "process details" wrapper.
1023
- expanded: block.kind === 'tools' && running,
1026
+ // Finished turns keep both panels inside the collapsed details wrapper.
1027
+ expanded: running && (block.kind === 'tools' ? panels.toolsExpanded : panels.thinkingExpanded),
1024
1028
  });
1025
1029
  if (running) elements.push(panel);
1026
1030
  else panelElements.push(panel);
@@ -1261,6 +1265,7 @@ export function splitStepStreamCardBlocks(
1261
1265
  blocks,
1262
1266
  limit = STEP_STREAM_CARD_MAX_BYTES,
1263
1267
  maxTables = STEP_STREAM_CARD_MAX_TABLES,
1268
+ stepCardPanels,
1264
1269
  ) {
1265
1270
  // A single block can carry more tables than a card accepts — one short answer
1266
1271
  // with six small tables — and splitting only between blocks cannot help. Each
@@ -1273,7 +1278,7 @@ export function splitStepStreamCardBlocks(
1273
1278
  const chunks = [];
1274
1279
  let current = [];
1275
1280
  for (const block of list) {
1276
- const tooLarge = Buffer.byteLength(stepStreamCard([...current, block]), 'utf8') > limit;
1281
+ const tooLarge = Buffer.byteLength(stepStreamCard([...current, block], { stepCardPanels }), 'utf8') > limit;
1277
1282
  const tooManyTables = stepStreamCardTableCount([...current, block]) > maxTables;
1278
1283
  if (current.length > 0 && (tooLarge || tooManyTables)) {
1279
1284
  chunks.push(current);
@@ -5,7 +5,7 @@ import { FeishuHarnessBridge } from './bridge.mjs';
5
5
  import { cardActionProbeCard } from './feishu-cards.mjs';
6
6
  import { VerifiedFeishuChannel, waitForFeishuOperation } from './feishu-channel.mjs';
7
7
  import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
8
- import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
8
+ import { normalizeFeishuStepPushMode, normalizeFeishuStepCardPanels } from './step-push-mode.mjs';
9
9
  import { createVoice } from './voice.mjs';
10
10
  import {
11
11
  syncSlashCommands,
@@ -119,6 +119,7 @@ export class FeishuRuntime {
119
119
  #mentionTopicReply;
120
120
  #stepPush;
121
121
  #stepPushMode;
122
+ #stepCardPanels;
122
123
  #voice = null;
123
124
  #sessionSyncTargetsFor;
124
125
  #ownerOpenIds;
@@ -171,6 +172,7 @@ export class FeishuRuntime {
171
172
  mentionTopicReply = true,
172
173
  stepPush = false,
173
174
  stepPushMode = 'post',
175
+ stepCardPanels,
174
176
  voice = null,
175
177
  sessionSyncTargetsFor = null,
176
178
  ownerOpenId,
@@ -214,6 +216,7 @@ export class FeishuRuntime {
214
216
  this.#mentionTopicReply = mentionTopicReply !== false;
215
217
  this.#stepPush = stepPush === true;
216
218
  this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
219
+ this.#stepCardPanels = normalizeFeishuStepCardPanels(stepCardPanels);
217
220
  // voice 为 { config, secret } 来源;凭据缺失时 createVoice 返回禁用对象,
218
221
  // 桥接层完全跳过语音路径,不影响连接。
219
222
  this.#voice = voice == null
@@ -262,6 +265,11 @@ export class FeishuRuntime {
262
265
  this.#bridge?.setStepPushMode(this.#stepPushMode);
263
266
  }
264
267
 
268
+ setStepCardPanels(value) {
269
+ this.#stepCardPanels = normalizeFeishuStepCardPanels(value);
270
+ this.#bridge?.setStepCardPanels(this.#stepCardPanels);
271
+ }
272
+
265
273
  setVoice(source) {
266
274
  this.#voice = source == null
267
275
  ? null
@@ -371,6 +379,7 @@ export class FeishuRuntime {
371
379
  mentionTopicReply: this.#mentionTopicReply,
372
380
  stepPush: this.#stepPush,
373
381
  stepPushMode: this.#stepPushMode,
382
+ stepCardPanels: this.#stepCardPanels,
374
383
  voice: this.#voice,
375
384
  sessionSyncTargetsFor: this.#sessionSyncTargetsFor,
376
385
  repair: this.#repair,
@@ -21,6 +21,8 @@ import {
21
21
  DEFAULT_FEISHU_STEP_PUSH_MODE,
22
22
  isFeishuStepPushMode,
23
23
  normalizeFeishuStepPushMode,
24
+ isFeishuStepCardPanels,
25
+ normalizeFeishuStepCardPanels,
24
26
  } from './step-push-mode.mjs';
25
27
  import { normalizeFeishuVoiceConfig } from './voice-config.mjs';
26
28
  import {
@@ -106,6 +108,7 @@ function configuredBotFingerprint(config) {
106
108
  mentionTopicReply: config.mentionTopicReply !== false,
107
109
  stepPush: config.stepPush === true,
108
110
  stepPushMode: normalizeFeishuStepPushMode(config.stepPushMode),
111
+ stepCardPanels: normalizeFeishuStepCardPanels(config.stepCardPanels),
109
112
  voice: config.voice,
110
113
  slashPanel: normalizeSlashPanelConfig(config.slashPanel),
111
114
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
@@ -816,6 +819,20 @@ export class MultiBotDshFeishuController {
816
819
  }));
817
820
  }
818
821
 
822
+ async updateStepCardPanels(botId, stepCardPanels) {
823
+ this.#assertOpen();
824
+ if (!isFeishuStepCardPanels(stepCardPanels)) {
825
+ throw new TypeError('Invalid Feishu step card panels');
826
+ }
827
+ return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
828
+ const config = this.#requireBot(botId);
829
+ const saved = await this.#configStore.saveBot({ ...config, stepCardPanels });
830
+ this.#runtimes.get(botId)?.setStepCardPanels?.(saved.stepCardPanels);
831
+ this.#touch();
832
+ return this.status(botId);
833
+ }));
834
+ }
835
+
819
836
  async updateVoice(botId, voice) {
820
837
  this.#assertOpen();
821
838
  if (voice !== null && (typeof voice !== 'object' || Array.isArray(voice))) {
@@ -901,6 +918,7 @@ export class MultiBotDshFeishuController {
901
918
  mentionTopicReply: config.mentionTopicReply !== false,
902
919
  stepPush: config.stepPush === true,
903
920
  stepPushMode: normalizeFeishuStepPushMode(config.stepPushMode),
921
+ stepCardPanels: normalizeFeishuStepCardPanels(config.stepCardPanels),
904
922
  voice: normalizeFeishuVoiceConfig(config.voice),
905
923
  slashPanel: normalizeSlashPanelConfig(config.slashPanel),
906
924
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
3
  import { dirname } from 'node:path';
4
4
  import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
5
- import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
5
+ import { normalizeFeishuStepPushMode, normalizeFeishuStepCardPanels } from './step-push-mode.mjs';
6
6
  import { normalizeFeishuVoiceConfig } from './voice-config.mjs';
7
7
  import { normalizeSlashPanelConfig } from './slash-command-panel.mjs';
8
8
 
@@ -52,6 +52,7 @@ function normalizeBot(value, { legacy = false } = {}) {
52
52
  mentionTopicReply: value.mentionTopicReply !== false,
53
53
  stepPush: value.stepPush === true,
54
54
  stepPushMode: normalizeFeishuStepPushMode(value.stepPushMode),
55
+ stepCardPanels: normalizeFeishuStepCardPanels(value.stepCardPanels),
55
56
  voice: normalizeFeishuVoiceConfig(value.voice),
56
57
  slashPanel: normalizeSlashPanelConfig(value.slashPanel),
57
58
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
@@ -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' },
@@ -7,6 +7,27 @@ export const FEISHU_STEP_PUSH_MODES = Object.freeze({
7
7
  /** New connections explicitly opt into the process-card presentation. */
8
8
  export const DEFAULT_FEISHU_STEP_PUSH_MODE = FEISHU_STEP_PUSH_MODES.STREAMING_CARD;
9
9
 
10
+ export const DEFAULT_FEISHU_STEP_CARD_PANELS = Object.freeze({
11
+ thinkingExpanded: false,
12
+ toolsExpanded: true,
13
+ });
14
+
15
+ export function normalizeFeishuStepCardPanels(value) {
16
+ return Object.freeze({
17
+ thinkingExpanded: typeof value?.thinkingExpanded === 'boolean'
18
+ ? value.thinkingExpanded : DEFAULT_FEISHU_STEP_CARD_PANELS.thinkingExpanded,
19
+ toolsExpanded: typeof value?.toolsExpanded === 'boolean'
20
+ ? value.toolsExpanded : DEFAULT_FEISHU_STEP_CARD_PANELS.toolsExpanded,
21
+ });
22
+ }
23
+
24
+ export function isFeishuStepCardPanels(value) {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
26
+ && Object.keys(value).every((key) => key === 'thinkingExpanded' || key === 'toolsExpanded')
27
+ && typeof value.thinkingExpanded === 'boolean'
28
+ && typeof value.toolsExpanded === 'boolean';
29
+ }
30
+
10
31
  export function normalizeFeishuStepPushMode(value) {
11
32
  // Bots created before modes existed used posts when step push was enabled.
12
33
  if (value === FEISHU_STEP_PUSH_MODES.STREAMING_CARD) {
@@ -97,6 +97,7 @@ export class MatrixCryptoEngine {
97
97
  #seenIndex = new Map();
98
98
  #pendingEvents = new Map();
99
99
  #requestState = new Map();
100
+ #pendingTasks = new Set();
100
101
  #lastKeyMaintenanceAt = 0;
101
102
  #stats = {
102
103
  undecryptable: 0,
@@ -239,6 +240,12 @@ export class MatrixCryptoEngine {
239
240
  }
240
241
 
241
242
  async stop() {
243
+ this.#ready = false;
244
+ // A key request can enqueue persistence after shutdown starts. Drain both
245
+ // before releasing sessions or allowing the caller to remove the store.
246
+ while (this.#pendingTasks.size > 0) {
247
+ await Promise.allSettled([...this.#pendingTasks]);
248
+ }
242
249
  for (const entry of this.#inbound.values()) entry.session.free?.();
243
250
  for (const entry of this.#outbound.values()) entry.session.free?.();
244
251
  this.#inbound.clear();
@@ -247,7 +254,12 @@ export class MatrixCryptoEngine {
247
254
  this.#memberCache.clear();
248
255
  this.#pendingEvents.clear();
249
256
  this.#seenIndex.clear();
250
- this.#ready = false;
257
+ }
258
+
259
+ #trackTask(task) {
260
+ const tracked = task.finally(() => this.#pendingTasks.delete(tracked));
261
+ this.#pendingTasks.add(tracked);
262
+ return tracked;
251
263
  }
252
264
 
253
265
  async maintain() {
@@ -717,7 +729,9 @@ export class MatrixCryptoEngine {
717
729
  if (!entry) {
718
730
  this.#stats.undecryptable += 1;
719
731
  this.#pendingEvents.set(`${key}|${event?.event_id ?? 'x'}`, { roomId, event, at: this.#now() });
720
- void this.#requestRoomKey(roomId, event);
732
+ void this.#trackTask(this.#requestRoomKey(roomId, event).catch((error) => {
733
+ this.#logger.warn?.('[dsh-im:matrix] a room key request failed:', error?.message ?? error);
734
+ }));
721
735
  return null;
722
736
  }
723
737
  const seen = this.#seenIndexes(key);
@@ -908,7 +922,7 @@ export class MatrixCryptoEngine {
908
922
  } catch {
909
923
  return Promise.resolve();
910
924
  }
911
- return this.#store.apply({ groupOutbound }).catch(() => undefined);
925
+ return this.#trackTask(this.#store.apply({ groupOutbound }).catch(() => undefined));
912
926
  }
913
927
 
914
928
  #persistInbound(key) {
@@ -936,7 +950,7 @@ export class MatrixCryptoEngine {
936
950
  lastUsedAt: entry.lastUsedAt,
937
951
  },
938
952
  ].slice(-900);
939
- return this.#store.apply({ groupInbound }).catch(() => undefined);
953
+ return this.#trackTask(this.#store.apply({ groupInbound }).catch(() => undefined));
940
954
  }
941
955
 
942
956
  #persistRequestState() {
@@ -944,7 +958,7 @@ export class MatrixCryptoEngine {
944
958
  if (!snapshot) return Promise.resolve();
945
959
  const requestState = {};
946
960
  for (const [key, state] of [...this.#requestState].slice(-200)) requestState[key] = { ...state };
947
- return this.#store.apply({ requestState }).catch(() => undefined);
961
+ return this.#trackTask(this.#store.apply({ requestState }).catch(() => undefined));
948
962
  }
949
963
 
950
964
  // ---- key registration ---------------------------------------------------