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

View File

@ -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 ensureCreatedno-op
// (no direct call here — 等同于 no-op)
// 3. 服务端: thinking deltas5 个增量,间隔 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('🛠️')
})
})

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.)

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+updatecardId */
/** CardKit 230099 N false
* finalize settings+updatecardId */
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
*
* markdownplain 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