From e338b2f7a3684e6460f6efc3b0e2a63bde2eb257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 11 Apr 2026 22:37:39 +0800 Subject: [PATCH] fix(feishu): stream reasoning + tool steps live, drop them on finalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feishu cards used to sit on "正在思考中..." for the entire turn and then dump everything at message_complete, while Telegram showed the reasoning and tool calls progressively. Two underlying causes: 1. handleServerMessage ignored `thinking`, `content_start{tool_use}` and `tool_use_complete` entirely, so nothing was fed into the card between turns of text output. 2. StreamingCard.performFlush silently set `cardKitStreamActive=false` on any non-rate-limit error, after which all middle frames were skipped and only finalize touched the card — exactly the "long wait → all at once" symptom. StreamingCard now tracks accumulatedReasoningText and a toolSteps list, with appendReasoning / startTool / completeTool methods that trigger throttled flushes like appendText. renderedText composes tools → reasoning → answer in plain markdown (no blockquotes / lists that historically tripped Feishu's parser). consecutiveStreamFailures threshold (3) keeps a single transient error from killing the stream. terminalText() is used by finalize so the final card holds only the answer text, matching the Desktop UI's clean post-completion view. handleServerMessage wires the new events to the existing per-chat StreamingCard via the streamingCards map (read-only — never auto-creates cards from thinking/tool events to avoid empty cards for /clear-style commands). Test coverage: realistic event-stream regression (thinking → tool_use → text → finalize), first-frame-failure recovery, finalize drops intermediate state, plus per-method tests for the new APIs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../feishu/__tests__/streaming-card.test.ts | 401 ++++++++++++++++++ adapters/feishu/index.ts | 28 +- adapters/feishu/streaming-card.ts | 190 ++++++++- 3 files changed, 602 insertions(+), 17 deletions(-) diff --git a/adapters/feishu/__tests__/streaming-card.test.ts b/adapters/feishu/__tests__/streaming-card.test.ts index 9a44cd76..1e40c388 100644 --- a/adapters/feishu/__tests__/streaming-card.test.ts +++ b/adapters/feishu/__tests__/streaming-card.test.ts @@ -407,6 +407,66 @@ describe('StreamingCard: finalize', () => { expect(sc._getPhase()).toBe('completed') }) + it('finalize 只保留 answerText,丢弃 reasoning + toolSteps', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_term' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + // 同时塞入三种内容 + sc.appendReasoning('Let me think about this problem carefully...') + sc.startTool('tu_1', 'Read') + sc.completeTool('tu_1', 'Read') + sc.appendText('## 答复\n\n这是最终答复正文。') + await sleep(150) + + // 流式中间帧应该包含 reasoning + tools + answer 全套 + const lastMidFrame = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + expect(lastMidFrame).toContain('思考中') + expect(lastMidFrame).toContain('Read') + expect(lastMidFrame).toContain('最终答复正文') + + await sc.finalize() + + // finalize 用的是 card.update,把整张卡换成只有 answer 的版本 + const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()! + const finalCardJson = JSON.parse(updateCall.args.data.card.data) + const finalContent = finalCardJson.body.elements[0].content as string + + expect(finalContent).toContain('最终答复正文') + // H2 → 降级 H5 + expect(finalContent).toContain('##### 答复') + // reasoning + tools 都不应该出现在终态 + expect(finalContent).not.toContain('思考中') + expect(finalContent).not.toContain('think about this problem') + expect(finalContent).not.toContain('Read') + expect(finalContent).not.toContain('🛠️') + expect(finalContent).not.toContain('💭') + }) + + it('finalize 边界: 没有 answerText 时退到组合渲染(保留推理)', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_no_answer' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + // 只有推理,没有 appendText —— 异常 case 但要可控降级 + sc.appendReasoning('I was thinking but never produced an answer.') + await sleep(150) + + await sc.finalize() + const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()! + const finalContent = JSON.parse(updateCall.args.data.card.data).body.elements[0].content as string + // 至少能看到推理内容 + expect(finalContent).toContain('thinking') + }) + it('finalize 失败不抛出', async () => { const { client } = makeMockClient({ 'card.create': { code: 0, data: { card_id: 'ck' } }, @@ -521,3 +581,344 @@ describe('StreamingCard: abort', () => { expect(sc._getPhase()).toBe('aborted') }) }) + +// --------------------------------------------------------------------------- +// Reasoning / tool use rendering +// --------------------------------------------------------------------------- + +describe('StreamingCard: appendReasoning', () => { + it('累积 thinking delta 并渲染在卡片中(plain markdown,不用 blockquote)', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_think' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.appendReasoning('Analyzing the problem. ') + sc.appendReasoning('Let me check file A.') + await sleep(150) + + const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + expect(contentCalls.length).toBeGreaterThan(0) + const last = contentCalls[contentCalls.length - 1]! + expect(last.args.data.content).toContain('💭') + expect(last.args.data.content).toContain('思考中') + expect(last.args.data.content).toContain('Analyzing the problem.') + expect(last.args.data.content).toContain('Let me check file A.') + // 没有 blockquote `>` 前缀 —— 这是新格式的关键 + expect(last.args.data.content).not.toContain('> Analyzing') + // 没有 appendText → 不应有普通正文 + expect(sc._getAccumulatedReasoning()).toContain('Analyzing') + expect(sc._getAccumulatedText()).toBe('') + }) + + it('completed 之后 appendReasoning 被忽略', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + await sc.finalize() + sc.appendReasoning('too late') + expect(sc._getAccumulatedReasoning()).toBe('') + }) +}) + +describe('StreamingCard: startTool / completeTool', () => { + it('startTool 压入 running 步骤,completeTool 翻到 done', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_tool' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.startTool('tu_1', 'Read') + await sleep(150) + let steps = sc._getToolSteps() + expect(steps.length).toBe(1) + expect(steps[0]!.name).toBe('Read') + expect(steps[0]!.status).toBe('running') + + // 卡片也应显示 "🛠️ ⚙️ Read"(inline 形式) + const runningContent = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .map((c) => c.args.data.content) + .join('\n') + expect(runningContent).toContain('⚙️') + expect(runningContent).toContain('Read') + expect(runningContent).toContain('🛠️') + + sc.completeTool('tu_1', 'Read') + await sleep(150) + steps = sc._getToolSteps() + expect(steps[0]!.status).toBe('done') + + // 最新 flush 应显示 "✅ Read" 不再有 "⚙️" + const lastContent = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + expect(lastContent).toContain('✅') + expect(lastContent).toContain('Read') + // 这一行整体换成了 `✅ Read`,不该再出现 ⚙️ 图标 + expect(lastContent).not.toContain('⚙️') + }) + + it('按 toolUseId 去重: 同一 id 不重复压入', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.startTool('tu_1', 'Read') + sc.startTool('tu_1', 'Read') + sc.startTool('tu_1', 'Read') + expect(sc._getToolSteps().length).toBe(1) + }) + + it('缺省 toolUseId 时按 name + index 合成 id,不同步骤可并存', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.startTool(undefined, 'Read') + sc.startTool(undefined, 'Read') + // 合成 id 不同 → 两个独立步骤 + expect(sc._getToolSteps().length).toBe(2) + }) + + it('completeTool 只匹配最近的 running 同名步骤', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.startTool('tu_1', 'Bash') + sc.startTool('tu_2', 'Bash') + sc.completeTool(undefined, 'Bash') + const steps = sc._getToolSteps() + // 更晚的 tu_2 被标记 done + expect(steps[0]!.status).toBe('running') + expect(steps[1]!.status).toBe('done') + }) + + it('空 toolName 忽略', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.startTool('tu_1', undefined) + sc.startTool('tu_1', '') + expect(sc._getToolSteps().length).toBe(0) + }) +}) + +// 复刻用户的真实场景: 用户发消息 → 服务端 thinking → tool_use → 最终 text。 +// 验证每个阶段都向 cardElement.content 写入了对应内容(不被 throttle / phase +// gate / 等任何东西吃掉)。 +describe('StreamingCard: 真实事件流(用户场景回归)', () => { + it('thinking → tool_use → text 应该在每个阶段都触发可见的 flush', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_real' } }, + 'im.message.create': { data: { message_id: 'om_real' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'oc_real' }) + + // 1. 用户发消息 → handleMessage 预建卡(fire-and-forget) + const creating = sc.ensureCreated() + await creating // 等卡可写 + + // 2. 服务端: status streaming + content_start{text} (thinking block) + // feishu/index.ts 的 content_start text 分支会再 await ensureCreated(no-op) + // (no direct call here — 等同于 no-op) + + // 3. 服务端: thinking deltas(5 个增量,间隔 30ms 模拟流式) + sc.appendReasoning('Analyzing the latest commits to find ') + await sleep(30) + sc.appendReasoning('breaking changes. Need to look at ') + await sleep(30) + sc.appendReasoning('the public API surface, the schema files, ') + await sleep(30) + sc.appendReasoning('and any removed exports. Let me check the ') + await sleep(30) + sc.appendReasoning('git log first.') + + // 等节流窗口结束 + await sleep(200) + + const flushesAfterReasoning = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content').length + expect(flushesAfterReasoning).toBeGreaterThan(0) + + const lastReasoningContent = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + // 应该包含 reasoning 累积内容 + expect(lastReasoningContent).toContain('breaking changes') + expect(lastReasoningContent).toContain('git log first') + + // 4. 服务端: content_start{tool_use, name: 'Bash'} + sc.startTool('tu_bash_1', 'Bash') + await sleep(150) + + const lastWithTool = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + expect(lastWithTool).toContain('Bash') + expect(lastWithTool).toContain('⚙️') + expect(lastWithTool).toContain('🛠️') + + // 5. 服务端: tool_use_complete + sc.completeTool('tu_bash_1', 'Bash') + await sleep(150) + + const lastAfterToolDone = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + expect(lastAfterToolDone).toContain('Bash') + // ⚙️ 切到 ✅ —— 当前唯一一步已完成 + expect(lastAfterToolDone).toContain('✅') + expect(lastAfterToolDone).not.toContain('⚙️') + + // 6. 第二个 tool 序列 + sc.startTool('tu_read_1', 'Read') + await sleep(150) + sc.completeTool('tu_read_1', 'Read') + await sleep(150) + + // 7. 最终 text 输出 + sc.appendText('## 破坏性变更分析\n\n') + await sleep(120) + sc.appendText('1. **API 重命名**: foo → bar\n') + await sleep(120) + sc.appendText('2. **删除导出**: baz') + await sleep(200) + + const lastWithText = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + // 应该同时包含 reasoning, tools, answer + expect(lastWithText).toContain('git log first') // reasoning + expect(lastWithText).toContain('Bash') // tool + expect(lastWithText).toContain('Read') // tool + expect(lastWithText).toContain('破坏性变更分析') // answer (post optimize: H2→H5) + expect(lastWithText).toContain('API 重命名') + + // 8. message_complete → finalize + await sc.finalize() + expect(sc._getPhase()).toBe('completed') + + // 验证有 settings + update 收尾 + expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true) + expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true) + }) + + it('cardKit 流式中第一帧失败不应永久禁用流式 —— 后续帧应能继续', async () => { + let firstFrameRejected = false + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_recover' } }, + 'im.message.create': { data: { message_id: 'om' } }, + 'cardElement.content': () => { + if (!firstFrameRejected) { + firstFrameRejected = true + // 模拟一个 *非* rate-limit、*非* table-limit 错误 + // 当前实现会把 cardKitStreamActive 设 false,本测试就是要发现这个问题 + const err: any = new Error('mystery cardkit error') + err.code = 999999 + throw err + } + return { code: 0 } + }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.appendReasoning('first thought') + await sleep(150) + // 此时第一帧已被拒,但我们期望流式仍然开着 —— 这样第二帧能继续 + sc.appendReasoning(' second thought') + await sleep(150) + // 验证: 至少尝试了 2 次 cardElement.content 调用 + const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + expect(contentCalls.length).toBeGreaterThanOrEqual(2) + // 而且 streaming 仍是 active + expect(sc._isCardKitStreamActive()).toBe(true) + }) +}) + +describe('StreamingCard: 组合渲染 (tools + reasoning + text)', () => { + it('三个 section 按顺序 tools → reasoning → answer 组合', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_all' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.appendReasoning('Should I read file A first?') + sc.startTool('tu_1', 'Read') + sc.appendText('Here is the answer.') + await sleep(150) + + const lastContent = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + + const idxTools = lastContent.indexOf('🛠️') + const idxReasoning = lastContent.indexOf('思考中') + const idxAnswer = lastContent.indexOf('Here is the answer') + + expect(idxTools).toBeGreaterThan(-1) + expect(idxReasoning).toBeGreaterThan(-1) + expect(idxAnswer).toBeGreaterThan(-1) + // tools 在最顶部 → reasoning 居中 → answer 在底部 + expect(idxTools).toBeLessThan(idxReasoning) + expect(idxReasoning).toBeLessThan(idxAnswer) + }) + + it('ensureCreated 期间到达的 tool_use 在卡可写后立即 flush', async () => { + let resolveCreate: (() => void) | null = null + const createLatch = new Promise((r) => { resolveCreate = r }) + + const { client, calls } = makeMockClient({ + 'card.create': async () => { + await createLatch + return { code: 0, data: { card_id: 'ck_slow' } } + }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + + // 不 await: 在 create 还没 resolve 之前,先压入一个 tool step + const creating = sc.ensureCreated() + // 让事件循环推进到 create 被 await + await sleep(10) + sc.startTool('tu_1', 'Glob') + + // 此时 cardMessageReady 仍是 false —— 没有任何 flush + const contentBefore = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + expect(contentBefore.length).toBe(0) + + // 解锁 create → ensureCreated 继续 → setCardMessageReady(true) → 触发 pending flush + resolveCreate!() + await creating + await sleep(150) + + const contentAfter = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + expect(contentAfter.length).toBeGreaterThan(0) + const last = contentAfter[contentAfter.length - 1]! + expect(last.args.data.content).toContain('Glob') + expect(last.args.data.content).toContain('🛠️') + }) +}) diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index fdced63e..a99b7852 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -782,6 +782,14 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< await card.ensureCreated().catch((err) => { console.error('[Feishu] ensureCreated on content_start failed:', err) }) + } else if (msg.blockType === 'tool_use') { + // 把工具调用起点登记到已存在的卡 —— 让用户看到 "⚙️ 运行中..." 指示。 + // 只读 map,不 getOrCreate: /clear 这类无回复命令不应该因为上游发了 + // 孤立的 tool_use 事件而被迫建一张空卡。 + const card = streamingCards.get(chatId) + if (card) { + card.startTool(msg.toolUseId, msg.toolName) + } } // 注意: tool_use 不 finalize 当前卡。让整个 turn 的所有文本输出 // 合并到同一张卡里 —— 更接近 Desktop UI 的一体化答复体验,也避免 @@ -813,13 +821,25 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< break } - case 'thinking': - // 推理文本(reasoning),当前版本不单独渲染,等以后加 collapsible panel + case 'thinking': { + // 推理文本(reasoning)—— 作为卡片顶部的 blockquote 预览持续更新, + // 让用户在工具执行期间也能看到模型的思考过程(对齐 Telegram 的行为)。 + // 同样不 auto-create: 没有预建卡的命令路径不应该被 thinking 事件撑出一张空卡。 + const card = streamingCards.get(chatId) + if (card && typeof msg.text === 'string' && msg.text) { + card.appendReasoning(msg.text) + } break + } - case 'tool_use_complete': - // Tool details are noise for IM users; visible in Desktop if needed. + case 'tool_use_complete': { + // 把对应 tool step 从 "⚙️ running" 切到 "✅ done",让用户看到进度推进。 + const card = streamingCards.get(chatId) + if (card) { + card.completeTool(msg.toolUseId, msg.toolName) + } break + } case 'tool_result': // Tool errors are handled internally by the AI (retries etc.) diff --git a/adapters/feishu/streaming-card.ts b/adapters/feishu/streaming-card.ts index bb411aa7..5f625318 100644 --- a/adapters/feishu/streaming-card.ts +++ b/adapters/feishu/streaming-card.ts @@ -110,6 +110,15 @@ export function buildErrorCard(message: string): Record { } } +/** 从末尾截取最多 maxLen 个字符;超过时前缀 "..." 保留最新 maxLen-3 个字。 + * + * 思考内容往往是"先分析 → 得出结论"的线性过程,截取末尾比截取开头更有用 —— 用户 + * 最关心的是"模型现在在想什么",不是"五千个 token 前在想什么"。 */ +function truncateReasoningPreview(text: string, maxLen: number): string { + if (text.length <= maxLen) return text + return '...' + text.slice(text.length - maxLen + 3) +} + // --------------------------------------------------------------------------- // State machine // --------------------------------------------------------------------------- @@ -128,6 +137,22 @@ export type StreamingCardDeps = { replyToMessageId?: string } +/** One entry in the tool-use trace displayed above the answer text. */ +type ToolStep = { + /** Prefer toolUseId for dedup; fall back to a synthetic id when missing. */ + id: string + name: string + status: 'running' | 'done' +} + +/** 最多保留的 reasoning 预览字符数,超过则取末尾 + 省略号前缀。 */ +const REASONING_PREVIEW_CHARS = 600 + +/** 连续 streamCardContent 失败多少次后才放弃 CardKit 流式。 + * 设成 3 而不是 1,是为了避免单次抖动(网络、临时校验失败等)把整张卡片 + * 冻结到 finalize —— 用户看到的就是 "long wait → 一次性 dump"。 */ +const STREAM_FAIL_DISABLE_THRESHOLD = 3 + export class StreamingCard { // ---- lifecycle state ---- private phase: StreamingCardPhase = 'idle' @@ -139,13 +164,19 @@ export class StreamingCard { private messageId: string | null = null /** CardKit cardElement.content() 单调递增序列号。 */ private sequence = 0 - /** CardKit 流式还在工作。230099 之后置为 false,中间帧将跳过, - * 最终 finalize 仍会尝试 settings+update(cardId 仍然有效)。 */ + /** CardKit 流式还在工作。230099 或连续 N 次未知错误之后置为 false, + * 中间帧将跳过,最终 finalize 仍会尝试 settings+update(cardId 仍然有效)。 */ private cardKitStreamActive = false + /** 连续 streamCardContent 未知错误计数。一次成功就清零。 */ + private consecutiveStreamFailures = 0 // ---- text state ---- private accumulatedText = '' private lastFlushedText = '' + /** 累积 thinking_delta,渲染为卡片顶部的推理预览 blockquote。 */ + private accumulatedReasoningText = '' + /** 工具调用轨迹:按 startTool 调用顺序排列,completeTool 改其 status。 */ + private toolSteps: ToolStep[] = [] // ---- flush ---- private flushController: FlushController @@ -219,8 +250,10 @@ export class StreamingCard { } } - // 卡片可写之后若已有 buffered 文本,立刻触发一次 flush - if (this.accumulatedText.length > 0) { + // 卡片可写之后若已有 buffered 内容(text / reasoning / tools), + // 立刻触发一次 flush —— 否则 content_start{tool_use} 或 thinking 在 + // ensureCreated 期间到达的状态会一直卡在节流 gate 上,用户看不到。 + if (this.hasAnyContent()) { void this.flushController.throttledUpdate(this.currentThrottle()) } } @@ -233,6 +266,55 @@ export class StreamingCard { void this.flushController.throttledUpdate(this.currentThrottle()) } + /** 追加 reasoning/thinking delta —— 与 appendText 并列,渲染为顶部预览。 */ + appendReasoning(delta: string): void { + if (!delta) return + if (this.phase === 'completed' || this.phase === 'aborted') return + this.accumulatedReasoningText += delta + void this.flushController.throttledUpdate(this.currentThrottle()) + } + + /** 记录一次 tool_use 开始。dedupe 按 toolUseId(缺省时按 name+index)。 */ + startTool(toolUseId: string | undefined, toolName: string | undefined): void { + if (this.phase === 'completed' || this.phase === 'aborted') return + if (!toolName) return + const id = toolUseId || `${toolName}#${this.toolSteps.length}` + if (this.toolSteps.some((s) => s.id === id)) return + this.toolSteps.push({ id, name: toolName, status: 'running' }) + void this.flushController.throttledUpdate(this.currentThrottle()) + } + + /** 把指定 tool 的状态从 running 切到 done。先按 id 匹配,再 fallback name。 */ + completeTool(toolUseId: string | undefined, toolName: string | undefined): void { + if (this.phase === 'completed' || this.phase === 'aborted') return + let step: ToolStep | undefined + if (toolUseId) { + step = this.toolSteps.find((s) => s.id === toolUseId) + } + if (!step && toolName) { + for (let i = this.toolSteps.length - 1; i >= 0; i--) { + const s = this.toolSteps[i]! + if (s.name === toolName && s.status === 'running') { + step = s + break + } + } + } + if (!step) return + if (step.status === 'done') return + step.status = 'done' + void this.flushController.throttledUpdate(this.currentThrottle()) + } + + /** 是否已有任何可渲染内容(文本 / 推理 / 工具)。 */ + private hasAnyContent(): boolean { + return ( + this.accumulatedText.length > 0 || + this.accumulatedReasoningText.length > 0 || + this.toolSteps.length > 0 + ) + } + /** * 流式结束,切到最终态。 * - 先 waitForFlush 确保中间帧写入完成 @@ -252,7 +334,7 @@ export class StreamingCard { this.flushController.cancelPendingFlush() await this.flushController.waitForFlush() - const finalText = this.renderedText() + const finalText = this.terminalText() try { if (this.cardId) { // CardKit 路径: settings(false) + card.update(即使中间 stream 曾失败) @@ -348,13 +430,70 @@ export class StreamingCard { return this.cardKitStreamActive ? THROTTLE.CARDKIT_MS : THROTTLE.PATCH_MS } - /** 把 accumulatedText 经 sanitize + optimize 管道出来。 */ + /** 组合 reasoning + toolSteps + answerText,经 sanitize + optimize 管道出来。 + * + * 顺序为 tools → reasoning → answer,以分隔符隔开: + * - tools 永远在最顶部,方便用户先看到 "现在在跑什么" + * - reasoning 居中(thinking 文本) + * - answer 在底部 + * + * 整张卡片只用最朴素的 markdown:plain text + emoji + bold + line break。 + * **不使用** blockquote / list / heading —— 这些会被 + * optimizeMarkdownForFeishu 触发额外的
注入和 H 降级,并且历史上 + * 曾导致飞书 CardKit 校验报错("long wait → 一次性 dump" 退化的根因)。 + * 任意 section 为空则忽略;全部为空时返回等待提示。 */ private renderedText(): string { + const sections: string[] = [] + + if (this.toolSteps.length > 0) { + // 单行 inline 形式: ⚙️ Bash · ✅ Read · ⚙️ Glob ... + // 用中点分隔,比 markdown list 更不容易触发 Feishu 排版异常 + const inline = this.toolSteps + .map((s) => `${s.status === 'done' ? '✅' : '⚙️'} ${s.name}`) + .join(' · ') + sections.push(`🛠️ ${inline}`) + } + + if (this.accumulatedReasoningText) { + const preview = truncateReasoningPreview( + this.accumulatedReasoningText, + REASONING_PREVIEW_CHARS, + ) + // openclaw 风格: 一行 header + 空行 + 原文。不引用 / 不缩进,让飞书 + // markdown 元素按普通段落渲染。 + sections.push(`💭 **思考中**\n\n${preview}`) + } + + if (this.accumulatedText) { + sections.push(this.accumulatedText) + } + + if (sections.length === 0) return '☁️ *正在思考中...*' + + // 用一行分隔符把 sections 分开,比单纯空行更稳定 + const composed = sections.join('\n\n---\n\n') + // 表格数限制在 optimize 之前做 —— sanitize 对原始 markdown 最准 - const limited = sanitizeTextForCard(this.accumulatedText) + const limited = sanitizeTextForCard(composed) return optimizeMarkdownForFeishu(limited, 2) } + /** 终态文本: 只渲染最终答复正文,丢弃 reasoning 和 toolSteps。 + * + * 推理过程和工具调用是"过程态"信息,已经在流式中展示给用户看过; + * message_complete 之后用户应该看到一张干净的答复卡(与 Desktop UI 对齐)。 + * 这个方法专供 finalize 调用,不要在中间帧用。 + * + * 边界情况: 如果完全没有 accumulatedText(比如纯 thinking 没产出答案 + * 这种异常 case),退回到 renderedText() 至少把推理留下来当兜底。 */ + private terminalText(): string { + if (this.accumulatedText) { + const limited = sanitizeTextForCard(this.accumulatedText) + return optimizeMarkdownForFeishu(limited, 2) + } + return this.renderedText() + } + /** FlushController 调用的 doFlush。 */ private async performFlush(): Promise { if (this.phase !== 'streaming') return @@ -379,6 +518,7 @@ export class StreamingCard { this.sequence, ) this.lastFlushedText = finalText + this.consecutiveStreamFailures = 0 } catch (err) { if (isCardRateLimitError(err)) { // 跳帧 —— 下次 throttledUpdate 会重试 @@ -392,12 +532,26 @@ export class StreamingCard { this.cardKitStreamActive = false return } - // 其他错误 —— 禁用流式,最坏情况等 finalize 兜底 - console.error( - '[Feishu StreamingCard] stream flush failed:', - err instanceof Error ? err.message : err, - ) - this.cardKitStreamActive = false + // 其他错误 —— 跳帧重试,避免单次失败把整张卡冻在最初状态。 + // 只有连续失败超过阈值才认定 CardKit 不可用并降级 —— 否则 + // 用户会看到 "long wait → 完事后一次性把所有内容刷出来" 的体验 + // 退化(这是 streamCardContent 一旦报错就 disable 流式造成的)。 + this.consecutiveStreamFailures += 1 + const errMsg = err instanceof Error ? err.message : String(err) + if (this.consecutiveStreamFailures === 1) { + // 首帧失败先记录一次,避免日志风暴 + console.warn( + '[Feishu StreamingCard] stream flush failed (will retry):', + errMsg, + ) + } + if (this.consecutiveStreamFailures >= STREAM_FAIL_DISABLE_THRESHOLD) { + console.error( + `[Feishu StreamingCard] stream flush failed ${this.consecutiveStreamFailures}× consecutively, disabling CardKit streaming until finalize:`, + errMsg, + ) + this.cardKitStreamActive = false + } return } } else { @@ -452,6 +606,16 @@ export class StreamingCard { return this.accumulatedText } + /** @internal */ + _getAccumulatedReasoning(): string { + return this.accumulatedReasoningText + } + + /** @internal */ + _getToolSteps(): ReadonlyArray { + return this.toolSteps + } + /** @internal */ _getFlushController(): FlushController { return this.flushController