@xmanrui/dsh-im 4.13.0 → 4.14.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 +76 -1
- package/lib/index.js +247 -243
- package/package.json +1 -1
- package/plugin-src/client/channels/feishu/api.js +2 -0
- package/plugin-src/client/channels/feishu/index.js +57 -0
- package/plugin-src/client/i18n.js +5 -0
- package/plugin-src/host/channels/feishu/production.mjs +1 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
- package/src/channels/feishu/bridge.mjs +510 -3
- package/src/channels/feishu/feishu-runtime.mjs +9 -0
- package/src/channels/feishu/multi-bot-controller.mjs +16 -0
- package/src/channels/feishu/plugin-config-store.mjs +1 -0
- package/src/channels/shared/bot-workspace-store.mjs +14 -1
- package/src/channels/shared/harness-client.mjs +21 -2
- package/src/channels/shared/i18n-en/feishu.mjs +2 -0
- package/src/channels/shared/semantic/artifact.mjs +3 -1
|
@@ -157,6 +157,77 @@ const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
|
|
|
157
157
|
/** Matches fast card commands that should not be queued behind a running task. */
|
|
158
158
|
const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|workspaces|wsl|watchlist|archived(?:\s+(on|off))?)$/i;
|
|
159
159
|
|
|
160
|
+
// ── Step push (分步直推) limits ──────────────────────────────────────────────
|
|
161
|
+
/** Feishu allows 5 msg/s per chat; step push stays well below that. */
|
|
162
|
+
const STEP_PUSH_MIN_INTERVAL_MS = 250;
|
|
163
|
+
/** One turn never pushes more step messages than this (熔断阈值). */
|
|
164
|
+
const STEP_PUSH_MAX_MESSAGES_PER_TURN = 200;
|
|
165
|
+
/** Excerpt bounds for step push message rendering. */
|
|
166
|
+
const STEP_PUSH_SUMMARY_MAX_CHARS = 120;
|
|
167
|
+
const STEP_PUSH_ARGUMENTS_MAX_CHARS = 400;
|
|
168
|
+
const STEP_PUSH_ERROR_MAX_CHARS = 200;
|
|
169
|
+
/** One post message carries at most this many UTF-8 bytes after its rich-text
|
|
170
|
+
* content has been JSON encoded (Feishu caps rich-text requests at 30KB). */
|
|
171
|
+
const STEP_PUSH_POST_CHUNK_MAX_BYTES = 24_000;
|
|
172
|
+
|
|
173
|
+
/** Split one markdown answer into post-sized chunks at paragraph bounds,
|
|
174
|
+
* budgeted by the encoded rich-text content rather than the source string.
|
|
175
|
+
* This accounts for both CJK bytes and JSON escape expansion. */
|
|
176
|
+
function splitStepPostMarkdown(text, limit = STEP_PUSH_POST_CHUNK_MAX_BYTES) {
|
|
177
|
+
const source = String(text ?? '');
|
|
178
|
+
const encodedBytes = (markdown) => Buffer.byteLength(JSON.stringify({
|
|
179
|
+
zh_cn: { content: [[{ tag: 'md', text: markdown }]] },
|
|
180
|
+
}), 'utf8');
|
|
181
|
+
if (encodedBytes(source) <= limit) return [source];
|
|
182
|
+
const chunks = [];
|
|
183
|
+
let remaining = source;
|
|
184
|
+
while (encodedBytes(remaining) > limit) {
|
|
185
|
+
let lo = 0;
|
|
186
|
+
let hi = remaining.length;
|
|
187
|
+
while (lo < hi) {
|
|
188
|
+
const mid = (lo + hi + 1) >> 1;
|
|
189
|
+
if (encodedBytes(remaining.slice(0, mid)) <= limit) lo = mid;
|
|
190
|
+
else hi = mid - 1;
|
|
191
|
+
}
|
|
192
|
+
let cut = lo;
|
|
193
|
+
const preferFrom = Math.floor(cut * 0.6);
|
|
194
|
+
const softCut = remaining.lastIndexOf('\n\n', cut);
|
|
195
|
+
if (softCut >= preferFrom) {
|
|
196
|
+
cut = softCut;
|
|
197
|
+
} else {
|
|
198
|
+
const lineCut = remaining.lastIndexOf('\n', cut);
|
|
199
|
+
if (lineCut >= preferFrom) cut = lineCut;
|
|
200
|
+
}
|
|
201
|
+
if (cut <= 0) cut = lo;
|
|
202
|
+
// JavaScript indexes strings by UTF-16 code unit. Never split between a
|
|
203
|
+
// high and low surrogate, which would turn one emoji into two replacement
|
|
204
|
+
// characters across adjacent messages.
|
|
205
|
+
const before = remaining.charCodeAt(cut - 1);
|
|
206
|
+
const after = remaining.charCodeAt(cut);
|
|
207
|
+
if (before >= 0xD800 && before <= 0xDBFF && after >= 0xDC00 && after <= 0xDFFF) {
|
|
208
|
+
cut -= 1;
|
|
209
|
+
}
|
|
210
|
+
if (cut <= 0) {
|
|
211
|
+
throw new RangeError('Feishu step push post byte limit is too small');
|
|
212
|
+
}
|
|
213
|
+
chunks.push(remaining.slice(0, cut));
|
|
214
|
+
remaining = remaining.slice(cut).trimStart();
|
|
215
|
+
}
|
|
216
|
+
if (remaining) chunks.push(remaining);
|
|
217
|
+
return chunks;
|
|
218
|
+
}
|
|
219
|
+
/** Salient tool-argument fields for the intent summary, in priority order. */
|
|
220
|
+
const STEP_PUSH_INTENT_FIELDS = [
|
|
221
|
+
'command', 'file_path', 'path', 'query', 'url', 'pattern', 'prompt', 'cmd',
|
|
222
|
+
];
|
|
223
|
+
/** Bounds the per-conversation step push send-state cache. */
|
|
224
|
+
const MAX_STEP_PUSH_SEND_STATES = 512;
|
|
225
|
+
/** Real clock used by step push throttling; tests inject a fake one. */
|
|
226
|
+
const DEFAULT_STEP_PUSH_CLOCK = Object.freeze({
|
|
227
|
+
now: () => Date.now(),
|
|
228
|
+
delay: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
229
|
+
});
|
|
230
|
+
|
|
160
231
|
/** Pretty-print a tool call's arguments for an approval card. */
|
|
161
232
|
function operationArguments(toolCall) {
|
|
162
233
|
const source = toolCall?.arguments;
|
|
@@ -247,6 +318,11 @@ function answerTextForDelivery(answer, artifacts) {
|
|
|
247
318
|
return artifacts.length > 0 ? t('结果文件已生成。') : answer;
|
|
248
319
|
}
|
|
249
320
|
|
|
321
|
+
/** Collapse a step push excerpt into one tidy line. */
|
|
322
|
+
function stepPushLine(value) {
|
|
323
|
+
return String(value ?? '').replace(/\s+/gu, ' ').trim();
|
|
324
|
+
}
|
|
325
|
+
|
|
250
326
|
function nonEmptyString(value) {
|
|
251
327
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
252
328
|
}
|
|
@@ -489,6 +565,12 @@ export class FeishuHarnessBridge {
|
|
|
489
565
|
#groupResponseMode;
|
|
490
566
|
/** When true, group replies that belong to a Feishu topic ask reply_in_thread. */
|
|
491
567
|
#groupTopicReply = false;
|
|
568
|
+
/** When true, streaming turns push tool calls and interim notes as discrete messages. */
|
|
569
|
+
#stepPush = false;
|
|
570
|
+
/** Per-conversation step push state: key → { lastSentAt, count, breakerLogged }. */
|
|
571
|
+
#stepPushSendState = new Map();
|
|
572
|
+
/** Injectable clock for step push throttling (tests pass a fake one). */
|
|
573
|
+
#stepPushClock;
|
|
492
574
|
/** Group chats this bot has seen; only these may use topic replies. */
|
|
493
575
|
#groupChatIds = new Set();
|
|
494
576
|
/** Anchor message id → whether its replies should stay in a Feishu topic. */
|
|
@@ -533,6 +615,8 @@ export class FeishuHarnessBridge {
|
|
|
533
615
|
botOpenId,
|
|
534
616
|
groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
|
|
535
617
|
groupTopicReply = false,
|
|
618
|
+
stepPush = false,
|
|
619
|
+
stepPushClock = null,
|
|
536
620
|
repair,
|
|
537
621
|
repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
|
|
538
622
|
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
|
|
@@ -560,6 +644,12 @@ export class FeishuHarnessBridge {
|
|
|
560
644
|
|| !Number.isFinite(cardDataTimeoutMs) || cardDataTimeoutMs <= 0) {
|
|
561
645
|
throw new TypeError('Feishu timing values must be positive numbers');
|
|
562
646
|
}
|
|
647
|
+
if (stepPushClock !== null
|
|
648
|
+
&& (typeof stepPushClock !== 'object' || Array.isArray(stepPushClock)
|
|
649
|
+
|| typeof stepPushClock.now !== 'function'
|
|
650
|
+
|| typeof stepPushClock.delay !== 'function')) {
|
|
651
|
+
throw new TypeError('Feishu step push clock requires now and delay functions');
|
|
652
|
+
}
|
|
563
653
|
this.#client = client;
|
|
564
654
|
this.#channel = channel;
|
|
565
655
|
this.#harness = harness;
|
|
@@ -573,6 +663,8 @@ export class FeishuHarnessBridge {
|
|
|
573
663
|
this.#botOpenId = nonEmptyString(botOpenId);
|
|
574
664
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
|
|
575
665
|
this.#groupTopicReply = groupTopicReply === true;
|
|
666
|
+
this.#stepPush = stepPush === true;
|
|
667
|
+
this.#stepPushClock = stepPushClock ?? DEFAULT_STEP_PUSH_CLOCK;
|
|
576
668
|
this.#repair = repair ?? null;
|
|
577
669
|
this.#repairPollIntervalMs = repairPollIntervalMs;
|
|
578
670
|
this.#repairLinkWaitMs = repairLinkWaitMs;
|
|
@@ -604,6 +696,10 @@ export class FeishuHarnessBridge {
|
|
|
604
696
|
this.#groupTopicReply = value === true;
|
|
605
697
|
}
|
|
606
698
|
|
|
699
|
+
setStepPush(value) {
|
|
700
|
+
this.#stepPush = value === true;
|
|
701
|
+
}
|
|
702
|
+
|
|
607
703
|
#isAddressed(event) {
|
|
608
704
|
if (event?.message?.chat_type === 'p2p') return true;
|
|
609
705
|
const mentions = Array.isArray(event?.message?.mentions) ? event.message.mentions : [];
|
|
@@ -639,10 +735,13 @@ export class FeishuHarnessBridge {
|
|
|
639
735
|
/**
|
|
640
736
|
* Record whether replies anchored on `messageId` should stay in a topic.
|
|
641
737
|
* `asRoot` marks a main-feed question this bot will auto-open as a topic.
|
|
738
|
+
* The flag is keyed on the conversation shape alone: once a conversation is
|
|
739
|
+
* happening inside a topic, its replies belong to that topic whether the
|
|
740
|
+
* bot auto-opens topics (群话题回复) or the user maintains them manually.
|
|
642
741
|
*/
|
|
643
742
|
#rememberTopicReply(messageId, key) {
|
|
644
743
|
if (!nonEmptyString(messageId)) return;
|
|
645
|
-
this.#anchorTopicReply.set(messageId,
|
|
744
|
+
this.#anchorTopicReply.set(messageId, isTopicGroupKey(key));
|
|
646
745
|
if (this.#anchorTopicReply.size > 2048) {
|
|
647
746
|
const oldest = this.#anchorTopicReply.keys().next().value;
|
|
648
747
|
if (oldest !== undefined) this.#anchorTopicReply.delete(oldest);
|
|
@@ -655,8 +754,10 @@ export class FeishuHarnessBridge {
|
|
|
655
754
|
|
|
656
755
|
/** Whether a reply anchored on `replyTo` must ask Feishu to keep it in a topic. */
|
|
657
756
|
#replyInThreadFor(replyTo) {
|
|
658
|
-
|
|
659
|
-
|
|
757
|
+
// Topic membership is decided by the conversation shape alone (see
|
|
758
|
+
// #rememberTopicReply): a manual-topic chat threads its replies even
|
|
759
|
+
// when the 群话题回复 switch is off.
|
|
760
|
+
return nonEmptyString(replyTo)
|
|
660
761
|
&& this.#anchorTopicReply.get(replyTo) === true;
|
|
661
762
|
}
|
|
662
763
|
|
|
@@ -3603,6 +3704,405 @@ export class FeishuHarnessBridge {
|
|
|
3603
3704
|
};
|
|
3604
3705
|
}
|
|
3605
3706
|
|
|
3707
|
+
// ── Step push (分步直推) ──────────────────────────────────────────────────
|
|
3708
|
+
|
|
3709
|
+
/** One-line intent summary built from a tool call's JSON arguments. */
|
|
3710
|
+
#stepIntentSummary(argumentsText) {
|
|
3711
|
+
if (typeof argumentsText !== 'string' || !argumentsText.trim()) return null;
|
|
3712
|
+
let source = null;
|
|
3713
|
+
try {
|
|
3714
|
+
const parsed = JSON.parse(argumentsText);
|
|
3715
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
3716
|
+
for (const field of STEP_PUSH_INTENT_FIELDS) {
|
|
3717
|
+
const value = parsed[field];
|
|
3718
|
+
if (typeof value === 'string' && value.trim()) {
|
|
3719
|
+
source = value;
|
|
3720
|
+
break;
|
|
3721
|
+
}
|
|
3722
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
3723
|
+
source = String(value);
|
|
3724
|
+
break;
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
} catch {
|
|
3729
|
+
source = argumentsText;
|
|
3730
|
+
}
|
|
3731
|
+
if (source === null) return null;
|
|
3732
|
+
return stepPushLine(source).slice(0, STEP_PUSH_SUMMARY_MAX_CHARS) || null;
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3735
|
+
/** Render one tool call as a rich-text post: headline + folded code block. */
|
|
3736
|
+
#formatStepToolPost(update) {
|
|
3737
|
+
const summary = this.#stepIntentSummary(update.arguments);
|
|
3738
|
+
const name = stepPushLine(update.name) || t('工具');
|
|
3739
|
+
const headline = `✅ ${name}${summary ? ` — ${summary}` : ''}`;
|
|
3740
|
+
const detail = typeof update.arguments === 'string' && update.arguments
|
|
3741
|
+
? update.arguments.slice(0, STEP_PUSH_ARGUMENTS_MAX_CHARS)
|
|
3742
|
+
: '';
|
|
3743
|
+
const paragraphs = [[{ tag: 'text', text: headline }]];
|
|
3744
|
+
if (detail) {
|
|
3745
|
+
const language = /^\s*[[{]/.test(detail) ? 'JSON' : undefined;
|
|
3746
|
+
paragraphs.push(language
|
|
3747
|
+
? [{ tag: 'code_block', language, text: detail }]
|
|
3748
|
+
: [{ tag: 'code_block', text: detail }]);
|
|
3749
|
+
}
|
|
3750
|
+
return {
|
|
3751
|
+
paragraphs,
|
|
3752
|
+
fallbackText: detail ? `${headline}\n▸ ${detail}` : headline,
|
|
3753
|
+
};
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
/**
|
|
3757
|
+
* Push one step message through the conversation's serial step queue: the
|
|
3758
|
+
* 250ms minimum interval guards Feishu's 5 QPS per-chat limit, and a turn
|
|
3759
|
+
* past the message cap stays silent (circuit breaker) while the final
|
|
3760
|
+
* answer still follows. Send failures are logged, never thrown — a flaky
|
|
3761
|
+
* step message must never break the turn.
|
|
3762
|
+
*/
|
|
3763
|
+
async #sendStepMessage(chatId, key, paragraphs, fallbackText, replyToMessageId = null, { billable = true } = {}) {
|
|
3764
|
+
const state = this.#stepPushSendState.get(key)
|
|
3765
|
+
?? { lastSentAt: 0, count: 0, breakerLogged: false };
|
|
3766
|
+
this.#stepPushSendState.set(key, state);
|
|
3767
|
+
if (billable && state.count >= STEP_PUSH_MAX_MESSAGES_PER_TURN) {
|
|
3768
|
+
if (!state.breakerLogged) {
|
|
3769
|
+
state.breakerLogged = true;
|
|
3770
|
+
this.#logger.warn?.(
|
|
3771
|
+
`[dsh-feishu] step push hit ${STEP_PUSH_MAX_MESSAGES_PER_TURN} messages for this turn; staying silent until it ends`,
|
|
3772
|
+
);
|
|
3773
|
+
}
|
|
3774
|
+
return;
|
|
3775
|
+
}
|
|
3776
|
+
this.#signal?.throwIfAborted();
|
|
3777
|
+
const wait = state.lastSentAt + STEP_PUSH_MIN_INTERVAL_MS - this.#stepPushClock.now();
|
|
3778
|
+
if (wait > 0) await this.#stepPushClock.delay(wait);
|
|
3779
|
+
// /stop or shutdown during the wait window must not let the queued
|
|
3780
|
+
// message land after the teardown has begun.
|
|
3781
|
+
this.#signal?.throwIfAborted();
|
|
3782
|
+
state.lastSentAt = this.#stepPushClock.now();
|
|
3783
|
+
if (billable) state.count += 1;
|
|
3784
|
+
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
|
|
3785
|
+
await this.#sendStepPost(chatId, replyToMessageId, paragraphs, fallbackText);
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
/**
|
|
3789
|
+
* Rich-text (post) delivery for step messages. Degradation ladder:
|
|
3790
|
+
* post(限流重试 ×2)→ 同话题纯文本回复 → 主界面纯文本。Returns
|
|
3791
|
+
* `{ ok, messageId?, error? }` — callers decide whether a failure is
|
|
3792
|
+
* tolerable (process notes: log and continue) or fatal (final answer:
|
|
3793
|
+
* surface it).
|
|
3794
|
+
*/
|
|
3795
|
+
async #sendStepPost(chatId, replyToMessageId, paragraphs, fallbackText) {
|
|
3796
|
+
const content = JSON.stringify({ zh_cn: { content: paragraphs } });
|
|
3797
|
+
if (!replyToMessageId) {
|
|
3798
|
+
const sentId = await this.#send(chatId, fallbackText);
|
|
3799
|
+
return { ok: true, messageId: typeof sentId === 'string' ? sentId : null };
|
|
3800
|
+
}
|
|
3801
|
+
const replyInThread = this.#replyInThreadFor(replyToMessageId);
|
|
3802
|
+
let lastError = null;
|
|
3803
|
+
// 1) 富文本 post;限流/瞬时失败重试两次(结构化 code 感知)。
|
|
3804
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
3805
|
+
try {
|
|
3806
|
+
const response = await this.#replyStepMessage(
|
|
3807
|
+
chatId, replyToMessageId, 'post', content, replyInThread,
|
|
3808
|
+
);
|
|
3809
|
+
return {
|
|
3810
|
+
ok: true,
|
|
3811
|
+
messageId: nonEmptyString(response?.data?.message_id) ?? null,
|
|
3812
|
+
};
|
|
3813
|
+
} catch (error) {
|
|
3814
|
+
lastError = error;
|
|
3815
|
+
if (attempt < 2 && this.#isRetriableSendError(error)) {
|
|
3816
|
+
await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1)));
|
|
3817
|
+
continue;
|
|
3818
|
+
}
|
|
3819
|
+
break;
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
// 2) 明确降级:仅富文本格式被拒时,话题内纯文本回复仍然可用——
|
|
3823
|
+
// 优先留在话题内,而不是直接漏到主界面。
|
|
3824
|
+
try {
|
|
3825
|
+
const response = await this.#replyStepMessage(
|
|
3826
|
+
chatId, replyToMessageId, 'text', JSON.stringify({ text: fallbackText }), replyInThread,
|
|
3827
|
+
);
|
|
3828
|
+
return {
|
|
3829
|
+
ok: true,
|
|
3830
|
+
messageId: nonEmptyString(response?.data?.message_id) ?? null,
|
|
3831
|
+
};
|
|
3832
|
+
} catch (error) {
|
|
3833
|
+
lastError = lastError ?? error;
|
|
3834
|
+
}
|
|
3835
|
+
// 3) 最后兜底:主界面纯文本。
|
|
3836
|
+
this.#logger.warn?.(
|
|
3837
|
+
'[dsh-feishu] step push post failed; falling back to main chat text:',
|
|
3838
|
+
lastError?.message ?? String(lastError),
|
|
3839
|
+
);
|
|
3840
|
+
try {
|
|
3841
|
+
const sentId = await this.#send(chatId, fallbackText);
|
|
3842
|
+
return { ok: true, messageId: typeof sentId === 'string' ? sentId : null };
|
|
3843
|
+
} catch (fallbackError) {
|
|
3844
|
+
this.#logger.warn?.(
|
|
3845
|
+
'[dsh-feishu] step push send failed:',
|
|
3846
|
+
fallbackError?.message ?? String(fallbackError),
|
|
3847
|
+
);
|
|
3848
|
+
return { ok: false, error: lastError ?? fallbackError };
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
/** Reply with one msg_type; throws errors carrying the structured Feishu code. */
|
|
3853
|
+
async #replyStepMessage(chatId, messageId, msgType, contentString, replyInThread) {
|
|
3854
|
+
const response = await this.#client.im.v1.message.reply({
|
|
3855
|
+
path: { message_id: messageId },
|
|
3856
|
+
data: {
|
|
3857
|
+
msg_type: msgType,
|
|
3858
|
+
content: contentString,
|
|
3859
|
+
...(replyInThread ? { reply_in_thread: true } : {}),
|
|
3860
|
+
},
|
|
3861
|
+
});
|
|
3862
|
+
if (response?.code && response.code !== 0) {
|
|
3863
|
+
const error = new Error(`Feishu reply failed: ${response.msg || response.code}`);
|
|
3864
|
+
// Preserve the structured code: rate-limit detection must not depend on
|
|
3865
|
+
// the human-readable message surviving.
|
|
3866
|
+
error.code = response.code;
|
|
3867
|
+
error.feishuCode = response.code;
|
|
3868
|
+
throw error;
|
|
3869
|
+
}
|
|
3870
|
+
const threadId = nonEmptyString(response?.data?.thread_id);
|
|
3871
|
+
if (threadId) {
|
|
3872
|
+
await this.#registerTopicThreadId(threadId, messageId, chatId);
|
|
3873
|
+
}
|
|
3874
|
+
return response;
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3877
|
+
/** Rate-limit / transient send errors are worth one more attempt. */
|
|
3878
|
+
#isRetriableSendError(error) {
|
|
3879
|
+
const structured = [error?.code, error?.feishuCode, error?.response?.data?.code]
|
|
3880
|
+
.map((value) => String(value ?? ''));
|
|
3881
|
+
return structured.some((code) => code === '230020' || code === '230006')
|
|
3882
|
+
|| /230020|230006/.test(error?.message ?? '');
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
/**
|
|
3886
|
+
* 分步直推:`#answerWithStream` 流式分支在开关开启时的完整替代路径。
|
|
3887
|
+
* 过程(工具调用、助手中间说明)与最终答案均以富文本 post 逐条直推
|
|
3888
|
+
* (工具参数折叠为代码块);post 失败走既有纯文本降级。
|
|
3889
|
+
*/
|
|
3890
|
+
async #answerWithStepPush(event, key, message, { onAskComplete } = {}) {
|
|
3891
|
+
const chatId = event.message.chat_id;
|
|
3892
|
+
const messageId = event.message.message_id;
|
|
3893
|
+
const text = message.content;
|
|
3894
|
+
let askCompleted = false;
|
|
3895
|
+
const markAskComplete = () => {
|
|
3896
|
+
if (askCompleted) return;
|
|
3897
|
+
askCompleted = true;
|
|
3898
|
+
onAskComplete?.();
|
|
3899
|
+
};
|
|
3900
|
+
// 与流式分支一致的提示内容构造:图片与回复引用展开为富提示内容,已接受
|
|
3901
|
+
// 的上下文增强按原样重放。直推分流发生在 `#answerWithStream` 构造之前,
|
|
3902
|
+
// 这里就是本回合唯一一次构造(无重复的 prompt 往返)。
|
|
3903
|
+
let content = hasInboundImages(message) || hasReplyReference(message)
|
|
3904
|
+
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
|
3905
|
+
: undefined;
|
|
3906
|
+
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
3907
|
+
let contextEnhanced = false;
|
|
3908
|
+
if (snapshot) {
|
|
3909
|
+
const originalContent = content ?? text;
|
|
3910
|
+
content = enhanceContextContent(originalContent, snapshot, () => ({
|
|
3911
|
+
channel: 'feishu',
|
|
3912
|
+
senderId: senderOpenId(event),
|
|
3913
|
+
chatId: event.message.chat_id,
|
|
3914
|
+
threadId: event.message.thread_id,
|
|
3915
|
+
}));
|
|
3916
|
+
contextEnhanced = content !== originalContent;
|
|
3917
|
+
}
|
|
3918
|
+
// Every turn reopens the per-conversation circuit breaker.
|
|
3919
|
+
const priorState = this.#stepPushSendState.get(key);
|
|
3920
|
+
if (this.#stepPushSendState.size >= MAX_STEP_PUSH_SEND_STATES && !priorState) {
|
|
3921
|
+
const oldest = this.#stepPushSendState.keys().next().value;
|
|
3922
|
+
if (oldest !== undefined) this.#stepPushSendState.delete(oldest);
|
|
3923
|
+
}
|
|
3924
|
+
this.#stepPushSendState.set(key, {
|
|
3925
|
+
lastSentAt: priorState?.lastSentAt ?? 0,
|
|
3926
|
+
count: 0,
|
|
3927
|
+
breakerLogged: false,
|
|
3928
|
+
});
|
|
3929
|
+
|
|
3930
|
+
// 上下文注入动作在详细级别下也体现为一条步骤(注入细节可忽略)。
|
|
3931
|
+
// 不计入工具/助手消息的熔断计数(规格口径:熔断只管「工具 + 助手」)。
|
|
3932
|
+
if (contextEnhanced || content) {
|
|
3933
|
+
await this.#sendStepMessage(
|
|
3934
|
+
chatId, key,
|
|
3935
|
+
[[{ tag: 'text', text: t('📎 已注入会话上下文') }]],
|
|
3936
|
+
t('📎 已注入会话上下文'),
|
|
3937
|
+
messageId,
|
|
3938
|
+
{ billable: false },
|
|
3939
|
+
);
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
// 缓冲-确认策略:每步定稿的助手文本先进缓冲;工具调用或更大 step 的定稿
|
|
3943
|
+
// 证明它是过程说明时才 flush 为 💬 消息。turn 结束时缓冲内剩余的即最终步
|
|
3944
|
+
// ——不直推,改为最终答案的富文本 post 内容,避免同一句话重复出现。
|
|
3945
|
+
let pendingStep = null;
|
|
3946
|
+
const flushPendingStep = async () => {
|
|
3947
|
+
if (!pendingStep) return;
|
|
3948
|
+
const note = pendingStep;
|
|
3949
|
+
pendingStep = null;
|
|
3950
|
+
await this.#sendStepMessage(
|
|
3951
|
+
chatId, key,
|
|
3952
|
+
[[{ tag: 'md', text: `💬 ${note.text}` }]],
|
|
3953
|
+
`💬 ${note.text}`,
|
|
3954
|
+
messageId,
|
|
3955
|
+
);
|
|
3956
|
+
};
|
|
3957
|
+
const completed = await askInWorkspaceSession({
|
|
3958
|
+
deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
|
|
3959
|
+
harness: this.#harness,
|
|
3960
|
+
state: this.#state,
|
|
3961
|
+
key,
|
|
3962
|
+
text,
|
|
3963
|
+
content,
|
|
3964
|
+
contextEnhanced,
|
|
3965
|
+
createOptions: { signal: this.#signal },
|
|
3966
|
+
existsOptions: { signal: this.#signal },
|
|
3967
|
+
askOptions: {
|
|
3968
|
+
...this.#interactionAskOptions(event, key, message.files),
|
|
3969
|
+
progressMode: 'all',
|
|
3970
|
+
onUpdate: async (update) => {
|
|
3971
|
+
if (update.type === 'assistant-message') {
|
|
3972
|
+
if (pendingStep && Number(update.step) > Number(pendingStep.step)) {
|
|
3973
|
+
await flushPendingStep();
|
|
3974
|
+
}
|
|
3975
|
+
pendingStep = update;
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
if (update.type === 'tool') {
|
|
3979
|
+
await flushPendingStep();
|
|
3980
|
+
const { paragraphs, fallbackText } = this.#formatStepToolPost(update);
|
|
3981
|
+
await this.#sendStepMessage(chatId, key, paragraphs, fallbackText, messageId);
|
|
3982
|
+
return;
|
|
3983
|
+
}
|
|
3984
|
+
if (update.type === 'status' && update.error) {
|
|
3985
|
+
const name = stepPushLine(update.toolName) || t('工具');
|
|
3986
|
+
const excerpt = stepPushLine(update.error).slice(0, STEP_PUSH_ERROR_MAX_CHARS);
|
|
3987
|
+
await this.#sendStepMessage(
|
|
3988
|
+
chatId, key,
|
|
3989
|
+
[[{ tag: 'md', text: `⚠️ **${name}** — ${excerpt}` }]],
|
|
3990
|
+
`⚠️ ${name} — ${excerpt}`,
|
|
3991
|
+
messageId,
|
|
3992
|
+
);
|
|
3993
|
+
}
|
|
3994
|
+
// text / status(无错误)保持静默:详细级直推完全取代简略级进度行。
|
|
3995
|
+
},
|
|
3996
|
+
},
|
|
3997
|
+
});
|
|
3998
|
+
markAskComplete();
|
|
3999
|
+
const finalStepText = pendingStep ? pendingStep.text : null;
|
|
4000
|
+
pendingStep = null;
|
|
4001
|
+
const finalText = (typeof finalStepText === 'string' && finalStepText.trim())
|
|
4002
|
+
? finalStepText
|
|
4003
|
+
: completed.answer;
|
|
4004
|
+
const deliveryText = answerTextForDelivery(finalText, completed.artifacts ?? []);
|
|
4005
|
+
let textReceipt;
|
|
4006
|
+
let postDeliveryError = null;
|
|
4007
|
+
try {
|
|
4008
|
+
// 分步直推模式下最终答案以富文本 post(md 标签,GFM 全语法)推送;
|
|
4009
|
+
// 超长答案按段落边界切分为多条 post(单条 content 上限 30KB)。
|
|
4010
|
+
// 任一分段失败都视为最终答案失败——进入 catch 阶梯上抛,
|
|
4011
|
+
// 决不允许「用户没收到答案、状态却记为已回复」。
|
|
4012
|
+
const providerMessageIds = [];
|
|
4013
|
+
const chunks = splitStepPostMarkdown(deliveryText);
|
|
4014
|
+
let failedChunkIndex = -1;
|
|
4015
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
4016
|
+
const chunk = chunks[index];
|
|
4017
|
+
const result = await this.#sendStepPost(
|
|
4018
|
+
chatId,
|
|
4019
|
+
messageId,
|
|
4020
|
+
[[{ tag: 'md', text: chunk }]],
|
|
4021
|
+
chunk,
|
|
4022
|
+
);
|
|
4023
|
+
if (!result.ok) {
|
|
4024
|
+
postDeliveryError ??= new Error(
|
|
4025
|
+
result.error?.message ?? 'step push post delivery failed',
|
|
4026
|
+
{ cause: result.error },
|
|
4027
|
+
);
|
|
4028
|
+
failedChunkIndex = index;
|
|
4029
|
+
break;
|
|
4030
|
+
}
|
|
4031
|
+
if (result.messageId) providerMessageIds.push(result.messageId);
|
|
4032
|
+
}
|
|
4033
|
+
if (postDeliveryError) {
|
|
4034
|
+
postDeliveryError.failedChunkIndex = failedChunkIndex;
|
|
4035
|
+
postDeliveryError.providerMessageIds = providerMessageIds;
|
|
4036
|
+
postDeliveryError.remainingChunks = chunks.slice(failedChunkIndex);
|
|
4037
|
+
throw postDeliveryError;
|
|
4038
|
+
}
|
|
4039
|
+
textReceipt = createDeliveryReceipt({
|
|
4040
|
+
deliveryId: messageId,
|
|
4041
|
+
presentation: 'feishu-step-push-post',
|
|
4042
|
+
providerMessageIds,
|
|
4043
|
+
});
|
|
4044
|
+
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
|
|
4045
|
+
} catch (error) {
|
|
4046
|
+
this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
|
|
4047
|
+
this.#logger.warn?.('[dsh-feishu] step push post failed; sending final text:', error.message);
|
|
4048
|
+
let textSendError = null;
|
|
4049
|
+
const providerMessageIds = Array.isArray(error?.providerMessageIds)
|
|
4050
|
+
? [...error.providerMessageIds]
|
|
4051
|
+
: [];
|
|
4052
|
+
const remainingChunks = Array.isArray(error?.remainingChunks)
|
|
4053
|
+
? error.remainingChunks
|
|
4054
|
+
: [deliveryText];
|
|
4055
|
+
for (const chunk of remainingChunks) {
|
|
4056
|
+
try {
|
|
4057
|
+
const receipt = await this.#sendAnswerText(chatId, chunk, {
|
|
4058
|
+
deliveryId: messageId,
|
|
4059
|
+
presentation: 'feishu-step-push-text',
|
|
4060
|
+
replyTo: messageId,
|
|
4061
|
+
});
|
|
4062
|
+
providerMessageIds.push(...receipt.providerMessageIds);
|
|
4063
|
+
} catch (fallbackError) {
|
|
4064
|
+
textSendError = channelDeliveryFailure(fallbackError);
|
|
4065
|
+
this.#logger.warn?.(
|
|
4066
|
+
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
|
|
4067
|
+
fallbackError,
|
|
4068
|
+
);
|
|
4069
|
+
break;
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
textReceipt = providerMessageIds.length > 0
|
|
4073
|
+
? createDeliveryReceipt({
|
|
4074
|
+
deliveryId: messageId,
|
|
4075
|
+
presentation: 'feishu-step-push-post-and-text',
|
|
4076
|
+
providerMessageIds,
|
|
4077
|
+
})
|
|
4078
|
+
: undefined;
|
|
4079
|
+
const delivery = await this.#deliverArtifacts(
|
|
4080
|
+
chatId,
|
|
4081
|
+
messageId,
|
|
4082
|
+
completed.artifacts ?? [],
|
|
4083
|
+
textReceipt,
|
|
4084
|
+
);
|
|
4085
|
+
const artifactDispatched = delivery.receipt.artifacts.some(
|
|
4086
|
+
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
|
4087
|
+
);
|
|
4088
|
+
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
|
4089
|
+
throw textSendError;
|
|
4090
|
+
}
|
|
4091
|
+
if (textSendError && delivery.artifactSendErrors === 0) {
|
|
4092
|
+
setLastMessageFailure(this.#status, textSendError);
|
|
4093
|
+
}
|
|
4094
|
+
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
|
4095
|
+
return { ...delivery, textDeliveryErrors: textSendError ? 1 : 0 };
|
|
4096
|
+
}
|
|
4097
|
+
const delivery = await this.#deliverArtifacts(
|
|
4098
|
+
chatId,
|
|
4099
|
+
messageId,
|
|
4100
|
+
completed.artifacts ?? [],
|
|
4101
|
+
textReceipt,
|
|
4102
|
+
);
|
|
4103
|
+
return { ...delivery, textDeliveryErrors: 0 };
|
|
4104
|
+
}
|
|
4105
|
+
|
|
3606
4106
|
async #answerWithStream(event, key, message, { onAskComplete } = {}) {
|
|
3607
4107
|
const chatId = event.message.chat_id;
|
|
3608
4108
|
const messageId = event.message.message_id;
|
|
@@ -3613,6 +4113,13 @@ export class FeishuHarnessBridge {
|
|
|
3613
4113
|
askCompleted = true;
|
|
3614
4114
|
onAskComplete?.();
|
|
3615
4115
|
};
|
|
4116
|
+
// 分步直推:开关开启且通道支持流式卡时,在构造提示内容之前分流到完整替
|
|
4117
|
+
// 代路径(`#answerWithStepPush` 自行构造一次,回复引用回合不做第二次
|
|
4118
|
+
// promptContentForInboundMessage 往返);关闭或无流式卡通道时与 main
|
|
4119
|
+
// 零差异(含下方 `!this.#channel?.stream` 纯文本路径)。
|
|
4120
|
+
if (this.#stepPush && this.#channel?.stream) {
|
|
4121
|
+
return this.#answerWithStepPush(event, key, message, { onAskComplete });
|
|
4122
|
+
}
|
|
3616
4123
|
let content = hasInboundImages(message) || hasReplyReference(message)
|
|
3617
4124
|
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
|
3618
4125
|
: undefined;
|
|
@@ -111,6 +111,7 @@ export class FeishuRuntime {
|
|
|
111
111
|
#botOpenId;
|
|
112
112
|
#groupResponseMode;
|
|
113
113
|
#groupTopicReply;
|
|
114
|
+
#stepPush;
|
|
114
115
|
#ownerOpenIds;
|
|
115
116
|
#harness;
|
|
116
117
|
#state;
|
|
@@ -141,6 +142,7 @@ export class FeishuRuntime {
|
|
|
141
142
|
botOpenId,
|
|
142
143
|
groupResponseMode,
|
|
143
144
|
groupTopicReply = false,
|
|
145
|
+
stepPush = false,
|
|
144
146
|
ownerOpenId,
|
|
145
147
|
ownerOpenIds,
|
|
146
148
|
harness,
|
|
@@ -177,6 +179,7 @@ export class FeishuRuntime {
|
|
|
177
179
|
this.#botOpenId = nonEmptyString(botOpenId);
|
|
178
180
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
|
|
179
181
|
this.#groupTopicReply = groupTopicReply === true;
|
|
182
|
+
this.#stepPush = stepPush === true;
|
|
180
183
|
this.#ownerOpenIds = normalizedOwners;
|
|
181
184
|
this.#harness = harness;
|
|
182
185
|
this.#state = state;
|
|
@@ -206,6 +209,11 @@ export class FeishuRuntime {
|
|
|
206
209
|
this.#bridge?.setGroupTopicReply(this.#groupTopicReply);
|
|
207
210
|
}
|
|
208
211
|
|
|
212
|
+
setStepPush(value) {
|
|
213
|
+
this.#stepPush = value === true;
|
|
214
|
+
this.#bridge?.setStepPush(this.#stepPush);
|
|
215
|
+
}
|
|
216
|
+
|
|
209
217
|
async start() {
|
|
210
218
|
while (true) {
|
|
211
219
|
while (this.#stopping) await this.#stopping;
|
|
@@ -295,6 +303,7 @@ export class FeishuRuntime {
|
|
|
295
303
|
botOpenId: this.#botOpenId,
|
|
296
304
|
groupResponseMode: this.#groupResponseMode,
|
|
297
305
|
groupTopicReply: this.#groupTopicReply,
|
|
306
|
+
stepPush: this.#stepPush,
|
|
298
307
|
repair: this.#repair,
|
|
299
308
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
300
309
|
// Interaction cards (approval/question buttons) are on by default.
|
|
@@ -91,6 +91,7 @@ function configuredBotFingerprint(config) {
|
|
|
91
91
|
activated: config.activated,
|
|
92
92
|
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
|
93
93
|
groupTopicReply: config.groupTopicReply === true,
|
|
94
|
+
stepPush: config.stepPush === true,
|
|
94
95
|
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
|
95
96
|
deletionPending: config.deletionPending === true,
|
|
96
97
|
connectedAt: config.connectedAt ?? null,
|
|
@@ -599,6 +600,20 @@ export class MultiBotDshFeishuController {
|
|
|
599
600
|
}));
|
|
600
601
|
}
|
|
601
602
|
|
|
603
|
+
async updateStepPush(botId, stepPush) {
|
|
604
|
+
this.#assertOpen();
|
|
605
|
+
if (typeof stepPush !== 'boolean') {
|
|
606
|
+
throw new TypeError('Invalid Feishu step push flag');
|
|
607
|
+
}
|
|
608
|
+
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
|
609
|
+
const config = this.#requireBot(botId);
|
|
610
|
+
const saved = await this.#configStore.saveBot({ ...config, stepPush });
|
|
611
|
+
this.#runtimes.get(botId)?.setStepPush?.(saved.stepPush);
|
|
612
|
+
this.#touch();
|
|
613
|
+
return this.status(botId);
|
|
614
|
+
}));
|
|
615
|
+
}
|
|
616
|
+
|
|
602
617
|
async deleteBot(botId) {
|
|
603
618
|
this.#assertOpen();
|
|
604
619
|
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
|
@@ -662,6 +677,7 @@ export class MultiBotDshFeishuController {
|
|
|
662
677
|
configured: true,
|
|
663
678
|
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
|
664
679
|
groupTopicReply: config.groupTopicReply === true,
|
|
680
|
+
stepPush: config.stepPush === true,
|
|
665
681
|
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
|
666
682
|
bot: publicBot(config),
|
|
667
683
|
connection,
|
|
@@ -46,6 +46,7 @@ function normalizeBot(value, { legacy = false } = {}) {
|
|
|
46
46
|
activated: value.activated ?? null,
|
|
47
47
|
groupResponseMode: normalizeFeishuGroupResponseMode(value.groupResponseMode),
|
|
48
48
|
groupTopicReply: value.groupTopicReply === true,
|
|
49
|
+
stepPush: value.stepPush === true,
|
|
49
50
|
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
|
|
50
51
|
deletionPending: value.deletionPending === true,
|
|
51
52
|
connectedAt: cleanString(value.connectedAt),
|