@xmanrui/dsh-im 4.15.0 → 4.16.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 (62) hide show
  1. package/README.en.md +2 -1
  2. package/README.md +9 -5
  3. package/lib/client.js +1729 -614
  4. package/lib/index.js +303 -293
  5. package/package.json +6 -2
  6. package/plugin-src/client/access-policy-settings.js +6 -0
  7. package/plugin-src/client/channels/feishu/index.js +15 -3
  8. package/plugin-src/client/channels/wecom-app/api.js +124 -0
  9. package/plugin-src/client/channels/wecom-app/index.js +612 -0
  10. package/plugin-src/client/channels/wecom-app/styles.js +25 -0
  11. package/plugin-src/client/delivery-settings.js +7 -0
  12. package/plugin-src/client/i18n.js +63 -0
  13. package/plugin-src/client/index.js +18 -0
  14. package/plugin-src/client/model-setting.js +135 -69
  15. package/plugin-src/client/styles.js +22 -7
  16. package/plugin-src/host/channels/dingtalk/index.mjs +8 -15
  17. package/plugin-src/host/channels/discord/index.mjs +8 -10
  18. package/plugin-src/host/channels/feishu/index.mjs +8 -15
  19. package/plugin-src/host/channels/office/index.mjs +9 -5
  20. package/plugin-src/host/channels/qq/index.mjs +8 -15
  21. package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
  22. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +1 -2
  23. package/plugin-src/host/channels/shared/startup-error.mjs +53 -0
  24. package/plugin-src/host/channels/shared/startup.mjs +47 -0
  25. package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
  26. package/plugin-src/host/channels/slack/index.mjs +8 -10
  27. package/plugin-src/host/channels/telegram/index.mjs +8 -10
  28. package/plugin-src/host/channels/wecom/index.mjs +8 -15
  29. package/plugin-src/host/channels/wecom/rpc.mjs +6 -1
  30. package/plugin-src/host/channels/wecom-app/index.mjs +33 -0
  31. package/plugin-src/host/channels/wecom-app/production.mjs +217 -0
  32. package/plugin-src/host/channels/wecom-app/rpc.mjs +225 -0
  33. package/plugin-src/host/channels/weixin/index.mjs +8 -15
  34. package/plugin-src/host/channels/whatsapp/index.mjs +8 -15
  35. package/plugin-src/host/delivery-adapter.mjs +4 -0
  36. package/plugin-src/host/delivery-suggestions.mjs +4 -0
  37. package/plugin-src/host/index.mjs +7 -2
  38. package/scripts/verify-model-setting.mjs +54 -0
  39. package/src/channels/feishu/bridge.mjs +295 -9
  40. package/src/channels/feishu/feishu-cards.mjs +38 -0
  41. package/src/channels/feishu/feishu-channel.mjs +88 -20
  42. package/src/channels/shared/bot-workspace-store.mjs +11 -3
  43. package/src/channels/shared/command-catalog.mjs +102 -0
  44. package/src/channels/shared/i18n-en/feishu.mjs +12 -0
  45. package/src/channels/shared/i18n-en/shared-a.mjs +3 -0
  46. package/src/channels/shared/i18n-en/shared-c.mjs +9 -0
  47. package/src/channels/shared/i18n-en/wecom-app.mjs +33 -0
  48. package/src/channels/shared/i18n-en.mjs +2 -0
  49. package/src/channels/shared/model-setting.mjs +43 -8
  50. package/src/channels/shared/text-harness-bridge.mjs +2 -26
  51. package/src/channels/telegram/telegram-api.mjs +18 -6
  52. package/src/channels/telegram/telegram-runtime.mjs +34 -32
  53. package/src/channels/wecom/send-error.mjs +31 -0
  54. package/src/channels/wecom/wecom-bridge.mjs +59 -17
  55. package/src/channels/wecom-app/callback-server.mjs +471 -0
  56. package/src/channels/wecom-app/config-store.mjs +240 -0
  57. package/src/channels/wecom-app/harness-client.mjs +11 -0
  58. package/src/channels/wecom-app/state-store.mjs +107 -0
  59. package/src/channels/wecom-app/wecom-app-api.mjs +432 -0
  60. package/src/channels/wecom-app/wecom-app-bridge.mjs +1059 -0
  61. package/src/channels/wecom-app/wecom-app-controller.mjs +440 -0
  62. package/src/channels/wecom-app/wecom-app-runtime.mjs +221 -0
@@ -87,6 +87,7 @@ import {
87
87
  menuHelpText,
88
88
  modelCard,
89
89
  presetCard,
90
+ answeredQuestionCard,
90
91
  questionCard,
91
92
  sessionListCard,
92
93
  statusCard,
@@ -166,6 +167,15 @@ const STEP_PUSH_MAX_MESSAGES_PER_TURN = 200;
166
167
  const STEP_PUSH_SUMMARY_MAX_CHARS = 120;
167
168
  const STEP_PUSH_ARGUMENTS_MAX_CHARS = 400;
168
169
  const STEP_PUSH_ERROR_MAX_CHARS = 200;
170
+ /** Silence threshold before the thinking status heartbeat appears. */
171
+ const STEP_PUSH_HEARTBEAT_AFTER_MS = 20_000;
172
+ /** The thinking heartbeat refreshes its elapsed time at this cadence. */
173
+ const STEP_PUSH_HEARTBEAT_REFRESH_MS = 30_000;
174
+ /** One turn never refreshes the thinking heartbeat more than this. */
175
+ const STEP_PUSH_HEARTBEAT_MAX_UPDATES = 30;
176
+ /** One pending heartbeat id is retried at most this many times across
177
+ * recall boundaries before it is abandoned (with a warning). */
178
+ const STEP_PUSH_HEARTBEAT_RECALL_ATTEMPTS = 3;
169
179
  /** One post message carries at most this many UTF-8 bytes after its rich-text
170
180
  * content has been JSON encoded (Feishu caps rich-text requests at 30KB). */
171
181
  const STEP_PUSH_POST_CHUNK_MAX_BYTES = 24_000;
@@ -529,6 +539,9 @@ export class FeishuHarnessBridge {
529
539
  #pendingInteractions = new Map();
530
540
  #interactionKeys = new Map();
531
541
  #resolvedQuestionReplies = new Map();
542
+ // issue #162:已提交答案的 interactionId → 过期时间(TTL-only,惰性清理,
543
+ // 与 #resolvedQuestionReplies 同风格)。陈旧点击据此给出「已回答」提示。
544
+ #answeredInteractionIds = new Map();
532
545
  // Keep the accepted configuration through the existing queue/reply lifecycle.
533
546
  #acceptedMessageIds = new Map();
534
547
  #interactionTasks = new Set();
@@ -2192,7 +2205,35 @@ export class FeishuHarnessBridge {
2192
2205
  if (pending && pending.kind === 'question' && !pending.submitting
2193
2206
  && pending.actor === actor
2194
2207
  && Number(indexText) === pending.index) {
2195
- await this.#submitQuestionAnswer(pending, optionLabel, { chatId });
2208
+ await this.#submitQuestionAnswer(pending, optionLabel, { chatId, questionMessageId: messageId });
2209
+ } else {
2210
+ // issue #162:pending 已清除时区分「已答过」与「其他客户端处理」,
2211
+ // 避免对同一张提问卡的重复点击弹出误导提示。
2212
+ const staleNotice = pending
2213
+ ? INTERACTION_RESOLVED_TEXT()
2214
+ : this.#isAnsweredInteraction(interactionId)
2215
+ ? t('这个问题已经回答过了。')
2216
+ : INTERACTION_RESOLVED_TEXT();
2217
+ await reply(staleNotice).catch(() => undefined);
2218
+ }
2219
+ }
2220
+ return;
2221
+ }
2222
+ // issue #162:自定义答案入口——与 answer: 同构校验;通过后引导用户直接
2223
+ // 发送文字消息(canClaimInteractionReply 语义:同 actor 的文本即答案)。
2224
+ if (action.startsWith('answerCustom:')) {
2225
+ const rest = action.slice('answerCustom:'.length);
2226
+ const sep = rest.indexOf(':');
2227
+ if (sep !== -1) {
2228
+ const interactionId = rest.slice(0, sep);
2229
+ const indexText = rest.slice(sep + 1);
2230
+ const qKey = this.#interactionKeys.get(interactionId);
2231
+ const pending = qKey ? this.#pendingInteractions.get(qKey) : null;
2232
+ if (pending && pending.kind === 'question' && !pending.submitting
2233
+ && pending.actor === actor && Number(indexText) === pending.index) {
2234
+ await reply(t('想自定义答案?直接发送文字消息即可,将作为本题答案提交。')).catch(() => undefined);
2235
+ } else if (!pending && this.#isAnsweredInteraction(interactionId)) {
2236
+ await reply(t('这个问题已经回答过了。')).catch(() => undefined);
2196
2237
  } else {
2197
2238
  await reply(INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
2198
2239
  }
@@ -2605,6 +2646,27 @@ export class FeishuHarnessBridge {
2605
2646
  }
2606
2647
  }
2607
2648
 
2649
+ /**
2650
+ * issue #162:已答状态卡的原地替换。patch 失败仅记录警告并明确降级——
2651
+ * 回执缺失不应影响答案提交,也不得像 #sendCard 那样回退成发送新卡。
2652
+ */
2653
+ async #patchCardMessage(chatId, messageId, cardJson) {
2654
+ if (!messageId) return null;
2655
+ try {
2656
+ const response = await this.#client.im.v1.message.patch({
2657
+ path: { message_id: messageId },
2658
+ data: { content: cardJson },
2659
+ });
2660
+ if (response?.code && response.code !== 0) {
2661
+ throw new Error(`Feishu card update failed: ${response.msg || response.code}`);
2662
+ }
2663
+ return messageId;
2664
+ } catch (error) {
2665
+ this.#logger.warn?.('[dsh-feishu] answered-state card patch failed:', error?.message ?? error);
2666
+ return null;
2667
+ }
2668
+ }
2669
+
2608
2670
  async #sendCard(chatId, cardJson, options = {}) {
2609
2671
  const updateMessageId = nonEmptyString(options.updateMessageId);
2610
2672
  const replyTo = nonEmptyString(options.replyTo);
@@ -3771,6 +3833,10 @@ export class FeishuHarnessBridge {
3771
3833
  const state = this.#stepPushSendState.get(key)
3772
3834
  ?? { lastSentAt: 0, count: 0, breakerLogged: false };
3773
3835
  this.#stepPushSendState.set(key, state);
3836
+ // 真实事件到达:先撤回思考中心跳再推真实消息。
3837
+ // pendingRecallIds 是唯一撤回清单(live 心跳创建时即入队),避免同一 id 双撤回;
3838
+ // 撤回失败的 id 留在队列里待下个边界重试,不能在这里无条件清空。
3839
+ await this.#recallPendingHeartbeats(state);
3774
3840
  if (billable && state.count >= STEP_PUSH_MAX_MESSAGES_PER_TURN) {
3775
3841
  if (!state.breakerLogged) {
3776
3842
  state.breakerLogged = true;
@@ -3778,6 +3844,11 @@ export class FeishuHarnessBridge {
3778
3844
  `[dsh-feishu] step push hit ${STEP_PUSH_MAX_MESSAGES_PER_TURN} messages for this turn; staying silent until it ends`,
3779
3845
  );
3780
3846
  }
3847
+ // 熔断静默期内同样刷新 silenceSince:用户仍在活动,静默窗口重新计时,
3848
+ // 避免看门狗在每个被拦截的事件后重建/撤回心跳。activitySeq 单调递增,
3849
+ // 供心跳创建返回后识别「创建在途期间出现了真实活动」(同毫秒事件也能识别)。
3850
+ state.silenceSince = this.#stepPushClock.now();
3851
+ state.activitySeq = (state.activitySeq ?? 0) + 1;
3781
3852
  return;
3782
3853
  }
3783
3854
  this.#signal?.throwIfAborted();
@@ -3787,6 +3858,8 @@ export class FeishuHarnessBridge {
3787
3858
  // message land after the teardown has begun.
3788
3859
  this.#signal?.throwIfAborted();
3789
3860
  state.lastSentAt = this.#stepPushClock.now();
3861
+ state.silenceSince = state.lastSentAt;
3862
+ state.activitySeq = (state.activitySeq ?? 0) + 1;
3790
3863
  if (billable) state.count += 1;
3791
3864
  this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
3792
3865
  await this.#sendStepPost(chatId, replyToMessageId, paragraphs, fallbackText);
@@ -3889,6 +3962,154 @@ export class FeishuHarnessBridge {
3889
3962
  || /230020|230006/.test(error?.message ?? '');
3890
3963
  }
3891
3964
 
3965
+ #formatElapsed(ms) {
3966
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
3967
+ return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
3968
+ }
3969
+
3970
+ /**
3971
+ * 撤回待撤回心跳清单:撤回成功(recallMessage 返回 true)的 id 移出队列;
3972
+ * 失败的保留,待下个真实事件/回合结束边界重试,单条最多尝试
3973
+ * STEP_PUSH_HEARTBEAT_RECALL_ATTEMPTS 次后放弃并告警(消息可能已被手动
3974
+ * 删除或接口持续受限,避免队列无限增长)。live 心跳撤回失败且仍在队列时
3975
+ * 保留 heartbeatMessageId:消息仍在会话里,看门狗继续原地刷新同一条,
3976
+ * 而不是另建一条造成重复。
3977
+ */
3978
+ async #recallPendingHeartbeats(state) {
3979
+ const remaining = [];
3980
+ for (const entry of state.pendingRecallIds ?? []) {
3981
+ let recalled = false;
3982
+ try {
3983
+ recalled = (await this.#channel?.recallMessage?.(entry.id)) === true;
3984
+ } catch (error) {
3985
+ this.#logger.warn?.('[dsh-feishu] heartbeat recall failed:', error?.message ?? String(error));
3986
+ }
3987
+ if (recalled) continue;
3988
+ const attempts = (entry.attempts ?? 0) + 1;
3989
+ if (attempts >= STEP_PUSH_HEARTBEAT_RECALL_ATTEMPTS) {
3990
+ this.#logger.warn?.(`[dsh-feishu] heartbeat recall gave up after ${attempts} attempts: ${entry.id}`);
3991
+ continue;
3992
+ }
3993
+ remaining.push({ id: entry.id, attempts });
3994
+ }
3995
+ state.pendingRecallIds = remaining;
3996
+ const liveId = state.heartbeatMessageId;
3997
+ if (liveId && !remaining.some((entry) => entry.id === liveId)) state.heartbeatMessageId = null;
3998
+ }
3999
+
4000
+ /**
4001
+ * 撤回一条刚创建、但创建返回时已过时(回合已结束或创建在途期间出现了
4002
+ * 真实活动)的心跳:成功即丢弃;失败则按失败保留策略入队,待下个边界重试。
4003
+ */
4004
+ async #recallLateHeartbeat(state, messageId) {
4005
+ let recalled = false;
4006
+ try {
4007
+ recalled = (await this.#channel?.recallMessage?.(messageId)) === true;
4008
+ } catch (error) {
4009
+ this.#logger.warn?.('[dsh-feishu] late heartbeat recall failed:', error?.message ?? String(error));
4010
+ }
4011
+ if (recalled) return;
4012
+ state.pendingRecallIds = [...(state.pendingRecallIds ?? []), { id: messageId, attempts: 1 }];
4013
+ }
4014
+
4015
+ /**
4016
+ * 思考中状态看门狗:直推回合内静默满阈值时推送/原地刷新一条「⏳ 正在思考中…」
4017
+ * 心跳 post;真实事件或回合结束时撤回。轮询检查可注入 stepPushClock(测试
4018
+ * 手动推进时钟),生命周期归属当前回合(stop 后循环退出)。
4019
+ */
4020
+ #startThinkingStatusWatchdog(key, chatId, replyToMessageId) {
4021
+ const state = this.#stepPushSendState.get(key);
4022
+ if (!state) return { stop: async () => {} };
4023
+ state.heartbeatStopped = false;
4024
+ state.heartbeatMessageId = null;
4025
+ state.heartbeatNextAttemptAt = 0;
4026
+ const watchdog = { stopped: false };
4027
+ void (async () => {
4028
+ try {
4029
+ // 轮询式看门狗:每 10ms 检查一次(真实时间),静默判定读取可注入
4030
+ // 时钟——生产随真实时间自然触发;测试手动推进时钟即可确定性验证。
4031
+ while (!watchdog.stopped && !this.#signal?.aborted && !state.heartbeatStopped) {
4032
+ await new Promise((resolve) => setTimeout(resolve, 10));
4033
+ if (watchdog.stopped || this.#signal?.aborted || state.heartbeatStopped) return;
4034
+ const now = this.#stepPushClock.now();
4035
+ // 静默基线 = silenceSince(回合开始播种,每次真实推送刷新)。
4036
+ const silentFor = now - (state.silenceSince ?? state.turnStartedAt ?? now);
4037
+ if (state.heartbeatMessageId) {
4038
+ if (now - (state.heartbeatUpdatedAt ?? 0) < STEP_PUSH_HEARTBEAT_REFRESH_MS) continue;
4039
+ const elapsed = this.#formatElapsed(now - (state.heartbeatShownAt ?? state.heartbeatUpdatedAt));
4040
+ state.heartbeatUpdatedAt = this.#stepPushClock.now();
4041
+ state.heartbeatUpdates = (state.heartbeatUpdates ?? 0) + 1;
4042
+ if (state.heartbeatUpdates > STEP_PUSH_HEARTBEAT_MAX_UPDATES) {
4043
+ state.heartbeatStopped = true;
4044
+ continue;
4045
+ }
4046
+ try {
4047
+ const response = await this.#client.im.v1.message.update({
4048
+ path: { message_id: state.heartbeatMessageId },
4049
+ data: {
4050
+ msg_type: 'post',
4051
+ content: JSON.stringify({
4052
+ zh_cn: { content: [[{ tag: 'text', text: t('⏳ 正在思考中…(已运行 {elapsed})', { elapsed }) }]] },
4053
+ }),
4054
+ },
4055
+ });
4056
+ if (response?.code && response.code !== 0) {
4057
+ this.#logger.warn?.('[dsh-feishu] heartbeat update failed:', response.msg || response.code);
4058
+ }
4059
+ } catch (error) {
4060
+ // 更新失败:保留心跳(消息仍在会话中),留待真实事件/回合结束撤回。
4061
+ this.#logger.warn?.('[dsh-feishu] heartbeat update failed:', error.message);
4062
+ }
4063
+ continue;
4064
+ }
4065
+ if (now < (state.heartbeatNextAttemptAt ?? 0)) continue;
4066
+ if (silentFor < STEP_PUSH_HEARTBEAT_AFTER_MS) continue;
4067
+ // 记录发起创建时的活动序号:创建请求在途期间若出现真实活动
4068
+ // (真实推送或熔断分支都会推进 activitySeq),这条心跳返回时
4069
+ // 已过时,必须立即撤回而不是登记为当前状态。
4070
+ const activitySeqAtDispatch = state.activitySeq ?? 0;
4071
+ const shownAt = now;
4072
+ const result = await this.#sendStepPost(
4073
+ chatId, replyToMessageId,
4074
+ [[{ tag: 'text', text: t('⏳ 正在思考中…(已运行 {elapsed})', { elapsed: this.#formatElapsed(0) }) }]],
4075
+ t('⏳ 正在思考中…'),
4076
+ );
4077
+ if (watchdog.stopped || this.#signal?.aborted) {
4078
+ // 创建期间回合已结束:立即撤回刚发出的心跳,避免残留。
4079
+ if (result.ok && result.messageId) await this.#recallLateHeartbeat(state, result.messageId);
4080
+ return;
4081
+ }
4082
+ if ((state.activitySeq ?? 0) !== activitySeqAtDispatch) {
4083
+ // 创建期间出现真实活动:这条「正在思考中」会排在真实步骤之后,
4084
+ // 立即撤回且不登记(不进入原地刷新,也不作为 live 心跳)。
4085
+ if (result.ok && result.messageId) await this.#recallLateHeartbeat(state, result.messageId);
4086
+ continue;
4087
+ }
4088
+ if (result.ok) {
4089
+ state.heartbeatMessageId = result.messageId ?? null;
4090
+ state.pendingRecallIds = [...(state.pendingRecallIds ?? []), { id: result.messageId, attempts: 0 }];
4091
+ state.heartbeatShownAt = shownAt;
4092
+ state.heartbeatUpdatedAt = shownAt;
4093
+ } else {
4094
+ // 发送失败:退避后再试,避免每个轮询都重试造成堆积。
4095
+ state.heartbeatNextAttemptAt = now + 5_000;
4096
+ }
4097
+ }
4098
+ } catch (error) {
4099
+ if (!this.#signal?.aborted) {
4100
+ this.#logger.warn?.('[dsh-feishu] thinking status watchdog failed:', error.message);
4101
+ }
4102
+ }
4103
+ })();
4104
+ return {
4105
+ stop: async () => {
4106
+ watchdog.stopped = true;
4107
+ // 回合结束撤回:失败项保留在队列里(跨回合携带),待下个边界重试。
4108
+ await this.#recallPendingHeartbeats(state);
4109
+ },
4110
+ };
4111
+ }
4112
+
3892
4113
  /**
3893
4114
  * 分步直推:`#answerWithStream` 流式分支在开关开启时的完整替代路径。
3894
4115
  * 过程(工具调用、助手中间说明)与最终答案均以富文本 post 逐条直推
@@ -3929,7 +4150,15 @@ export class FeishuHarnessBridge {
3929
4150
  if (oldest !== undefined) this.#stepPushSendState.delete(oldest);
3930
4151
  }
3931
4152
  this.#stepPushSendState.set(key, {
4153
+ // 思考中状态的静默基线 = 回合开始时刻;lastSentAt 仅供真实推送刷新,
4154
+ // 不参与心跳判定(否则首个真实步骤的节流等待会被误判/误伤)。
4155
+ silenceSince: this.#stepPushClock.now(),
4156
+ turnStartedAt: this.#stepPushClock.now(),
3932
4157
  lastSentAt: priorState?.lastSentAt ?? 0,
4158
+ // 撤回失败的心跳跨回合携带:回合边界没撤掉的「正在思考中」在下个
4159
+ // 回合的真实事件/结束时继续重试,而不是被 reopen 静默丢弃。
4160
+ pendingRecallIds: priorState?.pendingRecallIds ?? [],
4161
+ activitySeq: 0,
3933
4162
  count: 0,
3934
4163
  breakerLogged: false,
3935
4164
  });
@@ -3961,7 +4190,10 @@ export class FeishuHarnessBridge {
3961
4190
  messageId,
3962
4191
  );
3963
4192
  };
3964
- const completed = await askInWorkspaceSession({
4193
+ const watchdog = this.#startThinkingStatusWatchdog(key, chatId, messageId);
4194
+ let completed;
4195
+ try {
4196
+ completed = await askInWorkspaceSession({
3965
4197
  deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
3966
4198
  harness: this.#harness,
3967
4199
  state: this.#state,
@@ -4002,6 +4234,10 @@ export class FeishuHarnessBridge {
4002
4234
  },
4003
4235
  },
4004
4236
  });
4237
+ } finally {
4238
+ // 回合结束(成功/失败/中断):停看门狗并撤回残留思考中心跳。
4239
+ await watchdog.stop();
4240
+ }
4005
4241
  markAskComplete();
4006
4242
  const finalStepText = pendingStep ? pendingStep.text : null;
4007
4243
  pendingStep = null;
@@ -4203,14 +4439,22 @@ export class FeishuHarnessBridge {
4203
4439
  // issue #86:独立交互消息(提问/审批)会落在占位卡下方,呈现前
4204
4440
  // 先换卡,让最终答案落在交互消息之后的新流式卡上。
4205
4441
  onInteraction: async (interaction) => {
4206
- if ((interaction?.kind === 'question' || interaction?.kind === 'approval')
4207
- && typeof controller?.rotate === 'function') {
4208
- await controller.rotate();
4442
+ try {
4443
+ if ((interaction?.kind === 'question' || interaction?.kind === 'approval')
4444
+ && typeof controller?.rotate === 'function') {
4445
+ await controller.rotate();
4446
+ }
4447
+ await baseAskOptions.onInteraction(interaction);
4448
+ } finally {
4449
+ // issue #163:呈现完成(或失败)即解除换卡挂起,此后建的新卡
4450
+ // 必然位于交互消息下方;interactionPresented 不存在时静默跳过。
4451
+ controller?.interactionPresented?.();
4209
4452
  }
4210
- await baseAskOptions.onInteraction(interaction);
4211
4453
  },
4212
4454
  onUpdate: async (update) => {
4213
- await controller.setContent(this.#progressText(update));
4455
+ await controller.setContent(this.#progressText(update), {
4456
+ transient: update.type !== 'text',
4457
+ });
4214
4458
  this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
4215
4459
  },
4216
4460
  };
@@ -4403,14 +4647,35 @@ export class FeishuHarnessBridge {
4403
4647
  await this.#submitQuestionAnswer(pending, text, {
4404
4648
  chatId: event.message.chat_id,
4405
4649
  messageId,
4650
+ questionMessageId: pending.questionCardMessageId,
4406
4651
  });
4407
4652
  }
4408
4653
 
4409
- async #submitQuestionAnswer(pending, answerText, { chatId, messageId } = {}) {
4654
+ async #submitQuestionAnswer(pending, answerText, { chatId, messageId, questionMessageId } = {}) {
4410
4655
  const question = pending.questions[pending.index];
4411
4656
  if (!question) return;
4412
4657
  pending.chatId = chatId ?? pending.chatId;
4413
4658
 
4659
+ // issue #162:已答状态卡以推进前的题号渲染,并整卡替换原提问卡。
4660
+ const answeredIndex = pending.index;
4661
+ const answeredCardJson = answeredQuestionCard({
4662
+ interactionId: pending.interactionId,
4663
+ // 评审补充(#162):与呈现提问卡保持一致,传入原题的文本/标题/说明,
4664
+ // 否则回执会退化为「请输入你的回答。」。
4665
+ question: question.question,
4666
+ header: question.header,
4667
+ detail: question.detail,
4668
+ options: Array.isArray(question?.options) ? question.options : [],
4669
+ chosen: answerText,
4670
+ index: answeredIndex,
4671
+ total: pending.questions.length,
4672
+ });
4673
+ const patchAnsweredCard = () => this.#patchCardMessage(
4674
+ pending.chatId,
4675
+ questionMessageId ?? pending.questionCardMessageId,
4676
+ answeredCardJson,
4677
+ );
4678
+
4414
4679
  pending.answers.push(harnessAnswerForQuestion(question, answerText));
4415
4680
  pending.index += 1;
4416
4681
  if (pending.index < pending.questions.length) {
@@ -4418,6 +4683,7 @@ export class FeishuHarnessBridge {
4418
4683
  pending.claimedReplyMessageId = null;
4419
4684
  }
4420
4685
  pending.needsPresentation = true;
4686
+ await patchAnsweredCard();
4421
4687
  try {
4422
4688
  await this.#presentInteraction(pending);
4423
4689
  } catch {
@@ -4438,6 +4704,8 @@ export class FeishuHarnessBridge {
4438
4704
  answer: { answers: pending.answers },
4439
4705
  },
4440
4706
  });
4707
+ await patchAnsweredCard();
4708
+ this.#answeredInteractionIds.set(pending.interactionId, Date.now() + RESOLVED_REPLY_TTL_MS);
4441
4709
  this.#rememberResolvedInteraction(key, pending);
4442
4710
  this.#clearPendingInteraction(key, pending.interactionId);
4443
4711
  this.#status.lastError = null;
@@ -4574,6 +4842,7 @@ export class FeishuHarnessBridge {
4574
4842
  submitting: false,
4575
4843
  needsPresentation: true,
4576
4844
  questionMessageIds: new Set(),
4845
+ questionCardMessageId: null,
4577
4846
  inactive: false,
4578
4847
  };
4579
4848
  this.#pendingInteractions.set(key, pending);
@@ -4603,6 +4872,10 @@ export class FeishuHarnessBridge {
4603
4872
  && options.length > 0
4604
4873
  && question.multiSelect !== true;
4605
4874
  let messageId;
4875
+ let presentedAsCard = false;
4876
+ // 评审补充(#162):每次呈现先重置——本题走文本路径(或卡片回退到文本)
4877
+ // 时不得沿用上一题的卡消息 id,否则后一题的回执会覆盖前一张已答卡。
4878
+ pending.questionCardMessageId = null;
4606
4879
  if (interactive) {
4607
4880
  // Single-choice question with options: render each option as a button.
4608
4881
  messageId = await this.#sendCard(
@@ -4617,7 +4890,10 @@ export class FeishuHarnessBridge {
4617
4890
  total: pending.questions.length,
4618
4891
  }),
4619
4892
  { key: pending.key, replyTo: pending.replyToMessageId },
4620
- ).catch(async () => {
4893
+ ).then((sent) => {
4894
+ presentedAsCard = true;
4895
+ return sent;
4896
+ }).catch(async () => {
4621
4897
  // Fall back to the plain-text question if the card cannot be sent.
4622
4898
  // If the text send also fails, let the error propagate so the pending
4623
4899
  // question is not marked as presented and the existing retry logic runs.
@@ -4644,11 +4920,21 @@ export class FeishuHarnessBridge {
4644
4920
  }
4645
4921
  if (messageId) {
4646
4922
  pending.questionMessageIds.add(messageId);
4923
+ // issue #162:仅卡片路径成功时记录——文本回退产生的消息 id 不参与已答回写。
4924
+ if (presentedAsCard) pending.questionCardMessageId = messageId;
4647
4925
  if (pending.inactive) this.#rememberResolvedInteraction(pending.key, pending);
4648
4926
  }
4649
4927
  pending.needsPresentation = false;
4650
4928
  }
4651
4929
 
4930
+ #isAnsweredInteraction(interactionId) {
4931
+ const now = Date.now();
4932
+ for (const [id, expiresAt] of this.#answeredInteractionIds) {
4933
+ if (expiresAt <= now) this.#answeredInteractionIds.delete(id);
4934
+ }
4935
+ return this.#answeredInteractionIds.has(interactionId);
4936
+ }
4937
+
4652
4938
  #rememberResolvedInteraction(key, pending) {
4653
4939
  const expiresAt = Date.now() + RESOLVED_REPLY_TTL_MS;
4654
4940
  for (const messageId of pending.questionMessageIds ?? []) {
@@ -933,6 +933,44 @@ export function questionCard({ interactionId, header, question, detail, options,
933
933
  // question cannot be applied to the current one: answer:<interactionId>:<index>:<label>
934
934
  elements.push(button(buttonText, `answer:${interactionId}:${index}:${label}`));
935
935
  }
936
+ // issue #162:自定义答案入口与 menuCard/steerCard 的「✏️ 更多 / 自定义…」
937
+ // 对齐——文字流本就支持 custom 答案,卡片补上引导入口。
938
+ elements.push(button(t('✏️ 其他答案…'), `answerCustom:${interactionId}:${index}`));
936
939
  }
937
940
  return cardWith(t('❓ 请补充信息{progress}', { progress }), elements);
938
941
  }
942
+
943
+ /**
944
+ * issue #162:提问卡被回答后的已答状态卡——原卡整卡替换为该回执样式:
945
+ * 保留问题与选项文本、标注已选项、不渲染任何按钮(重复点击从根源消失)。
946
+ * 与 questionCard 一样返回 JSON 字符串,调用方直接作为卡 content 使用。
947
+ */
948
+ export function answeredQuestionCard({ interactionId, header, question, detail, options, chosen, index, total }) {
949
+ const elements = [];
950
+ const progress = total > 1 ? `(${index + 1}/${total})` : '';
951
+ if (header) elements.push({ tag: 'div', text: markdown(String(header)) });
952
+ const qText = typeof question === 'string' && question.trim() ? question : t('请输入你的回答。');
953
+ elements.push({ tag: 'div', text: markdown(String(qText)) });
954
+ if (detail) elements.push({ tag: 'div', text: markdown(String(detail)) });
955
+ if (Array.isArray(options) && options.length > 0) {
956
+ elements.push({ tag: 'hr' });
957
+ let chosenShown = false;
958
+ for (const option of options) {
959
+ const label = typeof option?.label === 'string' ? option.label : '';
960
+ if (!label) continue;
961
+ if (label === chosen) chosenShown = true;
962
+ elements.push({ tag: 'div', text: markdown(label === chosen
963
+ ? t('✅ 已选择:{label}', { label })
964
+ : label) });
965
+ }
966
+ // 自定义文本答案不在预设选项里——单独一行展示所选内容。
967
+ if (!chosenShown && typeof chosen === 'string' && chosen.trim()) {
968
+ elements.push({ tag: 'div', text: markdown(t('✅ 已选择:{label}', { label: chosen })) });
969
+ }
970
+ } else if (typeof chosen === 'string' && chosen.trim()) {
971
+ elements.push({ tag: 'div', text: markdown(t('✅ 已选择:{label}', { label: chosen })) });
972
+ }
973
+ elements.push({ tag: 'hr' });
974
+ elements.push({ tag: 'div', text: markdown(t('回答已提交,对话将继续。')) });
975
+ return cardWith(t('✅ 已回答{progress}', { progress }), elements);
976
+ }
@@ -207,59 +207,120 @@ export class VerifiedFeishuChannel {
207
207
  const cards = [];
208
208
  let activeCard = null;
209
209
  let rotating = false;
210
+ let awaitingPresentation = false;
211
+ // issue #163:过程写卡与换卡定格共用一条写队列串行化——在途写必然先于
212
+ // 定格完成,消除「写飞越定格」竞态;前序失败不阻塞后续写入。
213
+ let writeQueue = Promise.resolve();
214
+ const enqueue = (job) => {
215
+ const run = writeQueue.then(job, job);
216
+ writeQueue = run.catch(() => undefined);
217
+ return run;
218
+ };
210
219
  try {
211
220
  activeCard = await this.#createStreamCard(chatId, options);
212
221
  cards.push(activeCard);
213
222
  let lastContent = this.#initialText;
223
+ let lastContentIsTransient = true;
224
+ // Track the current card separately from incoming snapshots: held or
225
+ // failed writes have not delivered any text that can be deduplicated.
226
+ let activeView = lastContent;
227
+ let activeTextPrefix = null;
214
228
  // issue #86:独立交互消息(提问/审批)落在占位卡下方后,最终答案不得
215
229
  // 回写旧卡。rotate() 把旧卡定格为「过程记录 + 指引行」并标记换卡态;
216
230
  // 下一次 setContent(过程更新或最终答案)才创建新卡——新卡必然创建于
217
231
  // 交互消息之后。旧卡纳入 cards,参与 recall 与 providerMessageIds。
232
+ // issue #163:定格后进入换卡挂起(awaitingPresentation),挂起期内
233
+ // setContent 只推进快照、不建卡不写卡;bridge 呈现交互消息后调用
234
+ // interactionPresented() 解除挂起,此后建的新卡必然位于交互消息下方。
218
235
  const ensureActiveCard = async () => {
219
236
  if (!rotating) return activeCard;
220
237
  activeCard = await this.#createStreamCard(chatId, options);
238
+ activeView = this.#initialText;
239
+ activeTextPrefix = null;
221
240
  cards.push(activeCard);
222
241
  rotating = false;
223
242
  return activeCard;
224
243
  };
244
+ // issue #163:冻结前缀去重——换卡定格时旧卡已展示 frozenContent;此后
245
+ // 新卡只展示剥离该前缀后的增量(重放快照剥为空白则跳过写卡),终稿
246
+ // 分段同样使用增量视图,提问前的过程不再重复播放。
247
+ let frozenContent = null;
248
+ const applyFrozenDedup = (next) => {
249
+ if (frozenContent === null || !next.startsWith(frozenContent)) return next;
250
+ const rest = next.slice(frozenContent.length);
251
+ return rest.trim().length > 0 ? rest : ''; // 不改写增量原文;全空白视为无增量
252
+ };
253
+ // 评审补充(#163):去重基线与定格内容必须以「旧卡实际展示的内容」为准——
254
+ // 超长快照的旧卡只展示了截断前缀,若冻结完整 lastContent,未展示的尾部
255
+ // 会被整段剥掉而丢失;再次换卡时旧卡展示的是上一轮增量,不得回写完整快照。
256
+ const shownPrefixOf = (text) => {
257
+ const notice = `\n\n${t('内容较长,生成完成后将分段发送完整回答。')}`;
258
+ return text.length <= MAX_STREAM_CHARS
259
+ ? text
260
+ : streamTextPrefix(text, MAX_STREAM_CHARS - notice.length);
261
+ };
225
262
  const controller = {
226
263
  get messageId() {
227
264
  return activeCard.messageId;
228
265
  },
229
- rotate: async () => {
266
+ interactionPresented: () => {
267
+ awaitingPresentation = false;
268
+ },
269
+ rotate: () => enqueue(async () => {
230
270
  if (rotating) return;
231
271
  rotating = true;
272
+ awaitingPresentation = true;
273
+ const shownPrefix = shownPrefixOf(activeView);
232
274
  try {
233
275
  await this.#updateStreamCard(
234
276
  activeCard,
235
- `${streamPreview(lastContent)}\n\n${t('⤵️ 最终结果见下方')}`,
277
+ `${streamPreview(shownPrefix)}\n\n${t('⤵️ 最终结果见下方')}`,
236
278
  );
279
+ // Tool/status messages remain visible but never extend the text
280
+ // prefix. Only a successfully displayed text snapshot advances it.
281
+ if (activeTextPrefix !== null) frozenContent = activeTextPrefix;
237
282
  await this.#finishStreamCard(activeCard);
238
283
  } catch (error) {
239
284
  // 明确降级:定格失败不阻塞交互呈现,旧卡保留原内容。
240
285
  console.warn('[dsh-feishu] unable to finalize the superseded stream card:', error.message);
241
286
  }
242
- },
243
- setContent: async (content) => {
287
+ }),
288
+ setContent: (content, { transient = false } = {}) => enqueue(async () => {
244
289
  const next = String(content ?? '') || '…';
245
- const card = await ensureActiveCard();
246
- await this.#updateStreamCard(card, streamPreview(next));
247
- // Updates are replaceable snapshots, including progress/tool text.
248
- // Retain the full latest snapshot even when its preview is unchanged.
290
+ // Retain the latest snapshot even if it is a replay or a held write.
249
291
  lastContent = next;
250
- },
292
+ lastContentIsTransient = transient;
293
+ const visible = transient ? next : applyFrozenDedup(next);
294
+ if (visible === '') return; // 重放快照不建卡不写卡
295
+ if (awaitingPresentation) return; // 换卡挂起:视图已推进,仅挂起卡片写入
296
+ const card = await ensureActiveCard();
297
+ await this.#updateStreamCard(card, streamPreview(visible));
298
+ activeView = visible;
299
+ const previousPrefix = frozenContent !== null && next.startsWith(frozenContent)
300
+ ? frozenContent
301
+ : '';
302
+ activeTextPrefix = transient ? null : previousPrefix + shownPrefixOf(visible);
303
+ }),
251
304
  };
252
305
 
253
306
  await input.markdown(controller);
254
- const chunks = splitStreamContent(lastContent);
255
- for (const [index, chunk] of chunks.entries()) {
256
- const card = index === 0
257
- ? await ensureActiveCard()
258
- : await this.#createStreamCard(chatId, options);
259
- if (index > 0) cards.push(card);
260
- await this.#updateStreamCard(card, chunk);
261
- await this.#finishStreamCard(card);
262
- }
307
+ await enqueue(async () => {
308
+ // 终稿强制解除挂起:异常路径下呈现通知缺失时仍可收尾。
309
+ awaitingPresentation = false;
310
+ // Recompute from the final snapshot, including an empty delta, rather
311
+ // than retaining the last nonempty progress/tool view as the answer.
312
+ const finalView = (lastContentIsTransient ? lastContent : applyFrozenDedup(lastContent))
313
+ || lastContent;
314
+ const chunks = splitStreamContent(finalView);
315
+ for (const [index, chunk] of chunks.entries()) {
316
+ const card = index === 0
317
+ ? await ensureActiveCard()
318
+ : await this.#createStreamCard(chatId, options);
319
+ if (index > 0) cards.push(card);
320
+ await this.#updateStreamCard(card, chunk);
321
+ await this.#finishStreamCard(card);
322
+ }
323
+ });
263
324
  return {
264
325
  messageId: cards[0].messageId,
265
326
  providerMessageIds: cards.map((card) => card.messageId),
@@ -507,14 +568,16 @@ export class VerifiedFeishuChannel {
507
568
  return messageId;
508
569
  }
509
570
 
510
- async #recall(messageId) {
571
+ async #recall(messageId, label = 'streaming card') {
511
572
  try {
512
573
  const response = await this.#client.im.v1.message.delete({
513
574
  path: { message_id: messageId },
514
575
  });
515
576
  assertApiSuccess('Feishu message delete', response);
577
+ return true;
516
578
  } catch (error) {
517
- console.warn('[bridge] unable to recall a failed streaming card:', error.message);
579
+ console.warn(`[bridge] unable to recall ${label}:`, error.message);
580
+ return false;
518
581
  }
519
582
  }
520
583
 
@@ -528,6 +591,11 @@ export class VerifiedFeishuChannel {
528
591
  return reactionId;
529
592
  }
530
593
 
594
+ /** Delete one previously sent message (step-push heartbeat cleanup). */
595
+ async recallMessage(messageId) {
596
+ return this.#recall(messageId, 'thinking status heartbeat');
597
+ }
598
+
531
599
  async removeReaction(messageId, reactionId) {
532
600
  assertApiSuccess('Feishu reaction.delete', await this.#client.im.v1.messageReaction.delete({
533
601
  path: { message_id: messageId, reaction_id: reactionId },