mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-18 06:13:19 +08:00
feat(feishu): add stream card rotation to keep answers below mid-turn interactions
This commit is contained in:
parent
24f4a1e05b
commit
bf03b7e696
3 changed files with 97 additions and 6 deletions
|
|
@ -205,15 +205,45 @@ export class VerifiedFeishuChannel {
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards = [];
|
const cards = [];
|
||||||
|
let activeCard = null;
|
||||||
|
let rotating = false;
|
||||||
try {
|
try {
|
||||||
const firstCard = await this.#createStreamCard(chatId, options.replyTo);
|
activeCard = await this.#createStreamCard(chatId, options.replyTo);
|
||||||
cards.push(firstCard);
|
cards.push(activeCard);
|
||||||
let lastContent = this.#initialText;
|
let lastContent = this.#initialText;
|
||||||
|
// issue #86:独立交互消息(提问/审批)落在占位卡下方后,最终答案不得
|
||||||
|
// 回写旧卡。rotate() 把旧卡定格为「过程记录 + 指引行」并标记换卡态;
|
||||||
|
// 下一次 setContent(过程更新或最终答案)才创建新卡——新卡必然创建于
|
||||||
|
// 交互消息之后。旧卡纳入 cards,参与 recall 与 providerMessageIds。
|
||||||
|
const ensureActiveCard = async () => {
|
||||||
|
if (!rotating) return activeCard;
|
||||||
|
activeCard = await this.#createStreamCard(chatId, options.replyTo);
|
||||||
|
cards.push(activeCard);
|
||||||
|
rotating = false;
|
||||||
|
return activeCard;
|
||||||
|
};
|
||||||
const controller = {
|
const controller = {
|
||||||
messageId: firstCard.messageId,
|
get messageId() {
|
||||||
|
return activeCard.messageId;
|
||||||
|
},
|
||||||
|
rotate: async () => {
|
||||||
|
if (rotating) return;
|
||||||
|
rotating = true;
|
||||||
|
try {
|
||||||
|
await this.#updateStreamCard(
|
||||||
|
activeCard,
|
||||||
|
`${streamPreview(lastContent)}\n\n${t('⤵️ 最终结果见下方')}`,
|
||||||
|
);
|
||||||
|
await this.#finishStreamCard(activeCard);
|
||||||
|
} catch (error) {
|
||||||
|
// 明确降级:定格失败不阻塞交互呈现,旧卡保留原内容。
|
||||||
|
console.warn('[dsh-feishu] unable to finalize the superseded stream card:', error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
setContent: async (content) => {
|
setContent: async (content) => {
|
||||||
const next = String(content ?? '') || '…';
|
const next = String(content ?? '') || '…';
|
||||||
await this.#updateStreamCard(firstCard, streamPreview(next));
|
const card = await ensureActiveCard();
|
||||||
|
await this.#updateStreamCard(card, streamPreview(next));
|
||||||
// Updates are replaceable snapshots, including progress/tool text.
|
// Updates are replaceable snapshots, including progress/tool text.
|
||||||
// Retain the full latest snapshot even when its preview is unchanged.
|
// Retain the full latest snapshot even when its preview is unchanged.
|
||||||
lastContent = next;
|
lastContent = next;
|
||||||
|
|
@ -224,14 +254,14 @@ export class VerifiedFeishuChannel {
|
||||||
const chunks = splitStreamContent(lastContent);
|
const chunks = splitStreamContent(lastContent);
|
||||||
for (const [index, chunk] of chunks.entries()) {
|
for (const [index, chunk] of chunks.entries()) {
|
||||||
const card = index === 0
|
const card = index === 0
|
||||||
? firstCard
|
? await ensureActiveCard()
|
||||||
: await this.#createStreamCard(chatId, options.replyTo);
|
: await this.#createStreamCard(chatId, options.replyTo);
|
||||||
if (index > 0) cards.push(card);
|
if (index > 0) cards.push(card);
|
||||||
await this.#updateStreamCard(card, chunk);
|
await this.#updateStreamCard(card, chunk);
|
||||||
await this.#finishStreamCard(card);
|
await this.#finishStreamCard(card);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
messageId: firstCard.messageId,
|
messageId: cards[0].messageId,
|
||||||
providerMessageIds: cards.map((card) => card.messageId),
|
providerMessageIds: cards.map((card) => card.messageId),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,7 @@ export default {
|
||||||
|
|
||||||
// feishu/feishu-channel.mjs
|
// feishu/feishu-channel.mjs
|
||||||
'正在生成…': 'Generating…',
|
'正在生成…': 'Generating…',
|
||||||
|
'⤵️ 最终结果见下方': '⤵️ Final result below',
|
||||||
'回答完成': 'Answer complete',
|
'回答完成': 'Answer complete',
|
||||||
'内容较长,生成完成后将分段发送完整回答。':
|
'内容较长,生成完成后将分段发送完整回答。':
|
||||||
'This response is long. The complete answer will be sent in parts when generation finishes.',
|
'This response is long. The complete answer will be sent in parts when generation finishes.',
|
||||||
|
|
|
||||||
|
|
@ -713,3 +713,63 @@ test('VerifiedFeishuChannel recognizes the SDK array-shaped permission error', a
|
||||||
&& !error.message.includes('provider permission URL'),
|
&& !error.message.includes('provider permission URL'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rotate() finalizes the old card and carries the final answer into a new card', async () => {
|
||||||
|
const { client, calls } = fakeClient();
|
||||||
|
const channel = new VerifiedFeishuChannel({ client, initialText: '正在思考…' });
|
||||||
|
const result = await channel.stream('oc_chat', {
|
||||||
|
markdown: async (controller) => {
|
||||||
|
await controller.setContent('第一步进行中');
|
||||||
|
await controller.rotate();
|
||||||
|
// 此间隙 bridge 发出独立交互消息(此处不需要模拟)
|
||||||
|
await controller.setContent('最终回答');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(result.providerMessageIds, ['om-stream', 'om-stream-2']);
|
||||||
|
const card1 = calls.updates.filter((u) => u.path.card_id === 'card-test');
|
||||||
|
assert.ok(card1.at(-1).data.content.includes('最终结果见下方'), 'old card must carry the pointer notice');
|
||||||
|
const card2 = calls.updates.filter((u) => u.path.card_id === 'card-test-2');
|
||||||
|
assert.ok(card2.at(-1).data.content.includes('最终回答'), 'new card must carry the final answer');
|
||||||
|
assert.equal(calls.settings.length, 2, 'both cards must be finished');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotate() degrades gracefully when finalizing the old card fails', async () => {
|
||||||
|
let cardTestUpdates = 0;
|
||||||
|
const { client, calls } = fakeClient({
|
||||||
|
updateContent: async (request) => {
|
||||||
|
if (request.path.card_id === 'card-test') {
|
||||||
|
cardTestUpdates += 1;
|
||||||
|
if (cardTestUpdates === 2) throw new Error('transient finalize failure');
|
||||||
|
}
|
||||||
|
calls.updates.push(request);
|
||||||
|
return { code: 0 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const channel = new VerifiedFeishuChannel({ client, initialText: '正在思考…' });
|
||||||
|
const result = await channel.stream('oc_chat', {
|
||||||
|
markdown: async (controller) => {
|
||||||
|
await controller.setContent('第一步进行中');
|
||||||
|
await controller.rotate();
|
||||||
|
await controller.setContent('最终回答');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(result.providerMessageIds, ['om-stream', 'om-stream-2']);
|
||||||
|
const card2 = calls.updates.filter((u) => u.path.card_id === 'card-test-2');
|
||||||
|
assert.ok(card2.at(-1).data.content.includes('最终回答'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotate() keeps oversized chunked delivery on the rotated card chain', async () => {
|
||||||
|
const { client, calls } = fakeClient();
|
||||||
|
const channel = new VerifiedFeishuChannel({ client, initialText: '正在思考…' });
|
||||||
|
const huge = `${'A'.repeat(27990)}\nB`.repeat(2);
|
||||||
|
const result = await channel.stream('oc_chat', {
|
||||||
|
markdown: async (controller) => {
|
||||||
|
await controller.setContent('过程');
|
||||||
|
await controller.rotate();
|
||||||
|
await controller.setContent(huge);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(result.providerMessageIds.length >= 3, true);
|
||||||
|
const settingsByCard = calls.settings.map((s) => s.path.card_id);
|
||||||
|
assert.deepEqual([...new Set(settingsByCard)].length, settingsByCard.length, 'each card finishes exactly once');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue