mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
207da0ad9b
commit
e338b2f7a3
@ -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('🛠️')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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.)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user