@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.
- package/lib/client.js +66 -18
- package/lib/index.js +259 -256
- package/package.json +5 -1
- package/plugin-src/client/channels/feishu/api.js +3 -0
- package/plugin-src/client/channels/feishu/index.js +45 -13
- package/plugin-src/client/i18n.js +13 -2
- package/plugin-src/host/channels/feishu/production.mjs +1 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +20 -0
- package/src/channels/dingtalk/dingtalk-api.mjs +41 -48
- package/src/channels/dingtalk/dingtalk-bridge.mjs +5 -1
- package/src/channels/feishu/bridge.mjs +475 -11
- package/src/channels/feishu/feishu-cards.mjs +130 -0
- package/src/channels/feishu/feishu-runtime.mjs +10 -0
- package/src/channels/feishu/multi-bot-controller.mjs +28 -0
- package/src/channels/feishu/plugin-config-store.mjs +2 -0
- package/src/channels/feishu/step-push-mode.mjs +19 -0
- package/src/channels/shared/i18n-en/feishu.mjs +8 -0
|
@@ -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
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
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
|
-
...
|
|
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
|
|
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 {
|