@xmanrui/dsh-im 4.16.0 → 4.17.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.
@@ -90,7 +90,9 @@ import {
90
90
  answeredQuestionCard,
91
91
  questionCard,
92
92
  sessionListCard,
93
+ splitStepStreamCardBlocks,
93
94
  statusCard,
95
+ stepStreamCard,
94
96
  steerCard,
95
97
  watchListCard,
96
98
  workspaceListCard,
@@ -101,6 +103,10 @@ import {
101
103
  FEISHU_GROUP_RESPONSE_MODES,
102
104
  normalizeFeishuGroupResponseMode,
103
105
  } from './group-response-mode.mjs';
106
+ import {
107
+ FEISHU_STEP_PUSH_MODES,
108
+ normalizeFeishuStepPushMode,
109
+ } from './step-push-mode.mjs';
104
110
 
105
111
  // Lazily evaluated: t() must run after setImHostLanguage, not at import time.
106
112
  const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
@@ -179,6 +185,17 @@ const STEP_PUSH_HEARTBEAT_RECALL_ATTEMPTS = 3;
179
185
  /** One post message carries at most this many UTF-8 bytes after its rich-text
180
186
  * content has been JSON encoded (Feishu caps rich-text requests at 30KB). */
181
187
  const STEP_PUSH_POST_CHUNK_MAX_BYTES = 24_000;
188
+ /** Streaming-card mode coalesces card renders behind one PATCH per interval —
189
+ * patching the same message is far more rate sensitive than posting. */
190
+ const STEP_STREAM_PATCH_MIN_INTERVAL_MS = 1_000;
191
+ /** One answer chunk inside the streaming card: small enough that the block
192
+ * splitter can always distribute blocks across sealed/live cards. */
193
+ const STEP_STREAM_ANSWER_CHUNK_MAX_BYTES = 18_000;
194
+ /** Panels (tool summary / thinking) shed their oldest lines past this budget
195
+ * so a single panel can never outgrow the card on its own. */
196
+ const STEP_STREAM_PANEL_MAX_BYTES = 12_000;
197
+ /** One thinking note is excerpted to this many characters inside its panel. */
198
+ const STEP_STREAM_NOTE_MAX_CHARS = 500;
182
199
 
183
200
  /** Split one markdown answer into post-sized chunks at paragraph bounds,
184
201
  * budgeted by the encoded rich-text content rather than the source string.
@@ -580,8 +597,14 @@ export class FeishuHarnessBridge {
580
597
  #groupTopicReply = false;
581
598
  /** When true, streaming turns push tool calls and interim notes as discrete messages. */
582
599
  #stepPush = false;
600
+ /** Step push presentation: 'post' (discrete messages) or 'streaming_card'. */
601
+ #stepPushMode = FEISHU_STEP_PUSH_MODES.POST;
583
602
  /** Per-conversation step push state: key → { lastSentAt, count, breakerLogged }. */
584
603
  #stepPushSendState = new Map();
604
+ /** Live streaming step cards: key → per-turn card state (streaming_card mode). */
605
+ #stepCards = new Map();
606
+ /** In-flight turn stop markers: key → { requested } for 已停止 sealing. */
607
+ #stepStopFlags = new Map();
585
608
  /** Injectable clock for step push throttling (tests pass a fake one). */
586
609
  #stepPushClock;
587
610
  /** Group chats this bot has seen; only these may use topic replies. */
@@ -629,6 +652,7 @@ export class FeishuHarnessBridge {
629
652
  groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
630
653
  groupTopicReply = false,
631
654
  stepPush = false,
655
+ stepPushMode = FEISHU_STEP_PUSH_MODES.POST,
632
656
  stepPushClock = null,
633
657
  repair,
634
658
  repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
@@ -677,6 +701,7 @@ export class FeishuHarnessBridge {
677
701
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
678
702
  this.#groupTopicReply = groupTopicReply === true;
679
703
  this.#stepPush = stepPush === true;
704
+ this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
680
705
  this.#stepPushClock = stepPushClock ?? DEFAULT_STEP_PUSH_CLOCK;
681
706
  this.#repair = repair ?? null;
682
707
  this.#repairPollIntervalMs = repairPollIntervalMs;
@@ -713,6 +738,14 @@ export class FeishuHarnessBridge {
713
738
  this.#stepPush = value === true;
714
739
  }
715
740
 
741
+ setStepPushMode(value) {
742
+ this.#stepPushMode = normalizeFeishuStepPushMode(value);
743
+ }
744
+
745
+ get stepPushMode() {
746
+ return this.#stepPushMode;
747
+ }
748
+
716
749
  #isAddressed(event) {
717
750
  if (event?.message?.chat_type === 'p2p') return true;
718
751
  const mentions = Array.isArray(event?.message?.mentions) ? event.message.mentions : [];
@@ -3019,6 +3052,7 @@ export class FeishuHarnessBridge {
3019
3052
  },
3020
3053
  );
3021
3054
  if (result?.stopped) {
3055
+ this.#markStepStopRequested(key);
3022
3056
  await Promise.allSettled([
3023
3057
  this.#cancelPendingInteraction(key),
3024
3058
  this.#approvals.closeRoute(key),
@@ -3865,6 +3899,355 @@ export class FeishuHarnessBridge {
3865
3899
  await this.#sendStepPost(chatId, replyToMessageId, paragraphs, fallbackText);
3866
3900
  }
3867
3901
 
3902
+ // ── Streaming step card (流式过程卡片) ─────────────────────────────────────
3903
+
3904
+ /** Ensure the per-turn card state exists without scheduling a render. */
3905
+ #ensureStepCard(key, chatId, replyToMessageId) {
3906
+ let card = this.#stepCards.get(key);
3907
+ if (!card) {
3908
+ card = {
3909
+ blocks: [],
3910
+ messageId: null,
3911
+ chatId,
3912
+ replyToMessageId,
3913
+ broken: false,
3914
+ lastRenderAt: 0,
3915
+ renderQueued: false,
3916
+ renderChain: null,
3917
+ chunkCount: 1,
3918
+ cardIds: [],
3919
+ answerStart: null,
3920
+ answerEnd: null,
3921
+ // Answer-integrity tracking: the version bumps every time the draft
3922
+ // is rewritten in memory; `renderedAnswerVersion` records the version
3923
+ // covered by the last SUCCESSFUL render. The seal may only skip the
3924
+ // post fallback when both match (the final answer is provably
3925
+ // visible on a delivered card).
3926
+ answerVersion: 0,
3927
+ renderedAnswerVersion: 0,
3928
+ };
3929
+ this.#stepCards.set(key, card);
3930
+ }
3931
+ return card;
3932
+ }
3933
+
3934
+ /**
3935
+ * Append one process block to the turn's live streaming card: the first
3936
+ * block of a turn opens a card in the request topic, later blocks re-render
3937
+ * the same message via im.v1.message.patch. Consecutive tool lines merge
3938
+ * into one collapsible panel. Renders are coalesced behind
3939
+ * STEP_STREAM_PATCH_MIN_INTERVAL_MS; failures permanently downgrade the
3940
+ * turn (card.broken) — the final answer still delivers through the normal
3941
+ * ladder, and a flaky card never breaks the ask.
3942
+ */
3943
+ async #appendStepCardUpdate(key, chatId, replyToMessageId, block, { billable = true } = {}) {
3944
+ const card = this.#ensureStepCard(key, chatId, replyToMessageId);
3945
+ if (card.broken) return;
3946
+ const last = card.blocks[card.blocks.length - 1];
3947
+ if ((block.kind === 'tools' || block.kind === 'notes') && last?.kind === block.kind) {
3948
+ for (const line of block.lines) this.#pushPanelLine(last, line);
3949
+ } else {
3950
+ card.blocks.push(block);
3951
+ }
3952
+ if (billable) {
3953
+ this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
3954
+ }
3955
+ this.#queueStepCardRender(key, chatId);
3956
+ }
3957
+
3958
+ /** Append one line to a foldable panel, shedding oldest lines past budget. */
3959
+ #pushPanelLine(block, line) {
3960
+ if (!Array.isArray(block.lines)) block.lines = [];
3961
+ block.omitted = Number(block.omitted) || 0;
3962
+ let size = Buffer.byteLength(block.lines.join('\n'), 'utf8');
3963
+ const incoming = Buffer.byteLength(line, 'utf8');
3964
+ while (block.lines.length > 0 && size + incoming > STEP_STREAM_PANEL_MAX_BYTES) {
3965
+ const removed = block.lines.shift();
3966
+ block.omitted += 1;
3967
+ size -= Buffer.byteLength(`${removed}\n`, 'utf8');
3968
+ }
3969
+ block.lines.push(line);
3970
+ }
3971
+
3972
+ /**
3973
+ * Stream one finalized assistant step into the card as the live answer
3974
+ * draft: the newest step rewrites the draft blocks, so the answer grows in
3975
+ * place like ZCode's streaming card. Long answers are pre-chunked at
3976
+ * paragraph bounds so the block splitter can always spill them across
3977
+ * sealed cards. When a later tool call proves the draft interim,
3978
+ * #morphStepCardAnswerToNote folds it into the thinking panel.
3979
+ */
3980
+ #streamStepCardAnswer(key, chatId, replyToMessageId, text) {
3981
+ const card = this.#ensureStepCard(key, chatId, replyToMessageId);
3982
+ if (card.broken) return;
3983
+ const body = String(text ?? '');
3984
+ if (!body.trim()) return;
3985
+ this.#writeStepCardAnswer(card, body);
3986
+ this.#queueStepCardRender(key, chatId);
3987
+ }
3988
+
3989
+ /** Replace the draft interval with `text` chunked into splitter-safe blocks. */
3990
+ #writeStepCardAnswer(card, text) {
3991
+ const blocks = splitStepPostMarkdown(text, STEP_STREAM_ANSWER_CHUNK_MAX_BYTES)
3992
+ .map((chunk) => ({ kind: 'message', text: chunk }));
3993
+ if (card.answerStart !== null) {
3994
+ card.blocks.splice(card.answerStart, card.answerEnd - card.answerStart + 1, ...blocks);
3995
+ } else {
3996
+ card.blocks.push(...blocks);
3997
+ }
3998
+ card.answerStart = card.blocks.length - blocks.length;
3999
+ card.answerEnd = card.blocks.length - 1;
4000
+ // The in-memory draft changed; nothing rendered so far can prove the new
4001
+ // answer is visible. The next successful render re-aligns the versions.
4002
+ card.answerVersion = (card.answerVersion ?? 0) + 1;
4003
+ }
4004
+
4005
+ /** Convert the live answer draft into a folded thinking note in place. */
4006
+ #morphStepCardAnswerToNote(key, text) {
4007
+ const card = this.#stepCards.get(key);
4008
+ if (!card || card.broken) return;
4009
+ const body = String(text ?? '');
4010
+ if (!body.trim()) return;
4011
+ if (card.answerStart !== null) {
4012
+ card.blocks.splice(card.answerStart, card.answerEnd - card.answerStart + 1);
4013
+ card.answerStart = null;
4014
+ card.answerEnd = null;
4015
+ }
4016
+ const excerpt = body.length > STEP_STREAM_NOTE_MAX_CHARS
4017
+ ? `${body.slice(0, STEP_STREAM_NOTE_MAX_CHARS)}…`
4018
+ : body;
4019
+ const last = card.blocks[card.blocks.length - 1];
4020
+ if (last?.kind === 'notes') {
4021
+ this.#pushPanelLine(last, excerpt);
4022
+ } else {
4023
+ card.blocks.push({ kind: 'notes', lines: [excerpt], omitted: 0 });
4024
+ }
4025
+ this.#queueStepCardRender(key, card.chatId);
4026
+ }
4027
+
4028
+ #queueStepCardRender(key, chatId) {
4029
+ const card = this.#stepCards.get(key);
4030
+ if (!card || card.broken || card.renderQueued) return;
4031
+ card.renderQueued = true;
4032
+ // The coalescing delay lives inside the serial chain, so a finish that
4033
+ // awaits the chain can never race a pending first render.
4034
+ const wait = Math.max(
4035
+ 0,
4036
+ card.lastRenderAt + STEP_STREAM_PATCH_MIN_INTERVAL_MS - this.#stepPushClock.now(),
4037
+ );
4038
+ const previous = card.renderChain ?? Promise.resolve();
4039
+ card.renderChain = previous
4040
+ .catch(() => {})
4041
+ .then(() => this.#stepPushClock.delay(wait))
4042
+ .then(() => this.#renderStepCardNow(chatId, card))
4043
+ .finally(() => {
4044
+ card.renderQueued = false;
4045
+ });
4046
+ }
4047
+
4048
+ async #renderStepCardNow(chatId, card) {
4049
+ if (card.broken) return;
4050
+ const chunks = splitStepStreamCardBlocks(card.blocks);
4051
+ const live = chunks[chunks.length - 1] ?? [];
4052
+ try {
4053
+ if (card.messageId === null) {
4054
+ // First render: deliver every chunk. When the turn opens with a long
4055
+ // answer already in memory, chunks.length can exceed one — sending
4056
+ // only the live chunk here would silently drop its prefix, and the
4057
+ // chunkCount bookkeeping below would then rewrite the delivered
4058
+ // message with chunk 0 on the next render.
4059
+ for (let index = 0; index < chunks.length; index += 1) {
4060
+ const isLive = index === chunks.length - 1;
4061
+ const id = await this.#sendCard(
4062
+ chatId,
4063
+ stepStreamCard(chunks[index], { status: isLive ? 'running' : 'sealed' }),
4064
+ { replyTo: card.replyToMessageId },
4065
+ );
4066
+ card.cardIds.push(id);
4067
+ if (isLive) card.messageId = id;
4068
+ }
4069
+ card.chunkCount = chunks.length;
4070
+ card.lastRenderAt = this.#stepPushClock.now();
4071
+ card.renderedAnswerVersion = card.answerVersion ?? 0;
4072
+ return;
4073
+ }
4074
+ // Overflow: the accumulated blocks outgrew one card. Seal the current
4075
+ // message (no status line), spill extra chunks, and keep the last one
4076
+ // live. Chunk boundaries are stable because blocks only append.
4077
+ if (chunks.length > card.chunkCount) {
4078
+ await this.#patchStepCard(
4079
+ card.messageId,
4080
+ stepStreamCard(chunks[card.chunkCount - 1], { status: 'sealed' }),
4081
+ );
4082
+ for (let index = card.chunkCount; index < chunks.length; index += 1) {
4083
+ const isLive = index === chunks.length - 1;
4084
+ const id = await this.#sendCard(
4085
+ chatId,
4086
+ stepStreamCard(chunks[index], { status: isLive ? 'running' : 'sealed' }),
4087
+ { replyTo: card.replyToMessageId },
4088
+ );
4089
+ card.cardIds.push(id);
4090
+ if (isLive) card.messageId = id;
4091
+ }
4092
+ card.chunkCount = chunks.length;
4093
+ } else {
4094
+ await this.#patchStepCard(card.messageId, stepStreamCard(live, { status: 'running' }));
4095
+ }
4096
+ card.lastRenderAt = this.#stepPushClock.now();
4097
+ card.renderedAnswerVersion = card.answerVersion ?? 0;
4098
+ } catch (error) {
4099
+ card.broken = true;
4100
+ this.#logger.warn?.(
4101
+ '[dsh-feishu] step streaming card render failed; the turn continues without it:',
4102
+ error?.message ?? String(error),
4103
+ );
4104
+ }
4105
+ }
4106
+
4107
+ /**
4108
+ * Seal the turn's card with a terminal status and the final answer. The
4109
+ * streamed answer draft is rewritten in place with the delivery text; when
4110
+ * no draft exists the answer is appended. Returns `{ ok, cardIds }` when
4111
+ * the answer is visible on the cards, or null when the caller must fall
4112
+ * back to the post ladder (absent/broken card, or the answer never reached
4113
+ * a successful render). Safe to call twice.
4114
+ */
4115
+ async #finishStepCard(key, { stopped = false, answerText = null } = {}) {
4116
+ const card = this.#stepCards.get(key);
4117
+ this.#stepCards.delete(key);
4118
+ if (!card) return null;
4119
+ try {
4120
+ await card.renderChain?.catch?.(() => {});
4121
+ } catch { /* the seal below decides what the user sees */ }
4122
+ if (card.broken) return null;
4123
+ const status = stopped ? 'stopped' : 'completed';
4124
+ const body = typeof answerText === 'string' ? answerText : '';
4125
+ // Whether the answer was already visible from a streamed draft render —
4126
+ // decided BEFORE the seal writes the final text.
4127
+ const streamedDraft = card.answerStart !== null;
4128
+ if (body.trim()) {
4129
+ this.#writeStepCardAnswer(card, body);
4130
+ }
4131
+ const chunks = splitStepStreamCardBlocks(card.blocks);
4132
+ const live = chunks[chunks.length - 1] ?? [];
4133
+ try {
4134
+ if (card.messageId === null) {
4135
+ // A turn without any queued render (plain Q&A): open the cards
4136
+ // directly with their terminal status; every overflow chunk before
4137
+ // the last one is sealed. An empty turn still opens the status card.
4138
+ const groups = chunks.length > 0 ? chunks : [[]];
4139
+ for (let index = 0; index < groups.length; index += 1) {
4140
+ const isLive = index === groups.length - 1;
4141
+ const id = await this.#sendCard(
4142
+ card.chatId,
4143
+ stepStreamCard(groups[index], { status: isLive ? status : 'sealed' }),
4144
+ { replyTo: card.replyToMessageId },
4145
+ );
4146
+ card.cardIds.push(id);
4147
+ }
4148
+ return { ok: true, cardIds: card.cardIds };
4149
+ }
4150
+ // The live message only ever shows the last chunk; spill every chunk
4151
+ // that has not been delivered yet as sealed cards before patching the
4152
+ // live one, otherwise a long answer finishing on an existing card
4153
+ // would silently drop its prefix.
4154
+ if (chunks.length > card.chunkCount) {
4155
+ await this.#patchStepCard(
4156
+ card.messageId,
4157
+ stepStreamCard(chunks[card.chunkCount - 1], { status: 'sealed' }),
4158
+ );
4159
+ for (let index = card.chunkCount; index < chunks.length; index += 1) {
4160
+ const isLast = index === chunks.length - 1;
4161
+ const id = await this.#sendCard(
4162
+ card.chatId,
4163
+ stepStreamCard(chunks[index], { status: isLast ? status : 'sealed' }),
4164
+ { replyTo: card.replyToMessageId },
4165
+ );
4166
+ card.cardIds.push(id);
4167
+ if (isLast) card.messageId = id;
4168
+ }
4169
+ card.chunkCount = chunks.length;
4170
+ } else {
4171
+ await this.#patchStepCard(card.messageId, stepStreamCard(live, { status }));
4172
+ }
4173
+ card.renderedAnswerVersion = card.answerVersion ?? 0;
4174
+ return { ok: true, cardIds: card.cardIds };
4175
+ } catch (error) {
4176
+ // Only trust the streamed draft when the LAST SUCCESSFUL render actually
4177
+ // covered the current answer version. A draft sitting in memory while
4178
+ // its render failed (or never ran) does not prove the answer is visible.
4179
+ const answerVisible = card.messageId !== null
4180
+ && body.trim()
4181
+ && (card.renderedAnswerVersion ?? 0) === (card.answerVersion ?? 0)
4182
+ && streamedDraft;
4183
+ if (answerVisible) {
4184
+ this.#logger.warn?.(
4185
+ '[dsh-feishu] step streaming card seal failed; the delivered answer stays visible:',
4186
+ error?.message ?? String(error),
4187
+ );
4188
+ return { ok: true, cardIds: card.cardIds };
4189
+ }
4190
+ this.#logger.warn?.(
4191
+ '[dsh-feishu] step streaming card finish failed:',
4192
+ error?.message ?? String(error),
4193
+ );
4194
+ return null;
4195
+ }
4196
+ }
4197
+
4198
+ #stepCardToolBlock(update) {
4199
+ const summary = this.#stepIntentSummary(update.arguments);
4200
+ const name = stepPushLine(update.name) || t('工具');
4201
+ return { kind: 'tools', lines: [`✅ ${name}${summary ? ` — ${summary}` : ''}`], omitted: 0 };
4202
+ }
4203
+
4204
+ /** Flag the in-flight streaming-card turn as user-stopped (seals 已停止). */
4205
+ #markStepStopRequested(key) {
4206
+ const flag = this.#stepStopFlags.get(key);
4207
+ if (flag) flag.requested = true;
4208
+ }
4209
+
4210
+ /**
4211
+ * Freeze the live process card before an approval/question card renders;
4212
+ * later steps stream into a fresh card below the interaction (mirrors the
4213
+ * main streaming path's rotate). A failed seal never blocks the
4214
+ * interaction — the old card keeps its last rendered state.
4215
+ */
4216
+ async #rotateStepCard(key) {
4217
+ const card = this.#stepCards.get(key);
4218
+ if (!card || card.broken) return;
4219
+ await card.renderChain?.catch?.(() => {});
4220
+ if (card.broken) return;
4221
+ if (card.messageId === null) return;
4222
+ try {
4223
+ const chunks = splitStepStreamCardBlocks(card.blocks);
4224
+ const live = chunks[chunks.length - 1] ?? [];
4225
+ await this.#patchStepCard(card.messageId, stepStreamCard(live, { status: 'sealed' }));
4226
+ } catch (error) {
4227
+ this.#logger.warn?.(
4228
+ '[dsh-feishu] step streaming card rotate seal failed:',
4229
+ error?.message ?? String(error),
4230
+ );
4231
+ }
4232
+ // Restart the stream on a fresh card below the interaction message.
4233
+ card.blocks = [];
4234
+ card.messageId = null;
4235
+ card.chunkCount = 1;
4236
+ card.answerStart = null;
4237
+ card.answerEnd = null;
4238
+ card.lastRenderAt = 0;
4239
+ }
4240
+
4241
+ async #patchStepCard(messageId, cardJson) {
4242
+ const response = await this.#client.im.v1.message.patch({
4243
+ path: { message_id: messageId },
4244
+ data: { content: cardJson },
4245
+ });
4246
+ if (response?.code && response.code !== 0) {
4247
+ throw new Error(`Feishu step card patch failed: ${response.msg || response.code}`);
4248
+ }
4249
+ }
4250
+
3868
4251
  /**
3869
4252
  * Rich-text (post) delivery for step messages. Degradation ladder:
3870
4253
  * post(限流重试 ×2)→ 同话题纯文本回复 → 主界面纯文本。Returns
@@ -4162,17 +4545,39 @@ export class FeishuHarnessBridge {
4162
4545
  count: 0,
4163
4546
  breakerLogged: false,
4164
4547
  });
4548
+ // 流式卡片模式:每轮一张过程卡(原地 patch),过程与最终答案都进卡;
4549
+ // post 模式维持逐条直推。先预建卡片状态,纯问答回合也能在收尾时开卡。
4550
+ const streamingCard = this.#stepPushMode === FEISHU_STEP_PUSH_MODES.STREAMING_CARD;
4551
+ if (streamingCard) {
4552
+ if (this.#stepCards.has(key)) {
4553
+ // 上一轮异常退出留下的「运行中」卡片先收尾,避免与本轮混淆。
4554
+ await this.#finishStepCard(key);
4555
+ }
4556
+ this.#ensureStepCard(key, chatId, messageId);
4557
+ }
4558
+ // /stop 打标:回合进行中收到停止请求时,封存为「已停止」而非「已完成」。
4559
+ const stepStopFlag = streamingCard ? { requested: false } : null;
4560
+ if (streamingCard) this.#stepStopFlags.set(key, stepStopFlag);
4561
+ const baseAskOptions = this.#interactionAskOptions(event, key, message.files);
4165
4562
 
4166
4563
  // 上下文注入动作在详细级别下也体现为一条步骤(注入细节可忽略)。
4167
4564
  // 不计入工具/助手消息的熔断计数(规格口径:熔断只管「工具 + 助手」)。
4168
4565
  if (contextEnhanced || content) {
4169
- await this.#sendStepMessage(
4170
- chatId, key,
4171
- [[{ tag: 'text', text: t('📎 已注入会话上下文') }]],
4172
- t('📎 已注入会话上下文'),
4173
- messageId,
4174
- { billable: false },
4175
- );
4566
+ if (streamingCard) {
4567
+ await this.#appendStepCardUpdate(
4568
+ key, chatId, messageId,
4569
+ { kind: 'message', text: t('📎 已注入会话上下文') },
4570
+ { billable: false },
4571
+ );
4572
+ } else {
4573
+ await this.#sendStepMessage(
4574
+ chatId, key,
4575
+ [[{ tag: 'text', text: t('📎 已注入会话上下文') }]],
4576
+ t('📎 已注入会话上下文'),
4577
+ messageId,
4578
+ { billable: false },
4579
+ );
4580
+ }
4176
4581
  }
4177
4582
 
4178
4583
  // 缓冲-确认策略:每步定稿的助手文本先进缓冲;工具调用或更大 step 的定稿
@@ -4183,6 +4588,11 @@ export class FeishuHarnessBridge {
4183
4588
  if (!pendingStep) return;
4184
4589
  const note = pendingStep;
4185
4590
  pendingStep = null;
4591
+ if (streamingCard) {
4592
+ // 答案草稿被后续工具证实为过程说明:原位收进折叠的思考面板。
4593
+ this.#morphStepCardAnswerToNote(key, note.text);
4594
+ return;
4595
+ }
4186
4596
  await this.#sendStepMessage(
4187
4597
  chatId, key,
4188
4598
  [[{ tag: 'md', text: `💬 ${note.text}` }]],
@@ -4190,7 +4600,7 @@ export class FeishuHarnessBridge {
4190
4600
  messageId,
4191
4601
  );
4192
4602
  };
4193
- const watchdog = this.#startThinkingStatusWatchdog(key, chatId, messageId);
4603
+ const watchdog = streamingCard ? null : this.#startThinkingStatusWatchdog(key, chatId, messageId);
4194
4604
  let completed;
4195
4605
  try {
4196
4606
  completed = await askInWorkspaceSession({
@@ -4204,18 +4614,35 @@ export class FeishuHarnessBridge {
4204
4614
  createOptions: { signal: this.#signal },
4205
4615
  existsOptions: { signal: this.#signal },
4206
4616
  askOptions: {
4207
- ...this.#interactionAskOptions(event, key, message.files),
4617
+ ...baseAskOptions,
4208
4618
  progressMode: 'all',
4619
+ // 提问/审批卡弹出前先定格当前过程卡,答案随之流到交互消息之后
4620
+ // 的新卡上(与主流式路径的 rotate 语义一致)。
4621
+ ...(streamingCard ? {
4622
+ onInteraction: async (interaction) => {
4623
+ if (interaction?.kind === 'question' || interaction?.kind === 'approval') {
4624
+ await this.#rotateStepCard(key);
4625
+ }
4626
+ await baseAskOptions.onInteraction?.(interaction);
4627
+ },
4628
+ } : {}),
4209
4629
  onUpdate: async (update) => {
4210
4630
  if (update.type === 'assistant-message') {
4211
4631
  if (pendingStep && Number(update.step) > Number(pendingStep.step)) {
4212
4632
  await flushPendingStep();
4213
4633
  }
4214
4634
  pendingStep = update;
4635
+ if (streamingCard) {
4636
+ this.#streamStepCardAnswer(key, chatId, messageId, update.text);
4637
+ }
4215
4638
  return;
4216
4639
  }
4217
4640
  if (update.type === 'tool') {
4218
4641
  await flushPendingStep();
4642
+ if (streamingCard) {
4643
+ await this.#appendStepCardUpdate(key, chatId, messageId, this.#stepCardToolBlock(update));
4644
+ return;
4645
+ }
4219
4646
  const { paragraphs, fallbackText } = this.#formatStepToolPost(update);
4220
4647
  await this.#sendStepMessage(chatId, key, paragraphs, fallbackText, messageId);
4221
4648
  return;
@@ -4223,6 +4650,13 @@ export class FeishuHarnessBridge {
4223
4650
  if (update.type === 'status' && update.error) {
4224
4651
  const name = stepPushLine(update.toolName) || t('工具');
4225
4652
  const excerpt = stepPushLine(update.error).slice(0, STEP_PUSH_ERROR_MAX_CHARS);
4653
+ if (streamingCard) {
4654
+ await this.#appendStepCardUpdate(
4655
+ key, chatId, messageId,
4656
+ { kind: 'message', text: `⚠️ **${name}** — ${excerpt}` },
4657
+ );
4658
+ return;
4659
+ }
4226
4660
  await this.#sendStepMessage(
4227
4661
  chatId, key,
4228
4662
  [[{ tag: 'md', text: `⚠️ **${name}** — ${excerpt}` }]],
@@ -4233,10 +4667,16 @@ export class FeishuHarnessBridge {
4233
4667
  // text / status(无错误)保持静默:详细级直推完全取代简略级进度行。
4234
4668
  },
4235
4669
  },
4236
- });
4670
+ });
4671
+ } catch (error) {
4672
+ if (streamingCard) {
4673
+ this.#stepStopFlags.delete(key);
4674
+ await this.#finishStepCard(key, { stopped: true });
4675
+ }
4676
+ throw error;
4237
4677
  } finally {
4238
4678
  // 回合结束(成功/失败/中断):停看门狗并撤回残留思考中心跳。
4239
- await watchdog.stop();
4679
+ await watchdog?.stop();
4240
4680
  }
4241
4681
  markAskComplete();
4242
4682
  const finalStepText = pendingStep ? pendingStep.text : null;
@@ -4245,6 +4685,30 @@ export class FeishuHarnessBridge {
4245
4685
  ? finalStepText
4246
4686
  : completed.answer;
4247
4687
  const deliveryText = answerTextForDelivery(finalText, completed.artifacts ?? []);
4688
+ // 流式卡模式:答案已随步骤流进过程卡,封存后直接以卡片作回执;
4689
+ // 无卡/坏卡/封存失败时才回退到下方 post 阶梯。
4690
+ if (streamingCard) {
4691
+ const seal = await this.#finishStepCard(key, {
4692
+ answerText: deliveryText,
4693
+ stopped: stepStopFlag?.requested === true,
4694
+ });
4695
+ this.#stepStopFlags.delete(key);
4696
+ if (seal?.ok) {
4697
+ const cardReceipt = createDeliveryReceipt({
4698
+ deliveryId: messageId,
4699
+ presentation: 'feishu-step-push-card',
4700
+ providerMessageIds: seal.cardIds,
4701
+ });
4702
+ this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
4703
+ const delivery = await this.#deliverArtifacts(
4704
+ chatId,
4705
+ messageId,
4706
+ completed.artifacts ?? [],
4707
+ cardReceipt,
4708
+ );
4709
+ return { ...delivery, textDeliveryErrors: 0 };
4710
+ }
4711
+ }
4248
4712
  let textReceipt;
4249
4713
  let postDeliveryError = null;
4250
4714
  try {