fix(feishu): stream reasoning + tool steps live, drop them on finalize

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) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes)
2026-04-11 22:37:39 +08:00
parent 207da0ad9b
commit e338b2f7a3
3 changed files with 602 additions and 17 deletions
@@ -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<void>((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('🛠️')
})
})
+24 -4
View File
@@ -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.)
+177 -13
View File
@@ -110,6 +110,15 @@ export function buildErrorCard(message: string): Record<string, unknown> {
}
}
/** 从末尾截取最多 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 触发额外的 <br> 注入和 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<void> {
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<ToolStep> {
return this.toolSteps
}
/** @internal */
_getFlushController(): FlushController {
return this.flushController